import { toAsyncIterable } from "jsr:@core/iterutil@0.9/async/to-async-iterable"; export interface Page { /** ページタイトル */ title: string; /** このページに含める画像のインデックス */ indice: number[]; /** 前ページのタイトル * * なければ空文字 */ prev: string; /** 次ページのタイトル * * なければ空文字 */ next: string; /** 子ページのタイトル */ children: string[]; } export interface PageSource { url: string | URL; text: string; } /** ページ情報と画像URLとテキストから、scrapboxのページを作る TransformStream */ export class PageStringifyStream extends TransformStream<Page, string[]> { #stack: PageSource[] = []; #iter: AsyncIterator<PageSource>; #iterReady = Promise.resolve(); async #at(index: number) { const item = this.#stack.at(index); if (item) return item; await this.#iterReady; const ready = (async () => { while (true) { const { done, value } = await this.#iter.next(); if (done) return; this.#stack.push(value); if (index === this.#stack.length - 1) return value; } })(); this.#iterReady = ready as Promise<void>; return ready; } /** * @param source 書籍に使う画像のURLとテキストのセット * @param [offset=0] 書籍formatで指定したページ番号が0以外で始まる場合、この数値を変えて調節する */ constructor( source: Iterable<PageSource> | AsyncIterable<PageSource>, offset=0, ) { super({ transform: async (page, controller) => { controller.enqueue([ page.title, ...(await Promise.all(page.indice.map(async (i) => { const source = await this.#at(i+offset); if (!source) { console.warn(`Could not find the image source for ${i}.`); return []; } return [ ...source.text.split("\n"), "", `[${source.url}]`, "", ]; }))).flat(), ...page.children.map((title) => `[${title}]`), "", `<= ${page.prev !== "" ? `[${page.prev}]` : ""} | ${ page.next !== "" ? `[${page.next}]` : "" } =>`, "", ]); }, }); this.#iter = (Symbol.iterator in source ? toAsyncIterable(source) : source) [Symbol.asyncIterator](); } } /** 生成したページデータを並び替える * @example * ```ts * import { CsvParseStream } from "jsr:@std/csv@1/parse-stream"; * import { assertEquals } from "jsr:@std/assert@1/equals"; * * const csv = `1,0,2,3 * 1.1,3,3, * 1.2,x,x,2 * 1.2.1,4,6,0 * 1.2.2,6,12,0 * column1,13,14,0 * 2,,,2 * 2.1,15,16, * 2.2,17,20,1 * column2,19,19,0 * 3,20,24,0`; * const sorted = ReadableStream.from([csv]) * .pipeThrough(new CsvParseStream()) * .pipeThrough(new IndexParseStream()) * .pipeThrough(new PageSortStream()); * * assertEquals<Page[]>(await Array.fromAsync(sorted), [ * { * title: "1", * next: "2", * prev: "", * indice: [0, 1, 2], * children: ["1.1", "1.2", "column1"], * }, * { title: "1.1", next: "1.2", prev: "1", indice: [3], children: [] }, * { * title: "1.2", * next: "column1", * prev: "1.1", * indice: [], * children: ["1.2.1", "1.2.2"], * }, * { * title: "1.2.1", * next: "1.2.2", * prev: "1.2", * indice: [4, 5, 6], * children: [], * }, * { * title: "1.2.2", * next: "column1", * prev: "1.2.1", * indice: [6, 7, 8, 9, 10, 11, 12], * children: [], * }, * { * title: "column1", * next: "2", * prev: "1.2", * indice: [13, 14], * children: [], * }, * { * title: "2", * next: "3", * prev: "1", * indice: [], * children: ["2.1", "2.2"], * }, * { title: "2.1", next: "2.2", prev: "2", indice: [15, 16], children: [] }, * { * title: "2.2", * next: "3", * prev: "2.1", * indice: [17, 18, 19, 20], * children: ["column2"], * }, * { title: "column2", next: "3", prev: "2.2", indice: [19], children: [] }, * { * title: "3", * next: "", * prev: "2", * indice: [20, 21, 22, 23, 24], * children: [], * }, * ]); * ``` */ export class PageSortStream extends TransformStream<Page, Page> { #stack: Page[] = []; #find(title: string): Page | undefined { const index = this.#stack.findIndex((page) => page.title === title); if (index < 0) return; const [target] = this.#stack.splice(index, 1); return target; } constructor() { let returnedFirst = false; let next = ""; const enqueueAndSetNext = ( page: Page, controller: TransformStreamDefaultController<Page>, ) => { controller.enqueue(page); next = page.children.at(0) ?? page.next; }; super({ transform: (page, controller) => { if (page.title === next || (!returnedFirst && page.prev === "")) { if (!returnedFirst && page.prev === "") returnedFirst = true; enqueueAndSetNext(page, controller); return; } this.#stack.push(page); }, flush: (controller) => { if (!returnedFirst) { const first = this.#stack.find((page) => page.prev === ""); if (!first) throw new Error("Could not find the first page."); enqueueAndSetNext(first, controller); } while (next !== "") { const page = this.#find(next); if (!page) throw new Error(`Could not find the next page "${next}".`); enqueueAndSetNext(page, controller); } }, }); } } /** * @example * ```ts * import { CsvParseStream } from "jsr:@std/csv@1/parse-stream"; * import { assertEquals } from "jsr:@std/assert@1/equals"; * * const csv = `1,0,2,3 * 1.1,3,3, * 1.2,x,x,2 * 1.2.1,4,6,0 * 1.2.2,6,12,0 * column1,13,14,0 * 2,,,2 * 2.1,15,16, * 2.2,17,20,1 * column2,19,19,0 * 3,20,24,0`; * const data = ReadableStream.from([csv]) * .pipeThrough(new CsvParseStream()) * .pipeThrough(new IndexParseStream()); * * assertEquals<Page[]>(await Array.fromAsync(data), [ * { title: "1.1", next: "1.2", prev: "1", indice: [3], children: [] }, * { * title: "1.2.1", * next: "1.2.2", * prev: "1.2", * indice: [4, 5, 6], * children: [], * }, * { * title: "1.2.2", * next: "column1", * prev: "1.2.1", * indice: [6, 7, 8, 9, 10, 11, 12], * children: [], * }, * { * title: "1.2", * next: "column1", * prev: "1.1", * indice: [], * children: ["1.2.1", "1.2.2"], * }, * { * title: "column1", * next: "2", * prev: "1.2", * indice: [13, 14], * children: [], * }, * { * title: "1", * next: "2", * prev: "", * indice: [0, 1, 2], * children: ["1.1", "1.2", "column1"], * }, * { title: "2.1", next: "2.2", prev: "2", indice: [15, 16], children: [] }, * { title: "column2", next: "3", prev: "2.2", indice: [19], children: [] }, * { * title: "2.2", * next: "3", * prev: "2.1", * indice: [17, 18, 19, 20], * children: ["column2"], * }, * { * title: "2", * next: "3", * prev: "1", * indice: [], * children: ["2.1", "2.2"], * }, * { * title: "3", * next: "", * prev: "2", * indice: [20, 21, 22, 23, 24], * children: [], * }, * ]); * ``` */ export class IndexParseStream extends TransformStream<string[], Page> { constructor() { const parents: { page: Page; childNum: number }[] = []; super({ transform(row, controller) { // for loopの都合上、parentsには先頭から要素を追加する // 階層の深い順にparentが並ぶ const [title, start, end, childNumText] = row; const s = /^\d+$/.test(start) ? Math.abs(parseInt(start)) : -1; const e = /^\d+$/.test(end) ? Math.abs(parseInt(end)) : -1; const indice = s !== -1 && e !== -1 ? [...range(s, e)] : []; const childNum = /^\d+$/.test(childNumText) ? Math.abs(parseInt(childNumText)) : 0; const page: Page = { title, indice, prev: "", next: "", children: [] }; // 親見出しから処理する let i = 0; for (; i < parents.length; i++) { const parent = parents[i]; // `page`を親見出しの子とする場合は、追加処理を行ってループを抜ける if ( parent.childNum !== 0 && parent.childNum > parent.page.children.length ) { // 親見出しの子にする parent.page.children.push(page.title); // 最初の子の場合のみ、親見出しとつなげる if (parent.page.children.length === 1) { page.prev = parent.page.title; } break; } // 見出しが完成しているときは、pageを同階層の次の見出しとみなす // 小見出しがない見出しもここで処理される parent.page.next = page.title; page.prev = parent.page.title; } // 完成済みの見出しをparentsから削って返す // 添字がi未満のparentsが、完成した見出しとなる for (const { page } of parents.splice(0, i)) { controller.enqueue(page); } // 一旦parentsにためてから処理する parents.unshift({ page, childNum }); }, flush(controller) { for (const { page } of parents) { controller.enqueue(page); } }, }); } } function* range(start: number, end: number) { for (let i = start; i <= end; i++) { yield i; } }