mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-02 03:24:51 +08:00
feat(zim): let users browse the Kiwix library in any language
Content Explorer hardcoded `lang: 'eng'`, so it could only ever show about 1,300 of the catalog's ~10,900 books. Adds a language selector sourced live from the catalog's own language feed, with book counts, defaulting to English and remembered per browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
This commit is contained in:
committed by
Jake Turner
co-authored by
Claude Opus 5
parent
880c80f52f
commit
576464abc4
@@ -25,8 +25,12 @@ export default class ZimController {
|
||||
|
||||
async listRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(listRemoteZimValidator)
|
||||
const { start = 0, count = 12, query } = payload
|
||||
return await this.zimService.listRemote({ start, count, query })
|
||||
const { start = 0, count = 12, query, language } = payload
|
||||
return await this.zimService.listRemote({ start, count, query, language })
|
||||
}
|
||||
|
||||
async listCatalogLanguages({}: HttpContext) {
|
||||
return { languages: await this.zimService.listCatalogLanguages() }
|
||||
}
|
||||
|
||||
async downloadRemote({ request }: HttpContext) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CatalogLanguage,
|
||||
ListRemoteZimFilesResponse,
|
||||
RawRemoteZimFileEntry,
|
||||
RemoteZimFileEntry,
|
||||
@@ -77,10 +78,18 @@ export class ZimService {
|
||||
start,
|
||||
count,
|
||||
query,
|
||||
language = 'eng',
|
||||
}: {
|
||||
start: number
|
||||
count: number
|
||||
query?: string
|
||||
/**
|
||||
* ISO-639-3 code to filter the catalog by, or `all` for no filter. Defaults to
|
||||
* English, which is what this browser has always shown -- but it is now a default
|
||||
* the user can change, not a hardcode. English is ~1,300 of the catalog's ~10,900
|
||||
* books, so the filter was hiding the large majority of the library.
|
||||
*/
|
||||
language?: string
|
||||
}): Promise<ListRemoteZimFilesResponse> {
|
||||
// Kiwix returns pages of content unaware of what the user has installed locally. When
|
||||
// the installed set is large, a single 12-item Kiwix page can come back with everything
|
||||
@@ -111,7 +120,8 @@ export class ZimService {
|
||||
params: {
|
||||
start: currentStart,
|
||||
count: KIWIX_PAGE_SIZE,
|
||||
lang: 'eng',
|
||||
// `all` means "no filter", which the catalog expresses by omitting `lang`.
|
||||
...(language && language !== 'all' ? { lang: language } : {}),
|
||||
...(query ? { q: query } : {}),
|
||||
},
|
||||
responseType: 'text',
|
||||
@@ -191,6 +201,62 @@ export class ZimService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The languages the Kiwix catalog actually holds books in, newest count first.
|
||||
*
|
||||
* Sourced live from the catalog rather than a bundled ISO list, so the options are
|
||||
* always exactly what can be browsed, and each carries a real book count. Titles are
|
||||
* the catalog's own endonyms ("français", "中文"), which is what a reader scanning for
|
||||
* their own language recognises.
|
||||
*
|
||||
* Requires internet, like the rest of this browser. Failures return an empty list
|
||||
* rather than throwing: a missing language filter should degrade to "English only",
|
||||
* never take down the page that lists the books.
|
||||
*/
|
||||
async listCatalogLanguages(): Promise<CatalogLanguage[]> {
|
||||
const LANGUAGES_URL = 'https://opds.library.kiwix.org/catalog/v2/languages'
|
||||
try {
|
||||
const res = await axios.get(LANGUAGES_URL, { responseType: 'text', timeout: 15000 })
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
textNodeName: '#text',
|
||||
})
|
||||
const parsed = parser.parse(res.data)
|
||||
const rawEntries = parsed?.feed?.entry
|
||||
? Array.isArray(parsed.feed.entry)
|
||||
? parsed.feed.entry
|
||||
: [parsed.feed.entry]
|
||||
: []
|
||||
|
||||
const languages: CatalogLanguage[] = []
|
||||
for (const raw of rawEntries) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
// `dc:language` is the ISO-639-3 code; `thr:count` is how many books carry it.
|
||||
const code = raw['dc:language']
|
||||
const label = raw.title
|
||||
const bookCount = Number(raw['thr:count'])
|
||||
if (typeof code !== 'string' || !code.trim()) continue
|
||||
if (!Number.isFinite(bookCount) || bookCount <= 0) continue
|
||||
languages.push({
|
||||
code: code.trim(),
|
||||
label: typeof label === 'string' && label.trim() ? label.trim() : code.trim(),
|
||||
book_count: bookCount,
|
||||
})
|
||||
}
|
||||
|
||||
languages.sort((a, b) => b.book_count - a.book_count)
|
||||
return languages
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[ZimService] Catalog language list unavailable: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
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')) {
|
||||
|
||||
@@ -5,6 +5,14 @@ export const listRemoteZimValidator = vine.compile(
|
||||
start: vine.number().min(0).optional(),
|
||||
count: vine.number().min(1).max(100).optional(),
|
||||
query: vine.string().optional(),
|
||||
// An ISO-639-3 code (`eng`, `fra`, `zho`) or the literal `all`. Constrained to a
|
||||
// short alphabetic token so this can never smuggle anything else into the upstream
|
||||
// catalog query string.
|
||||
language: vine
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(all|[a-z]{2,8})$/)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios'
|
||||
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
||||
import {
|
||||
ListCatalogLanguagesResponse,
|
||||
ListRemoteZimFilesResponse,
|
||||
ListZimFilesResponse,
|
||||
} from '../../types/zim'
|
||||
import { ServiceSlim } from '../../types/services'
|
||||
import { FileEntry } from '../../types/files'
|
||||
import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, ContentAutoUpdateStatus, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
||||
@@ -832,10 +836,12 @@ class API {
|
||||
start = 0,
|
||||
count = 12,
|
||||
query,
|
||||
language,
|
||||
}: {
|
||||
start?: number
|
||||
count?: number
|
||||
query?: string
|
||||
language?: string
|
||||
}) {
|
||||
return catchInternal(async () => {
|
||||
return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {
|
||||
@@ -843,11 +849,20 @@ class API {
|
||||
start,
|
||||
count,
|
||||
query,
|
||||
language,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
async listCatalogLanguages() {
|
||||
return catchInternal(async () => {
|
||||
const response =
|
||||
await this.client.get<ListCatalogLanguagesResponse>('/zim/catalog-languages')
|
||||
return response.data.languages
|
||||
})()
|
||||
}
|
||||
|
||||
async listCustomLibraries() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ id: number; name: string; base_url: string; is_default: boolean }[]>(
|
||||
|
||||
@@ -45,6 +45,7 @@ import { ZimFileWithMetadata } from '../../../../types/zim'
|
||||
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
||||
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
||||
const CUSTOM_LIBRARIES_KEY = 'custom-libraries'
|
||||
const CATALOG_LANGUAGES_KEY = 'catalog-languages'
|
||||
const ZIM_FILES_KEY = 'zim-files'
|
||||
|
||||
type CustomLibrary = { id: number; name: string; base_url: string; is_default: boolean }
|
||||
@@ -82,6 +83,15 @@ export default function ZimRemoteExplorer() {
|
||||
} catch {}
|
||||
return 'default'
|
||||
})
|
||||
// Catalog language filter - also persisted, since someone browsing in their own
|
||||
// language wants that on every visit, not once per session.
|
||||
const [language, setLanguage] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem('nomad:zim-library-language') || 'eng'
|
||||
} catch {
|
||||
return 'eng'
|
||||
}
|
||||
})
|
||||
const [browseUrl, setBrowseUrl] = useState<string | null>(null)
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<{ name: string; url: string }[]>([])
|
||||
const [manageModalOpen, setManageModalOpen] = useState(false)
|
||||
@@ -140,15 +150,31 @@ export default function ZimRemoteExplorer() {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// The catalog's own language list, so the options are exactly what can be browsed and
|
||||
// each carries a real book count. Returns [] when offline; the selector hides itself.
|
||||
const { data: catalogLanguages } = useQuery({
|
||||
queryKey: [CATALOG_LANGUAGES_KEY],
|
||||
queryFn: () => api.listCatalogLanguages(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
})
|
||||
|
||||
const { data, fetchNextPage, isFetching, isLoading } =
|
||||
useInfiniteQuery<ListRemoteZimFilesResponse>({
|
||||
queryKey: ['remote-zim-files', query],
|
||||
// `language` is part of the key, so changing it resets pagination rather than
|
||||
// appending a second language's pages onto the first one's.
|
||||
queryKey: ['remote-zim-files', query, language],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// pageParam is an opaque Kiwix offset returned by the backend as `next_start`.
|
||||
// The backend accumulates across multiple upstream pages when needed (#731), so the
|
||||
// frontend can't derive the next offset from a 12-item page assumption.
|
||||
const start = typeof pageParam === 'number' ? pageParam : 0
|
||||
const res = await api.listRemoteZimFiles({ start, count: 12, query: query || undefined })
|
||||
const res = await api.listRemoteZimFiles({
|
||||
start,
|
||||
count: 12,
|
||||
query: query || undefined,
|
||||
language,
|
||||
})
|
||||
if (!res) {
|
||||
throw new Error('Failed to fetch remote ZIM files.')
|
||||
}
|
||||
@@ -232,6 +258,15 @@ export default function ZimRemoteExplorer() {
|
||||
}, [customLibraries, selectedSource])
|
||||
|
||||
// When selecting a custom library, navigate to its root
|
||||
const handleLanguageChange = (value: string) => {
|
||||
// localStorage can throw (private mode, blocked site data) -- the filter itself must
|
||||
// still work, it just won't be remembered next visit.
|
||||
try {
|
||||
localStorage.setItem('nomad:zim-library-language', value)
|
||||
} catch {}
|
||||
setLanguage(value)
|
||||
}
|
||||
|
||||
const handleSourceChange = (value: string) => {
|
||||
localStorage.setItem('nomad:zim-library-source', value)
|
||||
if (value === 'default') {
|
||||
@@ -592,7 +627,7 @@ export default function ZimRemoteExplorer() {
|
||||
{/* Default Kiwix library browser */}
|
||||
{selectedSource === 'default' && (
|
||||
<>
|
||||
<div className="flex justify-start mt-4">
|
||||
<div className="flex flex-wrap items-center justify-start gap-3 mt-4">
|
||||
<Input
|
||||
name="search"
|
||||
label=""
|
||||
@@ -605,6 +640,31 @@ export default function ZimRemoteExplorer() {
|
||||
className="w-1/3"
|
||||
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
|
||||
/>
|
||||
{/* Hidden entirely when the catalog list is unavailable (offline), rather
|
||||
than shown as an empty dropdown the user cannot act on. */}
|
||||
{catalogLanguages && catalogLanguages.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="zim-language"
|
||||
className="text-sm font-medium text-text-secondary"
|
||||
>
|
||||
Language:
|
||||
</label>
|
||||
<select
|
||||
id="zim-language"
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
className="rounded-md border border-border-default bg-surface-primary text-text-primary px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-desert-green"
|
||||
>
|
||||
<option value="all">All languages</option>
|
||||
{catalogLanguages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label} ({lang.book_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StyledTable<RemoteZimFileEntry & { actions?: any }>
|
||||
data={flatData}
|
||||
|
||||
@@ -701,6 +701,10 @@ router
|
||||
tags: ['zim'],
|
||||
query: listRemoteZimValidator,
|
||||
})
|
||||
documented(router.get('/catalog-languages', [ZimController, 'listCatalogLanguages']), {
|
||||
summary: 'List the languages the Kiwix catalog holds books in',
|
||||
tags: ['zim'],
|
||||
})
|
||||
documented(router.get('/curated-categories', [ZimController, 'listCuratedCategories']), {
|
||||
summary: 'List curated ZIM categories',
|
||||
tags: ['zim'],
|
||||
|
||||
@@ -19,6 +19,19 @@ export type ListRemoteZimFilesResponse = {
|
||||
next_start: number
|
||||
}
|
||||
|
||||
/** One selectable language in the Kiwix catalog, with how many books it holds. */
|
||||
export type CatalogLanguage = {
|
||||
/** ISO-639-3 code as the catalog uses it, e.g. `eng`, `fra`, `zho`. */
|
||||
code: string
|
||||
/** The catalog's own label, which is the endonym: "français", "中文". */
|
||||
label: string
|
||||
book_count: number
|
||||
}
|
||||
|
||||
export type ListCatalogLanguagesResponse = {
|
||||
languages: CatalogLanguage[]
|
||||
}
|
||||
|
||||
export type RawRemoteZimFileEntry = {
|
||||
'id': string
|
||||
'title': string
|
||||
|
||||
Reference in New Issue
Block a user