@gmickel/gno 1.32.0 → 1.34.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.
- package/README.md +17 -3
- package/assets/skill/SKILL.md +30 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +60 -1
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/commands/publish.ts +43 -7
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/ingestion/strip.ts +152 -26
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
* Obsidian-aware markdown pre-processor for publish export.
|
|
3
3
|
*
|
|
4
4
|
* Strips wikilinks, drops navigation sidebar idioms, removes references to
|
|
5
|
-
* private (`_internal/`) paths, and
|
|
6
|
-
*
|
|
5
|
+
* private (`_internal/`) paths, and resolves/bundles local raster embeds when
|
|
6
|
+
* an attachment context is provided (otherwise drops image embeds with a
|
|
7
|
+
* warning for backward compatibility).
|
|
7
8
|
*
|
|
8
9
|
* @module src/publish/obsidian-sanitize
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import { discoverImageOccurrences } from "./attachment-discover";
|
|
13
|
+
import { safePercentDecode } from "./attachment-path";
|
|
14
|
+
import {
|
|
15
|
+
rewriteAttachmentsInMarkdown,
|
|
16
|
+
type AttachmentDiagnostic,
|
|
17
|
+
type AttachmentResolveContext,
|
|
18
|
+
type AttachmentRewriteResult,
|
|
19
|
+
type PendingAssetPayload,
|
|
20
|
+
} from "./attachment-resolver";
|
|
21
|
+
|
|
11
22
|
const WIKILINK_INTERNAL_PREFIX = /_internal\//i;
|
|
12
23
|
const NAV_SIDEBAR_LINE = /^(!?\[\[[^\]]+\]\]\s*\|?\s*)+$/;
|
|
13
24
|
const IMAGE_EMBED = /!\[\[([^\]]+)\]\]/g;
|
|
@@ -16,6 +27,7 @@ const ALIASED_WIKILINK = /\[\[([^\]|]+)\|([^\]]+)\]\]/g;
|
|
|
16
27
|
const BARE_WIKILINK = /\[\[([^\]]+)\]\]/g;
|
|
17
28
|
const TAIL_SEGMENT = /[^/]+$/;
|
|
18
29
|
const BLOCK_ID_SUFFIX = /#\^?[\w-]+$/;
|
|
30
|
+
const EXTERNAL_DESTINATION = /^(?:\/\/|[a-z][a-z0-9+.-]*:)/iu;
|
|
19
31
|
|
|
20
32
|
export interface SanitizeWarning {
|
|
21
33
|
kind:
|
|
@@ -31,6 +43,13 @@ export interface SanitizeResult {
|
|
|
31
43
|
warnings: SanitizeWarning[];
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
export interface PublishSanitizeResult extends SanitizeResult {
|
|
47
|
+
diagnostics: AttachmentDiagnostic[];
|
|
48
|
+
externalCount: number;
|
|
49
|
+
payloads: Map<string, PendingAssetPayload>;
|
|
50
|
+
preDedupRawBytes: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
const splitFrontmatter = (
|
|
35
54
|
source: string
|
|
36
55
|
): { body: string; frontmatter: string } => {
|
|
@@ -61,8 +80,57 @@ const deriveLinkDisplay = (target: string): string => {
|
|
|
61
80
|
return tail.trim() || raw;
|
|
62
81
|
};
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
const
|
|
83
|
+
const isPrivateImageReference = (sourceRef: string): boolean => {
|
|
84
|
+
const trimmed = sourceRef.trim();
|
|
85
|
+
if (EXTERNAL_DESTINATION.test(trimmed)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const decoded = safePercentDecode(trimmed);
|
|
89
|
+
if (decoded === null) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
decoded
|
|
94
|
+
.split(/[?#]/u, 1)[0]
|
|
95
|
+
?.split("/")
|
|
96
|
+
.some((segment) => segment.toLowerCase() === "_internal") ?? false
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Remove private image references before attachment resolution can read bytes
|
|
102
|
+
* or replace the authored path with an opaque gno-asset sentinel.
|
|
103
|
+
*/
|
|
104
|
+
const stripPrivateImageReferences = (
|
|
105
|
+
body: string
|
|
106
|
+
): { markdown: string; warnings: SanitizeWarning[] } => {
|
|
107
|
+
const replacements = discoverImageOccurrences(body)
|
|
108
|
+
.filter((occurrence) => isPrivateImageReference(occurrence.sourceRef))
|
|
109
|
+
.map((occurrence) => ({
|
|
110
|
+
detail: occurrence.sourceRef.trim(),
|
|
111
|
+
end: occurrence.end,
|
|
112
|
+
start: occurrence.start,
|
|
113
|
+
}));
|
|
114
|
+
let markdown = body;
|
|
115
|
+
for (const replacement of [...replacements].sort(
|
|
116
|
+
(left, right) => right.start - left.start
|
|
117
|
+
)) {
|
|
118
|
+
markdown =
|
|
119
|
+
markdown.slice(0, replacement.start) + markdown.slice(replacement.end);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
markdown,
|
|
123
|
+
warnings: replacements.map((replacement) => ({
|
|
124
|
+
detail: replacement.detail,
|
|
125
|
+
kind: "internal-reference-stripped" as const,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const applyWikilinkSanitize = (
|
|
131
|
+
body: string,
|
|
132
|
+
options: { dropImageEmbeds: boolean }
|
|
133
|
+
): { markdown: string; warnings: SanitizeWarning[] } => {
|
|
66
134
|
const warnings: SanitizeWarning[] = [];
|
|
67
135
|
const lines = body.split("\n");
|
|
68
136
|
const output: string[] = [];
|
|
@@ -81,13 +149,15 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
|
|
|
81
149
|
|
|
82
150
|
let line = rawLine;
|
|
83
151
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
152
|
+
if (options.dropImageEmbeds) {
|
|
153
|
+
line = line.replace(IMAGE_EMBED, (_match, target: string) => {
|
|
154
|
+
warnings.push({
|
|
155
|
+
kind: "image-embed-dropped",
|
|
156
|
+
detail: target.trim(),
|
|
157
|
+
});
|
|
158
|
+
return "";
|
|
88
159
|
});
|
|
89
|
-
|
|
90
|
-
});
|
|
160
|
+
}
|
|
91
161
|
|
|
92
162
|
line = line.replace(INTERNAL_WIKILINK, (match) => {
|
|
93
163
|
warnings.push({
|
|
@@ -134,9 +204,46 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
|
|
|
134
204
|
output.push(line);
|
|
135
205
|
}
|
|
136
206
|
|
|
207
|
+
return { markdown: output.join("\n"), warnings };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Legacy sync sanitizer: drops Obsidian image embeds and converts wikilinks.
|
|
212
|
+
* Prefer `sanitizePublishMarkdown` when attachment bundling is enabled.
|
|
213
|
+
*/
|
|
214
|
+
export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
|
|
215
|
+
const { body, frontmatter } = splitFrontmatter(source);
|
|
216
|
+
const sanitized = applyWikilinkSanitize(body, { dropImageEmbeds: true });
|
|
217
|
+
return {
|
|
218
|
+
markdown: `${frontmatter}${sanitized.markdown}`,
|
|
219
|
+
warnings: sanitized.warnings,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parser-aware publish sanitize: resolve/rewrite local rasters in the same
|
|
225
|
+
* pass as Obsidian cleanup (image discovery is not a second independent scan).
|
|
226
|
+
*/
|
|
227
|
+
export async function sanitizePublishMarkdown(
|
|
228
|
+
source: string,
|
|
229
|
+
attachmentCtx: AttachmentResolveContext
|
|
230
|
+
): Promise<PublishSanitizeResult> {
|
|
231
|
+
const { body, frontmatter } = splitFrontmatter(source);
|
|
232
|
+
const privateImages = stripPrivateImageReferences(body);
|
|
233
|
+
const rewritten: AttachmentRewriteResult = await rewriteAttachmentsInMarkdown(
|
|
234
|
+
privateImages.markdown,
|
|
235
|
+
attachmentCtx
|
|
236
|
+
);
|
|
237
|
+
const sanitized = applyWikilinkSanitize(rewritten.markdown, {
|
|
238
|
+
dropImageEmbeds: false,
|
|
239
|
+
});
|
|
137
240
|
return {
|
|
138
|
-
|
|
139
|
-
|
|
241
|
+
diagnostics: rewritten.diagnostics,
|
|
242
|
+
externalCount: rewritten.externalCount,
|
|
243
|
+
markdown: `${frontmatter}${sanitized.markdown}`,
|
|
244
|
+
payloads: rewritten.payloads,
|
|
245
|
+
preDedupRawBytes: rewritten.preDedupRawBytes,
|
|
246
|
+
warnings: [...privateImages.warnings, ...sanitized.warnings],
|
|
140
247
|
};
|
|
141
248
|
}
|
|
142
249
|
|
|
@@ -159,7 +266,8 @@ export function formatSanitizeWarnings(warnings: SanitizeWarning[]): string[] {
|
|
|
159
266
|
}
|
|
160
267
|
|
|
161
268
|
const labels: Record<SanitizeWarning["kind"], string> = {
|
|
162
|
-
"image-embed-dropped":
|
|
269
|
+
"image-embed-dropped":
|
|
270
|
+
"Image embeds dropped (unsupported, missing, or unresolved)",
|
|
163
271
|
"internal-reference-stripped": "Private `_internal/` references stripped",
|
|
164
272
|
"nav-sidebar-dropped": "Navigation sidebar lines dropped",
|
|
165
273
|
"wikilink-unresolved":
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -1280,7 +1280,7 @@ export async function handlePublishExport(
|
|
|
1280
1280
|
}
|
|
1281
1281
|
|
|
1282
1282
|
try {
|
|
1283
|
-
const { artifact, warnings } = await exportPublishArtifact({
|
|
1283
|
+
const { artifact, assetSummary, warnings } = await exportPublishArtifact({
|
|
1284
1284
|
collections: config.collections,
|
|
1285
1285
|
options: {
|
|
1286
1286
|
encryptionPassphrase: body.encryptionPassphrase,
|
|
@@ -1295,6 +1295,7 @@ export async function handlePublishExport(
|
|
|
1295
1295
|
|
|
1296
1296
|
return jsonResponse({
|
|
1297
1297
|
artifact,
|
|
1298
|
+
assetSummary,
|
|
1298
1299
|
fileName: derivePublishArtifactFilename(artifact),
|
|
1299
1300
|
uploadUrl: "https://gno.sh/studio",
|
|
1300
1301
|
warnings,
|
|
@@ -515,6 +515,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
+
/** Open an existing index with SQLite enforced query-only semantics. */
|
|
519
|
+
openReadOnly(dbPath: string): StoreResult<void> {
|
|
520
|
+
try {
|
|
521
|
+
this.db = new Database(dbPath, { readonly: true, strict: true });
|
|
522
|
+
this.dbPath = dbPath;
|
|
523
|
+
this.db.exec("PRAGMA query_only = ON");
|
|
524
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
525
|
+
this.contextGeneration += 1;
|
|
526
|
+
return ok(undefined);
|
|
527
|
+
} catch (cause) {
|
|
528
|
+
this.db?.close();
|
|
529
|
+
this.db = null;
|
|
530
|
+
return err(
|
|
531
|
+
"CONNECTION_FAILED",
|
|
532
|
+
cause instanceof Error
|
|
533
|
+
? cause.message
|
|
534
|
+
: "Failed to open database read-only",
|
|
535
|
+
cause
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
518
540
|
async close(): Promise<void> {
|
|
519
541
|
if (this.db) {
|
|
520
542
|
this.db.close();
|
|
@@ -1559,6 +1581,66 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1559
1581
|
}
|
|
1560
1582
|
}
|
|
1561
1583
|
|
|
1584
|
+
async listDocumentsForAudit(options: {
|
|
1585
|
+
collections: readonly string[];
|
|
1586
|
+
pathPrefixes: readonly string[];
|
|
1587
|
+
tags: readonly string[];
|
|
1588
|
+
limit: number;
|
|
1589
|
+
}): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
|
|
1590
|
+
try {
|
|
1591
|
+
const db = this.ensureOpen();
|
|
1592
|
+
const conditions = ["d.active = 1"];
|
|
1593
|
+
const params: (string | number)[] = [];
|
|
1594
|
+
if (options.collections.length > 0) {
|
|
1595
|
+
conditions.push("d.collection IN (SELECT value FROM json_each(?))");
|
|
1596
|
+
params.push(JSON.stringify(options.collections));
|
|
1597
|
+
}
|
|
1598
|
+
if (options.pathPrefixes.length > 0) {
|
|
1599
|
+
conditions.push(`EXISTS (
|
|
1600
|
+
SELECT 1 FROM json_each(?) prefix
|
|
1601
|
+
WHERE COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) = prefix.value
|
|
1602
|
+
OR (substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), 1, length(prefix.value)) = prefix.value
|
|
1603
|
+
AND substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), length(prefix.value) + 1, 1) = '/')
|
|
1604
|
+
)`);
|
|
1605
|
+
params.push(JSON.stringify(options.pathPrefixes));
|
|
1606
|
+
}
|
|
1607
|
+
if (options.tags.length > 0) {
|
|
1608
|
+
conditions.push(`NOT EXISTS (
|
|
1609
|
+
SELECT 1 FROM json_each(?) requested_tag
|
|
1610
|
+
WHERE NOT EXISTS (
|
|
1611
|
+
SELECT 1 FROM doc_tags dt
|
|
1612
|
+
WHERE dt.document_id = d.id AND dt.tag = requested_tag.value
|
|
1613
|
+
)
|
|
1614
|
+
)`);
|
|
1615
|
+
params.push(JSON.stringify(options.tags));
|
|
1616
|
+
}
|
|
1617
|
+
const where = conditions.join(" AND ");
|
|
1618
|
+
const total =
|
|
1619
|
+
db
|
|
1620
|
+
.query<{ count: number }, (string | number)[]>(
|
|
1621
|
+
`SELECT COUNT(*) AS count FROM documents d WHERE ${where}`
|
|
1622
|
+
)
|
|
1623
|
+
.get(...params)?.count ?? 0;
|
|
1624
|
+
const rows = db
|
|
1625
|
+
.query<DbDocumentRow, (string | number)[]>(
|
|
1626
|
+
`SELECT d.* FROM documents d
|
|
1627
|
+
WHERE ${where}
|
|
1628
|
+
ORDER BY d.id
|
|
1629
|
+
LIMIT ?`
|
|
1630
|
+
)
|
|
1631
|
+
.all(...params, options.limit);
|
|
1632
|
+
return ok({ documents: rows.map(mapDocumentRow), total });
|
|
1633
|
+
} catch (cause) {
|
|
1634
|
+
return err(
|
|
1635
|
+
"QUERY_FAILED",
|
|
1636
|
+
cause instanceof Error
|
|
1637
|
+
? cause.message
|
|
1638
|
+
: "Failed to select bounded audit documents",
|
|
1639
|
+
cause
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1562
1644
|
async listRecordDocuments(
|
|
1563
1645
|
collection: string,
|
|
1564
1646
|
sourcePath: string
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** Linear-time bulk target resolution for large graph-link inventories. */
|
|
2
|
+
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
GraphLinkTarget,
|
|
7
|
+
ResolvedGraphLinkTarget,
|
|
8
|
+
} from "./graph-link-resolver";
|
|
9
|
+
|
|
10
|
+
import { stripWikiMdExt } from "../../core/links";
|
|
11
|
+
|
|
12
|
+
interface IndexedDocument {
|
|
13
|
+
id: number;
|
|
14
|
+
docid: string;
|
|
15
|
+
collection: string;
|
|
16
|
+
titleNorm: string | null;
|
|
17
|
+
relNorm: string;
|
|
18
|
+
relRaw: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Lookup = Map<string, IndexedDocument[]>;
|
|
22
|
+
|
|
23
|
+
export const GRAPH_LINK_BULK_MAX_DOCUMENTS = 100_000;
|
|
24
|
+
|
|
25
|
+
const lookupKey = (collection: string, value: string): string =>
|
|
26
|
+
`${collection}\0${value}`;
|
|
27
|
+
|
|
28
|
+
const suffixes = (value: string): string[] => {
|
|
29
|
+
const output = [value];
|
|
30
|
+
let separator = value.indexOf("/");
|
|
31
|
+
while (separator >= 0) {
|
|
32
|
+
const suffix = value.slice(separator + 1);
|
|
33
|
+
if (suffix) output.push(suffix);
|
|
34
|
+
separator = value.indexOf("/", separator + 1);
|
|
35
|
+
}
|
|
36
|
+
return output;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const appendLookup = (
|
|
40
|
+
lookup: Lookup,
|
|
41
|
+
collection: string,
|
|
42
|
+
value: string,
|
|
43
|
+
document: IndexedDocument
|
|
44
|
+
): void => {
|
|
45
|
+
const key = lookupKey(collection, value);
|
|
46
|
+
const documents = lookup.get(key) ?? [];
|
|
47
|
+
documents.push(document);
|
|
48
|
+
lookup.set(key, documents);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const candidatesFor = (
|
|
52
|
+
lookup: Lookup,
|
|
53
|
+
collection: string,
|
|
54
|
+
values: readonly string[]
|
|
55
|
+
): IndexedDocument[] => {
|
|
56
|
+
const byId = new Map<number, IndexedDocument>();
|
|
57
|
+
for (const value of values) {
|
|
58
|
+
for (const document of lookup.get(lookupKey(collection, value)) ?? []) {
|
|
59
|
+
byId.set(document.id, document);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...byId.values()].sort((left, right) => left.id - right.id);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const resolved = (
|
|
66
|
+
documents: readonly IndexedDocument[],
|
|
67
|
+
matchRank: number
|
|
68
|
+
): ResolvedGraphLinkTarget | null => {
|
|
69
|
+
const first = documents[0];
|
|
70
|
+
if (!first) return null;
|
|
71
|
+
return {
|
|
72
|
+
targetId: first.id,
|
|
73
|
+
targetDocid: first.docid,
|
|
74
|
+
matchRank,
|
|
75
|
+
matchCount: documents.length,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a large target set with lookup tables equivalent to the ranked SQL
|
|
81
|
+
* resolver. SQLite still performs lower()/trim(), preserving its normalization
|
|
82
|
+
* semantics; resolution then scales with documents plus path segments.
|
|
83
|
+
*/
|
|
84
|
+
export const resolveGraphLinkTargetsBulk = (
|
|
85
|
+
db: Database,
|
|
86
|
+
targets: readonly GraphLinkTarget[],
|
|
87
|
+
maxDocuments = GRAPH_LINK_BULK_MAX_DOCUMENTS
|
|
88
|
+
): Array<ResolvedGraphLinkTarget | null> | null => {
|
|
89
|
+
const boundedMaxDocuments = Math.max(
|
|
90
|
+
1,
|
|
91
|
+
Math.min(GRAPH_LINK_BULK_MAX_DOCUMENTS, maxDocuments)
|
|
92
|
+
);
|
|
93
|
+
const rows = db
|
|
94
|
+
.query<
|
|
95
|
+
{
|
|
96
|
+
id: number;
|
|
97
|
+
docid: string;
|
|
98
|
+
collection: string;
|
|
99
|
+
title_norm: string | null;
|
|
100
|
+
rel_norm: string;
|
|
101
|
+
rel_raw: string;
|
|
102
|
+
},
|
|
103
|
+
[number]
|
|
104
|
+
>(
|
|
105
|
+
`SELECT id, docid, collection, lower(trim(title)) AS title_norm,
|
|
106
|
+
lower(rel_path) AS rel_norm, rel_path AS rel_raw
|
|
107
|
+
FROM documents
|
|
108
|
+
WHERE active = 1
|
|
109
|
+
ORDER BY id
|
|
110
|
+
LIMIT ?`
|
|
111
|
+
)
|
|
112
|
+
.all(boundedMaxDocuments + 1);
|
|
113
|
+
// The fast lookup-table path is intentionally bounded. Callers fall back to
|
|
114
|
+
// set-oriented SQL batches when the active index exceeds this memory cap.
|
|
115
|
+
if (rows.length > boundedMaxDocuments) return null;
|
|
116
|
+
const titleExact: Lookup = new Map();
|
|
117
|
+
const relExact: Lookup = new Map();
|
|
118
|
+
const relExactRaw: Lookup = new Map();
|
|
119
|
+
const relSuffix: Lookup = new Map();
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
const document: IndexedDocument = {
|
|
122
|
+
id: row.id,
|
|
123
|
+
docid: row.docid,
|
|
124
|
+
collection: row.collection,
|
|
125
|
+
titleNorm: row.title_norm,
|
|
126
|
+
relNorm: row.rel_norm,
|
|
127
|
+
relRaw: row.rel_raw,
|
|
128
|
+
};
|
|
129
|
+
if (document.titleNorm !== null) {
|
|
130
|
+
appendLookup(titleExact, row.collection, document.titleNorm, document);
|
|
131
|
+
}
|
|
132
|
+
appendLookup(relExact, row.collection, document.relNorm, document);
|
|
133
|
+
appendLookup(relExactRaw, row.collection, document.relRaw, document);
|
|
134
|
+
for (const suffix of suffixes(document.relNorm)) {
|
|
135
|
+
appendLookup(relSuffix, row.collection, suffix, document);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const cache = new Map<string, ResolvedGraphLinkTarget | null>();
|
|
140
|
+
return targets.map((target) => {
|
|
141
|
+
const cacheKey = JSON.stringify([
|
|
142
|
+
target.linkType,
|
|
143
|
+
target.targetCollection,
|
|
144
|
+
target.targetRefNorm,
|
|
145
|
+
]);
|
|
146
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
|
|
147
|
+
let result: ResolvedGraphLinkTarget | null;
|
|
148
|
+
if (target.linkType === "markdown") {
|
|
149
|
+
result = resolved(
|
|
150
|
+
candidatesFor(relExactRaw, target.targetCollection, [
|
|
151
|
+
target.targetRefNorm,
|
|
152
|
+
]),
|
|
153
|
+
5
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
const baseRef = stripWikiMdExt(target.targetRefNorm);
|
|
157
|
+
const baseRefMd = `${baseRef}.md`;
|
|
158
|
+
const rankedCandidates: Array<[number, Lookup, string[]]> = [
|
|
159
|
+
[1, titleExact, [baseRef]],
|
|
160
|
+
[2, titleExact, [baseRefMd]],
|
|
161
|
+
[3, titleExact, suffixes(baseRef)],
|
|
162
|
+
[
|
|
163
|
+
4,
|
|
164
|
+
titleExact,
|
|
165
|
+
suffixes(baseRefMd)
|
|
166
|
+
.filter((value) => value.endsWith(".md"))
|
|
167
|
+
.map((value) => value.slice(0, -3)),
|
|
168
|
+
],
|
|
169
|
+
[5, relExact, [baseRef]],
|
|
170
|
+
[6, relExact, [baseRefMd]],
|
|
171
|
+
[7, relSuffix, [baseRefMd]],
|
|
172
|
+
[8, relSuffix, [baseRef]],
|
|
173
|
+
[9, relExact, suffixes(baseRefMd)],
|
|
174
|
+
[10, relExact, suffixes(baseRef)],
|
|
175
|
+
];
|
|
176
|
+
result = null;
|
|
177
|
+
for (const [rank, lookup, values] of rankedCandidates) {
|
|
178
|
+
const documents = candidatesFor(
|
|
179
|
+
lookup,
|
|
180
|
+
target.targetCollection,
|
|
181
|
+
values
|
|
182
|
+
);
|
|
183
|
+
if (documents.length === 0) continue;
|
|
184
|
+
result = resolved(documents, rank);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
cache.set(cacheKey, result);
|
|
189
|
+
return result;
|
|
190
|
+
});
|
|
191
|
+
};
|