@gmickel/gno 2.8.2 → 2.8.3
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.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +66 -4
- package/spec/mcp.md +5 -6
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor.ts +42 -24
- package/src/cli/commands/index-cmd.ts +10 -2
- package/src/cli/commands/links.ts +5 -22
- package/src/cli/commands/shared.ts +26 -1
- package/src/cli/commands/status.ts +60 -0
- package/src/cli/commands/update.ts +6 -1
- package/src/cli/commands/vec.ts +42 -2
- package/src/cli/program.ts +2 -2
- package/src/config/types.ts +19 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
- package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
- package/src/converters/adapters/xlsx/adapter.ts +132 -0
- package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
- package/src/converters/registry.ts +62 -6
- package/src/converters/versions.ts +2 -0
- package/src/core/audit-freshness.ts +1 -1
- package/src/core/audit-outside-index.ts +48 -3
- package/src/core/change-diff.ts +16 -4
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/links.ts +74 -16
- package/src/core/network-boundary-inventory.ts +18 -0
- package/src/index.ts +10 -0
- package/src/ingestion/document-metadata.ts +256 -0
- package/src/ingestion/file-child-env.ts +9 -0
- package/src/ingestion/file-child.ts +106 -0
- package/src/ingestion/file-processor.ts +546 -0
- package/src/ingestion/frontmatter.ts +6 -2
- package/src/ingestion/index.ts +1 -0
- package/src/ingestion/prepare-convert.ts +32 -0
- package/src/ingestion/prepare-file.ts +272 -0
- package/src/ingestion/prepare-worker.ts +43 -0
- package/src/ingestion/record-container.ts +7 -2
- package/src/ingestion/strip.ts +71 -8
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +207 -400
- package/src/ingestion/types.ts +18 -0
- package/src/llm/native-worker/embedding-identity.ts +15 -3
- package/src/mcp/http-transport.ts +11 -0
- package/src/mcp/tools/links.ts +10 -38
- package/src/serve/resident-request.ts +19 -0
- package/src/serve/resident-runtime.ts +84 -8
- package/src/serve/routes/links.ts +6 -24
- package/src/serve/server.ts +3 -2
- package/src/sessions/config-refresh.ts +3 -2
- package/src/store/sqlite/graph-similarity.ts +5 -13
- package/src/store/vector/partition-index.ts +100 -0
- package/src/store/vector/stored-vectors.ts +20 -15
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
package/src/cli/program.ts
CHANGED
|
@@ -3655,7 +3655,7 @@ function wireVecCommands(program: Command): void {
|
|
|
3655
3655
|
addWriteLeaseFlags(
|
|
3656
3656
|
vecCmd
|
|
3657
3657
|
.command("sync")
|
|
3658
|
-
.description("Sync vec0 index with
|
|
3658
|
+
.description("Sync the vec0 index with the active vector partition")
|
|
3659
3659
|
.option("--json", "JSON output")
|
|
3660
3660
|
).action(async (cmdOpts: Record<string, unknown>) => {
|
|
3661
3661
|
const format = getFormat(cmdOpts);
|
|
@@ -3728,7 +3728,7 @@ function wireVecCommands(program: Command): void {
|
|
|
3728
3728
|
addWriteLeaseFlags(
|
|
3729
3729
|
vecCmd
|
|
3730
3730
|
.command("rebuild")
|
|
3731
|
-
.description("Rebuild vec0 index from
|
|
3731
|
+
.description("Rebuild the vec0 index from the active vector partition")
|
|
3732
3732
|
.option("--json", "JSON output")
|
|
3733
3733
|
).action(async (cmdOpts: Record<string, unknown>) => {
|
|
3734
3734
|
const format = getFormat(cmdOpts);
|
package/src/config/types.ts
CHANGED
|
@@ -429,6 +429,22 @@ export const DEFAULT_MODEL_PRESETS: ModelPreset[] = [
|
|
|
429
429
|
},
|
|
430
430
|
];
|
|
431
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Per-file conversion budget. A file that overruns either limit is recorded
|
|
434
|
+
* with TIMEOUT or MEMORY_LIMIT and retried by the next update.
|
|
435
|
+
*/
|
|
436
|
+
export const ConversionConfigSchema = z
|
|
437
|
+
.object({
|
|
438
|
+
/** Wall-clock budget for converting one file, in ms (default 60000). */
|
|
439
|
+
timeoutMs: z.number().int().min(1000).max(86_400_000).optional(),
|
|
440
|
+
/**
|
|
441
|
+
* Resident-memory ceiling while a file converts, in MB (default: half
|
|
442
|
+
* of physical memory, at least 2048).
|
|
443
|
+
*/
|
|
444
|
+
maxMemoryMb: z.number().int().min(256).max(1_048_576).optional(),
|
|
445
|
+
})
|
|
446
|
+
.strict();
|
|
447
|
+
|
|
432
448
|
export const ModelConfigSchema = z.object({
|
|
433
449
|
/** Active preset ID */
|
|
434
450
|
activePreset: z.string().default("slim-tuned"),
|
|
@@ -608,6 +624,9 @@ export const ConfigSchema = z.object({
|
|
|
608
624
|
/** Optional index-wide chunk size/overlap; omitted preserves legacy defaults. */
|
|
609
625
|
chunking: ChunkingConfigSchema.optional(),
|
|
610
626
|
|
|
627
|
+
/** Per-file conversion budget; omitted fields use the runtime defaults. */
|
|
628
|
+
conversion: ConversionConfigSchema.optional(),
|
|
629
|
+
|
|
611
630
|
/** Model configuration */
|
|
612
631
|
models: ModelConfigSchema.optional(),
|
|
613
632
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
tooLargeError,
|
|
20
20
|
} from "../../errors";
|
|
21
21
|
import { ADAPTER_VERSIONS } from "../../versions";
|
|
22
|
+
import { hasPrefix, isPasswordProtectedXlsx } from "../shared/ooxml-protection";
|
|
22
23
|
|
|
23
24
|
const CONVERTER_ID = "adapter/markitdown-ts" as const;
|
|
24
25
|
const CONVERTER_VERSION = ADAPTER_VERSIONS["markitdown-ts"];
|
|
@@ -34,11 +35,6 @@ const SUPPORTED_MIMES = [
|
|
|
34
35
|
];
|
|
35
36
|
|
|
36
37
|
const PDF_SIGNATURE = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
37
|
-
const CFB_SIGNATURE = new Uint8Array([
|
|
38
|
-
0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
|
|
39
|
-
]);
|
|
40
|
-
const ENCRYPTION_INFO = utf16le("EncryptionInfo");
|
|
41
|
-
const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
|
|
42
38
|
const PDF_TRAILER_SCAN_BYTES = 2048;
|
|
43
39
|
const MAX_MESSAGE_LENGTH = 200;
|
|
44
40
|
const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
|
|
@@ -51,45 +47,6 @@ function toBuffer(bytes: Uint8Array): Buffer {
|
|
|
51
47
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
52
48
|
}
|
|
53
49
|
|
|
54
|
-
function utf16le(value: string): Uint8Array {
|
|
55
|
-
return new Uint8Array(Buffer.from(value, "utf16le"));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
|
|
59
|
-
if (bytes.length < prefix.length) {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
for (let index = 0; index < prefix.length; index += 1) {
|
|
64
|
-
if (bytes[index] !== prefix[index]) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
|
|
73
|
-
if (needle.length === 0 || bytes.length < needle.length) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
outer: for (
|
|
78
|
-
let index = 0;
|
|
79
|
-
index <= bytes.length - needle.length;
|
|
80
|
-
index += 1
|
|
81
|
-
) {
|
|
82
|
-
for (let offset = 0; offset < needle.length; offset += 1) {
|
|
83
|
-
if (bytes[index + offset] !== needle[offset]) {
|
|
84
|
-
continue outer;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
50
|
function isPasswordProtectedPdf(bytes: Uint8Array): boolean {
|
|
94
51
|
if (!hasPrefix(bytes, PDF_SIGNATURE)) {
|
|
95
52
|
return false;
|
|
@@ -113,14 +70,6 @@ function hasCompletePdfTrailer(bytes: Uint8Array): boolean {
|
|
|
113
70
|
return startXrefIndex >= 0 && eofIndex > startXrefIndex;
|
|
114
71
|
}
|
|
115
72
|
|
|
116
|
-
function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
|
|
117
|
-
return (
|
|
118
|
-
hasPrefix(bytes, CFB_SIGNATURE) &&
|
|
119
|
-
includesBytes(bytes, ENCRYPTION_INFO) &&
|
|
120
|
-
includesBytes(bytes, ENCRYPTED_PACKAGE)
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
73
|
function isPasswordProtected(input: ConvertInput): boolean {
|
|
125
74
|
if (input.ext === ".pdf") {
|
|
126
75
|
return isPasswordProtectedPdf(input.bytes);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte checks shared by document adapters: signature prefixes and the
|
|
3
|
+
* encrypted-OOXML (CFB container) test. Dependency-free so an adapter can
|
|
4
|
+
* use them without loading another adapter's parser stack.
|
|
5
|
+
*
|
|
6
|
+
* @module src/converters/adapters/shared/ooxml-protection
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CFB_SIGNATURE = new Uint8Array([
|
|
10
|
+
0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function utf16le(value: string): Uint8Array {
|
|
14
|
+
return new Uint8Array(Buffer.from(value, "utf16le"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ENCRYPTION_INFO = utf16le("EncryptionInfo");
|
|
18
|
+
const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
|
|
19
|
+
|
|
20
|
+
export function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
|
|
21
|
+
if (bytes.length < prefix.length) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
for (let index = 0; index < prefix.length; index += 1) {
|
|
25
|
+
if (bytes[index] !== prefix[index]) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
|
|
33
|
+
if (needle.length === 0 || bytes.length < needle.length) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
outer: for (
|
|
37
|
+
let index = 0;
|
|
38
|
+
index <= bytes.length - needle.length;
|
|
39
|
+
index += 1
|
|
40
|
+
) {
|
|
41
|
+
for (let offset = 0; offset < needle.length; offset += 1) {
|
|
42
|
+
if (bytes[index + offset] !== needle[offset]) {
|
|
43
|
+
continue outer;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An encrypted (password-protected) OOXML workbook: a CFB container. */
|
|
52
|
+
export function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
|
|
53
|
+
return (
|
|
54
|
+
hasPrefix(bytes, CFB_SIGNATURE) &&
|
|
55
|
+
includesBytes(bytes, ENCRYPTION_INFO) &&
|
|
56
|
+
includesBytes(bytes, ENCRYPTED_PACKAGE)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel (.xlsx) converter: SheetJS workbook -> Markdown tables, linear in
|
|
3
|
+
* cells (fn-198).
|
|
4
|
+
*
|
|
5
|
+
* Produces the Markdown markitdown-ts produced for workbooks (one `## sheet`
|
|
6
|
+
* section and GFM table per sheet with a range) but renders each table
|
|
7
|
+
* directly from the sheet data instead of through HTML, jsdom and turndown,
|
|
8
|
+
* whose cost grew with columns x rows^2 and about 20 KB per cell. Pivot
|
|
9
|
+
* cache parts are not user content and SheetJS does not read them, as
|
|
10
|
+
* before.
|
|
11
|
+
*
|
|
12
|
+
* @module src/converters/adapters/xlsx/adapter
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as XLSX from "xlsx";
|
|
16
|
+
|
|
17
|
+
import type { Converter, ConvertInput, ConvertResult } from "../../types";
|
|
18
|
+
|
|
19
|
+
import { corruptError, permissionError, tooLargeError } from "../../errors";
|
|
20
|
+
import { ADAPTER_VERSIONS } from "../../versions";
|
|
21
|
+
import { isPasswordProtectedXlsx } from "../shared/ooxml-protection";
|
|
22
|
+
import { sheetToMarkdown } from "./sheet-markdown";
|
|
23
|
+
|
|
24
|
+
const CONVERTER_ID = "adapter/xlsx" as const;
|
|
25
|
+
const CONVERTER_VERSION = ADAPTER_VERSIONS.xlsx;
|
|
26
|
+
const XLSX_MIME =
|
|
27
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
28
|
+
const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
|
|
29
|
+
/** Output under this length from a larger file is flagged as lossy. */
|
|
30
|
+
const SHORT_OUTPUT_CHARS = 10;
|
|
31
|
+
const SHORT_OUTPUT_MIN_BYTES = 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Markdown for a parsed workbook: one section per sheet with a range,
|
|
35
|
+
* normalized as markitdown-ts normalizes every result (line endings, outer
|
|
36
|
+
* whitespace, at most one blank line).
|
|
37
|
+
*/
|
|
38
|
+
export function workbookToMarkdown(workbook: XLSX.WorkBook): string {
|
|
39
|
+
let markdown = "";
|
|
40
|
+
for (const name of workbook.SheetNames) {
|
|
41
|
+
const sheet = workbook.Sheets[name];
|
|
42
|
+
if (sheet?.["!ref"]) {
|
|
43
|
+
markdown += `## ${name}\n${sheetToMarkdown(sheet)}\n\n`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return markdown
|
|
47
|
+
.replace(/\r\n|\r|\n/g, "\n")
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const xlsxAdapter: Converter = {
|
|
53
|
+
id: CONVERTER_ID,
|
|
54
|
+
version: CONVERTER_VERSION,
|
|
55
|
+
|
|
56
|
+
canHandle(mime: string, ext: string): boolean {
|
|
57
|
+
return ext === ".xlsx" || mime === XLSX_MIME;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
convert(input: ConvertInput): Promise<ConvertResult> {
|
|
61
|
+
return Promise.resolve(convertWorkbook(input));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function convertWorkbook(input: ConvertInput): ConvertResult {
|
|
66
|
+
if (input.bytes.length > input.limits.maxBytes) {
|
|
67
|
+
return { ok: false, error: tooLargeError(input, CONVERTER_ID) };
|
|
68
|
+
}
|
|
69
|
+
if (isPasswordProtectedXlsx(input.bytes)) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: permissionError(
|
|
73
|
+
input,
|
|
74
|
+
CONVERTER_ID,
|
|
75
|
+
"File is password-protected",
|
|
76
|
+
undefined,
|
|
77
|
+
{ protection: "xlsx" }
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let workbook: XLSX.WorkBook;
|
|
83
|
+
try {
|
|
84
|
+
// Dense sheets store rows as arrays: smaller than one object per cell.
|
|
85
|
+
workbook = XLSX.read(input.bytes, { type: "buffer", dense: true });
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: PASSWORD_ERROR_REGEX.test(message)
|
|
91
|
+
? permissionError(
|
|
92
|
+
input,
|
|
93
|
+
CONVERTER_ID,
|
|
94
|
+
"File is password-protected",
|
|
95
|
+
cause,
|
|
96
|
+
{ protection: "xlsx" }
|
|
97
|
+
)
|
|
98
|
+
: corruptError(
|
|
99
|
+
input,
|
|
100
|
+
CONVERTER_ID,
|
|
101
|
+
"Could not read the .xlsx workbook",
|
|
102
|
+
cause
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const markdown = workbookToMarkdown(workbook);
|
|
108
|
+
if (!markdown) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: corruptError(input, CONVERTER_ID, "Empty conversion result"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const lossy =
|
|
115
|
+
markdown.length < SHORT_OUTPUT_CHARS &&
|
|
116
|
+
input.bytes.length > SHORT_OUTPUT_MIN_BYTES;
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
value: {
|
|
120
|
+
markdown,
|
|
121
|
+
title: workbook.Props?.Title || "Untitled",
|
|
122
|
+
meta: {
|
|
123
|
+
converterId: CONVERTER_ID,
|
|
124
|
+
converterVersion: CONVERTER_VERSION,
|
|
125
|
+
sourceMime: input.mime,
|
|
126
|
+
warnings: lossy
|
|
127
|
+
? [{ code: "LOSSY", message: "Suspiciously short output" }]
|
|
128
|
+
: undefined,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worksheet to Markdown table, linear in cells (fn-198).
|
|
3
|
+
*
|
|
4
|
+
* Reproduces the Markdown the markitdown-ts chain produces for a worksheet
|
|
5
|
+
* (SheetJS `sheet_to_html` -> jsdom -> turndown + Joplin GFM tables) without
|
|
6
|
+
* building a DOM. That chain is O(columns x rows^2) and uses about 20 KB per
|
|
7
|
+
* cell: the GFM plugin's column-alignment scan re-reads `table.rows.length`
|
|
8
|
+
* through a jsdom proxy for every row of every column. Here each cell is
|
|
9
|
+
* rendered once from the sheet data:
|
|
10
|
+
*
|
|
11
|
+
* - cell text is chosen as `sheet_to_html` does (`h`, or escaped `w`),
|
|
12
|
+
* - plain text goes through turndown's whitespace collapsing and Markdown
|
|
13
|
+
* escaping, per line (a line break starts a new text node),
|
|
14
|
+
* - cells whose HTML carries markup (rich-text runs, hyperlinks) are
|
|
15
|
+
* converted by turndown itself with the same rules, one cell at a time,
|
|
16
|
+
* - rows, the empty heading row, `---` borders (SheetJS never sets an
|
|
17
|
+
* alignment), merges (`colspan` padding, covered cells skipped) and the
|
|
18
|
+
* single-cell-table case follow the GFM plugin.
|
|
19
|
+
*
|
|
20
|
+
* @module src/converters/adapters/xlsx/sheet-markdown
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { CellObject, WorkSheet } from "xlsx";
|
|
24
|
+
|
|
25
|
+
// @ts-expect-error -- @joplin/turndown-plugin-gfm ships no types
|
|
26
|
+
import turndownPluginGfm from "@joplin/turndown-plugin-gfm";
|
|
27
|
+
// @ts-expect-error -- turndown ships no types
|
|
28
|
+
import TurndownService from "turndown";
|
|
29
|
+
import * as XLSX from "xlsx";
|
|
30
|
+
|
|
31
|
+
/** The part of turndown's API this module uses. */
|
|
32
|
+
interface Turndown {
|
|
33
|
+
turndown(html: string): string;
|
|
34
|
+
use(plugin: unknown): Turndown;
|
|
35
|
+
addRule(
|
|
36
|
+
key: string,
|
|
37
|
+
rule: {
|
|
38
|
+
filter: string[];
|
|
39
|
+
replacement: (
|
|
40
|
+
content: string,
|
|
41
|
+
node: { getAttribute(name: string): string | null; title: string }
|
|
42
|
+
) => string;
|
|
43
|
+
}
|
|
44
|
+
): Turndown;
|
|
45
|
+
}
|
|
46
|
+
const TurndownConstructor = TurndownService as new (options: {
|
|
47
|
+
headingStyle: "atx";
|
|
48
|
+
}) => Turndown;
|
|
49
|
+
|
|
50
|
+
/** SheetJS `escapehtml` (xlsx 0.20.3), used to recognise plain cell HTML. */
|
|
51
|
+
const HTML_ENCODINGS: Record<string, string> = {
|
|
52
|
+
'"': """,
|
|
53
|
+
"'": "'",
|
|
54
|
+
">": ">",
|
|
55
|
+
"<": "<",
|
|
56
|
+
"&": "&",
|
|
57
|
+
};
|
|
58
|
+
const escapeHtml = (text: string): string =>
|
|
59
|
+
text
|
|
60
|
+
.replace(/[&<>'"]/g, (char) => HTML_ENCODINGS[char] ?? char)
|
|
61
|
+
.replace(/\n/g, "<br/>")
|
|
62
|
+
.replace(
|
|
63
|
+
// oxlint-disable-next-line no-control-regex -- SheetJS htmlcharegex: C0 controls become numeric references
|
|
64
|
+
/[\u0000-\u001f]/g,
|
|
65
|
+
(char) => `&#x${`000${char.charCodeAt(0).toString(16)}`.slice(-4)};`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** turndown 7.2 `escapeMarkdown`, applied to each text node. */
|
|
69
|
+
const MARKDOWN_ESCAPES: Array<[RegExp, string]> = [
|
|
70
|
+
[/\\/g, "\\\\"],
|
|
71
|
+
[/\*/g, "\\*"],
|
|
72
|
+
[/^-/g, "\\-"],
|
|
73
|
+
[/^\+ /g, "\\+ "],
|
|
74
|
+
[/^(=+)/g, "\\$1"],
|
|
75
|
+
[/^(#{1,6}) /g, "\\$1 "],
|
|
76
|
+
[/`/g, "\\`"],
|
|
77
|
+
[/^~~~/g, "\\~~~"],
|
|
78
|
+
[/\[/g, "\\["],
|
|
79
|
+
[/\]/g, "\\]"],
|
|
80
|
+
[/^>/g, "\\>"],
|
|
81
|
+
[/_/g, "\\_"],
|
|
82
|
+
[/^(\d+)\. /g, "$1\\. "],
|
|
83
|
+
];
|
|
84
|
+
/** Cheap pre-test: text that no escape rule can touch skips the chain. */
|
|
85
|
+
const MAY_NEED_ESCAPE = /[\\*`[\]_]|^[-+=#~>\d]/;
|
|
86
|
+
|
|
87
|
+
const escapeMarkdown = (text: string): string =>
|
|
88
|
+
MAY_NEED_ESCAPE.test(text)
|
|
89
|
+
? MARKDOWN_ESCAPES.reduce(
|
|
90
|
+
(value, [pattern, replacement]) => value.replace(pattern, replacement),
|
|
91
|
+
text
|
|
92
|
+
)
|
|
93
|
+
: text;
|
|
94
|
+
|
|
95
|
+
/** turndown's `<br>` replacement with the default `br: " "` option. */
|
|
96
|
+
const LINE_BREAK = " \n";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Plain cell text as turndown renders it inside a table cell: each line is a
|
|
100
|
+
* text node whose whitespace runs collapse to one space, with the leading
|
|
101
|
+
* and trailing space dropped (cells and line breaks are block boundaries),
|
|
102
|
+
* escaped on its own; every line break renders, empty lines included.
|
|
103
|
+
*/
|
|
104
|
+
const plainCellContent = (text: string): string => {
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
for (const raw of text.split("\n")) {
|
|
107
|
+
// The HTML parser turns the escaped NUL (�) into U+FFFD.
|
|
108
|
+
const line = raw
|
|
109
|
+
.replaceAll("\u0000", "\uFFFD")
|
|
110
|
+
.replace(/[ \r\n\t]+/g, " ")
|
|
111
|
+
.replace(/^ | $/g, "");
|
|
112
|
+
lines.push(line ? escapeMarkdown(line) : "");
|
|
113
|
+
}
|
|
114
|
+
return lines.join(LINE_BREAK);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let markupTurndown: Turndown | null = null;
|
|
118
|
+
const markupCache = new Map<string, string>();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cell HTML with markup (rich-text runs, a hyperlink), converted by turndown
|
|
122
|
+
* with markitdown's rules. A paragraph gives the same block context as a
|
|
123
|
+
* table cell.
|
|
124
|
+
*/
|
|
125
|
+
const markupCellContent = (html: string): string => {
|
|
126
|
+
const cached = markupCache.get(html);
|
|
127
|
+
if (cached !== undefined) return cached;
|
|
128
|
+
markupTurndown ??= createMarkitdownTurndown();
|
|
129
|
+
const content = markupTurndown.turndown(`<p>${html}</p>`);
|
|
130
|
+
markupCache.set(html, content);
|
|
131
|
+
return content;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** markitdown-ts `CustomTurnDown` configuration (vendored dist). */
|
|
135
|
+
function createMarkitdownTurndown(): Turndown {
|
|
136
|
+
const service = new TurndownConstructor({ headingStyle: "atx" });
|
|
137
|
+
service.use((turndownPluginGfm as { gfm: unknown }).gfm);
|
|
138
|
+
service.addRule("anchor tags", {
|
|
139
|
+
filter: ["a"],
|
|
140
|
+
replacement(content, node) {
|
|
141
|
+
if (content === "") return "";
|
|
142
|
+
const prefix = content[0] === " " ? " " : "";
|
|
143
|
+
const suffix = content.at(-1) === " " ? " " : "";
|
|
144
|
+
const text = content.trim().replace(/\n\n.*/g, "");
|
|
145
|
+
if (text === "") return "";
|
|
146
|
+
const href = node.getAttribute("href");
|
|
147
|
+
let title = node.title;
|
|
148
|
+
if (href) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = new URL(href);
|
|
151
|
+
if (!["https:", "http:", "file:"].includes(parsed.protocol)) {
|
|
152
|
+
return `${prefix}${text}${suffix}`;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
if (!/^https?:|^file:/.test(href)) {
|
|
156
|
+
return `${prefix}[${text}](${href} "${title}")${suffix}`;
|
|
157
|
+
}
|
|
158
|
+
return `${prefix}${text}${suffix}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (text.replace(/\\_/g, "_") === href && !title) return `<${href}>`;
|
|
162
|
+
if (!title && href) title = href;
|
|
163
|
+
const titlePart = title ? ` "${title}"` : "";
|
|
164
|
+
return `${prefix}[${text}](${href}${titlePart})${suffix}`;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
return service;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Joplin GFM `cell()`: trim, `<br>` for newlines, escape pipes, pad. */
|
|
171
|
+
const formatCell = (content: string, first: boolean, colspan: number) => {
|
|
172
|
+
let value = content
|
|
173
|
+
.trim()
|
|
174
|
+
.replace(/\n\r/g, "<br>")
|
|
175
|
+
.replace(/\n/g, "<br>")
|
|
176
|
+
.replace(/\|+/g, "\\|");
|
|
177
|
+
while (value.length < 3) value += " ";
|
|
178
|
+
for (let extra = 1; extra < colspan; extra += 1) value += " | ";
|
|
179
|
+
return `${first ? "| " : " "}${value} |`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** SheetJS error text for non-finite numbers (`make_html_row`). */
|
|
183
|
+
const nonFiniteText = (value: number): string =>
|
|
184
|
+
Number.isNaN(value) ? "#NUM!" : "#DIV/0!";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cell Markdown content (before `formatCell`), from the HTML `make_html_row`
|
|
188
|
+
* (xlsx 0.20.3) writes for it: `h` if present, else the escaped formatted
|
|
189
|
+
* text, wrapped in a link for an external hyperlink. HTML that is exactly
|
|
190
|
+
* the escaped text is rendered from the text; any other HTML goes through
|
|
191
|
+
* turndown.
|
|
192
|
+
*/
|
|
193
|
+
const cellContent = (cell: CellObject | undefined): string => {
|
|
194
|
+
if (!cell || cell.v == null) return "";
|
|
195
|
+
let text: string;
|
|
196
|
+
let html: string;
|
|
197
|
+
if (cell.t === "n" && !Number.isFinite(cell.v as number)) {
|
|
198
|
+
// SheetJS swaps in an error cell, which has no `h` and no hyperlink.
|
|
199
|
+
text = nonFiniteText(cell.v as number);
|
|
200
|
+
return text ? plainCellContent(text) : "";
|
|
201
|
+
}
|
|
202
|
+
if (cell.h) {
|
|
203
|
+
html = cell.h;
|
|
204
|
+
text = typeof cell.v === "string" ? cell.v : "";
|
|
205
|
+
} else {
|
|
206
|
+
text =
|
|
207
|
+
cell.w ||
|
|
208
|
+
(XLSX.utils.format_cell(cell), cell.w as string | undefined) ||
|
|
209
|
+
"";
|
|
210
|
+
html = escapeHtml(text);
|
|
211
|
+
}
|
|
212
|
+
if (!html) return "";
|
|
213
|
+
const target = cell.l?.Target;
|
|
214
|
+
if (target && target.charAt(0) !== "#") {
|
|
215
|
+
return markupCellContent(`<a href="${escapeHtml(target)}">${html}</a>`);
|
|
216
|
+
}
|
|
217
|
+
return escapeHtml(text) === html
|
|
218
|
+
? plainCellContent(text)
|
|
219
|
+
: markupCellContent(html);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
interface MergeSpan {
|
|
223
|
+
rowspan: number;
|
|
224
|
+
colspan: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Merge lookup as `make_html_row` resolves it: the first merge (in sheet
|
|
229
|
+
* order) containing a cell decides; its top-left cell carries the span and
|
|
230
|
+
* the other covered cells are skipped.
|
|
231
|
+
*/
|
|
232
|
+
const mergeLookup = (
|
|
233
|
+
merges: XLSX.Range[]
|
|
234
|
+
): ((row: number, col: number) => MergeSpan | "covered" | undefined) => {
|
|
235
|
+
if (merges.length === 0) return () => undefined;
|
|
236
|
+
const byCell = new Map<string, MergeSpan | "covered">();
|
|
237
|
+
for (let index = merges.length - 1; index >= 0; index -= 1) {
|
|
238
|
+
const merge = merges[index];
|
|
239
|
+
if (!merge) continue;
|
|
240
|
+
for (let row = merge.s.r; row <= merge.e.r; row += 1) {
|
|
241
|
+
for (let col = merge.s.c; col <= merge.e.c; col += 1) {
|
|
242
|
+
byCell.set(
|
|
243
|
+
`${row}:${col}`,
|
|
244
|
+
row === merge.s.r && col === merge.s.c
|
|
245
|
+
? {
|
|
246
|
+
rowspan: merge.e.r - merge.s.r + 1,
|
|
247
|
+
colspan: merge.e.c - merge.s.c + 1,
|
|
248
|
+
}
|
|
249
|
+
: "covered"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return (row, col) => byCell.get(`${row}:${col}`);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const readCell = (
|
|
258
|
+
sheet: WorkSheet,
|
|
259
|
+
row: number,
|
|
260
|
+
col: number
|
|
261
|
+
): CellObject | undefined => {
|
|
262
|
+
const dense = (sheet as { "!data"?: CellObject[][] })["!data"];
|
|
263
|
+
if (dense) return dense[row]?.[col];
|
|
264
|
+
return sheet[XLSX.utils.encode_cell({ r: row, c: col })] as
|
|
265
|
+
| CellObject
|
|
266
|
+
| undefined;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Markdown for one worksheet with a `!ref`, trimmed as markitdown trims it.
|
|
271
|
+
* Output matches `turndown(sheet_to_html(sheet))` for the same sheet.
|
|
272
|
+
*/
|
|
273
|
+
export function sheetToMarkdown(sheet: WorkSheet): string {
|
|
274
|
+
// Memoized markup is per sheet: the file processor reuses one worker across
|
|
275
|
+
// files, so a process-wide cache would retain every earlier workbook.
|
|
276
|
+
markupCache.clear();
|
|
277
|
+
const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1");
|
|
278
|
+
const mergeAt = mergeLookup(sheet["!merges"] ?? []);
|
|
279
|
+
const rows: string[] = [];
|
|
280
|
+
let columnCount = 0;
|
|
281
|
+
let onlyCell: string | null = null;
|
|
282
|
+
let firstRowCells = 0;
|
|
283
|
+
for (let row = range.s.r; row <= range.e.r; row += 1) {
|
|
284
|
+
const cells: string[] = [];
|
|
285
|
+
let cellCount = 0;
|
|
286
|
+
for (let col = range.s.c; col <= range.e.c; col += 1) {
|
|
287
|
+
const merge = mergeAt(row, col);
|
|
288
|
+
if (merge === "covered") continue;
|
|
289
|
+
const content = cellContent(readCell(sheet, row, col));
|
|
290
|
+
cells.push(formatCell(content, cellCount === 0, merge?.colspan ?? 1));
|
|
291
|
+
cellCount += 1;
|
|
292
|
+
onlyCell = content;
|
|
293
|
+
}
|
|
294
|
+
columnCount = Math.max(columnCount, cellCount);
|
|
295
|
+
if (row === range.s.r) firstRowCells = cellCount;
|
|
296
|
+
// A row whose cells are all covered by merges renders as an empty line,
|
|
297
|
+
// which the GFM plugin collapses away.
|
|
298
|
+
if (cells.length > 0) rows.push(cells.join(""));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A one-row table with at most one cell is left as plain content.
|
|
302
|
+
if (range.e.r === range.s.r && firstRowCells <= 1) {
|
|
303
|
+
return (onlyCell ?? "").trim();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const secondLine = rows[1] ?? "";
|
|
307
|
+
const header =
|
|
308
|
+
columnCount && !/\| :?---/.test(secondLine)
|
|
309
|
+
? `|${" |".repeat(columnCount)}\n|${" --- |".repeat(columnCount)}\n`
|
|
310
|
+
: "";
|
|
311
|
+
return `${header}${rows.join("\n")}`.trim();
|
|
312
|
+
}
|