diff --git a/apps/x/apps/renderer/src/lib/wiki-links.ts b/apps/x/apps/renderer/src/lib/wiki-links.ts index 4b429a05..0e376cc9 100644 --- a/apps/x/apps/renderer/src/lib/wiki-links.ts +++ b/apps/x/apps/renderer/src/lib/wiki-links.ts @@ -9,8 +9,7 @@ export const normalizeWikiPath = (input: string) => { } export const ensureMarkdownExtension = (path: string) => { - const lastSegment = path.split('/').pop() ?? path - if (lastSegment.includes('.')) return path + if (path.toLowerCase().endsWith('.md')) return path return `${path}.md` } diff --git a/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts b/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts new file mode 100644 index 00000000..68edbb9a --- /dev/null +++ b/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const WIKI_LINK_REGEX = /\[\[([^[\]]+)\]\]/g; +const KNOWLEDGE_PREFIX = 'knowledge/'; +const MARKDOWN_EXTENSION = '.md'; + +function normalizeRelPath(relPath: string): string { + return relPath.replace(/\\/g, '/'); +} + +function isKnowledgeMarkdownPath(relPath: string): boolean { + const normalized = normalizeRelPath(relPath).replace(/^\/+/, ''); + const lower = normalized.toLowerCase(); + return lower.startsWith(KNOWLEDGE_PREFIX) && lower.endsWith(MARKDOWN_EXTENSION); +} + +function stripKnowledgePrefix(relPath: string): string { + const normalized = normalizeRelPath(relPath).replace(/^\/+/, ''); + if (!normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)) return normalized; + return normalized.slice(KNOWLEDGE_PREFIX.length); +} + +function stripMarkdownExtension(wikiPath: string): string { + return wikiPath.toLowerCase().endsWith(MARKDOWN_EXTENSION) + ? wikiPath.slice(0, -MARKDOWN_EXTENSION.length) + : wikiPath; +} + +function toWikiPathCompareKey(wikiPath: string): string { + return stripMarkdownExtension(wikiPath).toLowerCase(); +} + +function splitWikiPathPrefix(rawPath: string): { pathWithoutPrefix: string; hadKnowledgePrefix: boolean } { + let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, ''); + const hadKnowledgePrefix = /^knowledge\//i.test(normalized); + if (hadKnowledgePrefix) { + normalized = normalized.slice(KNOWLEDGE_PREFIX.length); + } + return { pathWithoutPrefix: normalized, hadKnowledgePrefix }; +} + +function rewriteWikiLinksInMarkdown(markdown: string, fromWikiPath: string, toWikiPath: string): string { + const fromCompareKey = toWikiPathCompareKey(fromWikiPath); + const toWikiPathWithoutExtension = stripMarkdownExtension(toWikiPath); + + return markdown.replace(WIKI_LINK_REGEX, (fullMatch, innerRaw: string) => { + const pipeIndex = innerRaw.indexOf('|'); + const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw; + const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : ''; + + const hashIndex = pathAndAnchor.indexOf('#'); + const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor; + const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : ''; + + const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? ''; + const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? ''; + const rawPath = pathPart.trim(); + if (!rawPath) return fullMatch; + + const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath); + if (!pathWithoutPrefix) return fullMatch; + + if (toWikiPathCompareKey(pathWithoutPrefix) !== fromCompareKey) { + return fullMatch; + } + + const preserveMarkdownExtension = rawPath.toLowerCase().endsWith(MARKDOWN_EXTENSION); + const rewrittenPath = preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension; + const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenPath}` : rewrittenPath; + + return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`; + }); +} + +async function collectKnowledgeMarkdownFiles(workspaceRoot: string): Promise { + const knowledgeRoot = path.join(workspaceRoot, 'knowledge'); + try { + const stat = await fs.lstat(knowledgeRoot); + if (!stat.isDirectory()) return []; + } catch { + return []; + } + + const markdownFiles: string[] = []; + const pendingDirectories: string[] = [knowledgeRoot]; + + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop(); + if (!currentDirectory) continue; + + const entries = await fs.readdir(currentDirectory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + + const absolutePath = path.join(currentDirectory, entry.name); + if (entry.isDirectory()) { + pendingDirectories.push(absolutePath); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(MARKDOWN_EXTENSION)) continue; + + const relativePath = normalizeRelPath(path.relative(workspaceRoot, absolutePath)); + markdownFiles.push(relativePath); + } + } + + return markdownFiles; +} + +export async function rewriteWikiLinksForRenamedKnowledgeFile( + workspaceRoot: string, + fromRelPath: string, + toRelPath: string +): Promise { + const normalizedFrom = normalizeRelPath(fromRelPath); + const normalizedTo = normalizeRelPath(toRelPath); + + if (!isKnowledgeMarkdownPath(normalizedFrom) || !isKnowledgeMarkdownPath(normalizedTo)) { + return 0; + } + + const fromWikiPath = stripKnowledgePrefix(normalizedFrom); + const toWikiPath = stripKnowledgePrefix(normalizedTo); + if (toWikiPathCompareKey(fromWikiPath) === toWikiPathCompareKey(toWikiPath)) return 0; + + const markdownFiles = await collectKnowledgeMarkdownFiles(workspaceRoot); + let rewrittenFiles = 0; + + for (const relativePath of markdownFiles) { + const absolutePath = path.join(workspaceRoot, ...relativePath.split('/')); + try { + const markdown = await fs.readFile(absolutePath, 'utf8'); + if (!markdown.includes('[[')) continue; + + const rewritten = rewriteWikiLinksInMarkdown(markdown, fromWikiPath, toWikiPath); + if (rewritten === markdown) continue; + + await fs.writeFile(absolutePath, rewritten, 'utf8'); + rewrittenFiles += 1; + } catch (error) { + console.error('Failed to rewrite wiki links in file:', relativePath, error); + } + } + + return rewrittenFiles; +} diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts index 59910bdb..cd872f39 100644 --- a/apps/x/packages/core/src/workspace/workspace.ts +++ b/apps/x/packages/core/src/workspace/workspace.ts @@ -5,6 +5,7 @@ import { workspace } from '@x/shared'; import { z } from 'zod'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { WorkDir } from '../config/config.js'; +import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js'; // ============================================================================ // Path Utilities @@ -58,6 +59,11 @@ export function absToRelPosix(absPath: string): string | null { return relPath.split(path.sep).join('/'); } +function isKnowledgeMarkdownRelPath(relPath: string): boolean { + const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase(); + return normalized.startsWith('knowledge/') && normalized.endsWith('.md'); +} + // ============================================================================ // File System Utilities // ============================================================================ @@ -286,6 +292,7 @@ export async function rename( // Check if source exists await fs.access(fromPath); + const fromStats = await fs.lstat(fromPath); // Check if destination exists (only if overwrite is false) if (!overwrite) { @@ -309,6 +316,19 @@ export async function rename( await fs.mkdir(path.dirname(toPath), { recursive: true }); await fs.rename(fromPath, toPath); + + if ( + fromStats.isFile() + && isKnowledgeMarkdownRelPath(from) + && isKnowledgeMarkdownRelPath(to) + ) { + try { + await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, from, to); + } catch (error) { + console.error('Failed to rewrite wiki backlinks after file rename:', error); + } + } + return { ok: true as const }; } @@ -383,4 +403,4 @@ export async function remove( } return { ok: true as const }; -} \ No newline at end of file +}