Compare commits

...
8 Commits
Author SHA1 Message Date
ztimson 3515968929 Merge download progress into local catalog info
Publish Library / Build NPM Project (push) Successful in 1m16s
Publish Library / Tag Version (push) Successful in 7s
2026-10-05 18:42:22 -04:00
ztimson 3c80bfae93 Track download progress
Publish Library / Build NPM Project (push) Successful in 1m50s
Publish Library / Tag Version (push) Successful in 7s
2026-10-05 15:08:09 -04:00
ztimson ba4bdceab1 Track download progress
Publish Library / Build NPM Project (push) Successful in 1m47s
Publish Library / Tag Version (push) Successful in 21s
2026-10-05 15:00:09 -04:00
ztimson 5bc27fa632 Summary fix
Publish Library / Build NPM Project (push) Successful in 3m35s
Publish Library / Tag Version (push) Successful in 7s
2026-09-21 13:27:36 -04:00
ztimson 943280f326 Bump 0.4.5
Publish Library / Build NPM Project (push) Successful in 3m44s
Publish Library / Tag Version (push) Successful in 10s
2026-09-21 00:38:53 -04:00
ztimson dc24f48a36 Better catalog search
Publish Library / Build NPM Project (push) Successful in 3m6s
Publish Library / Tag Version (push) Canceled after 9s
2026-09-20 23:27:16 -04:00
ztimson 812e72a8cd Better catalog search
Publish Library / Build NPM Project (push) Successful in 4m2s
Publish Library / Tag Version (push) Failing after 19s
2026-09-20 23:26:57 -04:00
ztimson a45d7bad8c Search fix
Publish Library / Build NPM Project (push) Successful in 4m13s
Publish Library / Tag Version (push) Successful in 16s
2026-09-20 20:57:46 -04:00
6 changed files with 205 additions and 67 deletions
+3
View File
@@ -146,6 +146,9 @@ function resolveArchiveName() {
} }
async function install() { async function install() {
if (fs.existsSync(BIN_DIR) && (await fs.promises.readdir(BIN_DIR)).some(f => f.startsWith('kiwix-')))
return console.log(`Already installed: ${BIN_DIR}`);
const {archiveName, ext} = resolveArchiveName(); const {archiveName, ext} = resolveArchiveName();
const url = `${BASE_URL}/${archiveName}`; const url = `${BASE_URL}/${archiveName}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwix-install-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwix-install-'));
+7 -7
View File
@@ -1,17 +1,17 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.3.6", "version": "0.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.3.6", "version": "0.4.8",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ztimson/utils": "^0.30.8", "@ztimson/utils": "^0.30.9",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.18",
"lzma1": "^0.3.0", "lzma1": "^0.3.0",
"tar": "^7.4.3", "tar": "^7.4.3",
"zstd-codec": "^0.1.5" "zstd-codec": "^0.1.5"
@@ -30,9 +30,9 @@
} }
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.30.8", "version": "0.30.9",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.8.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.9.tgz",
"integrity": "sha512-+vBjcinqckqMHkP95xWiQeQz2E7Q1oS0b+Odjp+F9rvQ4z0US4JdodjqhEaqh+RAO/yP77x4Eu0a04yBB4HNdw==", "integrity": "sha512-th/6qBaRJFnxoQk+01ESXjFqhYzsiXziOzd3gPgFG6XrusA+oHtfgxBKJ77ygPq//0cI85srWgAzvQcAmKUAQw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.4.2", "version": "0.4.9",
"description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js", "description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
@@ -15,10 +15,10 @@
"postinstall": "node ./bin/install-kwix.js" "postinstall": "node ./bin/install-kwix.js"
}, },
"dependencies": { "dependencies": {
"@ztimson/utils": "^0.30.8", "@ztimson/utils": "^0.30.9",
"lzma1": "^0.3.0", "lzma1": "^0.3.0",
"tar": "^7.4.3", "tar": "^7.4.3",
"zstd-codec": "^0.1.5", "zstd-codec": "^0.1.5",
"adm-zip": "^0.5.16" "adm-zip": "^0.5.18"
} }
} }
+101 -21
View File
@@ -2,19 +2,29 @@ import {fuzzyMatch} from './utils.js';
import {decodeHtml} from '@ztimson/utils'; import {decodeHtml} from '@ztimson/utils';
export const CATALOG_URL = 'https://library.kiwix.org'; export const CATALOG_URL = 'https://library.kiwix.org';
export const VIEWER_URL = 'https://browse.library.kiwix.org';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
export function parseEntries(xml, catalog = CATALOG_URL) { export function parseEntries(xml, catalog = CATALOG_URL) {
const blocks = xml.match(/<entry>[\s\S]*?<\/entry>/g) || []; const blocks = xml.match(/<entry>[\s\S]*?<\/entry>/g) || [];
return blocks.map(b => { return blocks.map(b => {
const grab = re => (b.match(re) || [])[1] || ''; const grab = re => (b.match(re) || [])[1] || '';
const linkMatch = b.match(/<link[^>]*type=["']application\/x-zim[^"']*["'][^>]*href=["']([^"']+)["']/); const linkMatch = b.match(/<link[^>]*type=["']application\/x-zim[^"']*["'][^>]*href=["']([^"']+)["']/);
const iconMatch = b.match(/<link[^>]*rel=["']http:\/\/opds-spec\.org\/image\/thumbnail["'][^>]*href=["']([^"']+)["']/) const iconMatch = b.match(/<link[^>]*rel=["']http:\/\/opds-spec\.org\/image\/thumbnail["'][^>]*href=["']([^"']+)["']/)
|| b.match(/<link[^>]*type=["']image\/[^"']*["'][^>]*href=["']([^"']+)["']/); || b.match(/<link[^>]*type=["']image\/[^"']*["'][^>]*href=["']([^"']+)["']/);
let tags = grab(/<tags>([^<]*)<\/tags>/); let tags = grab(/<tags>([^<]*)<\/tags>/);
if (tags) tags = tags.split(';'); if (tags) tags = tags.split(';');
const name = grab(/<name>([^<]*)<\/name>/); const name = grab(/<name>([^<]*)<\/name>/);
const href = linkMatch ? linkMatch[1] : null;
const filename = href
? decodeURIComponent(new URL(href).pathname.split('/').pop()).replace(/\.meta4$/i, '')
: name;
const viewerName = filename.replace(/\.zim$/i, '');
return { return {
id: grab(/<id>([^<]*)<\/id>/), id: grab(/<id>([^<]*)<\/id>/),
title: decodeHtml(grab(/<title>([^<]*)<\/title>/)), title: decodeHtml(grab(/<title>([^<]*)<\/title>/)),
@@ -22,25 +32,52 @@ export function parseEntries(xml, catalog = CATALOG_URL) {
summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)), summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)),
language: grab(/<language>([^<]*)<\/language>/), language: grab(/<language>([^<]*)<\/language>/),
name, name,
flavour: grab(/<flavour>([^<]*)<\/flavour>/),
category: grab(/<category>([^<]*)<\/category>/), category: grab(/<category>([^<]*)<\/category>/),
tags, tags,
mediaCount: Number(grab(/<mediaCount>([^<]*)<\/mediaCount>/)) || 0, mediaCount: Number(grab(/<mediaCount>([^<]*)<\/mediaCount>/)) || 0,
author: grab(/<author>\s*<name>([^<]*)<\/name>\s*<\/author>/m), author: grab(/<author>\s*<name>([^<]*)<\/name>\s*<\/author>/m),
publisher: grab(/<publisher>\s*<name>([^<]*)<\/name>\s*<\/publisher>/m), publisher: grab(/<publisher>\s*<name>([^<]*)<\/name>\s*<\/publisher>/m),
articleCount: Number(grab(/<articleCount>([^<]*)<\/articleCount>/)) || 0, articleCount: Number(grab(/<articleCount>([^<]*)<\/articleCount>/)) || 0,
sizeMb: linkMatch ? +(Number((b.match(/length=["'](\d+)["']/) || [])[1] || 0) / 1024 / 1024).toFixed(1) : '?', sizeMb: href
href: linkMatch ? linkMatch[1] : null, ? +(Number((b.match(/length=["'](\d+)["']/) || [])[1] || 0) / 1024 / 1024).toFixed(1)
: '?',
href,
icon: iconMatch ? new URL(iconMatch[1], catalog).href : null, icon: iconMatch ? new URL(iconMatch[1], catalog).href : null,
viewer: name ? new URL(`viewer#${name}`, catalog).href : null, viewer: viewerName ? `${VIEWER_URL}/viewer#${viewerName}` : null,
}; };
}); });
} }
async function fetchEntries(term, lang, url = CATALOG_URL) { async function fetchEntries(term, lang, url = CATALOG_URL) {
const params = new URLSearchParams({q: term, count: String(PAGE_SIZE), lang: lang || 'eng'}); const entries = [];
let start = 0;
let total = Infinity;
while (start < total) {
const params = new URLSearchParams({
q: term,
count: String(PAGE_SIZE),
start: String(start),
lang: lang || 'eng',
});
const res = await fetch(`${url}/catalog/v2/entries?${params}`); const res = await fetch(`${url}/catalog/v2/entries?${params}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return parseEntries(await res.text());
const xml = await res.text();
const page = parseEntries(xml, url);
const totalResults = Number((xml.match(/<totalResults>(\d+)<\/totalResults>/) || [])[1]);
entries.push(...page);
if (Number.isFinite(totalResults)) total = totalResults;
if (!page.length || start + page.length >= total) break;
start += page.length;
}
return entries;
} }
/** Looks up a single catalog entry by exact `name` (used to check for available updates). */ /** Looks up a single catalog entry by exact `name` (used to check for available updates). */
@@ -48,30 +85,73 @@ export async function zimCatalogInfo(name, url = CATALOG_URL) {
const params = new URLSearchParams({name, count: '5'}); const params = new URLSearchParams({name, count: '5'});
const res = await fetch(`${url}/catalog/v2/entries?${params}`); const res = await fetch(`${url}/catalog/v2/entries?${params}`);
if (!res.ok) return null; if (!res.ok) return null;
return parseEntries(await res.text()).find(e => e.name === name) || null;
return parseEntries(await res.text(), url).find(e => e.name === name) || null;
} }
/** Searches the Kiwix catalog for ZIMs matching `terms` within `category`, ranked by term coverage then fuzzy similarity. */ /** Searches the Kiwix catalog for ZIMs matching `terms`, ranked by relevance. */
export async function zimCatalog(terms, opts = {lang: 'eng', count: 20, url: CATALOG_URL}) { export async function zimCatalog(terms, opts = {lang: 'eng', count: 20, url: CATALOG_URL}) {
opts = Object.assign({lang: 'eng', count: 20, url: CATALOG_URL}, opts) opts = Object.assign({lang: 'eng', count: 20, url: CATALOG_URL}, opts);
const termList = [...String(terms).split(',')].filter(Boolean).map(t => t.trim().toLowerCase());
const results = await Promise.allSettled(termList.map(t => fetchEntries(t, opts.lang, opts.url))); const termList = [...String(terms).split(',')]
const byName = new Map(); .filter(Boolean)
.map(t => t.trim().toLowerCase());
const results = await Promise.allSettled(
termList.map(t => fetchEntries(t, opts.lang, opts.url))
);
const byZim = new Map();
results.forEach((r, i) => { results.forEach((r, i) => {
if (r.status !== 'fulfilled') return; if (r.status !== 'fulfilled') return;
const term = termList[i]; const term = termList[i];
for (const entry of r.value) { for (const entry of r.value) {
if (!entry.name) continue; const zimId = entry.href || entry.id || entry.name;
if (!byName.has(entry.name)) byName.set(entry.name, {entry, hitTerms: new Set()}); if (!zimId) continue;
byName.get(entry.name).hitTerms.add(term);
if (!byZim.has(zimId)) {
byZim.set(zimId, {entry, hitTerms: new Set()});
}
byZim.get(zimId).hitTerms.add(term);
} }
}); });
if (!byName.size) return [];
return [...byName.values()].map(({entry, hitTerms}) => { if (!byZim.size) return [];
const text = `${entry.title} ${entry.summary}`.trim();
return {entry, hits: hitTerms.size, fuzzy: fuzzyMatch(text, ...termList).max}; return [...byZim.values()]
}).toSorted((a, b) => .map(({entry, hitTerms}) => {
b.hits - a.hits || b.fuzzy - a.fuzzy || b.entry.articleCount - a.entry.articleCount const title = entry.title.toLowerCase();
).slice(0, opts.count).map(r => r.entry); const summary = entry.summary.toLowerCase();
const titleWords = title.split(/\W+/).filter(Boolean);
const exactTitle = termList.some(term => title === term);
const exactWord = termList.some(term => titleWords.includes(term));
const titleMatch = termList.some(term => title.includes(term));
const titleFuzzy = fuzzyMatch(title, ...termList).max;
const summaryFuzzy = fuzzyMatch(summary, ...termList).max;
return {
entry,
hits: hitTerms.size,
exactTitle,
exactWord,
titleMatch,
titleFuzzy,
summaryFuzzy,
};
})
.toSorted((a, b) =>
Number(b.exactTitle) - Number(a.exactTitle)
|| Number(b.exactWord) - Number(a.exactWord)
|| Number(b.titleMatch) - Number(a.titleMatch)
|| b.titleFuzzy - a.titleFuzzy
|| b.summaryFuzzy - a.summaryFuzzy
|| b.hits - a.hits
|| new Date(b.entry.updated) - new Date(a.entry.updated)
)
.map(r => r.entry);
} }
+72 -23
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import {pipeline} from 'node:stream/promises'; import {pipeline} from 'node:stream/promises';
import {Readable} from 'node:stream'; import {Readable} from 'node:stream';
import {PromiseProgress} from '@ztimson/utils';
import {KiwixServer} from './server.js'; import {KiwixServer} from './server.js';
import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js'; import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js';
@@ -10,13 +11,15 @@ const VISIBLE_POLL_INTERVAL = 200;
/** Manages a local directory of ZIM archives: catalog search, downloads, update checks, deletion. /** Manages a local directory of ZIM archives: catalog search, downloads, update checks, deletion.
* Reuses (or owns & lazily starts) a KiwixServer for local listing/search, so `list()` and `catalog()` return the same shape. * Reuses (or owns & lazily starts) a KiwixServer for local listing/search, so `list()` and `catalog()` return the same shape.
* Doesn't drive library.xml/kiwix-serve reloads itself - the owning KiwixServer watches its directory and * In-flight downloads/updates are tracked by `href` (unique per catalog entry/flavor - unlike `name`, which collides
* reloads automatically whenever .zim files change, no matter which ZimManager (local or remote-attached) wrote them. */ * across flavors) and merged directly into `list()` (as synthetic pending entries) & `catalog()` (overlaid onto the
* matching row), so callers never need a separate progress endpoint - just re-poll list()/catalog() like normal. */
export class ZimManager { export class ZimManager {
#catalogUrl; #catalogUrl;
#dir; #dir;
#server; #server;
#ownsServer; #ownsServer;
#active = {}; // href -> {entry: catalogEntry, dl: PromiseProgress}
/** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string, url?: string}} [opts] /** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string, url?: string}} [opts]
* server: reuse an existing KiwixServer instance directly. * server: reuse an existing KiwixServer instance directly.
@@ -41,11 +44,22 @@ export class ZimManager {
return this.#server; return this.#server;
} }
async #download(url, destPath) { /** Raw local listing, no pending downloads merged in - used internally for matching against real files on disk. */
async #rawList() {
const server = await this.#ensureServer();
return server.list();
}
/** Streams `url` to `destPath`, reporting raw bytes/total via `onBytes(size, total)` as chunks arrive. */
async #download(url, destPath, onBytes) {
const {res} = await this.#resolveUrl(url); const {res} = await this.#resolveUrl(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const total = Number(res.headers.get('content-length')) || 0;
const tmpPath = `${destPath}.part`; const tmpPath = `${destPath}.part`;
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath)); let size = 0;
const readable = Readable.fromWeb(res.body);
if (onBytes) readable.on('data', chunk => onBytes(size += chunk.length, total));
await pipeline(readable, fs.createWriteStream(tmpPath));
await fs.promises.rename(tmpPath, destPath); await fs.promises.rename(tmpPath, destPath);
} }
@@ -61,13 +75,13 @@ export class ZimManager {
return {res, url: m[1]}; return {res, url: m[1]};
} }
/** Polls list() until `filename` shows up (or disappears, if `expect: false`), so callers get an accurate /** Polls #rawList() until `filename` shows up (or disappears, if `expect: false`), so callers get an accurate
* status back rather than one that's ahead of what the server has actually picked up yet. The owning * status back rather than one that's ahead of what the server has actually picked up yet. The owning
* KiwixServer's directory watcher does the real reload work in the background; this just waits for it. */ * KiwixServer's directory watcher does the real reload work in the background; this just waits for it. */
async #waitUntilVisible(filename, {expect = true, timeout = VISIBLE_TIMEOUT} = {}) { async #waitUntilVisible(filename, {expect = true, timeout = VISIBLE_TIMEOUT} = {}) {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const local = await this.list(); const local = await this.#rawList();
const present = local.some(l => l.file === filename); const present = local.some(l => l.file === filename);
if (present === expect) return true; if (present === expect) return true;
await new Promise(r => setTimeout(r, VISIBLE_POLL_INTERVAL)); await new Promise(r => setTimeout(r, VISIBLE_POLL_INTERVAL));
@@ -75,7 +89,9 @@ export class ZimManager {
return false; return false;
} }
async #update(name, catalogEntry, localMatch, force) { /** Performs the actual download + post-processing for a resolved catalog entry, forwarding byte progress via `onBytes`. */
async #update(catalogEntry, localMatch, force, onBytes) {
const name = catalogEntry.name;
const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null; const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null;
const localDate = localMatch?.updated ?? null; const localDate = localMatch?.updated ?? null;
if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate) if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate)
@@ -84,15 +100,30 @@ export class ZimManager {
const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, ''); const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, '');
const destPath = path.join(this.#dir, filename); const destPath = path.join(this.#dir, filename);
await this.#download(catalogEntry.href, destPath); await this.#download(catalogEntry.href, destPath, onBytes);
if (localMatch && localMatch.file !== filename) await fs.promises.rm(path.join(this.#dir, localMatch.file), {force: true}); if (localMatch && localMatch.file !== filename) await fs.promises.rm(path.join(this.#dir, localMatch.file), {force: true});
await this.#ensureServer(); await this.#ensureServer();
await this.#waitUntilVisible(filename, {expect: true}); await this.#waitUntilVisible(filename, {expect: true});
return {name, status: 'updated', file: filename}; return {name, status: 'updated', file: filename};
} }
catalog(search, opts = {}) { /** Wraps `#update` in a tracked `PromiseProgress`, registered in `#active` under the catalog entry's `href`. */
return zimCatalog(search, {url: this.#catalogUrl, ...opts}); #trackedUpdate(catalogEntry, localMatch, force) {
const dl = new PromiseProgress((resolve, reject, prog) =>
this.#update(catalogEntry, localMatch, force, prog).then(resolve, reject));
this.#active[catalogEntry.href] = {entry: catalogEntry, dl};
dl.catch(() => {}).finally(() => { if (this.#active[catalogEntry.href]?.dl === dl) delete this.#active[catalogEntry.href]; });
return dl;
}
/** Remote catalog search - any entry with a matching in-flight download/update gets live progress fields
* (`progress`/`speed`/`eta`/`size`/`total`/`downloading: true`) merged directly onto it by `href`, so distinct
* flavors of the same `name` never cross-light each other. */
async catalog(search, opts = {}) {
const results = await zimCatalog(search, {url: this.#catalogUrl, ...opts});
return results.map(entry => this.#active[entry.href]
? {...entry, ...this.#active[entry.href].dl.meta, downloading: true}
: entry);
} }
/** Stops the internally-owned KiwixServer, if this manager created its own (no-op if one was passed in). */ /** Stops the internally-owned KiwixServer, if this manager created its own (no-op if one was passed in). */
@@ -101,7 +132,7 @@ export class ZimManager {
} }
async delete(nameOrFile) { async delete(nameOrFile) {
const local = await this.list(); const local = await this.#rawList();
const match = local.find(l => l.name === nameOrFile || l.href === nameOrFile); const match = local.find(l => l.name === nameOrFile || l.href === nameOrFile);
if (!match) throw new Error(`ZIM not found locally: ${nameOrFile}`); if (!match) throw new Error(`ZIM not found locally: ${nameOrFile}`);
const file = match.href + (match.href.endsWith('.zim') ? '' : '.zim'); const file = match.href + (match.href.endsWith('.zim') ? '' : '.zim');
@@ -111,18 +142,28 @@ export class ZimManager {
return {file: match.file, status: 'deleted'}; return {file: match.file, status: 'deleted'};
} }
/** Downloads/updates a single ZIM by direct href, matching against any existing local copy by name. */ /** Downloads/updates a single ZIM by direct href, matching against any existing local copy by name.
async download(href, {force = false} = {}) { * Returns a `PromiseProgress` with live `.progress`/`.speed`/`.eta`/etc getters & `onProgress` callback;
* also tracked internally under `href` so `list()`/`catalog()` reflect it even if the reference is dropped. */
download(href, {force = false} = {}) {
let dl;
dl = new PromiseProgress(async (resolve, reject, prog) => {
try {
const {url: finalUrl} = await this.#resolveUrl(href); const {url: finalUrl} = await this.#resolveUrl(href);
const filename = path.basename(new URL(finalUrl).pathname).replace(/\.meta4$/i, ''); const filename = path.basename(new URL(finalUrl).pathname).replace(/\.meta4$/i, '');
await fs.promises.mkdir(this.#dir, {recursive: true}); await fs.promises.mkdir(this.#dir, {recursive: true});
const local = await this.list(); const local = await this.#rawList();
const exact = local.find(l => l.file === filename); const exact = local.find(l => l.file === filename);
if (exact && !force) return {name: exact.name ?? this.#stripDate(filename), status: 'skipped', reason: 'already downloaded', file: filename}; if (exact && !force) return resolve({name: exact.name ?? this.#stripDate(filename), status: 'skipped', reason: 'already downloaded', file: filename});
const name = this.#stripDate(filename); const name = this.#stripDate(filename);
const localMatch = local.find(l => l.name === name) ?? null; const localMatch = local.find(l => l.name === name) ?? null;
const catalogEntry = await zimCatalogInfo(name, this.#catalogUrl) || {name, updated: null, href}; const catalogEntry = await zimCatalogInfo(name, this.#catalogUrl) || {name, updated: null, href};
return this.#update(name, catalogEntry, localMatch, force); this.#active[href] = {entry: catalogEntry, dl};
dl.catch(() => {}).finally(() => { if (this.#active[href]?.dl === dl) delete this.#active[href]; });
resolve(await this.#update(catalogEntry, localMatch, force, prog));
} catch (e) { reject(e); }
});
return dl;
} }
async link(href) { async link(href) {
@@ -132,7 +173,7 @@ export class ZimManager {
/** Checks whether a local ZIM has a newer version in the catalog, without downloading. */ /** Checks whether a local ZIM has a newer version in the catalog, without downloading. */
async isOutdated(nameOrFile) { async isOutdated(nameOrFile) {
const local = await this.list(); const local = await this.#rawList();
const match = local.find(l => l.name === nameOrFile || l.file === nameOrFile); const match = local.find(l => l.name === nameOrFile || l.file === nameOrFile);
if (!match?.name) return {file: nameOrFile, upToDate: null, reason: 'no metadata'}; if (!match?.name) return {file: nameOrFile, upToDate: null, reason: 'no metadata'};
const catalogEntry = await zimCatalogInfo(match.name, this.#catalogUrl); const catalogEntry = await zimCatalogInfo(match.name, this.#catalogUrl);
@@ -141,10 +182,17 @@ export class ZimManager {
return {file: match.file, name: match.name, upToDate: !!(remoteDate && match.updated && remoteDate <= match.updated), localDate: match.updated, remoteDate}; return {file: match.file, name: match.name, upToDate: !!(remoteDate && match.updated && remoteDate <= match.updated), localDate: match.updated, remoteDate};
} }
/** Lists local ZIMs - same flat shape as `catalog()` (plus `file`), sourced from the KiwixServer's own library.xml. */ /** Local ZIMs - same flat shape as `catalog()` (plus `file`) - with any in-flight downloads/updates appended as
* synthetic pending entries (catalog metadata + live progress fields + `downloading: true`) so the UI always has
* a card to render progress against, even before the file exists on disk. Resolves itself: once the real file
* lands, its name matches the local entry and the synthetic one drops out automatically. */
async list() { async list() {
const server = await this.#ensureServer(); const local = await this.#rawList();
return server.list(); const localNames = new Set(local.map(l => l.name));
const pending = Object.values(this.#active)
.filter(({entry}) => !localNames.has(entry.name))
.map(({entry, dl}) => ({...entry, ...dl.meta, downloading: true}));
return [...local, ...pending];
} }
/** Fetches a single asset's raw bytes. */ /** Fetches a single asset's raw bytes. */
@@ -159,16 +207,17 @@ export class ZimManager {
return server.search(terms, {limit, sources}); return server.search(terms, {limit, sources});
} }
/** Checks all local ZIMs against the catalog and updates any that are outdated. */ /** Checks all local ZIMs against the catalog and updates any that are outdated.
* Each in-flight update is tracked in `#active` by href - reflected live via `list()`/`catalog()` mid-run. */
async updateAll({force = false} = {}) { async updateAll({force = false} = {}) {
const local = await this.list(); const local = await this.#rawList();
const results = []; const results = [];
for (const entry of local) { for (const entry of local) {
if (!entry.name) { results.push({file: entry.file, status: 'skipped', reason: 'no metadata'}); continue; } if (!entry.name) { results.push({file: entry.file, status: 'skipped', reason: 'no metadata'}); continue; }
const catalogEntry = await zimCatalogInfo(entry.name, this.#catalogUrl); const catalogEntry = await zimCatalogInfo(entry.name, this.#catalogUrl);
if (!catalogEntry) { results.push({name: entry.name, status: 'skipped', reason: 'missing from catalog'}); continue; } if (!catalogEntry) { results.push({name: entry.name, status: 'skipped', reason: 'missing from catalog'}); continue; }
try { try {
results.push(await this.#update(entry.name, catalogEntry, entry, force)); results.push(await this.#trackedUpdate(catalogEntry, entry, force));
} catch (e) { } catch (e) {
results.push({name: entry.name, status: 'error', reason: e.message}); results.push({name: entry.name, status: 'error', reason: e.message});
} }
+9 -3
View File
@@ -32,6 +32,12 @@ function tokenize(terms) {
return String(terms).split(/[,\s]+/).map(t => t.trim()).filter(Boolean); return String(terms).split(/[,\s]+/).map(t => t.trim()).filter(Boolean);
} }
function normalizeSummary(summary) {
if(typeof summary === 'string') return summary;
if(summary && typeof summary === 'object') return summary._text || '';
return '';
}
export class KiwixServer { export class KiwixServer {
static #empty = '<?xml version="1.0" encoding="UTF-8" ?>\n<library version="20110515"></library>\n'; static #empty = '<?xml version="1.0" encoding="UTF-8" ?>\n<library version="20110515"></library>\n';
@@ -218,7 +224,7 @@ export class KiwixServer {
id: e.id, id: e.id,
title: e.title, title: e.title,
updated: new Date(e.date), updated: new Date(e.date),
summary: e.description, summary: normalizeSummary(e.description),
language: e.language, language: e.language,
name: e.name, name: e.name,
category: tags.find(t => t.startsWith('_category'))?.slice(10) || '', category: tags.find(t => t.startsWith('_category'))?.slice(10) || '',
@@ -275,7 +281,7 @@ export class KiwixServer {
const found = fromXml(await res.text())?.rss?.channel?.item || []; const found = fromXml(await res.text())?.rss?.channel?.item || [];
return found.map(hit => { return makeArray(found).map(hit => {
const resultBook = bookMap.get(hit.book?.title) || book; const resultBook = bookMap.get(hit.book?.title) || book;
const prefix = `/content/${resultBook.href}/`; const prefix = `/content/${resultBook.href}/`;
@@ -292,7 +298,7 @@ export class KiwixServer {
href: `${resultBook.href}/${page}`, href: `${resultBook.href}/${page}`,
icon: resultBook.icon, icon: resultBook.icon,
viewer: this.baseUrl + hit.link, viewer: this.baseUrl + hit.link,
summary: hit.description, summary: normalizeSummary(hit.description),
xapianScore: +hit.score || 0, xapianScore: +hit.score || 0,
}; };
}).filter(Boolean); }).filter(Boolean);