fix: prioritize REF lookup over full-body title match

Codex flagged a precedence inversion: the earlier full-body-first checks
ran before REF_PATTERN, so a canonical ref link like `[[BUG-585]]` would
silently retarget onto a user item whose title happened to match the ref
literal (case-insensitive). Ref storage is now our canonical form via
markdownToWikiLinks, so this path must be deterministic.

Restructure the resolver so ref lookup is always checked first. The
legacy full-body title / collection-qualified title checks only run on
bodies that actually need them — i.e. when the body contains a pipe
(which is the only condition that motivated the full-body check in the
first place: recovering pre-existing "[[A|B]]" / "[[coll/A|B]]" titles).
This commit is contained in:
xarmian
2026-04-17 04:14:59 +00:00
parent 922cb26e1d
commit a4496849dc
+38 -31
View File
@@ -92,37 +92,17 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu
return content.replace(/\[\[((?:\\.|[^\]\\])+)\]\]/g, (_match, body: string) => {
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
// Try to resolve the entire body as a literal title FIRST, before
// splitting on `|`. Legacy data may contain titles that include an
// unescaped pipe (e.g. "[[A|B]]" where the item's actual title is
// "A|B"), and the `key|display` split would otherwise break those.
const fullBody = unescapeWikiBody(body);
const fullTitleItem = items.find(i => i.title.toLowerCase() === fullBody.toLowerCase());
if (fullTitleItem && fullTitleItem.collection_slug) {
return `[${escapeMarkdownLinkText(fullTitleItem.title)}](${prefix}/${fullTitleItem.collection_slug}/${itemUrlId(fullTitleItem)})`;
}
// Also try the collection-qualified legacy form on the full body,
// before the pipe split. Handles "[[tasks/A|B]]" where the actual
// title is literally "A|B" in the "tasks" collection.
if (fullBody.includes('/')) {
const [qualColl, ...qualRest] = fullBody.split('/');
const qualTitle = qualRest.join('/');
const qualItem = items.find(i =>
i.title.toLowerCase() === qualTitle.toLowerCase() &&
i.collection_slug === qualColl
);
if (qualItem && qualItem.collection_slug) {
return `[${escapeMarkdownLinkText(qualItem.title)}](${prefix}/${qualItem.collection_slug}/${itemUrlId(qualItem)})`;
}
}
// Split optional display override on the FIRST unescaped pipe.
// Split optional display override on the FIRST unescaped pipe. We do
// this up-front so REF_PATTERN can check the key alone (a ref like
// "BUG-585" contains no pipe, so this is a no-op for ref storage).
const { key: rawKey, displayOverride: rawDisplay } = splitWikiBody(body);
const key = unescapeWikiBody(rawKey);
const displayOverride = rawDisplay == null ? null : unescapeWikiBody(rawDisplay);
// 1. Ref-based lookup (e.g. [[BUG-585]]) — the preferred form.
// 1. Ref-based lookup FIRST. Ref storage is our canonical form, so
// it must win over any legacy title that happens to match the
// ref literal — otherwise `[[BUG-585]]` could silently retarget
// onto a user-created item whose title is "BUG-585".
if (REF_PATTERN.test(key.trim())) {
const ref = key.trim();
const byRef = items.find(i =>
@@ -137,11 +117,38 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu
return _match;
}
// 2. Legacy: the [[collection/Title]] disambiguation syntax. (Exact
// full-title match was already tried above via fullBody.)
let item: Item | undefined;
// 2. Legacy: exact full-body title match, BEFORE the pipe split.
// Handles pre-existing stored titles that contain a literal `|`
// (e.g. "[[A|B]]" where the item's real title is "A|B"). Only
// relevant when the body actually has a pipe — otherwise the
// already-split `key` is identical to the full body.
if (rawDisplay != null) {
const fullBody = unescapeWikiBody(body);
const fullTitleItem = items.find(i => i.title.toLowerCase() === fullBody.toLowerCase());
if (fullTitleItem && fullTitleItem.collection_slug) {
return `[${escapeMarkdownLinkText(fullTitleItem.title)}](${prefix}/${fullTitleItem.collection_slug}/${itemUrlId(fullTitleItem)})`;
}
// Collection-qualified legacy form whose title contains a pipe.
if (fullBody.includes('/')) {
const [qualColl, ...qualRest] = fullBody.split('/');
const qualTitle = qualRest.join('/');
const qualItem = items.find(i =>
i.title.toLowerCase() === qualTitle.toLowerCase() &&
i.collection_slug === qualColl
);
if (qualItem && qualItem.collection_slug) {
return `[${escapeMarkdownLinkText(qualItem.title)}](${prefix}/${qualItem.collection_slug}/${itemUrlId(qualItem)})`;
}
}
}
// 3. Legacy: exact title match on the key.
const titleLower = key.toLowerCase();
let item = items.find(i => i.title.toLowerCase() === titleLower);
let displayText = displayOverride ?? key;
if (key.includes('/')) {
// 4. Legacy: the [[collection/Title]] disambiguation syntax.
if (!item && key.includes('/')) {
const [collFilter, ...rest] = key.split('/');
const searchTitle = rest.join('/');
const found = items.find(i =>