Skip to content

Commit 2c4fc59

Browse files
chriscrosstalkclaude
authored andcommitted
feat(ContentManager): Display friendly names instead of filenames
Content Manager now shows Title and Summary columns from Kiwix metadata instead of just raw filenames. Metadata is captured when files are downloaded from Content Explorer and stored in a new zim_file_metadata table. Existing files without metadata gracefully fall back to showing the filename. Changes: - Add zim_file_metadata table and model for storing title, summary, author - Update download flow to capture and store metadata from Kiwix library - Update Content Manager UI to display Title and Summary columns - Clean up metadata when ZIM files are deleted Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3b31be6 commit 2c4fc59

File tree

9 files changed

+156
-15
lines changed

9 files changed

+156
-15
lines changed

admin/app/controllers/zim_controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ZimService } from '#services/zim_service'
22
import {
33
downloadCollectionValidator,
44
filenameParamValidator,
5-
remoteDownloadValidator,
5+
remoteDownloadWithMetadataValidator,
66
saveInstalledTierValidator,
77
selectWikipediaValidator,
88
} from '#validators/common'
@@ -25,8 +25,8 @@ export default class ZimController {
2525
}
2626

2727
async downloadRemote({ request }: HttpContext) {
28-
const payload = await request.validateUsing(remoteDownloadValidator)
29-
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
28+
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
29+
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
3030

3131
return {
3232
message: 'Download started successfully',
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { DateTime } from 'luxon'
2+
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
3+
4+
export default class ZimFileMetadata extends BaseModel {
5+
static namingStrategy = new SnakeCaseNamingStrategy()
6+
7+
@column({ isPrimary: true })
8+
declare id: number
9+
10+
@column()
11+
declare filename: string
12+
13+
@column()
14+
declare title: string
15+
16+
@column()
17+
declare summary: string | null
18+
19+
@column()
20+
declare author: string | null
21+
22+
@column()
23+
declare size_bytes: number | null
24+
25+
@column.dateTime({ autoCreate: true })
26+
declare created_at: DateTime
27+
28+
@column.dateTime({ autoCreate: true, autoUpdate: true })
29+
declare updated_at: DateTime
30+
}

admin/app/services/zim_service.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import CuratedCollection from '#models/curated_collection'
2424
import CuratedCollectionResource from '#models/curated_collection_resource'
2525
import InstalledTier from '#models/installed_tier'
2626
import WikipediaSelection from '#models/wikipedia_selection'
27+
import ZimFileMetadata from '#models/zim_file_metadata'
2728
import { RunDownloadJob } from '#jobs/run_download_job'
2829
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
2930
import { SERVICE_NAMES } from '../../constants/service_names.js'
@@ -52,8 +53,24 @@ export class ZimService implements IZimService {
5253
const all = await listDirectoryContents(dirPath)
5354
const files = all.filter((item) => item.name.endsWith('.zim'))
5455

56+
// Fetch metadata for all files
57+
const metadataRecords = await ZimFileMetadata.all()
58+
const metadataMap = new Map(metadataRecords.map((m) => [m.filename, m]))
59+
60+
// Enrich files with metadata
61+
const enrichedFiles = files.map((file) => {
62+
const metadata = metadataMap.get(file.name)
63+
return {
64+
...file,
65+
title: metadata?.title || null,
66+
summary: metadata?.summary || null,
67+
author: metadata?.author || null,
68+
size_bytes: metadata?.size_bytes || null,
69+
}
70+
})
71+
5572
return {
56-
files,
73+
files: enrichedFiles,
5774
}
5875
}
5976

@@ -148,7 +165,10 @@ export class ZimService implements IZimService {
148165
}
149166
}
150167

151-
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
168+
async downloadRemote(
169+
url: string,
170+
metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }
171+
): Promise<{ filename: string; jobId?: string }> {
152172
const parsed = new URL(url)
153173
if (!parsed.pathname.endsWith('.zim')) {
154174
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
@@ -167,6 +187,20 @@ export class ZimService implements IZimService {
167187

168188
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
169189

190+
// Store metadata if provided
191+
if (metadata) {
192+
await ZimFileMetadata.updateOrCreate(
193+
{ filename },
194+
{
195+
title: metadata.title,
196+
summary: metadata.summary || null,
197+
author: metadata.author || null,
198+
size_bytes: metadata.size_bytes || null,
199+
}
200+
)
201+
logger.info(`[ZimService] Stored metadata for ZIM file: ${filename}`)
202+
}
203+
170204
// Dispatch a background download job
171205
const result = await RunDownloadJob.dispatch({
172206
url,
@@ -347,6 +381,10 @@ export class ZimService implements IZimService {
347381
}
348382

349383
await deleteFileIfExists(fullPath)
384+
385+
// Clean up metadata
386+
await ZimFileMetadata.query().where('filename', fileName).delete()
387+
logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`)
350388
}
351389

352390
// Wikipedia selector methods

admin/app/validators/common.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@ export const remoteDownloadValidator = vine.compile(
1111
})
1212
)
1313

14+
export const remoteDownloadWithMetadataValidator = vine.compile(
15+
vine.object({
16+
url: vine
17+
.string()
18+
.url({
19+
require_tld: false, // Allow local URLs
20+
})
21+
.trim(),
22+
metadata: vine
23+
.object({
24+
title: vine.string().trim().minLength(1),
25+
summary: vine.string().trim().optional(),
26+
author: vine.string().trim().optional(),
27+
size_bytes: vine.number().optional(),
28+
})
29+
.optional(),
30+
})
31+
)
32+
1433
export const remoteDownloadValidatorOptional = vine.compile(
1534
vine.object({
1635
url: vine
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'zim_file_metadata'
5+
6+
async up() {
7+
this.schema.createTable(this.tableName, (table) => {
8+
table.increments('id').primary()
9+
table.string('filename').notNullable().unique()
10+
table.string('title').notNullable()
11+
table.text('summary').nullable()
12+
table.string('author').nullable()
13+
table.bigInteger('size_bytes').nullable()
14+
table.timestamp('created_at')
15+
table.timestamp('updated_at')
16+
})
17+
}
18+
19+
async down() {
20+
this.schema.dropTable(this.tableName)
21+
}
22+
}

admin/inertia/lib/api.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,14 @@ class API {
9999
})()
100100
}
101101

102-
async downloadRemoteZimFile(url: string) {
102+
async downloadRemoteZimFile(
103+
url: string,
104+
metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }
105+
) {
103106
return catchInternal(async () => {
104107
const response = await this.client.post<{ message: string; filename: string; url: string }>(
105108
'/zim/download-remote',
106-
{ url }
109+
{ url, metadata }
107110
)
108111
return response.data
109112
})()

admin/inertia/pages/settings/zim/index.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import { useModals } from '~/context/ModalContext'
88
import StyledModal from '~/components/StyledModal'
99
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
1010
import Alert from '~/components/Alert'
11-
import { FileEntry } from '../../../../types/files'
11+
import { ZimFileWithMetadata } from '../../../../types/zim'
1212
import { SERVICE_NAMES } from '../../../../constants/service_names'
1313

1414
export default function ZimPage() {
1515
const queryClient = useQueryClient()
1616
const { openModal, closeAllModals } = useModals()
1717
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
18-
const { data, isLoading } = useQuery<FileEntry[]>({
18+
const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
1919
queryKey: ['zim-files'],
2020
queryFn: getFiles,
2121
})
@@ -25,7 +25,7 @@ export default function ZimPage() {
2525
return res.data.files
2626
}
2727

28-
async function confirmDeleteFile(file: FileEntry) {
28+
async function confirmDeleteFile(file: ZimFileWithMetadata) {
2929
openModal(
3030
<StyledModal
3131
title="Confirm Delete?"
@@ -48,7 +48,7 @@ export default function ZimPage() {
4848
}
4949

5050
const deleteFileMutation = useMutation({
51-
mutationFn: async (file: FileEntry) => api.deleteZimFile(file.name.replace('.zim', '')),
51+
mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')),
5252
onSuccess: () => {
5353
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
5454
},
@@ -75,13 +75,30 @@ export default function ZimPage() {
7575
className="!mt-6"
7676
/>
7777
)}
78-
<StyledTable<FileEntry & { actions?: any }>
78+
<StyledTable<ZimFileWithMetadata & { actions?: any }>
7979
className="font-semibold mt-4"
8080
rowLines={true}
8181
loading={isLoading}
8282
compact
8383
columns={[
84-
{ accessor: 'name', title: 'Name' },
84+
{
85+
accessor: 'title',
86+
title: 'Title',
87+
render: (record) => (
88+
<span className="font-medium">
89+
{record.title || record.name}
90+
</span>
91+
),
92+
},
93+
{
94+
accessor: 'summary',
95+
title: 'Summary',
96+
render: (record) => (
97+
<span className="text-gray-600 text-sm line-clamp-2">
98+
{record.summary || '—'}
99+
</span>
100+
),
101+
},
85102
{
86103
accessor: 'actions',
87104
title: 'Actions',

admin/inertia/pages/settings/zim/remote-explorer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@ export default function ZimRemoteExplorer() {
209209

210210
async function downloadFile(record: RemoteZimFileEntry) {
211211
try {
212-
await api.downloadRemoteZimFile(record.download_url)
212+
await api.downloadRemoteZimFile(record.download_url, {
213+
title: record.title,
214+
summary: record.summary,
215+
author: record.author,
216+
size_bytes: record.size_bytes,
217+
})
213218
invalidateDownloads()
214219
} catch (error) {
215220
console.error('Error downloading file:', error)

admin/types/zim.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { FileEntry } from './files.js'
22

3+
export type ZimFileWithMetadata = FileEntry & {
4+
title: string | null
5+
summary: string | null
6+
author: string | null
7+
size_bytes: number | null
8+
}
9+
310
export type ListZimFilesResponse = {
4-
files: FileEntry[]
11+
files: ZimFileWithMetadata[]
512
next?: string
613
}
714

0 commit comments

Comments
 (0)