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
+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(