5 Commits
0.1.0 ... 0.1.5

Author SHA1 Message Date
4278ea9df9 Added delete
All checks were successful
Publish Library / Build NPM Project (push) Successful in 9s
Publish Library / Tag Version (push) Successful in 8s
2026-08-22 13:01:19 -04:00
86730f1de8 Better searching
All checks were successful
Publish Library / Build NPM Project (push) Successful in 10s
Publish Library / Tag Version (push) Successful in 8s
2026-08-22 12:27:54 -04:00
c5207261fa Added namespace to main page
All checks were successful
Publish Library / Build NPM Project (push) Successful in 9s
Publish Library / Tag Version (push) Successful in 7s
2026-08-22 10:48:40 -04:00
c38f149733 Better pathing
All checks were successful
Publish Library / Build NPM Project (push) Successful in 9s
Publish Library / Tag Version (push) Successful in 9s
2026-08-22 10:22:32 -04:00
f5eaf7f764 Bump 0.1.1
All checks were successful
Publish Library / Build NPM Project (push) Successful in 9s
Publish Library / Tag Version (push) Successful in 53s
2026-08-20 11:38:59 -04:00
4 changed files with 57 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.1.0", "version": "0.1.5",
"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",
@@ -10,7 +10,7 @@
"type": "git", "type": "git",
"url": "https://git.zakscode.com/ztimson/zim-utils" "url": "https://git.zakscode.com/ztimson/zim-utils"
}, },
"main": "index.js", "main": "src/index.js",
"dependencies": { "dependencies": {
"@ztimson/utils": "^0.30.7", "@ztimson/utils": "^0.30.7",
"lzma1": "^0.3.0", "lzma1": "^0.3.0",

View File

@@ -4,6 +4,7 @@ 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';
/** 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 {
@@ -73,8 +74,10 @@ export class ZimManager {
/** Resolves a file path or catalog `name` to a local file path. */ /** Resolves a file path or catalog `name` to a local file path. */
async #resolveFile(fileOrName) { async #resolveFile(fileOrName) {
if (fs.existsSync(fileOrName)) return fileOrName; if (fs.existsSync(fileOrName)) return fileOrName;
const joined = path.join(this.#dir, fileOrName);
if (fs.existsSync(joined)) return joined;
const local = await this.list(); const local = await this.list();
const match = local.find(l => l.meta?.name === 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 match.file;
} }
@@ -83,6 +86,12 @@ export class ZimManager {
return zimCatalog(search, opts); return zimCatalog(search, opts);
} }
async delete(fileOrName) {
const file = await this.#resolveFile(fileOrName);
await fs.promises.unlink(file);
return {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. */
async isOutdated(file) { async isOutdated(file) {
const meta = await this.#readMeta(file); const meta = await this.#readMeta(file);
@@ -144,7 +153,7 @@ export class ZimManager {
} }
/** 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. */
async search(terms, {limit = 20, htmlOnly = true} = {}) { async search(terms, {limit = 10, htmlOnly = true} = {}) {
const local = await this.list(); const local = await this.list();
const perZim = await Promise.all(local.map(async ({file, meta}) => { const perZim = await Promise.all(local.map(async ({file, meta}) => {
let reader; let reader;
@@ -158,6 +167,6 @@ export class ZimManager {
await reader?.close(); await reader?.close();
} }
})); }));
return perZim.flat().toSorted((a, b) => b.score - a.score).slice(0, limit); return perZim.flat().filter(a => a.score > 0).toSorted((a, b) => b.score - a.score).slice(0, limit);
} }
} }

View File

@@ -3,7 +3,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import {decompress as lzmaDecompress} from 'lzma1'; import {decompress as lzmaDecompress} from 'lzma1';
import {ZstdCodec} from 'zstd-codec'; import {ZstdCodec} from 'zstd-codec';
import {fuzzyMatch} from './utils.js'; import {fuzzyMatch, titleFromUrl} from './utils.js';
const HEADER_SIZE = 80; const HEADER_SIZE = 80;
const NS_CONTENT = 'C'; const NS_CONTENT = 'C';
@@ -206,7 +206,7 @@ export class ZimReader {
let dirent = await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, this.#header.mainPage)); let dirent = await this.#readDirent(await this.#ptr64(this.#header.urlPtrPos, this.#header.mainPage));
dirent = await this.#resolveRedirect(dirent); dirent = await this.#resolveRedirect(dirent);
const data = await this.#getBlob(dirent.cluster, dirent.blob); const data = await this.#getBlob(dirent.cluster, dirent.blob);
return {mimetype: this.#mimeTypes[dirent.mimetype] || 'application/octet-stream', data, url: dirent.url}; return {mimetype: this.#mimeTypes[dirent.mimetype] || 'application/octet-stream', data, url: dirent.url, namespace: dirent.namespace};
} }
/** /**
@@ -219,7 +219,7 @@ export class ZimReader {
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 [];
const candidates = this.#hasTitleListing const candidates = (this.#hasTitleListing && this.#header.articleCount > 5000)
? await this.#titleIndexCandidates(termList[0]) ? await this.#titleIndexCandidates(termList[0])
: await this.#allDirents(); : await this.#allDirents();
@@ -227,9 +227,16 @@ export class ZimReader {
for (const dirent of candidates) { for (const dirent of candidates) {
if (dirent.namespace !== NS_CONTENT) continue; if (dirent.namespace !== NS_CONTENT) continue;
if (htmlOnly && !(this.#mimeTypes[dirent.mimetype] || '').startsWith('text/html')) continue; if (htmlOnly && !(this.#mimeTypes[dirent.mimetype] || '').startsWith('text/html')) continue;
const {max} = fuzzyMatch(dirent.title, ...termList); const urlTitle = titleFromUrl(dirent.url);
scored.push({url: dirent.url, title: dirent.title, namespace: dirent.namespace, score: max}); const titleScore = fuzzyMatch(dirent.title, ...termList).max;
const urlScore = fuzzyMatch(urlTitle, ...termList).max;
scored.push({
url: dirent.url,
title: dirent.title.length > urlTitle.length ? dirent.title : urlTitle,
namespace: dirent.namespace,
score: Math.max(titleScore, urlScore)
});
} }
return scored.toSorted((a, b) => b.score - a.score).slice(0, limit); return scored.filter(a => a.score > 0).toSorted((a, b) => b.score - a.score).slice(0, limit);
} }
} }

View File

@@ -20,13 +20,42 @@ export function similarity(a, b) {
return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1); return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
} }
/**
* Scores `text` against a single lowercased `term`. Substring containment always wins.
* Otherwise, only rescues genuine typos: requires the same leading letter and a tight
* absolute edit-distance cap, so unrelated words can't win purely on coincidental
* letter overlap (e.g. "Viennese" vs "diannes").
*/
function scoreAgainst(text, term) {
if (text.includes(term)) return 1 - (text.length - term.length) / text.length * 0.3;
if (!text.length || !term.length || text[0] !== term[0]) return 0;
const dist = levenshtein(text, term);
const maxAllowed = Math.max(1, Math.ceil(term.length * 0.34));
if (dist > maxAllowed) return 0;
return 1 - dist / Math.max(text.length, term.length);
}
/** Compares `target` against one or more search terms; returns avg/max/per-term similarity. */ /** Compares `target` against one or more search terms; returns avg/max/per-term similarity. */
export function fuzzyMatch(target, ...terms) { export function fuzzyMatch(target, ...terms) {
if (!terms.length) throw new Error('Requires at least 1 term to compare'); if (!terms.length) throw new Error('Requires at least 1 term to compare');
const similarities = terms.map(t => similarity(target, t)); const lowerTarget = String(target).toLowerCase();
const words = lowerTarget.split(/\W+/).filter(Boolean);
const similarities = terms.map(term => {
const t = term.toLowerCase();
return Math.max(scoreAgainst(lowerTarget, t), ...words.map(w => scoreAgainst(w, t)));
});
return { return {
avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length,
max: Math.max(...similarities), max: Math.max(...similarities),
similarities, similarities,
}; };
} }
/** Derives a readable pseudo-title from a URL's last path segment, e.g. ".../diannes-southwest-salad/" -> "Diannes Southwest Salad". */
export function titleFromUrl(url) {
const slug = String(url).replace(/\/$/, '').split('/').pop() || url;
const clean = slug.replace(/\.(zim|meta4|html?|md)$/i, '').replace(/[-_.]+/g, ' ');
return clean.replace(/\b\w/g, c => c.toUpperCase());
}