generated from ztimson/template
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb3f0efd3 | |||
| 4ef022aa0e | |||
| fb7ae55d49 | |||
| bc90557de7 | |||
| 96ddcbe67a | |||
| d10ee1d686 | |||
| ffdca81c78 |
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
bin/*.dll
|
||||||
|
bin/kiwix*
|
||||||
|
node_modles
|
||||||
|
zims
|
||||||
87
bin/install-kwix.js
Normal file
87
bin/install-kwix.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import https from 'node:https';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const VERSION = '3.8.1';
|
||||||
|
const BASE_URL = 'https://download.kiwix.org/release/kiwix-tools';
|
||||||
|
const PLATFORM_MAP = {linux: 'linux', darwin: 'macos', win32: 'win'};
|
||||||
|
|
||||||
|
const BIN_NAME = process.platform === 'win32' ? 'kiwix-search.exe' : 'kiwix-search';
|
||||||
|
const BIN_DIR = path.join(__dirname, '..', 'bin'); // project root/bin
|
||||||
|
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
|
||||||
|
|
||||||
|
/** Download a file, following redirects. */
|
||||||
|
function download(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
https.get(url, res => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
file.close();
|
||||||
|
return resolve(download(res.headers.location, dest));
|
||||||
|
}
|
||||||
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
|
return reject(new Error(`Download failed: ${res.statusCode} ${res.statusMessage}`));
|
||||||
|
}
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => file.close(resolve));
|
||||||
|
}).on('error', err => fs.unlink(dest, () => reject(err)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively search a directory for a file by name. */
|
||||||
|
function findFile(dir, name) {
|
||||||
|
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const found = findFile(full, name);
|
||||||
|
if (found) return found;
|
||||||
|
} else if (entry.name === name) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndExtract() {
|
||||||
|
const platformName = PLATFORM_MAP[process.platform];
|
||||||
|
if (!platformName) throw new Error(`Unsupported platform: ${process.platform}`);
|
||||||
|
|
||||||
|
const ext = platformName === 'win' ? 'zip' : 'tar.gz';
|
||||||
|
const archiveName = `kiwix-tools_${platformName}-x86_64-${VERSION}.${ext}`;
|
||||||
|
const url = `${BASE_URL}/${archiveName}`;
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwix-tools-'));
|
||||||
|
const archivePath = path.join(tmpDir, archiveName);
|
||||||
|
|
||||||
|
console.log('Downloading kiwix-tools from:', url);
|
||||||
|
await download(url, archivePath);
|
||||||
|
|
||||||
|
console.log('Extracting...');
|
||||||
|
execFileSync('tar', ['-xf', archivePath, '-C', tmpDir]); // bsdtar handles zip too
|
||||||
|
|
||||||
|
const extractedBin = findFile(tmpDir, BIN_NAME);
|
||||||
|
if (!extractedBin) throw new Error(`Could not find ${BIN_NAME} in extracted archive`);
|
||||||
|
const extractedDir = path.dirname(extractedBin);
|
||||||
|
|
||||||
|
await fs.promises.mkdir(BIN_DIR, {recursive: true});
|
||||||
|
|
||||||
|
// Copy the binary plus any DLLs sitting alongside it (Windows deps)
|
||||||
|
for (const entry of await fs.promises.readdir(extractedDir)) {
|
||||||
|
if (entry === BIN_NAME || entry.toLowerCase().endsWith('.dll')) {
|
||||||
|
await fs.promises.copyFile(path.join(extractedDir, entry), path.join(BIN_DIR, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') await fs.promises.chmod(BIN_PATH, 0o755);
|
||||||
|
|
||||||
|
await fs.promises.rm(tmpDir, {recursive: true, force: true});
|
||||||
|
console.log('Installed to:', BIN_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAndExtract().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1)
|
||||||
|
}).finally(() => process.exit());
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ztimson/zim-utils",
|
"name": "@ztimson/zim-utils",
|
||||||
"version": "0.2.0",
|
"version": "0.2.4",
|
||||||
"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",
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"url": "https://git.zakscode.com/ztimson/zim-utils"
|
"url": "https://git.zakscode.com/ztimson/zim-utils"
|
||||||
},
|
},
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node ./bin/install-kwix.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ztimson/utils": "^0.30.7",
|
"@ztimson/utils": "^0.30.7",
|
||||||
"lzma1": "^0.3.0",
|
"lzma1": "^0.3.0",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {fuzzyMatch} from './utils.js';
|
import {fuzzyMatch} from './utils.js';
|
||||||
import {decodeHtml} from '@ztimson/utils';
|
import {decodeHtml} from '@ztimson/utils';
|
||||||
|
|
||||||
export const CATALOG_URL = 'https://library.kiwix.org/catalog/v2/entries';
|
export const CATALOG_URL = 'https://library.kiwix.org';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
function parseEntries(xml, baseUrl = 'https://library.kiwix.org') {
|
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] || '';
|
||||||
@@ -14,29 +14,31 @@ function parseEntries(xml, baseUrl = 'https://library.kiwix.org') {
|
|||||||
|| 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>/);
|
||||||
return {
|
return {
|
||||||
id: grab(/<id>([^<]*)<\/id>/),
|
id: grab(/<id>([^<]*)<\/id>/),
|
||||||
title: decodeHtml(grab(/<title>([^<]*)<\/title>/)),
|
title: decodeHtml(grab(/<title>([^<]*)<\/title>/)),
|
||||||
updated: new Date(grab(/<updated>([^<]*)<\/updated>/)),
|
updated: new Date(grab(/<updated>([^<]*)<\/updated>/)),
|
||||||
summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)),
|
summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)),
|
||||||
language: grab(/<language>([^<]*)<\/language>/),
|
language: grab(/<language>([^<]*)<\/language>/),
|
||||||
name: grab(/<name>([^<]*)<\/name>/),
|
name,
|
||||||
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: linkMatch ? +(Number((b.match(/length=["'](\d+)["']/) || [])[1] || 0) / 1024 / 1024).toFixed(1) : '?',
|
||||||
href: linkMatch ? linkMatch[1] : null,
|
download: linkMatch ? linkMatch[1] : null,
|
||||||
icon: iconMatch ? new URL(iconMatch[1], baseUrl).href : null,
|
icon: iconMatch ? new URL(iconMatch[1], catalog).href : null,
|
||||||
|
viewer: name ? new URL(`viewer#${name}`, catalog).href : 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 params = new URLSearchParams({q: term, count: String(PAGE_SIZE), lang: lang || 'eng'});
|
||||||
const res = await fetch(`${url}?${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());
|
return parseEntries(await res.text());
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,7 @@ async function fetchEntries(term, lang, url = CATALOG_URL) {
|
|||||||
/** 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). */
|
||||||
export async function zimCatalogInfo(name, url = CATALOG_URL) {
|
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}?${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()).find(e => e.name === name) || null;
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/decompress-worker.js
Normal file
32
src/decompress-worker.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import {parentPort} from 'node:worker_threads';
|
||||||
|
import {decompress as lzmaDecompress} from 'lzma1';
|
||||||
|
import {ZstdCodec} from 'zstd-codec';
|
||||||
|
|
||||||
|
let zstdStreamingPromise = null;
|
||||||
|
|
||||||
|
function getZstd() {
|
||||||
|
if (!zstdStreamingPromise) {
|
||||||
|
zstdStreamingPromise = new Promise(resolve => ZstdCodec.run(zstd => resolve(new zstd.Streaming())));
|
||||||
|
}
|
||||||
|
return zstdStreamingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds a full 13-byte "alone" LZMA header from libzim's truncated 5-byte one (unknown size). */
|
||||||
|
function toLzmaAloneStream(body) {
|
||||||
|
const header = Buffer.concat([body.subarray(0, 5), Buffer.alloc(8, 0xff)]);
|
||||||
|
return Buffer.concat([header, body.subarray(5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on('message', async ({id, compType, body}) => {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(body);
|
||||||
|
const data = compType === 4
|
||||||
|
? Buffer.from(lzmaDecompress(toLzmaAloneStream(buf)))
|
||||||
|
: Buffer.from((await getZstd()).decompress(new Uint8Array(buf)));
|
||||||
|
parentPort.postMessage({id, data}, [data.buffer]);
|
||||||
|
} catch (e) {
|
||||||
|
parentPort.postMessage({id, error: e.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
50
src/decompress.js
Normal file
50
src/decompress.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import {Worker} from 'node:worker_threads';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** Round-robin pool of worker threads for off-main-thread cluster decompression. */
|
||||||
|
class DecompressPool {
|
||||||
|
#workers = [];
|
||||||
|
#next = 0;
|
||||||
|
#pending = new Map(); // id -> {resolve, reject}
|
||||||
|
#nextId = 0;
|
||||||
|
#active = 0;
|
||||||
|
|
||||||
|
constructor(size = Math.max(1, os.cpus().length - 1)) {
|
||||||
|
for (let i = 0; i < size; i++) this.#spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
#spawn() {
|
||||||
|
const worker = new Worker(path.join(__dirname, 'decompress-worker.js'));
|
||||||
|
worker.on('message', ({id, data, error}) => {
|
||||||
|
const task = this.#pending.get(id);
|
||||||
|
if (!task) return;
|
||||||
|
this.#pending.delete(id);
|
||||||
|
// No work left in flight -> safe to let the process exit again.
|
||||||
|
if (--this.#active === 0) for (const w of this.#workers) w.unref();
|
||||||
|
error ? task.reject(new Error(error)) : task.resolve(Buffer.from(data));
|
||||||
|
});
|
||||||
|
worker.unref(); // idle workers must never keep a short script/server alive
|
||||||
|
this.#workers.push(worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decompresses `{compType, body}` on the next worker in rotation. `body` must be a Uint8Array/Buffer view. */
|
||||||
|
run({compType, body}) {
|
||||||
|
const worker = this.#workers[this.#next];
|
||||||
|
this.#next = (this.#next + 1) % this.#workers.length;
|
||||||
|
const id = this.#nextId++;
|
||||||
|
const owned = new Uint8Array(body);
|
||||||
|
if (this.#active++ === 0) for (const w of this.#workers) w.ref();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.#pending.set(id, {resolve, reject});
|
||||||
|
worker.postMessage({id, compType, body: owned}, [owned.buffer]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decompressPool = new DecompressPool();
|
||||||
@@ -4,16 +4,24 @@ import {pipeline} from 'node:stream/promises';
|
|||||||
import {Readable} from 'node:stream';
|
import {Readable} from 'node:stream';
|
||||||
import {ZimReader} from './reader.js';
|
import {ZimReader} from './reader.js';
|
||||||
import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js';
|
import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js';
|
||||||
import {fuzzyMatch, titleFromUrl} from './utils.js';
|
|
||||||
|
const DEFAULT_READER_TTL = 60_000;
|
||||||
|
|
||||||
/** Manages a local directory of ZIM archives: listing, update checks, downloads, and reading. */
|
/** Manages a local directory of ZIM archives: listing, update checks, downloads, and reading. */
|
||||||
export class ZimManager {
|
export class ZimManager {
|
||||||
#catalog;
|
#catalog;
|
||||||
#dir;
|
#dir;
|
||||||
|
#readerTTL;
|
||||||
|
#clusterTTL;
|
||||||
|
#readers = new Map(); // file -> {reader, timer}
|
||||||
|
#opening = new Map(); // file -> Promise<ZimReader>, dedupes concurrent first-open races
|
||||||
|
|
||||||
constructor(dir, catalog = CATALOG_URL) {
|
/** @param {{catalog?: string, readerTTL?: number, clusterTTL?: number}} [opts] */
|
||||||
|
constructor(dir, catalog = CATALOG_URL, {readerTTL = DEFAULT_READER_TTL, clusterTTL} = {}) {
|
||||||
this.#catalog = catalog;
|
this.#catalog = catalog;
|
||||||
this.#dir = dir;
|
this.#dir = dir;
|
||||||
|
this.#readerTTL = readerTTL;
|
||||||
|
this.#clusterTTL = clusterTTL; // undefined -> ZimReader's own default
|
||||||
}
|
}
|
||||||
|
|
||||||
async #download(url, destPath) {
|
async #download(url, destPath) {
|
||||||
@@ -58,13 +66,13 @@ export class ZimManager {
|
|||||||
const localDate = localMatch?.meta?.updated ?? null;
|
const localDate = localMatch?.meta?.updated ?? null;
|
||||||
if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate)
|
if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate)
|
||||||
return {name, status: 'skipped', reason: 'up to date'};
|
return {name, status: 'skipped', reason: 'up to date'};
|
||||||
if (!catalogEntry.href) return {name, status: 'skipped', reason: 'missing download link'};
|
if (!catalogEntry.download) return {name, status: 'skipped', reason: 'missing download link'};
|
||||||
|
|
||||||
const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, '');
|
const filename = path.basename(new URL(catalogEntry.download).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.download, destPath);
|
||||||
if (localMatch && localMatch.file !== destPath) await new ZimReader(localMatch.file).delete().catch(() => {});
|
if (localMatch && localMatch.file !== filename) await this.#evict(path.join(this.#dir, localMatch.file));
|
||||||
return {name, status: 'updated', file: destPath};
|
return {name, status: 'updated', file: filename};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves a file path or catalog `name` to a local file path. */
|
/** Resolves a file path or catalog `name` to a local file path. */
|
||||||
@@ -75,7 +83,16 @@ export class ZimManager {
|
|||||||
const local = await this.list();
|
const local = await this.list();
|
||||||
const match = local.find(l => l.meta?.name === fileOrName || path.basename(l.file) === fileOrName);
|
const match = local.find(l => l.meta?.name === fileOrName || path.basename(l.file) === fileOrName);
|
||||||
if (!match) throw new Error(`ZIM not found locally: ${fileOrName}`);
|
if (!match) throw new Error(`ZIM not found locally: ${fileOrName}`);
|
||||||
return match.file;
|
return path.join(this.#dir, match.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and drops a cached reader for `file`, if any (used before delete/replace). */
|
||||||
|
async #evict(file) {
|
||||||
|
const entry = this.#readers.get(file);
|
||||||
|
if (!entry) return new ZimReader(file).delete().catch(() => {});
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
this.#readers.delete(file);
|
||||||
|
await entry.reader.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
catalog(search, opts) {
|
catalog(search, opts) {
|
||||||
@@ -84,8 +101,8 @@ export class ZimManager {
|
|||||||
|
|
||||||
async delete(fileOrName) {
|
async delete(fileOrName) {
|
||||||
const file = await this.#resolveFile(fileOrName);
|
const file = await this.#resolveFile(fileOrName);
|
||||||
await new ZimReader(file).delete();
|
await this.#evict(file);
|
||||||
return {file, status: 'deleted'};
|
return {file: path.basename(file), status: 'deleted'};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -105,7 +122,7 @@ export class ZimManager {
|
|||||||
const files = (await fs.promises.readdir(this.#dir)).filter(f => f.endsWith('.zim'));
|
const files = (await fs.promises.readdir(this.#dir)).filter(f => f.endsWith('.zim'));
|
||||||
return Promise.all(files.map(async f => {
|
return Promise.all(files.map(async f => {
|
||||||
const file = path.join(this.#dir, f);
|
const file = path.join(this.#dir, f);
|
||||||
return {file, meta: await this.#readMeta(file)};
|
return {file: f, ...(await this.#readMeta(file))};
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +135,7 @@ export class ZimManager {
|
|||||||
|
|
||||||
const local = await this.list();
|
const local = await this.list();
|
||||||
const localMatch = local.find(l => l.meta?.name === name) ?? null;
|
const localMatch = local.find(l => l.meta?.name === name) ?? null;
|
||||||
const catalogEntry = await zimCatalogInfo(name, this.#catalog) || {name, updated: null, href};
|
const catalogEntry = await zimCatalogInfo(name, this.#catalog) || {name, updated: null, download: href};
|
||||||
return this.#update(name, catalogEntry, localMatch, force);
|
return this.#update(name, catalogEntry, localMatch, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,11 +158,32 @@ export class ZimManager {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a `ZimReader` for a local ZIM, resolved by file path or catalog `name`. Caller must `.close()` it. */
|
/** Opens a fresh `ZimReader` for a local ZIM, resolved by file path or catalog `name`. Caller must `.close()` it. */
|
||||||
async open(fileOrName) {
|
async open(fileOrName) {
|
||||||
await this.#ensureDir();
|
await this.#ensureDir();
|
||||||
const file = await this.#resolveFile(fileOrName);
|
const file = await this.#resolveFile(fileOrName);
|
||||||
return new ZimReader(file).open();
|
let entry = this.#readers.get(file);
|
||||||
|
if (!entry) {
|
||||||
|
let pending = this.#opening.get(file);
|
||||||
|
if (!pending) {
|
||||||
|
pending = new ZimReader(file, {clusterTTL: this.#clusterTTL}).open();
|
||||||
|
this.#opening.set(file, pending);
|
||||||
|
}
|
||||||
|
let reader;
|
||||||
|
try {
|
||||||
|
reader = await pending;
|
||||||
|
} finally {
|
||||||
|
this.#opening.delete(file);
|
||||||
|
}
|
||||||
|
entry = this.#readers.get(file) ?? {reader};
|
||||||
|
this.#readers.set(file, entry);
|
||||||
|
}
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(() => {
|
||||||
|
this.#readers.delete(file);
|
||||||
|
entry.reader.close();
|
||||||
|
}, this.#readerTTL).unref();
|
||||||
|
return entry.reader;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuzzy-searches titles across every local ZIM in the library, merging & re-ranking hits by score. */
|
/** Fuzzy-searches titles across every local ZIM in the library, merging & re-ranking hits by score. */
|
||||||
@@ -154,9 +192,9 @@ export class ZimManager {
|
|||||||
const perZim = await Promise.all(local.map(async ({file, meta}) => {
|
const perZim = await Promise.all(local.map(async ({file, meta}) => {
|
||||||
let reader;
|
let reader;
|
||||||
try {
|
try {
|
||||||
reader = await new ZimReader(file).open();
|
reader = await new ZimReader(path.join(this.#dir, file)).open();
|
||||||
const hits = await reader.search(terms, {limit, htmlOnly});
|
const hits = await reader.search(terms, {limit, htmlOnly});
|
||||||
return hits.map(h => ({...h, file, name: meta?.name}));
|
return hits.map(h => ({...h, file}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
208
src/reader.js
208
src/reader.js
@@ -1,32 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import {decompressPool} from './decompress.js';
|
||||||
import {decompress as lzmaDecompress} from 'lzma1';
|
import {fuzzyMatch, titleFromUrl, kiwixSearch} from './utils.js';
|
||||||
import {ZstdCodec} from 'zstd-codec';
|
|
||||||
import {fuzzyMatch, titleFromUrl} from './utils.js';
|
|
||||||
|
|
||||||
const INDEX_VERSION = 1;
|
|
||||||
const HEADER_SIZE = 80;
|
const HEADER_SIZE = 80;
|
||||||
const NS_CONTENT = 'C';
|
const NS_CONTENT = 'C';
|
||||||
const NS_METADATA = 'M';
|
const NS_METADATA = 'M';
|
||||||
const TITLE_SENTINEL = 0xffffffffffffffffn; // Indicator -> ZIM v6+ archives with no title
|
const TITLE_SENTINEL = 0xffffffffffffffffn; // Indicator -> ZIM v6+ archives with no title
|
||||||
|
|
||||||
let zstdStreamingPromise = null;
|
const DEFAULT_CLUSTER_CACHE_MAX = 32;
|
||||||
|
const DEFAULT_CLUSTER_TTL = 60_000;
|
||||||
/** Lazily initialised, shared Zstd streaming decompressor (handles unknown-size frames). */
|
|
||||||
function getZstd() {
|
|
||||||
if (!zstdStreamingPromise) {
|
|
||||||
zstdStreamingPromise = new Promise(resolve => ZstdCodec.run(zstd => resolve(new zstd.Streaming())));
|
|
||||||
}
|
|
||||||
return zstdStreamingPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rebuilds a full 13-byte "alone" LZMA header from libzim's truncated 5-byte one (unknown size). */
|
|
||||||
function toLzmaAloneStream(body) {
|
|
||||||
const header = Buffer.concat([body.subarray(0, 5), Buffer.alloc(8, 0xff)]);
|
|
||||||
return Buffer.concat([header, body.subarray(5)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native, dependency-light reader for .zim archives. Supports zstd & LZMA cluster compression. */
|
/** Native, dependency-light reader for .zim archives. Supports zstd & LZMA cluster compression. */
|
||||||
export class ZimReader {
|
export class ZimReader {
|
||||||
@@ -34,21 +18,19 @@ export class ZimReader {
|
|||||||
#header = null;
|
#header = null;
|
||||||
#mimeTypes = [];
|
#mimeTypes = [];
|
||||||
#hasTitleListing = false;
|
#hasTitleListing = false;
|
||||||
#index;
|
#clusterCache = new Map(); // clusterNumber -> {data, extended, timer}
|
||||||
|
#pending = new Map(); // clusterNumber -> Promise, dedupes concurrent misses
|
||||||
|
#clusterCacheMax;
|
||||||
|
#clusterTTL;
|
||||||
|
|
||||||
get articleCount() { return this.#header?.articleCount ?? 0; }
|
get articleCount() { return this.#header?.articleCount ?? 0; }
|
||||||
|
get mediaCount() { return this.#header?.clusterCount ?? 0; }
|
||||||
|
|
||||||
constructor(path) {
|
/** @param {{clusterCacheMax?: number, clusterTTL?: number}} [opts] clusterTTL in ms; 0/null disables idle eviction. */
|
||||||
|
constructor(path, {clusterCacheMax = DEFAULT_CLUSTER_CACHE_MAX, clusterTTL = DEFAULT_CLUSTER_TTL} = {}) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
}
|
this.#clusterCacheMax = clusterCacheMax;
|
||||||
|
this.#clusterTTL = clusterTTL;
|
||||||
/** Full O(n) scan over the URL pointer list, used when there's no title index. */
|
|
||||||
async #allDirents() {
|
|
||||||
const dirents = [];
|
|
||||||
for (let i = 0; i < this.#header.articleCount; i++) {
|
|
||||||
dirents.push(await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, i)));
|
|
||||||
}
|
|
||||||
return dirents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Binary search the URL pointer list for namespace+url. */
|
/** Binary search the URL pointer list for namespace+url. */
|
||||||
@@ -66,7 +48,31 @@ export class ZimReader {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #getBlob(clusterNumber, blobNumber) {
|
/** Binary search the title index for an exact title match. */
|
||||||
|
async #findByTitle(title) {
|
||||||
|
if (!this.#hasTitleListing) return null;
|
||||||
|
const q = title.toLowerCase();
|
||||||
|
let lo = 0, hi = this.#header.articleCount - 1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
const urlIdx = (await this.#read(this.#header.titlePtrPos + mid * 4, 4)).readUInt32LE(0);
|
||||||
|
const dirent = await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, urlIdx));
|
||||||
|
const t = dirent.title.toLowerCase();
|
||||||
|
if (t === q) return dirent;
|
||||||
|
if (t < q) lo = mid + 1; else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets a cluster's idle-eviction timer. No-op when TTL disabled. */
|
||||||
|
#touch(clusterNumber, entry) {
|
||||||
|
if (!this.#clusterTTL) return;
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(() => this.#clusterCache.delete(clusterNumber), this.#clusterTTL).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches + decompresses a cluster exactly once, offloading decompression to the worker pool. */
|
||||||
|
async #loadCluster(clusterNumber) {
|
||||||
const start = await this.#ptr64(this.#header.clusterPtrPos, clusterNumber);
|
const start = await this.#ptr64(this.#header.clusterPtrPos, clusterNumber);
|
||||||
const isLast = clusterNumber === this.#header.clusterCount - 1;
|
const isLast = clusterNumber === this.#header.clusterCount - 1;
|
||||||
const end = isLast
|
const end = isLast
|
||||||
@@ -79,14 +85,35 @@ export class ZimReader {
|
|||||||
const body = raw.subarray(1);
|
const body = raw.subarray(1);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
if (compType <= 1) data = body;
|
if (compType <= 1) data = Buffer.from(body);
|
||||||
else if (compType === 4) data = Buffer.from(lzmaDecompress(toLzmaAloneStream(body)));
|
else if (compType === 4 || compType === 5) data = await decompressPool.run({compType, body});
|
||||||
else if (compType === 5) data = Buffer.from((await getZstd()).decompress(new Uint8Array(body)));
|
|
||||||
else throw new Error(`Unsupported cluster compression type: ${compType}`);
|
else throw new Error(`Unsupported cluster compression type: ${compType}`);
|
||||||
if (!data) throw new Error(`Cluster ${clusterNumber} failed to decompress (compType ${compType})`);
|
if (!data) throw new Error(`Cluster ${clusterNumber} failed to decompress (compType ${compType})`);
|
||||||
|
|
||||||
const readPtr = i => extended ? Number(data.readBigUInt64LE(i * 8)) : data.readUInt32LE(i * 4);
|
return {data, extended};
|
||||||
return data.subarray(readPtr(blobNumber), readPtr(blobNumber + 1));
|
}
|
||||||
|
|
||||||
|
async #getBlob(clusterNumber, blobNumber) {
|
||||||
|
let entry = this.#clusterCache.get(clusterNumber);
|
||||||
|
if (!entry) {
|
||||||
|
let pending = this.#pending.get(clusterNumber);
|
||||||
|
if (!pending) {
|
||||||
|
pending = this.#loadCluster(clusterNumber);
|
||||||
|
this.#pending.set(clusterNumber, pending);
|
||||||
|
}
|
||||||
|
entry = await pending;
|
||||||
|
this.#pending.delete(clusterNumber);
|
||||||
|
this.#clusterCache.set(clusterNumber, entry);
|
||||||
|
if (this.#clusterCache.size > this.#clusterCacheMax) {
|
||||||
|
const oldestKey = this.#clusterCache.keys().next().value;
|
||||||
|
clearTimeout(this.#clusterCache.get(oldestKey)?.timer);
|
||||||
|
this.#clusterCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#touch(clusterNumber, entry);
|
||||||
|
|
||||||
|
const readPtr = i => entry.extended ? Number(entry.data.readBigUInt64LE(i * 8)) : entry.data.readUInt32LE(i * 4);
|
||||||
|
return entry.data.subarray(readPtr(blobNumber), readPtr(blobNumber + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
async #icon(size = 48) {
|
async #icon(size = 48) {
|
||||||
@@ -162,79 +189,18 @@ export class ZimReader {
|
|||||||
return this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, dirent.redirectIndex));
|
return this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, dirent.redirectIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrows to dirents near the term's alphabetical position in the title index. */
|
|
||||||
async #titleIndexCandidates(term) {
|
|
||||||
const q = term.toLowerCase();
|
|
||||||
let lo = 0, hi = this.#header.articleCount - 1;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >> 1;
|
|
||||||
const urlIdx = (await this.#read(this.#header.titlePtrPos + mid * 4, 4)).readUInt32LE(0);
|
|
||||||
const dirent = await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, urlIdx));
|
|
||||||
if (dirent.title.toLowerCase() < q) lo = mid + 1; else hi = mid;
|
|
||||||
}
|
|
||||||
// Widen around the prefix match since fuzzy scoring isn't purely alphabetical.
|
|
||||||
const start = Math.max(0, lo - 50), end = Math.min(this.#header.articleCount, lo + 200);
|
|
||||||
const dirents = [];
|
|
||||||
for (let i = start; i < end; i++) {
|
|
||||||
const urlIdx = (await this.#read(this.#header.titlePtrPos + i * 4, 4)).readUInt32LE(0);
|
|
||||||
dirents.push(await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, urlIdx)));
|
|
||||||
}
|
|
||||||
return dirents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path of the cached word index, kept in an `index/` subdirectory next to the archive. */
|
|
||||||
#indexPath() {
|
|
||||||
return path.join(path.dirname(this.path), 'index', `${path.basename(this.path)}.idx.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds (or loads a cached) word -> entry index, avoiding a re-scan on every search. */
|
|
||||||
async #wordIndex() {
|
|
||||||
if (this.#index) return this.#index;
|
|
||||||
const cachePath = this.#indexPath();
|
|
||||||
const stat = await fs.promises.stat(this.path);
|
|
||||||
|
|
||||||
if (fs.existsSync(cachePath)) {
|
|
||||||
try {
|
|
||||||
const cached = JSON.parse(await fs.promises.readFile(cachePath, 'utf8'));
|
|
||||||
// Rebuild if stale: index predates the archive's current mtime (e.g. a re-download).
|
|
||||||
if (cached.version === INDEX_VERSION && cached.mtimeMs >= stat.mtimeMs) {
|
|
||||||
return (this.#index = cached);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expensive full scan — only happens once per archive (or after it changes).
|
|
||||||
const dirents = await this.#allDirents();
|
|
||||||
const entries = [];
|
|
||||||
const words = new Map(); // word -> [entryIndex, ...]
|
|
||||||
for (const d of dirents) {
|
|
||||||
if (d.namespace !== NS_CONTENT) continue;
|
|
||||||
const idx = entries.length;
|
|
||||||
entries.push({url: d.url, title: d.title, mimetype: d.mimetype});
|
|
||||||
const text = `${d.title} ${titleFromUrl(d.url)}`.toLowerCase();
|
|
||||||
for (const w of text.split(/\W+/).filter(Boolean)) {
|
|
||||||
if (!words.has(w)) words.set(w, []);
|
|
||||||
words.get(w).push(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = {version: INDEX_VERSION, size: stat.size, mtimeMs: stat.mtimeMs, entries, words: Object.fromEntries(words)};
|
|
||||||
await fs.promises.mkdir(path.dirname(cachePath), {recursive: true});
|
|
||||||
await fs.promises.writeFile(cachePath, JSON.stringify(index));
|
|
||||||
return (this.#index = index);
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (this.#fd) await this.#fd.close();
|
if (this.#fd) await this.#fd.close();
|
||||||
this.#fd = null;
|
this.#fd = null;
|
||||||
|
for (const entry of this.#clusterCache.values()) clearTimeout(entry.timer);
|
||||||
|
this.#clusterCache.clear();
|
||||||
|
this.#pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes the zim archive and its cached index (if any). Safe to call on unopened readers. */
|
/** Deletes the zim archive. Safe to call on unopened readers. */
|
||||||
async delete() {
|
async delete() {
|
||||||
await this.close();
|
await this.close();
|
||||||
this.#index = null;
|
|
||||||
await fs.promises.rm(this.path, {force: true});
|
await fs.promises.rm(this.path, {force: true});
|
||||||
await fs.promises.rm(this.#indexPath(), {force: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads an 'M' namespace metadata value (e.g. Name, Date, Title). Returns null if missing. */
|
/** Reads an 'M' namespace metadata value (e.g. Name, Date, Title). Returns null if missing. */
|
||||||
@@ -249,7 +215,6 @@ export class ZimReader {
|
|||||||
['Title', 'Creator', 'Publisher', 'Date', 'Description', 'Language', 'Name', 'Tags'].map(get)
|
['Title', 'Creator', 'Publisher', 'Date', 'Description', 'Language', 'Name', 'Tags'].map(get)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: name,
|
|
||||||
title,
|
title,
|
||||||
updated: date ? new Date(date) : null,
|
updated: date ? new Date(date) : null,
|
||||||
summary: description,
|
summary: description,
|
||||||
@@ -257,12 +222,11 @@ export class ZimReader {
|
|||||||
name,
|
name,
|
||||||
category: tags ? tags.split(';')[0] || '' : '',
|
category: tags ? tags.split(';')[0] || '' : '',
|
||||||
tags: tags ? tags.split(';') : [],
|
tags: tags ? tags.split(';') : [],
|
||||||
mediaCount: 0,
|
|
||||||
author: creator,
|
author: creator,
|
||||||
publisher,
|
publisher,
|
||||||
articleCount: this.articleCount,
|
articleCount: this.articleCount,
|
||||||
sizeMb: ((await fs.promises.stat(this.path)).size / 1024 / 1024).toFixed(1),
|
mediaCount: this.mediaCount,
|
||||||
href: null,
|
sizeMb: +((await fs.promises.stat(this.path)).size / 1024 / 1024).toFixed(1),
|
||||||
icon: await this.#icon(),
|
icon: await this.#icon(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -270,8 +234,14 @@ export class ZimReader {
|
|||||||
/** Opens the archive and parses its header + mimetype list. */
|
/** Opens the archive and parses its header + mimetype list. */
|
||||||
async open() {
|
async open() {
|
||||||
this.#fd = await fs.promises.open(this.path, 'r');
|
this.#fd = await fs.promises.open(this.path, 'r');
|
||||||
await this.#readHeader();
|
try {
|
||||||
await this.#readMimeTypes();
|
await this.#readHeader();
|
||||||
|
await this.#readMimeTypes();
|
||||||
|
} catch (e) {
|
||||||
|
await this.#fd.close();
|
||||||
|
this.#fd = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,27 +265,15 @@ export class ZimReader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fuzzy-ranked title search. Accepts comma-separated `terms` the same way the
|
* Fuzzy-ranked title search. Accepts comma-separated `terms` the same way the
|
||||||
* catalog search does. Uses the sorted title index when present (binary search
|
* catalog search does. Uses kiwix-search's embedded fulltext index as a prefilter
|
||||||
* narrows the candidate window); falls back to a full linear scan otherwise
|
* to narrow candidates before fuzzy scoring.
|
||||||
* (common on ZIM v6+/zimit-generated archives with no title index).
|
|
||||||
*/
|
*/
|
||||||
async search(terms, {limit = 20, htmlOnly = true} = {}) {
|
async search(terms, {limit = 20, htmlOnly = true} = {}) {
|
||||||
const termList = String(terms).split(',').map(t => t.trim()).filter(Boolean);
|
const termList = String(terms).split(',').map(t => t.trim()).filter(Boolean);
|
||||||
if (!termList.length) return [];
|
if (!termList.length) return [];
|
||||||
|
|
||||||
let candidates;
|
const titles = await kiwixSearch(this.path, termList.join(' '));
|
||||||
if (this.#hasTitleListing && this.#header.articleCount > 5000) {
|
const candidates = (await Promise.all(titles.map(t => this.#findByTitle(t)))).filter(Boolean);
|
||||||
candidates = await this.#titleIndexCandidates(termList[0]);
|
|
||||||
} else {
|
|
||||||
const {entries, words} = await this.#wordIndex();
|
|
||||||
// Pull candidates from postings of any word that starts with (or contains) the search term.
|
|
||||||
const q = termList[0].toLowerCase();
|
|
||||||
const idxSet = new Set();
|
|
||||||
for (const [word, postings] of Object.entries(words)) {
|
|
||||||
if (word.includes(q) || q.includes(word)) postings.forEach(i => idxSet.add(i));
|
|
||||||
}
|
|
||||||
candidates = [...idxSet].map(i => ({...entries[i], namespace: NS_CONTENT}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const scored = [];
|
const scored = [];
|
||||||
for (const dirent of candidates) {
|
for (const dirent of candidates) {
|
||||||
@@ -325,7 +283,7 @@ export class ZimReader {
|
|||||||
const urlScore = fuzzyMatch(urlTitle, ...termList).max;
|
const urlScore = fuzzyMatch(urlTitle, ...termList).max;
|
||||||
scored.push({url: dirent.url, title: dirent.title.length > urlTitle.length ? dirent.title : urlTitle, namespace: NS_CONTENT, score: Math.max(titleScore, urlScore)});
|
scored.push({url: dirent.url, title: dirent.title.length > urlTitle.length ? dirent.title : urlTitle, namespace: NS_CONTENT, score: Math.max(titleScore, urlScore)});
|
||||||
}
|
}
|
||||||
const {id, summary, mediaCount, articleCount, sizeMb, href, ...meta} = await this.metadata();
|
const {summary, mediaCount, articleCount, sizeMb, ...meta} = await this.metadata();
|
||||||
return scored.filter(a => a.score > 0).toSorted((a, b) => b.score - a.score).slice(0, limit).map(a => ({...meta, ...a}));
|
return scored.filter(a => a.score > 0).toSorted((a, b) => b.score - a.score).slice(0, limit).map(a => ({...meta, ...a}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/utils.js
29
src/utils.js
@@ -1,3 +1,32 @@
|
|||||||
|
import {execFile} from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
import {promisify} from 'node:util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search a ZIM file's fulltext index.
|
||||||
|
* @param {string} zimPath - Path to the .zim file
|
||||||
|
* @param {string} pattern - Search terms
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.suggestion] - Suggest titles from partial pattern (completion-style)
|
||||||
|
* @param {boolean} [opts.spelling] - Suggest spelling-corrected titles
|
||||||
|
* @returns {Promise<string[]>} Matching article/tag titles
|
||||||
|
*/
|
||||||
|
export async function kiwixSearch(zimPath, pattern, opts = {}) {
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BIN_NAME = process.platform === 'win32' ? 'kiwix-search.exe' : 'kiwix-search';
|
||||||
|
const BIN_PATH = path.join(__dirname, '..', 'bin', BIN_NAME);
|
||||||
|
|
||||||
|
const args = [];
|
||||||
|
if (opts.suggestion) args.push('-s');
|
||||||
|
if (opts.spelling) args.push('--spelling');
|
||||||
|
args.push(zimPath, pattern);
|
||||||
|
|
||||||
|
const {stdout} = await execFileAsync(BIN_PATH, args);
|
||||||
|
return stdout.split('\n').map(line => line.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export function levenshtein(a, b) {
|
export function levenshtein(a, b) {
|
||||||
const m = a.length, n = b.length;
|
const m = a.length, n = b.length;
|
||||||
if (!m) return n;
|
if (!m) return n;
|
||||||
|
|||||||
Reference in New Issue
Block a user