@intentic/fileq 1.317.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/app.d.ts +2 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +43 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +4 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/derive.command.d.ts +5 -0
  12. package/dist/commands/derive.command.d.ts.map +1 -0
  13. package/dist/commands/derive.command.js +53 -0
  14. package/dist/commands/derive.command.js.map +1 -0
  15. package/dist/commands/git-attributes.command.d.ts +3 -0
  16. package/dist/commands/git-attributes.command.d.ts.map +1 -0
  17. package/dist/commands/git-attributes.command.js +12 -0
  18. package/dist/commands/git-attributes.command.js.map +1 -0
  19. package/dist/commands/read.command.d.ts +3 -0
  20. package/dist/commands/read.command.d.ts.map +1 -0
  21. package/dist/commands/read.command.js +126 -0
  22. package/dist/commands/read.command.js.map +1 -0
  23. package/dist/commands/sweep.command.d.ts +3 -0
  24. package/dist/commands/sweep.command.d.ts.map +1 -0
  25. package/dist/commands/sweep.command.js +41 -0
  26. package/dist/commands/sweep.command.js.map +1 -0
  27. package/dist/lib/archives.d.ts +18 -0
  28. package/dist/lib/archives.d.ts.map +1 -0
  29. package/dist/lib/archives.js +133 -0
  30. package/dist/lib/archives.js.map +1 -0
  31. package/dist/lib/derive.d.ts +32 -0
  32. package/dist/lib/derive.d.ts.map +1 -0
  33. package/dist/lib/derive.js +99 -0
  34. package/dist/lib/derive.js.map +1 -0
  35. package/dist/lib/derivers/archive.d.ts +3 -0
  36. package/dist/lib/derivers/archive.d.ts.map +1 -0
  37. package/dist/lib/derivers/archive.js +132 -0
  38. package/dist/lib/derivers/archive.js.map +1 -0
  39. package/dist/lib/derivers/deriver.d.ts +13 -0
  40. package/dist/lib/derivers/deriver.d.ts.map +1 -0
  41. package/dist/lib/derivers/deriver.js +8 -0
  42. package/dist/lib/derivers/deriver.js.map +1 -0
  43. package/dist/lib/derivers/docx.d.ts +6 -0
  44. package/dist/lib/derivers/docx.d.ts.map +1 -0
  45. package/dist/lib/derivers/docx.js +29 -0
  46. package/dist/lib/derivers/docx.js.map +1 -0
  47. package/dist/lib/derivers/epub.d.ts +3 -0
  48. package/dist/lib/derivers/epub.d.ts.map +1 -0
  49. package/dist/lib/derivers/epub.js +102 -0
  50. package/dist/lib/derivers/epub.js.map +1 -0
  51. package/dist/lib/derivers/html.d.ts +3 -0
  52. package/dist/lib/derivers/html.d.ts.map +1 -0
  53. package/dist/lib/derivers/html.js +18 -0
  54. package/dist/lib/derivers/html.js.map +1 -0
  55. package/dist/lib/derivers/image.d.ts +3 -0
  56. package/dist/lib/derivers/image.d.ts.map +1 -0
  57. package/dist/lib/derivers/image.js +42 -0
  58. package/dist/lib/derivers/image.js.map +1 -0
  59. package/dist/lib/derivers/ipynb.d.ts +3 -0
  60. package/dist/lib/derivers/ipynb.d.ts.map +1 -0
  61. package/dist/lib/derivers/ipynb.js +75 -0
  62. package/dist/lib/derivers/ipynb.js.map +1 -0
  63. package/dist/lib/derivers/media.d.ts +3 -0
  64. package/dist/lib/derivers/media.d.ts.map +1 -0
  65. package/dist/lib/derivers/media.js +45 -0
  66. package/dist/lib/derivers/media.js.map +1 -0
  67. package/dist/lib/derivers/odp.d.ts +3 -0
  68. package/dist/lib/derivers/odp.d.ts.map +1 -0
  69. package/dist/lib/derivers/odp.js +41 -0
  70. package/dist/lib/derivers/odp.js.map +1 -0
  71. package/dist/lib/derivers/ods.d.ts +3 -0
  72. package/dist/lib/derivers/ods.d.ts.map +1 -0
  73. package/dist/lib/derivers/ods.js +86 -0
  74. package/dist/lib/derivers/ods.js.map +1 -0
  75. package/dist/lib/derivers/odt.d.ts +4 -0
  76. package/dist/lib/derivers/odt.d.ts.map +1 -0
  77. package/dist/lib/derivers/odt.js +77 -0
  78. package/dist/lib/derivers/odt.js.map +1 -0
  79. package/dist/lib/derivers/pdf-furniture.d.ts +7 -0
  80. package/dist/lib/derivers/pdf-furniture.d.ts.map +1 -0
  81. package/dist/lib/derivers/pdf-furniture.js +76 -0
  82. package/dist/lib/derivers/pdf-furniture.js.map +1 -0
  83. package/dist/lib/derivers/pdf.d.ts +4 -0
  84. package/dist/lib/derivers/pdf.d.ts.map +1 -0
  85. package/dist/lib/derivers/pdf.js +79 -0
  86. package/dist/lib/derivers/pdf.js.map +1 -0
  87. package/dist/lib/derivers/pptx.d.ts +4 -0
  88. package/dist/lib/derivers/pptx.d.ts.map +1 -0
  89. package/dist/lib/derivers/pptx.js +51 -0
  90. package/dist/lib/derivers/pptx.js.map +1 -0
  91. package/dist/lib/derivers/profile.d.ts +3 -0
  92. package/dist/lib/derivers/profile.d.ts.map +1 -0
  93. package/dist/lib/derivers/profile.js +115 -0
  94. package/dist/lib/derivers/profile.js.map +1 -0
  95. package/dist/lib/derivers/rtf.d.ts +4 -0
  96. package/dist/lib/derivers/rtf.d.ts.map +1 -0
  97. package/dist/lib/derivers/rtf.js +187 -0
  98. package/dist/lib/derivers/rtf.js.map +1 -0
  99. package/dist/lib/derivers/xlsx.d.ts +3 -0
  100. package/dist/lib/derivers/xlsx.d.ts.map +1 -0
  101. package/dist/lib/derivers/xlsx.js +68 -0
  102. package/dist/lib/derivers/xlsx.js.map +1 -0
  103. package/dist/lib/env.d.ts +2 -0
  104. package/dist/lib/env.d.ts.map +1 -0
  105. package/dist/lib/env.js +5 -0
  106. package/dist/lib/env.js.map +1 -0
  107. package/dist/lib/flags.d.ts +2 -0
  108. package/dist/lib/flags.d.ts.map +1 -0
  109. package/dist/lib/flags.js +8 -0
  110. package/dist/lib/flags.js.map +1 -0
  111. package/dist/lib/formats.d.ts +6 -0
  112. package/dist/lib/formats.d.ts.map +1 -0
  113. package/dist/lib/formats.js +110 -0
  114. package/dist/lib/formats.js.map +1 -0
  115. package/dist/lib/sidecar.d.ts +30 -0
  116. package/dist/lib/sidecar.d.ts.map +1 -0
  117. package/dist/lib/sidecar.js +108 -0
  118. package/dist/lib/sidecar.js.map +1 -0
  119. package/dist/lib/sweep.d.ts +7 -0
  120. package/dist/lib/sweep.d.ts.map +1 -0
  121. package/dist/lib/sweep.js +56 -0
  122. package/dist/lib/sweep.js.map +1 -0
  123. package/dist/lib/tools.d.ts +2 -0
  124. package/dist/lib/tools.d.ts.map +1 -0
  125. package/dist/lib/tools.js +15 -0
  126. package/dist/lib/tools.js.map +1 -0
  127. package/dist/lib/xml.d.ts +3 -0
  128. package/dist/lib/xml.d.ts.map +1 -0
  129. package/dist/lib/xml.js +16 -0
  130. package/dist/lib/xml.js.map +1 -0
  131. package/dist/skill.d.ts +5 -0
  132. package/dist/skill.d.ts.map +1 -0
  133. package/dist/skill.js +75 -0
  134. package/dist/skill.js.map +1 -0
  135. package/dist/testing.d.ts +25 -0
  136. package/dist/testing.d.ts.map +1 -0
  137. package/dist/testing.js +220 -0
  138. package/dist/testing.js.map +1 -0
  139. package/dist/version.d.ts +2 -0
  140. package/dist/version.d.ts.map +1 -0
  141. package/dist/version.js +3 -0
  142. package/dist/version.js.map +1 -0
  143. package/package.json +89 -0
  144. package/src/app.ts +48 -0
  145. package/src/cli.integration.test.ts +200 -0
  146. package/src/cli.ts +6 -0
  147. package/src/commands/derive.command.ts +59 -0
  148. package/src/commands/git-attributes.command.ts +13 -0
  149. package/src/commands/read.command.ts +158 -0
  150. package/src/commands/sweep.command.ts +48 -0
  151. package/src/derivers.integration.test.ts +415 -0
  152. package/src/lib/archives.test.ts +72 -0
  153. package/src/lib/archives.ts +209 -0
  154. package/src/lib/derive.ts +132 -0
  155. package/src/lib/derivers/archive.ts +168 -0
  156. package/src/lib/derivers/deriver.ts +33 -0
  157. package/src/lib/derivers/docx.test.ts +38 -0
  158. package/src/lib/derivers/docx.ts +44 -0
  159. package/src/lib/derivers/epub.ts +130 -0
  160. package/src/lib/derivers/html.ts +21 -0
  161. package/src/lib/derivers/image.ts +54 -0
  162. package/src/lib/derivers/ipynb.ts +113 -0
  163. package/src/lib/derivers/load-cost.test.ts +44 -0
  164. package/src/lib/derivers/media.ts +51 -0
  165. package/src/lib/derivers/odp.ts +52 -0
  166. package/src/lib/derivers/ods.ts +104 -0
  167. package/src/lib/derivers/odt.ts +101 -0
  168. package/src/lib/derivers/pdf-furniture.test.ts +60 -0
  169. package/src/lib/derivers/pdf-furniture.ts +121 -0
  170. package/src/lib/derivers/pdf.ts +102 -0
  171. package/src/lib/derivers/pptx.ts +61 -0
  172. package/src/lib/derivers/profile.ts +172 -0
  173. package/src/lib/derivers/rtf.ts +233 -0
  174. package/src/lib/derivers/xlsx.ts +82 -0
  175. package/src/lib/env.ts +7 -0
  176. package/src/lib/formats.test.ts +50 -0
  177. package/src/lib/formats.ts +141 -0
  178. package/src/lib/sidecar.test.ts +60 -0
  179. package/src/lib/sidecar.ts +156 -0
  180. package/src/lib/sweep.ts +72 -0
  181. package/src/lib/tools.ts +16 -0
  182. package/src/lib/xml.ts +20 -0
  183. package/src/skill.ts +90 -0
  184. package/src/testing.ts +333 -0
