@gmickel/gno 2.7.0 → 2.8.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 +26 -30
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +8 -1
- package/assets/skill/examples.md +2 -1
- package/assets/skill/mcp-reference.md +3 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +48 -6
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +18 -3
- package/spec/output-schemas/audit-report.schema.json +18 -4
- package/spec/output-schemas/backlinks.schema.json +4 -0
- package/spec/output-schemas/collection-list.schema.json +15 -2
- package/spec/output-schemas/graph.schema.json +2 -0
- package/spec/output-schemas/links-list.schema.json +4 -0
- package/spec/output-schemas/status.schema.json +27 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/audit.ts +23 -4
- package/src/cli/commands/collection/list.ts +39 -5
- package/src/cli/commands/embed.ts +3 -3
- package/src/cli/commands/links.ts +34 -131
- package/src/cli/commands/ls.ts +6 -1
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +5 -0
- package/src/cli/program.ts +12 -2
- package/src/config/loader.ts +43 -0
- package/src/config/types.ts +8 -0
- package/src/core/audit-contract.ts +16 -4
- package/src/core/audit-freshness.ts +11 -1
- package/src/core/audit-links.ts +145 -25
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +19 -4
- package/src/core/audit.ts +67 -15
- package/src/core/context-compiler.ts +3 -0
- package/src/core/context-evidence.ts +11 -0
- package/src/core/graph-edge-confidence.ts +23 -1
- package/src/core/host-paths.ts +24 -5
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-workspace.ts +324 -0
- package/src/core/request-receipts.ts +63 -9
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +22 -10
- package/src/embed/variant-backlog.ts +34 -9
- package/src/index.ts +14 -1
- package/src/ingestion/graph-reconciliation.ts +77 -15
- package/src/ingestion/source-availability/darwin-path.ts +9 -3
- package/src/ingestion/sync.ts +22 -1
- package/src/ingestion/types.ts +14 -0
- package/src/llm/inference-scope.ts +19 -0
- package/src/mcp/http-egress.ts +42 -3
- package/src/mcp/tools/audit.ts +11 -2
- package/src/mcp/tools/changes.ts +1 -0
- package/src/mcp/tools/links.ts +3 -0
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +22 -6
- package/src/pipeline/expansion.ts +19 -31
- package/src/pipeline/graph-retrieval.ts +22 -2
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +6 -3
- package/src/serve/embed-scheduler.ts +2 -2
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/host-path-redaction.ts +51 -14
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +8 -6
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/resident-runtime.ts +7 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/links.ts +13 -0
- package/src/serve/routes/sessions.ts +81 -37
- package/src/serve/server.ts +5 -3
- package/src/serve/status-model.ts +10 -6
- package/src/serve/status.ts +2 -7
- package/src/sessions/config-refresh.ts +111 -0
- package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
- package/src/store/migrations/034-collection-link-workspace.ts +47 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +392 -233
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +252 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/src/store/sqlite/workspace-link-resolver.ts +654 -0
- package/src/store/types.ts +49 -3
- package/src/store/vector/stats.ts +1 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-wide wiki link resolution (Obsidian semantics) for plain
|
|
3
|
+
* `[[Target]]` links whose source document belongs to a link workspace.
|
|
4
|
+
*
|
|
5
|
+
* One ranking function serves both candidate strategies: a SQL basename
|
|
6
|
+
* prefilter for small target sets and a bulk load of every workspace member
|
|
7
|
+
* for large ones. Both feed identical rows into the same in-memory index, so
|
|
8
|
+
* results never depend on the strategy or on insertion order.
|
|
9
|
+
*
|
|
10
|
+
* Document-side case folding mirrors SQLite `lower()` (ASCII only), the
|
|
11
|
+
* documented caveat shared with collection-scoped resolution.
|
|
12
|
+
*
|
|
13
|
+
* @module src/store/sqlite/workspace-link-resolver
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Database } from "bun:sqlite";
|
|
17
|
+
|
|
18
|
+
// node:path/posix: POSIX path algebra for workspace-relative paths; no Bun equivalent.
|
|
19
|
+
import { posix as pathPosix } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
CollectionWorkspaceMembership,
|
|
23
|
+
LinkWorkspaceSource,
|
|
24
|
+
} from "../../core/link-workspace";
|
|
25
|
+
|
|
26
|
+
import { placeDocument } from "../../core/link-workspace";
|
|
27
|
+
import { stripWikiMdExt } from "../../core/links";
|
|
28
|
+
|
|
29
|
+
/** Why a workspace link resolved to its target. */
|
|
30
|
+
export type WorkspaceResolutionReason =
|
|
31
|
+
/** Exact path relative to the workspace root. */
|
|
32
|
+
| "workspace-path"
|
|
33
|
+
/** Exact path relative to the source's own collection root. */
|
|
34
|
+
| "collection-path"
|
|
35
|
+
/** The only file (or path suffix) with that name in the workspace. */
|
|
36
|
+
| "exact-name"
|
|
37
|
+
/** Won over other same-named files by folder or depth tie-break. */
|
|
38
|
+
| "tie-break"
|
|
39
|
+
/** No file matched; frontmatter title inside the source collection. */
|
|
40
|
+
| "title";
|
|
41
|
+
|
|
42
|
+
export interface WorkspaceCandidate {
|
|
43
|
+
id: number;
|
|
44
|
+
docid: string;
|
|
45
|
+
collection: string;
|
|
46
|
+
/** Workspace-relative path (NFC, original case). */
|
|
47
|
+
path: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WorkspaceLinkSource {
|
|
51
|
+
collection: string;
|
|
52
|
+
relPath: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface WorkspaceTargetResolution {
|
|
56
|
+
target: WorkspaceCandidate;
|
|
57
|
+
reason: WorkspaceResolutionReason;
|
|
58
|
+
/** False when candidates stay tied: audit-only, never a graph edge. */
|
|
59
|
+
traversable: boolean;
|
|
60
|
+
/** Distinct equally ranked candidates (length > 1 only when tied). */
|
|
61
|
+
tied: WorkspaceCandidate[];
|
|
62
|
+
/** Legacy-compatible rank for title fallback confidence (1-4), else null. */
|
|
63
|
+
titleRank: number | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface CatalogDoc extends WorkspaceCandidate {
|
|
67
|
+
wsKey: string;
|
|
68
|
+
/** Workspace-relative path, ASCII-lowered like SQLite lower(). */
|
|
69
|
+
wsNorm: string;
|
|
70
|
+
wsFolderNorm: string;
|
|
71
|
+
depth: number;
|
|
72
|
+
/** Collection-relative path, ASCII-lowered. */
|
|
73
|
+
relNorm: string;
|
|
74
|
+
baseNorm: string;
|
|
75
|
+
titleNorm: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CatalogRow {
|
|
79
|
+
id: number;
|
|
80
|
+
docid: string;
|
|
81
|
+
collection: string;
|
|
82
|
+
rel_path: string;
|
|
83
|
+
title_norm: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const RELATIVE_TARGET = /^\.\.?\//;
|
|
87
|
+
|
|
88
|
+
/** SQLite `lower()` folds ASCII letters only; mirror it exactly. */
|
|
89
|
+
const asciiLower = (value: string): string =>
|
|
90
|
+
value.replace(/[A-Z]+/g, (match) => match.toLowerCase());
|
|
91
|
+
|
|
92
|
+
const lastSegment = (value: string): string =>
|
|
93
|
+
value.slice(value.lastIndexOf("/") + 1);
|
|
94
|
+
|
|
95
|
+
const folderOf = (value: string): string =>
|
|
96
|
+
value.includes("/") ? value.slice(0, value.lastIndexOf("/")) : "";
|
|
97
|
+
|
|
98
|
+
const compareCodeUnits = (left: string, right: string): number =>
|
|
99
|
+
left < right ? -1 : left > right ? 1 : 0;
|
|
100
|
+
|
|
101
|
+
/** Read every collection's stored link workspace membership. */
|
|
102
|
+
export const loadLinkWorkspaceMemberships = (
|
|
103
|
+
db: Database
|
|
104
|
+
): Map<string, CollectionWorkspaceMembership> => {
|
|
105
|
+
const rows = db
|
|
106
|
+
.query<
|
|
107
|
+
{
|
|
108
|
+
name: string;
|
|
109
|
+
real_path: string | null;
|
|
110
|
+
workspace_root: string | null;
|
|
111
|
+
workspace_source: LinkWorkspaceSource | null;
|
|
112
|
+
workspace_nested: string | null;
|
|
113
|
+
},
|
|
114
|
+
[]
|
|
115
|
+
>(
|
|
116
|
+
"SELECT name, real_path, workspace_root, workspace_source, workspace_nested FROM collections"
|
|
117
|
+
)
|
|
118
|
+
.all();
|
|
119
|
+
const memberships = new Map<string, CollectionWorkspaceMembership>();
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
let nested: string[] = [];
|
|
122
|
+
if (row.workspace_nested) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed: unknown = JSON.parse(row.workspace_nested);
|
|
125
|
+
if (Array.isArray(parsed)) {
|
|
126
|
+
nested = parsed.filter(
|
|
127
|
+
(value): value is string => typeof value === "string"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
nested = [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
memberships.set(row.name, {
|
|
135
|
+
collection: row.name,
|
|
136
|
+
realPath: row.real_path,
|
|
137
|
+
root: row.workspace_root,
|
|
138
|
+
source: row.workspace_source ?? "none",
|
|
139
|
+
nested,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return memberships;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Workspace identity of a source document, or null for legacy resolution. */
|
|
146
|
+
export const workspaceKeyForDocument = (
|
|
147
|
+
memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
|
|
148
|
+
collection: string,
|
|
149
|
+
relPath: string
|
|
150
|
+
): string | null => placeDocument(memberships.get(collection), relPath).key;
|
|
151
|
+
|
|
152
|
+
/** Collections that can hold documents of the given workspace. */
|
|
153
|
+
export const workspaceMemberCollections = (
|
|
154
|
+
memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
|
|
155
|
+
wsKeys: ReadonlySet<string>
|
|
156
|
+
): string[] => {
|
|
157
|
+
const members: string[] = [];
|
|
158
|
+
for (const membership of memberships.values()) {
|
|
159
|
+
const placement = placeDocument(membership, "x");
|
|
160
|
+
const nestedKeys = membership.nested.map(
|
|
161
|
+
(prefix) => placeDocument(membership, `${prefix}/x`).key
|
|
162
|
+
);
|
|
163
|
+
if (
|
|
164
|
+
(placement.key !== null && wsKeys.has(placement.key)) ||
|
|
165
|
+
nestedKeys.some((key) => key !== null && wsKeys.has(key))
|
|
166
|
+
) {
|
|
167
|
+
members.push(membership.collection);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return members.sort();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Normalize a lowercased wiki target for workspace matching. `./` and `../`
|
|
175
|
+
* resolve against the source folder; `..` never escapes the workspace root.
|
|
176
|
+
* Returns null for an escaping target.
|
|
177
|
+
*/
|
|
178
|
+
export const normalizeWorkspaceTarget = (
|
|
179
|
+
targetRefNorm: string,
|
|
180
|
+
sourceFolderNorm: string
|
|
181
|
+
): string | null => {
|
|
182
|
+
let value = targetRefNorm.trim().replace(/^\/+/, "");
|
|
183
|
+
if (value.startsWith("./") || value.startsWith("../")) {
|
|
184
|
+
value = sourceFolderNorm ? `${sourceFolderNorm}/${value}` : value;
|
|
185
|
+
}
|
|
186
|
+
if (value.includes("/") || value === "..") {
|
|
187
|
+
value = pathPosix.normalize(value).replace(/\/+$/, "");
|
|
188
|
+
}
|
|
189
|
+
if (value === ".." || value.startsWith("../") || value === ".") return null;
|
|
190
|
+
return value;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Keys a document's lowered basename must equal for a target's last segment. */
|
|
194
|
+
const basenameKeys = (normalizedTarget: string): string[] => {
|
|
195
|
+
const base = stripWikiMdExt(lastSegment(normalizedTarget));
|
|
196
|
+
return [base, `${base}.md`];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Legacy title lookup values (ranks 1-4 of the collection-scoped resolver). */
|
|
200
|
+
const titleLookups = (targetRefNorm: string): Array<[number, string[]]> => {
|
|
201
|
+
const baseRef = stripWikiMdExt(targetRefNorm);
|
|
202
|
+
const baseRefMd = `${baseRef}.md`;
|
|
203
|
+
const suffixes = (value: string): string[] => {
|
|
204
|
+
const output = [value];
|
|
205
|
+
let separator = value.indexOf("/");
|
|
206
|
+
while (separator >= 0) {
|
|
207
|
+
const suffix = value.slice(separator + 1);
|
|
208
|
+
if (suffix) output.push(suffix);
|
|
209
|
+
separator = value.indexOf("/", separator + 1);
|
|
210
|
+
}
|
|
211
|
+
return output;
|
|
212
|
+
};
|
|
213
|
+
return [
|
|
214
|
+
[1, [baseRef]],
|
|
215
|
+
[2, [baseRefMd]],
|
|
216
|
+
[3, suffixes(baseRef)],
|
|
217
|
+
[
|
|
218
|
+
4,
|
|
219
|
+
suffixes(baseRefMd)
|
|
220
|
+
.filter((value) => value.endsWith(".md"))
|
|
221
|
+
.map((value) => value.slice(0, -3)),
|
|
222
|
+
],
|
|
223
|
+
];
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
class WorkspaceCatalog {
|
|
227
|
+
private readonly byBase = new Map<string, CatalogDoc[]>();
|
|
228
|
+
private readonly byTitle = new Map<string, CatalogDoc[]>();
|
|
229
|
+
|
|
230
|
+
constructor(
|
|
231
|
+
private readonly memberships: ReadonlyMap<
|
|
232
|
+
string,
|
|
233
|
+
CollectionWorkspaceMembership
|
|
234
|
+
>
|
|
235
|
+
) {}
|
|
236
|
+
|
|
237
|
+
add(row: CatalogRow): void {
|
|
238
|
+
const placement = placeDocument(
|
|
239
|
+
this.memberships.get(row.collection),
|
|
240
|
+
row.rel_path
|
|
241
|
+
);
|
|
242
|
+
if (placement.key === null) return;
|
|
243
|
+
const wsNorm = asciiLower(placement.path);
|
|
244
|
+
const doc: CatalogDoc = {
|
|
245
|
+
id: row.id,
|
|
246
|
+
docid: row.docid,
|
|
247
|
+
collection: row.collection,
|
|
248
|
+
path: placement.path,
|
|
249
|
+
wsKey: placement.key,
|
|
250
|
+
wsNorm,
|
|
251
|
+
wsFolderNorm: folderOf(wsNorm),
|
|
252
|
+
depth: wsNorm.split("/").length,
|
|
253
|
+
relNorm: asciiLower(row.rel_path.normalize("NFC")),
|
|
254
|
+
baseNorm: lastSegment(wsNorm),
|
|
255
|
+
titleNorm: row.title_norm,
|
|
256
|
+
};
|
|
257
|
+
const baseKey = `${doc.wsKey}\0${doc.baseNorm}`;
|
|
258
|
+
const bases = this.byBase.get(baseKey) ?? [];
|
|
259
|
+
bases.push(doc);
|
|
260
|
+
this.byBase.set(baseKey, bases);
|
|
261
|
+
if (doc.titleNorm !== null) {
|
|
262
|
+
const titleKey = `${doc.collection}\0${doc.titleNorm}`;
|
|
263
|
+
const titles = this.byTitle.get(titleKey) ?? [];
|
|
264
|
+
titles.push(doc);
|
|
265
|
+
this.byTitle.set(titleKey, titles);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
byBasename(wsKey: string, keys: readonly string[]): CatalogDoc[] {
|
|
270
|
+
const lists = keys
|
|
271
|
+
.map((key) => this.byBase.get(`${wsKey}\0${key}`))
|
|
272
|
+
.filter((list): list is CatalogDoc[] => list !== undefined);
|
|
273
|
+
if (lists.length <= 1) return lists[0] ?? [];
|
|
274
|
+
const found = new Map<number, CatalogDoc>();
|
|
275
|
+
for (const list of lists) {
|
|
276
|
+
for (const doc of list) found.set(doc.id, doc);
|
|
277
|
+
}
|
|
278
|
+
return [...found.values()];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
byTitleIn(
|
|
282
|
+
collection: string,
|
|
283
|
+
wsKey: string,
|
|
284
|
+
keys: readonly string[]
|
|
285
|
+
): CatalogDoc[] {
|
|
286
|
+
const found = new Map<number, CatalogDoc>();
|
|
287
|
+
for (const key of keys) {
|
|
288
|
+
for (const doc of this.byTitle.get(`${collection}\0${key}`) ?? []) {
|
|
289
|
+
if (doc.wsKey === wsKey) found.set(doc.id, doc);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return [...found.values()];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface RankedCandidate {
|
|
297
|
+
doc: CatalogDoc;
|
|
298
|
+
klass: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Collapse rows of one physical source (the same workspace path indexed by
|
|
303
|
+
* overlapping collections). Source identity is the exact, case-preserving
|
|
304
|
+
* workspace path: `Note.md` and `note.md` on a case-sensitive filesystem
|
|
305
|
+
* are two files and stay two candidates (a tie), never one. The
|
|
306
|
+
* representative keeps the best class, then the source's own collection,
|
|
307
|
+
* then the lexicographically first collection.
|
|
308
|
+
*/
|
|
309
|
+
const distinctSources = (
|
|
310
|
+
ranked: RankedCandidate[],
|
|
311
|
+
sourceCollection: string
|
|
312
|
+
): RankedCandidate[] => {
|
|
313
|
+
const preference = (left: RankedCandidate, right: RankedCandidate): number =>
|
|
314
|
+
left.klass - right.klass ||
|
|
315
|
+
Number(left.doc.collection !== sourceCollection) -
|
|
316
|
+
Number(right.doc.collection !== sourceCollection) ||
|
|
317
|
+
compareCodeUnits(left.doc.collection, right.doc.collection);
|
|
318
|
+
const bySource = new Map<string, RankedCandidate>();
|
|
319
|
+
for (const candidate of ranked) {
|
|
320
|
+
const key = `${candidate.doc.wsKey}\0${candidate.doc.path}`;
|
|
321
|
+
const existing = bySource.get(key);
|
|
322
|
+
if (!existing || preference(candidate, existing) < 0) {
|
|
323
|
+
bySource.set(key, candidate);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return [...bySource.values()];
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const toCandidate = (doc: CatalogDoc): WorkspaceCandidate => ({
|
|
330
|
+
id: doc.id,
|
|
331
|
+
docid: doc.docid,
|
|
332
|
+
collection: doc.collection,
|
|
333
|
+
path: doc.path,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Rank one plain wiki target inside its source's workspace. Ranking tuple:
|
|
338
|
+
* workspace-path exact, collection-path exact, same-folder name, other name
|
|
339
|
+
* or path-suffix match; then fewest path segments; candidates still equal
|
|
340
|
+
* are tied (listed in canonical path order, no edge).
|
|
341
|
+
*/
|
|
342
|
+
const rankTarget = (
|
|
343
|
+
catalog: WorkspaceCatalog,
|
|
344
|
+
wsKey: string,
|
|
345
|
+
source: { collection: string; wsFolderNorm: string },
|
|
346
|
+
targetRefNorm: string
|
|
347
|
+
): WorkspaceTargetResolution | null => {
|
|
348
|
+
const normalized = normalizeWorkspaceTarget(
|
|
349
|
+
targetRefNorm,
|
|
350
|
+
source.wsFolderNorm
|
|
351
|
+
);
|
|
352
|
+
if (normalized === null || normalized.length === 0) return null;
|
|
353
|
+
const base = stripWikiMdExt(normalized);
|
|
354
|
+
const exact = new Set([base, `${base}.md`]);
|
|
355
|
+
// A relative target stays a path even when it normalizes to a root name.
|
|
356
|
+
const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
|
|
357
|
+
const hasPath = relative || normalized.includes("/");
|
|
358
|
+
const matches: RankedCandidate[] = [];
|
|
359
|
+
for (const doc of catalog.byBasename(wsKey, basenameKeys(normalized))) {
|
|
360
|
+
let klass: number | null = null;
|
|
361
|
+
// Exact path classes apply to path targets only; a plain name ranks by
|
|
362
|
+
// folder and depth like any other same-named file. A relative target is
|
|
363
|
+
// already a full workspace path: only the exact workspace path matches.
|
|
364
|
+
if (hasPath && exact.has(doc.wsNorm)) klass = 0;
|
|
365
|
+
else if (relative) klass = null;
|
|
366
|
+
else if (
|
|
367
|
+
hasPath &&
|
|
368
|
+
doc.collection === source.collection &&
|
|
369
|
+
exact.has(doc.relNorm)
|
|
370
|
+
) {
|
|
371
|
+
klass = 1;
|
|
372
|
+
} else if (
|
|
373
|
+
!hasPath ||
|
|
374
|
+
doc.wsNorm.endsWith(`/${base}`) ||
|
|
375
|
+
doc.wsNorm.endsWith(`/${base}.md`)
|
|
376
|
+
) {
|
|
377
|
+
klass = doc.wsFolderNorm === source.wsFolderNorm ? 2 : 3;
|
|
378
|
+
}
|
|
379
|
+
if (klass !== null) matches.push({ doc, klass });
|
|
380
|
+
}
|
|
381
|
+
const distinct = distinctSources(matches, source.collection);
|
|
382
|
+
if (distinct.length > 0) {
|
|
383
|
+
distinct.sort(
|
|
384
|
+
(left, right) =>
|
|
385
|
+
left.klass - right.klass ||
|
|
386
|
+
left.doc.depth - right.doc.depth ||
|
|
387
|
+
compareCodeUnits(left.doc.wsNorm, right.doc.wsNorm) ||
|
|
388
|
+
compareCodeUnits(left.doc.path, right.doc.path)
|
|
389
|
+
);
|
|
390
|
+
const best = distinct[0]!;
|
|
391
|
+
const tied = distinct.filter(
|
|
392
|
+
(candidate) =>
|
|
393
|
+
candidate.klass === best.klass && candidate.doc.depth === best.doc.depth
|
|
394
|
+
);
|
|
395
|
+
const traversable = tied.length === 1;
|
|
396
|
+
const reason: WorkspaceResolutionReason =
|
|
397
|
+
best.klass === 0
|
|
398
|
+
? "workspace-path"
|
|
399
|
+
: best.klass === 1
|
|
400
|
+
? "collection-path"
|
|
401
|
+
: distinct.length === 1
|
|
402
|
+
? "exact-name"
|
|
403
|
+
: "tie-break";
|
|
404
|
+
return {
|
|
405
|
+
target: toCandidate(best.doc),
|
|
406
|
+
reason,
|
|
407
|
+
traversable,
|
|
408
|
+
tied: traversable ? [] : tied.map(({ doc }) => toCandidate(doc)),
|
|
409
|
+
titleRank: null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// Fallback: frontmatter title inside the source collection only; relative
|
|
413
|
+
// targets are paths and never fall back to titles.
|
|
414
|
+
if (relative) return null;
|
|
415
|
+
for (const [rank, keys] of titleLookups(targetRefNorm)) {
|
|
416
|
+
const docs = catalog
|
|
417
|
+
.byTitleIn(source.collection, wsKey, keys)
|
|
418
|
+
.sort(
|
|
419
|
+
(left, right) =>
|
|
420
|
+
left.depth - right.depth ||
|
|
421
|
+
compareCodeUnits(left.wsNorm, right.wsNorm)
|
|
422
|
+
);
|
|
423
|
+
if (docs.length === 0) continue;
|
|
424
|
+
return {
|
|
425
|
+
target: toCandidate(docs[0]!),
|
|
426
|
+
reason: "title",
|
|
427
|
+
traversable: docs.length === 1,
|
|
428
|
+
tied: docs.length === 1 ? [] : docs.map(toCandidate),
|
|
429
|
+
titleRank: rank,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Membership map from collection rows (as returned by getCollections). */
|
|
436
|
+
export const membershipsFromCollectionRows = (
|
|
437
|
+
rows: ReadonlyArray<{
|
|
438
|
+
name: string;
|
|
439
|
+
realPath?: string | null;
|
|
440
|
+
workspaceRoot?: string | null;
|
|
441
|
+
workspaceSource?: LinkWorkspaceSource;
|
|
442
|
+
workspaceNested?: string[];
|
|
443
|
+
}>
|
|
444
|
+
): Map<string, CollectionWorkspaceMembership> =>
|
|
445
|
+
new Map(
|
|
446
|
+
rows.map((row) => [
|
|
447
|
+
row.name,
|
|
448
|
+
{
|
|
449
|
+
collection: row.name,
|
|
450
|
+
realPath: row.realPath ?? null,
|
|
451
|
+
root: row.workspaceRoot ?? null,
|
|
452
|
+
source: row.workspaceSource ?? "none",
|
|
453
|
+
nested: row.workspaceNested ?? [],
|
|
454
|
+
},
|
|
455
|
+
])
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Same ranking over an in-memory document list (graph projection). Returns
|
|
460
|
+
* `undefined` when the source is not in a link workspace (caller keeps its
|
|
461
|
+
* collection-scoped contract), `null` when nothing matched.
|
|
462
|
+
*/
|
|
463
|
+
export const createInMemoryWorkspaceResolver = (
|
|
464
|
+
memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
|
|
465
|
+
documents: ReadonlyArray<{
|
|
466
|
+
id: number;
|
|
467
|
+
docid: string;
|
|
468
|
+
collection: string;
|
|
469
|
+
relPath: string;
|
|
470
|
+
title: string | null;
|
|
471
|
+
}>
|
|
472
|
+
): ((
|
|
473
|
+
source: WorkspaceLinkSource,
|
|
474
|
+
targetRefNorm: string
|
|
475
|
+
) => WorkspaceTargetResolution | null | undefined) => {
|
|
476
|
+
const anyWorkspace = [...memberships.values()].some(
|
|
477
|
+
(membership) =>
|
|
478
|
+
membership.realPath !== null &&
|
|
479
|
+
membership.source !== "disabled" &&
|
|
480
|
+
membership.source !== "unavailable" &&
|
|
481
|
+
(membership.root !== null || membership.nested.length > 0)
|
|
482
|
+
);
|
|
483
|
+
if (!anyWorkspace) return () => undefined;
|
|
484
|
+
const catalog = new WorkspaceCatalog(memberships);
|
|
485
|
+
for (const document of documents) {
|
|
486
|
+
catalog.add({
|
|
487
|
+
id: document.id,
|
|
488
|
+
docid: document.docid,
|
|
489
|
+
collection: document.collection,
|
|
490
|
+
rel_path: document.relPath,
|
|
491
|
+
// Mirrors SQLite lower(trim(title)) used by the SQL candidate paths.
|
|
492
|
+
title_norm:
|
|
493
|
+
typeof document.title === "string"
|
|
494
|
+
? asciiLower(document.title.trim())
|
|
495
|
+
: null,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return (source, targetRefNorm) => {
|
|
499
|
+
const placement = placeDocument(
|
|
500
|
+
memberships.get(source.collection),
|
|
501
|
+
source.relPath
|
|
502
|
+
);
|
|
503
|
+
if (placement.key === null) return undefined;
|
|
504
|
+
return rankTarget(
|
|
505
|
+
catalog,
|
|
506
|
+
placement.key,
|
|
507
|
+
{
|
|
508
|
+
collection: source.collection,
|
|
509
|
+
wsFolderNorm: folderOf(asciiLower(placement.path)),
|
|
510
|
+
},
|
|
511
|
+
targetRefNorm
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export interface WorkspaceTargetInput {
|
|
517
|
+
targetRefNorm: string;
|
|
518
|
+
source: WorkspaceLinkSource;
|
|
519
|
+
wsKey: string;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const MAX_SQL_PARAMS = 900;
|
|
523
|
+
const CATALOG_COLUMNS =
|
|
524
|
+
"id, docid, collection, rel_path, lower(trim(title)) AS title_norm";
|
|
525
|
+
/** SQLite basename of rel_path (text after the last '/'). */
|
|
526
|
+
const BASENAME_SQL =
|
|
527
|
+
"substr(rel_path, length(rtrim(rel_path, replace(rel_path, '/', ''))) + 1)";
|
|
528
|
+
|
|
529
|
+
const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
|
530
|
+
const chunks: T[][] = [];
|
|
531
|
+
for (let offset = 0; offset < items.length; offset += size) {
|
|
532
|
+
chunks.push(items.slice(offset, offset + size));
|
|
533
|
+
}
|
|
534
|
+
return chunks;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const loadRows = (
|
|
538
|
+
db: Database,
|
|
539
|
+
collections: readonly string[],
|
|
540
|
+
filter: { sql: string; values: string[] } | null
|
|
541
|
+
): CatalogRow[] => {
|
|
542
|
+
if (collections.length === 0) return [];
|
|
543
|
+
const rows: CatalogRow[] = [];
|
|
544
|
+
const collectionPlaceholders = collections.map(() => "?").join(",");
|
|
545
|
+
const valueChunks = filter
|
|
546
|
+
? chunk(filter.values, Math.max(1, MAX_SQL_PARAMS - collections.length))
|
|
547
|
+
: [[]];
|
|
548
|
+
for (const values of valueChunks) {
|
|
549
|
+
if (filter && values.length === 0) continue;
|
|
550
|
+
rows.push(
|
|
551
|
+
...db
|
|
552
|
+
.query<CatalogRow, string[]>(
|
|
553
|
+
`SELECT ${CATALOG_COLUMNS} FROM documents
|
|
554
|
+
WHERE active = 1 AND collection IN (${collectionPlaceholders})
|
|
555
|
+
${filter ? `AND ${filter.sql} IN (${values.map(() => "?").join(",")})` : ""}
|
|
556
|
+
ORDER BY id`
|
|
557
|
+
)
|
|
558
|
+
.all(...collections, ...values)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return rows;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/** Both Unicode forms so a NFD file name still reaches the NFC ranking. */
|
|
565
|
+
const withUnicodeForms = (values: Iterable<string>): string[] => {
|
|
566
|
+
const output = new Set<string>();
|
|
567
|
+
for (const value of values) {
|
|
568
|
+
output.add(value);
|
|
569
|
+
output.add(value.normalize("NFD"));
|
|
570
|
+
}
|
|
571
|
+
return [...output].sort();
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolve workspace targets. `bulk` loads every document of the involved
|
|
576
|
+
* workspaces once; otherwise a SQL basename/title prefilter fetches only
|
|
577
|
+
* potential candidates. Both paths rank identical rows identically.
|
|
578
|
+
*/
|
|
579
|
+
export const resolveWorkspaceTargets = (
|
|
580
|
+
db: Database,
|
|
581
|
+
memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
|
|
582
|
+
targets: readonly WorkspaceTargetInput[],
|
|
583
|
+
options: { bulk: boolean }
|
|
584
|
+
): Array<WorkspaceTargetResolution | null> => {
|
|
585
|
+
if (targets.length === 0) return [];
|
|
586
|
+
const wsKeys = new Set(targets.map((target) => target.wsKey));
|
|
587
|
+
const collections = workspaceMemberCollections(memberships, wsKeys);
|
|
588
|
+
const catalog = new WorkspaceCatalog(memberships);
|
|
589
|
+
const seen = new Set<number>();
|
|
590
|
+
const addRows = (rows: CatalogRow[]) => {
|
|
591
|
+
for (const row of rows) {
|
|
592
|
+
if (seen.has(row.id)) continue;
|
|
593
|
+
seen.add(row.id);
|
|
594
|
+
catalog.add(row);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
if (options.bulk) {
|
|
598
|
+
addRows(loadRows(db, collections, null));
|
|
599
|
+
} else {
|
|
600
|
+
const bases = new Set<string>();
|
|
601
|
+
const titles = new Set<string>();
|
|
602
|
+
for (const target of targets) {
|
|
603
|
+
const normalized = normalizeWorkspaceTarget(target.targetRefNorm, "");
|
|
604
|
+
const raw = target.targetRefNorm.trim();
|
|
605
|
+
for (const value of [normalized, raw]) {
|
|
606
|
+
if (!value) continue;
|
|
607
|
+
for (const key of basenameKeys(value)) bases.add(key);
|
|
608
|
+
}
|
|
609
|
+
for (const [, keys] of titleLookups(target.targetRefNorm)) {
|
|
610
|
+
for (const key of keys) titles.add(key);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
addRows(
|
|
614
|
+
loadRows(db, collections, {
|
|
615
|
+
sql: `lower(${BASENAME_SQL})`,
|
|
616
|
+
values: withUnicodeForms(bases),
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
addRows(
|
|
620
|
+
loadRows(db, collections, {
|
|
621
|
+
sql: "lower(trim(title))",
|
|
622
|
+
values: [...titles].sort(),
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const cache = new Map<string, WorkspaceTargetResolution | null>();
|
|
627
|
+
const folders = new Map<string, string>();
|
|
628
|
+
return targets.map((target) => {
|
|
629
|
+
const sourceKey = `${target.source.collection}\0${target.source.relPath}`;
|
|
630
|
+
let wsFolderNorm = folders.get(sourceKey);
|
|
631
|
+
if (wsFolderNorm === undefined) {
|
|
632
|
+
wsFolderNorm = folderOf(
|
|
633
|
+
asciiLower(
|
|
634
|
+
placeDocument(
|
|
635
|
+
memberships.get(target.source.collection),
|
|
636
|
+
target.source.relPath
|
|
637
|
+
).path
|
|
638
|
+
)
|
|
639
|
+
);
|
|
640
|
+
folders.set(sourceKey, wsFolderNorm);
|
|
641
|
+
}
|
|
642
|
+
// Resolution depends on workspace, source collection and folder only.
|
|
643
|
+
const cacheKey = `${target.wsKey}\0${target.source.collection}\0${wsFolderNorm}\0${target.targetRefNorm}`;
|
|
644
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
|
|
645
|
+
const result = rankTarget(
|
|
646
|
+
catalog,
|
|
647
|
+
target.wsKey,
|
|
648
|
+
{ collection: target.source.collection, wsFolderNorm },
|
|
649
|
+
target.targetRefNorm
|
|
650
|
+
);
|
|
651
|
+
cache.set(cacheKey, result);
|
|
652
|
+
return result;
|
|
653
|
+
});
|
|
654
|
+
};
|