fix(uri): include index in custom qmd links

This commit is contained in:
cocoon 2026-04-07 23:26:19 +08:00
parent c2f3a40372
commit 8404cc3bb1
4 changed files with 78 additions and 7 deletions

View File

@ -110,6 +110,7 @@ enableProductionMode();
let store: ReturnType<typeof createStore> | null = null; let store: ReturnType<typeof createStore> | null = null;
let storeDbPathOverride: string | undefined; let storeDbPathOverride: string | undefined;
let currentIndexName = "index";
function getStore(): ReturnType<typeof createStore> { function getStore(): ReturnType<typeof createStore> {
if (!store) { if (!store) {
@ -160,6 +161,10 @@ function getDbPath(): string {
return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath(); return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath();
} }
function getActiveIndexName(): string {
return currentIndexName;
}
function setIndexName(name: string | null): void { function setIndexName(name: string | null): void {
let normalizedName = name; let normalizedName = name;
// Normalize relative paths to prevent malformed database paths // Normalize relative paths to prevent malformed database paths
@ -170,6 +175,7 @@ function setIndexName(name: string | null): void {
// Replace path separators with underscores to create a valid filename // Replace path separators with underscores to create a valid filename
normalizedName = absolutePath.replace(/\//g, '_').replace(/^_/, ''); normalizedName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
} }
currentIndexName = normalizedName || "index";
storeDbPathOverride = normalizedName ? getDefaultDbPath(normalizedName) : undefined; storeDbPathOverride = normalizedName ? getDefaultDbPath(normalizedName) : undefined;
// Reset open handle so next use opens the new index // Reset open handle so next use opens the new index
closeDb(); closeDb();
@ -818,8 +824,6 @@ function contextRemove(pathArg: string): void {
} }
function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void { function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void {
const db = getDb();
// Parse :linenum suffix from filename (e.g., "file.md:100") // Parse :linenum suffix from filename (e.g., "file.md:100")
let inputPath = filename; let inputPath = filename;
const colonMatch = inputPath.match(/:(\d+)$/); const colonMatch = inputPath.match(/:(\d+)$/);
@ -831,6 +835,14 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
} }
} }
const parsedIndexPath = isVirtualPath(inputPath) ? parseVirtualPath(inputPath) : null;
if (parsedIndexPath?.indexName) {
setIndexName(parsedIndexPath.indexName);
setConfigIndexName(parsedIndexPath.indexName);
}
const db = getDb();
// Handle docid lookup (#abc123, abc123, "#abc123", "abc123", etc.) // Handle docid lookup (#abc123, abc123, "#abc123", "abc123", etc.)
if (isDocid(inputPath)) { if (isDocid(inputPath)) {
const docidMatch = findDocumentByDocid(db, inputPath); const docidMatch = findDocumentByDocid(db, inputPath);
@ -842,7 +854,6 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
process.exit(1); process.exit(1);
} }
} }
let doc: { collectionName: string; path: string; body: string } | null = null; let doc: { collectionName: string; path: string; body: string } | null = null;
let virtualPath: string; let virtualPath: string;
@ -1925,7 +1936,18 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
} }
// Helper to create qmd:// URI from displayPath // Helper to create qmd:// URI from displayPath
const toQmdPath = (displayPath: string) => `qmd://${displayPath}`; const toQmdPath = (displayPath: string) => {
const [collectionName, ...segments] = displayPath.split("/");
if (!collectionName || segments.length === 0) {
return `qmd://${displayPath}`;
}
const indexName = getActiveIndexName();
return buildVirtualPath(
collectionName,
segments.join("/"),
indexName === "index" ? undefined : indexName,
);
};
if (opts.format === "json") { if (opts.format === "json") {
// JSON output for LLM consumption // JSON output for LLM consumption

View File

@ -566,6 +566,7 @@ export function getRealPath(path: string): string {
export type VirtualPath = { export type VirtualPath = {
collectionName: string; collectionName: string;
path: string; // relative path within collection path: string; // relative path within collection
indexName?: string;
}; };
/** /**
@ -609,22 +610,26 @@ export function normalizeVirtualPath(input: string): string {
export function parseVirtualPath(virtualPath: string): VirtualPath | null { export function parseVirtualPath(virtualPath: string): VirtualPath | null {
// Normalize the path first // Normalize the path first
const normalized = normalizeVirtualPath(virtualPath); const normalized = normalizeVirtualPath(virtualPath);
const [pathPart = normalized, queryString = ""] = normalized.split("?");
// Match: qmd://collection-name[/optional-path] // Match: qmd://collection-name[/optional-path]
// Allows: qmd://name, qmd://name/, qmd://name/path // Allows: qmd://name, qmd://name/, qmd://name/path
const match = normalized.match(/^qmd:\/\/([^\/]+)\/?(.*)$/); const match = pathPart.match(/^qmd:\/\/([^\/]+)\/?(.*)$/);
if (!match?.[1]) return null; if (!match?.[1]) return null;
const indexName = new URLSearchParams(queryString).get("index")?.trim() || undefined;
return { return {
collectionName: match[1], collectionName: match[1],
path: match[2] ?? '', // Empty string for collection root path: match[2] ?? '', // Empty string for collection root
...(indexName ? { indexName } : {}),
}; };
} }
/** /**
* Build a virtual path from collection name and relative path. * Build a virtual path from collection name and relative path.
*/ */
export function buildVirtualPath(collectionName: string, path: string): string { export function buildVirtualPath(collectionName: string, path: string, indexName?: string): string {
return `qmd://${collectionName}/${path}`; const base = `qmd://${collectionName}/${path}`;
return indexName ? `${base}?index=${encodeURIComponent(indexName)}` : base;
} }
/** /**

View File

@ -1130,6 +1130,42 @@ describe("search output formats", () => {
expect(result.file).not.toMatch(/^\/home\//); expect(result.file).not.toMatch(/^\/home\//);
}); });
test("custom-index search links include ?index= and can be passed back to qmd get", async () => {
const env = await createIsolatedTestEnv("custom-index-links");
const customColl = "fixtures-alt";
const customIndex = "release-notes";
const customCacheDir = join(testDir, `cache-${Date.now()}-${Math.random().toString(16).slice(2)}`);
await mkdir(customCacheDir, { recursive: true });
const sharedEnv = {
INDEX_PATH: "",
XDG_CACHE_HOME: customCacheDir,
};
const addResult = await runQmd(
["--index", customIndex, "collection", "add", fixturesDir, "--name", customColl],
{ dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv }
);
expect(addResult.exitCode).toBe(0);
const searchResult = await runQmd(
["--index", customIndex, "search", "test", "--json", "-n", "1"],
{ dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv }
);
expect(searchResult.exitCode).toBe(0);
const results = JSON.parse(searchResult.stdout);
const file = results[0]?.file;
expect(file).toMatch(new RegExp(`^qmd://${customColl}/.+\\?index=${customIndex}$`));
const getResult = await runQmd(
["get", file, "-l", "2"],
{ dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv }
);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim().length).toBeGreaterThan(0);
});
test("search --files includes qmd:// path, docid, and context", async () => { test("search --files includes qmd:// path, docid, and context", async () => {
const { stdout, exitCode } = await runQmd(["search", "test", "--files", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); const { stdout, exitCode } = await runQmd(["search", "test", "--files", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0); expect(exitCode).toBe(0);

View File

@ -3154,6 +3154,14 @@ describe("parseVirtualPath", () => {
}); });
}); });
test("parses qmd:// paths with index query parameters", () => {
expect(parseVirtualPath("qmd://collection/path.md?index=docs-v2")).toEqual({
collectionName: "collection",
path: "path.md",
indexName: "docs-v2",
});
});
test("returns null for non-virtual paths", () => { test("returns null for non-virtual paths", () => {
expect(parseVirtualPath("/absolute/path.md")).toBe(null); expect(parseVirtualPath("/absolute/path.md")).toBe(null);
expect(parseVirtualPath("~/home/path.md")).toBe(null); expect(parseVirtualPath("~/home/path.md")).toBe(null);