generated from ztimson/template
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36f954bb6b | ||
|
|
a1aa158264 | ||
|
|
3515968929 | ||
|
|
3c80bfae93 | ||
|
|
ba4bdceab1 | ||
|
|
5bc27fa632 | ||
|
|
943280f326 | ||
|
|
dc24f48a36 | ||
|
|
812e72a8cd | ||
|
|
a45d7bad8c | ||
|
|
0a7e87f6b7 | ||
|
|
e12c3e3cc6 |
@@ -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-'));
|
||||||
|
|||||||
Generated
+7
-7
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ztimson/zim-utils",
|
"name": "@ztimson/zim-utils",
|
||||||
"version": "0.4.0",
|
"version": "0.4.11",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-22
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-26
@@ -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,21 +75,29 @@ 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;
|
||||||
|
const server = await this.#ensureServer();
|
||||||
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 match = local.find(l => l.file === filename);
|
||||||
if (present === expect) return true;
|
if (!expect) {
|
||||||
|
if (!match) return true;
|
||||||
|
} else if (match?.name) {
|
||||||
|
const probe = await fetch(`${server.baseUrl}/content/${encodeURIComponent(match.name)}/`).catch(() => null);
|
||||||
|
if (probe?.ok) return true;
|
||||||
|
}
|
||||||
await new Promise(r => setTimeout(r, VISIBLE_POLL_INTERVAL));
|
await new Promise(r => setTimeout(r, VISIBLE_POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
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 +106,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,28 +138,39 @@ 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');
|
||||||
|
await this.#server.stop();
|
||||||
await fs.promises.rm(path.join(this.#dir, file), {force: true});
|
await fs.promises.rm(path.join(this.#dir, file), {force: true});
|
||||||
await this.#ensureServer();
|
await this.#ensureServer();
|
||||||
await this.#waitUntilVicsible(file, {expect: false});
|
await this.#waitUntilVisible(file, {expect: false});
|
||||||
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 +180,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 +189,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 +214,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});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export function rerank(hits, termList) {
|
|||||||
const title = fieldScore(hit.title, termList, {fuzzy: true});
|
const title = fieldScore(hit.title, termList, {fuzzy: true});
|
||||||
const summary = fieldScore(hit.summary, termList);
|
const summary = fieldScore(hit.summary, termList);
|
||||||
const titleLower = (hit.title || '').toLowerCase();
|
const titleLower = (hit.title || '').toLowerCase();
|
||||||
const summaryLower = (hit.summary || '').toLowerCase();
|
const summaryLower = (hit.summary?._text || '').toLowerCase();
|
||||||
const phrase = termList.join(' ').toLowerCase();
|
const phrase = termList.join(' ').toLowerCase();
|
||||||
|
|
||||||
const titleExactPhrase = titleLower.includes(phrase) ? 1 : 0;
|
const titleExactPhrase = titleLower.includes(phrase) ? 1 : 0;
|
||||||
|
|||||||
+33
-38
@@ -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';
|
||||||
|
|
||||||
@@ -89,20 +95,16 @@ export class KiwixServer {
|
|||||||
|
|
||||||
async #rebuildLibrary() {
|
async #rebuildLibrary() {
|
||||||
if(this.#remote) return;
|
if(this.#remote) return;
|
||||||
|
|
||||||
await fs.promises.rm(this.#libraryPath, {force: true});
|
|
||||||
|
|
||||||
const files = await this.#zimFiles();
|
const files = await this.#zimFiles();
|
||||||
|
const tmpPath = `${this.#libraryPath}.tmp-${process.pid}`;
|
||||||
if(!files.length)
|
await fs.promises.rm(tmpPath, {force: true});
|
||||||
return fs.promises.writeFile(this.#libraryPath, KiwixServer.#empty);
|
if(!files.length) {
|
||||||
|
await fs.promises.writeFile(tmpPath, KiwixServer.#empty);
|
||||||
|
} else {
|
||||||
for(const file of files)
|
for(const file of files)
|
||||||
await execFileAsync(this.#bin('kiwix-manage'), [
|
await execFileAsync(this.#bin('kiwix-manage'), [tmpPath, 'add', path.join(this.#dir, file)]);
|
||||||
this.#libraryPath,
|
}
|
||||||
'add',
|
await fs.promises.rename(tmpPath, this.#libraryPath);
|
||||||
path.join(this.#dir, file),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #waitUntilReady() {
|
async #waitUntilReady() {
|
||||||
@@ -212,13 +214,13 @@ export class KiwixServer {
|
|||||||
|
|
||||||
return makeArray(entries?.library?.book || []).map(e => {
|
return makeArray(entries?.library?.book || []).map(e => {
|
||||||
const tags = e.tags.split(';');
|
const tags = e.tags.split(';');
|
||||||
const name = e.path.replace('.zim', '');
|
const name = e.path.replace(/\.zim$/i, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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) || '',
|
||||||
@@ -262,53 +264,43 @@ export class KiwixServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #rawSearch(termList, scoped, bookMap, limit) {
|
async #rawSearch(termList, scoped, bookMap, limit) {
|
||||||
const groups = new Map();
|
const perBook = await Promise.all(scoped.map(async book => {
|
||||||
|
|
||||||
for(const book of scoped) {
|
|
||||||
const lang = book.language || '';
|
|
||||||
(groups.get(lang) ?? groups.set(lang, []).get(lang)).push(book);
|
|
||||||
}
|
|
||||||
|
|
||||||
const perGroup = await Promise.all([...groups.values()].map(async group => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
pattern: termList.join(' '),
|
pattern: termList.join(' '),
|
||||||
format: 'xml',
|
format: 'xml',
|
||||||
pageLength: String(limit),
|
pageLength: String(limit),
|
||||||
|
'books.name': book.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
for(const book of group)
|
|
||||||
params.append('books.name', book.name);
|
|
||||||
|
|
||||||
const res = await fetch(`${this.baseUrl}/search?${params}`);
|
const res = await fetch(`${this.baseUrl}/search?${params}`);
|
||||||
if(!res.ok) return [];
|
if(!res.ok) return [];
|
||||||
|
|
||||||
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 book = bookMap.get(hit.book.title);
|
const resultBook = bookMap.get(hit.book?.title) || book;
|
||||||
if(!book) return null;
|
|
||||||
|
|
||||||
const prefix = `/content/${book.href}/`;
|
const prefix = `/content/${resultBook.href}/`;
|
||||||
const page = hit.link.startsWith(prefix)
|
const page = hit.link.startsWith(prefix)
|
||||||
? hit.link.slice(prefix.length)
|
? hit.link.slice(prefix.length)
|
||||||
: hit.link.replace(/^\/+/, '');
|
: hit.link.replace(/^\/+/, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: book.id,
|
id: resultBook.id,
|
||||||
title: hit.title,
|
title: hit.title,
|
||||||
page,
|
page,
|
||||||
name: book.name,
|
name: resultBook.name,
|
||||||
publisher: book.publisher,
|
publisher: resultBook.publisher,
|
||||||
href: `${book.href}/${page}`,
|
href: `${resultBook.href}/${page}`,
|
||||||
icon: book.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).sort((a, b) => b.xapianScore - a.xapianScore);
|
}).filter(Boolean);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return rrfMerge(perGroup);
|
return rrfMerge(perBook);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fulltext search across local ZIMs with ranking, diversification and spelling correction. */
|
/** Fulltext search across local ZIMs with ranking, diversification and spelling correction. */
|
||||||
@@ -320,6 +312,7 @@ export class KiwixServer {
|
|||||||
|
|
||||||
const books = await this.list();
|
const books = await this.list();
|
||||||
const bookMap = new Map(books.map(b => [b.title, b]));
|
const bookMap = new Map(books.map(b => [b.title, b]));
|
||||||
|
|
||||||
const scoped = sources?.length
|
const scoped = sources?.length
|
||||||
? books.filter(b => sources.includes(b.name) || sources.includes(b.href))
|
? books.filter(b => sources.includes(b.name) || sources.includes(b.href))
|
||||||
: books;
|
: books;
|
||||||
@@ -360,8 +353,10 @@ export class KiwixServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ranked = rerank(hits, activeTerms);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: diversify(rerank(hits, activeTerms), limit),
|
results: diversify(ranked, limit),
|
||||||
spellcheck,
|
spellcheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user