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:
Chris Sherwood
2026-09-29 15:54:41 -07:00
committed by Jake Turner
co-authored by Claude Opus 5
parent 880c80f52f
commit 576464abc4
7 changed files with 177 additions and 7 deletions
+6 -2
View File
@@ -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) {
+67 -1
View File
@@ -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')) {
+8
View File
@@ -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(),
})
)
+16 -1
View File
@@ -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}
+4
View File
@@ -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'],
+13
View File
@@ -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