diff --git a/pyproject.toml b/pyproject.toml index 3b433d1..af26d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-files" -version = "0.1.2" +version = "0.1.3" description = "GovOPlaN files module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.2", + "govoplan-core>=0.1.3", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index cc416eb..c054aaf 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -66,7 +66,7 @@ def _files_router(context: ModuleContext): manifest = ModuleManifest( id="files", name="Files", - version="1.0.0", + version="0.1.2", dependencies=("access",), optional_dependencies=(), permissions=PERMISSIONS, @@ -91,4 +91,3 @@ manifest = ModuleManifest( def get_manifest() -> ModuleManifest: return manifest - diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py index 2a5eda1..7079d7e 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -16,6 +16,8 @@ from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_sco from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_files.backend.schemas import ( ArchiveRequest, + BulkFileShareRequest, + BulkFileShareResponse, BulkDeleteRequest, BulkDeleteResponse, ConflictResolutionRequest, @@ -57,6 +59,7 @@ from govoplan_files.backend.storage.files import ( list_assets_for_user, read_asset_bytes, share_file, + share_files, soft_delete_assets, ) from govoplan_files.backend.storage.folders import create_folder, list_folders_for_user, soft_delete_folder @@ -547,6 +550,48 @@ def create_share( raise _http_error(exc) from exc +@router.post("/bulk-shares", response_model=BulkFileShareResponse) +def create_bulk_shares( + payload: BulkFileShareRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:share")), +): + try: + file_ids = list(dict.fromkeys(payload.file_ids)) + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + for file_id in file_ids + ] + shares = share_files( + session, + tenant_id=principal.tenant_id, + assets=assets, + target_type=payload.target_type, + target_id=payload.target_id, + permission=payload.permission, + user_id=principal.user.id, + ) + session.commit() + return BulkFileShareResponse( + shared_count=len(shares), + shares=[ + FileShareResponse( + id=share.id, + target_type=share.target_type, + target_id=share.target_id, + permission=share.permission, + created_at=share.created_at.isoformat(), + revoked_at=share.revoked_at.isoformat() if share.revoked_at else None, + ) + for share in shares + ], + ) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc) from exc + + + @router.post("/bulk-rename", response_model=RenameResponse) def bulk_rename( payload: RenameRequest, diff --git a/src/govoplan_files/backend/schemas.py b/src/govoplan_files/backend/schemas.py index cb532e4..c373e41 100644 --- a/src/govoplan_files/backend/schemas.py +++ b/src/govoplan_files/backend/schemas.py @@ -113,6 +113,18 @@ class FileShareRequest(BaseModel): permission: Literal["read", "write", "manage"] = "read" +class BulkFileShareRequest(BaseModel): + file_ids: list[str] = Field(default_factory=list, max_length=1000) + target_type: Literal["user", "group", "campaign", "tenant"] + target_id: str + permission: Literal["read", "write", "manage"] = "read" + + +class BulkFileShareResponse(BaseModel): + shares: list[FileShareResponse] + shared_count: int + + class RenameRequest(BaseModel): file_ids: list[str] = Field(default_factory=list) folder_paths: list[str] = Field(default_factory=list) diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index 90a11b9..ba7e807 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -336,6 +336,79 @@ def share_file( return share +def share_files( + session: Session, + *, + tenant_id: str, + assets: Iterable[FileAsset], + target_type: str, + target_id: str, + permission: str, + user_id: str, +) -> list[FileShare]: + target_type = target_type.lower().strip() + permission = permission.lower().strip() + if target_type not in {"user", "group", "campaign", "tenant"}: + raise FileStorageError("Unsupported share target") + if permission not in {"read", "write", "manage"}: + raise FileStorageError("Unsupported file permission") + if target_type == "user": + target_user = session.get(User, target_id) + if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active: + raise FileStorageError("User not found") + if target_type == "group": + group = session.get(Group, target_id) + if not group or group.tenant_id != tenant_id or not group.is_active: + raise FileStorageError("Group not found") + if target_type == "tenant": + tenant = session.get(Tenant, target_id) + if target_id != tenant_id or not tenant or not tenant.is_active: + raise FileStorageError("Tenant not found") + if target_type == "campaign": + Campaign = _campaign_model() + campaign = session.get(Campaign, target_id) + if not campaign or campaign.tenant_id != tenant_id: + raise FileStorageError("Campaign not found") + + asset_list = list(assets) + if not asset_list: + return [] + for asset in asset_list: + if asset.tenant_id != tenant_id: + raise FileStorageError("File not found") + + existing_by_asset: dict[str, FileShare] = {} + for share in session.query(FileShare).filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id.in_([asset.id for asset in asset_list]), + FileShare.target_type == target_type, + FileShare.target_id == target_id, + FileShare.revoked_at.is_(None), + ).all(): + existing_by_asset.setdefault(share.file_asset_id, share) + + shares: list[FileShare] = [] + for asset in asset_list: + existing = existing_by_asset.get(asset.id) + if existing: + existing.permission = permission + session.add(existing) + shares.append(existing) + continue + share = FileShare( + tenant_id=tenant_id, + file_asset_id=asset.id, + target_type=target_type, + target_id=target_id, + permission=permission, + created_by_user_id=user_id, + ) + session.add(share) + shares.append(share) + return shares + + + def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int: count = 0 now = utcnow() diff --git a/webui/package.json b/webui/package.json index 5be767e..5477d66 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "main": "src/index.ts", @@ -21,7 +21,7 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", "lucide-react": "^0.555.0", - "@govoplan/core-webui": "^0.1.1" + "@govoplan/core-webui": "^0.1.2" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index f856f81..d671caa 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -1,4 +1,4 @@ -import { apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; +import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; export type FileSpace = { id: string; @@ -40,6 +40,7 @@ export type ManagedFile = { export type FileListResponse = { files: ManagedFile[] }; export type FileSpacesResponse = { spaces: FileSpace[] }; export type FileUploadResponse = { files: ManagedFile[] }; +export type FileUploadProgress = { loaded: number; total?: number; percentage: number | null }; export type FileFolder = { id: string; tenant_id: string; @@ -102,7 +103,16 @@ export function listFiles(settings: ApiSettings, params: { owner_type?: string; export async function uploadFiles( settings: ApiSettings, files: File[], - options: { owner_type: "user" | "group"; owner_id: string; path?: string; campaign_id?: string; unpack_zip?: boolean; conflict_strategy?: ConflictStrategy; conflict_resolutions?: ConflictResolution[] } + options: { + owner_type: "user" | "group"; + owner_id: string; + path?: string; + campaign_id?: string; + unpack_zip?: boolean; + conflict_strategy?: ConflictStrategy; + conflict_resolutions?: ConflictResolution[]; + onProgress?: (progress: FileUploadProgress) => void; + } ): Promise { const form = new FormData(); files.forEach((file) => form.append("files", file)); @@ -113,9 +123,54 @@ export async function uploadFiles( if (options.unpack_zip) form.append("unpack_zip", "true"); if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy); if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions)); + if (options.onProgress) return uploadFilesWithProgress(settings, form, options.onProgress); return apiFetch(settings, "/api/v1/files/upload", { method: "POST", body: form }); } +function uploadFilesWithProgress( + settings: ApiSettings, + form: FormData, + onProgress: (progress: FileUploadProgress) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", apiUrl(settings, "/api/v1/files/upload")); + xhr.withCredentials = true; + for (const [key, value] of authHeaders(settings)) xhr.setRequestHeader(key, value); + const csrf = csrfToken(); + if (csrf) xhr.setRequestHeader("X-CSRF-Token", csrf); + + xhr.upload.onprogress = (event) => { + const total = event.lengthComputable ? event.total : undefined; + onProgress({ + loaded: event.loaded, + total, + percentage: total && total > 0 ? Math.round((event.loaded / total) * 100) : null + }); + }; + + xhr.onerror = () => reject(new Error("Upload failed because the network request could not be completed.")); + xhr.onabort = () => reject(new Error("Upload was cancelled.")); + xhr.onload = () => { + const responseText = xhr.responseText || ""; + if (xhr.status < 200 || xhr.status >= 300) { + reject(new ApiError(xhr.status, xhr.statusText, responseText)); + return; + } + onProgress({ loaded: 1, total: 1, percentage: 100 }); + if (xhr.status === 204 || !responseText) { + resolve({ files: [] }); + return; + } + const contentType = xhr.getResponseHeader("content-type") || ""; + resolve(contentType.includes("application/json") ? JSON.parse(responseText) as FileUploadResponse : { files: [] }); + }; + + onProgress({ loaded: 0, total: undefined, percentage: 0 }); + xhr.send(form); + }); +} + export function deleteFile(settings: ApiSettings, fileId: string): Promise { return apiFetch(settings, `/api/v1/files/${fileId}`, { method: "DELETE" }); } @@ -124,13 +179,22 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi return apiFetch(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) }); } -export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { - return apiFetch(settings, `/api/v1/files/${fileId}/shares`, { +export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number }; + +export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise { + return apiFetch(settings, "/api/v1/files/bulk-shares", { method: "POST", - body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" }) + body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" }) }); } +export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { + const response = await shareFilesWithCampaign(settings, [fileId], campaignId); + const share = response.shares[0]; + if (!share) throw new Error("File share was not created."); + return share; +} + export function bulkRenameFiles( settings: ApiSettings, payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean } diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index 1001ef7..d1109d9 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; -import { ChevronRight, Copy, Download, File, Folder, Home, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; +import { useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; import { Button, ConfirmDialog, DismissibleAlert, FieldLabel, + FileDropZone, FormField, LoadingIndicator, ToggleSwitch, @@ -75,6 +76,8 @@ import { useFileTreeState } from "./hooks/useFileTreeState"; import { useFileDialogs } from "./hooks/useFileDialogs"; import { useFileDragDropState } from "./hooks/useFileDragDropState"; +type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; + export default function FilesPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { const canDownload = hasScope(auth, "files:download"); const canUpload = hasScope(auth, "files:upload"); @@ -98,9 +101,12 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a const [unmatchedCount, setUnmatchedCount] = useState(null); const [unpackZip, setUnpackZip] = useState(false); const [busy, setBusy] = useState(false); + const [uploadActive, setUploadActive] = useState(false); + const [uploadPhase, setUploadPhase] = useState("idle"); + const [uploadProgress, setUploadProgress] = useState(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); - const { dragActive, setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); + const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); const { dialog, setDialog, @@ -130,8 +136,6 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a const [renameSuffix, setRenameSuffix] = useState(""); const [renameRecursive, setRenameRecursive] = useState(false); const [renamePreview, setRenamePreview] = useState(null); - const fileInputRef = useRef(null); - const visibleFiles = searchActive && searchResults ? searchResults : files; const explorerEntries = useMemo(() => { const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); @@ -275,6 +279,9 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a function closeDialog() { closeRawDialog(); setNewFolderError(""); + setUploadActive(false); + setUploadPhase("idle"); + setUploadProgress(null); } function updateActiveDialogFolder(spaceId: string, folderPath: string) { @@ -309,7 +316,11 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a return; } } + const uploadsZipArchive = unpackZip && selected.some((file) => file.name.toLowerCase().endsWith(".zip")); setBusy(true); + setUploadActive(true); + setUploadPhase("uploading"); + setUploadProgress(0); setError(""); setMessage(`Uploading ${selected.length} file(s)…`); try { @@ -319,8 +330,17 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a path: target.folderPath, unpack_zip: unpackZip, conflict_strategy: options.conflictStrategy ?? "reject", - conflict_resolutions: options.conflictResolutions + conflict_resolutions: options.conflictResolutions, + onProgress: ({ percentage }) => { + setUploadProgress(percentage); + if (percentage !== null && percentage >= 100) { + setUploadPhase(uploadsZipArchive ? "unpacking" : "finalizing"); + setMessage(uploadsZipArchive ? "Unpacking ZIP upload…" : "Finalizing upload…"); + } + } }); + setUploadPhase("finalizing"); + setUploadProgress(100); setMessage(`Uploaded ${response.files.length} file(s) into ${target.folderPath || "Root"}.`); closeDialog(); setConflictDialog(null); @@ -331,7 +351,9 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a setMessage(""); } finally { setBusy(false); - if (fileInputRef.current) fileInputRef.current.value = ""; + setUploadActive(false); + setUploadPhase("idle"); + setUploadProgress(null); } } @@ -386,25 +408,29 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a } } - async function runPatternSearch() { - if (!activeSpace || !searchPattern.trim()) { + async function runPatternSearch(patternOverride = searchPattern, caseSensitiveOverride = searchCaseSensitive) { + const trimmedPattern = patternOverride.trim(); + if (!activeSpace || !trimmedPattern) { await clearSearch(); return; } + const caseSensitive = caseSensitiveOverride; setBusy(true); setError(""); setMessage(""); try { const response = await resolveFilePatterns(settings, { - patterns: [searchPattern.trim()], + patterns: [trimmedPattern], owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id, path_prefix: currentFolder, include_unmatched: true, - case_sensitive: searchCaseSensitive + case_sensitive: caseSensitive }); setSearchResults(response.patterns.flatMap((pattern) => pattern.matches)); setSearchActive(true); + setSearchPattern(trimmedPattern); + setSearchCaseSensitive(caseSensitive); setUnmatchedCount(response.unmatched.length); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); @@ -1127,6 +1153,14 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a ); + const uploadBusyLabel = uploadPhase === "unpacking" ? "Unpacking ZIP archive" : "Uploading files"; + const uploadProgressLabel = uploadPhase === "unpacking" + ? "Extracting files on the server…" + : uploadProgress !== null && uploadProgress >= 100 + ? "Finalizing upload…" + : undefined; + const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress; + return (
{error && ( @@ -1199,13 +1233,15 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a ))} -
-
+ void runPatternSearch(patternValue, caseSensitiveValue)} + onClear={() => void clearSearch()} + />
{selectedSummary} {explorerEntries.filter((entry) => entry.kind === "folder").length} folder(s) · {explorerEntries.filter((entry) => entry.kind === "file").length} file(s) @@ -1313,7 +1349,16 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
{formatBytes(entry.file.size_bytes)} @@ -1378,34 +1423,15 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a {dialog === "upload" && ( -
{ - if (!busy && activeDialogSpace && canUpload) fileInputRef.current?.click(); - }} - onKeyDown={(event) => { - if ((event.key === "Enter" || event.key === " ") && !busy && activeDialogSpace && canUpload) { - event.preventDefault(); - fileInputRef.current?.click(); - } - }} - onDragOver={(event) => { event.preventDefault(); setDragActive(true); }} - onDragLeave={() => setDragActive(false)} - onDrop={(event) => { - event.preventDefault(); - setDragActive(false); - void handleFilesUpload(event.dataTransfer.files); - }} - > -
+ handleFilesUpload(files)} + />
Destination folder @@ -1417,10 +1443,8 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
- event.target.files && void handleFilesUpload(event.target.files)} />
-
)} @@ -1527,3 +1551,72 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a ); } +function FileSearchRow({ + value, + caseSensitive, + busy, + searchActive, + disabled, + onSearch, + onClear +}: { + value: string; + caseSensitive: boolean; + busy: boolean; + searchActive: boolean; + disabled: boolean; + onSearch: (value: string, caseSensitive: boolean) => void; + onClear: () => void; +}) { + const [draft, setDraft] = useState(value); + const [caseSensitiveDraft, setCaseSensitiveDraft] = useState(caseSensitive); + + useEffect(() => { + setDraft(value); + }, [value]); + + useEffect(() => { + setCaseSensitiveDraft(caseSensitive); + }, [caseSensitive]); + + function clear() { + setDraft(""); + onClear(); + } + + return ( +
+
+ ); +} + +function activeCampaignShares(file: ManagedFile) { + return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at); +} + +function activeCampaignShareCount(file: ManagedFile): number { + return activeCampaignShares(file).length; +} + +function campaignShareLabel(file: ManagedFile): string { + const count = activeCampaignShareCount(file); + if (count === 0) return "No campaign links"; + if (count === 1) return "Linked to 1 campaign"; + return `Linked to ${count} campaigns`; +} + +function campaignShareTitle(file: ManagedFile): string | undefined { + const shares = activeCampaignShares(file); + if (shares.length === 0) return undefined; + return shares.map((share) => share.target_id).join(", "); +} diff --git a/webui/src/styles/file-manager.css b/webui/src/styles/file-manager.css index 917b29a..06f70f5 100644 --- a/webui/src/styles/file-manager.css +++ b/webui/src/styles/file-manager.css @@ -356,6 +356,16 @@ font-size: 12px; } +.file-linkage-status { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.file-linkage-status.is-linked { + color: var(--text-strong); +} + .file-row-icon { color: var(--muted); flex: 0 0 auto; @@ -473,37 +483,6 @@ padding: 18px; } -.file-upload-drop-zone { - display: grid; - place-items: center; - gap: 8px; - min-height: 170px; - border: 1px dashed var(--line-dark); - border-radius: 14px; - background: var(--panel-soft); - color: var(--muted); - text-align: center; - cursor: pointer; - transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; -} - -.file-upload-drop-zone:hover, -.file-upload-drop-zone:focus-visible, -.file-upload-drop-zone.is-active { - border-color: #0d6efd; - background: rgba(13, 110, 253, .08); -} - -.file-upload-drop-zone:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(13, 110, 253, .18); -} - -.file-upload-drop-zone[aria-disabled="true"] { - cursor: not-allowed; - opacity: .65; -} - .field-error { color: var(--danger-text); font-weight: 700;