Pārlūkot izejas kodu

Merge pull request #304 from sebkouba/feature/collection-ignore

feat: add ignore patterns for collections
Tobias Lütke 2 mēneši atpakaļ
vecāks
revīzija
a28163fb2c
3 mainītis faili ar 126 papildinājumiem un 4 dzēšanām
  1. 1 0
      src/collections.ts
  2. 12 4
      src/qmd.ts
  3. 113 0
      test/cli.test.ts

+ 1 - 0
src/collections.ts

@@ -27,6 +27,7 @@ export type ContextMap = Record<string, string>;
 export interface Collection {
   path: string;              // Absolute path to index
   pattern: string;           // Glob pattern (e.g., "**/*.md")
+  ignore?: string[];         // Glob patterns to exclude (e.g., ["Sessions/**"])
   context?: ContextMap;      // Optional context definitions
   update?: string;           // Optional bash command to run during qmd update
   includeByDefault?: boolean; // Include in queries by default (default: true)

+ 12 - 4
src/qmd.ts

@@ -522,7 +522,7 @@ async function updateCollections(): Promise<void> {
       }
     }
 
-    await indexFiles(col.pwd, col.glob_pattern, col.name, true);
+    await indexFiles(col.pwd, col.glob_pattern, col.name, true, yamlCol?.ignore);
     console.log("");
   }
 
@@ -1306,6 +1306,9 @@ function collectionList(): void {
 
     console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}${excludeTag}`);
     console.log(`  ${c.dim}Pattern:${c.reset}  ${coll.glob_pattern}`);
+    if (yamlColl?.ignore?.length) {
+      console.log(`  ${c.dim}Ignore:${c.reset}   ${yamlColl.ignore.join(', ')}`);
+    }
     console.log(`  ${c.dim}Files:${c.reset}    ${coll.active_count}`);
     console.log(`  ${c.dim}Updated:${c.reset}  ${timeAgo}`);
     console.log();
@@ -1348,7 +1351,8 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P
 
   // Create the collection and index files
   console.log(`Creating collection '${collName}'...`);
-  await indexFiles(pwd, globPattern, collName);
+  const newColl = getCollectionFromYaml(collName);
+  await indexFiles(pwd, globPattern, collName, false, newColl?.ignore);
   console.log(`${c.green}✓${c.reset} Collection '${collName}' created successfully`);
 }
 
@@ -1397,7 +1401,7 @@ function collectionRename(oldName: string, newName: string): void {
   console.log(`  Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
 }
 
-async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false): Promise<void> {
+async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false, ignorePatterns?: string[]): Promise<void> {
   const db = getDb();
   const resolvedPwd = pwd || getPwd();
   const now = new Date().toISOString();
@@ -1414,12 +1418,16 @@ async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, coll
   console.log(`Collection: ${resolvedPwd} (${globPattern})`);
 
   progress.indeterminate();
+  const allIgnore = [
+    ...excludeDirs.map(d => `**/${d}/**`),
+    ...(ignorePatterns || []),
+  ];
   const allFiles: string[] = await fastGlob(globPattern, {
     cwd: resolvedPwd,
     onlyFiles: true,
     followSymbolicLinks: false,
     dot: false,
-    ignore: excludeDirs.map(d => `**/${d}/**`),
+    ignore: allIgnore,
   });
   // Filter hidden files/folders (dot: false handles top-level but not nested)
   const files = allFiles.filter(file => {

+ 113 - 0
test/cli.test.ts

@@ -819,6 +819,119 @@ describe("CLI Collection Commands", () => {
   });
 });
 
+// =============================================================================
+// Collection Ignore Patterns
+// =============================================================================
+
+describe("collection ignore patterns", () => {
+  let localDbPath: string;
+  let localConfigDir: string;
+  let ignoreTestDir: string;
+
+  beforeAll(async () => {
+    const env = await createIsolatedTestEnv("ignore-patterns");
+    localDbPath = env.dbPath;
+    localConfigDir = env.configDir;
+
+    // Create directory structure with subdirectories to ignore
+    ignoreTestDir = join(testDir, "ignore-fixtures");
+    await mkdir(join(ignoreTestDir, "notes"), { recursive: true });
+    await mkdir(join(ignoreTestDir, "sessions"), { recursive: true });
+    await mkdir(join(ignoreTestDir, "sessions", "2026-03"), { recursive: true });
+    await mkdir(join(ignoreTestDir, "archive"), { recursive: true });
+
+    // Files that should be indexed
+    await writeFile(join(ignoreTestDir, "readme.md"), "# Main readme\nThis should be indexed.");
+    await writeFile(join(ignoreTestDir, "notes", "note1.md"), "# Note 1\nThis is a personal note.");
+
+    // Files that should be ignored
+    await writeFile(join(ignoreTestDir, "sessions", "session1.md"), "# Session 1\nThis session should be ignored.");
+    await writeFile(join(ignoreTestDir, "sessions", "2026-03", "session2.md"), "# Session 2\nNested session should also be ignored.");
+    await writeFile(join(ignoreTestDir, "archive", "old.md"), "# Old stuff\nThis archive file should be ignored.");
+  });
+
+  test("ignore patterns exclude matching files from indexing", async () => {
+    // Write YAML config with ignore patterns
+    await writeFile(
+      join(localConfigDir, "index.yml"),
+      `collections:
+  ignoretst:
+    path: ${ignoreTestDir}
+    pattern: "**/*.md"
+    ignore:
+      - "sessions/**"
+      - "archive/**"
+`
+    );
+
+    const { stdout, exitCode } = await runQmd(["update"], {
+      cwd: ignoreTestDir,
+      dbPath: localDbPath,
+      configDir: localConfigDir,
+    });
+    expect(exitCode).toBe(0);
+    // Should index 2 files (readme.md + notes/note1.md), not 5
+    expect(stdout).toContain("2 new");
+  });
+
+  test("ignored files are not searchable", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "session", "-n", "10"], {
+      cwd: ignoreTestDir,
+      dbPath: localDbPath,
+      configDir: localConfigDir,
+    });
+    // Should find no results since sessions/ was ignored
+    if (exitCode === 0) {
+      expect(stdout).not.toContain("session1");
+      expect(stdout).not.toContain("session2");
+    }
+  });
+
+  test("non-ignored files are searchable", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "personal note", "-n", "10"], {
+      cwd: ignoreTestDir,
+      dbPath: localDbPath,
+      configDir: localConfigDir,
+    });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("note1");
+  });
+
+  test("status shows ignore patterns", async () => {
+    const { stdout, exitCode } = await runQmd(["collection", "list"], {
+      cwd: ignoreTestDir,
+      dbPath: localDbPath,
+      configDir: localConfigDir,
+    });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Ignore:");
+    expect(stdout).toContain("sessions/**");
+    expect(stdout).toContain("archive/**");
+  });
+
+  test("collection without ignore indexes all files", async () => {
+    // Create a second collection without ignore
+    const env2 = await createIsolatedTestEnv("no-ignore");
+    await writeFile(
+      join(env2.configDir, "index.yml"),
+      `collections:
+  allfiles:
+    path: ${ignoreTestDir}
+    pattern: "**/*.md"
+`
+    );
+
+    const { stdout, exitCode } = await runQmd(["update"], {
+      cwd: ignoreTestDir,
+      dbPath: env2.dbPath,
+      configDir: env2.configDir,
+    });
+    expect(exitCode).toBe(0);
+    // Should index all 5 files
+    expect(stdout).toContain("5 new");
+  });
+});
+
 // =============================================================================
 // Output Format Tests - qmd:// URIs, context, and docid
 // =============================================================================