feat: add directory-scoped scan with folder picker UI

All three scan commands (elaboration, summarize, enrichment) now show
a searchable folder picker before scanning, allowing users to scope
scans to a specific directory instead of the entire vault. The picker
defaults to the active file's parent folder. Vault root selection
preserves full-vault behavior. Auto-scan/startup and enrichment cache
warming remain vault-wide.

Co-Authored-By: Claude <bot@wafflenet.io>
This commit is contained in:
bot 2026-03-16 12:14:45 -07:00
parent 1e1199cac8
commit aca392addc
8 changed files with 246 additions and 19 deletions

View file

@ -74,4 +74,7 @@ Secondly, we've been neglecting proper git patterns up until now. Now that the c
- Once ready work should be pushed to branch and pr should be made without a human in the loop.
### Next
There's a big gap in our feature set. Both Transcriptions and even *generic URL's* should have the ability to summarize. This would come in the form of "note taking" on the reference. It should be its own command for now, with the same 2 workflows that other commands have available: scan vault, and current note. Please *plan* this out with the team and return to me with your consensus.
There's a big gap in our feature set. Both Transcriptions and even *generic URL's* should have the ability to summarize. This would come in the form of "note taking" on the reference. It should be its own command for now, with the same 2 workflows that other commands have available: scan vault, and current note. Please *plan* this out with the team and return to me with your consensus.
### Next
Scan functionality should be a bit more sophisticated. When choosing a scan command, the user should be presented with a UI that allows them to select the directory they wish to scan, starting with the parent directory they are in. They can choose to scan the vault root, or any subdirectory therein. The scan operation, and subsequent operations after that, should only apply to the chosen (sub)directory. Please plan how to implement this feature.

View file

