From af6aec8decccdea022205a91b5569ca75291ced8 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 23 Jun 2026 14:54:18 +0100 Subject: [PATCH] feat: migrate files to v1 --- packages/api-client/src/modules/index.ts | 2 + .../api-client/src/modules/kyros/files/v1.ts | 93 +++++++++++ .../api-client/src/modules/kyros/types.ts | 55 ++++++ .../[id]/instances/[instance-id]/files.vue | 158 +++++++++--------- 4 files changed, 232 insertions(+), 76 deletions(-) create mode 100644 packages/api-client/src/modules/kyros/files/v1.ts diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index e435d2f823..7c1781603f 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -15,6 +15,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' @@ -88,6 +89,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/v1.ts b/packages/api-client/src/modules/kyros/files/v1.ts new file mode 100644 index 0000000000..c4039416c3 --- /dev/null +++ b/packages/api-client/src/modules/kyros/files/v1.ts @@ -0,0 +1,93 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Kyros } from '../types' + +export class KyrosFilesV1Module extends AbstractModule { + public getModuleID(): string { + return 'kyros_files_v1' + } + + public async listDescendants( + worldId: string, + path: string, + page: number = 1, + itemsPerPage: number = 100, + ): Promise { + return this.client.request( + `/worlds/${worldId}/files/list`, + { + api: '', + version: 'v1', + method: 'POST', + body: { + path, + page, + items_per_page: itemsPerPage, + }, + useNodeAuth: true, + }, + ) + } + + public async downloadRawFileContents(worldId: string, path: string): Promise { + return this.client.request(`/worlds/${worldId}/files/contents-raw`, { + api: '', + version: 'v1', + method: 'GET', + params: { path }, + useNodeAuth: true, + }) + } + + public async downloadTokenizedContents(worldId: string, downloadId: string): Promise { + return this.client.request(`/worlds/${worldId}/files/contents/${downloadId}`, { + api: '', + version: 'v1', + method: 'GET', + useNodeAuth: true, + }) + } + + public async deleteFile(worldId: string, path: string): Promise { + return this.client.request(`/worlds/${worldId}/files/delete`, { + api: '', + version: 'v1', + method: 'POST', + body: { path }, + useNodeAuth: true, + }) + } + + public async moveFile( + worldId: string, + source: string, + destination: string, + ): Promise { + return this.client.request( + `/worlds/${worldId}/files/move`, + { + api: '', + version: 'v1', + method: 'POST', + body: { source, destination }, + useNodeAuth: true, + }, + ) + } + + public async renameFile( + worldId: string, + path: string, + name: string, + ): Promise { + return this.client.request( + `/worlds/${worldId}/files/rename`, + { + api: '', + version: 'v1', + method: 'POST', + body: { path, name }, + useNodeAuth: true, + }, + ) + } +} diff --git a/packages/api-client/src/modules/kyros/types.ts b/packages/api-client/src/modules/kyros/types.ts index b649ba645a..4f61f38aa0 100644 --- a/packages/api-client/src/modules/kyros/types.ts +++ b/packages/api-client/src/modules/kyros/types.ts @@ -28,6 +28,61 @@ export namespace Kyros { } export namespace Files { + export namespace v1 { + export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other' + + export interface CreateDownloadSessionRequest { + path: string + zipped: boolean + } + + export interface DeleteFileRequest { + path: string + } + + export interface FileListingItem { + name: string + full_path: string + size_bytes: number + type: DescendantType + mtime: string + ctime: string + descendants: number + } + + export interface FileListingRequest { + path: string + page: number + items_per_page: number + } + + export interface FileListingResponse { + items: FileListingItem[] + page: number + items_per_page: number + page_total: number + items_total: number + too_many_descendants: boolean + descendants_limit: number + digest?: string | null + } + + export interface FileMutationResponse { + source: string + destination: string + } + + export interface MoveFileRequest { + source: string + destination: string + } + + export interface RenameFileRequest { + path: string + name: string + } + } + export namespace v0 { export interface DirectoryItem { name: string diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue index 3759614d67..ecb4a5c8a9 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/instances/[instance-id]/files.vue @@ -119,38 +119,77 @@ function initializeFileEdit() { } } +function getWorldId() { + if (!worldId.value) { + throw new Error('World ID is not available.') + } + return worldId.value +} + +function timestampSeconds(value: string) { + return Math.floor(new Date(value).getTime() / 1000) +} + +function toFileItem(item: Kyros.Files.v1.FileListingItem): FileItem | null { + if (item.type === 'other') return null + + return { + name: item.name, + type: item.type === 'regular' ? 'file' : item.type, + path: item.full_path, + modified: timestampSeconds(item.mtime), + created: timestampSeconds(item.ctime), + ...(item.type === 'directory' ? { count: item.descendants } : { size: item.size_bytes }), + } +} + // Directory listing query const { data: directoryData, isLoading, error: loadError, } = useQuery({ - queryKey: computed(() => ['files', serverId, currentPath.value]), + queryKey: computed(() => ['files', 'v1', worldId.value, currentPath.value]), queryFn: async () => { - return client.kyros.files_v0.listDirectory(currentPath.value, 1, 2000) + return client.kyros.files_v1.listDescendants(getWorldId(), currentPath.value, 1, 200) }, + enabled: computed(() => !!worldId.value), staleTime: 30_000, }) -function isVisibleFileItem(item: Kyros.Files.v0.DirectoryItem) { +function isVisibleFileItem(item: FileItem) { return !item.path.split('/').includes('.modrinth-staged') } const items = computed(() => - (directoryData.value?.items ?? []).filter(isVisibleFileItem), + (directoryData.value?.items ?? []) + .map(toFileItem) + .filter((item): item is FileItem => !!item) + .filter(isVisibleFileItem), ) const filesReadyPending = useReadyState({ isLoading, data: directoryData }) // Prefetching function prefetchDirectory(path: string) { + const id = worldId.value + if (!id) return + queryClient.prefetchQuery({ - queryKey: ['files', serverId, path], + queryKey: ['files', 'v1', id, path], queryFn: async () => { try { - return await client.kyros.files_v0.listDirectory(path, 1, 2000) + return await client.kyros.files_v1.listDescendants(id, path, 1, 200) } catch { - return { items: [], total: 0, current: 1 } + return { + items: [], + page: 1, + items_per_page: 200, + page_total: 0, + items_total: 0, + too_many_descendants: false, + descendants_limit: 0, + } } }, staleTime: 30_000, @@ -158,11 +197,14 @@ function prefetchDirectory(path: string) { } function prefetchFile(path: string) { + const id = worldId.value + if (!id) return + queryClient.prefetchQuery({ - queryKey: ['file-content', serverId, path], + queryKey: ['file-content', id, path], queryFn: async () => { try { - const blob = await client.kyros.files_v0.downloadFile(path) + const blob = await client.kyros.files_v1.downloadRawFileContents(id, path) return await blob.text() } catch { return null @@ -173,24 +215,24 @@ function prefetchFile(path: string) { } function getQueryKey() { - return ['files', serverId, currentPath.value] + return ['files', 'v1', worldId.value, currentPath.value] } function refreshList() { - queryClient.invalidateQueries({ queryKey: ['files', serverId] }) + queryClient.invalidateQueries({ queryKey: ['files', 'v1', worldId.value] }) } // Mutations const deleteMutation = useMutation({ - mutationFn: ({ path, recursive }: { path: string; recursive: boolean }) => - client.kyros.files_v0.deleteFileOrFolder(path, recursive), + mutationFn: ({ path }: { path: string; recursive: boolean }) => + client.kyros.files_v1.deleteFile(getWorldId(), path), onMutate: async ({ path }) => { const queryKey = getQueryKey() await queryClient.cancelQueries({ queryKey }) const previous = queryClient.getQueryData(queryKey) - queryClient.setQueryData(queryKey, (old: Kyros.Files.v0.DirectoryResponse | undefined) => { + queryClient.setQueryData(queryKey, (old: Kyros.Files.v1.FileListingResponse | undefined) => { if (!old) return old - return { ...old, items: old.items.filter((item) => item.path !== path) } + return { ...old, items: old.items.filter((item) => item.full_path !== path) } }) return { previous } }, @@ -210,27 +252,27 @@ const deleteMutation = useMutation({ }) }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['files', serverId] }) + refreshList() }, }) const renameMutation = useMutation({ mutationFn: ({ path, newName }: { path: string; newName: string }) => - client.kyros.files_v0.renameFileOrFolder(path, newName), + client.kyros.files_v1.renameFile(getWorldId(), path, newName), onMutate: async ({ path, newName }) => { const queryKey = getQueryKey() await queryClient.cancelQueries({ queryKey }) const previous = queryClient.getQueryData(queryKey) - queryClient.setQueryData(queryKey, (old: Kyros.Files.v0.DirectoryResponse | undefined) => { + queryClient.setQueryData(queryKey, (old: Kyros.Files.v1.FileListingResponse | undefined) => { if (!old) return old return { ...old, items: old.items.map((item) => - item.path === path + item.full_path === path ? { ...item, name: newName, - path: item.path.replace(/[^/]+$/, newName), + full_path: item.full_path.replace(/[^/]+$/, newName), } : item, ), @@ -250,20 +292,20 @@ const renameMutation = useMutation({ addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' }) }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['files', serverId] }) + refreshList() }, }) const moveMutation = useMutation({ mutationFn: ({ source, destination }: { source: string; destination: string }) => - client.kyros.files_v0.moveFileOrFolder(source, destination), + client.kyros.files_v1.moveFile(getWorldId(), source, destination), onMutate: async ({ source }) => { const queryKey = getQueryKey() await queryClient.cancelQueries({ queryKey }) const previous = queryClient.getQueryData(queryKey) - queryClient.setQueryData(queryKey, (old: Kyros.Files.v0.DirectoryResponse | undefined) => { + queryClient.setQueryData(queryKey, (old: Kyros.Files.v1.FileListingResponse | undefined) => { if (!old) return old - return { ...old, items: old.items.filter((item) => item.path !== source) } + return { ...old, items: old.items.filter((item) => item.full_path !== source) } }) return { previous } }, @@ -279,86 +321,50 @@ const moveMutation = useMutation({ addNotification({ title: 'Moved', text: `Moved to ${destination}`, type: 'success' }) }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['files', serverId] }) + refreshList() }, }) const createMutation = useMutation({ - mutationFn: ({ path, type }: { path: string; type: 'file' | 'directory' }) => - client.kyros.files_v0.createFileOrFolder(path, type), - onMutate: async ({ path, type }) => { - const queryKey = getQueryKey() - await queryClient.cancelQueries({ queryKey }) - const previous = queryClient.getQueryData(queryKey) - const name = path.split('/').pop()! - const now = Math.floor(Date.now() / 1000) - const newItem: Kyros.Files.v0.DirectoryItem = { - name, - path, - type, - modified: now, - created: now, - ...(type === 'directory' ? { count: 0 } : { size: 0 }), - } - queryClient.setQueryData(queryKey, (old: Kyros.Files.v0.DirectoryResponse | undefined) => { - if (!old) return old - return { ...old, items: [newItem, ...old.items] } - }) - return { previous } + mutationFn: (_vars: { path: string; type: 'file' | 'directory' }) => { + // await client.kyros.files_v0.createFileOrFolder(path, type) + throw new Error('Creating files or folders is not supported by the v1 world-scoped files API.') }, - onError: (err: Error, _vars, context) => { - queryClient.setQueryData(getQueryKey(), context?.previous) + onError: (err: Error) => { addNotification({ title: formatMessage(commonMessages.createFailedLabel), text: err.message, type: 'error', }) }, - onSuccess: (_, { path, type }) => { - const name = path.split('/').pop() - addNotification({ - title: `${type === 'directory' ? 'Folder' : 'File'} created`, - text: `Created ${name}`, - type: 'success', - }) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['files', serverId] }) - }, }) -// Extraction -async function extractFile(path: string, override: boolean, dry: boolean) { - if (fileWriteDisabled.value) return - if (dry) { - return await client.kyros.files_v0.extractFile(path, override, true) - } - await client.kyros.files_v0.extractFile(path, override, false) -} - // File I/O async function readFile(path: string): Promise { const normalizedPath = path.startsWith('/') ? path : `/${path}` - const cachedContent = queryClient.getQueryData(['file-content', serverId, normalizedPath]) + const id = getWorldId() + const cachedContent = queryClient.getQueryData(['file-content', id, normalizedPath]) if (cachedContent) return cachedContent - const blob = await client.kyros.files_v0.downloadFile(normalizedPath) + const blob = await client.kyros.files_v1.downloadRawFileContents(id, normalizedPath) return await blob.text() } async function readFileAsBlob(path: string): Promise { const normalizedPath = path.startsWith('/') ? path : `/${path}` - return await client.kyros.files_v0.downloadFile(normalizedPath) + return await client.kyros.files_v1.downloadRawFileContents(getWorldId(), normalizedPath) } async function writeFile(path: string, content: string): Promise { if (fileWriteDisabled.value) return - await client.kyros.files_v0.updateFile(path, content) - queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }) + void path + void content + // await client.kyros.files_v0.updateFile(path, content) + throw new Error('Updating file contents is not supported by the v1 world-scoped files API.') } async function downloadFile(path: string, fileName: string): Promise { try { - const fileData = await client.kyros.files_v0.downloadFile(path) + const fileData = await client.kyros.files_v1.downloadRawFileContents(getWorldId(), path) if (fileData) { const blob = new Blob([fileData], { type: 'application/octet-stream' }) const link = document.createElement('a') @@ -460,10 +466,10 @@ provideFileManager({ isBusy: fileWriteDisabled, busyTooltip: fileWriteDisabledTooltip, busyWarning, - extractFile, + // extractFile: async (path, override, dry) => client.kyros.files_v0.extractFile(path, override, dry), prefetchDirectory, prefetchFile, - showInstallFromUrl: true, + showInstallFromUrl: false, canRestart: canUsePowerActions.value, restartServer, canShareToMclogs: true,