@@ -0,0 +1,130 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { posix } from "node:path";
3
+ import { unzipSync, type Unzipped } from "fflate";
4
+ import { bodyOf, metaOf, parseHtml } from "@intentic/webq/dom";
5
+ import { renderMarkdown } from "@intentic/webq/markdown";
6
+ import type { DerivedDoc, Deriver } from "./deriver.js";
7
+ import { attributeOf, decodeEntities } from "../xml.js";
8
+
9
+ /* EPUBs: a zip of XHTML chapters, read in the order the package document (the OPF) says they are read. */
10
+
11
+ const MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
12
+
13
+ interface ManifestItem {
14
+ readonly href: string;
15
+ readonly mediaType: string;
16
+ readonly nav: boolean;
17
+ }
18
+
19
+ interface Package {
20
+ readonly dir: string;
21
+ readonly title: string | undefined;
22
+ readonly manifest: Map<string, ManifestItem>;
23
+ readonly spine: string[];
24
+ }
25
+
26
+ const decoder = new TextDecoder();
27
+
28
+ const partOf = (zip: Unzipped, name: string): string | undefined => (zip[name] === undefined ? undefined : decoder.decode(zip[name]));
29
+
30
+ const manifestOf = (opf: string): Map<string, ManifestItem> => {
31
+ const items = new Map<string, ManifestItem>();
32
+ for (const match of opf.matchAll(/<(?:opf:)?item\b([^>]*?)\/?>/g)) {
33
+ const attributes = match[1] ?? "";
34
+ const id = attributeOf(attributes, "id");
35
+ const href = attributeOf(attributes, "href");
36
+ if (id !== undefined && href !== undefined) {
37
+ items.set(id, {
38
+ href,
39
+ mediaType: attributeOf(attributes, "media-type") ?? "",
40
+ nav: (attributeOf(attributes, "properties") ?? "").split(/\s+/).includes("nav"),
41
+ });
42
+ }
43
+ }
44
+ return items;
45
+ };
46
+
47
+ const spineOf = (opf: string): string[] =>
48
+ [...opf.matchAll(/<(?:opf:)?itemref\b([^>]*?)\/?>/g)]
49
+ .map((match) => attributeOf(match[1] ?? "", "idref"))
50
+ .filter((idref): idref is string => idref !== undefined);
51
+
52
+ // container.xml names the OPF; the OPF names everything else. Undefined when either is missing.
53
+ const readPackage = (zip: Unzipped): Package | undefined => {
54
+ const container = partOf(zip, "META-INF/container.xml");
55
+ const opfPath = container === undefined ? undefined : /full-path\s*=\s*"([^"]+)"/.exec(container)?.[1];
56
+ const opf = opfPath === undefined ? undefined : partOf(zip, opfPath);
57
+ if (opfPath === undefined || opf === undefined) {
58
+ return undefined;
59
+ }
60
+ const titleMatch = /<dc:title[^>]*>([\s\S]*?)<\/dc:title>/.exec(opf);
61
+ const raw = titleMatch?.[1]?.trim() ?? "";
62
+ return { dir: posix.dirname(opfPath), title: raw === "" ? undefined : decodeEntities(raw), manifest: manifestOf(opf), spine: spineOf(opf) };
63
+ };
64
+
65
+ // One spine entry as a markdown section: undefined when it is not a chapter (the nav document, a stylesheet,
66
+ // an empty page); "missing" when the package names a file the archive does not carry.
67
+ const chapterOf = (zip: Unzipped, pkg: Package, idref: string): string | "missing" | undefined => {
68
+ const item = pkg.manifest.get(idref);
69
+ if (item === undefined || item.nav || !/x?html/.test(item.mediaType)) {
70
+ return undefined;
71
+ }
72
+ const path = posix.normalize(posix.join(pkg.dir, decodeURIComponent(item.href.split("#")[0] ?? item.href)));
73
+ const html = partOf(zip, path);
74
+ if (html === undefined) {
75
+ return "missing";
76
+ }
77
+ const doc = parseHtml(html);
78
+ const body = bodyOf(doc);
79
+ const markdown = body === undefined ? "" : renderMarkdown(body).trim();
80
+ if (markdown === "") {
81
+ return undefined;
82
+ }
83
+ return markdown.startsWith("#") ? markdown : `## ${metaOf(doc).title || posix.basename(path)}\n\n${markdown}`;
84
+ };
85
+
86
+ const notesOf = (rendered: number, chapters: number, missing: number, capped: boolean): string[] => {
87
+ const notes: string[] = [];
88
+ if (capped) {
89
+ notes.push(`showing ${rendered} of ${chapters} chapters: cut at ${Math.round(MAX_MARKDOWN_BYTES / 1024 / 1024)} MB of text`);
90
+ }
91
+ if (missing > 0) {
92
+ notes.push(`${missing} spine item${missing === 1 ? "" : "s"} named in the package but absent from the archive`);
93
+ }
94
+ if (chapters === 0) {
95
+ notes.push("no readable chapters in this EPUB");
96
+ }
97
+ return notes;
98
+ };
99
+
100
+ export const epubDeriver: Deriver = {
101
+ name: "epub",
102
+ version: 1,
103
+ derive: async (absPath): Promise<DerivedDoc> => {
104
+ const zip = unzipSync(new Uint8Array(await readFile(absPath)));
105
+ const pkg = readPackage(zip);
106
+ if (pkg === undefined) {
107
+ return { markdown: "", notes: ["no package document (OPF) found: META-INF/container.xml is missing or points nowhere"] };
108
+ }
109
+ const sections: string[] = [];
110
+ let bytes = 0;
111
+ let chapters = 0;
112
+ let missing = 0;
113
+ for (const idref of pkg.spine) {
114
+ const chapter = chapterOf(zip, pkg, idref);
115
+ if (chapter === undefined) {
116
+ continue;
117
+ }
118
+ if (chapter === "missing") {
119
+ missing += 1;
120
+ continue;
121
+ }
122
+ chapters += 1;
123
+ if (bytes < MAX_MARKDOWN_BYTES) {
124
+ sections.push(chapter); // past the cap a chapter is counted, not rendered, so the note can say how many
125
+ bytes += chapter.length;
126
+ }
127
+ }
128
+ return { markdown: sections.join("\n\n"), title: pkg.title, notes: notesOf(sections.length, chapters, missing, bytes >= MAX_MARKDOWN_BYTES) };
129
+ },
130
+ };
@@ -0,0 +1,21 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { bodyOf, metaOf, parseHtml } from "@intentic/webq/dom";
3
+ import { renderMarkdown } from "@intentic/webq/markdown";
4
+ import type { DerivedDoc, Deriver } from "./deriver.js";
5
+
6
+ /* Local HTML files, through webq's own writer — the same DOM → markdown conventions a fetched page gets, but with NO fit-pruning. */
7
+
8
+ export const htmlDeriver: Deriver = {
9
+ name: "html",
10
+ version: 1,
11
+ derive: async (absPath): Promise<DerivedDoc> => {
12
+ const doc = parseHtml(await readFile(absPath, "utf8"));
13
+ const body = bodyOf(doc);
14
+ const meta = metaOf(doc);
15
+ return {
16
+ markdown: body === undefined ? "" : renderMarkdown(body),
17
+ title: meta.title === "" ? undefined : meta.title,
18
+ notes: [],
19
+ };
20
+ },
21
+ };
@@ -0,0 +1,54 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import exifr from "exifr";
3
+ import { imageSize } from "image-size";
4
+ import type { DerivedDoc, Deriver } from "./deriver.js";
5
+
6
+ // exifr is CommonJS behind a `main` field with no exports map, so a named `import { parse }` type-checks but throws at
7
+ // load; the default import is its module.exports under Node and under a bundler alike (the Claude Code plugin bundles
8
+ // fileq, and a `createRequire` call is the one form a bundle cannot follow).
9
+ // oxlint-disable-next-line import/no-named-as-default-member -- the named import this rule asks for is the one that throws at load.
10
+ const { parse: parseExif } = exifr;
11
+
12
+ // Deterministic tier: dimensions, format, and EXIF facts left behind; no caption, since describing pixels needs a
13
+ // vision model.
14
+ // The sidecar says explicitly that no description was generated, so that reads as "not generated", not "nothing to
15
+ // see".
16
+
17
+ // EXIF fields worth a line each, in the order they help: what took it, when, where, how it's oriented.
18
+ const EXIF_LINES: readonly { readonly label: string; readonly keys: readonly string[] }[] = [
19
+ { label: "Camera", keys: ["Make", "Model"] },
20
+ { label: "Taken", keys: ["DateTimeOriginal", "CreateDate"] },
21
+ { label: "Software", keys: ["Software"] },
22
+ { label: "Orientation", keys: ["Orientation"] },
23
+ ];
24
+
25
+ export const imageDeriver: Deriver = {
26
+ name: "image",
27
+ version: 1,
28
+ derive: async (absPath): Promise<DerivedDoc> => {
29
+ const bytes = await readFile(absPath);
30
+ const lines: string[] = [];
31
+ try {
32
+ const dims = imageSize(bytes);
33
+ lines.push(`- Format: ${dims.type ?? "unknown"}`, `- Dimensions: ${dims.width}×${dims.height}`);
34
+ } catch {
35
+ lines.push("- Dimensions: unreadable");
36
+ }
37
+ const exif = (await parseExif(bytes).catch(() => undefined)) as Record<string, unknown> | undefined;
38
+ if (exif !== undefined) {
39
+ for (const { label, keys } of EXIF_LINES) {
40
+ const parts = keys.map((key) => exif[key]).filter((value) => value !== undefined && value !== "");
41
+ if (parts.length > 0) {
42
+ lines.push(`- ${label}: ${parts.map((part) => (part instanceof Date ? part.toISOString() : String(part))).join(" ")}`);
43
+ }
44
+ }
45
+ if (typeof exif["latitude"] === "number" && typeof exif["longitude"] === "number") {
46
+ lines.push(`- GPS: ${exif["latitude"].toFixed(5)}, ${exif["longitude"].toFixed(5)}`);
47
+ }
48
+ }
49
+ return {
50
+ markdown: lines.join("\n"),
51
+ notes: ["no visual description: image captioning is a later, model-backed tier — vision models should Read the image itself"],
52
+ };
53
+ },
54
+ };
@@ -0,0 +1,113 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { plural } from "@intentic/base/format";
3
+ import type { DerivedDoc, Deriver } from "./deriver.js";
4
+
5
+ /* Jupyter notebooks: JSON whose cells ARE the document. */
6
+
7
+ const MAX_OUTPUT_LINES = 40;
8
+
9
+ interface Output {
10
+ readonly output_type?: string;
11
+ readonly text?: string | string[];
12
+ readonly data?: Record<string, string | string[]>;
13
+ readonly ename?: string;
14
+ readonly evalue?: string;
15
+ }
16
+
17
+ interface Cell {
18
+ readonly cell_type?: string;
19
+ readonly source?: string | string[];
20
+ readonly outputs?: Output[];
21
+ }
22
+
23
+ interface Notebook {
24
+ readonly cells?: Cell[];
25
+ readonly metadata?: { readonly language_info?: { readonly name?: string }; readonly kernelspec?: { readonly language?: string } };
26
+ }
27
+
28
+ interface Counters {
29
+ rich: number;
30
+ clipped: number;
31
+ }
32
+
33
+ // nbformat stores multi-line text as an array of lines (newlines included) or, from older writers, one string.
34
+ const joined = (value: string | string[] | undefined): string => (Array.isArray(value) ? value.join("") : (value ?? ""));
35
+
36
+ const linesOf = (value: string | string[] | undefined): string[] => joined(value).trimEnd().split("\n");
37
+
38
+ const outputLines = (output: Output, counters: Counters): string[] => {
39
+ if (output.output_type === "stream") {
40
+ return linesOf(output.text);
41
+ }
42
+ if (output.output_type === "error") {
43
+ return [`${output.ename ?? "Error"}: ${output.evalue ?? ""}`.trimEnd()];
44
+ }
45
+ if (output.data === undefined) {
46
+ return [];
47
+ }
48
+ const plain = output.data["text/plain"];
49
+ if (plain !== undefined) {
50
+ return linesOf(plain);
51
+ }
52
+ counters.rich += 1;
53
+ return [`[${Object.keys(output.data).join(", ")} output omitted]`];
54
+ };
55
+
56
+ const codeCell = (cell: Cell, source: string, language: string, counters: Counters): string => {
57
+ const block = [`\`\`\`${language}`, source, "```"];
58
+ const lines = (cell.outputs ?? []).flatMap((output) => outputLines(output, counters));
59
+ if (lines.length === 0) {
60
+ return block.join("\n");
61
+ }
62
+ const kept = lines.slice(0, MAX_OUTPUT_LINES);
63
+ if (lines.length > MAX_OUTPUT_LINES) {
64
+ counters.clipped += 1;
65
+ kept.push(`… ${lines.length - MAX_OUTPUT_LINES} more output lines`);
66
+ }
67
+ return [...block, "", "```text", ...kept, "```"].join("\n");
68
+ };
69
+
70
+ // One cell as a markdown section, or undefined for a cell with nothing to show.
71
+ const sectionOf = (cell: Cell, language: string, counters: Counters): string | undefined => {
72
+ const source = joined(cell.source).trimEnd();
73
+ if (cell.cell_type === "code") {
74
+ return codeCell(cell, source, language, counters);
75
+ }
76
+ if (source === "") {
77
+ return undefined;
78
+ }
79
+ // Markdown cells are already markdown; raw cells are fenced so their text cannot pose as prose.
80
+ return cell.cell_type === "markdown" ? source : ["```text", source, "```"].join("\n");
81
+ };
82
+
83
+ const notesOf = (cellCount: number, counters: Counters): string[] => {
84
+ const notes: string[] = [];
85
+ if (cellCount === 0) {
86
+ notes.push("no cells in this notebook");
87
+ }
88
+ if (counters.clipped > 0) {
89
+ notes.push(`${plural(counters.clipped, "cell")}: output cut at ${MAX_OUTPUT_LINES} lines`);
90
+ }
91
+ if (counters.rich > 0) {
92
+ notes.push(`${plural(counters.rich, "rich output")} (images, HTML) omitted: a notebook's pictures need the notebook itself`);
93
+ }
94
+ return notes;
95
+ };
96
+
97
+ export const ipynbDeriver: Deriver = {
98
+ name: "ipynb",
99
+ version: 1,
100
+ derive: async (absPath): Promise<DerivedDoc> => {
101
+ const notebook = JSON.parse(await readFile(absPath, "utf8")) as Notebook;
102
+ const language = notebook.metadata?.language_info?.name ?? notebook.metadata?.kernelspec?.language ?? "python";
103
+ const cells = Array.isArray(notebook.cells) ? notebook.cells : [];
104
+ const counters: Counters = { rich: 0, clipped: 0 };
105
+ const sections = cells.map((cell) => sectionOf(cell, language, counters)).filter((section): section is string => section !== undefined);
106
+ // The first markdown heading is the notebook's own idea of its title.
107
+ const title = cells
108
+ .filter((cell) => cell.cell_type === "markdown")
109
+ .map((cell) => /^#\s+(.+)$/m.exec(joined(cell.source))?.[1]?.trim())
110
+ .find((heading) => heading !== undefined);
111
+ return { markdown: sections.join("\n\n"), title, notes: notesOf(cells.length, counters) };
112
+ },
113
+ };
@@ -0,0 +1,44 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ // What a fileq run costs before it does anything. `derive.ts` imports all eleven derivers so `read` and `derive`
5
+ // cannot disagree about a file, which means one deriver's top-level `import` is paid by every run of the CLI: an
6
+ // agent reading a png, a sweep with nothing stale in it, `fileq --version`.
7
+ // Measured when this guard was written, on the sandbox image: loading these three took ~1.3s of a ~1.4s no-op run,
8
+ // and moving them inside `derive()` took `fileq --version` from ~560ms to ~120ms. A file of that format pays the
9
+ // same load it always did, once, in the process that actually parses it.
10
+ // Re-measure with: `node -e "import('exceljs')"` against the clock, and add anything else of that size here.
11
+ const LOAD_LAZILY = ["exceljs", "mammoth", "music-metadata"];
12
+
13
+ const HERE = import.meta.dirname;
14
+
15
+ const deriverSources = async (): Promise<{ name: string; source: string }[]> => {
16
+ const names = (await readdir(HERE)).filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"));
17
+ return Promise.all(names.map(async (name) => ({ name, source: await readFile(join(HERE, name), "utf8") })));
18
+ };
19
+
20
+ describe("what a deriver costs to load", () => {
21
+ it("keeps the heavy parsers out of every run that does not use them", async () => {
22
+ const statik: string[] = [];
23
+ for (const { name, source } of await deriverSources()) {
24
+ for (const module of LOAD_LAZILY) {
25
+ // A value import: `import x from "exceljs"`. `import type` is erased by the compiler and costs nothing,
26
+ // and `await import("exceljs")` inside derive() is the whole point.
27
+ if (new RegExp(String.raw`^import (?!type )[^;]*from "${module}";`, "m").test(source)) {
28
+ statik.push(`${name} → ${module}`);
29
+ }
30
+ }
31
+ }
32
+ expect(statik).toEqual([]);
33
+ });
34
+
35
+ // The same rule read from the other side: each of those parsers is still reachable, just later. A deriver that
36
+ // dropped its parser entirely would pass the test above by doing nothing at all.
37
+ it("still loads each of them where its own format is parsed", async () => {
38
+ const sources = await deriverSources();
39
+ for (const module of LOAD_LAZILY) {
40
+ const users = sources.filter(({ source }) => source.includes(`await import("${module}")`));
41
+ expect({ module, users: users.length }).toEqual({ module, users: 1 });
42
+ }
43
+ });
44
+ });
@@ -0,0 +1,51 @@
1
+ import type { DerivedDoc, Deriver } from "./deriver.js";
2
+
3
+ /* Media derivation reads container duration, codecs, and tags without decoding frames. */
4
+
5
+ // music-metadata is 0.2s to load, and only a recording needs it; see xlsx.ts for why that is loaded inside derive().
6
+
7
+ const formatDuration = (seconds: number): string => {
8
+ const whole = Math.round(seconds);
9
+ const minutes = Math.floor(whole / 60);
10
+ return minutes === 0 ? `${whole}s` : `${minutes}m ${String(whole % 60).padStart(2, "0")}s`;
11
+ };
12
+
13
+ export const mediaDeriver: Deriver = {
14
+ name: "media",
15
+ version: 1,
16
+ derive: async (absPath): Promise<DerivedDoc> => {
17
+ const { parseFile } = await import("music-metadata");
18
+ const meta = await parseFile(absPath, { duration: true });
19
+ const lines: string[] = [];
20
+ const { format, common } = meta;
21
+ if (format.container !== undefined) {
22
+ lines.push(`- Container: ${format.container}${format.codec === undefined ? "" : ` (${format.codec})`}`);
23
+ }
24
+ if (format.duration !== undefined) {
25
+ lines.push(`- Duration: ${formatDuration(format.duration)}`);
26
+ }
27
+ if (format.sampleRate !== undefined) {
28
+ lines.push(`- Sample rate: ${format.sampleRate} Hz${format.numberOfChannels === undefined ? "" : `, ${format.numberOfChannels}ch`}`);
29
+ }
30
+ if (format.bitrate !== undefined) {
31
+ lines.push(`- Bitrate: ${Math.round(format.bitrate / 1000)} kbit/s`);
32
+ }
33
+ const tags: readonly (readonly [string, string | number | undefined])[] = [
34
+ ["Title", common.title],
35
+ ["Artist", common.artist],
36
+ ["Album", common.album],
37
+ ["Year", common.year],
38
+ ["Genre", common.genre?.join(", ")],
39
+ ];
40
+ for (const [label, value] of tags) {
41
+ if (value !== undefined && value !== "") {
42
+ lines.push(`- ${label}: ${value}`);
43
+ }
44
+ }
45
+ return {
46
+ markdown: lines.join("\n"),
47
+ title: common.title,
48
+ notes: ["no transcript: audio transcription is a later, whisper-backed tier"],
49
+ };
50
+ },
51
+ };
@@ -0,0 +1,52 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { unzipSync } from "fflate";
3
+ import type { DerivedDoc, Deriver } from "./deriver.js";
4
+ import { attributeOf, decodeEntities } from "../xml.js";
5
+
6
+ /* OpenDocument presentations and drawings: one `## Slide N` section per page, the same shape pptx derives to. */
7
+
8
+ const PAGE = /<draw:page\b([^>]*)>([\s\S]*?)<\/draw:page>/g;
9
+ const NOTES = /<presentation:notes\b[^>]*>[\s\S]*?<\/presentation:notes>/g;
10
+ const PARAGRAPH = /<text:(p|h)\b[^>]*(?:\/>|>([\s\S]*?)<\/text:\1>)/g;
11
+
12
+ /** The text of one `<text:p>` or `<text:h>`, with the elements ODF uses for spacing turned back into spaces. */
13
+ const lineOf = (inner: string): string =>
14
+ decodeEntities(
15
+ inner
16
+ .replaceAll(/<text:s\b[^>]*\/>/g, " ")
17
+ .replaceAll(/<text:tab\b[^>]*\/>/g, " ")
18
+ .replaceAll(/<text:line-break\b[^>]*\/>/g, " ")
19
+ .replaceAll(/<[^>]+>/g, ""),
20
+ )
21
+ .replaceAll(/\s+/g, " ")
22
+ .trim();
23
+
24
+ const linesOf = (xml: string): string[] => [...xml.matchAll(PARAGRAPH)].map((match) => lineOf(match[2] ?? "")).filter((line) => line !== "");
25
+
26
+ export const odpDeriver: Deriver = {
27
+ name: "odp",
28
+ version: 1,
29
+ derive: async (absPath): Promise<DerivedDoc> => {
30
+ const zip = unzipSync(new Uint8Array(await readFile(absPath)), { filter: (file) => file.name === "content.xml" });
31
+ const content = zip["content.xml"];
32
+ if (content === undefined) {
33
+ return { markdown: "", notes: ["no content.xml in this OpenDocument container"] };
34
+ }
35
+ const xml = new TextDecoder().decode(content);
36
+ const sections: string[] = [];
37
+ for (const page of xml.matchAll(PAGE)) {
38
+ const body = page[2] ?? "";
39
+ // The notes page travels inside the slide; it is what the speaker says, not what the slide shows.
40
+ const slide = body.replaceAll(NOTES, "");
41
+ const notes = (body.match(NOTES) ?? []).flatMap((part) => linesOf(part)).filter((line) => !/^\d+$/.test(line));
42
+ const name = attributeOf(page[1] ?? "", "draw:name");
43
+ const heading = `## Slide ${sections.length + 1}${name === undefined || name === "" ? "" : `: ${name}`}`;
44
+ const section = [heading, ...linesOf(slide)];
45
+ if (notes.length > 0) {
46
+ section.push("", "Notes:", ...notes.map((line) => `> ${line}`));
47
+ }
48
+ sections.push(section.join("\n"));
49
+ }
50
+ return { markdown: sections.join("\n\n"), notes: sections.length === 0 ? ["no slides in this presentation"] : [] };
51
+ },
52
+ };
@@ -0,0 +1,104 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { unzipSync } from "fflate";
3
+ import type { DerivedDoc, Deriver } from "./deriver.js";
4
+ import { attributeOf, decodeEntities } from "../xml.js";
5
+
6
+ /* OpenDocument spreadsheets: one markdown table per sheet, the same shape xlsx derives to, so a reader cannot tell
7
+ which program wrote the file. */
8
+
9
+ const MAX_ROWS_PER_SHEET = 200;
10
+ const MAX_COLUMNS = 30;
11
+
12
+ const TABLE = /<table:table\b([^>]*)>([\s\S]*?)<\/table:table>/g;
13
+ const ROW = /<table:table-row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/table:table-row>)/g;
14
+ // The backreference keeps a covered cell's own closing tag straight; a merged-away cell is an empty one.
15
+ const CELL = /<table:(covered-table-cell|table-cell)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/table:\1>)/g;
16
+
17
+ const repeatOf = (attributes: string, name: string, limit: number): number => {
18
+ const value = Number.parseInt(attributeOf(attributes, name) ?? "1", 10);
19
+ return Number.isFinite(value) && value > 0 ? Math.min(value, limit) : 1;
20
+ };
21
+
22
+ /** A cell as a person reading the sheet would: its displayed text, or its value when it displays nothing. */
23
+ const cellText = (attributes: string, inner: string): string => {
24
+ const shown = decodeEntities(
25
+ inner
26
+ .replaceAll(/<text:s\b[^>]*\/>/g, " ")
27
+ .replaceAll(/<text:tab\b[^>]*\/>/g, " ")
28
+ .replaceAll(/<\/text:p>/g, " ")
29
+ .replaceAll(/<[^>]+>/g, ""),
30
+ )
31
+ .replaceAll(/\s+/g, " ")
32
+ .trim();
33
+ return shown === "" ? (attributeOf(attributes, "office:value") ?? "") : shown;
34
+ };
35
+
36
+ const trimEnd = <T>(items: T[], empty: (item: T) => boolean): T[] => {
37
+ let end = items.length;
38
+ while (end > 0 && empty(items[end - 1] as T)) {
39
+ end -= 1;
40
+ }
41
+ return items.slice(0, end);
42
+ };
43
+
44
+ const cellsOf = (xml: string): string[] => {
45
+ const cells: string[] = [];
46
+ for (const cell of xml.matchAll(CELL)) {
47
+ const attributes = cell[2] ?? "";
48
+ const text = cell[1] === "covered-table-cell" ? "" : cellText(attributes, cell[3] ?? "");
49
+ for (let repeat = 0; repeat < repeatOf(attributes, "table:number-columns-repeated", MAX_COLUMNS); repeat += 1) {
50
+ cells.push(text);
51
+ }
52
+ }
53
+ return trimEnd(cells, (cell) => cell === "");
54
+ };
55
+
56
+ /** One `<table:table>`'s rows. Trailing empties are dropped: ODF pads every sheet out to its full width and height. */
57
+ const rowsOf = (xml: string): string[][] => {
58
+ const rows: string[][] = [];
59
+ for (const row of xml.matchAll(ROW)) {
60
+ const cells = cellsOf(row[2] ?? "");
61
+ for (let repeat = 0; repeat < repeatOf(row[1] ?? "", "table:number-rows-repeated", MAX_ROWS_PER_SHEET); repeat += 1) {
62
+ rows.push(cells);
63
+ }
64
+ }
65
+ return trimEnd(rows, (row) => row.length === 0);
66
+ };
67
+
68
+ const tableRow = (cells: string[]): string => `| ${cells.map((cell) => cell.replaceAll("|", "\\|")).join(" | ")} |`;
69
+
70
+ const sectionOf = (name: string, rows: string[][], notes: string[]): string => {
71
+ if (rows.length === 0) {
72
+ return `## ${name}\n\n(empty sheet)`;
73
+ }
74
+ const width = Math.min(MAX_COLUMNS, Math.max(...rows.map((row) => row.length)));
75
+ if (Math.max(...rows.map((row) => row.length)) > MAX_COLUMNS) {
76
+ notes.push(`sheet "${name}": showing ${MAX_COLUMNS} of ${Math.max(...rows.map((row) => row.length))} columns`);
77
+ }
78
+ if (rows.length > MAX_ROWS_PER_SHEET) {
79
+ notes.push(`sheet "${name}": showing ${MAX_ROWS_PER_SHEET} of ${rows.length} rows`);
80
+ }
81
+ const kept = rows.slice(0, MAX_ROWS_PER_SHEET).map((row) => Array.from({ length: width }, (_, index) => row[index] ?? ""));
82
+ const [header, ...body] = kept as [string[], ...string[][]];
83
+ return `## ${name}\n\n${[tableRow(header), tableRow(header.map(() => "---")), ...body.map(tableRow)].join("\n")}`;
84
+ };
85
+
86
+ export const odsDeriver: Deriver = {
87
+ name: "ods",
88
+ version: 1,
89
+ derive: async (absPath): Promise<DerivedDoc> => {
90
+ const zip = unzipSync(new Uint8Array(await readFile(absPath)), { filter: (file) => file.name === "content.xml" });
91
+ const content = zip["content.xml"];
92
+ if (content === undefined) {
93
+ return { markdown: "", notes: ["no content.xml in this OpenDocument container"] };
94
+ }
95
+ const xml = new TextDecoder().decode(content);
96
+ const notes: string[] = [];
97
+ const sections: string[] = [];
98
+ for (const table of xml.matchAll(TABLE)) {
99
+ const name = attributeOf(table[1] ?? "", "table:name") ?? `Sheet ${sections.length + 1}`;
100
+ sections.push(sectionOf(name, rowsOf(table[2] ?? ""), notes));
101
+ }
102
+ return { markdown: sections.join("\n\n"), notes: sections.length === 0 ? [...notes, "no sheets in this spreadsheet"] : notes };
103
+ },
104
+ };