Release govoplan-files v0.1.26: speed archive workflows and unify file tools
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+157
-36
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user