3617 lines
173 KiB
TypeScript
3617 lines
173 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
|
import { Archive, ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Settings2, Share2, Trash2, UploadCloud } from "lucide-react";
|
|
import { FormGrid, ActionToolbar,
|
|
Button,
|
|
ConfirmDialog,
|
|
ContentGrid,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
FieldLabel,
|
|
FileDropZone,
|
|
FormField,
|
|
FormSection,
|
|
LoadingFrame,
|
|
LoadingIndicator,
|
|
PasswordField,
|
|
ResourceAccessExplanation,
|
|
ToggleSwitch,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
hasScope,
|
|
usePlatformLanguage,
|
|
type ApiSettings,
|
|
type AuthInfo, i18nMessage } from
|
|
"@govoplan/core-webui";
|
|
import { useLocation, useNavigate } from "react-router";
|
|
import {
|
|
bulkDeleteFiles,
|
|
bulkRenameFiles,
|
|
browseFileConnectorProfile,
|
|
createFileConnectorSpace,
|
|
createFolder,
|
|
confirmArchiveUpload,
|
|
confirmManagedArchive,
|
|
deleteFolder,
|
|
deleteFileConnectorSpace,
|
|
downloadFile,
|
|
downloadFilesAsZip,
|
|
fetchResourceAccessExplanation,
|
|
fetchResourceAccessExplanationSubjects,
|
|
listFilesDelta,
|
|
listFilesByProperties,
|
|
listFileConnectorProfiles,
|
|
listFileSpaces,
|
|
listManagedFileSnapshot,
|
|
previewArchiveUpload,
|
|
previewManagedArchive,
|
|
releaseArchivePreview,
|
|
resolveFilePatterns,
|
|
syncFileConnectorSpaceFolder,
|
|
syncFileConnectorFile,
|
|
transferFiles,
|
|
uploadFiles,
|
|
virtualFolderResourceId,
|
|
type ArchivePreviewEntry,
|
|
type ArchivePreviewResponse,
|
|
type ArchiveOperationProgress,
|
|
type ConflictResolution,
|
|
type ConflictStrategy,
|
|
type FileConnectorBrowseItem,
|
|
type FileConnectorFolderSyncResponse,
|
|
type FileConnectorProfile,
|
|
type FileDeltaResponse,
|
|
type FileCampaignUsageFilter,
|
|
type FileFolder,
|
|
type FileSpace,
|
|
type ManagedFile,
|
|
type RenameResponse,
|
|
type ResourceAccessExplanationResponse,
|
|
type ResourceAccessExplanationSubjectsResponse } from
|
|
"../../api/files";
|
|
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
|
|
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
|
|
import FileShareDialog from "./components/FileShareDialog";
|
|
import type {
|
|
ContextMenuState,
|
|
DialogKind,
|
|
EntrySelectionKey,
|
|
ExplorerEntry,
|
|
FolderEntry,
|
|
FileActionTarget,
|
|
FileConflictItem,
|
|
RenameMode,
|
|
SelectionSets,
|
|
SortColumn,
|
|
SortDirection,
|
|
TransferMode } from
|
|
"./types";
|
|
import {
|
|
buildExplorerEntries,
|
|
buildFolderEntryFromSources,
|
|
buildFolderTree,
|
|
candidateRenamedPath,
|
|
entrySelectionKey,
|
|
fileIdsForSelection,
|
|
folderAncestorPaths,
|
|
folderBreadcrumbs,
|
|
folderContentLabel,
|
|
formatBytes,
|
|
formatDate,
|
|
isFileInFolder,
|
|
joinFolder,
|
|
lastPathSegment,
|
|
normalizeFilePath,
|
|
normalizeFolder,
|
|
parseDragState,
|
|
parentFolderPath,
|
|
sortExplorerEntries,
|
|
treeNodeKey } from
|
|
"./utils/fileManagerUtils";
|
|
import { useFileSelection } from "./hooks/useFileSelection";
|
|
import { useFileTreeState } from "./hooks/useFileTreeState";
|
|
import { useFileDialogs } from "./hooks/useFileDialogs";
|
|
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
|
|
|
type UploadPhase = "idle" | "uploading" | "inspecting" | "unpacking" | "finalizing";
|
|
type AuditRelevantFilter = "" | "true" | "false";
|
|
type FileAccessExplanationTarget = {
|
|
resourceType: "file" | "folder";
|
|
resourceId: string;
|
|
label: string;
|
|
action: string;
|
|
};
|
|
|
|
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
|
|
const FILE_LIST_OVERSCAN_ROWS = 8;
|
|
const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
|
|
const FILES_WORKFLOW_DOCUMENTATION = {
|
|
topicId: "files.workflow.organize-managed-files",
|
|
documentationType: "user"
|
|
} as const;
|
|
|
|
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { translateText } = usePlatformLanguage();
|
|
const canDownload = hasScope(auth, "files:download");
|
|
const canUpload = hasScope(auth, "files:upload");
|
|
const canOrganize = hasScope(auth, "files:organize");
|
|
const canDelete = hasScope(auth, "files:delete");
|
|
const canShare = hasScope(auth, "files:file:share");
|
|
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
|
|
const [spacesLoaded, setSpacesLoaded] = useState(false);
|
|
const [activeSpaceId, setActiveSpaceId] = useState("");
|
|
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
|
|
const activeSpaceIsConnector = activeSpace?.space_type === "connector";
|
|
const [filesBySpace, setFilesBySpace] = useState<Record<string, ManagedFile[]>>({});
|
|
const [foldersBySpace, setFoldersBySpace] = useState<Record<string, FileFolder[]>>({});
|
|
const [fileDeltaWatermarksBySpace, setFileDeltaWatermarksBySpace] = useState<Record<string, string>>({});
|
|
const files = activeSpace ? filesBySpace[activeSpace.id] ?? EMPTY_FILES : EMPTY_FILES;
|
|
const folders = activeSpace ? foldersBySpace[activeSpace.id] ?? EMPTY_FOLDERS : EMPTY_FOLDERS;
|
|
|
|
const [currentFolder, setCurrentFolder] = useState("");
|
|
const [searchPattern, setSearchPattern] = useState("");
|
|
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
|
const [searchActive, setSearchActive] = useState(false);
|
|
const [searchResults, setSearchResults] = useState<ManagedFile[] | null>(null);
|
|
const [campaignUsageFilter, setCampaignUsageFilter] = useState<"" | FileCampaignUsageFilter>("");
|
|
const [auditRelevantFilter, setAuditRelevantFilter] = useState<AuditRelevantFilter>("");
|
|
const [propertyFiltersActive, setPropertyFiltersActive] = useState(false);
|
|
const [propertyFilterResults, setPropertyFilterResults] = useState<ManagedFile[] | null>(null);
|
|
const [propertyFilterTotal, setPropertyFilterTotal] = useState<number | null>(null);
|
|
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
|
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
|
const [unpackZip, setUnpackZip] = useState(false);
|
|
const [archiveFile, setArchiveFile] = useState<File | null>(null);
|
|
const [managedArchiveFile, setManagedArchiveFile] = useState<ManagedFile | null>(null);
|
|
const [archivePreview, setArchivePreview] = useState<ArchivePreviewResponse | null>(null);
|
|
const [archivePassword, setArchivePassword] = useState("");
|
|
const [selectedArchivePaths, setSelectedArchivePaths] = useState<Set<string>>(new Set());
|
|
const [operationBusy, setBusy] = useState(false);
|
|
const [reloadingView, setReloadingView] = useState(false);
|
|
const busy = operationBusy || reloadingView;
|
|
const [viewReloadFailed, setViewReloadFailed] = useState(false);
|
|
const reloadSequenceRef = useRef(0);
|
|
const reloadContextRef = useRef("");
|
|
reloadContextRef.current = JSON.stringify([
|
|
settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant?.id, auth.user.id,
|
|
activeSpaceId, currentFolder, searchActive, searchPattern, searchCaseSensitive,
|
|
propertyFiltersActive, campaignUsageFilter, auditRelevantFilter
|
|
]);
|
|
const [toolsPanel, setToolsPanel] = useState<"connections" | "selection" | null>(null);
|
|
const [uploadActive, setUploadActive] = useState(false);
|
|
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
|
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
|
const [archiveProgress, setArchiveProgress] = useState<ArchiveOperationProgress | null>(null);
|
|
const [connectorProfiles, setConnectorProfiles] = useState<FileConnectorProfile[]>([]);
|
|
const [connectorProfileId, setConnectorProfileId] = useState("");
|
|
const [connectorLibraryId, setConnectorLibraryId] = useState<string | null>(null);
|
|
const [connectorPath, setConnectorPath] = useState("");
|
|
const [connectorItems, setConnectorItems] = useState<FileConnectorBrowseItem[]>([]);
|
|
const [connectorSelectedItem, setConnectorSelectedItem] = useState<FileConnectorBrowseItem | null>(null);
|
|
const [connectorLoading, setConnectorLoading] = useState(false);
|
|
const [connectorError, setConnectorError] = useState("");
|
|
const [connectorSpaceLabel, setConnectorSpaceLabel] = useState("");
|
|
const [connectorSpaceReadOnly, setConnectorSpaceReadOnly] = useState(true);
|
|
const [connectorSpaceOwnerSpaceId, setConnectorSpaceOwnerSpaceId] = useState("");
|
|
const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState<Record<string, FileConnectorBrowseItem[]>>({});
|
|
const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState<Record<string, string | null>>({});
|
|
const [connectorSpaceSelectedItem, setConnectorSpaceSelectedItem] = useState<FileConnectorBrowseItem | null>(null);
|
|
const [connectorSpaceRemovalTarget, setConnectorSpaceRemovalTarget] = useState<FileSpace | null>(null);
|
|
const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false);
|
|
const [connectorSpaceError, setConnectorSpaceError] = useState("");
|
|
const [folderSyncRecursive, setFolderSyncRecursive] = useState(true);
|
|
const [folderSyncConflictStrategy, setFolderSyncConflictStrategy] = useState<"skip" | "rename" | "reject" | "overwrite">("skip");
|
|
const [folderSyncMaxFiles, setFolderSyncMaxFiles] = useState(100);
|
|
const [folderSyncResult, setFolderSyncResult] = useState<FileConnectorFolderSyncResponse | null>(null);
|
|
const [message, setMessage] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
|
|
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
|
const [resourceAccessSubjects, setResourceAccessSubjects] = useState<ResourceAccessExplanationSubjectsResponse | null>(null);
|
|
const [resourceAccessSubjectId, setResourceAccessSubjectId] = useState("");
|
|
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
|
|
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
|
|
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
|
|
const {
|
|
dialog,
|
|
setDialog,
|
|
dialogTarget,
|
|
setDialogTarget,
|
|
transferMode,
|
|
setTransferMode,
|
|
transferDialogState,
|
|
setTransferDialogState,
|
|
contextMenu,
|
|
setContextMenu,
|
|
conflictDialog,
|
|
setConflictDialog,
|
|
deleteDialog,
|
|
setDeleteDialog,
|
|
openDialog: openRawDialog,
|
|
closeDialog: closeRawDialog
|
|
} = useFileDialogs();
|
|
const [newFolderName, setNewFolderName] = useState("");
|
|
const [newFolderError, setNewFolderError] = useState("");
|
|
const [renamePreviewVisibleCount, setRenamePreviewVisibleCount] = useState(20);
|
|
const [singleRenameName, setSingleRenameName] = useState("");
|
|
const [renameMode, setRenameMode] = useState<RenameMode>("prefix");
|
|
const [renameFind, setRenameFind] = useState("");
|
|
const [renameReplacement, setRenameReplacement] = useState("");
|
|
const [renamePrefix, setRenamePrefix] = useState("");
|
|
const [renameSuffix, setRenameSuffix] = useState("");
|
|
const [renameRecursive, setRenameRecursive] = useState(false);
|
|
const [renamePreview, setRenamePreview] = useState<RenameResponse | null>(null);
|
|
const visibleFiles = useMemo(() => {
|
|
if (searchActive && searchResults && propertyFiltersActive && propertyFilterResults) {
|
|
const ids = new Set(propertyFilterResults.map((file) => file.id));
|
|
return searchResults.filter((file) => ids.has(file.id));
|
|
}
|
|
if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults;
|
|
if (searchActive && searchResults) return searchResults;
|
|
return files;
|
|
}, [files, propertyFilterResults, propertyFiltersActive, searchActive, searchResults]);
|
|
const explorerEntries = useMemo(() => {
|
|
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive || propertyFiltersActive);
|
|
return sortExplorerEntries(entries, sortColumn, sortDirection);
|
|
}, [visibleFiles, folders, currentFolder, searchActive, propertyFiltersActive, sortColumn, sortDirection]);
|
|
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
|
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
|
const [fileListViewportHeight, setFileListViewportHeight] = useState(0);
|
|
const [fileListScrollTop, setFileListScrollTop] = useState(0);
|
|
const [fileListRowHeight, setFileListRowHeight] = useState(DEFAULT_FILE_LIST_ROW_HEIGHT);
|
|
const virtualExplorerRows = useMemo(() => {
|
|
const rowHeight = Math.max(1, fileListRowHeight);
|
|
const entryScrollTop = Math.max(0, fileListScrollTop - rowHeight);
|
|
const viewportHeight = Math.max(fileListViewportHeight, rowHeight * FILE_LIST_OVERSCAN_ROWS);
|
|
const startIndex = Math.max(0, Math.floor(entryScrollTop / rowHeight) - FILE_LIST_OVERSCAN_ROWS);
|
|
const endIndex = Math.min(
|
|
explorerEntries.length,
|
|
Math.ceil((entryScrollTop + viewportHeight) / rowHeight) + FILE_LIST_OVERSCAN_ROWS
|
|
);
|
|
|
|
return {
|
|
startIndex,
|
|
topSpacerHeight: startIndex * rowHeight,
|
|
bottomSpacerHeight: Math.max(0, (explorerEntries.length - endIndex) * rowHeight),
|
|
entries: explorerEntries.slice(startIndex, endIndex)
|
|
};
|
|
}, [explorerEntries, fileListRowHeight, fileListScrollTop, fileListViewportHeight]);
|
|
const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]);
|
|
const {
|
|
selectedFileIds,
|
|
setSelectedFileIds,
|
|
selectedFolderPaths,
|
|
setSelectedFolderPaths,
|
|
selectedEntryCount,
|
|
hasSelection,
|
|
selectedSummary,
|
|
currentSelectionKeys,
|
|
setSelectionAnchor,
|
|
applySelectionKeys,
|
|
clearSelection,
|
|
currentSelectionSets,
|
|
setsForEntry,
|
|
isEntrySelected,
|
|
preventTextSelectionOnShift,
|
|
handleEntrySelection
|
|
} = useFileSelection(visibleEntryKeys, busy);
|
|
const { expandedTreeNodes, setExpandedTreeNodes, cancelTreeDragExpand, scheduleTreeDragExpand, toggleTreeFolder } = useFileTreeState({
|
|
activeSpaceId,
|
|
currentFolder,
|
|
onOpenFolder: openFolder
|
|
});
|
|
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
|
|
const selectedArchive = selectedFiles.length === 1 && selectedFolderPaths.size === 0 && ARCHIVE_FILENAME_PATTERN.test(selectedFiles[0].filename) ? selectedFiles[0] : null;
|
|
const shareManageableFile = useMemo(() => {
|
|
if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null;
|
|
const file = selectedFiles[0];
|
|
const ownsFile = file.owner_type === "user"
|
|
? file.owner_id === auth.user.id
|
|
: auth.groups.some((group) => group.id === file.owner_id);
|
|
return ownsFile || hasScope(auth, "files:file:admin") ? file : null;
|
|
}, [auth, canShare, selectedFiles, selectedFolderPaths]);
|
|
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
|
|
const accessExplainableTarget = useMemo(
|
|
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
|
|
[activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths]
|
|
);
|
|
const canExplainResourceAccess =
|
|
hasScope(auth, "admin:users:read") ||
|
|
hasScope(auth, "admin:roles:read") ||
|
|
hasScope(auth, "access:membership:read") ||
|
|
hasScope(auth, "access:role:read");
|
|
const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]);
|
|
const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
|
|
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
|
|
const downloadLabel = selectedDownloadFileIds.length > 1 ? "i18n:govoplan-files.download_zip.7bd0e4b0" : "i18n:govoplan-files.download.a479c9c3";
|
|
const activeConnectorProfile = connectorProfiles.find((profile) => profile.id === connectorProfileId) ?? null;
|
|
const activeConnectorSpaceItems = activeSpaceIsConnector && activeSpace ? connectorSpaceItemsBySpace[activeSpace.id] ?? [] : [];
|
|
|
|
useEffect(() => {
|
|
const viewport = fileListViewportRef.current;
|
|
if (!viewport) return undefined;
|
|
|
|
const updateViewportHeight = () => {
|
|
setFileListViewportHeight(viewport.clientHeight);
|
|
setFileListScrollTop(viewport.scrollTop);
|
|
};
|
|
|
|
updateViewportHeight();
|
|
if (typeof ResizeObserver === "undefined") return undefined;
|
|
|
|
const observer = new ResizeObserver(updateViewportHeight);
|
|
observer.observe(viewport);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const row = fileListMeasureRowRef.current;
|
|
if (!row) return undefined;
|
|
|
|
const updateRowHeight = () => {
|
|
const measuredHeight = row.getBoundingClientRect().height;
|
|
if (measuredHeight > 0) setFileListRowHeight(Math.round(measuredHeight));
|
|
};
|
|
|
|
updateRowHeight();
|
|
if (typeof ResizeObserver === "undefined") return undefined;
|
|
|
|
const observer = new ResizeObserver(updateRowHeight);
|
|
observer.observe(row);
|
|
return () => observer.disconnect();
|
|
}, [activeSpaceId, currentFolder]);
|
|
|
|
useEffect(() => {
|
|
const viewport = fileListViewportRef.current;
|
|
if (!viewport) return;
|
|
viewport.scrollTop = 0;
|
|
setFileListScrollTop(0);
|
|
}, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]);
|
|
|
|
async function loadSpaces() {
|
|
setSpacesLoaded(false);
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const response = await listFileSpaces(settings);
|
|
setSpaces(response.spaces);
|
|
const nextActiveSpace = response.spaces.find((space) => space.id === activeSpaceId) ?? response.spaces[0] ?? null;
|
|
setActiveSpaceId(nextActiveSpace?.id || "");
|
|
if (nextActiveSpace && !isConnectorSpace(nextActiveSpace)) await loadSpaceContents(nextActiveSpace, { silent: true });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSpacesLoaded(true);
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function reloadCurrentView() {
|
|
if (busy || reloadingView || connectorSpaceLoading) return;
|
|
const sequence = ++reloadSequenceRef.current;
|
|
const context = reloadContextRef.current;
|
|
const isCurrent = () => sequence === reloadSequenceRef.current && context === reloadContextRef.current;
|
|
const readOptions = { cache: "no-store" } as const;
|
|
setReloadingView(true);
|
|
setViewReloadFailed(false);
|
|
setError("");
|
|
if (activeSpaceIsConnector) setConnectorSpaceError("");
|
|
try {
|
|
if (!activeSpace) {
|
|
const response = await listFileSpaces(settings, readOptions);
|
|
if (!isCurrent()) return;
|
|
setSpaces(response.spaces);
|
|
setSpacesLoaded(true);
|
|
setActiveSpaceId(response.spaces[0]?.id || "");
|
|
return;
|
|
}
|
|
if (activeSpaceIsConnector) {
|
|
if (!activeSpace.connector_profile_id) throw new Error(translateText("i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5"));
|
|
const libraryId = connectorSpaceLibrary(activeSpace);
|
|
const response = await browseFileConnectorProfile(settings, activeSpace.connector_profile_id, {
|
|
path: connectorSpaceBrowsePath(activeSpace, currentFolder), library_id: libraryId || undefined
|
|
}, readOptions);
|
|
if (!isCurrent()) return;
|
|
setConnectorSpaceItemsBySpace((current) => ({ ...current, [activeSpace.id]: response.items }));
|
|
setConnectorSpaceLibraryBySpace((current) => ({ ...current, [activeSpace.id]: response.library_id ?? libraryId ?? null }));
|
|
setConnectorSpaceSelectedItem((current) => current && response.items.find((item) => item.path === current.path && item.kind === current.kind) || null);
|
|
return;
|
|
}
|
|
// Re-read the current projection, including active filters. None of these
|
|
// calls imports, synchronizes, uploads, or changes a managed resource.
|
|
// Apply only after all reads succeed so a failed reload keeps usable data.
|
|
const owner = { owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id };
|
|
const [snapshot, matches, properties] = await Promise.all([
|
|
listManagedFileSnapshot(settings, owner, readOptions),
|
|
searchActive ? resolveFilePatterns(settings, {
|
|
...owner, patterns: [searchPattern], path_prefix: currentFolder,
|
|
include_unmatched: true, case_sensitive: searchCaseSensitive
|
|
}) : Promise.resolve(null),
|
|
propertyFiltersActive ? listFilesByProperties(settings, {
|
|
...owner, path_prefix: currentFolder, campaign_usage: campaignUsageFilter || undefined,
|
|
audit_relevant: auditRelevantFilter ? auditRelevantFilter === "true" : undefined
|
|
}, readOptions) : Promise.resolve(null)
|
|
]);
|
|
if (!isCurrent()) return;
|
|
setFilesBySpace((current) => ({ ...current, [activeSpace.id]: snapshot.files }));
|
|
setFoldersBySpace((current) => ({ ...current, [activeSpace.id]: snapshot.folders }));
|
|
setFileDeltaWatermarksBySpace((current) => ({ ...current, [activeSpace.id]: snapshot.watermark || "" }));
|
|
const fileIds = new Set(snapshot.files.map((file) => file.id));
|
|
const folderPaths = new Set(snapshot.folders.map((folder) => folder.path));
|
|
setSelectedFileIds((current) => new Set(Array.from(current).filter((id) => fileIds.has(id))));
|
|
setSelectedFolderPaths((current) => new Set(Array.from(current).filter((path) =>
|
|
folderPaths.has(path) || snapshot.files.some((file) => file.display_path.startsWith(`${path}/`))
|
|
)));
|
|
if (matches) {
|
|
setSearchResults(matches.patterns.flatMap((pattern) => pattern.matches));
|
|
setUnmatchedCount(matches.unmatched.length);
|
|
}
|
|
if (properties) {
|
|
setPropertyFilterResults(properties.files);
|
|
setPropertyFilterTotal(properties.total);
|
|
}
|
|
} catch (err) {
|
|
if (!isCurrent()) return;
|
|
setViewReloadFailed(true);
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
if (activeSpaceIsConnector) setConnectorSpaceError(detail);
|
|
else setError(detail);
|
|
} finally {
|
|
if (sequence === reloadSequenceRef.current) setReloadingView(false);
|
|
}
|
|
}
|
|
|
|
function applyManagedSpaceDelta(spaceId: string, response: FileDeltaResponse) {
|
|
if (response.full) {
|
|
setFilesBySpace((current) => ({ ...current, [spaceId]: response.files }));
|
|
setFoldersBySpace((current) => ({ ...current, [spaceId]: response.folders }));
|
|
} else {
|
|
const deletedFileIds = new Set(response.deleted.filter((item) => !item.resource_type || item.resource_type === "file").map((item) => item.id));
|
|
const deletedFolderIds = new Set(response.deleted.filter((item) => item.resource_type === "folder").map((item) => item.id));
|
|
setFilesBySpace((current) => {
|
|
const byId = new Map((current[spaceId] ?? EMPTY_FILES).map((file) => [file.id, file]));
|
|
deletedFileIds.forEach((id) => byId.delete(id));
|
|
response.files.forEach((file) => byId.set(file.id, file));
|
|
return { ...current, [spaceId]: Array.from(byId.values()) };
|
|
});
|
|
setFoldersBySpace((current) => {
|
|
const byId = new Map((current[spaceId] ?? EMPTY_FOLDERS).map((folder) => [folder.id, folder]));
|
|
deletedFolderIds.forEach((id) => byId.delete(id));
|
|
response.folders.forEach((folder) => byId.set(folder.id, folder));
|
|
return { ...current, [spaceId]: Array.from(byId.values()) };
|
|
});
|
|
}
|
|
if (response.watermark) {
|
|
setFileDeltaWatermarksBySpace((current) => ({ ...current, [spaceId]: response.watermark || "" }));
|
|
}
|
|
}
|
|
|
|
async function loadSpaceContents(space = activeSpace, options: {silent?: boolean;} = {}) {
|
|
if (!space) return;
|
|
if (isConnectorSpace(space)) {
|
|
await loadConnectorSpaceContents(space, { silent: options.silent, folderPath: "" });
|
|
return;
|
|
}
|
|
if (managedSpaceLoadsInFlightRef.current.has(space.id)) return;
|
|
managedSpaceLoadsInFlightRef.current.add(space.id);
|
|
if (!options.silent) {
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
}
|
|
try {
|
|
let since = fileDeltaWatermarksBySpace[space.id];
|
|
let response: FileDeltaResponse | null = null;
|
|
if (!since) {
|
|
const snapshot = await listManagedFileSnapshot(settings, {
|
|
owner_type: space.owner_type,
|
|
owner_id: space.owner_id
|
|
});
|
|
setFilesBySpace((current) => ({ ...current, [space.id]: snapshot.files }));
|
|
setFoldersBySpace((current) => ({ ...current, [space.id]: snapshot.folders }));
|
|
if (snapshot.watermark) {
|
|
setFileDeltaWatermarksBySpace((current) => ({ ...current, [space.id]: snapshot.watermark || "" }));
|
|
}
|
|
} else {
|
|
do {
|
|
response = await listFilesDelta(settings, {
|
|
owner_type: space.owner_type,
|
|
owner_id: space.owner_id,
|
|
since: since || undefined
|
|
});
|
|
applyManagedSpaceDelta(space.id, response);
|
|
since = response.watermark || "";
|
|
} while (response.has_more && response.watermark);
|
|
}
|
|
setSelectedFileIds(new Set());
|
|
setSelectedFolderPaths(new Set());
|
|
setUnmatchedCount(null);
|
|
setRenamePreview(null);
|
|
if (!options.silent) {
|
|
setSearchActive(false);
|
|
setSearchPattern("");
|
|
setSearchResults(null);
|
|
setCampaignUsageFilter("");
|
|
setAuditRelevantFilter("");
|
|
setPropertyFiltersActive(false);
|
|
setPropertyFilterResults(null);
|
|
setPropertyFilterTotal(null);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
managedSpaceLoadsInFlightRef.current.delete(space.id);
|
|
if (!options.silent) setBusy(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
// A late manual reload must not publish a previous tenant/account/folder's
|
|
// projection, or release a newer operation's busy state.
|
|
++reloadSequenceRef.current;
|
|
setReloadingView(false);
|
|
setViewReloadFailed(false);
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant?.id, auth.user.id, activeSpaceId, currentFolder]);
|
|
|
|
useEffect(() => {
|
|
void loadSpaces();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
useEffect(() => {
|
|
const parameters = new URLSearchParams(location.search);
|
|
if (parameters.get("quickAction") !== "upload" || !spacesLoaded) return;
|
|
|
|
const uploadSpace =
|
|
activeSpace && !isConnectorSpace(activeSpace)
|
|
? activeSpace
|
|
: spaces.find((space) => !isConnectorSpace(space)) ?? null;
|
|
if (canUpload && uploadSpace) {
|
|
setActiveSpaceId(uploadSpace.id);
|
|
openDialog("upload", { spaceId: uploadSpace.id, folderPath: "" });
|
|
} else if (!canUpload) {
|
|
setError("File upload permission is required.");
|
|
} else {
|
|
setError("No authorized managed file space is available for upload.");
|
|
}
|
|
|
|
parameters.delete("quickAction");
|
|
const search = parameters.toString();
|
|
navigate(
|
|
{ pathname: location.pathname, search: search ? `?${search}` : "" },
|
|
{ replace: true, state: location.state }
|
|
);
|
|
}, [
|
|
activeSpace,
|
|
canUpload,
|
|
location.pathname,
|
|
location.search,
|
|
location.state,
|
|
navigate,
|
|
spaces,
|
|
spacesLoaded
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return undefined;
|
|
const close = () => setContextMenu(null);
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setContextMenu(null);
|
|
};
|
|
window.addEventListener("click", close);
|
|
window.addEventListener("resize", close);
|
|
window.addEventListener("scroll", close, true);
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
return () => {
|
|
window.removeEventListener("click", close);
|
|
window.removeEventListener("resize", close);
|
|
window.removeEventListener("scroll", close, true);
|
|
window.removeEventListener("keydown", closeOnEscape);
|
|
};
|
|
}, [contextMenu]);
|
|
|
|
useEffect(() => {
|
|
setCurrentFolder("");
|
|
setSearchPattern("");
|
|
setSearchActive(false);
|
|
setSearchResults(null);
|
|
setCampaignUsageFilter("");
|
|
setAuditRelevantFilter("");
|
|
setPropertyFiltersActive(false);
|
|
setPropertyFilterResults(null);
|
|
setPropertyFilterTotal(null);
|
|
setSelectedFileIds(new Set());
|
|
setSelectedFolderPaths(new Set());
|
|
setConnectorSpaceSelectedItem(null);
|
|
setConnectorSpaceError("");
|
|
const nextSpace = spaces.find((space) => space.id === activeSpaceId);
|
|
if (nextSpace && isConnectorSpace(nextSpace)) {
|
|
void loadConnectorSpaceContents(nextSpace, { folderPath: "", silent: true });
|
|
} else if (nextSpace && filesBySpace[nextSpace.id] === undefined && foldersBySpace[nextSpace.id] === undefined) {
|
|
void loadSpaceContents(nextSpace, { silent: true });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeSpaceId, spaces]);
|
|
|
|
|
|
|
|
function resetTransientState(options: {keepSearch?: boolean;} = {}) {
|
|
clearSelection();
|
|
if (!options.keepSearch) {
|
|
setSearchActive(false);
|
|
setSearchPattern("");
|
|
setSearchResults(null);
|
|
setCampaignUsageFilter("");
|
|
setAuditRelevantFilter("");
|
|
setPropertyFiltersActive(false);
|
|
setPropertyFilterResults(null);
|
|
setPropertyFilterTotal(null);
|
|
}
|
|
setUnmatchedCount(null);
|
|
setRenamePreview(null);
|
|
}
|
|
|
|
function currentActionTarget(): FileActionTarget | null {
|
|
return activeDialogTarget;
|
|
}
|
|
|
|
function uploadRejectedReason(target: FileActionTarget | null): string {
|
|
if (busy || uploadActive) return "Another file operation is already running. Wait until it finishes before dropping files.";
|
|
if (!canUpload) return "You do not have permission to upload files here.";
|
|
if (!target) return "Choose a destination folder before dropping files.";
|
|
const targetSpace = findSpace(target.spaceId);
|
|
if (!targetSpace) return "The selected upload destination is no longer available.";
|
|
if (isConnectorSpace(targetSpace)) return "Files cannot be uploaded into connector spaces from this view.";
|
|
return "The dropped item cannot be uploaded here.";
|
|
}
|
|
|
|
function openDialog(kind: DialogKind, target: FileActionTarget | null = null) {
|
|
if ((kind === "upload" || kind === "connector-sync" || kind === "connector-folder-sync") && !canUpload) return;
|
|
if (kind === "connector-space" && !canOrganize) return;
|
|
if (kind && ["create-folder", "rename", "single-rename", "transfer"].includes(kind) && !canOrganize) return;
|
|
if (target && isConnectorSpace(findSpace(target.spaceId)) && kind !== "connector-sync" && kind !== "connector-folder-sync") return;
|
|
if (kind === "create-folder") {
|
|
setNewFolderName("");
|
|
setNewFolderError("");
|
|
}
|
|
if (kind === "upload") {
|
|
resetArchiveUploadState();
|
|
}
|
|
openRawDialog(kind, target);
|
|
}
|
|
|
|
function resetArchiveUploadState() {
|
|
void releaseArchivePreview(settings, archiveFile);
|
|
setArchiveProgress(null);
|
|
setArchiveFile(null);
|
|
setManagedArchiveFile(null);
|
|
setArchivePreview(null);
|
|
setArchivePassword("");
|
|
setSelectedArchivePaths(new Set());
|
|
}
|
|
|
|
function closeDialog() {
|
|
closeRawDialog();
|
|
setNewFolderError("");
|
|
setUploadActive(false);
|
|
setUploadPhase("idle");
|
|
setUploadProgress(null);
|
|
setArchiveProgress(null);
|
|
setConnectorError("");
|
|
setConnectorSelectedItem(null);
|
|
setConnectorSpaceLabel("");
|
|
setConnectorSpaceReadOnly(true);
|
|
setFolderSyncResult(null);
|
|
resetArchiveUploadState();
|
|
}
|
|
|
|
function updateActiveDialogFolder(spaceId: string, folderPath: string) {
|
|
setDialogTarget({ spaceId, folderPath: normalizeFolder(folderPath) });
|
|
setArchivePreview(null);
|
|
setSelectedArchivePaths(new Set());
|
|
}
|
|
|
|
function openManagedArchive(file: ManagedFile, target: FileActionTarget | null) {
|
|
if (busy || !canUpload || !canDownload || !target || isConnectorSpace(findSpace(target.spaceId)) || !ARCHIVE_FILENAME_PATTERN.test(file.filename)) return;
|
|
setContextMenu(null);
|
|
openDialog("upload", target);
|
|
setManagedArchiveFile(file);
|
|
setUnpackZip(true);
|
|
}
|
|
|
|
function findSpace(spaceId: string): FileSpace | null {
|
|
return spaces.find((space) => space.id === spaceId) ?? null;
|
|
}
|
|
|
|
function isConnectorSpace(space: FileSpace | null | undefined): boolean {
|
|
return space?.space_type === "connector";
|
|
}
|
|
|
|
function connectorSpaceLibrary(space: FileSpace): string | null {
|
|
return connectorSpaceLibraryBySpace[space.id] ?? space.library_id ?? null;
|
|
}
|
|
|
|
function connectorSpaceRootPath(space: FileSpace): string {
|
|
return normalizeFolder(space.remote_path || "");
|
|
}
|
|
|
|
function connectorSpaceBrowsePath(space: FileSpace, folderPath: string): string {
|
|
return normalizeFolder(joinFolder(connectorSpaceRootPath(space), folderPath));
|
|
}
|
|
|
|
function connectorSpaceRelativePath(space: FileSpace, path: string): string {
|
|
const root = connectorSpaceRootPath(space);
|
|
const normalized = normalizeFolder(path);
|
|
if (!root) return normalized;
|
|
if (normalized === root) return "";
|
|
if (normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1);
|
|
return normalized;
|
|
}
|
|
|
|
function connectorSpaceLibraryForSync(space: FileSpace, item: FileConnectorBrowseItem): string {
|
|
return connectorSpaceLibrary(space) || connectorMetadataString(item, "library_id") || connectorMetadataString(item, "share") || space.connector_profile_id || "default";
|
|
}
|
|
|
|
async function loadConnectorSpaceContents(
|
|
space: FileSpace,
|
|
options: {folderPath?: string;libraryId?: string | null;silent?: boolean;} = {})
|
|
{
|
|
if (!space.connector_profile_id) {
|
|
setConnectorSpaceError("i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5");
|
|
return;
|
|
}
|
|
const relativeFolder = normalizeFolder(options.folderPath ?? currentFolder);
|
|
const libraryId = options.libraryId !== undefined ? options.libraryId : connectorSpaceLibrary(space);
|
|
if (!options.silent) {
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
}
|
|
setConnectorSpaceLoading(true);
|
|
setConnectorSpaceError("");
|
|
try {
|
|
const response = await browseFileConnectorProfile(settings, space.connector_profile_id, {
|
|
path: connectorSpaceBrowsePath(space, relativeFolder),
|
|
library_id: libraryId || undefined
|
|
});
|
|
setConnectorSpaceItemsBySpace((current) => ({ ...current, [space.id]: response.items }));
|
|
setConnectorSpaceLibraryBySpace((current) => ({ ...current, [space.id]: response.library_id ?? libraryId ?? null }));
|
|
setConnectorSpaceSelectedItem(null);
|
|
setCurrentFolder(relativeFolder);
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
setConnectorSpaceError(detail);
|
|
setConnectorSpaceItemsBySpace((current) => ({ ...current, [space.id]: [] }));
|
|
} finally {
|
|
setConnectorSpaceLoading(false);
|
|
if (!options.silent) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openConnectorSpaceItem(item: FileConnectorBrowseItem) {
|
|
if (!activeSpace || !isConnectorSpace(activeSpace)) return;
|
|
if (item.kind === "file") {
|
|
setConnectorSpaceSelectedItem(item);
|
|
return;
|
|
}
|
|
if (item.kind === "library") {
|
|
void loadConnectorSpaceContents(activeSpace, { folderPath: "", libraryId: item.external_id || item.path });
|
|
return;
|
|
}
|
|
void loadConnectorSpaceContents(activeSpace, {
|
|
folderPath: connectorSpaceRelativePath(activeSpace, item.path),
|
|
libraryId: connectorSpaceLibrary(activeSpace)
|
|
});
|
|
}
|
|
|
|
function browseConnectorSpaceParent() {
|
|
if (!activeSpace || !isConnectorSpace(activeSpace)) return;
|
|
if (currentFolder) {
|
|
void loadConnectorSpaceContents(activeSpace, {
|
|
folderPath: parentFolderPath(currentFolder),
|
|
libraryId: connectorSpaceLibrary(activeSpace)
|
|
});
|
|
return;
|
|
}
|
|
if (connectorSpaceLibrary(activeSpace) && connectorSpaceLibrary(activeSpace) !== (activeSpace.library_id ?? null)) {
|
|
void loadConnectorSpaceContents(activeSpace, { folderPath: "", libraryId: activeSpace.library_id ?? null });
|
|
}
|
|
}
|
|
|
|
async function syncConnectorSpaceSelection(itemOverride?: FileConnectorBrowseItem) {
|
|
const space = activeSpace;
|
|
const selected = itemOverride ?? connectorSpaceSelectedItem;
|
|
if (!canUpload || !space || !isConnectorSpace(space) || !space.connector_profile_id || !selected || selected.kind !== "file") return;
|
|
const relativePath = connectorSpaceRelativePath(space, selected.path);
|
|
const targetFolder = parentFolderPath(relativePath);
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
setConnectorSpaceError("");
|
|
try {
|
|
const response = await syncFileConnectorFile(settings, space.connector_profile_id, {
|
|
library_id: connectorSpaceLibraryForSync(space, selected),
|
|
path: selected.path,
|
|
owner_type: space.owner_type,
|
|
owner_id: space.owner_id,
|
|
target_folder: targetFolder,
|
|
conflict_strategy: "rename",
|
|
source_revision: connectorRevision(selected),
|
|
metadata: {
|
|
...selected.metadata,
|
|
connector_space_id: space.connector_space_id,
|
|
browse_name: selected.name,
|
|
browse_path: selected.path,
|
|
browse_modified_at: selected.modified_at,
|
|
browse_etag: selected.etag,
|
|
synced_from_space: true
|
|
}
|
|
});
|
|
const actionLabel = response.action === "created" ?
|
|
"i18n:govoplan-files.synced_new_file.d5e8243d" :
|
|
response.action === "updated" ?
|
|
"i18n:govoplan-files.synced_new_version.83784264" :
|
|
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1";
|
|
setMessage(i18nMessage("i18n:govoplan-files.value_value.36bc3a40", { value0: actionLabel, value1: response.file.display_path }));
|
|
setConnectorSpaceSelectedItem(null);
|
|
const ownerSpace = spaces.find((item) => item.space_type !== "connector" && item.owner_type === space.owner_type && item.owner_id === space.owner_id);
|
|
if (ownerSpace) await loadSpaceContents(ownerSpace, { silent: ownerSpace.id !== activeSpaceId });
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
setError(detail);
|
|
setConnectorSpaceError(detail);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openConnectorFolderSyncDialog() {
|
|
if (!canUpload || !activeSpace || !isConnectorSpace(activeSpace) || !activeSpace.connector_space_id) return;
|
|
setFolderSyncRecursive(true);
|
|
setFolderSyncConflictStrategy("skip");
|
|
setFolderSyncMaxFiles(100);
|
|
setFolderSyncResult(null);
|
|
setDialog("connector-folder-sync");
|
|
}
|
|
|
|
async function syncActiveConnectorFolder() {
|
|
const space = activeSpace;
|
|
if (!canUpload || !space || !isConnectorSpace(space) || !space.connector_space_id) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
setConnectorSpaceError("");
|
|
try {
|
|
const response = await syncFileConnectorSpaceFolder(settings, space.connector_space_id, {
|
|
path: currentFolder,
|
|
target_folder: "",
|
|
recursive: folderSyncRecursive,
|
|
conflict_strategy: folderSyncConflictStrategy,
|
|
max_files: folderSyncMaxFiles,
|
|
max_depth: 12,
|
|
metadata: {
|
|
initiated_from: "files_connector_space",
|
|
connector_space_label: space.label
|
|
}
|
|
});
|
|
setFolderSyncResult(response);
|
|
const summary = response.summary;
|
|
setMessage(i18nMessage("i18n:govoplan-files.folder_sync.finished", {
|
|
value0: summary.created,
|
|
value1: summary.updated,
|
|
value2: summary.unchanged,
|
|
value3: summary.skipped,
|
|
value4: summary.conflicts + summary.policy_denied + summary.failed
|
|
}));
|
|
const ownerSpace = spaces.find((item) => item.space_type !== "connector" && item.owner_type === space.owner_type && item.owner_id === space.owner_id);
|
|
if (ownerSpace) await loadSpaceContents(ownerSpace, { silent: true });
|
|
await loadConnectorSpaceContents(space, { folderPath: currentFolder, silent: true });
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
setError(detail);
|
|
setConnectorSpaceError(detail);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadArchivePreview(
|
|
file: File | ManagedFile,
|
|
target: FileActionTarget,
|
|
options: { preserveSelection?: boolean } = {}
|
|
) {
|
|
if (busy || uploadActive) return;
|
|
const targetSpace = findSpace(target.spaceId);
|
|
if (!targetSpace || isConnectorSpace(targetSpace)) {
|
|
setError(uploadRejectedReason(target));
|
|
return;
|
|
}
|
|
const managed = "version_id" in file;
|
|
if (archiveFile && archiveFile !== file) void releaseArchivePreview(settings, archiveFile);
|
|
setArchiveFile(managed ? null : file);
|
|
setManagedArchiveFile(managed ? file : null);
|
|
setBusy(true);
|
|
setUploadActive(true);
|
|
setUploadPhase(managed ? "inspecting" : "uploading");
|
|
setUploadProgress(null);
|
|
setArchiveProgress(null);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const previewOptions = {
|
|
owner_type: targetSpace.owner_type,
|
|
owner_id: targetSpace.owner_id,
|
|
path: target.folderPath,
|
|
password: archivePassword || undefined
|
|
};
|
|
const response = managed
|
|
? await previewManagedArchive(settings, file.id, { ...previewOptions, source_version_id: file.version_id })
|
|
: await previewArchiveUpload(settings, file, {
|
|
...previewOptions,
|
|
onProgress: ({ percentage }) => {
|
|
setUploadProgress(percentage);
|
|
if (percentage !== null && percentage >= 100) setUploadPhase("inspecting");
|
|
}
|
|
});
|
|
const availableFiles = new Set(
|
|
response.entries
|
|
.filter((entry) => entry.kind === "file")
|
|
.map((entry) => entry.path)
|
|
);
|
|
setArchivePreview(response);
|
|
setSelectedArchivePaths((current) => {
|
|
if (!options.preserveSelection) return availableFiles;
|
|
const retained = new Set(
|
|
Array.from(current).filter((path) => availableFiles.has(path))
|
|
);
|
|
return retained.size > 0 ? retained : availableFiles;
|
|
});
|
|
} catch (err) {
|
|
setArchivePreview(null);
|
|
setSelectedArchivePaths(new Set());
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
setUploadActive(false);
|
|
setUploadPhase("idle");
|
|
setUploadProgress(null);
|
|
}
|
|
}
|
|
|
|
function archiveFilesForEntry(entry: ArchivePreviewEntry): string[] {
|
|
if (!archivePreview) return [];
|
|
if (entry.kind === "file") return [entry.path];
|
|
const prefix = `${entry.path}/`;
|
|
return archivePreview.entries
|
|
.filter((candidate) => candidate.kind === "file" && candidate.path.startsWith(prefix))
|
|
.map((candidate) => candidate.path);
|
|
}
|
|
|
|
function archiveEntryIsSelected(entry: ArchivePreviewEntry): boolean {
|
|
const paths = archiveFilesForEntry(entry);
|
|
return paths.length > 0 && paths.every((path) => selectedArchivePaths.has(path));
|
|
}
|
|
|
|
function toggleArchiveEntry(entry: ArchivePreviewEntry, selected: boolean) {
|
|
const paths = archiveFilesForEntry(entry);
|
|
setSelectedArchivePaths((current) => {
|
|
const next = new Set(current);
|
|
paths.forEach((path) => selected ? next.add(path) : next.delete(path));
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleAllArchiveFiles(selected: boolean) {
|
|
setSelectedArchivePaths(
|
|
selected && archivePreview
|
|
? new Set(
|
|
archivePreview.entries
|
|
.filter((entry) => entry.kind === "file")
|
|
.map((entry) => entry.path)
|
|
)
|
|
: new Set()
|
|
);
|
|
}
|
|
|
|
async function confirmCurrentArchive() {
|
|
const target = currentActionTarget();
|
|
const targetSpace = target ? findSpace(target.spaceId) : null;
|
|
if (
|
|
busy
|
|
|| (!archiveFile && !managedArchiveFile)
|
|
|| !archivePreview
|
|
|| !target
|
|
|| !targetSpace
|
|
|| isConnectorSpace(targetSpace)
|
|
) {
|
|
return;
|
|
}
|
|
if (selectedArchivePaths.size === 0) {
|
|
setError("Select at least one archive file to import.");
|
|
return;
|
|
}
|
|
if (archivePreview.requires_password && !archivePassword) {
|
|
setError("Enter the archive password before importing.");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setUploadActive(true);
|
|
setUploadPhase("inspecting");
|
|
setUploadProgress(null);
|
|
setArchiveProgress(null);
|
|
setError("");
|
|
setMessage("i18n:govoplan-files.archive_progress.inspecting");
|
|
try {
|
|
const confirmOptions = {
|
|
preview_token: archivePreview.preview_token,
|
|
selected_paths: Array.from(selectedArchivePaths),
|
|
owner_type: targetSpace.owner_type,
|
|
owner_id: targetSpace.owner_id,
|
|
path: target.folderPath,
|
|
password: archivePassword || undefined,
|
|
onArchiveProgress: (progress: ArchiveOperationProgress) => {
|
|
setArchiveProgress(progress);
|
|
setUploadPhase(progress.phase === "inspecting" ? "inspecting" : progress.phase === "finalizing" || progress.phase === "complete" ? "finalizing" : "unpacking");
|
|
}
|
|
};
|
|
const response = managedArchiveFile
|
|
? await confirmManagedArchive(settings, managedArchiveFile.id, { ...confirmOptions, source_version_id: managedArchiveFile.version_id })
|
|
: await confirmArchiveUpload(settings, archiveFile!, {
|
|
...confirmOptions,
|
|
conflict_strategy: "reject",
|
|
onProgress: ({ percentage }) => {
|
|
setUploadProgress(percentage);
|
|
if (percentage !== null && percentage >= 100) {
|
|
setUploadPhase("unpacking");
|
|
setMessage("Extracting the selected archive files.");
|
|
} else setUploadPhase("uploading");
|
|
}
|
|
});
|
|
setUploadPhase("finalizing");
|
|
setUploadProgress(100);
|
|
const uploadedCount = response.files.length;
|
|
const destination = target.folderPath || "i18n:govoplan-files.root.e96857c5";
|
|
closeDialog();
|
|
setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", {
|
|
value0: uploadedCount,
|
|
value1: destination
|
|
}));
|
|
resetTransientState();
|
|
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setMessage("");
|
|
} finally {
|
|
setBusy(false);
|
|
setUploadActive(false);
|
|
setUploadPhase("idle");
|
|
setUploadProgress(null);
|
|
}
|
|
}
|
|
|
|
async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
|
|
const target = options.target ?? currentActionTarget();
|
|
const targetSpace = target ? findSpace(target.spaceId) : null;
|
|
if (busy || uploadActive || !canUpload || !target || !targetSpace || isConnectorSpace(targetSpace)) {
|
|
setError(uploadRejectedReason(target));
|
|
return;
|
|
}
|
|
const selected = Array.from(fileList);
|
|
if (selected.length === 0) return;
|
|
if (unpackZip) {
|
|
if (selected.length !== 1 || !ARCHIVE_FILENAME_PATTERN.test(selected[0].name)) {
|
|
setError("Choose one ZIP, TAR, TAR.GZ, TAR.BZ2, or TAR.XZ archive to preview and unpack.");
|
|
return;
|
|
}
|
|
await loadArchivePreview(selected[0], target);
|
|
return;
|
|
}
|
|
if (!options.bypassConflictDialog && !unpackZip) {
|
|
const conflicts = uploadConflicts(selected, target);
|
|
if (conflicts.length > 0) {
|
|
closeDialog();
|
|
setConflictDialog({
|
|
operation: "upload",
|
|
title: i18nMessage("i18n:govoplan-files.value_upload_conflict_s.3a04f117", { value0: conflicts.length }),
|
|
message: "i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0",
|
|
items: conflicts,
|
|
pendingUpload: { files: selected, target },
|
|
review: false
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
setBusy(true);
|
|
setUploadActive(true);
|
|
setUploadPhase("uploading");
|
|
setUploadProgress(0);
|
|
setArchiveProgress(null);
|
|
setError("");
|
|
setMessage(i18nMessage("i18n:govoplan-files.uploading_value_file_s.715ba963", { value0: selected.length }));
|
|
try {
|
|
const response = await uploadFiles(settings, selected, {
|
|
owner_type: targetSpace.owner_type,
|
|
owner_id: targetSpace.owner_id,
|
|
path: target.folderPath,
|
|
conflict_strategy: options.conflictStrategy ?? "reject",
|
|
conflict_resolutions: options.conflictResolutions,
|
|
onProgress: ({ percentage }) => {
|
|
setUploadProgress(percentage);
|
|
if (percentage !== null && percentage >= 100) {
|
|
setUploadPhase("finalizing");
|
|
setMessage("i18n:govoplan-files.finalizing_upload.bcce936d");
|
|
}
|
|
}
|
|
});
|
|
setUploadPhase("finalizing");
|
|
setUploadProgress(100);
|
|
setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", { value0: response.files.length, value1: target.folderPath || "i18n:govoplan-files.root.e96857c5" }));
|
|
closeDialog();
|
|
setConflictDialog(null);
|
|
resetTransientState();
|
|
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setMessage("");
|
|
} finally {
|
|
setBusy(false);
|
|
setUploadActive(false);
|
|
setUploadPhase("idle");
|
|
setUploadProgress(null);
|
|
}
|
|
}
|
|
|
|
async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) {
|
|
await handleFilesUpload(fileList, { target });
|
|
}
|
|
|
|
async function openConnectorSyncDialog(target: FileActionTarget | null = toolbarTarget()) {
|
|
if (!canUpload || !target) return;
|
|
openDialog("connector-sync", target);
|
|
if (connectorProfiles.length === 0) {
|
|
await loadConnectorProfilesForDialog();
|
|
return;
|
|
}
|
|
const profileId = connectorProfileId || connectorProfiles[0]?.id || "";
|
|
if (profileId && connectorItems.length === 0) await browseConnector(profileId, "", null);
|
|
}
|
|
|
|
async function openConnectorSpaceDialog() {
|
|
if (!canOrganize) return;
|
|
const defaultOwnerSpace = activeSpace && !activeSpaceIsConnector ?
|
|
activeSpace :
|
|
spaces.find((space) => !isConnectorSpace(space)) ?? null;
|
|
setConnectorSpaceOwnerSpaceId(defaultOwnerSpace?.id || "");
|
|
setConnectorSpaceLabel("");
|
|
openDialog("connector-space", null);
|
|
if (connectorProfiles.length === 0) {
|
|
await loadConnectorProfilesForDialog();
|
|
return;
|
|
}
|
|
const profileId = connectorProfileId || connectorProfiles[0]?.id || "";
|
|
if (profileId && connectorItems.length === 0) await browseConnector(profileId, "", null);
|
|
}
|
|
|
|
async function loadConnectorProfilesForDialog(preferredProfileId = connectorProfileId) {
|
|
setConnectorLoading(true);
|
|
setConnectorError("");
|
|
try {
|
|
const response = await listFileConnectorProfiles(settings);
|
|
const enabledProfiles = response.profiles.filter((profile) => profile.enabled);
|
|
setConnectorProfiles(enabledProfiles);
|
|
const nextProfileId = enabledProfiles.some((profile) => profile.id === preferredProfileId) ?
|
|
preferredProfileId :
|
|
enabledProfiles[0]?.id || "";
|
|
setConnectorProfileId(nextProfileId);
|
|
setConnectorLibraryId(null);
|
|
setConnectorPath("");
|
|
setConnectorItems([]);
|
|
setConnectorSelectedItem(null);
|
|
if (nextProfileId) {
|
|
await browseConnector(nextProfileId, "", null);
|
|
} else {
|
|
setConnectorError("i18n:govoplan-files.no_connector_profiles_are_available.9be3be28");
|
|
}
|
|
} catch (err) {
|
|
setConnectorError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setConnectorLoading(false);
|
|
}
|
|
}
|
|
|
|
async function browseConnector(profileId: string, path = "", libraryId: string | null = connectorLibraryId) {
|
|
if (!profileId) {
|
|
setConnectorPath("");
|
|
setConnectorLibraryId(null);
|
|
setConnectorItems([]);
|
|
setConnectorSelectedItem(null);
|
|
return;
|
|
}
|
|
setConnectorLoading(true);
|
|
setConnectorError("");
|
|
try {
|
|
const response = await browseFileConnectorProfile(settings, profileId, {
|
|
path: normalizeFolder(path),
|
|
library_id: libraryId || undefined
|
|
});
|
|
setConnectorPath(response.path || "");
|
|
setConnectorLibraryId(response.library_id ?? libraryId ?? null);
|
|
setConnectorItems(response.items);
|
|
setConnectorSelectedItem(null);
|
|
} catch (err) {
|
|
setConnectorError(err instanceof Error ? err.message : String(err));
|
|
setConnectorItems([]);
|
|
setConnectorSelectedItem(null);
|
|
} finally {
|
|
setConnectorLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleConnectorProfileChange(profileId: string) {
|
|
setConnectorProfileId(profileId);
|
|
setConnectorLibraryId(null);
|
|
setConnectorPath("");
|
|
setConnectorItems([]);
|
|
setConnectorSelectedItem(null);
|
|
if (profileId) void browseConnector(profileId, "", null);
|
|
}
|
|
|
|
function openConnectorItem(item: FileConnectorBrowseItem) {
|
|
if (!connectorProfileId) return;
|
|
if (item.kind === "file") {
|
|
setConnectorSelectedItem(item);
|
|
return;
|
|
}
|
|
if (item.kind === "library") {
|
|
void browseConnector(connectorProfileId, "", item.external_id || item.path);
|
|
return;
|
|
}
|
|
void browseConnector(connectorProfileId, item.path, connectorLibraryId);
|
|
}
|
|
|
|
function browseConnectorParent() {
|
|
if (!connectorProfileId) return;
|
|
if (connectorPath) {
|
|
void browseConnector(connectorProfileId, parentFolderPath(connectorPath), connectorLibraryId);
|
|
return;
|
|
}
|
|
if (connectorLibraryId) void browseConnector(connectorProfileId, "", null);
|
|
}
|
|
|
|
function connectorSpaceSuggestedLabel(): string {
|
|
const location = connectorPath ? lastPathSegment(connectorPath) : connectorLibraryId || "";
|
|
return [activeConnectorProfile?.label, location].filter(Boolean).join(" / ") || "i18n:govoplan-files.linked_file_space.1a614c7d";
|
|
}
|
|
|
|
async function createConnectorSpaceFromDialog() {
|
|
const ownerSpace = findSpace(connectorSpaceOwnerSpaceId) ?? spaces.find((space) => !isConnectorSpace(space)) ?? null;
|
|
if (!canOrganize || !ownerSpace || isConnectorSpace(ownerSpace) || !connectorProfileId) {
|
|
setConnectorError("i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00");
|
|
return;
|
|
}
|
|
const label = connectorSpaceLabel.trim() || connectorSpaceSuggestedLabel();
|
|
const remotePath = normalizeFolder(connectorPath);
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
setConnectorError("");
|
|
try {
|
|
const created = await createFileConnectorSpace(settings, {
|
|
owner_type: ownerSpace.owner_type,
|
|
owner_id: ownerSpace.owner_id,
|
|
label,
|
|
connector_profile_id: connectorProfileId,
|
|
library_id: connectorLibraryId || undefined,
|
|
remote_path: remotePath,
|
|
sync_mode: "manual",
|
|
read_only: connectorSpaceReadOnly,
|
|
metadata: {
|
|
created_from_ui: true,
|
|
source_label: activeConnectorProfile?.label,
|
|
source_path: remotePath,
|
|
source_library_id: connectorLibraryId
|
|
}
|
|
});
|
|
setMessage(i18nMessage("i18n:govoplan-files.added_connector_space_value.a24725b8", { value0: created.label }));
|
|
closeDialog();
|
|
resetTransientState();
|
|
await loadSpaces();
|
|
setActiveSpaceId(`connector:${created.id}`);
|
|
setCurrentFolder("");
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
setError(detail);
|
|
setConnectorError(detail);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function removeConnectorSpace() {
|
|
const target = connectorSpaceRemovalTarget;
|
|
if (!canOrganize || !target?.connector_space_id || !isConnectorSpace(target)) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
await deleteFileConnectorSpace(settings, target.connector_space_id);
|
|
setConnectorSpaceRemovalTarget(null);
|
|
setConnectorSpaceItemsBySpace((current) => {
|
|
const next = { ...current };
|
|
delete next[target.id];
|
|
return next;
|
|
});
|
|
setConnectorSpaceLibraryBySpace((current) => {
|
|
const next = { ...current };
|
|
delete next[target.id];
|
|
return next;
|
|
});
|
|
setConnectorSpaceSelectedItem(null);
|
|
setMessage(`Removed connector space “${target.label}”. Remote content and managed GovOPlaN files were not changed.`);
|
|
await loadSpaces();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function connectorMetadataString(item: FileConnectorBrowseItem, key: string): string | null {
|
|
const value = item.metadata[key];
|
|
if (typeof value === "string" && value.trim()) return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return null;
|
|
}
|
|
|
|
function connectorLibraryForSync(item: FileConnectorBrowseItem): string {
|
|
return connectorLibraryId || connectorMetadataString(item, "library_id") || connectorMetadataString(item, "share") || activeConnectorProfile?.id || "default";
|
|
}
|
|
|
|
function connectorRevision(item: FileConnectorBrowseItem): string | null {
|
|
return item.etag || item.modified_at || connectorMetadataString(item, "revision");
|
|
}
|
|
|
|
async function syncConnectorSelection() {
|
|
const target = currentActionTarget();
|
|
const targetSpace = target ? findSpace(target.spaceId) : null;
|
|
const selected = connectorSelectedItem;
|
|
if (!canUpload || !target || !targetSpace || !connectorProfileId || !selected || selected.kind !== "file") {
|
|
setConnectorError("i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
setConnectorError("");
|
|
try {
|
|
const response = await syncFileConnectorFile(settings, connectorProfileId, {
|
|
library_id: connectorLibraryForSync(selected),
|
|
path: selected.path,
|
|
owner_type: targetSpace.owner_type,
|
|
owner_id: targetSpace.owner_id,
|
|
target_folder: target.folderPath,
|
|
conflict_strategy: "rename",
|
|
source_revision: connectorRevision(selected),
|
|
metadata: {
|
|
...selected.metadata,
|
|
browse_name: selected.name,
|
|
browse_path: selected.path,
|
|
browse_modified_at: selected.modified_at,
|
|
browse_etag: selected.etag,
|
|
synced_from_ui: true
|
|
}
|
|
});
|
|
const actionLabel = response.action === "created" ?
|
|
"i18n:govoplan-files.synced_new_file.d5e8243d" :
|
|
response.action === "updated" ?
|
|
"i18n:govoplan-files.synced_new_version.83784264" :
|
|
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1";
|
|
setMessage(i18nMessage("i18n:govoplan-files.value_value.36bc3a40", { value0: actionLabel, value1: response.file.display_path }));
|
|
closeDialog();
|
|
resetTransientState();
|
|
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
setError(detail);
|
|
setConnectorError(detail);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateFolder() {
|
|
const target = currentActionTarget();
|
|
const targetSpace = target ? findSpace(target.spaceId) : null;
|
|
const trimmedName = newFolderName.trim();
|
|
if (!target || !targetSpace || isConnectorSpace(targetSpace) || !trimmedName) return;
|
|
const folderPath = joinFolder(target.folderPath, trimmedName);
|
|
if (folderExistsInTarget(target, folderPath)) {
|
|
setNewFolderError(`Folder already exists: ${folderPath}.`);
|
|
return;
|
|
}
|
|
if (pathExistsInTarget(target, folderPath, { includeFolders: false })) {
|
|
setNewFolderError(`A file already exists at ${folderPath}.`);
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
setNewFolderError("");
|
|
setMessage("");
|
|
try {
|
|
await createFolder(settings, {
|
|
owner_type: targetSpace.owner_type,
|
|
owner_id: targetSpace.owner_id,
|
|
path: folderPath
|
|
});
|
|
setMessage(i18nMessage("i18n:govoplan-files.created_folder_value.6c3002f9", { value0: folderPath }));
|
|
closeDialog();
|
|
setNewFolderName("");
|
|
resetTransientState();
|
|
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
|
|
setExpandedTreeNodes((current) => {
|
|
const next = new Set(current);
|
|
folderAncestorPaths(target.folderPath, { includeSelf: true }).forEach((path) => next.add(treeNodeKey(target.spaceId, path)));
|
|
return next;
|
|
});
|
|
} catch (err) {
|
|
setNewFolderError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function runPatternSearch(patternOverride = searchPattern, caseSensitiveOverride = searchCaseSensitive) {
|
|
const trimmedPattern = patternOverride.trim();
|
|
if (!activeSpace || activeSpaceIsConnector || !trimmedPattern) {
|
|
await clearSearch();
|
|
return;
|
|
}
|
|
const caseSensitive = caseSensitiveOverride;
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const response = await resolveFilePatterns(settings, {
|
|
patterns: [trimmedPattern],
|
|
owner_type: activeSpace.owner_type,
|
|
owner_id: activeSpace.owner_id,
|
|
path_prefix: currentFolder,
|
|
include_unmatched: true,
|
|
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());
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function clearSearch() {
|
|
setSearchPattern("");
|
|
setSearchActive(false);
|
|
setSearchResults(null);
|
|
setUnmatchedCount(null);
|
|
setSelectedFileIds(new Set());
|
|
setSelectedFolderPaths(new Set());
|
|
}
|
|
|
|
async function runPropertyFilters(
|
|
campaignUsage: "" | FileCampaignUsageFilter,
|
|
auditRelevant: AuditRelevantFilter
|
|
) {
|
|
if (!activeSpace || activeSpaceIsConnector) return;
|
|
if (!campaignUsage && !auditRelevant) {
|
|
clearPropertyFilters();
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const response = await listFilesByProperties(settings, {
|
|
owner_type: activeSpace.owner_type,
|
|
owner_id: activeSpace.owner_id,
|
|
path_prefix: currentFolder,
|
|
campaign_usage: campaignUsage || undefined,
|
|
audit_relevant: auditRelevant ? auditRelevant === "true" : undefined
|
|
});
|
|
setCampaignUsageFilter(campaignUsage);
|
|
setAuditRelevantFilter(auditRelevant);
|
|
setPropertyFilterResults(response.files);
|
|
setPropertyFilterTotal(response.total);
|
|
setPropertyFiltersActive(true);
|
|
setSelectedFileIds(new Set());
|
|
setSelectedFolderPaths(new Set());
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function clearPropertyFilters() {
|
|
setCampaignUsageFilter("");
|
|
setAuditRelevantFilter("");
|
|
setPropertyFiltersActive(false);
|
|
setPropertyFilterResults(null);
|
|
setPropertyFilterTotal(null);
|
|
setSelectedFileIds(new Set());
|
|
setSelectedFolderPaths(new Set());
|
|
}
|
|
|
|
function deleteSelected(
|
|
fileIds: Set<string> = selectedFileIds,
|
|
folderPaths: Set<string> = selectedFolderPaths,
|
|
space: FileSpace | null = activeSpace)
|
|
{
|
|
const fileCount = fileIds.size;
|
|
const folderCount = folderPaths.size;
|
|
if (!space || isConnectorSpace(space) || fileCount === 0 && folderCount === 0) return;
|
|
const folderText = folderCount ? `${folderCount} folder(s) and their contents` : "";
|
|
const fileText = fileCount ? `${fileCount} file(s)` : "";
|
|
const what = [folderText, fileText].filter(Boolean).join(" plus ");
|
|
setDeleteDialog({
|
|
fileIds: new Set(fileIds),
|
|
folderPaths: new Set(folderPaths),
|
|
spaceId: space.id,
|
|
title: "i18n:govoplan-files.delete_selected_item_s.4b6a0023",
|
|
message: i18nMessage("i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f", { value0: what })
|
|
});
|
|
}
|
|
|
|
async function performConfirmedDelete() {
|
|
const pending = deleteDialog;
|
|
if (!pending) return;
|
|
const space = findSpace(pending.spaceId);
|
|
if (!space) {
|
|
setDeleteDialog(null);
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
if (pending.fileIds.size > 0) {
|
|
await bulkDeleteFiles(settings, Array.from(pending.fileIds));
|
|
}
|
|
if (pending.folderPaths.size > 0) {
|
|
for (const folderPath of pending.folderPaths) {
|
|
await deleteFolder(settings, {
|
|
owner_type: space.owner_type,
|
|
owner_id: space.owner_id,
|
|
path: folderPath,
|
|
recursive: true
|
|
});
|
|
}
|
|
}
|
|
setDeleteDialog(null);
|
|
resetTransientState();
|
|
await loadSpaceContents(space, { silent: space.id !== activeSpaceId });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
|
|
function downloadLabelForSets(sets: SelectionSets, spaceId = activeSpaceId): string {
|
|
const count = fileIdsForSelection(filesBySpace[spaceId] ?? EMPTY_FILES, sets.fileIds, sets.folderPaths).length;
|
|
return count > 1 ? "i18n:govoplan-files.download_zip.7bd0e4b0" : "i18n:govoplan-files.download.a479c9c3";
|
|
}
|
|
|
|
async function downloadSelection(
|
|
fileIds: Set<string> = selectedFileIds,
|
|
folderPaths: Set<string> = selectedFolderPaths,
|
|
sourceFiles: ManagedFile[] = files)
|
|
{
|
|
if (!canDownload) return;
|
|
const idsToDownload = fileIdsForSelection(sourceFiles, fileIds, folderPaths);
|
|
if (idsToDownload.length === 0) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
if (idsToDownload.length === 1) {
|
|
const file = sourceFiles.find((item) => item.id === idsToDownload[0]);
|
|
if (file) await downloadFile(settings, file);
|
|
} else {
|
|
await downloadFilesAsZip(settings, idsToDownload, "multi-seal-mail-files.zip");
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openTransferDialog(mode: TransferMode, sourceSpaceId = activeSpaceId, sets: SelectionSets = currentSelectionSets(), defaultTarget?: FileActionTarget) {
|
|
if (!canOrganize) return;
|
|
if (!sourceSpaceId || sets.fileIds.size === 0 && sets.folderPaths.size === 0) return;
|
|
setTransferMode(mode);
|
|
setTransferDialogState({
|
|
fileIds: new Set(sets.fileIds),
|
|
folderPaths: new Set(sets.folderPaths),
|
|
sourceSpaceId,
|
|
targetSpaceId: defaultTarget?.spaceId ?? sourceSpaceId,
|
|
targetFolder: defaultTarget?.folderPath ?? currentFolder
|
|
});
|
|
setDialog("transfer");
|
|
}
|
|
|
|
function invalidDropReason(sourceSpaceId: string, folderPaths: Iterable<string>, target: FileActionTarget): string | null {
|
|
if (sourceSpaceId !== target.spaceId) return null;
|
|
const targetFolder = normalizeFolder(target.folderPath);
|
|
for (const folderPathRaw of folderPaths) {
|
|
const folderPath = normalizeFolder(folderPathRaw);
|
|
if (!folderPath) continue;
|
|
if (targetFolder === folderPath || targetFolder.startsWith(`${folderPath}/`)) {
|
|
return "i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function runTransfer(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget, options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;} = {}) {
|
|
const sourceSpace = findSpace(sourceSpaceId);
|
|
const targetSpace = findSpace(target.spaceId);
|
|
if (!sourceSpace || !targetSpace || isConnectorSpace(sourceSpace) || isConnectorSpace(targetSpace)) return;
|
|
const reason = invalidDropReason(sourceSpaceId, sets.folderPaths, target);
|
|
if (reason) {
|
|
setError(reason);
|
|
return;
|
|
}
|
|
if (sets.fileIds.size === 0 && sets.folderPaths.size === 0) return;
|
|
if (mode === "move" && transferWouldBeNoop(sourceSpaceId, sets, target)) {
|
|
setInternalDrag(null);
|
|
setDropTargetKey("");
|
|
return;
|
|
}
|
|
if (!options.bypassConflictDialog) {
|
|
const conflicts = transferConflicts(mode, sourceSpaceId, sets, target);
|
|
if (conflicts.length > 0) {
|
|
closeDialog();
|
|
setConflictDialog({
|
|
operation: "transfer",
|
|
title: i18nMessage("i18n:govoplan-files.value_conflict_s.b3872a48", { value0: conflicts.length }),
|
|
message: i18nMessage("i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4", { value0: mode === "copy" ? "i18n:govoplan-files.copying.43b7de98" : "i18n:govoplan-files.moving.65cd4dd8" }),
|
|
items: conflicts,
|
|
pendingTransfer: { mode, sourceSpaceId, sets: { fileIds: new Set(sets.fileIds), folderPaths: new Set(sets.folderPaths) }, target },
|
|
review: false
|
|
});
|
|
setInternalDrag(null);
|
|
setDropTargetKey("");
|
|
return;
|
|
}
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const response = await transferFiles(settings, {
|
|
operation: mode,
|
|
file_ids: Array.from(sets.fileIds),
|
|
folder_paths: Array.from(sets.folderPaths),
|
|
source_owner_type: sourceSpace.owner_type,
|
|
source_owner_id: sourceSpace.owner_id,
|
|
target_owner_type: targetSpace.owner_type,
|
|
target_owner_id: targetSpace.owner_id,
|
|
target_folder: target.folderPath,
|
|
conflict_strategy: options.conflictStrategy ?? "reject",
|
|
conflict_resolutions: options.conflictResolutions
|
|
});
|
|
setMessage(i18nMessage("i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725", { value0: mode === "copy" ? "i18n:govoplan-files.copied.8e3df45a" : "i18n:govoplan-files.moved.0fc28db0", value1: response.files, value2: response.folders }));
|
|
setConflictDialog(null);
|
|
closeDialog();
|
|
resetTransientState();
|
|
const reloadIds = new Set([sourceSpace.id, targetSpace.id]);
|
|
await Promise.all(Array.from(reloadIds).map((spaceId) => {
|
|
const space = findSpace(spaceId);
|
|
return space ? loadSpaceContents(space, { silent: space.id !== activeSpaceId }) : Promise.resolve();
|
|
}));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
setInternalDrag(null);
|
|
setDropTargetKey("");
|
|
}
|
|
}
|
|
|
|
async function handleTransferDialogSubmit() {
|
|
if (!transferDialogState) return;
|
|
const state = transferDialogState;
|
|
const mode = transferMode;
|
|
closeDialog();
|
|
await runTransfer(mode, state.sourceSpaceId, state, {
|
|
spaceId: state.targetSpaceId,
|
|
folderPath: state.targetFolder
|
|
});
|
|
}
|
|
|
|
async function runRenamePreview(dryRun: boolean) {
|
|
if (!activeSpace || activeSpaceIsConnector || selectedEntryCount === 0) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const response = await bulkRenameFiles(settings, {
|
|
file_ids: Array.from(selectedFileIds),
|
|
folder_paths: Array.from(selectedFolderPaths),
|
|
owner_type: activeSpace.owner_type,
|
|
owner_id: activeSpace.owner_id,
|
|
mode: renameMode,
|
|
find: renameFind,
|
|
replacement: renameReplacement,
|
|
prefix: renamePrefix,
|
|
suffix: renameSuffix,
|
|
recursive: renameRecursive,
|
|
dry_run: dryRun
|
|
});
|
|
setRenamePreview(response);
|
|
setRenamePreviewVisibleCount(20);
|
|
if (dryRun) {
|
|
setMessage("");
|
|
} else {
|
|
setMessage(i18nMessage("i18n:govoplan-files.renamed_value_item_s.3c4a40fe", { value0: response.items.length }));
|
|
}
|
|
if (!dryRun) {
|
|
closeDialog();
|
|
resetTransientState();
|
|
await loadSpaceContents(activeSpace);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openRenameDialog() {
|
|
if (!canOrganize || activeSpaceIsConnector || selectedEntryCount === 0) return;
|
|
setRenamePreview(null);
|
|
if (selectedEntryCount === 1) {
|
|
const selectedFile = selectedFiles[0];
|
|
const selectedFolderPath = Array.from(selectedFolderPaths)[0];
|
|
setSingleRenameName(selectedFile ? selectedFile.filename : lastPathSegment(selectedFolderPath));
|
|
openDialog("single-rename");
|
|
return;
|
|
}
|
|
openDialog("rename");
|
|
}
|
|
|
|
async function runSingleRename() {
|
|
if (!canOrganize || !activeSpace || activeSpaceIsConnector || selectedEntryCount !== 1 || !singleRenameName.trim()) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const response = await bulkRenameFiles(settings, {
|
|
file_ids: Array.from(selectedFileIds),
|
|
folder_paths: Array.from(selectedFolderPaths),
|
|
owner_type: activeSpace.owner_type,
|
|
owner_id: activeSpace.owner_id,
|
|
mode: "direct",
|
|
new_name: singleRenameName,
|
|
dry_run: false
|
|
});
|
|
setMessage(i18nMessage("i18n:govoplan-files.renamed_value_item_s.3c4a40fe", { value0: response.items.length }));
|
|
closeDialog();
|
|
resetTransientState();
|
|
await loadSpaceContents(activeSpace);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openFolder(spaceId: string, path: string) {
|
|
const space = findSpace(spaceId);
|
|
if (spaceId !== activeSpaceId) setActiveSpaceId(spaceId);
|
|
resetTransientState();
|
|
if (space && isConnectorSpace(space)) {
|
|
void loadConnectorSpaceContents(space, { folderPath: normalizeFolder(path) });
|
|
return;
|
|
}
|
|
setCurrentFolder(normalizeFolder(path));
|
|
}
|
|
|
|
|
|
function handleEntryKeyDown(entry: ExplorerEntry, event: ReactKeyboardEvent<HTMLElement>) {
|
|
if (event.key === " " || event.key === "Spacebar") {
|
|
event.preventDefault();
|
|
handleEntrySelection(entry, event as unknown as ReactMouseEvent<HTMLElement>);
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (entry.kind === "folder") activeSpace && openFolder(activeSpace.id, entry.path);else
|
|
if (canDownload) void downloadFile(settings, entry.file);
|
|
}
|
|
}
|
|
|
|
function dragSetsForEntry(entry: ExplorerEntry): SelectionSets {
|
|
const key = entrySelectionKey(entry);
|
|
return currentSelectionKeys().has(key) ? currentSelectionSets() : setsForEntry(entry);
|
|
}
|
|
|
|
function handleInternalDragStart(entry: ExplorerEntry, event: ReactDragEvent<HTMLElement>) {
|
|
if (!canOrganize || !activeSpace || activeSpaceIsConnector) {event.preventDefault();return;}
|
|
const sets = dragSetsForEntry(entry);
|
|
if (!currentSelectionKeys().has(entrySelectionKey(entry))) {
|
|
applySelectionKeys(new Set<EntrySelectionKey>([entrySelectionKey(entry)]));
|
|
setSelectionAnchor(entrySelectionKey(entry));
|
|
}
|
|
const state = { sourceSpaceId: activeSpace.id, fileIds: Array.from(sets.fileIds), folderPaths: Array.from(sets.folderPaths) };
|
|
setInternalDrag(state);
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state));
|
|
event.dataTransfer.setData("text/plain", `${state.fileIds.length + state.folderPaths.length} item(s)`);
|
|
}
|
|
|
|
function handleTreeFolderDragStart(spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) {
|
|
if (!canOrganize || isConnectorSpace(findSpace(spaceId))) {event.preventDefault();return;}
|
|
const folderPath = normalizeFolder(path);
|
|
if (!folderPath) return;
|
|
const state = { sourceSpaceId: spaceId, fileIds: [], folderPaths: [folderPath] };
|
|
setInternalDrag(state);
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state));
|
|
event.dataTransfer.setData("text/plain", folderPath);
|
|
}
|
|
|
|
function isInternalDrag(event: ReactDragEvent<HTMLElement>): boolean {
|
|
return event.dataTransfer.types.includes(INTERNAL_DRAG_TYPE) || Boolean(internalDrag);
|
|
}
|
|
|
|
function dropTargetId(target: FileActionTarget): string {
|
|
return `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
|
}
|
|
|
|
function handleDropTargetDragOver(event: ReactDragEvent<HTMLElement>, target: FileActionTarget) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (isConnectorSpace(findSpace(target.spaceId))) {
|
|
event.dataTransfer.dropEffect = "none";
|
|
setDropTargetKey("");
|
|
return;
|
|
}
|
|
if (isInternalDrag(event)) {
|
|
if (!canOrganize) {event.dataTransfer.dropEffect = "none";setDropTargetKey("");return;}
|
|
const state = internalDrag;
|
|
const mode = event.ctrlKey ? "copy" : "move";
|
|
const sets = state ? { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) } : null;
|
|
const reason = state ? invalidDropReason(state.sourceSpaceId, state.folderPaths, target) : null;
|
|
const noop = state && sets && mode === "move" ? transferWouldBeNoop(state.sourceSpaceId, sets, target) : false;
|
|
event.dataTransfer.dropEffect = reason || noop ? "none" : mode;
|
|
setDropTargetKey(reason || noop ? "" : dropTargetId(target));
|
|
return;
|
|
}
|
|
event.dataTransfer.dropEffect = canUpload && !busy && !uploadActive ? "copy" : "none";
|
|
setDropTargetKey(canUpload && !busy && !uploadActive ? dropTargetId(target) : "");
|
|
}
|
|
|
|
function handleCurrentFolderDragOver(event: ReactDragEvent<HTMLElement>) {
|
|
const target = toolbarTarget();
|
|
if (!target) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.dataTransfer.dropEffect = "none";
|
|
setDropTargetKey("");
|
|
return;
|
|
}
|
|
handleDropTargetDragOver(event, target);
|
|
}
|
|
|
|
async function handleDropOnTarget(event: ReactDragEvent<HTMLElement>, target: FileActionTarget) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setDragActive(false);
|
|
setDropTargetKey("");
|
|
const hasDroppedFiles = event.dataTransfer.files.length > 0;
|
|
if (isConnectorSpace(findSpace(target.spaceId))) {
|
|
if (hasDroppedFiles) setError(uploadRejectedReason(target));
|
|
return;
|
|
}
|
|
if (isInternalDrag(event)) {
|
|
if (!canOrganize) return;
|
|
const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE));
|
|
if (!state) return;
|
|
const reason = invalidDropReason(state.sourceSpaceId, state.folderPaths, target);
|
|
if (reason) {
|
|
setError(reason);
|
|
return;
|
|
}
|
|
await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target);
|
|
return;
|
|
}
|
|
if (hasDroppedFiles) {
|
|
if (!canUpload || busy || uploadActive) {
|
|
setError(uploadRejectedReason(target));
|
|
return;
|
|
}
|
|
await uploadExternalFilesToTarget(event.dataTransfer.files, target);
|
|
return;
|
|
}
|
|
setError("Drop one or more files from your computer, or drag files and folders from this file space.");
|
|
}
|
|
|
|
async function handleDropOnCurrentFolder(event: ReactDragEvent<HTMLElement>) {
|
|
const target = toolbarTarget();
|
|
if (!target) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setDropTargetKey("");
|
|
setError(uploadRejectedReason(null));
|
|
return;
|
|
}
|
|
await handleDropOnTarget(event, target);
|
|
}
|
|
|
|
function clearDropState() {
|
|
clearDragDropState();
|
|
cancelTreeDragExpand();
|
|
}
|
|
|
|
function spaceForContext(menu: ContextMenuState | null): FileSpace | null {
|
|
const spaceId = menu?.spaceId ?? activeSpaceId;
|
|
return findSpace(spaceId) ?? activeSpace;
|
|
}
|
|
|
|
function filesForContext(menu: ContextMenuState | null): ManagedFile[] {
|
|
const spaceId = menu?.spaceId ?? activeSpaceId;
|
|
return filesBySpace[spaceId] ?? files;
|
|
}
|
|
|
|
function folderEntryForPath(spaceId: string, path: string): FolderEntry {
|
|
const sourceFiles = filesBySpace[spaceId] ?? EMPTY_FILES;
|
|
const sourceFolders = foldersBySpace[spaceId] ?? EMPTY_FOLDERS;
|
|
return buildFolderEntryFromSources(sourceFiles, sourceFolders, path);
|
|
}
|
|
|
|
function selectedSetsForContext(menu: ContextMenuState | null): SelectionSets {
|
|
if (!menu) return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) };
|
|
if (menu.source === "tree") {
|
|
if (menu.entry?.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([menu.entry.path]) };
|
|
if (menu.folderPath !== undefined) {
|
|
const sourceFiles = filesForContext(menu);
|
|
const fileIds = new Set(sourceFiles.filter((file) => isFileInFolder(file, menu.folderPath ?? "")).map((file) => file.id));
|
|
return { fileIds, folderPaths: new Set() };
|
|
}
|
|
}
|
|
if (menu.target === "empty" || !menu.entry) {
|
|
return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) };
|
|
}
|
|
const key = entrySelectionKey(menu.entry);
|
|
if (currentSelectionKeys().has(key)) {
|
|
return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) };
|
|
}
|
|
return setsForEntry(menu.entry);
|
|
}
|
|
|
|
function openContextMenu(event: ReactMouseEvent<HTMLElement>, target: "empty" | "folder" | "file", entry?: ExplorerEntry) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (entry) {
|
|
const key = entrySelectionKey(entry);
|
|
const currentKeys = currentSelectionKeys();
|
|
if (!currentKeys.has(key)) {
|
|
applySelectionKeys(new Set<EntrySelectionKey>([key]));
|
|
setSelectionAnchor(key);
|
|
}
|
|
}
|
|
setContextMenu({ x: event.clientX, y: event.clientY, target, entry, source: "list", spaceId: activeSpaceId, folderPath: entry?.kind === "folder" ? entry.path : currentFolder });
|
|
}
|
|
|
|
function openTreeContextMenu(event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const normalizedPath = normalizeFolder(path);
|
|
const entry = normalizedPath ? folderEntryForPath(spaceId, normalizedPath) : undefined;
|
|
setContextMenu({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
target: entry ? "folder" : "empty",
|
|
entry,
|
|
source: "tree",
|
|
spaceId,
|
|
folderPath: normalizedPath
|
|
});
|
|
}
|
|
|
|
function openTreeEmptyContextMenu(event: ReactMouseEvent<HTMLElement>) {
|
|
event.preventDefault();
|
|
const target = toolbarTarget();
|
|
if (!target) return;
|
|
setContextMenu({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
target: "empty",
|
|
source: "tree",
|
|
spaceId: target.spaceId,
|
|
folderPath: target.folderPath
|
|
});
|
|
}
|
|
|
|
function contextActionTarget(menu: ContextMenuState | null): FileActionTarget | null {
|
|
const space = spaceForContext(menu);
|
|
if (!space) return null;
|
|
const folderPath = menu?.entry?.kind === "folder" ? menu.entry.path : menu?.folderPath ?? currentFolder;
|
|
return { spaceId: space.id, folderPath: normalizeFolder(folderPath) };
|
|
}
|
|
|
|
function openUploadDialogForContext(menu: ContextMenuState | null) {
|
|
const target = contextActionTarget(menu);
|
|
setContextMenu(null);
|
|
openDialog("upload", target);
|
|
}
|
|
|
|
function openCreateFolderDialogForContext(menu: ContextMenuState | null) {
|
|
const target = contextActionTarget(menu);
|
|
setContextMenu(null);
|
|
openDialog("create-folder", target);
|
|
}
|
|
|
|
async function downloadContextSelection(menu: ContextMenuState | null) {
|
|
const { fileIds, folderPaths } = selectedSetsForContext(menu);
|
|
const sourceFiles = filesForContext(menu);
|
|
setContextMenu(null);
|
|
await downloadSelection(fileIds, folderPaths, sourceFiles);
|
|
}
|
|
|
|
async function deleteContextSelection(menu: ContextMenuState | null) {
|
|
const { fileIds, folderPaths } = selectedSetsForContext(menu);
|
|
const space = spaceForContext(menu);
|
|
setContextMenu(null);
|
|
await deleteSelected(fileIds, folderPaths, space);
|
|
}
|
|
|
|
function accessTargetForSelection(fileIds: Set<string>, folderPaths: Set<string>, spaceId: string): FileAccessExplanationTarget | null {
|
|
if (fileIds.size === 1 && folderPaths.size === 0) {
|
|
const fileId = Array.from(fileIds)[0];
|
|
const file = filesInSpace(spaceId).find((item) => item.id === fileId);
|
|
return file ? {
|
|
resourceType: "file",
|
|
resourceId: file.id,
|
|
label: file.display_path || file.filename,
|
|
action: "files:file:read"
|
|
} : null;
|
|
}
|
|
if (fileIds.size === 0 && folderPaths.size === 1) {
|
|
const folderPath = normalizeFolder(Array.from(folderPaths)[0]);
|
|
const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath);
|
|
if (folder) {
|
|
return {
|
|
resourceType: "folder",
|
|
resourceId: folder.id,
|
|
label: folder.path,
|
|
action: "files:file:read"
|
|
};
|
|
}
|
|
const space = findSpace(spaceId);
|
|
const tenantId = (auth.active_tenant ?? auth.tenant)?.id;
|
|
if (!space || !tenantId || !folderPath) return null;
|
|
return {
|
|
resourceType: "folder",
|
|
resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }),
|
|
label: folderPath,
|
|
action: "files:file:read"
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null {
|
|
const { fileIds, folderPaths } = selectedSetsForContext(menu);
|
|
return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId);
|
|
}
|
|
|
|
function openAccessExplanationForContext(menu: ContextMenuState | null) {
|
|
const target = accessTargetForContext(menu);
|
|
setContextMenu(null);
|
|
if (target) void openAccessExplanation(target);
|
|
}
|
|
|
|
async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> {
|
|
const currentUserId = auth.principal?.membership_id || auth.user?.id;
|
|
if (!currentUserId) return;
|
|
setAccessExplanationTarget(target);
|
|
setResourceAccessExplanation(null);
|
|
setResourceAccessSubjects(null);
|
|
setResourceAccessLoading(true);
|
|
setError("");
|
|
try {
|
|
const subjects = await fetchResourceAccessExplanationSubjects(settings, {
|
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
|
});
|
|
const initialSubjectId = subjects.users.some((user) => user.id === currentUserId)
|
|
? currentUserId
|
|
: subjects.users[0]?.id;
|
|
if (!initialSubjectId) throw new Error("No permitted access-explanation subject is available.");
|
|
setResourceAccessSubjects(subjects);
|
|
setResourceAccessSubjectId(initialSubjectId);
|
|
setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
|
userId: initialSubjectId,
|
|
resourceType: target.resourceType,
|
|
resourceId: target.resourceId,
|
|
action: target.action,
|
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
|
}));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setAccessExplanationTarget(null);
|
|
} finally {
|
|
setResourceAccessLoading(false);
|
|
}
|
|
}
|
|
|
|
async function selectAccessExplanationSubject(userId: string): Promise<void> {
|
|
if (!accessExplanationTarget || userId === resourceAccessSubjectId) return;
|
|
setResourceAccessLoading(true);
|
|
setError("");
|
|
try {
|
|
const explanation = await fetchResourceAccessExplanation(settings, {
|
|
userId,
|
|
resourceType: accessExplanationTarget.resourceType,
|
|
resourceId: accessExplanationTarget.resourceId,
|
|
action: accessExplanationTarget.action,
|
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
|
});
|
|
setResourceAccessExplanation(explanation);
|
|
setResourceAccessSubjectId(userId);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setResourceAccessLoading(false);
|
|
}
|
|
}
|
|
|
|
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
|
|
const sets = selectedSetsForContext(menu);
|
|
const space = spaceForContext(menu);
|
|
const target = contextActionTarget(menu) ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
|
|
setContextMenu(null);
|
|
if (space) openTransferDialog(mode, space.id, sets, target ?? undefined);
|
|
}
|
|
|
|
function toolbarTarget(): FileActionTarget | null {
|
|
return activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null;
|
|
}
|
|
|
|
function parentFolder(path: string): string {
|
|
const parts = normalizeFilePath(path).split("/").filter(Boolean);
|
|
return normalizeFolder(parts.slice(0, -1).join("/"));
|
|
}
|
|
|
|
function filesInSpace(spaceId: string): ManagedFile[] {
|
|
return filesBySpace[spaceId] ?? EMPTY_FILES;
|
|
}
|
|
|
|
function foldersInSpace(spaceId: string): FileFolder[] {
|
|
return foldersBySpace[spaceId] ?? EMPTY_FOLDERS;
|
|
}
|
|
|
|
function pathExistsInTarget(target: FileActionTarget, path: string, options: {excludeFileId?: string;includeFolders?: boolean;} = {}): boolean {
|
|
const normalized = normalizeFilePath(path);
|
|
const targetFiles = filesInSpace(target.spaceId);
|
|
if (targetFiles.some((file) => file.id !== options.excludeFileId && normalizeFilePath(file.display_path) === normalized)) return true;
|
|
if (options.includeFolders && folderExistsInTarget(target, normalized)) return true;
|
|
return false;
|
|
}
|
|
|
|
function folderExistsInTarget(target: FileActionTarget, path: string): boolean {
|
|
const normalized = normalizeFolder(path);
|
|
return foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized);
|
|
}
|
|
|
|
function uploadConflicts(uploadedFiles: File[], target: FileActionTarget): FileConflictItem[] {
|
|
const conflicts: FileConflictItem[] = [];
|
|
const reserved = new Set<string>();
|
|
uploadedFiles.forEach((file, index) => {
|
|
const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.name));
|
|
if (pathExistsInTarget(target, targetPath, { includeFolders: true }) || reserved.has(targetPath)) {
|
|
conflicts.push({
|
|
id: `upload:${index}:${targetPath}`,
|
|
kind: "file",
|
|
label: file.name,
|
|
targetPath,
|
|
action: "rename",
|
|
newPath: nextClientAvailablePath(target, targetPath, reserved)
|
|
});
|
|
}
|
|
reserved.add(targetPath);
|
|
});
|
|
return conflicts;
|
|
}
|
|
|
|
function nextClientAvailablePath(target: FileActionTarget, desiredPath: string, reserved = new Set<string>()): string {
|
|
const desired = normalizeFilePath(desiredPath);
|
|
if (!reserved.has(desired) && !pathExistsInTarget(target, desired, { includeFolders: true })) return desired;
|
|
for (let counter = 1; counter < 10000; counter += 1) {
|
|
const candidate = candidateRenamedPath(desired, counter);
|
|
if (!reserved.has(candidate) && !pathExistsInTarget(target, candidate, { includeFolders: true })) return candidate;
|
|
}
|
|
return desired;
|
|
}
|
|
|
|
function transferWouldBeNoop(sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): boolean {
|
|
if (sourceSpaceId !== target.spaceId) return false;
|
|
const targetFolder = normalizeFolder(target.folderPath);
|
|
const sourceFiles = filesInSpace(sourceSpaceId);
|
|
const selectedItems = sets.fileIds.size + sets.folderPaths.size;
|
|
if (selectedItems === 0) return false;
|
|
for (const fileId of sets.fileIds) {
|
|
const file = sourceFiles.find((item) => item.id === fileId);
|
|
if (!file || parentFolder(file.display_path) !== targetFolder) return false;
|
|
}
|
|
for (const folderPath of sets.folderPaths) {
|
|
if (parentFolder(folderPath) !== targetFolder) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function transferConflicts(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): FileConflictItem[] {
|
|
const conflicts: FileConflictItem[] = [];
|
|
const sourceFiles = filesInSpace(sourceSpaceId);
|
|
const reserved = new Set<string>();
|
|
const maybeAddConflict = (id: string, kind: "file" | "folder", label: string, targetPath: string, excludeFileId?: string) => {
|
|
const normalized = kind === "folder" ? normalizeFolder(targetPath) : normalizeFilePath(targetPath);
|
|
const exists = kind === "folder" ?
|
|
foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized) :
|
|
pathExistsInTarget(target, normalized, { excludeFileId: mode === "move" && sourceSpaceId === target.spaceId ? excludeFileId : undefined });
|
|
if (exists || reserved.has(normalized)) {
|
|
conflicts.push({
|
|
id,
|
|
kind,
|
|
label,
|
|
targetPath: normalized,
|
|
action: "rename",
|
|
newPath: kind === "folder" ? nextClientAvailableFolderPath(target, normalized, reserved) : nextClientAvailablePath(target, normalized, reserved)
|
|
});
|
|
}
|
|
reserved.add(normalized);
|
|
};
|
|
|
|
for (const fileId of sets.fileIds) {
|
|
const file = sourceFiles.find((item) => item.id === fileId);
|
|
if (!file) continue;
|
|
const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.filename));
|
|
if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue;
|
|
maybeAddConflict(`file:${file.id}`, "file", file.filename, targetPath, file.id);
|
|
}
|
|
|
|
for (const folderPathRaw of sets.folderPaths) {
|
|
const folderPath = normalizeFolder(folderPathRaw);
|
|
const folderName = lastPathSegment(folderPath);
|
|
const targetFolderPath = normalizeFolder(joinFolder(target.folderPath, folderName));
|
|
if (mode === "move" && sourceSpaceId === target.spaceId && folderPath === targetFolderPath) continue;
|
|
maybeAddConflict(`folder:${folderPath}`, "folder", folderName, targetFolderPath);
|
|
for (const file of sourceFiles.filter((item) => isFileInFolder(item, folderPath))) {
|
|
const relative = normalizeFilePath(file.display_path).slice(folderPath.length + 1);
|
|
const targetPath = normalizeFilePath(joinFolder(targetFolderPath, relative));
|
|
if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue;
|
|
maybeAddConflict(`folder-file:${folderPath}:${file.id}`, "file", relative, targetPath, file.id);
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
function nextClientAvailableFolderPath(target: FileActionTarget, desiredPath: string, reserved = new Set<string>()): string {
|
|
const desired = normalizeFolder(desiredPath);
|
|
if (!reserved.has(desired) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === desired)) return desired;
|
|
for (let counter = 1; counter < 10000; counter += 1) {
|
|
const candidate = candidateRenamedPath(desired, counter);
|
|
if (!reserved.has(candidate) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === candidate)) return candidate;
|
|
}
|
|
return desired;
|
|
}
|
|
|
|
function updateConflictItem(id: string, patch: Partial<FileConflictItem>) {
|
|
setConflictDialog((current) => current ? { ...current, items: current.items.map((item) => item.id === id ? { ...item, ...patch } : item) } : current);
|
|
}
|
|
|
|
async function applyConflictDecision(strategy: ConflictStrategy | "review") {
|
|
const currentConflict = conflictDialog;
|
|
if (!currentConflict) return;
|
|
if (strategy === "review" && !currentConflict.review) {
|
|
setConflictDialog({ ...currentConflict, review: true });
|
|
return;
|
|
}
|
|
|
|
const reviewed = currentConflict.review || strategy === "review";
|
|
const conflictStrategy: ConflictStrategy = reviewed ? "reject" : strategy as ConflictStrategy;
|
|
const conflictResolutions: ConflictResolution[] | undefined = reviewed ?
|
|
currentConflict.items.map((item) => ({
|
|
target_path: item.targetPath,
|
|
action: item.action,
|
|
new_path: item.action === "rename" ? item.newPath : undefined
|
|
})) :
|
|
undefined;
|
|
|
|
setConflictDialog(null);
|
|
|
|
if (currentConflict.operation === "upload" && currentConflict.pendingUpload) {
|
|
const pending = currentConflict.pendingUpload;
|
|
let filesToUpload = pending.files;
|
|
let uploadResolutions = conflictResolutions;
|
|
if (reviewed) {
|
|
const skippedPaths = new Set(currentConflict.items.filter((item) => item.action === "skip").map((item) => item.targetPath));
|
|
filesToUpload = pending.files.filter((file) => !skippedPaths.has(normalizeFilePath(joinFolder(pending.target.folderPath, file.name))));
|
|
uploadResolutions = conflictResolutions?.filter((item) => item.action !== "skip");
|
|
}
|
|
if (filesToUpload.length === 0) {
|
|
closeDialog();
|
|
setMessage("i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5");
|
|
return;
|
|
}
|
|
await handleFilesUpload(filesToUpload, {
|
|
target: pending.target,
|
|
conflictStrategy,
|
|
conflictResolutions: uploadResolutions,
|
|
bypassConflictDialog: true
|
|
});
|
|
return;
|
|
}
|
|
if (currentConflict.operation === "transfer" && currentConflict.pendingTransfer) {
|
|
const pending = currentConflict.pendingTransfer;
|
|
await runTransfer(pending.mode, pending.sourceSpaceId, pending.sets, pending.target, {
|
|
conflictStrategy,
|
|
conflictResolutions,
|
|
bypassConflictDialog: true
|
|
});
|
|
}
|
|
}
|
|
|
|
function toggleSort(column: SortColumn) {
|
|
if (sortColumn === column) {
|
|
setSortDirection((current) => current === "asc" ? "desc" : "asc");
|
|
return;
|
|
}
|
|
setSortColumn(column);
|
|
setSortDirection("asc");
|
|
}
|
|
|
|
function sortLabel(column: SortColumn, label: string): string {
|
|
if (sortColumn !== column) return label;
|
|
return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`;
|
|
}
|
|
|
|
const noticeTone = uploadActive ? "info" : message.startsWith("i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped") ? "warning" :
|
|
message.startsWith("i18n:govoplan-files.uploading_value_file_s") ||
|
|
message === "i18n:govoplan-files.unpacking_zip_upload.35019691" ||
|
|
message === "i18n:govoplan-files.finalizing_upload.bcce936d" ? "info" : "success";
|
|
|
|
const managedSpaceBlocker = !activeSpace ? "Select a file space first." :
|
|
activeSpaceIsConnector ? "This action writes managed storage and is unavailable in a read-only connector space." : "";
|
|
const workingBlocker = busy ? "Wait for the current file operation to finish." : "";
|
|
const uploadBlocker = workingBlocker || managedSpaceBlocker || (!canUpload ? "File upload permission is required." : "");
|
|
const unpackBlocker = uploadBlocker || (!canDownload ? "i18n:govoplan-files.managed_archive.download_required" : "") || (!selectedArchive ? "i18n:govoplan-files.managed_archive.select_one" : "");
|
|
const organizeBlocker = workingBlocker || managedSpaceBlocker || (!canOrganize ? "File organization permission is required." : "");
|
|
const selectionBlocker = !hasSelection ? "Select at least one file or folder first." : "";
|
|
const downloadBlocker = workingBlocker || managedSpaceBlocker || (!canDownload ? "File download permission is required." : "") || (selectedDownloadFileIds.length === 0 ? "Select at least one downloadable file first." : "");
|
|
const shareBlocker = workingBlocker || managedSpaceBlocker || (!canShare ? "File sharing permission is required." : "") || (!shareManageableFile ? "Select one manageable file first." : "");
|
|
const accessExplanationBlocker = workingBlocker || (activeSpaceIsConnector ? "Access explanations apply to managed files and folders." : "") || (!canExplainResourceAccess ? "Permission to inspect resource access is required." : "") || (!accessExplainableTarget ? "Select one file or folder first." : "");
|
|
const deleteBlocker = workingBlocker || managedSpaceBlocker || (!canDelete ? "File deletion permission is required." : "") || selectionBlocker;
|
|
const syncBlocker = workingBlocker || (!activeSpace ? "Select a file space first." : "") || (!canUpload ? "File upload permission is required to import synchronized content." : "") || (activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file" ? "Select one remote file to synchronize." : "");
|
|
const folderSyncBlocker = workingBlocker || (!activeSpaceIsConnector || !activeSpace?.connector_space_id ? "i18n:govoplan-files.folder_sync.blocker.select_space" : "") || (!canUpload ? "i18n:govoplan-files.folder_sync.blocker.permission" : "") || (connectorSpaceLoading ? "i18n:govoplan-files.folder_sync.blocker.loading" : "");
|
|
const folderSyncSummaryRows = folderSyncResult ? [
|
|
["i18n:govoplan-files.folder_sync.summary.discovered", folderSyncResult.summary.discovered],
|
|
["i18n:govoplan-files.folder_sync.summary.created", folderSyncResult.summary.created],
|
|
["i18n:govoplan-files.folder_sync.summary.updated", folderSyncResult.summary.updated],
|
|
["i18n:govoplan-files.folder_sync.summary.unchanged", folderSyncResult.summary.unchanged],
|
|
["i18n:govoplan-files.folder_sync.summary.skipped", folderSyncResult.summary.skipped],
|
|
["i18n:govoplan-files.folder_sync.summary.conflicts", folderSyncResult.summary.conflicts],
|
|
["i18n:govoplan-files.folder_sync.summary.policy_denied", folderSyncResult.summary.policy_denied],
|
|
["i18n:govoplan-files.folder_sync.summary.failed", folderSyncResult.summary.failed]
|
|
] as const : [];
|
|
|
|
function chooseTool(action: () => void) {
|
|
setToolsPanel(null);
|
|
action();
|
|
}
|
|
|
|
const toolbar = <WorkspaceActionBar
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
label="i18n:govoplan-files.file_actions.9e1b94c5"
|
|
interfaceId="files.workspace.actions"
|
|
helpContextId="files.list"
|
|
helpModuleId="files"
|
|
reloadAction={{ onReload: () => void reloadCurrentView(), loading: reloadingView, state: viewReloadFailed ? "reload-failed" : "current", disabled: busy || connectorSpaceLoading, disabledReason: workingBlocker || (connectorSpaceLoading ? "This connector space is already refreshing." : undefined) }}
|
|
contextActions={<Button onClick={() => setToolsPanel("connections")} disabled={busy} disabledReason={workingBlocker}><Settings2 size={16} aria-hidden="true" /> i18n:govoplan-files.tools.connections</Button>}
|
|
helpAction={<DocumentationHelpLink reference={FILES_WORKFLOW_DOCUMENTATION} />}
|
|
createAction={<>
|
|
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={Boolean(organizeBlocker)} disabledReason={organizeBlocker}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
|
|
<Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={Boolean(uploadBlocker)} disabledReason={uploadBlocker}><UploadCloud size={16} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</Button>
|
|
</>}
|
|
/>;
|
|
|
|
const selectionToolbar = <WorkspaceActionBar
|
|
scope="detail-pane"
|
|
variant="detail"
|
|
label="i18n:govoplan-files.tools.selection"
|
|
contextActions={<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.name || translateText("i18n:govoplan-files.no_file_selected.f76f1c1c") : selectedSummary}</span>}
|
|
primaryActions={<>
|
|
<Button onClick={() => void downloadSelection()} disabled={Boolean(downloadBlocker)} disabledReason={downloadBlocker}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
|
|
{selectedArchive && <Button onClick={() => openManagedArchive(selectedArchive, toolbarTarget())} disabled={Boolean(unpackBlocker)} disabledReason={unpackBlocker}><Archive size={16} aria-hidden="true" /> i18n:govoplan-files.managed_archive.unpack</Button>}
|
|
<Button onClick={() => setToolsPanel("selection")} disabled={Boolean(workingBlocker || managedSpaceBlocker || selectionBlocker)} disabledReason={workingBlocker || managedSpaceBlocker || selectionBlocker}><Settings2 size={16} aria-hidden="true" /> i18n:govoplan-files.tools.selection</Button>
|
|
</>}
|
|
/>;
|
|
|
|
|
|
const selectedArchiveBytes = archivePreview?.entries.reduce((total, entry) => total + (entry.kind === "file" && selectedArchivePaths.has(entry.path) ? entry.size_bytes : 0), 0) ?? 0;
|
|
const operationBusyLabel = uploadPhase === "inspecting" ? "i18n:govoplan-files.archive_progress.inspecting"
|
|
: uploadPhase === "finalizing" ? "i18n:govoplan-files.archive_progress.finalizing"
|
|
: uploadPhase === "unpacking" ? (archiveProgress?.phase === "storing" ? "i18n:govoplan-files.archive_progress.storing" : "i18n:govoplan-files.archive_progress.extracting")
|
|
: "i18n:govoplan-files.uploading_files.6536791d";
|
|
const serverProgressRatio = archiveProgress && (archiveProgress.total_bytes > 0
|
|
? archiveProgress.completed_bytes / archiveProgress.total_bytes
|
|
: archiveProgress.total_files > 0 ? archiveProgress.completed_files / archiveProgress.total_files : null);
|
|
// Complete bytes/files do not imply a committed transaction. Keep finalization indeterminate.
|
|
const operationProgressValue = archiveProgress?.status === "complete" ? 100
|
|
: archiveProgress && archiveProgress.phase !== "finalizing" && serverProgressRatio !== null && serverProgressRatio < 1 ? serverProgressRatio * 100
|
|
: uploadPhase === "uploading" && uploadProgress !== null && uploadProgress < 100 ? uploadProgress
|
|
: null;
|
|
const operationProgressLabel = archiveProgress && archiveProgress.total_files > 0
|
|
? i18nMessage("i18n:govoplan-files.archive_progress.processed", { completed: archiveProgress.completed_files, total: archiveProgress.total_files, bytes: formatBytes(archiveProgress.completed_bytes), totalBytes: formatBytes(archiveProgress.total_bytes) })
|
|
: uploadPhase === "uploading" && uploadProgress !== null && uploadProgress < 100
|
|
? i18nMessage("i18n:govoplan-files.archive_progress.transferred", { percentage: Math.floor(uploadProgress) })
|
|
: archivePreview && selectedArchivePaths.size > 0
|
|
? i18nMessage("i18n:govoplan-files.archive_progress.selected", { total: selectedArchivePaths.size, bytes: formatBytes(selectedArchiveBytes) })
|
|
: "i18n:govoplan-files.archive_progress.waiting";
|
|
const connectorLocationLabel = activeConnectorProfile ?
|
|
[activeConnectorProfile.label, connectorLibraryId, connectorPath || (connectorLibraryId ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") :
|
|
"i18n:govoplan-files.connector.ba358306";
|
|
const connectorParentDisabled = !connectorProfileId || connectorLoading || busy || !connectorPath && !connectorLibraryId;
|
|
const activeConnectorSpaceLibrary = activeSpace && activeSpaceIsConnector ? connectorSpaceLibrary(activeSpace) : null;
|
|
const connectorSpaceLocationLabel = activeSpace && activeSpaceIsConnector ?
|
|
[activeSpace.label, activeConnectorSpaceLibrary, currentFolder || (activeConnectorSpaceLibrary ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") :
|
|
"";
|
|
const connectorSpaceParentDisabled = !activeSpace || !activeSpaceIsConnector || connectorSpaceLoading || busy || !currentFolder && !activeConnectorSpaceLibrary;
|
|
|
|
function renderExplorerEntry(entry: ExplorerEntry, rowIndex: number) {
|
|
const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null;
|
|
const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false;
|
|
|
|
return entry.kind === "folder" ?
|
|
<div
|
|
key={entry.id}
|
|
className={`file-list-row folder-row ${isEntrySelected(entry) ? "is-selected" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
|
role="row"
|
|
aria-rowindex={rowIndex}
|
|
tabIndex={0}
|
|
draggable
|
|
onDragStart={(event) => handleInternalDragStart(entry, event)}
|
|
onDragEnd={() => {setInternalDrag(null);clearDropState();}}
|
|
onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)}
|
|
onDragLeave={clearDropState}
|
|
onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)}
|
|
onMouseDown={preventTextSelectionOnShift}
|
|
onClick={(event) => handleEntrySelection(entry, event)}
|
|
onDoubleClick={() => {if (!busy && activeSpace) openFolder(activeSpace.id, entry.path);}}
|
|
onContextMenu={(event) => openContextMenu(event, "folder", entry)}
|
|
onKeyDown={(event) => handleEntryKeyDown(entry, event)}>
|
|
|
|
<div className="file-list-name-cell">
|
|
<div className="file-list-name">
|
|
<Folder className="file-row-icon" size={20} aria-hidden="true" />
|
|
<span><strong>{entry.name}</strong><small>{folderContentLabel(entry)}</small></span>
|
|
</div>
|
|
</div>
|
|
<span>{formatBytes(entry.totalSize)}</span>
|
|
<span className="file-row-tail"><span>{formatDate(entry.updatedAt)}</span></span>
|
|
</div> :
|
|
|
|
<div
|
|
key={entry.id}
|
|
className={`file-list-row file-row ${isEntrySelected(entry) ? "is-selected" : ""}`}
|
|
role="row"
|
|
aria-rowindex={rowIndex}
|
|
tabIndex={0}
|
|
draggable
|
|
onDragStart={(event) => handleInternalDragStart(entry, event)}
|
|
onDragEnd={() => {setInternalDrag(null);clearDropState();}}
|
|
onMouseDown={preventTextSelectionOnShift}
|
|
onClick={(event) => handleEntrySelection(entry, event)}
|
|
onDoubleClick={() => {if (!busy) void downloadFile(settings, entry.file);}}
|
|
onContextMenu={(event) => openContextMenu(event, "file", entry)}
|
|
onKeyDown={(event) => handleEntryKeyDown(entry, event)}>
|
|
|
|
<div className="file-list-name-cell">
|
|
<div className="file-list-name">
|
|
<File className="file-row-icon" size={20} aria-hidden="true" />
|
|
<span>
|
|
<strong>{searchActive ? entry.file.display_path : entry.file.filename}</strong>
|
|
<small
|
|
className={`file-linkage-status ${activeCampaignShareCount(entry.file) > 0 ? "is-linked" : ""}`}
|
|
title={campaignShareTitle(entry.file)}>
|
|
|
|
{activeCampaignShareCount(entry.file) > 0 && <Link2 size={12} aria-hidden="true" />}
|
|
{campaignShareLabel(entry.file)}
|
|
</small>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span>{formatBytes(entry.file.size_bytes)}</span>
|
|
<span className="file-row-tail"><span>{formatDate(entry.file.updated_at)}</span></span>
|
|
</div>;
|
|
|
|
}
|
|
|
|
function renderConnectorSpaceContent() {
|
|
if (!activeSpace || !activeSpaceIsConnector) return null;
|
|
return (
|
|
<div className="connector-space-panel">
|
|
<div className="connector-browser connector-space-browser">
|
|
<ActionToolbar className="connector-browser-toolbar">
|
|
<Button onClick={browseConnectorSpaceParent} disabled={connectorSpaceParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
|
|
<span className="connector-browser-path" title={connectorSpaceLocationLabel}>{connectorSpaceLocationLabel}</span>
|
|
</ActionToolbar>
|
|
{connectorSpaceError && <p className="field-error connector-browser-error">{connectorSpaceError}</p>}
|
|
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_space_files.dbb0ab24">
|
|
{connectorSpaceLoading &&
|
|
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_space.c176e6b8" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_space.356d83c6")}</span></div>
|
|
}
|
|
{!connectorSpaceLoading && activeConnectorSpaceItems.length === 0 &&
|
|
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
|
|
}
|
|
{!connectorSpaceLoading && activeConnectorSpaceItems.map((item) => {
|
|
const isSelected = connectorSpaceSelectedItem?.kind === "file" && item.kind === "file" && connectorSpaceSelectedItem.path === item.path;
|
|
const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes);
|
|
const itemModified = item.modified_at ? formatDate(item.modified_at) : "";
|
|
const meta = item.kind === "file" ?
|
|
[itemSize, itemModified].filter(Boolean).join(" · ") :
|
|
item.kind === "library" ?
|
|
"i18n:govoplan-files.library.b8100f5b" :
|
|
"i18n:govoplan-files.folder.30baa249";
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={`${item.kind}:${item.external_id || item.path}`}
|
|
className={`connector-browser-row ${isSelected ? "is-selected" : ""}`}
|
|
onClick={() => openConnectorSpaceItem(item)}
|
|
onDoubleClick={() => {
|
|
if (item.kind === "file") void syncConnectorSpaceSelection(item);
|
|
}}
|
|
disabled={busy}>
|
|
|
|
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
|
|
<span>
|
|
<strong>{item.name}</strong>
|
|
<small>{meta}</small>
|
|
</span>
|
|
</button>);
|
|
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="connector-sync-selection">
|
|
<strong>{connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"}</strong>
|
|
<small>
|
|
{connectorSpaceSelectedItem?.kind === "file" ? i18nMessage("i18n:govoplan-files.value_value.598aab59", { value0:
|
|
connectorSpaceSelectedItem.path, value1: parentFolderPath(connectorSpaceRelativePath(activeSpace, connectorSpaceSelectedItem.path)) || "i18n:govoplan-files.root.e96857c5" }) :
|
|
"i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae"}
|
|
</small>
|
|
</div>
|
|
</div>);
|
|
|
|
}
|
|
|
|
const currentFolderDropTarget = toolbarTarget();
|
|
const currentFolderDropActive = currentFolderDropTarget ? dropTargetKey === dropTargetId(currentFolderDropTarget) : false;
|
|
|
|
return (
|
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="file-manager-page files-page" label="i18n:govoplan-files.files.6ce6c512" interfaceId="files.workspace" helpContextId="files.list" helpModuleId="files">
|
|
{toolbar}
|
|
<Dialog
|
|
open={toolsPanel !== null}
|
|
title={toolsPanel === "selection" ? "i18n:govoplan-files.tools.selection" : "i18n:govoplan-files.tools.connections"}
|
|
description={toolsPanel === "selection" ? selectedSummary : "i18n:govoplan-files.tools.connections_description"}
|
|
size="wide"
|
|
onClose={() => setToolsPanel(null)}
|
|
closeDisabled={busy}
|
|
footer={<Button onClick={() => setToolsPanel(null)} disabled={busy}>i18n:govoplan-files.close.bbfa773e</Button>}
|
|
>
|
|
{toolsPanel === "selection" ? <ContentGrid columns={1}>
|
|
<FormSection title="i18n:govoplan-files.tools.organize" description="i18n:govoplan-files.tools.organize_description">
|
|
<ActionToolbar>
|
|
<Button onClick={() => chooseTool(() => openTransferDialog("move"))} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
|
|
<Button onClick={() => chooseTool(() => openTransferDialog("copy"))} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
|
|
<Button onClick={() => chooseTool(openRenameDialog)} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>
|
|
</ActionToolbar>
|
|
</FormSection>
|
|
<FormSection title="i18n:govoplan-files.tools.sharing_access" variant="separated">
|
|
<ActionToolbar>
|
|
<Button onClick={() => chooseTool(() => { if (shareManageableFile) setShareDialogFile(shareManageableFile); })} disabled={Boolean(shareBlocker)} disabledReason={shareBlocker}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
|
|
<Button onClick={() => chooseTool(() => { if (accessExplainableTarget) void openAccessExplanation(accessExplainableTarget); })} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
|
|
</ActionToolbar>
|
|
</FormSection>
|
|
<FormSection title="i18n:govoplan-files.tools.destructive" description="i18n:govoplan-files.tools.delete_description" variant="separated">
|
|
<WorkspaceActionBar scope="detail-pane" variant="detail" label="i18n:govoplan-files.tools.destructive" destructiveActions={<Button variant="danger" helpContextId="files.list" helpModuleId="files" onClick={() => chooseTool(() => void deleteSelected())} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>} />
|
|
</FormSection>
|
|
</ContentGrid> : <ContentGrid columns={1}>
|
|
<FormSection title="i18n:govoplan-files.tools.import_sync" description="i18n:govoplan-files.tools.import_sync_description">
|
|
<ActionToolbar>
|
|
<Button onClick={() => chooseTool(() => { if (activeSpaceIsConnector) void syncConnectorSpaceSelection(); else void openConnectorSyncDialog(toolbarTarget()); })} disabled={Boolean(syncBlocker)} disabledReason={syncBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309</Button>
|
|
{activeSpaceIsConnector && <Button onClick={() => chooseTool(openConnectorFolderSyncDialog)} disabled={Boolean(folderSyncBlocker)} disabledReason={folderSyncBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.folder_sync.button</Button>}
|
|
</ActionToolbar>
|
|
</FormSection>
|
|
<FormSection title="i18n:govoplan-files.tools.spaces" variant="separated">
|
|
<ActionToolbar><Button onClick={() => chooseTool(() => void openConnectorSpaceDialog())} disabled={busy || !canOrganize} disabledReason={workingBlocker || (!canOrganize ? "File organization permission is required to add a connector space." : "")}><Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4</Button></ActionToolbar>
|
|
</FormSection>
|
|
{activeSpaceIsConnector && <FormSection title="i18n:govoplan-files.tools.destructive" description="i18n:govoplan-files.tools.remove_space_description" variant="separated">
|
|
<WorkspaceActionBar scope="detail-pane" variant="detail" label="i18n:govoplan-files.tools.destructive" destructiveActions={<Button variant="danger" onClick={() => chooseTool(() => { if (activeSpace) setConnectorSpaceRemovalTarget(activeSpace); })} disabled={busy || !canOrganize || !activeSpace?.connector_space_id} disabledReason={workingBlocker || (!canOrganize ? "File organization permission is required to remove a connector space." : !activeSpace?.connector_space_id ? "This space is not a removable connector space." : "")}><Trash2 size={16} aria-hidden="true" /> Remove space</Button>} />
|
|
</FormSection>}
|
|
</ContentGrid>}
|
|
</Dialog>
|
|
{error &&
|
|
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
|
|
}
|
|
{message && !error &&
|
|
<DismissibleAlert tone={noticeTone} resetKey={message} floating>{message}</DismissibleAlert>
|
|
}
|
|
|
|
<div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
|
|
<aside className="file-tree-panel" aria-label="i18n:govoplan-files.file_spaces_and_folders.443d2056">
|
|
<div className="file-tree-heading">i18n:govoplan-files.spaces.9b584b52</div>
|
|
<div className="file-tree-list" onContextMenu={openTreeEmptyContextMenu}>
|
|
{spaces.length === 0 && <p className="muted small-text">i18n:govoplan-files.no_file_spaces_available.a81fa3d0</p>}
|
|
{spaces.map((space) => {
|
|
const isActiveRoot = activeSpaceId === space.id && currentFolder === "";
|
|
const spaceIsConnector = isConnectorSpace(space);
|
|
const nodes = spaceIsConnector ? [] : buildFolderTree(filesBySpace[space.id] ?? EMPTY_FILES, foldersBySpace[space.id] ?? EMPTY_FOLDERS);
|
|
const rootTarget = { spaceId: space.id, folderPath: "" };
|
|
const rootDropActive = dropTargetKey === dropTargetId(rootTarget);
|
|
return (
|
|
<div className="file-tree-space" key={space.id}>
|
|
<button
|
|
type="button"
|
|
className={`file-tree-node file-tree-root ${isActiveRoot ? "is-active" : ""} ${rootDropActive && !spaceIsConnector ? "is-drop-target" : ""}`}
|
|
onClick={() => openFolder(space.id, "")}
|
|
onContextMenu={(event) => {if (!spaceIsConnector) openTreeContextMenu(event, space.id, "");}}
|
|
onDragOver={(event) => {if (!spaceIsConnector) handleDropTargetDragOver(event, rootTarget);}}
|
|
onDragLeave={clearDropState}
|
|
onDrop={(event) => {if (!spaceIsConnector) void handleDropOnTarget(event, rootTarget);}}
|
|
disabled={busy}>
|
|
|
|
{spaceIsConnector ? <Link2 size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
|
|
<span>{space.label}</span>
|
|
</button>
|
|
{!spaceIsConnector &&
|
|
<FolderTree
|
|
nodes={nodes}
|
|
activeSpaceId={activeSpaceId}
|
|
spaceId={space.id}
|
|
currentFolder={currentFolder}
|
|
dropTargetKey={dropTargetKey}
|
|
onOpen={openFolder}
|
|
expandedKeys={expandedTreeNodes}
|
|
onToggle={toggleTreeFolder}
|
|
onContextMenu={openTreeContextMenu}
|
|
onDragOverTarget={handleDropTargetDragOver}
|
|
onDropOnTarget={handleDropOnTarget}
|
|
onClearDropState={clearDropState}
|
|
onRequestDragExpand={scheduleTreeDragExpand}
|
|
onDragStartFolder={handleTreeFolderDragStart}
|
|
onDragEndFolder={() => {setInternalDrag(null);clearDropState();}}
|
|
disabled={busy} />
|
|
|
|
}
|
|
</div>);
|
|
|
|
})}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="file-list-panel" aria-label="i18n:govoplan-files.current_folder_contents.f9a24fa8">
|
|
<div className="file-list-sticky">
|
|
{selectionToolbar}
|
|
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-files.current_folder.5aeab2f0">
|
|
<button type="button" className="file-breadcrumb" onClick={() => activeSpace && openFolder(activeSpace.id, "")} disabled={busy || !activeSpace}>
|
|
<Home size={15} aria-hidden="true" /> {activeSpace?.label || "i18n:govoplan-files.files.6ce6c512"}
|
|
</button>
|
|
{folderCrumbs.map((crumb) =>
|
|
<span className="file-breadcrumb-segment" key={crumb.path}>
|
|
<ChevronRight size={14} aria-hidden="true" />
|
|
<button type="button" className="file-breadcrumb" onClick={() => activeSpace && openFolder(activeSpace.id, crumb.path)} disabled={busy}>{crumb.name}</button>
|
|
</span>
|
|
)}
|
|
</nav>
|
|
<FileSearchRow
|
|
value={searchPattern}
|
|
caseSensitive={searchCaseSensitive}
|
|
busy={busy}
|
|
searchActive={searchActive}
|
|
disabled={!activeSpace || activeSpaceIsConnector}
|
|
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
|
|
onClear={() => void clearSearch()} />
|
|
<FilePropertyFilterRow
|
|
campaignUsage={campaignUsageFilter}
|
|
auditRelevant={auditRelevantFilter}
|
|
busy={busy}
|
|
active={propertyFiltersActive}
|
|
disabled={!activeSpace || activeSpaceIsConnector}
|
|
total={propertyFilterTotal}
|
|
onApply={(campaignUsage, auditRelevant) => void runPropertyFilters(campaignUsage, auditRelevant)}
|
|
onClear={clearPropertyFilters} />
|
|
|
|
<div className="file-list-meta">
|
|
<span>
|
|
{activeSpaceIsConnector ? i18nMessage("i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c", { value0:
|
|
activeConnectorSpaceItems.filter((item) => item.kind === "folder" || item.kind === "library").length, value1: activeConnectorSpaceItems.filter((item) => item.kind === "file").length }) : i18nMessage("i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c", { value0:
|
|
explorerEntries.filter((entry) => entry.kind === "folder").length, value1: explorerEntries.filter((entry) => entry.kind === "file").length })}
|
|
</span>
|
|
{activeSpaceIsConnector &&
|
|
<span>{[activeSpace?.provider?.toUpperCase() || "i18n:govoplan-files.connector.ba358306", activeSpace?.read_only ? "i18n:govoplan-files.read_only.9b19a5a2" : "i18n:govoplan-files.writable.dd35487a", `${activeSpace?.sync_mode || "manual"} sync`].join(" · ")}</span>
|
|
}
|
|
{busy && <span>i18n:govoplan-files.working.13b7bfca</span>}
|
|
{!activeSpaceIsConnector && unmatchedCount !== null && <span>{unmatchedCount} i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90</span>}
|
|
{!activeSpaceIsConnector && selectedFiles.some((file) => file.audit_relevant) && <span>i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543</span>}
|
|
</div>
|
|
{!activeSpaceIsConnector &&
|
|
<div className="file-list-table-head" role="row">
|
|
<button type="button" onClick={() => toggleSort("name")}>{sortLabel("name", "i18n:govoplan-files.name.709a2322")}</button>
|
|
<button type="button" onClick={() => toggleSort("size")}>{sortLabel("size", "i18n:govoplan-files.size.b7152342")}</button>
|
|
<button type="button" onClick={() => toggleSort("modified")}>{sortLabel("modified", "i18n:govoplan-files.modified.19a532c8")}</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
{activeSpaceIsConnector ?
|
|
renderConnectorSpaceContent() :
|
|
|
|
<div
|
|
className={`file-list-drop-target ${currentFolderDropActive ? "is-drop-target" : ""}`}
|
|
ref={fileListViewportRef}
|
|
onScroll={(event) => setFileListScrollTop(event.currentTarget.scrollTop)}
|
|
onDragOver={handleCurrentFolderDragOver}
|
|
onDragLeave={clearDropState}
|
|
onDrop={(event) => void handleDropOnCurrentFolder(event)}
|
|
onContextMenu={(event) => openContextMenu(event, "empty")}
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>());
|
|
}}>
|
|
|
|
<div className="file-list-table" role="table" aria-label="i18n:govoplan-files.managed_files.4dd6ad11" aria-rowcount={explorerEntries.length + 1}>
|
|
{(() => {
|
|
const normalizedCurrentFolder = normalizeFolder(currentFolder);
|
|
const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace);
|
|
const targetParentFolder = parentFolderPath(normalizedCurrentFolder);
|
|
return (
|
|
<div
|
|
ref={fileListMeasureRowRef}
|
|
className={`file-list-row folder-row file-parent-row ${canOpenParent ? "" : "is-disabled"}`}
|
|
role="row"
|
|
aria-rowindex={1}
|
|
tabIndex={canOpenParent ? 0 : -1}
|
|
aria-disabled={!canOpenParent}
|
|
aria-label={canOpenParent ? i18nMessage("i18n:govoplan-files.open_parent_folder_value.8030a7c7", { value0: targetParentFolder || activeSpace?.label || "root" }) : "i18n:govoplan-files.already_at_the_root_folder.1238f110"}
|
|
onMouseDown={preventTextSelectionOnShift}
|
|
onClick={() => {if (canOpenParent) applySelectionKeys(new Set<EntrySelectionKey>());}}
|
|
onDoubleClick={() => {
|
|
if (canOpenParent && activeSpace && !busy) openFolder(activeSpace.id, targetParentFolder);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && canOpenParent && activeSpace && !busy) {
|
|
event.preventDefault();
|
|
openFolder(activeSpace.id, targetParentFolder);
|
|
}
|
|
}}>
|
|
|
|
<div className="file-list-name-cell">
|
|
<div className="file-list-name">
|
|
<Folder className="file-row-icon" size={20} aria-hidden="true" />
|
|
<span><strong>..</strong><small>i18n:govoplan-files.parent_folder.d8ed4c2a</small></span>
|
|
</div>
|
|
</div>
|
|
<span aria-hidden="true">-</span>
|
|
<span className="file-row-tail" aria-hidden="true"><span>-</span></span>
|
|
</div>);
|
|
|
|
})()}
|
|
{explorerEntries.length === 0 &&
|
|
<div className="file-list-empty">i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784</div>
|
|
}
|
|
{virtualExplorerRows.topSpacerHeight > 0 &&
|
|
<div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.topSpacerHeight }} aria-hidden="true" />
|
|
}
|
|
{virtualExplorerRows.entries.map((entry, index) => renderExplorerEntry(entry, virtualExplorerRows.startIndex + index + 2))}
|
|
{virtualExplorerRows.bottomSpacerHeight > 0 &&
|
|
<div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.bottomSpacerHeight }} aria-hidden="true" />
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</section>
|
|
{busy &&
|
|
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
|
<div className="loading-frame-panel"><LoadingIndicator label="i18n:govoplan-files.loading_files.3b142382" /> <span>{i18nMessage("i18n:govoplan-files.loading_files.bfd27a7e")}</span></div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
{conflictDialog &&
|
|
<FileConflictDialog
|
|
state={conflictDialog}
|
|
busy={busy}
|
|
onClose={() => setConflictDialog(null)}
|
|
onCancel={() => setConflictDialog(null)}
|
|
onOverwrite={() => void applyConflictDecision("overwrite")}
|
|
onRename={() => void applyConflictDecision("rename")}
|
|
onReview={() => void applyConflictDecision("review")}
|
|
onApplyReview={() => void applyConflictDecision("review")}
|
|
onUpdateItem={updateConflictItem} />
|
|
|
|
}
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteDialog)}
|
|
title={deleteDialog?.title || "i18n:govoplan-files.delete_selected_item_s.4b6a0023"}
|
|
message={deleteDialog?.message || "i18n:govoplan-files.delete_selected_item_s.4b6a0023"}
|
|
confirmLabel="i18n:govoplan-files.delete.f6fdbe48"
|
|
cancelLabel="i18n:govoplan-files.cancel.77dfd213"
|
|
tone="danger"
|
|
busy={busy}
|
|
onConfirm={() => void performConfirmedDelete()}
|
|
onCancel={() => setDeleteDialog(null)} />
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(connectorSpaceRemovalTarget)}
|
|
title="Remove connector space?"
|
|
message={connectorSpaceRemovalTarget ? `Remove “${connectorSpaceRemovalTarget.label}” from Files? This retires only the local connector-space link. Remote files and folders remain at the provider. Imported GovOPlaN files, metadata and shares remain managed in their owner spaces. The connector profile, credentials and remote references are not deleted and can be linked again.` : ""}
|
|
confirmLabel="Remove space"
|
|
cancelLabel="i18n:govoplan-files.cancel.77dfd213"
|
|
tone="danger"
|
|
busy={busy}
|
|
onConfirm={() => void removeConnectorSpace()}
|
|
onCancel={() => setConnectorSpaceRemovalTarget(null)} />
|
|
|
|
|
|
{contextMenu &&
|
|
<FileContextMenu
|
|
menu={contextMenu}
|
|
hasSelection={selectedSetsForContext(contextMenu).fileIds.size > 0 || selectedSetsForContext(contextMenu).folderPaths.size > 0}
|
|
canCreateFolder={canOrganize}
|
|
canUpload={canUpload}
|
|
canDownload={canDownload}
|
|
canOrganize={canOrganize}
|
|
canDelete={canDelete}
|
|
canExplainAccess={canExplainResourceAccess && Boolean(accessTargetForContext(contextMenu))}
|
|
downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)}
|
|
onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)}
|
|
onUpload={() => openUploadDialogForContext(contextMenu)}
|
|
canUnpackArchive={!busy && canUpload && canDownload && !isConnectorSpace(findSpace(contextMenu.spaceId ?? activeSpaceId)) && contextMenu.entry?.kind === "file" && ARCHIVE_FILENAME_PATTERN.test(contextMenu.entry.file.filename)}
|
|
onUnpackArchive={() => contextMenu.entry?.kind === "file" && openManagedArchive(contextMenu.entry.file, contextActionTarget(contextMenu))}
|
|
onDownload={() => void downloadContextSelection(contextMenu)}
|
|
onMove={() => openTransferDialogForContext(contextMenu, "move")}
|
|
onCopy={() => openTransferDialogForContext(contextMenu, "copy")}
|
|
onExplainAccess={() => openAccessExplanationForContext(contextMenu)}
|
|
onDelete={() => void deleteContextSelection(contextMenu)} />
|
|
|
|
}
|
|
|
|
{accessExplanationTarget &&
|
|
<FileDialog title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);setResourceAccessSubjects(null);setResourceAccessSubjectId("");}}}>
|
|
<ResourceAccessExplanation
|
|
loading={resourceAccessLoading}
|
|
explanation={resourceAccessExplanation}
|
|
subjects={resourceAccessSubjects}
|
|
selectedUserId={resourceAccessSubjectId}
|
|
onSelectedUserIdChange={(userId) => void selectAccessExplanationSubject(userId)}
|
|
fallbackResourceLabel={accessExplanationTarget.label} />
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);setResourceAccessSubjects(null);setResourceAccessSubjectId("");}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{shareDialogFile &&
|
|
<FileShareDialog
|
|
settings={settings}
|
|
file={shareDialogFile}
|
|
tenantId={auth.active_tenant?.id || auth.tenant.id}
|
|
onClose={() => setShareDialogFile(null)}
|
|
onChanged={() => {
|
|
if (activeSpace) void loadSpaceContents(activeSpace, { silent: true });
|
|
}} />
|
|
}
|
|
|
|
{dialog === "upload" &&
|
|
<FileDialog title={managedArchiveFile ? "i18n:govoplan-files.managed_archive.unpack" : i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} busy={busy || uploadActive} onClose={() => { if (!busy) closeDialog(); }}>
|
|
<LoadingFrame loading={uploadActive} label={operationBusyLabel} indicator="none" progress={operationProgressValue} progressLabel={operationProgressLabel}>
|
|
<div inert={uploadActive}>
|
|
{managedArchiveFile && <p className="form-help">{i18nMessage("i18n:govoplan-files.managed_archive.source", { value0: managedArchiveFile.filename })}</p>}
|
|
{managedArchiveFile && <p className="form-help">i18n:govoplan-files.managed_archive.protection</p>}
|
|
{managedArchiveFile && <DocumentationHelpLink reference={{ topicId: "files.workflow.unpack-managed-archive", documentationType: "user" }} />}
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{!archivePreview &&
|
|
<>
|
|
{!managedArchiveFile && <ToggleSwitch
|
|
label="Preview and unpack archive"
|
|
checked={unpackZip}
|
|
onChange={setUnpackZip}
|
|
disabled={busy} />}
|
|
|
|
{managedArchiveFile && <FormField label="i18n:govoplan-files.destination_space.92b63970">
|
|
<select value={activeDialogSpace?.id || ""} disabled={busy} onChange={(event) => {
|
|
updateActiveDialogFolder(event.target.value, "");
|
|
const space = findSpace(event.target.value);
|
|
if (space) void loadSpaceContents(space, { silent: true });
|
|
}}>
|
|
{spaces.filter((space) => !isConnectorSpace(space)).map((space) => <option key={space.id} value={space.id}>{space.label}</option>)}
|
|
</select>
|
|
</FormField>}
|
|
|
|
{unpackZip &&
|
|
<p className="form-help archive-upload-help">
|
|
Supports ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ. Nothing is written until you review and confirm the archive contents.
|
|
</p>
|
|
}
|
|
<div className="field-block">
|
|
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
|
|
<TransferFolderSelector
|
|
space={activeDialogSpace}
|
|
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
|
|
selectedFolder={activeDialogTarget?.folderPath || ""}
|
|
disabled={busy || !activeDialogSpace}
|
|
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
|
|
|
|
</div>
|
|
{!managedArchiveFile && <FileDropZone
|
|
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
|
|
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
|
|
onRejectedDrop={(reason) => setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")}
|
|
onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />}
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
{managedArchiveFile && <Button variant="primary" disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace)} onClick={() => activeDialogTarget && void loadArchivePreview(managedArchiveFile, activeDialogTarget)}>i18n:govoplan-files.managed_archive.preview</Button>}
|
|
</div>
|
|
</>
|
|
}
|
|
{archivePreview && (archiveFile || managedArchiveFile) &&
|
|
<div className="archive-preview">
|
|
<div className="archive-preview-summary">
|
|
<div>
|
|
<strong>{managedArchiveFile?.filename || archiveFile?.name}</strong>
|
|
<span>{archivePreview.archive_format.toUpperCase()} · {formatBytes(archivePreview.compressed_size_bytes)} compressed</span>
|
|
</div>
|
|
<div>
|
|
<strong>{archivePreview.file_count} files</strong>
|
|
<span>{formatBytes(archivePreview.expanded_size_bytes)} expanded · {archivePreview.directory_count} folders</span>
|
|
</div>
|
|
<div>
|
|
<strong>Destination</strong>
|
|
<span>{activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{archivePreview.requires_password &&
|
|
<FormField
|
|
label="Archive password"
|
|
helpContextId="files.list"
|
|
helpModuleId="files"
|
|
help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
|
|
<PasswordField
|
|
helpContextId="files.list"
|
|
helpModuleId="files"
|
|
value={archivePassword}
|
|
onValueChange={setArchivePassword}
|
|
disabled={busy}
|
|
autoComplete="off" />
|
|
|
|
</FormField>
|
|
}
|
|
|
|
<ActionToolbar justify="between" className="archive-selection-toolbar">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedArchivePaths.size === archivePreview.file_count && archivePreview.file_count > 0}
|
|
onChange={(event) => toggleAllArchiveFiles(event.target.checked)}
|
|
disabled={busy || archivePreview.file_count === 0} />
|
|
|
|
<span>Select all files</span>
|
|
</label>
|
|
<span>{selectedArchivePaths.size} of {archivePreview.file_count} selected</span>
|
|
</ActionToolbar>
|
|
|
|
<div className="archive-entry-list" role="list" aria-label="Archive contents">
|
|
{archivePreview.entries.map((entry) => {
|
|
const selectableFiles = archiveFilesForEntry(entry);
|
|
const depth = Math.max(0, entry.path.split("/").length - 1);
|
|
return (
|
|
<label
|
|
key={`${entry.kind}:${entry.path}`}
|
|
className={`archive-entry-row ${entry.kind === "directory" ? "is-directory" : ""}`}
|
|
style={{ paddingLeft: `${12 + depth * 18}px` }}>
|
|
|
|
<input
|
|
type="checkbox"
|
|
checked={archiveEntryIsSelected(entry)}
|
|
onChange={(event) => toggleArchiveEntry(entry, event.target.checked)}
|
|
disabled={busy || selectableFiles.length === 0} />
|
|
|
|
{entry.kind === "directory" ?
|
|
<Folder size={16} aria-hidden="true" /> :
|
|
<File size={16} aria-hidden="true" />
|
|
}
|
|
<span className="archive-entry-path">{entry.path.split("/").slice(-1)[0]}</span>
|
|
<span className="archive-entry-size">{entry.kind === "file" ? formatBytes(entry.size_bytes) : `${selectableFiles.length} files`}</span>
|
|
</label>);
|
|
})}
|
|
</div>
|
|
|
|
<p className="form-help">
|
|
{managedArchiveFile
|
|
? i18nMessage("i18n:govoplan-files.managed_archive.expires", { value0: formatDate(archivePreview.expires_at) })
|
|
: i18nMessage("i18n:govoplan-files.archive_progress.preview_expires", { expires: formatDate(archivePreview.expires_at) })}
|
|
</p>
|
|
|
|
<div className="button-row compact-actions archive-preview-actions">
|
|
<Button
|
|
onClick={() => {
|
|
if (managedArchiveFile) { setArchivePreview(null); setSelectedArchivePaths(new Set()); }
|
|
else resetArchiveUploadState();
|
|
setError("");
|
|
}}
|
|
disabled={busy}>
|
|
{managedArchiveFile ? "i18n:govoplan-files.managed_archive.change_destination" : "Choose another file"}
|
|
</Button>
|
|
{archivePreview.requires_password &&
|
|
<Button
|
|
helpContextId="files.list"
|
|
helpModuleId="files"
|
|
onClick={() => activeDialogTarget && void loadArchivePreview((managedArchiveFile || archiveFile)!, activeDialogTarget, { preserveSelection: true })}
|
|
disabled={busy || !archivePassword}>
|
|
<RefreshCw size={15} aria-hidden="true" /> Verify password
|
|
</Button>
|
|
}
|
|
<span className="archive-preview-action-spacer" />
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void confirmCurrentArchive()}
|
|
disabled={
|
|
busy
|
|
|| selectedArchivePaths.size === 0
|
|
|| archivePreview.requires_password && !archivePassword
|
|
}>
|
|
Import selected
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</LoadingFrame>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "connector-sync" &&
|
|
<FileDialog title={i18nMessage("i18n:govoplan-files.sync_to_value_value.29c362ea", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
|
<div className="connector-sync-grid">
|
|
<FormField label="i18n:govoplan-files.connector_profile.9e9cd317">
|
|
<select value={connectorProfileId} onChange={(event) => handleConnectorProfileChange(event.target.value)} disabled={busy || connectorLoading}>
|
|
{connectorProfiles.length === 0 && <option value="">i18n:govoplan-files.no_connector_profiles.03c730ee</option>}
|
|
{connectorProfiles.map((profile) =>
|
|
<option key={profile.id} value={profile.id}>{profile.label}</option>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
<div className="field-block">
|
|
<FieldLabel className="form-label">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
|
|
<TransferFolderSelector
|
|
space={activeDialogSpace}
|
|
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
|
|
selectedFolder={activeDialogTarget?.folderPath || ""}
|
|
disabled={busy || !activeDialogSpace}
|
|
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connector-browser">
|
|
<ActionToolbar className="connector-browser-toolbar">
|
|
<Button onClick={browseConnectorParent} disabled={connectorParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
|
|
<span className="connector-browser-path" title={connectorLocationLabel}>{connectorLocationLabel}</span>
|
|
<Button onClick={() => connectorProfileId && void browseConnector(connectorProfileId, connectorPath, connectorLibraryId)} disabled={busy || connectorLoading || !connectorProfileId}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
|
|
</ActionToolbar>
|
|
{connectorError && <p className="field-error connector-browser-error">{connectorError}</p>}
|
|
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_files.8f351f5d">
|
|
{connectorLoading &&
|
|
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_files.e5b0871d" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_files.02c876e0")}</span></div>
|
|
}
|
|
{!connectorLoading && connectorItems.length === 0 &&
|
|
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
|
|
}
|
|
{!connectorLoading && connectorItems.map((item) => {
|
|
const isSelected = connectorSelectedItem?.kind === "file" && item.kind === "file" && connectorSelectedItem.path === item.path;
|
|
const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes);
|
|
const itemModified = item.modified_at ? formatDate(item.modified_at) : "";
|
|
const meta = item.kind === "file" ?
|
|
[itemSize, itemModified].filter(Boolean).join(" · ") :
|
|
item.kind === "library" ?
|
|
"i18n:govoplan-files.library.b8100f5b" :
|
|
"i18n:govoplan-files.folder.30baa249";
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={`${item.kind}:${item.external_id || item.path}`}
|
|
className={`connector-browser-row ${isSelected ? "is-selected" : ""}`}
|
|
onClick={() => openConnectorItem(item)}
|
|
disabled={busy}>
|
|
|
|
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
|
|
<span>
|
|
<strong>{item.name}</strong>
|
|
<small>{meta}</small>
|
|
</span>
|
|
</button>);
|
|
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connector-sync-selection">
|
|
<strong>{connectorSelectedItem?.kind === "file" ? connectorSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"}</strong>
|
|
<small>
|
|
{connectorSelectedItem?.kind === "file" ? i18nMessage("i18n:govoplan-files.value_value.598aab59", { value0:
|
|
connectorSelectedItem.path, value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" }) :
|
|
"i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47"}
|
|
</small>
|
|
</div>
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button variant="primary" onClick={() => void syncConnectorSelection()} disabled={busy || connectorLoading || !connectorSelectedItem || connectorSelectedItem.kind !== "file" || !activeDialogSpace}>i18n:govoplan-files.sync.905f6309</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "connector-space" &&
|
|
<FileDialog title="i18n:govoplan-files.add_connector_space.aa6bdbd6" onClose={closeDialog}>
|
|
<div className="connector-sync-grid">
|
|
<FormField label="i18n:govoplan-files.connector_profile.9e9cd317">
|
|
<select value={connectorProfileId} onChange={(event) => handleConnectorProfileChange(event.target.value)} disabled={busy || connectorLoading}>
|
|
{connectorProfiles.length === 0 && <option value="">i18n:govoplan-files.no_connector_profiles.03c730ee</option>}
|
|
{connectorProfiles.map((profile) =>
|
|
<option key={profile.id} value={profile.id}>{profile.label}</option>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-files.owner_space.364c4b62">
|
|
<select value={connectorSpaceOwnerSpaceId} onChange={(event) => setConnectorSpaceOwnerSpaceId(event.target.value)} disabled={busy}>
|
|
{spaces.filter((space) => !isConnectorSpace(space)).map((space) =>
|
|
<option key={space.id} value={space.id}>{space.label}</option>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-files.space_label.ec892726">
|
|
<input value={connectorSpaceLabel} onChange={(event) => setConnectorSpaceLabel(event.target.value)} disabled={busy} placeholder={connectorSpaceSuggestedLabel()} />
|
|
</FormField>
|
|
<FormField label="Connector mode" help="Two-way mode permits explicit writes only. Remote deletion, rename, and move propagation remain disabled.">
|
|
<select
|
|
value={connectorSpaceReadOnly ? "read-only" : "two-way"}
|
|
onChange={(event) => setConnectorSpaceReadOnly(event.target.value !== "two-way")}
|
|
disabled={busy}>
|
|
<option value="read-only">Read-only</option>
|
|
<option
|
|
value="two-way"
|
|
disabled={activeConnectorProfile?.provider !== "s3" || !activeConnectorProfile.capabilities.includes("write")}>
|
|
Two-way (S3, explicit writes)
|
|
</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="connector-browser">
|
|
<ActionToolbar className="connector-browser-toolbar">
|
|
<Button onClick={browseConnectorParent} disabled={connectorParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
|
|
<span className="connector-browser-path" title={connectorLocationLabel}>{connectorLocationLabel}</span>
|
|
<Button onClick={() => connectorProfileId && void browseConnector(connectorProfileId, connectorPath, connectorLibraryId)} disabled={busy || connectorLoading || !connectorProfileId}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
|
|
</ActionToolbar>
|
|
{connectorError && <p className="field-error connector-browser-error">{connectorError}</p>}
|
|
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_folders.960cbb03">
|
|
{connectorLoading &&
|
|
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_folders.426de3b6" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_folders.dde26f3e")}</span></div>
|
|
}
|
|
{!connectorLoading && connectorItems.length === 0 &&
|
|
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
|
|
}
|
|
{!connectorLoading && connectorItems.map((item) => {
|
|
const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes);
|
|
const itemModified = item.modified_at ? formatDate(item.modified_at) : "";
|
|
const meta = item.kind === "file" ?
|
|
[itemSize, itemModified].filter(Boolean).join(" · ") :
|
|
item.kind === "library" ?
|
|
"i18n:govoplan-files.library.b8100f5b" :
|
|
"i18n:govoplan-files.folder.30baa249";
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={`${item.kind}:${item.external_id || item.path}`}
|
|
className="connector-browser-row"
|
|
onClick={() => openConnectorItem(item)}
|
|
disabled={busy}>
|
|
|
|
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
|
|
<span>
|
|
<strong>{item.name}</strong>
|
|
<small>{meta}</small>
|
|
</span>
|
|
</button>);
|
|
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connector-sync-selection">
|
|
<strong>{connectorSpaceLabel.trim() || connectorSpaceSuggestedLabel()}</strong>
|
|
<small>{[activeConnectorProfile?.label, connectorLibraryId, connectorPath || (connectorLibraryId ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") || "i18n:govoplan-files.connector_root.9027235c"}</small>
|
|
</div>
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button variant="primary" onClick={() => void createConnectorSpaceFromDialog()} disabled={busy || connectorLoading || !connectorProfileId || !connectorSpaceOwnerSpaceId}>i18n:govoplan-files.create_space.b50606b4</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "connector-folder-sync" && activeSpace && activeSpaceIsConnector &&
|
|
<FileDialog title="i18n:govoplan-files.folder_sync.title" onClose={closeDialog}>
|
|
<p className="muted">i18n:govoplan-files.folder_sync.description</p>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<FormField
|
|
label="i18n:govoplan-files.folder_sync.remote.label"
|
|
help="i18n:govoplan-files.folder_sync.remote.help"
|
|
interfaceId="files.connector-folder-sync.remote-path"
|
|
helpContextId="files.connector-folder-sync.remote-path"
|
|
helpModuleId="files"
|
|
helpTopicId="files.governed-connectors-and-provenance">
|
|
<input value={currentFolder || translateText("i18n:govoplan-files.root.e96857c5")} readOnly />
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-files.folder_sync.destination.label"
|
|
help="i18n:govoplan-files.folder_sync.destination.help"
|
|
interfaceId="files.connector-folder-sync.target-folder"
|
|
helpContextId="files.connector-folder-sync.target-folder"
|
|
helpModuleId="files"
|
|
helpTopicId="files.governed-connectors-and-provenance">
|
|
<input value={spaces.find((item) => item.space_type !== "connector" && item.owner_type === activeSpace.owner_type && item.owner_id === activeSpace.owner_id)?.label || `${activeSpace.owner_type}:${activeSpace.owner_id}`} readOnly />
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-files.folder_sync.conflict.label"
|
|
help="i18n:govoplan-files.folder_sync.conflict.help"
|
|
interfaceId="files.connector-folder-sync.conflict-strategy"
|
|
helpContextId="files.connector-folder-sync.conflict-strategy"
|
|
helpModuleId="files"
|
|
helpTopicId="files.governed-connectors-and-provenance">
|
|
<select value={folderSyncConflictStrategy} onChange={(event) => setFolderSyncConflictStrategy(event.target.value as typeof folderSyncConflictStrategy)} disabled={busy}>
|
|
<option value="skip">i18n:govoplan-files.folder_sync.conflict.skip</option>
|
|
<option value="rename">i18n:govoplan-files.folder_sync.conflict.rename</option>
|
|
<option value="reject">i18n:govoplan-files.folder_sync.conflict.reject</option>
|
|
<option value="overwrite">i18n:govoplan-files.folder_sync.conflict.overwrite</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-files.folder_sync.limit.label"
|
|
help="i18n:govoplan-files.folder_sync.limit.help"
|
|
interfaceId="files.connector-folder-sync.max-files"
|
|
helpContextId="files.connector-folder-sync.max-files"
|
|
helpModuleId="files"
|
|
helpTopicId="files.governed-connectors-and-provenance">
|
|
<input type="number" min={1} max={500} value={folderSyncMaxFiles} onChange={(event) => setFolderSyncMaxFiles(Math.min(500, Math.max(1, Number(event.target.value) || 1)))} disabled={busy} />
|
|
</FormField>
|
|
</FormGrid>
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-files.folder_sync.recursive.label"
|
|
checked={folderSyncRecursive}
|
|
onChange={setFolderSyncRecursive}
|
|
disabled={busy}
|
|
help="i18n:govoplan-files.folder_sync.recursive.help"
|
|
interfaceId="files.connector-folder-sync.recursive"
|
|
helpContextId="files.connector-folder-sync.recursive"
|
|
helpModuleId="files"
|
|
helpTopicId="files.governed-connectors-and-provenance" />
|
|
|
|
{folderSyncResult &&
|
|
<div className="connector-folder-sync-review" aria-live="polite">
|
|
<div className="connector-folder-sync-summary" aria-label="i18n:govoplan-files.folder_sync.summary.aria_label">
|
|
{folderSyncSummaryRows.map(([label, value]) =>
|
|
<span key={label}><strong>{value}</strong> {label}</span>
|
|
)}
|
|
</div>
|
|
{folderSyncResult.truncated && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-files.folder_sync.truncated</DismissibleAlert>}
|
|
<div className="connector-folder-sync-results" role="table" aria-label="i18n:govoplan-files.folder_sync.results.aria_label">
|
|
<div className="connector-folder-sync-result is-header" role="row">
|
|
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.source</strong>
|
|
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.result</strong>
|
|
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.detail</strong>
|
|
</div>
|
|
{folderSyncResult.items.map((item, index) =>
|
|
<div className={`connector-folder-sync-result is-${item.action}`} role="row" key={`${item.source_path}:${index}`}>
|
|
<span role="cell">{item.source_path}</span>
|
|
<strong role="cell">{folderSyncActionLabel(item.action)}</strong>
|
|
<span role="cell">{item.target_path || item.detail || item.policy_decision?.reason || "—"}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>{folderSyncResult ? "i18n:govoplan-files.close.bbfa773e" : "i18n:govoplan-files.cancel.77dfd213"}</Button>
|
|
<Button variant="primary" onClick={() => void syncActiveConnectorFolder()} disabled={busy || connectorSpaceLoading}>
|
|
<RefreshCw size={15} aria-hidden="true" /> {folderSyncResult ? "i18n:govoplan-files.folder_sync.run_again" : "i18n:govoplan-files.folder_sync.button"}
|
|
</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "create-folder" &&
|
|
<FileDialog title="i18n:govoplan-files.create_folder.e59f63fa" onClose={closeDialog}>
|
|
<FormField label="i18n:govoplan-files.folder_name.b2ce023b" help="i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76">
|
|
<input autoFocus value={newFolderName} placeholder="i18n:govoplan-files.e_g_invoices.77a73bd1" onChange={(event) => {setNewFolderName(event.target.value);setNewFolderError("");}} onKeyDown={(event) => {if (event.key === "Enter") void handleCreateFolder();}} />
|
|
<small>i18n:govoplan-files.created_inside.3426a815 {activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.</small>
|
|
{newFolderError && <small className="field-error">{newFolderError}</small>}
|
|
</FormField>
|
|
<div className="field-block">
|
|
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580">i18n:govoplan-files.parent_folder.d8ed4c2a</FieldLabel>
|
|
<TransferFolderSelector
|
|
space={activeDialogSpace}
|
|
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
|
|
selectedFolder={activeDialogTarget?.folderPath || ""}
|
|
disabled={busy || !activeDialogSpace}
|
|
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
|
|
|
|
</div>
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button variant="primary" onClick={() => void handleCreateFolder()} disabled={busy || !newFolderName.trim()}>i18n:govoplan-files.create_folder.97bafaba</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "transfer" && transferDialogState &&
|
|
<FileDialog title={`${transferMode === "copy" ? "i18n:govoplan-files.copy.af74f7c5" : "i18n:govoplan-files.move.76cdb950"} selection`} onClose={closeDialog}>
|
|
<p className="muted">i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643</p>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<FormField label="i18n:govoplan-files.destination_space.92b63970" help="i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f">
|
|
<select value={transferDialogState.targetSpaceId} onChange={(event) => setTransferDialogState((current) => current ? { ...current, targetSpaceId: event.target.value, targetFolder: "" } : current)}>
|
|
{spaces.filter((space) => !isConnectorSpace(space)).map((space) => <option key={space.id} value={space.id}>{space.label}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<div className="field-block">
|
|
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
|
|
<TransferFolderSelector
|
|
space={findSpace(transferDialogState.targetSpaceId)}
|
|
nodes={buildFolderTree(filesBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FILES, foldersBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FOLDERS)}
|
|
selectedFolder={transferDialogState.targetFolder}
|
|
disabled={busy}
|
|
onSelect={(folderPath) => setTransferDialogState((current) => current ? { ...current, targetFolder: folderPath } : current)} />
|
|
|
|
</div>
|
|
</FormGrid>
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button variant="primary" onClick={() => void handleTransferDialogSubmit()} disabled={busy}>{transferMode === "copy" ? "i18n:govoplan-files.copy.af74f7c5" : "i18n:govoplan-files.move.76cdb950"}</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "single-rename" &&
|
|
<FileDialog title="i18n:govoplan-files.rename_item.3d21d6ca" onClose={closeDialog}>
|
|
<FormField label="i18n:govoplan-files.new_name.9e627c7b" help="i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71">
|
|
<input autoFocus value={singleRenameName} onChange={(event) => setSingleRenameName(event.target.value)} onKeyDown={(event) => {if (event.key === "Enter") void runSingleRename();}} />
|
|
</FormField>
|
|
<div className="button-row compact-actions align-end">
|
|
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
|
<Button variant="primary" onClick={() => void runSingleRename()} disabled={busy || !singleRenameName.trim()}>i18n:govoplan-files.rename.d3f4cb89</Button>
|
|
</div>
|
|
</FileDialog>
|
|
}
|
|
|
|
{dialog === "rename" &&
|
|
<FileDialog title="i18n:govoplan-files.bulk_rename_selected_items.5e79d694" onClose={closeDialog}>
|
|
<p className="muted">i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b</p>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<FormField label="i18n:govoplan-files.mode.a7b93d21" help="i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f">
|
|
<select value={renameMode} onChange={(event) => setRenameMode(event.target.value as RenameMode)}>
|
|
<option value="prefix">i18n:govoplan-files.add_prefix.672452bc</option>
|
|
<option value="suffix">i18n:govoplan-files.add_suffix_before_extension.784381fe</option>
|
|
<option value="replace">i18n:govoplan-files.find_and_replace.2c6b404b</option>
|
|
</select>
|
|
</FormField>
|
|
{renameMode === "prefix" && <FormField label="i18n:govoplan-files.prefix.90eceb01"><input value={renamePrefix} onChange={(event) => setRenamePrefix(event.target.value)} /></FormField>}
|
|
{renameMode === "suffix" && <FormField label="i18n:govoplan-files.suffix.885db975"><input value={renameSuffix} onChange={(event) => setRenameSuffix(event.target.value)} /></FormField>}
|
|
{renameMode === "replace" && <><FormField label="i18n:govoplan-files.find.df251b06"><input value={renameFind} onChange={(event) => setRenameFind(event.target.value)} /></FormField><FormField label="i18n:govoplan-files.replacement.c385e347"><input value={renameReplacement} onChange={(event) => setRenameReplacement(event.target.value)} /></FormField></>}
|
|
</FormGrid>
|
|
{selectedFolderPaths.size > 0 &&
|
|
<ToggleSwitch label="i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984" checked={renameRecursive} onChange={setRenameRecursive} disabled={busy} />
|
|
}
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => void runRenamePreview(true)} disabled={busy || selectedEntryCount === 0}>i18n:govoplan-files.preview_rename.bb890b24</Button>
|
|
<Button variant="primary" onClick={() => void runRenamePreview(false)} disabled={busy || selectedEntryCount === 0 || !renamePreview}>i18n:govoplan-files.apply_preview.8692d5eb</Button>
|
|
</div>
|
|
{renamePreview &&
|
|
<RenamePreviewList
|
|
response={renamePreview}
|
|
selectedFileIds={selectedFileIds}
|
|
selectedFolderPaths={selectedFolderPaths}
|
|
recursive={renameRecursive}
|
|
visibleCount={renamePreviewVisibleCount}
|
|
onShowMore={() => setRenamePreviewVisibleCount((current) => current + 20)}
|
|
onShowFewer={() => setRenamePreviewVisibleCount(20)} />
|
|
|
|
}
|
|
</FileDialog>
|
|
}
|
|
</WorkspaceFrame>);
|
|
|
|
}
|
|
|
|
function folderSyncActionLabel(action: FileConnectorFolderSyncResponse["items"][number]["action"]): string {
|
|
const labels: Record<FileConnectorFolderSyncResponse["items"][number]["action"], string> = {
|
|
created: "i18n:govoplan-files.folder_sync.action.created",
|
|
updated: "i18n:govoplan-files.folder_sync.action.updated",
|
|
unchanged: "i18n:govoplan-files.folder_sync.action.unchanged",
|
|
skipped: "i18n:govoplan-files.folder_sync.action.skipped",
|
|
conflict: "i18n:govoplan-files.folder_sync.action.conflict",
|
|
policy_denied: "i18n:govoplan-files.folder_sync.action.policy_denied",
|
|
failed: "i18n:govoplan-files.folder_sync.action.failed"
|
|
};
|
|
return labels[action];
|
|
}
|
|
|
|
function FilePropertyFilterRow({
|
|
campaignUsage,
|
|
auditRelevant,
|
|
busy,
|
|
active,
|
|
disabled,
|
|
total,
|
|
onApply,
|
|
onClear
|
|
}: {
|
|
campaignUsage: "" | FileCampaignUsageFilter;
|
|
auditRelevant: AuditRelevantFilter;
|
|
busy: boolean;
|
|
active: boolean;
|
|
disabled: boolean;
|
|
total: number | null;
|
|
onApply: (campaignUsage: "" | FileCampaignUsageFilter, auditRelevant: AuditRelevantFilter) => void;
|
|
onClear: () => void;
|
|
}) {
|
|
const [campaignUsageDraft, setCampaignUsageDraft] = useState(campaignUsage);
|
|
const [auditRelevantDraft, setAuditRelevantDraft] = useState(auditRelevant);
|
|
|
|
useEffect(() => {
|
|
setCampaignUsageDraft(campaignUsage);
|
|
}, [campaignUsage]);
|
|
|
|
useEffect(() => {
|
|
setAuditRelevantDraft(auditRelevant);
|
|
}, [auditRelevant]);
|
|
|
|
return (
|
|
<div className="file-property-filter-row">
|
|
<ListFilter size={17} aria-hidden="true" />
|
|
<label className="file-property-filter-field">
|
|
<span>Campaign use</span>
|
|
<select
|
|
value={campaignUsageDraft}
|
|
disabled={busy || disabled}
|
|
onChange={(event) => setCampaignUsageDraft(event.target.value as "" | FileCampaignUsageFilter)}>
|
|
<option value="">Any</option>
|
|
<option value="linked">Linked or used</option>
|
|
<option value="unlinked">Not linked or used</option>
|
|
</select>
|
|
</label>
|
|
<label className="file-property-filter-field">
|
|
<span>Audit evidence</span>
|
|
<select
|
|
value={auditRelevantDraft}
|
|
disabled={busy || disabled}
|
|
onChange={(event) => setAuditRelevantDraft(event.target.value as AuditRelevantFilter)}>
|
|
<option value="">Any</option>
|
|
<option value="true">Audit relevant</option>
|
|
<option value="false">Not audit relevant</option>
|
|
</select>
|
|
</label>
|
|
<Button
|
|
onClick={() => onApply(campaignUsageDraft, auditRelevantDraft)}
|
|
disabled={busy || disabled || !campaignUsageDraft && !auditRelevantDraft}>
|
|
Apply filters
|
|
</Button>
|
|
{active && <Button onClick={onClear} disabled={busy}>Clear filters</Button>}
|
|
{active && total !== null && <span className="file-property-filter-count">{total} matching file{total === 1 ? "" : "s"}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="file-search-row">
|
|
<Search size={17} aria-hidden="true" />
|
|
<input
|
|
value={draft}
|
|
placeholder="i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a"
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {if (event.key === "Enter") onSearch(draft, caseSensitiveDraft);}} />
|
|
|
|
<ToggleSwitch label="i18n:govoplan-files.case_sensitive.c73af7bd" checked={caseSensitiveDraft} onChange={setCaseSensitiveDraft} disabled={busy || disabled} />
|
|
<Button onClick={() => onSearch(draft, caseSensitiveDraft)} disabled={busy || disabled}>i18n:govoplan-files.search.bce06414</Button>
|
|
{searchActive && <Button onClick={clear} disabled={busy}>i18n:govoplan-files.clear.719ea396</Button>}
|
|
</div>);
|
|
|
|
}
|
|
|
|
function activeCampaignShares(file: ManagedFile) {
|
|
return (file.shares ?? []).filter((share) =>
|
|
share.target_type === "campaign" && fileShareIsActive(share)
|
|
);
|
|
}
|
|
|
|
function fileShareIsActive(share: NonNullable<ManagedFile["shares"]>[number]): boolean {
|
|
if (typeof share.active === "boolean") return share.active;
|
|
return !share.revoked_at
|
|
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now());
|
|
}
|
|
|
|
function activeCampaignShareCount(file: ManagedFile): number {
|
|
return activeCampaignShares(file).length;
|
|
}
|
|
|
|
function campaignShareLabel(file: ManagedFile): string {
|
|
const count = activeCampaignShareCount(file);
|
|
if (count === 0) return "i18n:govoplan-files.no_campaign_links.4203c2be";
|
|
if (count === 1) return "i18n:govoplan-files.linked_to_1_campaign.5349694f";
|
|
return i18nMessage("i18n:govoplan-files.linked_to_value_campaigns.1ad7be94", { value0: count });
|
|
}
|
|
|
|
function campaignShareTitle(file: ManagedFile): string | undefined {
|
|
const shares = activeCampaignShares(file);
|
|
if (shares.length === 0) return undefined;
|
|
return shares.map((share) => share.target_id).join(", ");
|
|
}
|