diff --git a/src/content-selector.ts b/src/content-selector.ts new file mode 100644 index 0000000..604276a --- /dev/null +++ b/src/content-selector.ts @@ -0,0 +1,400 @@ +import { App, CachedMetadata, HeadingCache, SectionCache, TFile } from 'obsidian'; +import { estimateTokens } from './history'; + +// === Types === + +export interface HeadingNode { + heading: string; + level: number; + position: { start: number; end: number }; + children: HeadingNode[]; + selected: boolean; +} + +export interface FileSelection { + file: TFile; + selected: boolean; + expanded: boolean; + headings: HeadingNode[]; + blocks: BlockNode[]; + fullContent: string; +} + +export interface BlockNode { + id: string; + position: { start: number; end: number }; + selected: boolean; +} + +export interface ContentReference { + noteName: string; + headingPath: string[]; + blockId: string | null; +} + +// === Reference Syntax Parser === + +/** + * Parse a reference string like "NoteName#Heading1#Heading2" or "NoteName^blockid" + */ +export function parseReference(ref: string): ContentReference { + const blockMatch = ref.match(/^(.+?)\^(\w+)$/); + if (blockMatch) { + return { + noteName: blockMatch[1] || '', + headingPath: [], + blockId: blockMatch[2] || null, + }; + } + + const parts = ref.split('#'); + return { + noteName: parts[0] || '', + headingPath: parts.slice(1).filter(p => p), + blockId: null, + }; +} + +/** + * Format a reference back to string + */ +export function formatReference(ref: ContentReference): string { + if (ref.blockId) { + return `${ref.noteName}^${ref.blockId}`; + } + if (ref.headingPath.length > 0) { + return `${ref.noteName}#${ref.headingPath.join('#')}`; + } + return ref.noteName; +} + +// === Content Selector === + +export class ContentSelector { + private app: App; + + constructor(app: App) { + this.app = app; + } + + /** + * Build a FileSelection with heading structure for a file + */ + async buildFileSelection(file: TFile): Promise { + const content = await this.app.vault.read(file); + const cache = this.app.metadataCache.getFileCache(file); + + const headings = this.buildHeadingTree(cache, content); + const blocks = this.extractBlocks(cache, content); + + return { + file, + selected: true, + expanded: false, + headings, + blocks, + fullContent: content, + }; + } + + /** + * Build hierarchical heading tree from cache + */ + private buildHeadingTree(cache: CachedMetadata | null, content: string): HeadingNode[] { + if (!cache?.headings || cache.headings.length === 0) { + return []; + } + + const lines = content.split('\n'); + const headings = cache.headings; + const root: HeadingNode[] = []; + const stack: { node: HeadingNode; level: number }[] = []; + + for (let i = 0; i < headings.length; i++) { + const h = headings[i]; + if (!h) continue; + + // Calculate end position (start of next heading or end of file) + const nextHeading = headings[i + 1]; + const endLine = nextHeading + ? nextHeading.position.start.line - 1 + : lines.length - 1; + + const node: HeadingNode = { + heading: h.heading, + level: h.level, + position: { + start: h.position.start.offset, + end: this.getLineEndOffset(lines, endLine), + }, + children: [], + selected: true, + }; + + // Find parent based on level + while (stack.length > 0) { + const last = stack[stack.length - 1]; + if (last && last.level < h.level) { + break; + } + stack.pop(); + } + + if (stack.length === 0) { + root.push(node); + } else { + const parent = stack[stack.length - 1]; + if (parent) { + parent.node.children.push(node); + } + } + + stack.push({ node, level: h.level }); + } + + return root; + } + + private getLineEndOffset(lines: string[], lineIndex: number): number { + let offset = 0; + for (let i = 0; i <= lineIndex && i < lines.length; i++) { + const line = lines[i]; + offset += (line?.length || 0) + 1; // +1 for newline + } + return offset; + } + + /** + * Extract block references from cache + */ + private extractBlocks(cache: CachedMetadata | null, content: string): BlockNode[] { + if (!cache?.sections) { + return []; + } + + const blocks: BlockNode[] = []; + + for (const section of cache.sections) { + if (section.id) { + blocks.push({ + id: section.id, + position: { + start: section.position.start.offset, + end: section.position.end.offset, + }, + selected: true, + }); + } + } + + return blocks; + } + + /** + * Extract content based on selection + */ + extractSelectedContent(selection: FileSelection): string { + if (!selection.selected) { + return ''; + } + + // If no headings or all headings selected, return full content + if (selection.headings.length === 0 || this.allHeadingsSelected(selection.headings)) { + return selection.fullContent; + } + + // Build content from selected headings + const parts: string[] = []; + this.collectSelectedContent(selection.headings, selection.fullContent, parts); + + return parts.join('\n\n'); + } + + private allHeadingsSelected(headings: HeadingNode[]): boolean { + for (const h of headings) { + if (!h.selected) return false; + if (!this.allHeadingsSelected(h.children)) return false; + } + return true; + } + + private collectSelectedContent(headings: HeadingNode[], fullContent: string, parts: string[]): void { + for (const h of headings) { + if (h.selected) { + // Include this heading and all its content + const content = fullContent.substring(h.position.start, h.position.end).trim(); + if (content) { + parts.push(content); + } + } else { + // Not selected, but check children + this.collectSelectedContent(h.children, fullContent, parts); + } + } + } + + /** + * Extract content for a specific heading path + */ + extractByHeadingPath(content: string, cache: CachedMetadata | null, headingPath: string[]): string | null { + if (!cache?.headings || headingPath.length === 0) { + return null; + } + + const lines = content.split('\n'); + let currentLevel = 0; + let pathIndex = 0; + let startOffset: number | null = null; + let endOffset: number | null = null; + + for (let i = 0; i < cache.headings.length; i++) { + const h = cache.headings[i]; + if (!h) continue; + + const targetHeading = headingPath[pathIndex]; + + if (startOffset !== null) { + // We're inside the target heading, look for end + if (h.level <= currentLevel) { + // Found a heading at same or higher level, this ends our section + endOffset = h.position.start.offset; + break; + } + } else { + // Looking for the target heading + if (h.heading.toLowerCase() === targetHeading?.toLowerCase()) { + if (pathIndex === headingPath.length - 1) { + // Found the final heading in the path + startOffset = h.position.start.offset; + currentLevel = h.level; + } else { + // Found an intermediate heading, continue looking + pathIndex++; + } + } + } + } + + if (startOffset === null) { + return null; + } + + if (endOffset === null) { + endOffset = content.length; + } + + return content.substring(startOffset, endOffset).trim(); + } + + /** + * Extract content for a specific block ID + */ + extractByBlockId(content: string, cache: CachedMetadata | null, blockId: string): string | null { + if (!cache?.sections) { + return null; + } + + for (const section of cache.sections) { + if (section.id === blockId) { + return content.substring( + section.position.start.offset, + section.position.end.offset + ).trim(); + } + } + + return null; + } + + /** + * Resolve a reference to content + */ + async resolveReference(ref: ContentReference): Promise { + // Find the file + const file = this.app.metadataCache.getFirstLinkpathDest(ref.noteName, ''); + if (!file) { + return null; + } + + const content = await this.app.vault.read(file); + const cache = this.app.metadataCache.getFileCache(file); + + if (ref.blockId) { + return this.extractByBlockId(content, cache, ref.blockId); + } + + if (ref.headingPath.length > 0) { + return this.extractByHeadingPath(content, cache, ref.headingPath); + } + + return content; + } + + /** + * Calculate token estimate for a selection + */ + estimateSelectionTokens(selection: FileSelection): number { + const content = this.extractSelectedContent(selection); + return estimateTokens(content); + } + + /** + * Toggle heading selection (including children) + */ + toggleHeading(heading: HeadingNode, selected: boolean): void { + heading.selected = selected; + for (const child of heading.children) { + this.toggleHeading(child, selected); + } + } + + /** + * Update parent selection based on children + */ + updateParentSelection(headings: HeadingNode[]): void { + for (const h of headings) { + if (h.children.length > 0) { + this.updateParentSelection(h.children); + // Parent is selected if any child is selected + h.selected = h.children.some(c => c.selected); + } + } + } +} + +// === Heading Flattening for Display === + +export interface FlatHeading { + heading: HeadingNode; + depth: number; + path: string[]; +} + +export function flattenHeadings(headings: HeadingNode[], depth = 0, path: string[] = []): FlatHeading[] { + const result: FlatHeading[] = []; + + for (const h of headings) { + const currentPath = [...path, h.heading]; + result.push({ heading: h, depth, path: currentPath }); + result.push(...flattenHeadings(h.children, depth + 1, currentPath)); + } + + return result; +} + +// === Token Counter === + +export function countSelectionTokens(selections: FileSelection[]): number { + let total = 0; + const selector = new ContentSelector(null as unknown as App); // We only need extraction logic + + for (const sel of selections) { + if (sel.selected) { + // This is a simplified count - in real usage we'd use the actual selector + const content = sel.fullContent; + total += estimateTokens(content); + } + } + + return total; +} diff --git a/src/file-selector-modal.ts b/src/file-selector-modal.ts new file mode 100644 index 0000000..b0fb7a0 --- /dev/null +++ b/src/file-selector-modal.ts @@ -0,0 +1,311 @@ +import { App, Modal, TFile, TFolder } from 'obsidian'; +import ClaudeContextPlugin from './main'; +import { + ContentSelector, + FileSelection, + HeadingNode, + flattenHeadings, +} from './content-selector'; +import { estimateTokens } from './history'; + +export interface FileSelectionResult { + selections: FileSelection[]; + totalTokens: number; +} + +export class FileSelectorModal extends Modal { + plugin: ClaudeContextPlugin; + selector: ContentSelector; + selections: FileSelection[] = []; + onConfirm: (result: FileSelectionResult) => void; + + // UI elements for live update + tokenDisplay: HTMLElement | null = null; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + onConfirm: (result: FileSelectionResult) => void + ) { + super(app); + this.plugin = plugin; + this.selector = new ContentSelector(app); + this.onConfirm = onConfirm; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-file-selector'); + contentEl.style.minWidth = '500px'; + + contentEl.createEl('h2', { text: 'Select Context Content' }); + + // Token counter + const tokenContainer = contentEl.createDiv({ cls: 'token-counter' }); + tokenContainer.style.padding = '10px'; + tokenContainer.style.backgroundColor = 'var(--background-secondary)'; + tokenContainer.style.borderRadius = '4px'; + tokenContainer.style.marginBottom = '15px'; + tokenContainer.style.display = 'flex'; + tokenContainer.style.justifyContent = 'space-between'; + tokenContainer.style.alignItems = 'center'; + + this.tokenDisplay = tokenContainer.createEl('span', { text: 'Calculating...' }); + + const selectAllContainer = tokenContainer.createDiv(); + const selectAllBtn = selectAllContainer.createEl('button', { text: 'Select All' }); + selectAllBtn.style.marginRight = '8px'; + selectAllBtn.addEventListener('click', () => this.selectAll(true)); + + const deselectAllBtn = selectAllContainer.createEl('button', { text: 'Deselect All' }); + deselectAllBtn.addEventListener('click', () => this.selectAll(false)); + + // Load files + await this.loadFiles(); + + // File list + const listContainer = contentEl.createDiv({ cls: 'file-list' }); + listContainer.style.maxHeight = '400px'; + listContainer.style.overflow = 'auto'; + listContainer.style.border = '1px solid var(--background-modifier-border)'; + listContainer.style.borderRadius = '4px'; + + this.renderFileList(listContainer); + this.updateTokenCount(); + + // Buttons + const buttonContainer = contentEl.createDiv({ cls: 'button-container' }); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '15px'; + + const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const confirmBtn = buttonContainer.createEl('button', { text: 'Use Selection', cls: 'mod-cta' }); + confirmBtn.addEventListener('click', () => { + this.onConfirm({ + selections: this.selections, + totalTokens: this.calculateTotalTokens(), + }); + this.close(); + }); + } + + private async loadFiles() { + const folder = this.app.vault.getAbstractFileByPath(this.plugin.settings.contextFolder); + + if (!folder || !(folder instanceof TFolder)) { + return; + } + + const excludedFiles = this.plugin.settings.excludedFiles.map(f => f.toLowerCase()); + + const files = folder.children + .filter((f): f is TFile => + f instanceof TFile && + f.extension === 'md' && + !excludedFiles.includes(f.name.toLowerCase()) + ) + .sort((a, b) => { + if (a.basename === 'VAULT') return -1; + if (b.basename === 'VAULT') return 1; + return a.basename.localeCompare(b.basename); + }); + + for (const file of files) { + const selection = await this.selector.buildFileSelection(file); + this.selections.push(selection); + } + } + + private renderFileList(container: HTMLElement) { + container.empty(); + + for (const selection of this.selections) { + this.renderFileRow(container, selection); + } + } + + private renderFileRow(container: HTMLElement, selection: FileSelection) { + const row = container.createDiv({ cls: 'file-row' }); + row.style.borderBottom = '1px solid var(--background-modifier-border)'; + + // File header + const header = row.createDiv({ cls: 'file-header' }); + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.padding = '8px 12px'; + header.style.gap = '8px'; + header.style.cursor = 'pointer'; + + // Expand/collapse icon + const expandIcon = header.createEl('span', { + text: selection.headings.length > 0 ? (selection.expanded ? '▼' : '▶') : '•' + }); + expandIcon.style.width = '16px'; + expandIcon.style.fontSize = '10px'; + + // File checkbox + const checkbox = header.createEl('input', { type: 'checkbox' }); + checkbox.checked = selection.selected; + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + selection.selected = checkbox.checked; + // Toggle all headings + for (const h of selection.headings) { + this.selector.toggleHeading(h, selection.selected); + } + this.renderFileList(container.parentElement as HTMLElement); + this.updateTokenCount(); + }); + + // File name + const name = header.createEl('span', { text: selection.file.name }); + name.style.flex = '1'; + name.style.fontWeight = '500'; + + // Token estimate for this file + const tokens = this.selector.estimateSelectionTokens(selection); + const tokenBadge = header.createEl('span', { text: `~${tokens.toLocaleString()} tokens` }); + tokenBadge.style.fontSize = '11px'; + tokenBadge.style.color = 'var(--text-muted)'; + + // Heading count + if (selection.headings.length > 0) { + const headingCount = header.createEl('span', { + text: `${this.countSelectedHeadings(selection.headings)}/${this.countTotalHeadings(selection.headings)} sections` + }); + headingCount.style.fontSize = '11px'; + headingCount.style.color = 'var(--text-muted)'; + headingCount.style.marginLeft = '8px'; + } + + // Click to expand + if (selection.headings.length > 0) { + header.addEventListener('click', (e) => { + if ((e.target as HTMLElement).tagName !== 'INPUT') { + selection.expanded = !selection.expanded; + this.renderFileList(container.parentElement as HTMLElement); + } + }); + } + + // Headings (if expanded) + if (selection.expanded && selection.headings.length > 0) { + const headingsContainer = row.createDiv({ cls: 'headings-container' }); + headingsContainer.style.backgroundColor = 'var(--background-secondary)'; + headingsContainer.style.paddingLeft = '20px'; + + const flatHeadings = flattenHeadings(selection.headings); + + for (const { heading, depth } of flatHeadings) { + this.renderHeadingRow(headingsContainer, heading, depth, selection, container.parentElement as HTMLElement); + } + } + } + + private renderHeadingRow( + container: HTMLElement, + heading: HeadingNode, + depth: number, + selection: FileSelection, + listContainer: HTMLElement + ) { + const row = container.createDiv({ cls: 'heading-row' }); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.padding = '4px 12px'; + row.style.paddingLeft = `${12 + depth * 20}px`; + row.style.gap = '8px'; + + // Checkbox + const checkbox = row.createEl('input', { type: 'checkbox' }); + checkbox.checked = heading.selected; + checkbox.addEventListener('change', () => { + this.selector.toggleHeading(heading, checkbox.checked); + // Update file selection based on headings + selection.selected = selection.headings.some(h => this.hasSelectedHeading(h)); + this.renderFileList(listContainer); + this.updateTokenCount(); + }); + + // Heading level indicator + const level = row.createEl('span', { text: `H${heading.level}` }); + level.style.fontSize = '10px'; + level.style.color = 'var(--text-muted)'; + level.style.width = '20px'; + + // Heading text + const text = row.createEl('span', { text: heading.heading }); + text.style.flex = '1'; + + // Content length + const length = heading.position.end - heading.position.start; + const chars = row.createEl('span', { text: `${length.toLocaleString()} chars` }); + chars.style.fontSize = '11px'; + chars.style.color = 'var(--text-muted)'; + } + + private hasSelectedHeading(heading: HeadingNode): boolean { + if (heading.selected) return true; + return heading.children.some(c => this.hasSelectedHeading(c)); + } + + private countSelectedHeadings(headings: HeadingNode[]): number { + let count = 0; + for (const h of headings) { + if (h.selected) count++; + count += this.countSelectedHeadings(h.children); + } + return count; + } + + private countTotalHeadings(headings: HeadingNode[]): number { + let count = headings.length; + for (const h of headings) { + count += this.countTotalHeadings(h.children); + } + return count; + } + + private selectAll(selected: boolean) { + for (const selection of this.selections) { + selection.selected = selected; + for (const h of selection.headings) { + this.selector.toggleHeading(h, selected); + } + } + + const container = this.contentEl.querySelector('.file-list') as HTMLElement; + if (container) { + this.renderFileList(container); + } + this.updateTokenCount(); + } + + private calculateTotalTokens(): number { + let total = 0; + for (const selection of this.selections) { + if (selection.selected) { + total += this.selector.estimateSelectionTokens(selection); + } + } + return total; + } + + private updateTokenCount() { + if (this.tokenDisplay) { + const tokens = this.calculateTotalTokens(); + const fileCount = this.selections.filter(s => s.selected).length; + this.tokenDisplay.setText(`${fileCount} files selected · ~${tokens.toLocaleString()} tokens`); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/generator.ts b/src/generator.ts index 7cbd883..a579262 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -342,8 +342,13 @@ export class ContextGeneratorModal extends Modal { }); templateInfo.style.marginTop = '5px'; - // Copy with context button - new Setting(contentEl) + // Copy buttons + const copyButtonContainer = contentEl.createDiv(); + copyButtonContainer.style.display = 'flex'; + copyButtonContainer.style.gap = '10px'; + copyButtonContainer.style.marginTop = '10px'; + + new Setting(copyButtonContainer) .addButton(btn => btn .setButtonText('Copy Context Now') .onClick(async () => { @@ -362,6 +367,12 @@ export class ContextGeneratorModal extends Modal { // Copy context with selected template await this.plugin.copyContextToClipboard(false, this.temporaryFreetext, this.selectedTemplateId); this.close(); + })) + .addButton(btn => btn + .setButtonText('Select Sections...') + .onClick(() => { + this.close(); + this.plugin.openFileSelector(); })); // === GENERATE BUTTON === diff --git a/src/main.ts b/src/main.ts index e6ae27b..172e047 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { SourceRegistry, formatSourceOutput } from './sources'; import { TemplateEngine } from './templates'; import { HistoryManager, HistoryMetadata } from './history'; import { HistoryModal } from './history-modal'; +import { ContentSelector, FileSelection } from './content-selector'; +import { FileSelectorModal, FileSelectionResult } from './file-selector-modal'; export default class ClaudeContextPlugin extends Plugin { settings: ClaudeContextSettings; @@ -44,9 +46,21 @@ export default class ClaudeContextPlugin extends Plugin { callback: () => this.openHistory() }); + this.addCommand({ + id: 'copy-context-selective', + name: 'Copy context (select sections)', + callback: () => this.openFileSelector() + }); + this.addSettingTab(new ClaudeContextSettingTab(this.app, this)); } + openFileSelector() { + new FileSelectorModal(this.app, this, async (result) => { + await this.copyContextWithSelections(result.selections); + }).open(); + } + async loadSettings() { const loaded = await this.loadData(); this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded); @@ -232,4 +246,132 @@ export default class ClaudeContextPlugin extends Plugin { } new Notice(message); } + + async copyContextWithSelections( + selections: FileSelection[], + templateId?: string | null + ) { + const selector = new ContentSelector(this.app); + + // Filter to selected files only + const selectedFiles = selections.filter(s => s.selected); + + if (selectedFiles.length === 0) { + new Notice('No files selected'); + return; + } + + // Resolve additional sources + const registry = new SourceRegistry(); + const enabledSources = this.settings.sources.filter(s => s.enabled); + const resolvedSources = await registry.resolveAll(enabledSources); + + // Check for source errors + const errors = resolvedSources.filter(r => r.error); + if (errors.length > 0) { + const errorNames = errors.map(e => e.source.name).join(', '); + new Notice(`Some sources failed: ${errorNames}`, 5000); + } + + // Separate prefix and suffix sources + const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); + const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); + + // Build output parts + const outputParts: string[] = []; + + // Add prefix sources + for (const resolved of prefixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) { + outputParts.push(formatted); + } + } + + // Add selected vault content + const vaultParts: string[] = []; + for (const selection of selectedFiles) { + const content = selector.extractSelectedContent(selection); + if (content) { + if (this.settings.includeFilenames) { + // Include heading info if partial selection + const isPartial = !this.isFullFileSelected(selection); + const suffix = isPartial ? ' (partial)' : ''; + vaultParts.push(`# === ${selection.file.name}${suffix} ===\n\n${content}`); + } else { + vaultParts.push(content); + } + } + } + outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); + + // Add suffix sources + for (const resolved of suffixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) { + outputParts.push(formatted); + } + } + + let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`); + const sourceCount = prefixSources.length + suffixSources.length; + const fileCount = selectedFiles.length; + const totalCount = fileCount + sourceCount; + + // Apply template if specified + const effectiveTemplateId = templateId !== undefined ? templateId : this.settings.defaultTemplateId; + let templateName: string | null = null; + + if (effectiveTemplateId) { + const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId); + if (template) { + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = engine.processTemplate(template.content, context); + templateName = template.name; + } + } + + // Prepare history metadata + const historyMetadata: Omit = { + templateId: effectiveTemplateId || null, + templateName, + includedFiles: selectedFiles.map(s => { + const isPartial = !this.isFullFileSelected(s); + return isPartial ? `${s.file.path} (partial)` : s.file.path; + }), + includedSources: [ + ...prefixSources.map(r => r.source.name), + ...suffixSources.map(r => r.source.name), + ], + activeNote: null, + }; + + // Copy and save + const copyAndSave = async () => { + await navigator.clipboard.writeText(combined); + this.showCopyNotice(fileCount, sourceCount, templateName); + await this.historyManager.saveEntry(combined, historyMetadata); + }; + + if (this.settings.showPreview) { + new PreviewModal(this.app, combined, totalCount, copyAndSave).open(); + } else { + await copyAndSave(); + } + } + + private isFullFileSelected(selection: FileSelection): boolean { + if (selection.headings.length === 0) return true; + + const checkAll = (headings: import('./content-selector').HeadingNode[]): boolean => { + for (const h of headings) { + if (!h.selected) return false; + if (!checkAll(h.children)) return false; + } + return true; + }; + + return checkAll(selection.headings); + } }