@gmickel/gno 2.8.2 → 2.8.4
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/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +77 -6
- package/spec/mcp.md +5 -6
- package/spec/output-schemas/doctor.schema.json +10 -4
- package/spec/output-schemas/status.schema.json +4 -2
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor-activation.ts +38 -11
- package/src/cli/commands/doctor.ts +46 -25
- 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 +76 -4
- 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/activation-empty.ts +16 -0
- package/src/core/activation-status.ts +23 -5
- 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 +127 -31
- 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 +136 -31
- 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/activation-health.ts +25 -7
- package/src/serve/public/components/BootstrapStatus.tsx +9 -4
- 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/serve/status.ts +27 -8
- 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
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
loadConfig,
|
|
20
20
|
} from "../../config";
|
|
21
21
|
import { isConnectorActivationComplete } from "../../core/activation-connector-health";
|
|
22
|
+
import { isEmptyActivationCollection } from "../../core/activation-empty";
|
|
22
23
|
import { buildActivationStatus } from "../../core/activation-status";
|
|
23
24
|
import { formatChunkingStatus } from "../../core/chunking-status";
|
|
24
25
|
import { formatLinkWorkspace } from "../../core/link-workspace";
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
formatMemoryStatusLines,
|
|
28
29
|
} from "../../core/memory-diagnostics";
|
|
29
30
|
import { formatVectorPartitionLines } from "../../core/vector-partition-status";
|
|
31
|
+
import { BUDGET_ERROR_CODES } from "../../ingestion/file-processor";
|
|
30
32
|
import { ModelCache } from "../../llm/cache";
|
|
31
33
|
import { getActivePreset, resolveModelUri } from "../../llm/registry";
|
|
32
34
|
import { getConnectorVerificationTargets } from "../../serve/connectors";
|
|
@@ -64,9 +66,56 @@ export type StatusResult =
|
|
|
64
66
|
contentTypeBoost: ContentTypeBoostStatus;
|
|
65
67
|
memory: MemoryStatus;
|
|
66
68
|
backgroundIssues: ResidentBackgroundIssue[];
|
|
69
|
+
/** Files whose latest conversion stopped at the per-file budget. */
|
|
70
|
+
budgetStops: BudgetStop[];
|
|
67
71
|
}
|
|
68
72
|
| { success: false; error: string };
|
|
69
73
|
|
|
74
|
+
/** A file left unindexed because its conversion overran its budget. */
|
|
75
|
+
export interface BudgetStop {
|
|
76
|
+
collection: string;
|
|
77
|
+
relPath: string;
|
|
78
|
+
code: string;
|
|
79
|
+
message: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Most recent ingest errors inspected for budget stops. */
|
|
83
|
+
const BUDGET_STOP_SCAN_LIMIT = 100;
|
|
84
|
+
/** Most budget stops listed in status output. */
|
|
85
|
+
const MAX_BUDGET_STOPS = 10;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Files whose latest indexing attempt stopped at the conversion budget, from
|
|
89
|
+
* the recent error log, kept only while the document still carries that code.
|
|
90
|
+
*/
|
|
91
|
+
async function collectBudgetStops(store: SqliteAdapter): Promise<BudgetStop[]> {
|
|
92
|
+
const recent = await store.getRecentErrors(BUDGET_STOP_SCAN_LIMIT);
|
|
93
|
+
if (!recent.ok) return [];
|
|
94
|
+
const stops: BudgetStop[] = [];
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
for (const row of recent.value) {
|
|
97
|
+
const key = `${row.collection}\u0000${row.relPath}`;
|
|
98
|
+
if (seen.has(key)) continue;
|
|
99
|
+
seen.add(key);
|
|
100
|
+
if (!BUDGET_ERROR_CODES.has(row.code)) continue;
|
|
101
|
+
const document = await store.getDocument(row.collection, row.relPath);
|
|
102
|
+
if (
|
|
103
|
+
document.ok &&
|
|
104
|
+
document.value?.active &&
|
|
105
|
+
document.value.lastErrorCode === row.code
|
|
106
|
+
) {
|
|
107
|
+
stops.push({
|
|
108
|
+
collection: row.collection,
|
|
109
|
+
relPath: row.relPath,
|
|
110
|
+
code: row.code,
|
|
111
|
+
message: row.message,
|
|
112
|
+
});
|
|
113
|
+
if (stops.length >= MAX_BUDGET_STOPS) break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return stops;
|
|
117
|
+
}
|
|
118
|
+
|
|
70
119
|
/** A background issue reported by (or about) a running detached resident. */
|
|
71
120
|
export type ResidentBackgroundIssue = BackgroundIssue & {
|
|
72
121
|
process: "serve" | "daemon";
|
|
@@ -181,10 +230,21 @@ function formatTerminal(
|
|
|
181
230
|
`Health: ${isStatusHealthy(indexStatus, activation) ? "OK" : "DEGRADED"}`
|
|
182
231
|
);
|
|
183
232
|
lines.push("");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
233
|
+
const activationState = activation.healthy
|
|
234
|
+
? activation.usable
|
|
235
|
+
? "READY"
|
|
236
|
+
: "NO DOCUMENTS YET"
|
|
237
|
+
: activation.usable
|
|
238
|
+
? "DEGRADED"
|
|
239
|
+
: "BLOCKED";
|
|
240
|
+
lines.push(`Lexical activation: ${activationState}`);
|
|
187
241
|
for (const collection of activation.collections) {
|
|
242
|
+
if (isEmptyActivationCollection(collection)) {
|
|
243
|
+
lines.push(
|
|
244
|
+
` ${collection.collection}: no documents yet (add files, then run ${collection.remediation?.command})`
|
|
245
|
+
);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
188
248
|
const failedStage = collection.remediation?.stage;
|
|
189
249
|
const suffix = failedStage
|
|
190
250
|
? ` (${failedStage}: ${collection.remediation?.code}; ${collection.remediation?.command})`
|
|
@@ -277,7 +337,7 @@ function formatMarkdown(
|
|
|
277
337
|
lines.push(`- **Lexically healthy**: ${activation.healthy}`);
|
|
278
338
|
for (const collection of activation.collections) {
|
|
279
339
|
lines.push(
|
|
280
|
-
`- **${collection.collection}**: ${collection.ready ? "lexical ready" : `${collection.remediation?.stage ?? "index"} ${collection.remediation?.code ?? "index_query_failed"}`}`
|
|
340
|
+
`- **${collection.collection}**: ${collection.ready ? "lexical ready" : isEmptyActivationCollection(collection) ? `no documents yet (${collection.remediation?.command})` : `${collection.remediation?.stage ?? "index"} ${collection.remediation?.code ?? "index_query_failed"}`}`
|
|
281
341
|
);
|
|
282
342
|
}
|
|
283
343
|
for (const connector of activation.connectors) {
|
|
@@ -368,6 +428,7 @@ export async function status(
|
|
|
368
428
|
contentTypeBoost: buildContentTypeBoostStatus(config.contentTypes ?? []),
|
|
369
429
|
memory: await buildMemoryStatus(store, config.collections),
|
|
370
430
|
backgroundIssues: await collectResidentIssues(),
|
|
431
|
+
budgetStops: await collectBudgetStops(store),
|
|
371
432
|
};
|
|
372
433
|
} finally {
|
|
373
434
|
await store.close();
|
|
@@ -439,6 +500,10 @@ export function formatStatus(
|
|
|
439
500
|
(issue) =>
|
|
440
501
|
` ${issue.process} (pid ${issue.pid}): ${formatBackgroundIssue(issue)}`
|
|
441
502
|
);
|
|
503
|
+
const stopLines = result.budgetStops.map(
|
|
504
|
+
(stop) =>
|
|
505
|
+
` ${stop.collection}/${stop.relPath} [${stop.code}] ${stop.message}`
|
|
506
|
+
);
|
|
442
507
|
return [
|
|
443
508
|
formatTerminal(
|
|
444
509
|
result.status,
|
|
@@ -446,6 +511,13 @@ export function formatStatus(
|
|
|
446
511
|
result.contentTypeBoost,
|
|
447
512
|
result.memory
|
|
448
513
|
),
|
|
514
|
+
...(stopLines.length
|
|
515
|
+
? [
|
|
516
|
+
"",
|
|
517
|
+
"Stopped at conversion budget (retried by the next update):",
|
|
518
|
+
...stopLines,
|
|
519
|
+
]
|
|
520
|
+
: []),
|
|
449
521
|
...(issueLines.length ? ["", "Background issues:", ...issueLines] : []),
|
|
450
522
|
].join("\n");
|
|
451
523
|
}
|
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
type SyncResult,
|
|
16
16
|
withContentTypeRules,
|
|
17
17
|
} from "../../ingestion";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
formatSyncResultLines,
|
|
20
|
+
initStore,
|
|
21
|
+
reportSlowConversion,
|
|
22
|
+
} from "./shared";
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* Options for update command.
|
|
@@ -66,6 +70,7 @@ export async function update(
|
|
|
66
70
|
{
|
|
67
71
|
gitPull: options.gitPull,
|
|
68
72
|
runUpdateCmd: true,
|
|
73
|
+
onSlowConversion: reportSlowConversion,
|
|
69
74
|
},
|
|
70
75
|
config
|
|
71
76
|
)
|
package/src/cli/commands/vec.ts
CHANGED
|
@@ -13,10 +13,16 @@ import {
|
|
|
13
13
|
createVectorIndexPort,
|
|
14
14
|
createVectorStatsPort,
|
|
15
15
|
} from "../../store/vector";
|
|
16
|
+
import {
|
|
17
|
+
rebuildPartitionIndex,
|
|
18
|
+
syncPartitionIndex,
|
|
19
|
+
} from "../../store/vector/partition-index";
|
|
16
20
|
import {
|
|
17
21
|
dropVectorPartition,
|
|
22
|
+
storedVectorPartition,
|
|
18
23
|
type VectorPartitionStatus,
|
|
19
24
|
} from "../../store/vector/status";
|
|
25
|
+
import { loadSqliteVec } from "../../store/vector/variants";
|
|
20
26
|
|
|
21
27
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
28
|
// Types
|
|
@@ -76,7 +82,9 @@ function inferDimensions(
|
|
|
76
82
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
|
-
* Sync vec0 index with
|
|
85
|
+
* Sync the vec0 index with stored vectors (add missing, remove orphans): the
|
|
86
|
+
* active vector partition's variants, or legacy `content_vectors` on an
|
|
87
|
+
* index that never activated a partition.
|
|
80
88
|
*/
|
|
81
89
|
export async function vecSync(
|
|
82
90
|
options: VecOptions = {}
|
|
@@ -112,6 +120,21 @@ export async function vecSync(
|
|
|
112
120
|
try {
|
|
113
121
|
const db = store.getRawDb();
|
|
114
122
|
|
|
123
|
+
const partition = storedVectorPartition(db, modelUri);
|
|
124
|
+
if (partition) {
|
|
125
|
+
if (!(await loadSqliteVec(db))) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: "sqlite-vec not available. Cannot sync index.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
...syncPartitionIndex(db, partition),
|
|
134
|
+
model: modelUri,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
// Infer dimensions from stored vectors for this model
|
|
116
139
|
const dimensions = inferDimensions(db, modelUri);
|
|
117
140
|
if (dimensions === null) {
|
|
@@ -154,7 +177,9 @@ export async function vecSync(
|
|
|
154
177
|
}
|
|
155
178
|
|
|
156
179
|
/**
|
|
157
|
-
* Rebuild vec0 index
|
|
180
|
+
* Rebuild the vec0 index (drop + recreate + repopulate) from the active
|
|
181
|
+
* vector partition's variants, or from legacy `content_vectors` on an index
|
|
182
|
+
* that never activated a partition.
|
|
158
183
|
*/
|
|
159
184
|
export async function vecRebuild(
|
|
160
185
|
options: VecOptions = {}
|
|
@@ -190,6 +215,21 @@ export async function vecRebuild(
|
|
|
190
215
|
try {
|
|
191
216
|
const db = store.getRawDb();
|
|
192
217
|
|
|
218
|
+
const partition = storedVectorPartition(db, modelUri);
|
|
219
|
+
if (partition) {
|
|
220
|
+
if (!(await loadSqliteVec(db))) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: "sqlite-vec not available. Cannot rebuild index.",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
success: true,
|
|
228
|
+
count: rebuildPartitionIndex(db, partition),
|
|
229
|
+
model: modelUri,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
193
233
|
// Infer dimensions from stored vectors for this model
|
|
194
234
|
const dimensions = inferDimensions(db, modelUri);
|
|
195
235
|
if (dimensions === null) {
|
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
|
+
}
|