Add safe archive preview and extraction workflows
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user