Release govoplan-files v0.1.26: speed archive workflows and unify file tools
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:41 +02:00
parent 2baa8f2657
commit ff84812f7f
35 changed files with 4331 additions and 275 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/files-webui",
"version": "0.1.25",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,6 +14,9 @@
"./styles/file-manager.css": "./src/styles/file-manager.css"
},
"scripts": {
"test:reload-client": "node scripts/test-files-reload-client.mjs",
"test:archive-client": "node scripts/test-archive-client.mjs",
"test:managed-archive": "node scripts/test-managed-archive-structure.mjs",
"test:connector-folder-sync": "node scripts/test-connector-folder-sync-structure.mjs",
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs",
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs",
@@ -28,7 +31,7 @@
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.44"
"@govoplan/core-webui": "^0.1.45"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+302
View File
@@ -0,0 +1,302 @@
// Execute the actual Files client and Core HTTP transport. Only browser I/O
// (fetch, XHR, timers, document cookies) and temporal context are substituted.
// No source slicing, regex assertions, live credentials, or server is involved.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
const coreRoot = new URL("../../../govoplan-core/webui/", import.meta.url);
const require = createRequire(new URL("package.json", coreRoot));
const { transformSync } = require("esbuild");
const compile = (url) => transformSync(readFileSync(url, "utf8"), {
loader: "ts", format: "cjs", target: "es2022", sourcefile: url.pathname,
define: { "import.meta.env": "{}" }
}).code;
const coreCode = compile(new URL("src/api/client.ts", coreRoot));
const filesCode = compile(new URL("../src/api/files.ts", import.meta.url));
const settle = () => new Promise((resolve) => setImmediate(resolve));
const plain = (value) => JSON.parse(JSON.stringify(value));
const settings = { apiBaseUrl: "https://fixture.invalid", accessToken: "", apiKey: "" };
const destination = { owner_type: "user", owner_id: "fixture-user", path: "imports" };
const previewResponse = (changes = {}) => ({
preview_token: "preview-token", staged_upload_id: "stage-one", expires_at: "2099-01-01T00:00:00Z",
entries: [], file_count: 1, directory_count: 0, archive_format: "zip",
requires_password: false, password_verified: true, compressed_size_bytes: 100,
expanded_size_bytes: 10, ...changes
});
const json = (body, status = 200) => new Response(JSON.stringify(body), {
status, headers: { "content-type": "application/json" }
});
const deferred = () => {
let resolve;
const promise = new Promise((complete) => { resolve = complete; });
return { promise, resolve };
};
function harness() {
const requests = [];
const xhrRequests = [];
const timers = new Map();
let timerId = 0;
let now = Date.parse("2026-09-07T10:00:00Z");
let onFetch = () => { throw new Error("Unexpected fetch"); };
let onXhr = () => { throw new Error("Unexpected XHR"); };
class ClockDate extends Date { static now() { return now; } }
class FakeXhr {
upload = {};
headers = new Headers();
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(name, value) { this.headers.set(name, value); }
getResponseHeader(name) { return name.toLowerCase() === "content-type" ? "application/json" : null; }
send(body) { this.body = body; xhrRequests.push(this); onXhr(this); }
progress(loaded, total, lengthComputable = true) { this.upload.onprogress?.({ loaded, total, lengthComputable }); }
respond(body, status = 200) { this.status = status; this.statusText = status === 200 ? "OK" : "Error"; this.responseText = JSON.stringify(body); this.onload?.(); }
}
const context = vm.createContext({
File, Blob, FormData, Headers, Response, URL, URLSearchParams, AbortController,
Date: ClockDate, crypto: { randomUUID }, XMLHttpRequest: FakeXhr,
document: { cookie: "" }, console,
fetch: async (url, init) => {
const request = { url, path: new URL(url).pathname, method: init?.method ?? "GET", ...init };
requests.push(request);
return onFetch(request);
},
setTimeout: (callback, delay) => { const id = ++timerId; timers.set(id, { callback, at: now + delay }); return id; },
clearTimeout: (id) => timers.delete(id)
});
function load(code, dependencies) {
context.module = { exports: {} };
context.exports = context.module.exports;
context.require = (name) => {
assert.ok(name in dependencies, `Unexpected client import: ${name}`);
return dependencies[name];
};
vm.runInContext(`(function(module, exports, require) {\n${code}\n})(module, exports, require);`, context);
return context.module.exports;
}
const core = load(coreCode, { "../platform/temporal": { temporalRequestHeaders: () => ({}) } });
const api = load(filesCode, { "@govoplan/core-webui": core });
return {
api, core, requests, xhrRequests, timers,
fetch(handler) { onFetch = handler; },
xhr(handler) { onXhr = handler; },
async advance(milliseconds) {
const target = now + milliseconds;
while (true) {
const next = [...timers.entries()].filter(([, timer]) => timer.at <= target).sort((left, right) => left[1].at - right[1].at)[0];
if (!next) break;
now = next[1].at;
timers.delete(next[0]);
next[1].callback();
await settle();
}
now = target;
await settle();
}
};
}
let passed = 0;
async function test(name, run) {
await run();
passed += 1;
console.log(`PASS ${name}`);
}
await test("locally expired stage is released and preview reuploads the original file", async () => {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
let previews = 0;
env.fetch((request) => {
if (request.method === "DELETE") return new Response(null, { status: 204 });
assert.equal(request.path, "/api/v1/files/archive-preview");
assert.equal(request.body.get("file"), file);
assert.equal(request.body.has("staged_upload_id"), false);
return json(previewResponse(++previews === 1 ? { expires_at: "2026-09-07T10:00:01Z" } : { staged_upload_id: "stage-two" }));
});
await env.api.previewArchiveUpload(settings, file, destination);
await env.advance(1100);
const result = await env.api.previewArchiveUpload(settings, file, destination);
assert.equal(previews, 2);
assert.equal(result.staged_upload_id, "stage-two");
assert.equal(env.requests.filter((request) => request.method === "DELETE").length, 1);
assert.equal(env.requests.filter((request) => request.path.endsWith("archive-confirm")).length, 0);
});
await test("server-expired cached preview retries only the read-only preview once with file bytes", async () => {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
let calls = 0;
env.fetch((request) => {
assert.equal(request.path, "/api/v1/files/archive-preview");
calls += 1;
if (calls === 2) {
assert.equal(request.body.get("staged_upload_id"), "stage-one");
assert.equal(request.body.get("preview_token"), "preview-token");
assert.equal(request.body.has("file"), false);
return json({ detail: "Archive preview expired" }, 410);
}
assert.equal(request.body.get("file"), file);
assert.equal(request.body.has("staged_upload_id"), false);
return json(previewResponse({ staged_upload_id: calls === 1 ? "stage-one" : "stage-two" }));
});
await env.api.previewArchiveUpload(settings, file, destination);
assert.equal((await env.api.previewArchiveUpload(settings, file, destination)).staged_upload_id, "stage-two");
assert.equal(calls, 3, "one initial preview, one expired-stage attempt, one safe reupload");
});
await test("a failed fresh reupload cannot recurse into repeated preview attempts", async () => {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
let calls = 0;
env.fetch(() => ++calls === 1 ? json(previewResponse()) : json({ detail: "Expired" }, 410));
await env.api.previewArchiveUpload(settings, file, destination);
await assert.rejects(env.api.previewArchiveUpload(settings, file, destination), (error) => error instanceof env.core.ApiError && error.status === 410);
assert.equal(calls, 3);
});
await test("failed confirmation never automatically retries or falls back to a file reupload", async () => {
for (const status of [400, 410, 500]) {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
env.fetch((request) => request.path.endsWith("archive-preview") ? json(previewResponse()) : json({ detail: "Confirmation failed" }, status));
const preview = await env.api.previewArchiveUpload(settings, file, destination);
await assert.rejects(env.api.confirmArchiveUpload(settings, file, { ...destination, preview_token: preview.preview_token, selected_paths: ["one.txt"] }), (error) => error instanceof env.core.ApiError && error.status === status);
const confirmations = env.requests.filter((request) => request.path.endsWith("archive-confirm"));
assert.equal(confirmations.length, 1);
assert.equal(confirmations[0].body.get("staged_upload_id"), "stage-one");
assert.equal(confirmations[0].body.has("file"), false);
assert.equal(env.requests.length, 2);
}
});
await test("progress polling failures neither reject nor retry the authoritative confirmation POST", async () => {
for (const failure of [404, 500, "network"]) {
const env = harness();
const pending = deferred();
const events = [];
env.fetch((request) => {
if (request.method === "POST") return pending.promise;
if (failure === "network") throw new TypeError("Offline telemetry");
return json({ detail: "Telemetry unavailable" }, failure);
});
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
onArchiveProgress: (event) => events.push(event)
});
let settled = false;
void confirmation.then(() => { settled = true; });
await env.advance(1200);
assert.equal(settled, false);
assert.equal(events.length, 0);
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
const polls = env.requests.filter((request) => request.method === "GET");
assert.ok(polls.length >= 2);
assert.ok(polls.every((request) => request.cache === "no-store"));
pending.resolve(json({ files: [{ id: "one", size_bytes: 12345 }, { id: "two", size_bytes: 678 }] }));
const result = await confirmation;
assert.equal(result.files.length, 2);
assert.deepEqual(plain(events.at(-1)), { phase: "complete", status: "complete", completed_files: 2, total_files: 2, completed_bytes: 13023, total_bytes: 13023 });
assert.equal(env.timers.size, 0);
assert.ok(polls.every((request) => request.signal.aborted));
await env.advance(2000);
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
assert.equal(env.requests.filter((request) => request.method === "GET").length, polls.length);
}
});
await test("confirmation errors stop polling without fabricating a successful completion", async () => {
const env = harness();
const pending = deferred();
const events = [];
env.fetch((request) => request.method === "POST" ? pending.promise : json({ detail: "Not yet available" }, 404));
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
onArchiveProgress: (event) => events.push(event)
});
const rejected = assert.rejects(confirmation, (error) => error instanceof env.core.ApiError && error.status === 409);
await env.advance(200);
pending.resolve(json({ detail: "Destination conflict" }, 409));
await rejected;
assert.equal(events.length, 0);
assert.equal(env.timers.size, 0);
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
});
await test("a late in-flight poll cannot replace the authoritative successful result", async () => {
const env = harness();
const post = deferred();
const poll = deferred();
const events = [];
env.fetch((request) => request.method === "POST" ? post.promise : poll.promise);
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
onArchiveProgress: (event) => events.push(event)
});
await env.advance(200);
assert.equal(env.requests.filter((request) => request.method === "GET").length, 1);
post.resolve(json({ files: [{ id: "one", size_bytes: 12345 }] }));
await confirmation;
poll.resolve(json({ phase: "extracting", status: "running", completed_files: 0, total_files: 1, completed_bytes: 10, total_bytes: 12345 }));
await settle();
assert.equal(events.length, 1);
assert.equal(events[0].status, "complete");
assert.equal(events[0].completed_bytes, 12345);
assert.equal(env.timers.size, 0);
});
await test("XHR preview reports measured byte counters and preserves them at transfer completion", async () => {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
const events = [];
env.xhr((xhr) => {
assert.equal(xhr.body.get("file"), file);
assert.equal(xhr.withCredentials, true);
xhr.progress(1578, 4096);
xhr.progress(4096, 4096);
xhr.respond(previewResponse());
});
await env.api.previewArchiveUpload(settings, file, { ...destination, onProgress: (event) => events.push(event) });
assert.deepEqual(plain(events.slice(1)), [
{ loaded: 1578, total: 4096, percentage: 39 },
{ loaded: 4096, total: 4096, percentage: 100 },
{ loaded: 4096, total: 4096, percentage: 100 }
]);
assert.equal(env.xhrRequests.length, 1);
assert.equal(env.requests.length, 0);
});
await test("unknown-length XHR uploads never fabricate total bytes", async () => {
const env = harness();
const events = [];
env.xhr((xhr) => { xhr.progress(3917, 0, false); xhr.respond(previewResponse()); });
await env.api.previewArchiveUpload(settings, new File(["fixture"], "fixture.zip"), { ...destination, onProgress: (event) => events.push(event) });
assert.equal(events.at(-2).percentage, null);
assert.equal(events.at(-1).loaded, 3917);
assert.equal(events.at(-1).total, undefined);
assert.equal(events.at(-1).percentage, 100);
});
await test("password repreview and confirmation reuse staging without another XHR transfer", async () => {
const env = harness();
const file = new File(["archive fixture"], "fixture.zip");
env.xhr((xhr) => { xhr.progress(1024, 1024); xhr.respond(previewResponse()); });
env.fetch((request) => {
assert.equal(request.body.get("staged_upload_id"), "stage-one");
assert.equal(request.body.has("file"), false);
assert.equal(request.body.get("password"), "fixture-only-password");
return request.path.endsWith("archive-preview") ? json(previewResponse({ preview_token: "verified-token" })) : json({ files: [] });
});
await env.api.previewArchiveUpload(settings, file, { ...destination, onProgress: () => {} });
const verified = await env.api.previewArchiveUpload(settings, file, { ...destination, password: "fixture-only-password", onProgress: () => assert.fail("Staged repreview must not claim another upload") });
await env.api.confirmArchiveUpload(settings, file, {
...destination, password: "fixture-only-password", preview_token: verified.preview_token, selected_paths: ["one.txt"],
onProgress: () => assert.fail("Staged confirmation must not claim another upload")
});
assert.equal(env.xhrRequests.length, 1);
assert.equal(env.requests.length, 2);
});
console.log(`Archive client behavior: ${passed} tests passed using real Files and Core transport code.`);
@@ -0,0 +1,80 @@
// Run the owning Files read helpers and real Core request cache, replacing only network I/O.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
const coreRoot = new URL("../../../govoplan-core/webui/", import.meta.url);
const require = createRequire(new URL("package.json", coreRoot));
const { transformSync } = require("esbuild");
const compile = (url) => transformSync(readFileSync(url, "utf8"), {
loader: "ts", format: "cjs", target: "es2022", define: { "import.meta.env": "{}" }
}).code;
const coreCode = compile(new URL("src/api/client.ts", coreRoot));
const filesCode = compile(new URL("../src/api/files.ts", import.meta.url));
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "", accessToken: "" };
const owner = { owner_type: "user", owner_id: "fixture-user" };
const fresh = { cache: "no-store" };
function harness(respond) {
const requests = [];
const context = vm.createContext({
Headers, Response, FormData, URL, URLSearchParams, AbortController, console,
document: { cookie: "" },
fetch: async (url, init) => {
const request = { url: new URL(url), ...init };
requests.push(request);
assert.equal(init?.method ?? "GET", "GET", "Reload read helpers must never mutate");
return new Response(JSON.stringify(respond(request)), { status: 200, headers: { "content-type": "application/json" } });
}
});
function load(code, dependencies) {
context.module = { exports: {} };
context.exports = context.module.exports;
context.require = (name) => {
assert.ok(name in dependencies, `Unexpected module import: ${name}`);
return dependencies[name];
};
vm.runInContext(`(function(module, exports, require) {\n${code}\n})(module, exports, require);`, context);
return context.module.exports;
}
const core = load(coreCode, { "../platform/temporal": { temporalRequestHeaders: () => ({}) } });
return { api: load(filesCode, { "@govoplan/core-webui": core }), requests };
}
for (const kind of ["spaces", "connector"]) {
const { api, requests } = harness(() => kind === "spaces" ? { spaces: [] } : { items: [], path: "source/nested" });
const read = (options) => kind === "spaces"
? api.listFileSpaces(settings, options)
: api.browseFileConnectorProfile(settings, "fixture-profile", { path: "source/nested" }, options);
await read();
await read();
assert.equal(requests.length, 1, "normal reads retain short-lived request reuse");
await read(fresh);
await read(fresh);
assert.equal(requests.length, 3, "each explicit Reload bypasses the shared recent-response cache");
assert.equal(requests[2].cache, "no-store");
}
{
const { api, requests } = harness(({ url }) => {
const next = url.searchParams.has("cursor");
if (url.pathname.endsWith("/folders")) return { folders: [], total: 0, next_cursor: next ? null : "folders-2", watermark: "start" };
if (url.pathname.endsWith("/delta")) return { full: false, files: [], folders: [], deleted: [], has_more: url.searchParams.get("since") === "start", watermark: url.searchParams.get("since") === "start" ? "middle" : "end" };
return { files: [], total: 0, next_cursor: next ? null : "files-2" };
});
await api.listManagedFileSnapshot(settings, owner, fresh);
await api.listManagedFileSnapshot(settings, owner, fresh);
assert.equal(requests.length, 12, "two snapshots each re-read both folder/file pages and both reconciliation pages");
assert.ok(requests.every((request) => request.cache === "no-store"));
}
{
const { api, requests } = harness(({ url }) => ({ files: [], total: 0, next_cursor: url.searchParams.has("cursor") ? null : "filtered-2" }));
await api.listFilesByProperties(settings, { ...owner, campaign_usage: "linked", audit_relevant: true }, fresh);
await api.listFilesByProperties(settings, { ...owner, campaign_usage: "linked", audit_relevant: true }, fresh);
assert.equal(requests.length, 4, "each filtered Reload must re-read every server result page");
assert.ok(requests.every((request) => request.cache === "no-store" && request.url.searchParams.get("campaign_usage") === "linked" && request.url.searchParams.get("audit_relevant") === "true"));
}
console.log("Files Reload: 4 real-client cache, read-only, pagination and filter checks passed.");
@@ -38,7 +38,17 @@ assert.match(filesPage, /topicId: "files\.workflow\.organize-managed-files"/);
assert.match(filesPage, /disabledReason=\{uploadBlocker\}/);
assert.match(filesPage, /disabledReason=\{deleteBlocker\}/);
assert.match(filesPage, /<ConfirmDialog[\s\S]*tone="danger"/);
assert.match(filesPage, /className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen files-page"/);
assert.match(filesPage, /<WorkspaceFrame as="main" height="viewport" surface="plain"/);
assert.match(filesPage, /const toolbar = <WorkspaceActionBar\s+scope="workspace"\s+variant="collection"\s+refreshable/);
assert.match(filesPage, /\{toolbar\}[\s\S]*className=\{`file-manager-shell/);
assert.match(filesPage, /const selectionToolbar = <WorkspaceActionBar\s+scope="detail-pane"/);
assert.match(filesPage, /<Dialog\s+open=\{toolsPanel !== null\}/);
assert.match(filesPage, /destructiveActions=\{<Button variant="danger"/);
const reload = filesPage.slice(filesPage.indexOf(" async function reloadCurrentView()"), filesPage.indexOf(" function applyManagedSpaceDelta"));
for (const readOperation of ["listManagedFileSnapshot", "resolveFilePatterns", "listFilesByProperties", "browseFileConnectorProfile"]) assert.ok(reload.includes(readOperation));
assert.doesNotMatch(reload, /syncConnector|importFile|uploadFile|deleteFile|resetTransientState|setCurrentFolder|clearSelection|setSearchActive/);
assert.match(reload, /if \(!isCurrent\(\)\) return;/);
assert.doesNotMatch(reload, /setBusy\(/, "manual reload owns only its own busy state");
assert.doesNotMatch(filesPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(`${connector}\n${integrity}\n${filesPage}\n${moduleSource}`, /@govoplan\/(?:campaign|mail|docs)-webui|govoplan_(?:campaign|mail|docs)/);
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8");
const page = read("../src/features/files/FilesPage.tsx");
const menu = read("../src/features/files/components/FileManagerComponents.tsx");
const api = read("../src/api/files.ts");
const translations = read("../src/i18n/generatedTranslations.ts");
assert.match(page, /selectedFiles\.length === 1 && selectedFolderPaths\.size === 0 && ARCHIVE_FILENAME_PATTERN/);
assert.match(page, /function openManagedArchive[\s\S]*?!canUpload \|\| !canDownload[\s\S]*?setManagedArchiveFile\(file\)/);
assert.match(page, /disabledReason=\{unpackBlocker\}/);
assert.match(menu, /onClick=\{onUnpackArchive\} disabled=\{!canUnpackArchive\}/);
assert.match(page, /file: File \| ManagedFile/);
assert.match(page, /previewManagedArchive\(settings, file\.id,[\s\S]*?source_version_id: file\.version_id/);
assert.match(page, /confirmManagedArchive\(settings, managedArchiveFile\.id,[\s\S]*?source_version_id: managedArchiveFile\.version_id/);
assert.match(page, /archivePreview && \(archiveFile \|\| managedArchiveFile\)/);
assert.match(page, /loadArchivePreview\(\(managedArchiveFile \|\| archiveFile\)!/);
assert.match(page, /if \(managedArchiveFile\) \{ setArchivePreview\(null\); setSelectedArchivePaths/);
assert.match(page, /onClose=\{\(\) => \{ if \(!busy\) closeDialog\(\); \}\}/);
assert.match(page, /<LoadingFrame loading=\{uploadActive\}[\s\S]*?indicator="none"[\s\S]*?progress=\{operationProgressValue\}/);
assert.match(page, /<div inert=\{uploadActive\}>/);
assert.match(page, /onArchiveProgress: \(progress: ArchiveOperationProgress\)/);
assert.match(page, /archiveProgress\?\.status === "complete" \? 100/);
assert.match(page, /archiveProgress.phase !== "finalizing"[\s\S]*?serverProgressRatio < 1/);
assert.match(menu, /closeDisabled=\{busy\}[\s\S]*?closeOnBackdrop=\{!busy\}/);
const feedback = page.slice(page.indexOf(" const selectedArchiveBytes ="), page.indexOf(" const connectorLocationLabel ="));
function operationFeedback(archiveProgress, uploadPhase = "unpacking", uploadProgress = null) {
const context = { archiveProgress, uploadPhase, uploadProgress, selectedArchivePaths: new Set(["one.txt", "empty.txt"]),
archivePreview: { entries: [{ path: "one.txt", kind: "file", size_bytes: 3 }, { path: "empty.txt", kind: "file", size_bytes: 0 }] },
formatBytes: (value) => `${value} B`, i18nMessage: (key, values) => ({ key, values }) };
vm.runInNewContext(`${feedback}\nglobalThis.result = { value: operationProgressValue, label: operationProgressLabel, phase: operationBusyLabel };`, context);
return context.result;
}
const measured = { phase: "extracting", status: "running", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6 };
assert.equal(operationFeedback(measured).value, 50);
assert.equal(operationFeedback({ ...measured, phase: "finalizing", completed_files: 2, completed_bytes: 6 }).value, null, "finalization does not fabricate completion");
assert.equal(operationFeedback({ ...measured, completed_files: 2, completed_bytes: 6 }).value, null, "processed bytes alone do not mean commit success");
assert.equal(operationFeedback({ ...measured, phase: "complete", status: "complete" }).value, 100);
assert.equal(operationFeedback({ ...measured, total_bytes: 0, completed_bytes: 0 }).value, 50, "empty members use actual file counts");
assert.equal(operationFeedback(null).value, null, "no measured result remains indeterminate");
assert.equal(operationFeedback(null).label.values.total, 2, "selected totals remain available before polling responds");
assert.equal(operationFeedback(null).label.values.bytes, "3 B");
assert.equal(operationFeedback(null, "uploading", 24).value, 24);
assert.equal(operationFeedback(null, "uploading", 100).value, null, "completed transfer is not completed extraction");
assert.equal(page.split('event.dataTransfer.effectAllowed = "copyMove";').length - 1, 2);
assert.doesNotMatch(page, /effectAllowed = "i18n:/, "browser drag/drop enums must not be translated");
for (const name of ["previewManagedArchive", "confirmManagedArchive"]) {
const body = api.slice(api.indexOf(`export function ${name}`)).split("\n}\n", 1)[0];
assert.match(body, /encodeURIComponent\(fileId\)/);
assert.match(body, /body: JSON\.stringify\(/);
assert.doesNotMatch(body, /FormData|downloadFile|Blob|createObjectURL/);
}
assert.match(page, /function resetArchiveUploadState\(\) \{\s*void releaseArchivePreview\(settings, archiveFile\)/, "reset/cancel releases any temporary archive before forgetting it");
assert.match(page, /archiveFile && archiveFile !== file\) void releaseArchivePreview/, "replacing a selected archive releases its previous temporary upload");
for (const key of ["unpack", "preview", "select_one", "source", "expires", "extracting", "change_destination"]) {
assert.equal(translations.split(`"i18n:govoplan-files.managed_archive.${key}":`).length - 1, 2, `${key} must have EN and DE text`);
}
console.log("Managed archive actions reuse the preview dialog and send source-bound JSON without browser re-upload.");
+157 -36
View File
@@ -389,6 +389,14 @@ export type ManagedFileSnapshotResponse = {files: ManagedFile[];folders: FileFol
export type FileSpacesResponse = {spaces: FileSpace[];};
export type FileUploadResponse = {files: ManagedFile[];};
export type FileUploadProgress = {loaded: number;total?: number;percentage: number | null;};
export type ArchiveOperationProgress = {
phase: "inspecting" | "extracting" | "storing" | "finalizing" | "complete" | "failed";
completed_files: number;
total_files: number;
completed_bytes: number;
total_bytes: number;
status: "running" | "complete" | "failed";
};
export type ArchivePreviewEntry = {
path: string;
kind: "file" | "directory";
@@ -397,6 +405,7 @@ export type ArchivePreviewEntry = {
encrypted: boolean;
};
export type ArchivePreviewResponse = {
staged_upload_id?: string | null;
preview_token: string;
archive_format: string;
entries: ArchivePreviewEntry[];
@@ -552,18 +561,20 @@ export type PatternResolveResponse = {
};
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
type FileReadOptions = Pick<RequestInit, "cache" | "signal">;
export function listFileSpaces(settings: ApiSettings, options?: FileReadOptions): Promise<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces", options);
}
export function listFolders(settings: ApiSettings, params: {owner_type: "user" | "group";owner_id: string;page_size?: number;cursor?: string | null;}): Promise<FileFoldersResponse> {
export function listFolders(settings: ApiSettings, params: {owner_type: "user" | "group";owner_id: string;page_size?: number;cursor?: string | null;}, options?: FileReadOptions): Promise<FileFoldersResponse> {
const search = new URLSearchParams();
search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id);
if (params.page_size) search.set("page_size", String(params.page_size));
if (params.cursor) search.set("cursor", params.cursor);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`, options);
}
export function createFolder(
@@ -580,13 +591,13 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?:
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
}
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;sort?: "path" | "recent";page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;sort?: "path" | "recent";page_size?: number;cursor?: string | null;} = {}, options?: FileReadOptions): Promise<FileListResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`, options);
}
export async function listFilesByProperties(
@@ -598,7 +609,7 @@ params: {
campaign_usage?: FileCampaignUsageFilter;
audit_relevant?: boolean;
page_size?: number;
})
}, options?: FileReadOptions)
: Promise<{files: ManagedFile[];total: number;}> {
const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE;
let cursor: string | null | undefined = null;
@@ -609,7 +620,7 @@ params: {
...params,
page_size: pageSize,
cursor
});
}, options);
files = files.concat(response.files);
total = response.total;
cursor = response.next_cursor;
@@ -619,14 +630,14 @@ params: {
export function listFilesDelta(
settings: ApiSettings,
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {}, options?: FileReadOptions)
: Promise<FileDeltaResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileDeltaResponse>(settings, `/api/v1/files/delta${suffix}`);
return apiFetch<FileDeltaResponse>(settings, `/api/v1/files/delta${suffix}`, options);
}
function applyDeltaToSnapshot(
@@ -648,7 +659,7 @@ response: FileDeltaResponse)
export async function listManagedFileSnapshot(
settings: ApiSettings,
params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page_size?: number;})
params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page_size?: number;}, options?: FileReadOptions)
: Promise<ManagedFileSnapshotResponse> {
const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE;
let watermark: string | null | undefined = null;
@@ -660,7 +671,7 @@ params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page
owner_id: params.owner_id,
page_size: pageSize,
cursor: folderCursor
});
}, options);
watermark = watermark || response.watermark;
folders = folders.concat(response.folders);
folderCursor = response.next_cursor;
@@ -675,7 +686,7 @@ params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page
path_prefix: params.path_prefix,
page_size: pageSize,
cursor: fileCursor
});
}, options);
watermark = watermark || response.watermark;
files = files.concat(response.files);
fileCursor = response.next_cursor;
@@ -691,7 +702,7 @@ params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page
path_prefix: params.path_prefix,
since,
limit: pageSize
});
}, options);
const snapshot = applyDeltaToSnapshot(files, folders, response);
files = snapshot.files;
folders = snapshot.folders;
@@ -736,7 +747,17 @@ options: {
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
}
export function previewArchiveUpload(
const stagedArchivePreviews = new WeakMap<File, ArchivePreviewResponse>();
export async function releaseArchivePreview(settings: ApiSettings, file: File | null): Promise<void> {
const preview = file ? stagedArchivePreviews.get(file) : undefined;
if (file) stagedArchivePreviews.delete(file);
if (preview?.staged_upload_id) {
await apiFetch(settings, `/api/v1/files/archive-staging/${encodeURIComponent(preview.staged_upload_id)}`, { method: "DELETE" }).catch(() => undefined);
}
}
export async function previewArchiveUpload(
settings: ApiSettings,
file: File,
options: {
@@ -745,16 +766,67 @@ options: {
path?: string;
campaign_id?: string;
password?: string;
onProgress?: (progress: FileUploadProgress) => void;
})
: Promise<ArchivePreviewResponse> {
let staged = stagedArchivePreviews.get(file);
if (staged && !(Date.parse(staged.expires_at) > Date.now())) {
void releaseArchivePreview(settings, file);
staged = undefined;
}
const form = new FormData();
form.append("file", file);
if (staged?.staged_upload_id) {
form.append("staged_upload_id", staged.staged_upload_id);
form.append("preview_token", staged.preview_token);
} else {
form.append("file", file);
}
form.append("retain_upload", "true");
form.append("owner_type", options.owner_type);
form.append("owner_id", options.owner_id);
form.append("path", options.path ?? "");
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
if (options.password) form.append("password", options.password);
return apiFetch<ArchivePreviewResponse>(settings, "/api/v1/files/archive-preview", { method: "POST", body: form });
try {
const result = options.onProgress && !staged?.staged_upload_id
? await uploadFilesWithProgress<ArchivePreviewResponse>(settings, form, options.onProgress, "/api/v1/files/archive-preview")
: await apiFetch<ArchivePreviewResponse>(settings, "/api/v1/files/archive-preview", { method: "POST", body: form });
stagedArchivePreviews.set(file, result);
return result;
} catch (error) {
// Preview is read-only with respect to managed files: an expired temporary
// stage can safely be uploaded once again. Never retry confirmation.
if (staged?.staged_upload_id && error instanceof ApiError && error.status === 410) {
stagedArchivePreviews.delete(file);
return previewArchiveUpload(settings, file, options);
}
throw error;
}
}
type ManagedArchiveOptions = {
source_version_id: string;
owner_type: "user" | "group";
owner_id: string;
path?: string;
password?: string;
};
export function previewManagedArchive(settings: ApiSettings, fileId: string, options: ManagedArchiveOptions): Promise<ArchivePreviewResponse> {
return apiFetch<ArchivePreviewResponse>(settings, `/api/v1/files/${encodeURIComponent(fileId)}/archive-preview`, {
method: "POST", body: JSON.stringify(options)
});
}
export function confirmManagedArchive(settings: ApiSettings, fileId: string, options: ManagedArchiveOptions & {
preview_token: string;
selected_paths: string[];
onArchiveProgress?: (progress: ArchiveOperationProgress) => void;
}): Promise<FileUploadResponse> {
const { onArchiveProgress, ...payload } = options;
return runArchiveOperation(settings, (operation_id) => apiFetch<FileUploadResponse>(settings, `/api/v1/files/${encodeURIComponent(fileId)}/archive-confirm`, {
method: "POST", body: JSON.stringify({ ...payload, operation_id })
}), onArchiveProgress);
}
export function confirmArchiveUpload(
@@ -774,10 +846,16 @@ options: {
source_revision?: string;
connector_policy_sources?: FileConnectorPolicySource[];
onProgress?: (progress: FileUploadProgress) => void;
onArchiveProgress?: (progress: ArchiveOperationProgress) => void;
})
: Promise<FileUploadResponse> {
const form = new FormData();
form.append("file", file);
const staged = stagedArchivePreviews.get(file);
if (staged?.staged_upload_id && staged.preview_token === options.preview_token) {
form.append("staged_upload_id", staged.staged_upload_id);
} else {
form.append("file", file);
}
form.append("preview_token", options.preview_token);
form.append("selected_paths_json", JSON.stringify(options.selected_paths));
form.append("owner_type", options.owner_type);
@@ -790,23 +868,59 @@ options: {
if (options.source_provenance) form.append("source_provenance_json", JSON.stringify(options.source_provenance));
if (options.source_revision) form.append("source_revision", options.source_revision);
if (options.connector_policy_sources?.length) form.append("connector_policy_json", JSON.stringify({ sources: options.connector_policy_sources }));
if (options.onProgress) {
return uploadFilesWithProgress(
settings,
form,
options.onProgress,
"/api/v1/files/archive-confirm"
);
}
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/archive-confirm", { method: "POST", body: form });
return runArchiveOperation(settings, (operationId) => {
if (operationId) form.append("operation_id", operationId);
return options.onProgress && !form.has("staged_upload_id")
? uploadFilesWithProgress(settings, form, options.onProgress, "/api/v1/files/archive-confirm")
: apiFetch<FileUploadResponse>(settings, "/api/v1/files/archive-confirm", { method: "POST", body: form });
}, options.onArchiveProgress).then((result) => {
stagedArchivePreviews.delete(file);
return result;
});
}
function uploadFilesWithProgress(
async function runArchiveOperation(
settings: ApiSettings,
request: (operationId?: string) => Promise<FileUploadResponse>,
onProgress?: (progress: ArchiveOperationProgress) => void
): Promise<FileUploadResponse> {
if (!onProgress) return request();
const operationId = crypto.randomUUID();
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = new AbortController();
const poll = async () => {
try {
const progress = await apiFetch<ArchiveOperationProgress>(settings, `/api/v1/files/archive-progress/${operationId}`, { cache: "no-store", signal: abort.signal });
if (!stopped) onProgress(progress);
} catch {
// Missing/expired or temporarily unreachable telemetry never retries or
// fails an import. Its authoritative result remains the POST response.
} finally {
if (!stopped) timer = setTimeout(() => void poll(), 500);
}
};
timer = setTimeout(() => void poll(), 150);
try {
const result = await request(operationId);
stopped = true;
const bytes = result.files.reduce((total, file) => total + file.size_bytes, 0);
onProgress({ phase: "complete", status: "complete", completed_files: result.files.length,
total_files: result.files.length, completed_bytes: bytes, total_bytes: bytes });
return result;
} finally {
stopped = true;
clearTimeout(timer);
abort.abort();
}
}
function uploadFilesWithProgress<T = FileUploadResponse>(
settings: ApiSettings,
form: FormData,
onProgress: (progress: FileUploadProgress) => void,
endpoint = "/api/v1/files/upload")
: Promise<FileUploadResponse> {
: Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl(settings, endpoint));
@@ -815,13 +929,15 @@ endpoint = "/api/v1/files/upload")
const csrf = csrfToken();
if (csrf) xhr.setRequestHeader("X-CSRF-Token", csrf);
let lastProgress: FileUploadProgress = { loaded: 0, total: undefined, percentage: 0 };
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : undefined;
onProgress({
lastProgress = {
loaded: event.loaded,
total,
percentage: total && total > 0 ? Math.round(event.loaded / total * 100) : null
});
};
onProgress(lastProgress);
};
xhr.onerror = () => reject(new Error("i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3"));
@@ -832,13 +948,18 @@ endpoint = "/api/v1/files/upload")
reject(new ApiError(xhr.status, xhr.statusText, responseText));
return;
}
onProgress({ loaded: 1, total: 1, percentage: 100 });
onProgress({ ...lastProgress, percentage: 100 });
if (xhr.status === 204 || !responseText) {
resolve({ files: [] });
resolve({ files: [] } as T);
return;
}
const contentType = xhr.getResponseHeader("content-type") || "";
resolve(contentType.includes("application/json") ? JSON.parse(responseText) as FileUploadResponse : { files: [] });
try {
if (!contentType.includes("application/json")) throw new Error("Expected JSON response");
resolve(JSON.parse(responseText) as T);
} catch {
reject(new ApiError(xhr.status, "Invalid JSON response", ""));
}
};
onProgress({ loaded: 0, total: undefined, percentage: 0 });
@@ -1185,7 +1306,7 @@ export function deactivateFileConnectorProfile(settings: ApiSettings, profileId:
export function browseFileConnectorProfile(
settings: ApiSettings,
profileId: string,
params: {path?: string;library_id?: string;continuation_token?: string;campaign_id?: string;} = {})
params: {path?: string;library_id?: string;continuation_token?: string;campaign_id?: string;} = {}, options?: FileReadOptions)
: Promise<FileConnectorBrowseResponse> {
const search = new URLSearchParams();
if (params.path) search.set("path", params.path);
@@ -1193,7 +1314,7 @@ params: {path?: string;library_id?: string;continuation_token?: string;campaign_
if (params.continuation_token) search.set("continuation_token", params.continuation_token);
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorBrowseResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/browse${suffix}`);
return apiFetch<FileConnectorBrowseResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/browse${suffix}`, options);
}
export function importFileConnectorFile(
+297 -86
View File
@@ -1,17 +1,23 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-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,
@@ -25,6 +31,7 @@ import {
createFileConnectorSpace,
createFolder,
confirmArchiveUpload,
confirmManagedArchive,
deleteFolder,
deleteFileConnectorSpace,
downloadFile,
@@ -37,6 +44,8 @@ import {
listFileSpaces,
listManagedFileSnapshot,
previewArchiveUpload,
previewManagedArchive,
releaseArchivePreview,
resolveFilePatterns,
syncFileConnectorSpaceFolder,
syncFileConnectorFile,
@@ -45,6 +54,7 @@ import {
virtualFolderResourceId,
type ArchivePreviewEntry,
type ArchivePreviewResponse,
type ArchiveOperationProgress,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
@@ -103,7 +113,7 @@ import { useFileTreeState } from "./hooks/useFileTreeState";
import { useFileDialogs } from "./hooks/useFileDialogs";
import { useFileDragDropState } from "./hooks/useFileDragDropState";
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
type UploadPhase = "idle" | "uploading" | "inspecting" | "unpacking" | "finalizing";
type AuditRelevantFilter = "" | "true" | "false";
type FileAccessExplanationTarget = {
resourceType: "file" | "folder";
@@ -155,13 +165,26 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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 [busy, setBusy] = useState(false);
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);
@@ -282,6 +305,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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];
@@ -366,6 +390,81 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
}
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 }));
@@ -450,6 +549,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
}
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
@@ -578,7 +685,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
function resetArchiveUploadState() {
void releaseArchivePreview(settings, archiveFile);
setArchiveProgress(null);
setArchiveFile(null);
setManagedArchiveFile(null);
setArchivePreview(null);
setArchivePassword("");
setSelectedArchivePaths(new Set());
@@ -590,6 +700,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setUploadActive(false);
setUploadPhase("idle");
setUploadProgress(null);
setArchiveProgress(null);
setConnectorError("");
setConnectorSelectedItem(null);
setConnectorSpaceLabel("");
@@ -600,6 +711,16 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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 {
@@ -801,29 +922,43 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
async function loadArchivePreview(
file: File,
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;
}
setArchiveFile(file);
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("uploading");
setUploadPhase(managed ? "inspecting" : "uploading");
setUploadProgress(null);
setArchiveProgress(null);
setError("");
setMessage("");
try {
const response = await previewArchiveUpload(settings, file, {
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")
@@ -889,7 +1024,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const targetSpace = target ? findSpace(target.spaceId) : null;
if (
busy
|| !archiveFile
|| (!archiveFile && !managedArchiveFile)
|| !archivePreview
|| !target
|| !targetSpace
@@ -907,25 +1042,35 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
setUploadProgress(0);
setUploadPhase("inspecting");
setUploadProgress(null);
setArchiveProgress(null);
setError("");
setMessage("Uploading the archive for confirmed extraction.");
setMessage("i18n:govoplan-files.archive_progress.inspecting");
try {
const response = await confirmArchiveUpload(settings, archiveFile, {
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");
@@ -986,6 +1131,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setUploadActive(true);
setUploadPhase("uploading");
setUploadProgress(0);
setArchiveProgress(null);
setError("");
setMessage(i18nMessage("i18n:govoplan-files.uploading_value_file_s.715ba963", { value0: selected.length }));
try {
@@ -1708,7 +1854,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
const state = { sourceSpaceId: activeSpace.id, fileIds: Array.from(sets.fileIds), folderPaths: Array.from(sets.folderPaths) };
setInternalDrag(state);
event.dataTransfer.effectAllowed = "i18n:govoplan-files.copymove.d0fa5904";
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)`);
}
@@ -1719,7 +1865,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
if (!folderPath) return;
const state = { sourceSpaceId: spaceId, fileIds: [], folderPaths: [folderPath] };
setInternalDrag(state);
event.dataTransfer.effectAllowed = "i18n:govoplan-files.copymove.d0fa5904";
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state));
event.dataTransfer.setData("text/plain", folderPath);
}
@@ -2240,7 +2386,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`;
}
const noticeTone = message.startsWith("i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped") ? "warning" :
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";
@@ -2249,6 +2395,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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." : "");
@@ -2268,57 +2415,61 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
["i18n:govoplan-files.folder_sync.summary.failed", folderSyncResult.summary.failed]
] as const : [];
const toolbar =
<ActionToolbar className="file-manager-toolbar" aria-label="i18n:govoplan-files.file_actions.9e1b94c5">
<Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={Boolean(uploadBlocker)} disabledReason={uploadBlocker}><UploadCloud size={16} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</Button>
<Button
onClick={() => activeSpaceIsConnector ? void syncConnectorSpaceSelection() : void openConnectorSyncDialog(toolbarTarget())}
disabled={Boolean(syncBlocker)}
disabledReason={syncBlocker}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309
</Button>
{activeSpaceIsConnector &&
<Button onClick={openConnectorFolderSyncDialog} disabled={Boolean(folderSyncBlocker)} disabledReason={folderSyncBlocker}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.folder_sync.button
</Button>
}
<Button onClick={() => 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>
<Button onClick={() => void downloadSelection()} disabled={Boolean(downloadBlocker)} disabledReason={downloadBlocker}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={Boolean(shareBlocker)} disabledReason={shareBlocker}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
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 onClick={() => openTransferDialog("move")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
{hasSelection && <Button onClick={openRenameDialog} disabled={Boolean(organizeBlocker)} disabledReason={organizeBlocker}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
<Button variant="danger" helpContextId="files.list" helpModuleId="files" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector &&
<Button
variant="danger"
onClick={() => 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>
}
{activeSpaceIsConnector &&
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace} disabledReason={workingBlocker || (connectorSpaceLoading ? "This connector space is already refreshing." : !activeSpace ? "Select a connector space first." : "")}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc
</Button>
}
<DocumentationHelpLink reference={FILES_WORKFLOW_DOCUMENTATION} />
</ActionToolbar>;
<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 uploadBusyLabel = uploadPhase === "unpacking" ? "i18n:govoplan-files.unpacking_zip_archive.698095f4" : "i18n:govoplan-files.uploading_files.6536791d";
const uploadProgressLabel = uploadPhase === "unpacking" ?
"i18n:govoplan-files.extracting_files_on_the_server.845a3c1a" :
uploadProgress !== null && uploadProgress >= 100 ?
"i18n:govoplan-files.finalizing_upload.bcce936d" :
undefined;
const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress;
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";
@@ -2406,7 +2557,6 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<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>
<Button onClick={() => void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
</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">
@@ -2462,7 +2612,49 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const currentFolderDropActive = currentFolderDropTarget ? dropTargetKey === dropTargetId(currentFolderDropTarget) : false;
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen files-page">
<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>
}
@@ -2524,7 +2716,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<section className="file-list-panel" aria-label="i18n:govoplan-files.current_folder_contents.f9a24fa8">
<div className="file-list-sticky">
{toolbar}
{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"}
@@ -2555,7 +2747,6 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
onClear={clearPropertyFilters} />
<div className="file-list-meta">
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
<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:
@@ -2700,6 +2891,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
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")}
@@ -2735,14 +2928,30 @@ 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}>
<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 &&
<>
<ToggleSwitch
{!managedArchiveFile && <ToggleSwitch
label="Preview and unpack archive"
checked={unpackZip}
onChange={setUnpackZip}
disabled={busy} />
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">
@@ -2759,25 +2968,22 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
<FileDropZone
{!managedArchiveFile && <FileDropZone
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
busy={uploadActive}
progress={visibleUploadProgress}
busyLabel={uploadBusyLabel}
progressLabel={uploadProgressLabel}
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 })} />
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 &&
{archivePreview && (archiveFile || managedArchiveFile) &&
<div className="archive-preview">
<div className="archive-preview-summary">
<div>
<strong>{archiveFile.name}</strong>
<strong>{managedArchiveFile?.filename || archiveFile?.name}</strong>
<span>{archivePreview.archive_format.toUpperCase()} · {formatBytes(archivePreview.compressed_size_bytes)} compressed</span>
</div>
<div>
@@ -2840,30 +3046,33 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<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-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">
Preview expires {formatDate(archivePreview.expires_at)}. The original archive is uploaded again only when you confirm.
{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={() => {
resetArchiveUploadState();
if (managedArchiveFile) { setArchivePreview(null); setSelectedArchivePaths(new Set()); }
else resetArchiveUploadState();
setError("");
}}
disabled={busy}>
Choose another file
{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(archiveFile, activeDialogTarget, { preserveSelection: true })}
onClick={() => activeDialogTarget && void loadArchivePreview((managedArchiveFile || archiveFile)!, activeDialogTarget, { preserveSelection: true })}
disabled={busy || !archivePassword}>
<RefreshCw size={15} aria-hidden="true" /> Verify password
</Button>
@@ -2883,6 +3092,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
</div>
</div>
}
</div>
</LoadingFrame>
</FileDialog>
}
@@ -3243,7 +3454,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
</FileDialog>
}
</div>);
</WorkspaceFrame>);
}
@@ -195,12 +195,14 @@ export function RenamePreviewList({
}
export function FileDialog({ title, onClose, children }: {title: string;onClose: () => void;children: ReactNode;}) {
export function FileDialog({ title, onClose, children, busy = false }: {title: string;onClose: () => void;children: ReactNode;busy?: boolean;}) {
return (
<Dialog
open
title={title}
onClose={onClose}
closeDisabled={busy}
closeOnBackdrop={!busy}
backdropClassName="file-dialog-backdrop"
className="file-dialog"
headerClassName="file-dialog-header"
@@ -217,6 +219,7 @@ export function FileContextMenu({
hasSelection,
canCreateFolder,
canUpload,
canUnpackArchive,
canDownload,
canOrganize,
canDelete,
@@ -224,6 +227,7 @@ export function FileContextMenu({
downloadLabel,
onCreateFolder,
onUpload,
onUnpackArchive,
onDownload,
onMove,
onCopy,
@@ -244,13 +248,13 @@ export function FileContextMenu({
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canUnpackArchive: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onUnpackArchive: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
const showNewFolder = true;
const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight;
const estimatedWidth = 220;
const estimatedHeight = 260;
const estimatedHeight = 300;
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
const openUp = menu.y + estimatedHeight > viewportHeight;
const style: CSSProperties = openUp ?
@@ -260,6 +264,7 @@ export function FileContextMenu({
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> i18n:govoplan-files.new_folder.a711999b</button>}
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</button>
<button type="button" role="menuitem" onClick={onUnpackArchive} disabled={!canUnpackArchive}>i18n:govoplan-files.managed_archive.unpack</button>
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
+62
View File
@@ -2,6 +2,37 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-files.tools.connections": "Connections and imports",
"i18n:govoplan-files.tools.connections_description": "Browse linked sources or import files explicitly. Reload only refreshes the current listing; it never synchronizes or imports files.",
"i18n:govoplan-files.tools.selection": "Manage selection",
"i18n:govoplan-files.tools.organize": "Organize files and folders",
"i18n:govoplan-files.tools.organize_description": "Move, copy, or rename the selected items. The next dialog lets you review the destination or preview the change.",
"i18n:govoplan-files.tools.sharing_access": "Sharing and access",
"i18n:govoplan-files.tools.destructive": "Removal actions",
"i18n:govoplan-files.tools.delete_description": "Deletion requires confirmation. Retention and audit protections continue to apply.",
"i18n:govoplan-files.tools.import_sync": "Import and synchronize",
"i18n:govoplan-files.tools.import_sync_description": "These actions can create or update managed files. A selected remote file is synchronized explicitly; folder synchronization has its own scope and conflict review.",
"i18n:govoplan-files.tools.spaces": "Linked file spaces",
"i18n:govoplan-files.tools.remove_space_description": "Remove only the local link after confirmation. Remote provider files and previously imported managed files remain unchanged.",
"i18n:govoplan-files.archive_progress.inspecting": "Inspecting the archive…",
"i18n:govoplan-files.archive_progress.extracting": "Extracting selected archive files…",
"i18n:govoplan-files.archive_progress.storing": "Storing extracted files…",
"i18n:govoplan-files.archive_progress.finalizing": "Finalizing and committing changes…",
"i18n:govoplan-files.archive_progress.processed": "{completed} of {total} files · {bytes} of {totalBytes} processed. Keep this dialog open until completion.",
"i18n:govoplan-files.archive_progress.selected": "{total} files selected · {bytes}. Waiting for server progress; keep this dialog open.",
"i18n:govoplan-files.archive_progress.transferred": "{percentage}% transferred to the server. Inspection and processing follow separately.",
"i18n:govoplan-files.archive_progress.waiting": "Waiting for a measured result. Keep this dialog open; no completion percentage is available yet.",
"i18n:govoplan-files.archive_progress.preview_expires": "Preview expires {expires}. Confirmation reuses the protected temporary archive when available and rechecks its contents and your destination.",
"i18n:govoplan-files.managed_archive.unpack": "Unpack archive",
"i18n:govoplan-files.managed_archive.select_one": "Select exactly one managed ZIP or TAR archive first.",
"i18n:govoplan-files.managed_archive.download_required": "File download permission is required to unpack an existing archive.",
"i18n:govoplan-files.managed_archive.source": "Source: {value0}. The managed archive remains unchanged; no browser download or re-upload is needed.",
"i18n:govoplan-files.managed_archive.preview": "Preview archive",
"i18n:govoplan-files.managed_archive.protection": "Extracted files use normal upload storage. Archive passwords and a source storage encryption envelope are not automatically applied to individual files.",
"i18n:govoplan-files.managed_archive.inspecting": "Inspecting the managed archive…",
"i18n:govoplan-files.managed_archive.extracting": "Extracting the selected archive files. Keep this dialog open until the operation finishes.",
"i18n:govoplan-files.managed_archive.expires": "Preview expires {value0}. Confirmation rechecks the source version and access; existing destination files and the source archive are never overwritten.",
"i18n:govoplan-files.managed_archive.change_destination": "Change destination",
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",
@@ -401,6 +432,37 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.writable.dd35487a": "Writable"
},
"de": {
"i18n:govoplan-files.tools.connections": "Verbindungen und Importe",
"i18n:govoplan-files.tools.connections_description": "Verknüpfte Quellen durchsuchen oder Dateien ausdrücklich importieren. Neu laden aktualisiert nur die aktuelle Liste; es synchronisiert oder importiert keine Dateien.",
"i18n:govoplan-files.tools.selection": "Auswahl verwalten",
"i18n:govoplan-files.tools.organize": "Dateien und Ordner organisieren",
"i18n:govoplan-files.tools.organize_description": "Ausgewählte Elemente verschieben, kopieren oder umbenennen. Im nächsten Dialog prüfen Sie das Ziel oder eine Vorschau der Änderung.",
"i18n:govoplan-files.tools.sharing_access": "Freigaben und Zugriff",
"i18n:govoplan-files.tools.destructive": "Entfernen",
"i18n:govoplan-files.tools.delete_description": "Das Löschen erfordert eine Bestätigung. Aufbewahrungsvorgaben und Schutz für prüfrelevante Dateien gelten weiterhin.",
"i18n:govoplan-files.tools.import_sync": "Importieren und synchronisieren",
"i18n:govoplan-files.tools.import_sync_description": "Diese Aktionen können verwaltete Dateien anlegen oder aktualisieren. Eine ausgewählte entfernte Datei wird ausdrücklich synchronisiert; die Ordnersynchronisierung hat einen eigenen Umfang und eine Konfliktprüfung.",
"i18n:govoplan-files.tools.spaces": "Verknüpfte Dateibereiche",
"i18n:govoplan-files.tools.remove_space_description": "Nach Bestätigung wird nur die lokale Verknüpfung entfernt. Dateien beim entfernten Anbieter und bereits importierte verwaltete Dateien bleiben unverändert.",
"i18n:govoplan-files.archive_progress.inspecting": "Archiv wird geprüft…",
"i18n:govoplan-files.archive_progress.extracting": "Ausgewählte Archivdateien werden entpackt…",
"i18n:govoplan-files.archive_progress.storing": "Entpackte Dateien werden gespeichert…",
"i18n:govoplan-files.archive_progress.finalizing": "Änderungen werden abgeschlossen und verbindlich gespeichert…",
"i18n:govoplan-files.archive_progress.processed": "{completed} von {total} Dateien · {bytes} von {totalBytes} verarbeitet. Diesen Dialog bis zum Abschluss geöffnet lassen.",
"i18n:govoplan-files.archive_progress.selected": "{total} Dateien ausgewählt · {bytes}. Serverfortschritt wird erwartet; diesen Dialog geöffnet lassen.",
"i18n:govoplan-files.archive_progress.transferred": "{percentage}% an den Server übertragen. Prüfung und Verarbeitung folgen gesondert.",
"i18n:govoplan-files.archive_progress.waiting": "Ein gemessenes Ergebnis wird erwartet. Diesen Dialog geöffnet lassen; ein Abschlussprozentsatz ist noch nicht verfügbar.",
"i18n:govoplan-files.archive_progress.preview_expires": "Die Vorschau läuft am {expires} ab. Die Bestätigung verwendet das geschützte temporäre Archiv erneut, sofern verfügbar, und prüft Inhalt sowie Ziel erneut.",
"i18n:govoplan-files.managed_archive.unpack": "Archiv entpacken",
"i18n:govoplan-files.managed_archive.select_one": "Wählen Sie zunächst genau ein verwaltetes ZIP- oder TAR-Archiv aus.",
"i18n:govoplan-files.managed_archive.download_required": "Zum Entpacken eines vorhandenen Archivs ist die Berechtigung zum Herunterladen erforderlich.",
"i18n:govoplan-files.managed_archive.source": "Quelle: {value0}. Das verwaltete Archiv bleibt unverändert; Herunterladen und erneutes Hochladen im Browser sind nicht erforderlich.",
"i18n:govoplan-files.managed_archive.preview": "Archivvorschau",
"i18n:govoplan-files.managed_archive.protection": "Entpackte Dateien verwenden die normalen Upload-Speichereinstellungen. Archivpasswörter und eine Speicher-Verschlüsselungshülle der Quelle werden nicht automatisch auf einzelne Dateien angewendet.",
"i18n:govoplan-files.managed_archive.inspecting": "Verwaltetes Archiv wird geprüft…",
"i18n:govoplan-files.managed_archive.extracting": "Die ausgewählten Archivdateien werden entpackt. Lassen Sie diesen Dialog bis zum Abschluss geöffnet.",
"i18n:govoplan-files.managed_archive.expires": "Die Vorschau läuft am {value0} ab. Beim Bestätigen werden Quellversion und Zugriff erneut geprüft. Vorhandene Zieldateien und das Quellarchiv werden niemals überschrieben.",
"i18n:govoplan-files.managed_archive.change_destination": "Ziel ändern",
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",