diff --git a/src/utils/wikilink.test.ts b/src/utils/wikilink.test.ts index 2fd02c6e..17abc773 100644 --- a/src/utils/wikilink.test.ts +++ b/src/utils/wikilink.test.ts @@ -119,6 +119,20 @@ describe('resolveEntry', () => { expect(resolveEntry([alpha, alphaArchived], 'archive/alpha')).toBe(alphaArchived) }) + it('resolves full-path wikilinks to non-Markdown duplicate filenames', () => { + const yamlA = makeEntry({ path: '/vault/a/file.yml', filename: 'file.yml', title: 'file.yml', fileKind: 'text' }) + const yamlB = makeEntry({ path: '/vault/b/file.yml', filename: 'file.yml', title: 'file.yml', fileKind: 'text' }) + const pdfA = makeEntry({ path: '/vault/a/document.pdf', filename: 'document.pdf', title: 'document.pdf', fileKind: 'binary' }) + const pdfB = makeEntry({ path: '/vault/b/document.pdf', filename: 'document.pdf', title: 'document.pdf', fileKind: 'binary' }) + const targetA = makeEntry({ path: '/vault/a/target.md', filename: 'target.md', title: 'Target' }) + const targetB = makeEntry({ path: '/vault/b/target.md', filename: 'target.md', title: 'Target' }) + const duplicateEntries = [yamlA, yamlB, pdfA, pdfB, targetA, targetB] + + expect(resolveEntry(duplicateEntries, 'b/file.yml')).toBe(yamlB) + expect(resolveEntry(duplicateEntries, 'b/document.pdf')).toBe(pdfB) + expect(resolveEntry(duplicateEntries, 'b/target.md')).toBe(targetB) + }) + it('resolves workspace-prefixed targets inside the matching mounted workspace', () => { const personal = makeEntry({ path: '/personal/projects/alpha.md', diff --git a/src/utils/wikilink.ts b/src/utils/wikilink.ts index af0b4896..e9b432dc 100644 --- a/src/utils/wikilink.ts +++ b/src/utils/wikilink.ts @@ -101,7 +101,7 @@ interface ResolutionKey { workspaceAlias: string | null targetWithoutWorkspace: string lastSegment: string - pathSuffix: string | null + pathSuffixes: string[] humanizedTarget: string | null } @@ -115,6 +115,10 @@ function buildResolutionKey(rawTarget: WikilinkTarget, knownWorkspaceAliases: Se : null const targetWithoutWorkspace = workspaceAlias ? segments.slice(1).join('/') : exactTarget const normalizedLocalTarget = targetWithoutWorkspace.toLowerCase() + const normalizedPathTarget = normalizedLocalTarget.replace(/^\/+/, '') + const pathSuffixes = normalizedPathTarget.includes('/') + ? [`/${normalizedPathTarget}`, ...normalizedPathTarget.endsWith('.md') ? [] : [`/${normalizedPathTarget}.md`]] + : [] const lastSegment = targetWithoutWorkspace.includes('/') ? (targetWithoutWorkspace.split('/').pop() ?? targetWithoutWorkspace).toLowerCase() : normalizedLocalTarget const humanizedTarget = lastSegment.replace(/-/g, ' ') @@ -123,7 +127,7 @@ function buildResolutionKey(rawTarget: WikilinkTarget, knownWorkspaceAliases: Se workspaceAlias, targetWithoutWorkspace: normalizedLocalTarget, lastSegment, - pathSuffix: targetWithoutWorkspace.includes('/') ? `/${normalizedLocalTarget}.md` : null, + pathSuffixes, humanizedTarget: humanizedTarget === normalizedLocalTarget ? null : humanizedTarget, } } @@ -143,9 +147,8 @@ function prioritizeSourceWorkspace(entries: VaultEntry[], sourceEntry?: VaultEnt } function findEntryByPathSuffix(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined { - if (!resolutionKey.pathSuffix) return undefined - const { pathSuffix } = resolutionKey - return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix)) + if (resolutionKey.pathSuffixes.length === 0) return undefined + return entries.find(entry => resolutionKey.pathSuffixes.some(pathSuffix => entry.path.toLowerCase().endsWith(pathSuffix))) } function findEntryByFilename(entries: VaultEntry[], { exactTarget, targetWithoutWorkspace, lastSegment }: ResolutionKey): VaultEntry | undefined { diff --git a/tests/smoke/non-markdown-wikilink-duplicates.spec.ts b/tests/smoke/non-markdown-wikilink-duplicates.spec.ts new file mode 100644 index 00000000..8100cb76 --- /dev/null +++ b/tests/smoke/non-markdown-wikilink-duplicates.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' + +const SOURCE_TITLE = 'Duplicate Attachment Links' +const SOURCE_RELATIVE_PATH = path.join('b', 'note.md') +const YAML_TARGET = path.join('b', 'file.yml') +const YAML_MARKER = 'selected-folder: b' + +let tempVaultDir: string + +function writeDuplicateLinkFixture(vaultPath: string): void { + fs.mkdirSync(path.join(vaultPath, 'a'), { recursive: true }) + fs.mkdirSync(path.join(vaultPath, 'b'), { recursive: true }) + fs.writeFileSync(path.join(vaultPath, 'a', 'file.yml'), 'selected-folder: a\n') + fs.writeFileSync(path.join(vaultPath, 'b', 'file.yml'), `${YAML_MARKER}\n`) + fs.writeFileSync(path.join(vaultPath, SOURCE_RELATIVE_PATH), `--- +type: Note +--- +# ${SOURCE_TITLE} + +- YAML: [[b/file.yml|b/file.yml]] +`) +} + +function buildFileEntry(vaultPath: string, relativePath: string) { + const filePath = path.join(vaultPath, relativePath) + const filename = path.basename(filePath) + return { + path: filePath, + filename, + title: filename, + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: Date.now(), + createdAt: null, + fileSize: fs.statSync(filePath).size, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, + fileKind: 'text', + } +} + +async function includeNonMarkdownEntries(page: Page, vaultPath: string): Promise { + const duplicateEntries = [ + buildFileEntry(vaultPath, path.join('a', 'file.yml')), + buildFileEntry(vaultPath, YAML_TARGET), + ] + await page.route('**/api/vault/list*', async (route) => { + const response = await route.fetch() + const entries = await response.json() + if (!Array.isArray(entries)) { + await route.fulfill({ response }) + return + } + + const existingPaths = new Set(entries.map((entry) => entry?.path).filter(Boolean)) + const nextEntries = [ + ...entries, + ...duplicateEntries.filter((entry) => !existingPaths.has(entry.path)), + ] + await route.fulfill({ response, json: nextEntries }) + }) +} + +async function readRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('.cm-content') + if (!host) return '' + + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { doc: { toString(): string } } + } + } + } + + return (host as CodeMirrorHost).cmTile?.view?.state.doc.toString() ?? host.textContent ?? '' + }) +} + +test.describe('non-Markdown duplicate wikilinks', () => { + test.beforeEach(() => { + tempVaultDir = createFixtureVaultCopy() + writeDuplicateLinkFixture(tempVaultDir) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('full-path wikilinks open the matching non-Markdown duplicate', async ({ page }) => { + await includeNonMarkdownEntries(page, tempVaultDir) + await openFixtureVaultDesktopHarness(page, tempVaultDir, { expectedReadyTitle: SOURCE_TITLE }) + + await page.getByText(SOURCE_TITLE, { exact: true }).first().click() + const link = page.locator('.bn-editor [data-target="b/file.yml|b/file.yml"]').first() + await expect(link).toBeVisible({ timeout: 5_000 }) + + await link.click({ modifiers: ['Meta'] }) + + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expect.poll(() => readRawEditorContent(page)).toContain(YAML_MARKER) + }) +})