import fs from 'node:fs'; import path from 'node:path'; import {pipeline} from 'node:stream/promises'; import {Readable} from 'node:stream'; import {ZimReader} from './reader.js'; import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js'; import {fuzzyMatch, titleFromUrl} from './utils.js'; /** Manages a local directory of ZIM archives: listing, update checks, downloads, and reading. */ export class ZimManager { #catalog; #dir; constructor(dir, catalog = CATALOG_URL) { this.#catalog = catalog; this.#dir = dir; } async #download(url, destPath) { const {res} = await this.#resolveUrl(url); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const tmpPath = `${destPath}.part`; await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath)); await fs.promises.rename(tmpPath, destPath); } async #ensureDir() { await fs.promises.mkdir(this.#dir, {recursive: true}); } /** Resolves a `.meta4` metalink URL down to the real mirror `.zim` download URL. */ async #resolveUrl(url) { const head = await fetch(url); const ct = head.headers.get('content-type') || ''; if (!ct.includes('metalink') && !url.endsWith('.meta4')) return {res: head, url}; const meta = await head.text(); const m = meta.match(/]*>([^<]+\.zim)<\/url>/); if (!m) throw new Error('Could not resolve metalink mirror'); const res = await fetch(m[1]); return {res, url: m[1]}; } /** Reads Name/Date/Title metadata from a local ZIM file. Returns null if unreadable. */ async #readMeta(filepath) { let reader; try { reader = await new ZimReader(filepath).open(); return { name: await reader.metadata('Name'), date: await reader.metadata('Date'), title: await reader.metadata('Title'), }; } catch { return null; } finally { await reader?.close(); } } async #update(name, catalogEntry, localMatch, force) { const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null; const localDate = localMatch?.meta?.date ? new Date(localMatch.meta.date) : null; if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate) return {name, status: 'skipped', reason: 'up to date'}; if (!catalogEntry.href) return {name, status: 'skipped', reason: 'missing download link'}; const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, ''); const destPath = path.join(this.#dir, filename); await this.#download(catalogEntry.href, destPath); if (localMatch && localMatch.file !== destPath) await fs.promises.unlink(localMatch.file).catch(() => {}); return {name, status: 'updated', file: destPath}; } /** Resolves a file path or catalog `name` to a local file path. */ async #resolveFile(fileOrName) { if (fs.existsSync(fileOrName)) return fileOrName; const joined = path.join(this.#dir, fileOrName); if (fs.existsSync(joined)) return joined; const local = await this.list(); const match = local.find(l => l.meta?.name === fileOrName || path.basename(l.file) === fileOrName); if (!match) throw new Error(`ZIM not found locally: ${fileOrName}`); return match.file; } catalog(search, opts) { return zimCatalog(search, opts); } /** Checks whether a local ZIM has a newer version in the catalog, without downloading. */ async isOutdated(file) { const meta = await this.#readMeta(file); if (!meta?.name) return {file, upToDate: null, reason: 'no metadata'}; const catalogEntry = await zimCatalog(meta.name, this.#catalog); if (!catalogEntry) return {file, upToDate: null, reason: 'missing from catalog'}; const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null; const localDate = meta.date ? new Date(meta.date) : null; return {file, name: meta.name, upToDate: !!(remoteDate && localDate && remoteDate <= localDate), localDate, remoteDate}; } /** Lists local `.zim` files with their parsed metadata (or `null` if unreadable). */ async list() { await this.#ensureDir(); const files = (await fs.promises.readdir(this.#dir)).filter(f => f.endsWith('.zim')); return Promise.all(files.map(async f => { const file = path.join(this.#dir, f); return {file, meta: await this.#readMeta(file)}; })); } /** Downloads/updates a single ZIM by direct href, matching against any existing local copy by name. */ async download(href, {force = false} = {}) { await this.#ensureDir(); const {url: finalUrl} = await this.#resolveUrl(href); const filename = path.basename(new URL(finalUrl).pathname).replace(/\.meta4$/i, ''); const name = filename.replace(/\.zim$/i, '').replace(/_\d{4}-\d{2}(?:_\d+)?$/, ''); const local = await this.list(); const localMatch = local.find(l => l.meta?.name === name) ?? null; const catalogEntry = await zimCatalogInfo(name, this.#catalog) || {name, updated: null, href}; return this.#update(name, catalogEntry, localMatch, force); } /** Checks all local ZIMs against the catalog and updates any that are outdated. */ async updateAll({force = false}) { const local = await this.list(); if (!local.length) return []; const results = []; for (const {file, meta} of local) { if (!meta?.name) { results.push({file, status: 'skipped', reason: 'no metadata'}); continue; } const catalogEntry = await zimCatalogInfo(meta.name, this.#catalog); if (!catalogEntry) { results.push({name: meta.name, status: 'skipped', reason: 'missing from catalog'}); continue; } try { results.push(await this.#update(meta.name, catalogEntry, {file, meta}, force)); } catch (e) { results.push({name: meta.name, status: 'error', reason: e.message}); } } return results; } /** Opens a `ZimReader` for a local ZIM, resolved by file path or catalog `name`. Caller must `.close()` it. */ async open(fileOrName) { await this.#ensureDir(); const file = await this.#resolveFile(fileOrName); return new ZimReader(file).open(); } /** Fuzzy-searches titles across every local ZIM in the library, merging & re-ranking hits by score. */ async search(terms, {limit = 10, htmlOnly = true} = {}) { const local = await this.list(); const perZim = await Promise.all(local.map(async ({file, meta}) => { let reader; try { reader = await new ZimReader(file).open(); const hits = await reader.search(terms, {limit, htmlOnly}); return hits.map(h => ({...h, file, name: meta?.name})); } catch { return []; } finally { await reader?.close(); } })); return perZim.flat().filter(a => a.score > 0).toSorted((a, b) => b.score - a.score).slice(0, limit); } }