diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 43792728bc..6740b7a674 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -16,6 +16,7 @@ import { ArchonTransfersInternalModule } from './archon/transfers/internal' import { ISO3166Module } from './iso3166' import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosFilesV0Module } from './kyros/files/v0' +import { KyrosFilesV1Module } from './kyros/files/v1' import { KyrosLogsV1Module } from './kyros/logs/v1' import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1' import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth' @@ -98,6 +99,7 @@ export const MODULE_REGISTRY = { launchermeta_manifest_v0: LauncherMetaManifestV0Module, kyros_content_v1: KyrosContentV1Module, kyros_files_v0: KyrosFilesV0Module, + kyros_files_v1: KyrosFilesV1Module, kyros_logs_v1: KyrosLogsV1Module, kyros_upload_sessions_v1: KyrosUploadSessionsV1Module, labrinth_affiliate_internal: LabrinthAffiliateInternalModule, diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index b027294e33..f31ff5ec00 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -239,18 +239,20 @@ export class KyrosFilesV0Module extends AbstractModule { * @param path - Path to archive file * @param override - If true, overwrite existing files * @param dry - If true, perform dry run (returns conflicts without extracting) + * @param target - Directory to extract the archive into * @returns Extract result with modpack name and conflicting files */ public async extractFile( path: string, override: boolean = true, dry: boolean = false, + target: string = '/', ): Promise { return this.client.request('/fs/unarchive', { api: '', version: 'v1', method: 'POST', - params: { src: path, trg: '/', override, dry }, + params: { src: path, trg: target, override, dry }, useNodeAuth: true, }) } diff --git a/packages/api-client/src/modules/kyros/files/v1.ts b/packages/api-client/src/modules/kyros/files/v1.ts new file mode 100644 index 0000000000..789718cfa2 --- /dev/null +++ b/packages/api-client/src/modules/kyros/files/v1.ts @@ -0,0 +1,87 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Kyros } from '../types' + +export class KyrosFilesV1Module extends AbstractModule { + public getModuleID(): string { + return 'kyros_files_v1' + } + + /** + * Get metadata for a path in world storage. + */ + public async stat( + worldId: string, + data: Kyros.Files.v1.FileStatRequest, + ): Promise { + return this.client.request(`/worlds/${worldId}/files/stat`, { + api: '', + version: 'v1', + method: 'POST', + body: data, + useNodeAuth: true, + }) + } + + /** + * Create a ZIP archive beside a directory in world storage. + */ + public async createZip( + worldId: string, + data: Kyros.Files.v1.ZipRequest, + onProgress?: (record: Kyros.Files.v1.ZipProgress) => void, + ): Promise { + const stream = await this.client.stream(`/worlds/${worldId}/files/zip`, { + api: '', + version: 'v1', + method: 'POST', + body: data, + useNodeAuth: true, + headers: { Accept: 'application/json-seq' }, + }) + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + let completed = false + + const parseRecord = (value: string) => { + const trimmedValue = value.trim() + const text = trimmedValue.startsWith('\u001e') ? trimmedValue.slice(1).trim() : trimmedValue + if (!text) return + const record = JSON.parse(text) as Kyros.Files.v1.ZipProgress + onProgress?.(record) + if (record.error) throw new Error(record.error) + if (record.done === true) completed = true + } + + const parseRecords = (flush = false) => { + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + parseRecord(buffer.slice(0, newlineIndex)) + buffer = buffer.slice(newlineIndex + 1) + newlineIndex = buffer.indexOf('\n') + } + if (flush && buffer.trim()) { + parseRecord(buffer) + buffer = '' + } + } + + try { + while (!completed) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + parseRecords() + } + if (completed) { + await reader.cancel().catch(() => undefined) + return + } + buffer += decoder.decode() + parseRecords(true) + if (!completed) throw new Error('ZIP operation ended before completion') + } finally { + reader.releaseLock() + } + } +} diff --git a/packages/api-client/src/modules/kyros/types.ts b/packages/api-client/src/modules/kyros/types.ts index b649ba645a..5f78a3e61a 100644 --- a/packages/api-client/src/modules/kyros/types.ts +++ b/packages/api-client/src/modules/kyros/types.ts @@ -28,6 +28,41 @@ export namespace Kyros { } export namespace Files { + export namespace v1 { + export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other' + + export interface FileStatRequest { + path: string + } + + export interface FileStatResponse { + name: string + full_path: string + size_bytes: number + type: DescendantType + mtime: string + ctime: string + } + + export type ZipRequest = + | { + target_type: 'Directory' + path: string + } + | { + target_type: 'ManyPaths' + parent: string + include: string[] + target: string + } + + export interface ZipProgress { + progress: number + done?: boolean + error?: string + } + } + export namespace v0 { export interface DirectoryItem { name: string diff --git a/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue b/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue index dc96fe350a..3493213fba 100644 --- a/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue +++ b/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue @@ -4,15 +4,22 @@ :dismissible="dismissible && isTerminal" :progress="'progress' in op ? (op.progress ?? 0) : 0" :progress-color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'" - :waiting="op.state === 'queued' || !op.progress || op.progress === 0" + :waiting="!isTerminal && (op.state === 'queued' || !op.progress || op.progress === 0)" @dismiss="$emit('dismiss')" > - + + {{ op.error }} + + + {{ formatMessage(messages.compressed, { progress: Math.round((op.progress ?? 0) * 100) }) }} + + {{ formatMessage(messages.extracted, { size: formatBytes(op.bytes_processed ?? 0), @@ -25,7 +32,7 @@