mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 17:36:25 +02:00
Enhance wiki link handling during file renaming
This commit is contained in:
parent
9de0b4aea2
commit
34792e9c19
3 changed files with 170 additions and 3 deletions
148
apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
Normal file
148
apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
Normal file
|
|
@ -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<string[]> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue