mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-08 09:46:15 +02:00
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>
This commit is contained in:
parent
3b31be66f9
commit
2c4fc59428
|
|
@ -2,7 +2,7 @@ import { ZimService } from '#services/zim_service'
|
||||||
import {
|
import {
|
||||||
downloadCollectionValidator,
|
downloadCollectionValidator,
|
||||||
filenameParamValidator,
|
filenameParamValidator,
|
||||||
remoteDownloadValidator,
|
remoteDownloadWithMetadataValidator,
|
||||||
saveInstalledTierValidator,
|
saveInstalledTierValidator,
|
||||||
selectWikipediaValidator,
|
selectWikipediaValidator,
|
||||||
} from '#validators/common'
|
} from '#validators/common'
|
||||||
|
|
@ -25,8 +25,8 @@ export default class ZimController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadRemote({ request }: HttpContext) {
|
async downloadRemote({ request }: HttpContext) {
|
||||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
||||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
|
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Download started successfully',
|
message: 'Download started successfully',
|
||||||
|
|
|
||||||
30
admin/app/models/zim_file_metadata.ts
Normal file
30
admin/app/models/zim_file_metadata.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import CuratedCollection from '#models/curated_collection'
|
||||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
import CuratedCollectionResource from '#models/curated_collection_resource'
|
||||||
import InstalledTier from '#models/installed_tier'
|
import InstalledTier from '#models/installed_tier'
|
||||||
import WikipediaSelection from '#models/wikipedia_selection'
|
import WikipediaSelection from '#models/wikipedia_selection'
|
||||||
|
import ZimFileMetadata from '#models/zim_file_metadata'
|
||||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
|
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
|
||||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||||
|
|
@ -52,8 +53,24 @@ export class ZimService implements IZimService {
|
||||||
const all = await listDirectoryContents(dirPath)
|
const all = await listDirectoryContents(dirPath)
|
||||||
const files = all.filter((item) => item.name.endsWith('.zim'))
|
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 {
|
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)
|
const parsed = new URL(url)
|
||||||
if (!parsed.pathname.endsWith('.zim')) {
|
if (!parsed.pathname.endsWith('.zim')) {
|
||||||
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .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)
|
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
|
// Dispatch a background download job
|
||||||
const result = await RunDownloadJob.dispatch({
|
const result = await RunDownloadJob.dispatch({
|
||||||
url,
|
url,
|
||||||
|
|
@ -347,6 +381,10 @@ export class ZimService implements IZimService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteFileIfExists(fullPath)
|
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
|
// Wikipedia selector methods
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export const remoteDownloadValidatorOptional = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
url: vine
|
url: vine
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post<{ message: string; filename: string; url: string }>(
|
const response = await this.client.post<{ message: string; filename: string; url: string }>(
|
||||||
'/zim/download-remote',
|
'/zim/download-remote',
|
||||||
{ url }
|
{ url, metadata }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ import { useModals } from '~/context/ModalContext'
|
||||||
import StyledModal from '~/components/StyledModal'
|
import StyledModal from '~/components/StyledModal'
|
||||||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
import { FileEntry } from '../../../../types/files'
|
import { ZimFileWithMetadata } from '../../../../types/zim'
|
||||||
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
||||||
|
|
||||||
export default function ZimPage() {
|
export default function ZimPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { openModal, closeAllModals } = useModals()
|
const { openModal, closeAllModals } = useModals()
|
||||||
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
|
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
|
||||||
const { data, isLoading } = useQuery<FileEntry[]>({
|
const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
|
||||||
queryKey: ['zim-files'],
|
queryKey: ['zim-files'],
|
||||||
queryFn: getFiles,
|
queryFn: getFiles,
|
||||||
})
|
})
|
||||||
|
|
@ -25,7 +25,7 @@ export default function ZimPage() {
|
||||||
return res.data.files
|
return res.data.files
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteFile(file: FileEntry) {
|
async function confirmDeleteFile(file: ZimFileWithMetadata) {
|
||||||
openModal(
|
openModal(
|
||||||
<StyledModal
|
<StyledModal
|
||||||
title="Confirm Delete?"
|
title="Confirm Delete?"
|
||||||
|
|
@ -48,7 +48,7 @@ export default function ZimPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteFileMutation = useMutation({
|
const deleteFileMutation = useMutation({
|
||||||
mutationFn: async (file: FileEntry) => api.deleteZimFile(file.name.replace('.zim', '')),
|
mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
|
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
|
||||||
},
|
},
|
||||||
|
|
@ -75,13 +75,30 @@ export default function ZimPage() {
|
||||||
className="!mt-6"
|
className="!mt-6"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StyledTable<FileEntry & { actions?: any }>
|
<StyledTable<ZimFileWithMetadata & { actions?: any }>
|
||||||
className="font-semibold mt-4"
|
className="font-semibold mt-4"
|
||||||
rowLines={true}
|
rowLines={true}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
compact
|
compact
|
||||||
columns={[
|
columns={[
|
||||||
{ accessor: 'name', title: 'Name' },
|
{
|
||||||
|
accessor: 'title',
|
||||||
|
title: 'Title',
|
||||||
|
render: (record) => (
|
||||||
|
<span className="font-medium">
|
||||||
|
{record.title || record.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'summary',
|
||||||
|
title: 'Summary',
|
||||||
|
render: (record) => (
|
||||||
|
<span className="text-gray-600 text-sm line-clamp-2">
|
||||||
|
{record.summary || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessor: 'actions',
|
accessor: 'actions',
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,12 @@ export default function ZimRemoteExplorer() {
|
||||||
|
|
||||||
async function downloadFile(record: RemoteZimFileEntry) {
|
async function downloadFile(record: RemoteZimFileEntry) {
|
||||||
try {
|
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()
|
invalidateDownloads()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading file:', error)
|
console.error('Error downloading file:', error)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { FileEntry } from './files.js'
|
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 = {
|
export type ListZimFilesResponse = {
|
||||||
files: FileEntry[]
|
files: ZimFileWithMetadata[]
|
||||||
next?: string
|
next?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user