2026-09-19 18:40:08 -04:00
|
|
|
//! JavaScript and TypeScript, through WebAssembly.
|
|
|
|
|
//!
|
|
|
|
|
//! # Shape
|
|
|
|
|
//!
|
|
|
|
|
//! Records come back as **plain objects**, not handles: the same documents
|
|
|
|
|
//! the command line writes, with the same field names and the same stability
|
|
|
|
|
//! promise. Nothing has to be freed, `JSON.stringify` works, and an array of
|
|
|
|
|
//! chunks spreads into state without ceremony.
|
|
|
|
|
//!
|
|
|
|
|
//! # What is not here
|
|
|
|
|
//!
|
|
|
|
|
//! The tools that read files or call another program. A browser has no
|
|
|
|
|
//! `pdftoppm` and no files, and a binding that pretends otherwise is one that
|
|
|
|
|
//! fails at run time rather than at compile time.
|
|
|
|
|
|
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
|
|
|
|
|
|
use crate::{ChunkBudgets, CueBudgets};
|
|
|
|
|
|
|
|
|
|
/// Hand-written because the generated `.d.ts` would otherwise say `any`: a
|
|
|
|
|
/// binding that types its whole return value as `any` is a binding
|
|
|
|
|
/// TypeScript cannot check.
|
|
|
|
|
#[wasm_bindgen(typescript_custom_section)]
|
|
|
|
|
const TYPES: &'static str = r#"
|
|
|
|
|
/** A retrievable passage. The same record `chunkmd` writes. */
|
|
|
|
|
export interface Chunk {
|
|
|
|
|
v: number;
|
|
|
|
|
/** Position in the stream, from zero. */
|
|
|
|
|
idx: number;
|
|
|
|
|
text: string;
|
|
|
|
|
/** Tokens, by the encoding the budgets count in. */
|
|
|
|
|
tokens: number;
|
|
|
|
|
/** The page it came from, where the source had page markers. */
|
|
|
|
|
page: number | null;
|
|
|
|
|
/** The heading it sits under. */
|
|
|
|
|
chapter: string | null;
|
|
|
|
|
/** "body", "front", "back", "index" or "bibliography". */
|
|
|
|
|
role: string;
|
|
|
|
|
/** Seconds into the recording, for passages made from cues. */
|
|
|
|
|
start_time: number | null;
|
|
|
|
|
end_time: number | null;
|
|
|
|
|
flags: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One line of a transcript. */
|
|
|
|
|
export interface Cue {
|
|
|
|
|
v: number;
|
|
|
|
|
start: number;
|
|
|
|
|
end: number;
|
|
|
|
|
text: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One work inside a collection. */
|
|
|
|
|
export interface Section {
|
|
|
|
|
v: number;
|
|
|
|
|
idx: number;
|
|
|
|
|
title: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
text: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One scored candidate, before or after fusion. */
|
|
|
|
|
export interface Hit {
|
|
|
|
|
v: number;
|
|
|
|
|
id: string;
|
|
|
|
|
/** "vec", "fts" or "meta". */
|
|
|
|
|
channel: string;
|
|
|
|
|
rank: number;
|
|
|
|
|
score: number;
|
|
|
|
|
payload: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:19:54 -04:00
|
|
|
/** A DOI, ISSN, ISBN or arXiv identifier, read and checked. */
|
|
|
|
|
export interface Identifier {
|
|
|
|
|
v: number;
|
|
|
|
|
/** The text it was read from. */
|
|
|
|
|
input: string;
|
|
|
|
|
/** "doi", "issn", "isbn" or "arxiv". */
|
|
|
|
|
kind: string;
|
|
|
|
|
/** The identifier, in the form it should be stored and compared in. */
|
|
|
|
|
id: string;
|
|
|
|
|
/** Whether the check digit agrees, or null where the kind carries none. */
|
|
|
|
|
checksum: boolean | null;
|
|
|
|
|
/** A URL that resolves it. */
|
|
|
|
|
url?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:03:47 -04:00
|
|
|
/** What a title says about its series. */
|
|
|
|
|
export interface SeriesProposal {
|
|
|
|
|
v: number;
|
|
|
|
|
title: string;
|
|
|
|
|
author?: string;
|
|
|
|
|
series: string;
|
|
|
|
|
/** Which volume, where the title said. Absent for membership without order. */
|
|
|
|
|
volume?: number;
|
|
|
|
|
/** A title reconstructed from publisher shorthand, where one reliably can be. */
|
|
|
|
|
proposed_title?: string;
|
|
|
|
|
/** "volume" or "publisher". */
|
|
|
|
|
method: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 18:40:08 -04:00
|
|
|
/** What a file is. */
|
|
|
|
|
export interface Doc {
|
|
|
|
|
v: number;
|
|
|
|
|
path: string;
|
|
|
|
|
kind: string;
|
|
|
|
|
declared_ext: string | null;
|
|
|
|
|
bytes: number;
|
|
|
|
|
pages: number | null;
|
|
|
|
|
language: string | null;
|
|
|
|
|
text_layer: string | null;
|
|
|
|
|
needs_ocr: boolean | null;
|
|
|
|
|
notes: string[];
|
|
|
|
|
}
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
/// What the chunker does when nothing is passed.
|
|
|
|
|
fn chunk_budgets(
|
|
|
|
|
target: Option<usize>,
|
|
|
|
|
overlap: Option<usize>,
|
|
|
|
|
min_tokens: Option<usize>,
|
|
|
|
|
hard_max: Option<usize>,
|
|
|
|
|
) -> ChunkBudgets {
|
|
|
|
|
let default = ChunkBudgets::default();
|
|
|
|
|
ChunkBudgets {
|
|
|
|
|
target: target.unwrap_or(default.target),
|
|
|
|
|
overlap: overlap.unwrap_or(default.overlap),
|
|
|
|
|
min_tokens: min_tokens.unwrap_or(default.min_tokens),
|
|
|
|
|
hard_max: hard_max.unwrap_or(default.hard_max),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_js<T: serde::Serialize>(value: &T) -> Result<JsValue, JsValue> {
|
|
|
|
|
serde_wasm_bindgen::to_value(value).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn from_js<T: serde::de::DeserializeOwned>(value: JsValue, what: &str) -> Result<T, JsValue> {
|
|
|
|
|
serde_wasm_bindgen::from_value(value)
|
|
|
|
|
.map_err(|e| JsValue::from_str(&format!("{what} is not the expected shape: {e}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Cut markdown into retrievable passages.
|
|
|
|
|
#[wasm_bindgen(js_name = chunkMarkdown, skip_typescript)]
|
|
|
|
|
pub fn chunk_markdown(
|
|
|
|
|
markdown: &str,
|
|
|
|
|
target: Option<usize>,
|
|
|
|
|
overlap: Option<usize>,
|
|
|
|
|
min_tokens: Option<usize>,
|
|
|
|
|
hard_max: Option<usize>,
|
|
|
|
|
) -> Result<JsValue, JsValue> {
|
|
|
|
|
let budgets = chunk_budgets(target, overlap, min_tokens, hard_max);
|
|
|
|
|
to_js(&crate::chunk_markdown(markdown, budgets))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Clean converted markdown.
|
|
|
|
|
///
|
|
|
|
|
/// `pages` also normalises page markers, which is not idempotent.
|
|
|
|
|
#[wasm_bindgen(js_name = scrub)]
|
|
|
|
|
pub fn scrub(markdown: &str, pages: Option<bool>) -> String {
|
|
|
|
|
match pages.unwrap_or(false) {
|
|
|
|
|
true => crate::scrub_markdown_pages(markdown),
|
|
|
|
|
false => crate::scrub_markdown(markdown),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How many tokens a string is.
|
|
|
|
|
#[wasm_bindgen(js_name = countTokens)]
|
|
|
|
|
pub fn count_tokens(text: &str) -> usize {
|
|
|
|
|
crate::count_tokens(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read any of the subtitle formats into cues.
|
|
|
|
|
#[wasm_bindgen(js_name = parseSubtitles, skip_typescript)]
|
|
|
|
|
pub fn parse_subtitles(content: &str, keep_repeats: Option<bool>) -> Result<JsValue, JsValue> {
|
|
|
|
|
let cues = crate::parse_subtitles(content);
|
|
|
|
|
let cues = match keep_repeats.unwrap_or(false) {
|
|
|
|
|
true => cues,
|
|
|
|
|
false => crate::merge_repeats(&cues),
|
|
|
|
|
};
|
|
|
|
|
to_js(&cues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pack cues into passages worth embedding.
|
|
|
|
|
#[wasm_bindgen(js_name = packCues, skip_typescript)]
|
|
|
|
|
pub fn pack_cues(
|
|
|
|
|
cues: JsValue,
|
|
|
|
|
seconds: Option<f64>,
|
|
|
|
|
max_tokens: Option<usize>,
|
|
|
|
|
) -> Result<JsValue, JsValue> {
|
|
|
|
|
let default = CueBudgets::default();
|
|
|
|
|
let budgets = CueBudgets {
|
|
|
|
|
seconds: seconds.unwrap_or(default.seconds),
|
|
|
|
|
max_tokens: max_tokens.unwrap_or(default.max_tokens),
|
|
|
|
|
};
|
|
|
|
|
let cues: Vec<cuekit::Cue> = from_js(cues, "cues")?;
|
|
|
|
|
to_js(&crate::pack_cues(&cues, budgets))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Split a collection into the works inside it.
|
|
|
|
|
///
|
|
|
|
|
/// Throws when the document is one work rather than a collection, which is
|
|
|
|
|
/// the usual answer, with the reason each candidate was rejected.
|
|
|
|
|
#[wasm_bindgen(js_name = splitCollection, skip_typescript)]
|
|
|
|
|
pub fn split_collection(markdown: &str) -> Result<JsValue, JsValue> {
|
|
|
|
|
match crate::split_collection(markdown) {
|
|
|
|
|
Ok(sections) => to_js(§ions),
|
|
|
|
|
Err(why) => Err(JsValue::from_str(&why)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fuse ranked lists into one.
|
|
|
|
|
#[wasm_bindgen(js_name = fuseHits, skip_typescript)]
|
|
|
|
|
pub fn fuse_hits(
|
|
|
|
|
hits: JsValue,
|
|
|
|
|
k: Option<f64>,
|
|
|
|
|
weights: JsValue,
|
|
|
|
|
limit: Option<usize>,
|
|
|
|
|
) -> Result<JsValue, JsValue> {
|
|
|
|
|
let hits: Vec<corpus_core::Hit> = from_js(hits, "hits")?;
|
|
|
|
|
let weights: Vec<(String, f64)> = match weights.is_undefined() || weights.is_null() {
|
|
|
|
|
true => Vec::new(),
|
|
|
|
|
false => from_js::<std::collections::HashMap<String, f64>>(weights, "weights")?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.collect(),
|
|
|
|
|
};
|
|
|
|
|
to_js(&crate::fuse_hits(
|
|
|
|
|
&hits,
|
|
|
|
|
k.unwrap_or(rrf::K),
|
|
|
|
|
&weights,
|
|
|
|
|
limit,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Say what a file is from its first bytes and its name.
|
|
|
|
|
#[wasm_bindgen(js_name = identify, skip_typescript)]
|
|
|
|
|
pub fn identify(name: &str, data: &[u8]) -> Result<JsValue, JsValue> {
|
|
|
|
|
to_js(&crate::identify(name, data))
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:03:47 -04:00
|
|
|
/// The series a title belongs to, and which volume of it this is.
|
|
|
|
|
///
|
|
|
|
|
/// `undefined` when the title says nothing about a series.
|
|
|
|
|
#[wasm_bindgen(js_name = seriesOf, skip_typescript)]
|
|
|
|
|
pub fn series_of(title: &str, author: Option<String>) -> Result<JsValue, JsValue> {
|
|
|
|
|
match crate::series_of(title, author.as_deref()) {
|
|
|
|
|
Some(found) => to_js(&found),
|
|
|
|
|
None => Ok(JsValue::UNDEFINED),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:19:54 -04:00
|
|
|
/// Read an identifier out of whatever it arrived wrapped in.
|
|
|
|
|
///
|
|
|
|
|
/// `undefined` when the string is not one.
|
|
|
|
|
#[wasm_bindgen(js_name = parseIdentifier, skip_typescript)]
|
|
|
|
|
pub fn parse_identifier(text: &str) -> Result<JsValue, JsValue> {
|
|
|
|
|
match crate::parse_identifier(text) {
|
|
|
|
|
Some(found) => to_js(&found),
|
|
|
|
|
None => Ok(JsValue::UNDEFINED),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 18:40:08 -04:00
|
|
|
/// Declarations for the functions whose return type the generator cannot
|
|
|
|
|
/// infer, so a caller gets `Chunk[]` rather than `any`.
|
|
|
|
|
#[wasm_bindgen(typescript_custom_section)]
|
|
|
|
|
const SIGNATURES: &'static str = r#"
|
|
|
|
|
export function chunkMarkdown(markdown: string, target?: number, overlap?: number, min_tokens?: number, hard_max?: number): Chunk[];
|
|
|
|
|
export function parseSubtitles(content: string, keep_repeats?: boolean): Cue[];
|
|
|
|
|
export function packCues(cues: Cue[], seconds?: number, max_tokens?: number): Chunk[];
|
|
|
|
|
export function splitCollection(markdown: string): Section[];
|
|
|
|
|
export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[];
|
|
|
|
|
export function identify(name: string, data: Uint8Array): Doc;
|
2026-09-19 19:03:47 -04:00
|
|
|
export function seriesOf(title: string, author?: string): SeriesProposal | undefined;
|
2026-09-19 19:19:54 -04:00
|
|
|
export function parseIdentifier(text: string): Identifier | undefined;
|
2026-09-19 18:40:08 -04:00
|
|
|
"#;
|