@ -131,6 +131,23 @@ export class ItemView {
}
}
export class SuggestModal<T = unknown> {
app: unknown;
inputEl: any = { value: '', focus: vi.fn() };
constructor(app: unknown) {
this.app = app;
}
getSuggestions(_query: string): T[] {
return [];
}
renderSuggestion(_item: T, _el: unknown): void {}
onChooseSuggestion(_item: T, _evt: MouseEvent | KeyboardEvent): void {}
open = vi.fn();
close = vi.fn();
onOpen(): void {}
onClose(): void {}
}
export class Setting {
constructor(_containerEl: unknown) {}
setName = vi.fn().mockReturnThis();

View file

@ -1,6 +1,6 @@
import { Plugin, TFile } from 'obsidian';
import { AutoNotesSettings } from '../settings';
import { blockquoteOriginal, NotificationManager, sanitizeAIResponse } from '../shared';
import { blockquoteOriginal, FolderPickerModal, getMarkdownFiles, NotificationManager, sanitizeAIResponse } from '../shared';
import { PlaceholderDetector } from './detector';
import { ProposalStore } from './proposal-store';
import { ProposalGenerator } from './proposer';
@ -36,7 +36,14 @@ export class ElaborationModule {
this.plugin.addCommand({
id: 'auto-notes:scan-vault',
name: 'Scan vault for stub notes',
callback: () => this.scanVault(),
callback: () => {
const defaultPath = this.plugin.app.workspace.getActiveFile()?.parent?.path || '';
new FolderPickerModal(
this.plugin.app,
(folder) => this.scanVault(folder.isRoot() ? undefined : folder.path),
defaultPath
).open();
},
});
this.plugin.addCommand({
@ -85,15 +92,16 @@ export class ElaborationModule {
* 2. Confirmation snackbar user decides whether to generate proposals
* 3. Heavy proposal generation with cancellation support
*/
async scanVault(): Promise<number> {
async scanVault(folderPath?: string): Promise<number> {
// --- Phase 1: Detection (lightweight, local-only) ---
const scanOp = this.notifications.startOperation('Scanning vault', 'vault-scan');
const files = this.plugin.app.vault.getMarkdownFiles();
const scopeLabel = folderPath ? `Scanning ${folderPath}` : 'Scanning vault';
const scanOp = this.notifications.startOperation(scopeLabel, 'vault-scan');
const files = getMarkdownFiles(this.plugin.app, folderPath);
const detected: DetectionResult[] = [];
try {
for (let i = 0; i < files.length; i++) {
scanOp.progress(i + 1, files.length, 'Scanning vault');
scanOp.progress(i + 1, files.length, scopeLabel);
const result = await this.detector.detect(files[i]);
if (result) {
detected.push(result);

View file

@ -1,6 +1,6 @@
import { Plugin, TFile } from 'obsidian';
import { AutoNotesSettings } from '../settings';
import { NotificationManager, parseFrontmatter } from '../shared';
import { FolderPickerModal, getMarkdownFiles, NotificationManager, parseFrontmatter } from '../shared';
import { EnrichmentApplier } from './enrichment-applier';
import { EnrichmentStore } from './enrichment-store';
import { LinkResolver } from './link-resolver';
@ -71,7 +71,14 @@ export class EnrichmentModule {
this.plugin.addCommand({
id: 'auto-notes:scan-vault-enrichment',
name: 'Scan vault for enrichment',
callback: () => this.scanVault(),
callback: () => {
const defaultPath = this.plugin.app.workspace.getActiveFile()?.parent?.path || '';
new FolderPickerModal(
this.plugin.app,
(folder) => this.scanVault(folder.isRoot() ? undefined : folder.path),
defaultPath
).open();
},
});
this.plugin.addCommand({
@ -102,19 +109,20 @@ export class EnrichmentModule {
* 4. Resolve cross-note new-note candidates only topics referenced
* by 2+ notes become new-note link suggestions
*/
async scanVault(): Promise<number> {
async scanVault(folderPath?: string): Promise<number> {
// ── Phase 1: Collect eligible files & warm caches ──
const scopeLabel = folderPath ? `Scanning ${folderPath}` : 'Scanning vault';
const scanOp = this.notifications.startOperation(
'Scanning vault for enrichment',
`${scopeLabel} for enrichment`,
'enrichment-vault-scan'
);
const allFiles = this.plugin.app.vault.getMarkdownFiles();
const allFiles = getMarkdownFiles(this.plugin.app, folderPath);
const eligible: TFile[] = [];
try {
for (let i = 0; i < allFiles.length; i++) {
scanOp.progress(i + 1, allFiles.length, 'Scanning vault');
scanOp.progress(i + 1, allFiles.length, scopeLabel);
if (!this.isExcluded(allFiles[i])) {
eligible.push(allFiles[i]);
}

View file

@ -0,0 +1,129 @@
import { describe, it, expect, vi } from 'vitest';
import { TFolder } from 'obsidian';
import { FolderPickerModal } from './folder-picker-modal';
function makeFolder(path: string): TFolder {
const f = new TFolder();
f.path = path;
f.name = path.split('/').pop() || '';
f.children = [];
return f;
}
function buildFolderTree() {
const root = makeFolder('/');
root.isRoot = () => true;
const notes = makeFolder('notes');
const daily = makeFolder('notes/daily');
const projects = makeFolder('projects');
const templates = makeFolder('templates');
notes.children = [daily];
root.children = [notes, projects, templates];
return { root, notes, daily, projects, templates };
}
function createMockApp(root: TFolder) {
return {
vault: { getRoot: () => root },
workspace: { getActiveFile: () => null },
} as any;
}
describe('FolderPickerModal', () => {
it('getSuggestions returns all folders including root for empty query', () => {
const { root, notes, daily, projects, templates } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
const results = modal.getSuggestions('');
const paths = results.map(f => f.path);
expect(paths).toContain('/');
expect(paths).toContain('notes');
expect(paths).toContain('notes/daily');
expect(paths).toContain('projects');
expect(paths).toContain('templates');
});
it('getSuggestions filters by query but always includes root', () => {
const { root } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
const results = modal.getSuggestions('proj');
const paths = results.map(f => f.path);
expect(paths).toContain('/');
expect(paths).toContain('projects');
expect(paths).not.toContain('notes');
expect(paths).not.toContain('templates');
});
it('getSuggestions is case-insensitive', () => {
const { root } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
const results = modal.getSuggestions('NOTES');
const paths = results.map(f => f.path);
expect(paths).toContain('notes');
expect(paths).toContain('notes/daily');
});
it('onChooseSuggestion calls the callback', () => {
const { root, projects } = buildFolderTree();
const app = createMockApp(root);
const callback = vi.fn();
const modal = new FolderPickerModal(app, callback);
modal.onChooseSuggestion(projects);
expect(callback).toHaveBeenCalledWith(projects);
});
it('onOpen pre-fills inputEl with defaultPath', () => {
const { root } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn(), 'notes/daily');
modal.onOpen();
expect(modal.inputEl.value).toBe('notes/daily');
});
it('onOpen does not set inputEl when no defaultPath', () => {
const { root } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
modal.onOpen();
expect(modal.inputEl.value).toBe('');
});
it('renderSuggestion shows vault root label for root folder', () => {
const { root } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
const el = { createEl: vi.fn() } as any;
modal.renderSuggestion(root, el);
expect(el.createEl).toHaveBeenCalledWith('div', { text: '/ (vault root)' });
});
it('renderSuggestion shows folder path for non-root', () => {
const { root, projects } = buildFolderTree();
const app = createMockApp(root);
const modal = new FolderPickerModal(app, vi.fn());
const el = { createEl: vi.fn() } as any;
modal.renderSuggestion(projects, el);
expect(el.createEl).toHaveBeenCalledWith('div', { text: 'projects' });
});
});

View file

@ -0,0 +1,53 @@
import { App, SuggestModal, TFolder } from 'obsidian';
export class FolderPickerModal extends SuggestModal<TFolder> {
private onChoose: (folder: TFolder) => void;
private defaultPath: string | undefined;
constructor(app: App, onChoose: (folder: TFolder) => void, defaultPath?: string) {
super(app);
this.onChoose = onChoose;
this.defaultPath = defaultPath;
}
onOpen(): void {
if (this.defaultPath) {
this.inputEl.value = this.defaultPath;
// Trigger re-filter by dispatching input event
this.inputEl.dispatchEvent?.(new Event('input'));
}
}
getSuggestions(query: string): TFolder[] {
const folders = this.collectFolders(this.app.vault.getRoot());
const lower = query.toLowerCase();
return folders.filter(
f => f.isRoot() || f.path.toLowerCase().includes(lower)
);
}
renderSuggestion(folder: TFolder, el: HTMLElement): void {
el.createEl('div', {
text: folder.isRoot() ? '/ (vault root)' : folder.path,
});
}
onChooseSuggestion(folder: TFolder): void {
this.onChoose(folder);
}
private collectFolders(root: TFolder): TFolder[] {
const result: TFolder[] = [root];
const queue: TFolder[] = [root];
while (queue.length > 0) {
const current = queue.shift()!;
for (const child of current.children) {
if (child instanceof TFolder) {
result.push(child);
queue.push(child);
}
}
}
return result;
}
}

View file

@ -10,6 +10,7 @@ export {
} from './file-utils';
export { NotificationManager } from './notifications';
export type { OperationHandle } from './notifications';
export { FolderPickerModal } from './folder-picker-modal';
export {
sanitizeUrl,
sanitizePath,

View file

@ -1,6 +1,6 @@
import { Plugin, TFile } from 'obsidian';
import { AutoNotesSettings } from '../settings';
import { NotificationManager } from '../shared';
import { FolderPickerModal, getMarkdownFiles, NotificationManager } from '../shared';
import { OperationHandle } from '../shared/notifications';
import { fetchPageContent } from './content-fetcher';
import { findSummarizeTargets } from './note-scanner';
@ -58,7 +58,14 @@ export class SummarizeModule {
this.plugin.addCommand({
id: 'auto-notes:scan-vault-summarize',
name: 'Scan vault for notes to summarize',
callback: () => this.scanVault(),
callback: () => {
const defaultPath = this.plugin.app.workspace.getActiveFile()?.parent?.path || '';
new FolderPickerModal(
this.plugin.app,
(folder) => this.scanVault(folder.isRoot() ? undefined : folder.path),
defaultPath
).open();
},
});
}
@ -292,22 +299,23 @@ export class SummarizeModule {
return `${folderPrefix}${safeName}.md`;
}
private async scanVault(): Promise<void> {
private async scanVault(folderPath?: string): Promise<void> {
const settings = this.getSettings().summarize;
// Phase 1: Scan for files with targets
const scopeLabel = folderPath ? `Scanning ${folderPath}` : 'Scanning vault';
const scanOp = this.notifications.startOperation(
'Scanning vault for summarizable content',
`${scopeLabel} for summarizable content`,
'summarize-vault-scan'
);
const allFiles = this.plugin.app.vault.getMarkdownFiles();
const allFiles = getMarkdownFiles(this.plugin.app, folderPath);
const filesWithTargets: Array<{ file: TFile; targets: SummarizeTarget[] }> = [];
try {
for (let i = 0; i < allFiles.length; i++) {
if (scanOp.cancelled) break;
scanOp.progress(i + 1, allFiles.length, 'Scanning vault');
scanOp.progress(i + 1, allFiles.length, scopeLabel);
const file = allFiles[i];
if (this.isExcluded(file)) continue;