@gmickel/gno 1.9.0 → 1.10.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/assets/skill/SKILL.md +28 -16
- package/assets/skill/cli-reference.md +30 -0
- package/assets/skill/examples.md +20 -0
- package/assets/skill/mcp-reference.md +7 -1
- package/package.json +1 -1
- package/src/cli/commands/graph.ts +89 -2
- package/src/cli/commands/links.ts +237 -54
- package/src/cli/commands/query.ts +212 -0
- package/src/cli/commands/ref-parser.ts +8 -103
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +176 -9
- package/src/config/content-types.ts +14 -0
- package/src/core/graph-query.ts +137 -0
- package/src/core/graph-resolver.ts +117 -0
- package/src/core/ref-parser.ts +145 -0
- package/src/ingestion/frontmatter.ts +95 -2
- package/src/ingestion/sync.ts +281 -1
- package/src/mcp/tools/get.ts +1 -1
- package/src/mcp/tools/index.ts +89 -1
- package/src/mcp/tools/links.ts +83 -1
- package/src/mcp/tools/multi-get.ts +1 -1
- package/src/mcp/tools/query.ts +207 -0
- package/src/pipeline/diagnose.ts +302 -0
- package/src/pipeline/filters.ts +119 -0
- package/src/pipeline/hybrid.ts +90 -17
- package/src/pipeline/types.ts +32 -0
- package/src/publish/export-service.ts +1 -1
- package/src/sdk/documents.ts +2 -2
- package/src/serve/routes/api.ts +194 -0
- package/src/serve/routes/graph.ts +173 -1
- package/src/serve/server.ts +24 -1
- package/src/store/migrations/010-typed-edges.ts +67 -0
- package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +826 -102
- package/src/store/types.ts +141 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SQL expressions for graph link target resolution.
|
|
3
|
+
*
|
|
4
|
+
* These helpers preserve getGraph's existing wiki matching order so future
|
|
5
|
+
* typed-edge projection/backfill can reuse the exact resolver.
|
|
6
|
+
*
|
|
7
|
+
* @module src/core/graph-resolver
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const suffixMatch = (targetExpr: string, valueExpr: string): string =>
|
|
11
|
+
`(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
|
|
12
|
+
AND (length(${targetExpr}) = length(${valueExpr})
|
|
13
|
+
OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
|
|
14
|
+
|
|
15
|
+
const wikiTitleExpr = (alias: string): string => `lower(trim(${alias}.title))`;
|
|
16
|
+
const wikiRelPathExpr = (alias: string): string => `lower(${alias}.rel_path)`;
|
|
17
|
+
|
|
18
|
+
function wikiTargetExpressions(targetRefExpr: string): {
|
|
19
|
+
targetBaseExpr: string;
|
|
20
|
+
targetMdExpr: string;
|
|
21
|
+
} {
|
|
22
|
+
const targetBaseExpr = `CASE
|
|
23
|
+
WHEN ${targetRefExpr} LIKE '%.md' THEN substr(${targetRefExpr}, 1, length(${targetRefExpr}) - 3)
|
|
24
|
+
ELSE ${targetRefExpr}
|
|
25
|
+
END`;
|
|
26
|
+
return { targetBaseExpr, targetMdExpr: `${targetBaseExpr} || '.md'` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildWikiMatchExpression(
|
|
30
|
+
alias: string,
|
|
31
|
+
targetRefExpr: string
|
|
32
|
+
): string {
|
|
33
|
+
const titleExpr = wikiTitleExpr(alias);
|
|
34
|
+
const relExpr = wikiRelPathExpr(alias);
|
|
35
|
+
const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
|
|
36
|
+
return `(
|
|
37
|
+
${titleExpr} = ${targetBaseExpr}
|
|
38
|
+
OR ${titleExpr} = ${targetMdExpr}
|
|
39
|
+
OR ${suffixMatch(targetBaseExpr, titleExpr)}
|
|
40
|
+
OR ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)}
|
|
41
|
+
OR ${relExpr} = ${targetBaseExpr}
|
|
42
|
+
OR ${relExpr} = ${targetMdExpr}
|
|
43
|
+
OR ${suffixMatch(relExpr, targetMdExpr)}
|
|
44
|
+
OR ${suffixMatch(relExpr, targetBaseExpr)}
|
|
45
|
+
OR ${suffixMatch(targetMdExpr, relExpr)}
|
|
46
|
+
OR ${suffixMatch(targetBaseExpr, relExpr)}
|
|
47
|
+
)`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildWikiOrderExpression(
|
|
51
|
+
alias: string,
|
|
52
|
+
targetRefExpr: string
|
|
53
|
+
): string {
|
|
54
|
+
const titleExpr = wikiTitleExpr(alias);
|
|
55
|
+
const relExpr = wikiRelPathExpr(alias);
|
|
56
|
+
const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
|
|
57
|
+
return `CASE
|
|
58
|
+
WHEN ${titleExpr} = ${targetBaseExpr} THEN 1
|
|
59
|
+
WHEN ${titleExpr} = ${targetMdExpr} THEN 2
|
|
60
|
+
WHEN ${suffixMatch(targetBaseExpr, titleExpr)} THEN 3
|
|
61
|
+
WHEN ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)} THEN 4
|
|
62
|
+
WHEN ${relExpr} = ${targetBaseExpr} THEN 5
|
|
63
|
+
WHEN ${relExpr} = ${targetMdExpr} THEN 6
|
|
64
|
+
WHEN ${suffixMatch(relExpr, targetMdExpr)} THEN 7
|
|
65
|
+
WHEN ${suffixMatch(relExpr, targetBaseExpr)} THEN 8
|
|
66
|
+
WHEN ${suffixMatch(targetMdExpr, relExpr)} THEN 9
|
|
67
|
+
WHEN ${suffixMatch(targetBaseExpr, relExpr)} THEN 10
|
|
68
|
+
ELSE 11
|
|
69
|
+
END`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildWikiBestMatchSubquery(
|
|
73
|
+
collectionExpr: string,
|
|
74
|
+
targetRefExpr: string
|
|
75
|
+
): string {
|
|
76
|
+
return `
|
|
77
|
+
SELECT t.id FROM documents t
|
|
78
|
+
WHERE t.active = 1
|
|
79
|
+
AND t.collection = ${collectionExpr}
|
|
80
|
+
AND ${buildWikiMatchExpression("t", targetRefExpr)}
|
|
81
|
+
AND ${buildWikiOrderExpression("t", targetRefExpr)} = (
|
|
82
|
+
SELECT MIN(${buildWikiOrderExpression("t2", targetRefExpr)}) FROM documents t2
|
|
83
|
+
WHERE t2.active = 1
|
|
84
|
+
AND t2.collection = ${collectionExpr}
|
|
85
|
+
AND ${buildWikiMatchExpression("t2", targetRefExpr)}
|
|
86
|
+
)
|
|
87
|
+
ORDER BY t.id LIMIT 1
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildWikiBestRankSubquery(
|
|
92
|
+
collectionExpr: string,
|
|
93
|
+
targetRefExpr: string
|
|
94
|
+
): string {
|
|
95
|
+
return `
|
|
96
|
+
SELECT MIN(${buildWikiOrderExpression("t", targetRefExpr)}) FROM documents t
|
|
97
|
+
WHERE t.active = 1
|
|
98
|
+
AND t.collection = ${collectionExpr}
|
|
99
|
+
AND ${buildWikiMatchExpression("t", targetRefExpr)}
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildWikiBestRankMatchCountSubquery(
|
|
104
|
+
collectionExpr: string,
|
|
105
|
+
targetRefExpr: string
|
|
106
|
+
): string {
|
|
107
|
+
return `
|
|
108
|
+
SELECT COUNT(*) FROM documents t
|
|
109
|
+
WHERE t.active = 1
|
|
110
|
+
AND t.collection = ${collectionExpr}
|
|
111
|
+
AND ${buildWikiMatchExpression("t", targetRefExpr)}
|
|
112
|
+
AND ${buildWikiOrderExpression("t", targetRefExpr)} = (${buildWikiBestRankSubquery(
|
|
113
|
+
collectionExpr,
|
|
114
|
+
targetRefExpr
|
|
115
|
+
)})
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference parser and resolver for document refs.
|
|
3
|
+
* Pure parsing helpers stay store-free; resolution depends only on StorePort.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/ref-parser
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DocumentRow, StorePort } from "../store/types";
|
|
9
|
+
|
|
10
|
+
export type RefType = "docid" | "uri" | "collPath";
|
|
11
|
+
|
|
12
|
+
export interface ParsedRef {
|
|
13
|
+
type: RefType;
|
|
14
|
+
/** Normalized ref (without :line suffix) */
|
|
15
|
+
value: string;
|
|
16
|
+
/** For collPath type */
|
|
17
|
+
collection?: string;
|
|
18
|
+
/** For collPath type */
|
|
19
|
+
relPath?: string;
|
|
20
|
+
/** Parsed :line suffix (1-indexed) */
|
|
21
|
+
line?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ParseRefResult = ParsedRef | { error: string };
|
|
25
|
+
|
|
26
|
+
export type ResolvedDocRef =
|
|
27
|
+
| { doc: DocumentRow }
|
|
28
|
+
| { error: string; isValidation: boolean };
|
|
29
|
+
|
|
30
|
+
const DOCID_PATTERN = /^#[a-f0-9]{6,}$/;
|
|
31
|
+
const LINE_SUFFIX_PATTERN = /:(\d+)$/;
|
|
32
|
+
const GLOB_PATTERN = /[*?[\]]/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a single ref string.
|
|
36
|
+
* - Docid: starts with # (no :line suffix allowed)
|
|
37
|
+
* - URI: starts with gno:// (optional :N suffix)
|
|
38
|
+
* - Else: collection/path (optional :N suffix)
|
|
39
|
+
*/
|
|
40
|
+
export function parseRef(ref: string): ParseRefResult {
|
|
41
|
+
if (ref.startsWith("#")) {
|
|
42
|
+
if (ref.includes(":")) {
|
|
43
|
+
return { error: "Docid refs cannot have :line suffix" };
|
|
44
|
+
}
|
|
45
|
+
if (!DOCID_PATTERN.test(ref)) {
|
|
46
|
+
return { error: `Invalid docid format: ${ref}` };
|
|
47
|
+
}
|
|
48
|
+
return { type: "docid", value: ref };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let line: number | undefined;
|
|
52
|
+
let baseRef = ref;
|
|
53
|
+
const lineMatch = ref.match(LINE_SUFFIX_PATTERN);
|
|
54
|
+
if (lineMatch?.[1]) {
|
|
55
|
+
const parsed = Number.parseInt(lineMatch[1], 10);
|
|
56
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
57
|
+
return { error: `Invalid line suffix (must be >= 1): ${ref}` };
|
|
58
|
+
}
|
|
59
|
+
line = parsed;
|
|
60
|
+
baseRef = ref.slice(0, -lineMatch[0].length);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (baseRef.startsWith("gno://")) {
|
|
64
|
+
return { type: "uri", value: baseRef, line };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const slashIdx = baseRef.indexOf("/");
|
|
68
|
+
if (slashIdx === -1) {
|
|
69
|
+
return { error: `Invalid ref format (missing /): ${ref}` };
|
|
70
|
+
}
|
|
71
|
+
const collection = baseRef.slice(0, slashIdx);
|
|
72
|
+
const relPath = baseRef.slice(slashIdx + 1);
|
|
73
|
+
|
|
74
|
+
return { type: "collPath", value: baseRef, collection, relPath, line };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a document reference against the store.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveDocRef(
|
|
81
|
+
store: StorePort,
|
|
82
|
+
docRef: string
|
|
83
|
+
): Promise<ResolvedDocRef> {
|
|
84
|
+
const parsed = parseRef(docRef);
|
|
85
|
+
|
|
86
|
+
if ("error" in parsed) {
|
|
87
|
+
return { error: parsed.error, isValidation: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let doc: DocumentRow | null = null;
|
|
91
|
+
|
|
92
|
+
switch (parsed.type) {
|
|
93
|
+
case "docid": {
|
|
94
|
+
const result = await store.getDocumentByDocid(parsed.value);
|
|
95
|
+
if (result.ok && result.value) {
|
|
96
|
+
doc = result.value;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "uri": {
|
|
101
|
+
const result = await store.getDocumentByUri(parsed.value);
|
|
102
|
+
if (result.ok && result.value) {
|
|
103
|
+
doc = result.value;
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "collPath": {
|
|
108
|
+
const uri = `gno://${parsed.collection}/${parsed.relPath}`;
|
|
109
|
+
const result = await store.getDocumentByUri(uri);
|
|
110
|
+
if (result.ok && result.value) {
|
|
111
|
+
doc = result.value;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!doc) {
|
|
118
|
+
return { error: `Document not found: ${docRef}`, isValidation: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { doc };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split comma-separated refs. Does NOT expand globs.
|
|
126
|
+
*/
|
|
127
|
+
export function splitRefs(refs: string[]): string[] {
|
|
128
|
+
const result: string[] = [];
|
|
129
|
+
for (const r of refs) {
|
|
130
|
+
for (const part of r.split(",")) {
|
|
131
|
+
const trimmed = part.trim();
|
|
132
|
+
if (trimmed) {
|
|
133
|
+
result.push(trimmed);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if a ref contains glob characters.
|
|
142
|
+
*/
|
|
143
|
+
export function isGlobPattern(ref: string): boolean {
|
|
144
|
+
return GLOB_PATTERN.test(ref);
|
|
145
|
+
}
|
|
@@ -170,11 +170,95 @@ function parseBlockArrayValue(lines: string[], startIdx: number): string[] {
|
|
|
170
170
|
return items;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
function countIndent(line: string): number {
|
|
174
|
+
let indent = 0;
|
|
175
|
+
for (const char of line) {
|
|
176
|
+
if (char === " ") {
|
|
177
|
+
indent += 1;
|
|
178
|
+
} else if (char === "\t") {
|
|
179
|
+
indent += 2;
|
|
180
|
+
} else {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return indent;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseNestedMapValue(
|
|
188
|
+
lines: string[],
|
|
189
|
+
startIdx: number
|
|
190
|
+
): Record<string, string[]> {
|
|
191
|
+
const result: Record<string, string[]> = {};
|
|
192
|
+
|
|
193
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
194
|
+
const line = lines[i];
|
|
195
|
+
if (line === undefined) break;
|
|
196
|
+
if (line.trim().length === 0) continue;
|
|
197
|
+
if (!line.startsWith(" ") && !line.startsWith("\t")) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const keyIndent = countIndent(line);
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
const colonIdx = trimmed.indexOf(":");
|
|
204
|
+
if (colonIdx <= 0) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
209
|
+
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
210
|
+
const values: string[] = [];
|
|
211
|
+
|
|
212
|
+
const inlineArray = parseInlineArrayValue(rawValue);
|
|
213
|
+
if (inlineArray) {
|
|
214
|
+
values.push(...inlineArray);
|
|
215
|
+
} else if (rawValue.length > 0) {
|
|
216
|
+
values.push(unquoteScalar(rawValue));
|
|
217
|
+
} else {
|
|
218
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
219
|
+
const childLine = lines[j];
|
|
220
|
+
if (childLine === undefined) break;
|
|
221
|
+
if (childLine.trim().length === 0) continue;
|
|
222
|
+
const childIndent = countIndent(childLine);
|
|
223
|
+
if (childIndent <= keyIndent) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
const itemMatch = YAML_ARRAY_ITEM_REGEX.exec(childLine);
|
|
227
|
+
if (itemMatch?.[1]) {
|
|
228
|
+
const item = unquoteScalar(itemMatch[1]);
|
|
229
|
+
if (item.length > 0) {
|
|
230
|
+
values.push(item);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (key.length > 0 && values.length > 0) {
|
|
237
|
+
result[key] = values;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function startsNestedMap(lines: string[], startIdx: number): boolean {
|
|
245
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
246
|
+
const line = lines[i];
|
|
247
|
+
if (line === undefined) break;
|
|
248
|
+
if (line.trim().length === 0) continue;
|
|
249
|
+
if (!line.startsWith(" ") && !line.startsWith("\t")) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
return line.trim().indexOf(":") > 0;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
173
257
|
function parseMetadataValue(
|
|
174
258
|
value: string,
|
|
175
259
|
lines: string[],
|
|
176
260
|
startIdx: number
|
|
177
|
-
): string | string[] | undefined {
|
|
261
|
+
): string | string[] | Record<string, string[]> | undefined {
|
|
178
262
|
const trimmed = value.trim();
|
|
179
263
|
const inlineArray = parseInlineArrayValue(trimmed);
|
|
180
264
|
if (inlineArray) {
|
|
@@ -182,8 +266,14 @@ function parseMetadataValue(
|
|
|
182
266
|
}
|
|
183
267
|
|
|
184
268
|
if (trimmed.length === 0) {
|
|
269
|
+
if (startsNestedMap(lines, startIdx)) {
|
|
270
|
+
const nestedMap = parseNestedMapValue(lines, startIdx);
|
|
271
|
+
return Object.keys(nestedMap).length > 0 ? nestedMap : undefined;
|
|
272
|
+
}
|
|
185
273
|
const blockArray = parseBlockArrayValue(lines, startIdx);
|
|
186
|
-
|
|
274
|
+
if (blockArray.length > 0) {
|
|
275
|
+
return blockArray;
|
|
276
|
+
}
|
|
187
277
|
}
|
|
188
278
|
|
|
189
279
|
return unquoteScalar(trimmed);
|
|
@@ -222,6 +312,9 @@ export function parseFrontmatter(source: string): FrontmatterResult {
|
|
|
222
312
|
for (let i = 0; i < lines.length; i++) {
|
|
223
313
|
const line = lines[i];
|
|
224
314
|
if (line === undefined) continue;
|
|
315
|
+
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
225
318
|
|
|
226
319
|
// Logseq format: tags:: value
|
|
227
320
|
const logseqMatch = LOGSEQ_TAGS_REGEX.exec(line);
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { NormalizedContentTypeRule } from "../config";
|
|
|
14
14
|
import type { Collection } from "../config/types";
|
|
15
15
|
import type {
|
|
16
16
|
ChunkInput,
|
|
17
|
+
DocEdgeInput,
|
|
17
18
|
DocLinkInput,
|
|
18
19
|
DocumentRow,
|
|
19
20
|
IngestErrorInput,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
normalizeMarkdownPath,
|
|
44
45
|
normalizeWikiName,
|
|
45
46
|
parseLinks,
|
|
47
|
+
parseTargetParts,
|
|
46
48
|
} from "../core/links";
|
|
47
49
|
import { normalizeTag, validateTag } from "../core/tags";
|
|
48
50
|
import { defaultChunker } from "./chunker";
|
|
@@ -70,8 +72,130 @@ const MAX_CONCURRENCY = 16;
|
|
|
70
72
|
* Increment when ingestion adds new derived data (tags, metadata, etc.)
|
|
71
73
|
* Documents with ingestVersion < INGEST_VERSION will be re-processed.
|
|
72
74
|
*/
|
|
73
|
-
export const INGEST_VERSION =
|
|
75
|
+
export const INGEST_VERSION = 6;
|
|
74
76
|
const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
|
|
77
|
+
const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
78
|
+
|
|
79
|
+
type RelationMap = Record<string, string[]>;
|
|
80
|
+
|
|
81
|
+
function isRelationMap(value: unknown): value is RelationMap {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Object.values(value).every(
|
|
87
|
+
(targets) =>
|
|
88
|
+
Array.isArray(targets) &&
|
|
89
|
+
targets.every((target) => typeof target === "string")
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeRelationTarget(raw: string): string {
|
|
94
|
+
const trimmed = raw.trim();
|
|
95
|
+
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
|
|
96
|
+
return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
|
|
97
|
+
}
|
|
98
|
+
return trimmed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeRelationEdgeType(raw: string): string {
|
|
102
|
+
return raw
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.replace(/[-\s]+/g, "_");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findDocByWikiRef(
|
|
109
|
+
docs: DocumentRow[],
|
|
110
|
+
targetRef: string,
|
|
111
|
+
collection?: string
|
|
112
|
+
): DocumentRow | undefined {
|
|
113
|
+
const normalized = normalizeWikiName(targetRef);
|
|
114
|
+
const candidates = collection
|
|
115
|
+
? docs.filter((doc) => doc.collection === collection)
|
|
116
|
+
: docs;
|
|
117
|
+
|
|
118
|
+
return candidates.find((doc) => {
|
|
119
|
+
const title = doc.title ?? doc.relPath.split("/").pop() ?? doc.relPath;
|
|
120
|
+
const relStem = doc.relPath.replace(/\.[^/.]+$/, "");
|
|
121
|
+
return (
|
|
122
|
+
normalizeWikiName(title) === normalized ||
|
|
123
|
+
normalizeWikiName(doc.relPath) === normalized ||
|
|
124
|
+
normalizeWikiName(relStem) === normalized
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveRelationTarget(
|
|
130
|
+
docs: DocumentRow[],
|
|
131
|
+
sourceDoc: DocumentRow,
|
|
132
|
+
rawTarget: string
|
|
133
|
+
): DocumentRow | undefined {
|
|
134
|
+
const target = normalizeRelationTarget(rawTarget);
|
|
135
|
+
if (!target) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (target.startsWith("#")) {
|
|
140
|
+
return docs.find((doc) => doc.docid === target);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (target.startsWith("gno://")) {
|
|
144
|
+
return docs.find((doc) => doc.uri === target);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parts = parseTargetParts(target);
|
|
148
|
+
const targetCollection = parts.collection;
|
|
149
|
+
const targetRef = parts.ref;
|
|
150
|
+
if (!targetRef) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (targetCollection) {
|
|
155
|
+
const exact = docs.find(
|
|
156
|
+
(doc) => doc.collection === targetCollection && doc.relPath === targetRef
|
|
157
|
+
);
|
|
158
|
+
return exact ?? findDocByWikiRef(docs, targetRef, targetCollection);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sameCollectionPath = normalizeMarkdownPath(
|
|
162
|
+
targetRef,
|
|
163
|
+
sourceDoc.relPath
|
|
164
|
+
);
|
|
165
|
+
if (sameCollectionPath) {
|
|
166
|
+
const exact = docs.find(
|
|
167
|
+
(doc) =>
|
|
168
|
+
doc.collection === sourceDoc.collection &&
|
|
169
|
+
doc.relPath === sameCollectionPath
|
|
170
|
+
);
|
|
171
|
+
if (exact) {
|
|
172
|
+
return exact;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const explicitCollPath = docs.find(
|
|
177
|
+
(doc) => `${doc.collection}/${doc.relPath}` === targetRef
|
|
178
|
+
);
|
|
179
|
+
if (explicitCollPath) {
|
|
180
|
+
return explicitCollPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
findDocByWikiRef(docs, targetRef, sourceDoc.collection) ??
|
|
185
|
+
findDocByWikiRef(docs, targetRef)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getPrimaryGraphHint(
|
|
190
|
+
contentType: string | null | undefined,
|
|
191
|
+
rules: NormalizedContentTypeRule[]
|
|
192
|
+
): string | undefined {
|
|
193
|
+
if (!contentType) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const rule = rules.find((candidate) => candidate.id === contentType);
|
|
197
|
+
return rule?.graphHints?.[0];
|
|
198
|
+
}
|
|
75
199
|
|
|
76
200
|
/**
|
|
77
201
|
* Decide whether to process a file or skip it.
|
|
@@ -658,6 +782,7 @@ export class SyncService {
|
|
|
658
782
|
converterVersion: artifact.meta.converterVersion,
|
|
659
783
|
languageHint: artifact.languageHint ?? collection.languageHint,
|
|
660
784
|
contentType: extractedMetadata.contentType,
|
|
785
|
+
contentTypeSource: extractedMetadata.contentTypeSource,
|
|
661
786
|
categories: extractedMetadata.categories,
|
|
662
787
|
author: extractedMetadata.author,
|
|
663
788
|
frontmatterDate: extractedMetadata.frontmatterDate,
|
|
@@ -892,9 +1017,160 @@ export class SyncService {
|
|
|
892
1017
|
results.push(result);
|
|
893
1018
|
}
|
|
894
1019
|
|
|
1020
|
+
await this.projectTypedEdges(collection, store, options);
|
|
1021
|
+
|
|
895
1022
|
return results;
|
|
896
1023
|
}
|
|
897
1024
|
|
|
1025
|
+
private async projectTypedEdges(
|
|
1026
|
+
collection: Collection,
|
|
1027
|
+
store: StorePort,
|
|
1028
|
+
options: SyncOptions
|
|
1029
|
+
): Promise<Array<{ relPath: string; code: string; message: string }>> {
|
|
1030
|
+
const errors: Array<{ relPath: string; code: string; message: string }> =
|
|
1031
|
+
[];
|
|
1032
|
+
|
|
1033
|
+
const backfillResult = await store.backfillDocEdges();
|
|
1034
|
+
if (!backfillResult.ok) {
|
|
1035
|
+
return [
|
|
1036
|
+
{
|
|
1037
|
+
relPath: "(typed edge backfill)",
|
|
1038
|
+
code: backfillResult.error.code,
|
|
1039
|
+
message: backfillResult.error.message,
|
|
1040
|
+
},
|
|
1041
|
+
];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const docsResult = await store.listDocuments();
|
|
1045
|
+
if (!docsResult.ok) {
|
|
1046
|
+
return [
|
|
1047
|
+
{
|
|
1048
|
+
relPath: "(typed edge projection)",
|
|
1049
|
+
code: docsResult.error.code,
|
|
1050
|
+
message: docsResult.error.message,
|
|
1051
|
+
},
|
|
1052
|
+
];
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const activeDocs = docsResult.value.filter((doc) => doc.active);
|
|
1056
|
+
|
|
1057
|
+
for (const doc of activeDocs) {
|
|
1058
|
+
if (!doc.mirrorHash) {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const contentResult = await store.getContent(doc.mirrorHash);
|
|
1063
|
+
if (!contentResult.ok || contentResult.value === null) {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const relationsValue = parseFrontmatter(contentResult.value).metadata
|
|
1068
|
+
.relations;
|
|
1069
|
+
const relationEdges: DocEdgeInput[] = [];
|
|
1070
|
+
|
|
1071
|
+
if (isRelationMap(relationsValue)) {
|
|
1072
|
+
for (const [rawEdgeType, targets] of Object.entries(relationsValue)) {
|
|
1073
|
+
const edgeType = normalizeRelationEdgeType(rawEdgeType);
|
|
1074
|
+
if (!RELATION_EDGE_TYPE_PATTERN.test(edgeType)) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
for (const target of targets) {
|
|
1078
|
+
const targetDoc = resolveRelationTarget(activeDocs, doc, target);
|
|
1079
|
+
if (targetDoc) {
|
|
1080
|
+
relationEdges.push({
|
|
1081
|
+
targetDocId: targetDoc.id,
|
|
1082
|
+
edgeType,
|
|
1083
|
+
confidence: "manual",
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const relationTargetIds = new Set(
|
|
1090
|
+
relationEdges.map((edge) => edge.targetDocId)
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
const relationsResult = await store.setDocEdges(
|
|
1094
|
+
doc.id,
|
|
1095
|
+
relationEdges,
|
|
1096
|
+
"frontmatter-relation"
|
|
1097
|
+
);
|
|
1098
|
+
if (!relationsResult.ok) {
|
|
1099
|
+
errors.push({
|
|
1100
|
+
relPath: doc.relPath,
|
|
1101
|
+
code: relationsResult.error.code,
|
|
1102
|
+
message: relationsResult.error.message,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const primaryHint = getPrimaryGraphHint(
|
|
1107
|
+
doc.contentType,
|
|
1108
|
+
options.contentTypeRules ?? []
|
|
1109
|
+
);
|
|
1110
|
+
if (!primaryHint || !RELATION_EDGE_TYPE_PATTERN.test(primaryHint)) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const linksResult = await store.getLinksForDoc(doc.id);
|
|
1115
|
+
if (!linksResult.ok) {
|
|
1116
|
+
errors.push({
|
|
1117
|
+
relPath: doc.relPath,
|
|
1118
|
+
code: linksResult.error.code,
|
|
1119
|
+
message: linksResult.error.message,
|
|
1120
|
+
});
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const wikiEdges: DocEdgeInput[] = [];
|
|
1125
|
+
const markdownEdges: DocEdgeInput[] = [];
|
|
1126
|
+
for (const link of linksResult.value) {
|
|
1127
|
+
const targetRef =
|
|
1128
|
+
link.linkType === "markdown"
|
|
1129
|
+
? `${doc.collection}/${link.targetRefNorm}`
|
|
1130
|
+
: link.targetCollection
|
|
1131
|
+
? `${link.targetCollection}:${link.targetRef}`
|
|
1132
|
+
: link.targetRefNorm;
|
|
1133
|
+
const targetDoc = resolveRelationTarget(activeDocs, doc, targetRef);
|
|
1134
|
+
if (!targetDoc || relationTargetIds.has(targetDoc.id)) {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const edge = {
|
|
1138
|
+
targetDocId: targetDoc.id,
|
|
1139
|
+
edgeType: primaryHint,
|
|
1140
|
+
confidence: "configured" as const,
|
|
1141
|
+
};
|
|
1142
|
+
if (link.linkType === "wiki") {
|
|
1143
|
+
wikiEdges.push(edge);
|
|
1144
|
+
} else {
|
|
1145
|
+
markdownEdges.push(edge);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const wikiResult = await store.setDocEdges(doc.id, wikiEdges, "wikilink");
|
|
1150
|
+
if (!wikiResult.ok) {
|
|
1151
|
+
errors.push({
|
|
1152
|
+
relPath: doc.relPath,
|
|
1153
|
+
code: wikiResult.error.code,
|
|
1154
|
+
message: wikiResult.error.message,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
const markdownResult = await store.setDocEdges(
|
|
1158
|
+
doc.id,
|
|
1159
|
+
markdownEdges,
|
|
1160
|
+
"markdown-link"
|
|
1161
|
+
);
|
|
1162
|
+
if (!markdownResult.ok) {
|
|
1163
|
+
errors.push({
|
|
1164
|
+
relPath: doc.relPath,
|
|
1165
|
+
code: markdownResult.error.code,
|
|
1166
|
+
message: markdownResult.error.message,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return errors;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
898
1174
|
/**
|
|
899
1175
|
* Sync a single collection.
|
|
900
1176
|
*/
|
|
@@ -1121,6 +1397,10 @@ export class SyncService {
|
|
|
1121
1397
|
}
|
|
1122
1398
|
}
|
|
1123
1399
|
|
|
1400
|
+
errors.push(
|
|
1401
|
+
...(await this.projectTypedEdges(collection, store, syncOptions))
|
|
1402
|
+
);
|
|
1403
|
+
|
|
1124
1404
|
return {
|
|
1125
1405
|
collection: collection.name,
|
|
1126
1406
|
filesProcessed: entries.length,
|