Add safe archive preview and extraction workflows

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 159a012833
commit b752dea610
20 changed files with 1961 additions and 182 deletions
+310 -16
View File
@@ -8,6 +8,7 @@ import {
FileDropZone,
FormField,
LoadingIndicator,
PasswordField,
ResourceAccessExplanation,
ToggleSwitch,
hasScope,
@@ -20,6 +21,7 @@ import {
browseFileConnectorProfile,
createFileConnectorSpace,
createFolder,
confirmArchiveUpload,
deleteFolder,
downloadFile,
downloadFilesAsZip,
@@ -29,11 +31,14 @@ import {
listFileConnectorProfiles,
listFileSpaces,
listManagedFileSnapshot,
previewArchiveUpload,
resolveFilePatterns,
syncFileConnectorFile,
transferFiles,
uploadFiles,
virtualFolderResourceId,
type ArchivePreviewEntry,
type ArchivePreviewResponse,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
@@ -101,6 +106,7 @@ type FileAccessExplanationTarget = {
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;
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const canDownload = hasScope(auth, "files:download");
@@ -132,6 +138,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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 [archivePreview, setArchivePreview] = useState<ArchivePreviewResponse | null>(null);
const [archivePassword, setArchivePassword] = useState("");
const [selectedArchivePaths, setSelectedArchivePaths] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [uploadActive, setUploadActive] = useState(false);
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
@@ -501,9 +511,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setNewFolderName("");
setNewFolderError("");
}
if (kind === "upload") {
resetArchiveUploadState();
}
openRawDialog(kind, target);
}
function resetArchiveUploadState() {
setArchiveFile(null);
setArchivePreview(null);
setArchivePassword("");
setSelectedArchivePaths(new Set());
}
function closeDialog() {
closeRawDialog();
setNewFolderError("");
@@ -513,6 +533,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setConnectorError("");
setConnectorSelectedItem(null);
setConnectorSpaceLabel("");
resetArchiveUploadState();
}
function updateActiveDialogFolder(spaceId: string, folderPath: string) {
@@ -667,6 +688,156 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
async function loadArchivePreview(
file: File,
target: FileActionTarget,
options: { preserveSelection?: boolean } = {}
) {
const targetSpace = findSpace(target.spaceId);
if (!targetSpace || isConnectorSpace(targetSpace)) {
setError(uploadRejectedReason(target));
return;
}
setArchiveFile(file);
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
setUploadProgress(null);
setError("");
setMessage("");
try {
const response = await previewArchiveUpload(settings, file, {
owner_type: targetSpace.owner_type,
owner_id: targetSpace.owner_id,
path: target.folderPath,
password: archivePassword || undefined
});
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
|| !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("uploading");
setUploadProgress(0);
setError("");
setMessage("Uploading the archive for confirmed extraction.");
try {
const response = await confirmArchiveUpload(settings, archiveFile, {
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,
conflict_strategy: "reject",
onProgress: ({ percentage }) => {
setUploadProgress(percentage);
if (percentage !== null && percentage >= 100) {
setUploadPhase("unpacking");
setMessage("Extracting the selected archive files.");
}
}
});
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;
@@ -676,6 +847,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
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) {
@@ -691,7 +870,6 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
return;
}
}
const uploadsZipArchive = unpackZip && selected.some((file) => file.name.toLowerCase().endsWith(".zip"));
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
@@ -703,14 +881,13 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
owner_type: targetSpace.owner_type,
owner_id: targetSpace.owner_id,
path: target.folderPath,
unpack_zip: unpackZip,
conflict_strategy: options.conflictStrategy ?? "reject",
conflict_resolutions: options.conflictResolutions,
onProgress: ({ percentage }) => {
setUploadProgress(percentage);
if (percentage !== null && percentage >= 100) {
setUploadPhase(uploadsZipArchive ? "unpacking" : "finalizing");
setMessage(uploadsZipArchive ? "i18n:govoplan-files.unpacking_zip_upload.35019691" : "i18n:govoplan-files.finalizing_upload.bcce936d");
setUploadPhase("finalizing");
setMessage("i18n:govoplan-files.finalizing_upload.bcce936d");
}
}
});
@@ -2332,6 +2509,29 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
{dialog === "upload" &&
<FileDialog title={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" })} onClose={closeDialog}>
{!archivePreview &&
<>
<ToggleSwitch
label="Preview and unpack archive"
checked={unpackZip}
onChange={setUnpackZip}
disabled={busy} />
{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>
<FileDropZone
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
busy={uploadActive}
@@ -2341,21 +2541,115 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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 })} />
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
<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>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
</div>
</>
}
{archivePreview && archiveFile &&
<div className="archive-preview">
<div className="archive-preview-summary">
<div>
<strong>{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"
help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
<PasswordField
value={archivePassword}
onValueChange={setArchivePassword}
disabled={busy}
autoComplete="off" />
</FormField>
}
<div 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>
</div>
<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("/").at(-1)}</span>
<span className="archive-entry-size">{entry.kind === "file" ? formatBytes(entry.size_bytes) : `${selectableFiles.length} files`}</span>
</label>);
})}
</div>
<p className="form-help">
Preview expires {formatDate(archivePreview.expires_at)}. The original archive is uploaded again only when you confirm.
</p>
<div className="button-row compact-actions archive-preview-actions">
<Button
onClick={() => {
resetArchiveUploadState();
setError("");
}}
disabled={busy}>
Choose another file
</Button>
{archivePreview.requires_password &&
<Button
onClick={() => activeDialogTarget && void loadArchivePreview(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>
}
</FileDialog>
}