diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index df58a1d..d1b861b 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -2,7 +2,7 @@ import { ZimService } from '#services/zim_service' import { downloadCollectionValidator, filenameParamValidator, - remoteDownloadValidator, + remoteDownloadWithMetadataValidator, saveInstalledTierValidator, selectWikipediaValidator, } from '#validators/common' @@ -25,8 +25,8 @@ export default class ZimController { } async downloadRemote({ request }: HttpContext) { - const payload = await request.validateUsing(remoteDownloadValidator) - const { filename, jobId } = await this.zimService.downloadRemote(payload.url) + const payload = await request.validateUsing(remoteDownloadWithMetadataValidator) + const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata) return { message: 'Download started successfully', diff --git a/admin/app/models/zim_file_metadata.ts b/admin/app/models/zim_file_metadata.ts new file mode 100644 index 0000000..bc7107d --- /dev/null +++ b/admin/app/models/zim_file_metadata.ts @@ -0,0 +1,30 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' + +export default class ZimFileMetadata extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare filename: string + + @column() + declare title: string + + @column() + declare summary: string | null + + @column() + declare author: string | null + + @column() + declare size_bytes: number | null + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index a00a389..870cb95 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -24,6 +24,7 @@ import CuratedCollection from '#models/curated_collection' import CuratedCollectionResource from '#models/curated_collection_resource' import InstalledTier from '#models/installed_tier' import WikipediaSelection from '#models/wikipedia_selection' +import ZimFileMetadata from '#models/zim_file_metadata' import { RunDownloadJob } from '#jobs/run_download_job' import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js' import { SERVICE_NAMES } from '../../constants/service_names.js' @@ -52,8 +53,24 @@ export class ZimService implements IZimService { const all = await listDirectoryContents(dirPath) const files = all.filter((item) => item.name.endsWith('.zim')) + // Fetch metadata for all files + const metadataRecords = await ZimFileMetadata.all() + const metadataMap = new Map(metadataRecords.map((m) => [m.filename, m])) + + // Enrich files with metadata + const enrichedFiles = files.map((file) => { + const metadata = metadataMap.get(file.name) + return { + ...file, + title: metadata?.title || null, + summary: metadata?.summary || null, + author: metadata?.author || null, + size_bytes: metadata?.size_bytes || null, + } + }) + return { - files, + files: enrichedFiles, } } @@ -148,7 +165,10 @@ export class ZimService implements IZimService { } } - async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> { + async downloadRemote( + url: string, + metadata?: { title: string; summary?: string; author?: string; size_bytes?: number } + ): Promise<{ filename: string; jobId?: string }> { const parsed = new URL(url) if (!parsed.pathname.endsWith('.zim')) { throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`) @@ -167,6 +187,20 @@ export class ZimService implements IZimService { const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename) + // Store metadata if provided + if (metadata) { + await ZimFileMetadata.updateOrCreate( + { filename }, + { + title: metadata.title, + summary: metadata.summary || null, + author: metadata.author || null, + size_bytes: metadata.size_bytes || null, + } + ) + logger.info(`[ZimService] Stored metadata for ZIM file: ${filename}`) + } + // Dispatch a background download job const result = await RunDownloadJob.dispatch({ url, @@ -347,6 +381,10 @@ export class ZimService implements IZimService { } await deleteFileIfExists(fullPath) + + // Clean up metadata + await ZimFileMetadata.query().where('filename', fileName).delete() + logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`) } // Wikipedia selector methods diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index 49436dc..dc5dbc1 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -11,6 +11,25 @@ export const remoteDownloadValidator = vine.compile( }) ) +export const remoteDownloadWithMetadataValidator = vine.compile( + vine.object({ + url: vine + .string() + .url({ + require_tld: false, // Allow local URLs + }) + .trim(), + metadata: vine + .object({ + title: vine.string().trim().minLength(1), + summary: vine.string().trim().optional(), + author: vine.string().trim().optional(), + size_bytes: vine.number().optional(), + }) + .optional(), + }) +) + export const remoteDownloadValidatorOptional = vine.compile( vine.object({ url: vine diff --git a/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts b/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts new file mode 100644 index 0000000..02bbe53 --- /dev/null +++ b/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'zim_file_metadata' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('filename').notNullable().unique() + table.string('title').notNullable() + table.text('summary').nullable() + table.string('author').nullable() + table.bigInteger('size_bytes').nullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index b9d10a1..aa7853d 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -99,11 +99,14 @@ class API { })() } - async downloadRemoteZimFile(url: string) { + async downloadRemoteZimFile( + url: string, + metadata?: { title: string; summary?: string; author?: string; size_bytes?: number } + ) { return catchInternal(async () => { const response = await this.client.post<{ message: string; filename: string; url: string }>( '/zim/download-remote', - { url } + { url, metadata } ) return response.data })() diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index d784e99..da2238b 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -8,14 +8,14 @@ import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' -import { FileEntry } from '../../../../types/files' +import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) - const { data, isLoading } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['zim-files'], queryFn: getFiles, }) @@ -25,7 +25,7 @@ export default function ZimPage() { return res.data.files } - async function confirmDeleteFile(file: FileEntry) { + async function confirmDeleteFile(file: ZimFileWithMetadata) { openModal( api.deleteZimFile(file.name.replace('.zim', '')), + mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['zim-files'] }) }, @@ -75,13 +75,30 @@ export default function ZimPage() { className="!mt-6" /> )} - + className="font-semibold mt-4" rowLines={true} loading={isLoading} compact columns={[ - { accessor: 'name', title: 'Name' }, + { + accessor: 'title', + title: 'Title', + render: (record) => ( + + {record.title || record.name} + + ), + }, + { + accessor: 'summary', + title: 'Summary', + render: (record) => ( + + {record.summary || '—'} + + ), + }, { accessor: 'actions', title: 'Actions', diff --git a/admin/inertia/pages/settings/zim/remote-explorer.tsx b/admin/inertia/pages/settings/zim/remote-explorer.tsx index fae5e58..7ff4230 100644 --- a/admin/inertia/pages/settings/zim/remote-explorer.tsx +++ b/admin/inertia/pages/settings/zim/remote-explorer.tsx @@ -209,7 +209,12 @@ export default function ZimRemoteExplorer() { async function downloadFile(record: RemoteZimFileEntry) { try { - await api.downloadRemoteZimFile(record.download_url) + await api.downloadRemoteZimFile(record.download_url, { + title: record.title, + summary: record.summary, + author: record.author, + size_bytes: record.size_bytes, + }) invalidateDownloads() } catch (error) { console.error('Error downloading file:', error) diff --git a/admin/types/zim.ts b/admin/types/zim.ts index 305d52d..68f7194 100644 --- a/admin/types/zim.ts +++ b/admin/types/zim.ts @@ -1,7 +1,14 @@ import { FileEntry } from './files.js' +export type ZimFileWithMetadata = FileEntry & { + title: string | null + summary: string | null + author: string | null + size_bytes: number | null +} + export type ListZimFilesResponse = { - files: FileEntry[] + files: ZimFileWithMetadata[] next?: string }