feat: add context snapshots for saving and replaying context recipes
Snapshots capture the file list, template, and formatting config from a history entry so the context can be regenerated from current vault state. Stored as JSON in .context-snapshots/ with no auto-cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
06a228847f
commit
40ce34c9b4
5 changed files with 537 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ import {
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
DiffResult,
|
DiffResult,
|
||||||
} from './history';
|
} from './history';
|
||||||
|
import { SaveSnapshotModal } from './snapshot-modal';
|
||||||
|
|
||||||
export class HistoryModal extends Modal {
|
export class HistoryModal extends Modal {
|
||||||
plugin: PromptfirePlugin;
|
plugin: PromptfirePlugin;
|
||||||
|
|
@ -324,6 +325,11 @@ class HistoryDetailModal extends Modal {
|
||||||
new Notice('Copied to clipboard');
|
new Notice('Copied to clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const snapshotBtn = buttonContainer.createEl('button', { text: 'Save as Snapshot' });
|
||||||
|
snapshotBtn.addEventListener('click', () => {
|
||||||
|
new SaveSnapshotModal(this.app, this.plugin, this.entry).open();
|
||||||
|
});
|
||||||
|
|
||||||
const closeBtn = buttonContainer.createEl('button', { text: 'Close' });
|
const closeBtn = buttonContainer.createEl('button', { text: 'Close' });
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
this.onUpdate();
|
this.onUpdate();
|
||||||
|
|
|
||||||
149
src/main.ts
149
src/main.ts
|
|
@ -6,6 +6,8 @@ import { SourceRegistry, formatSourceOutput } from './sources';
|
||||||
import { TemplateEngine } from './templates';
|
import { TemplateEngine } from './templates';
|
||||||
import { HistoryManager, HistoryMetadata } from './history';
|
import { HistoryManager, HistoryMetadata } from './history';
|
||||||
import { HistoryModal } from './history-modal';
|
import { HistoryModal } from './history-modal';
|
||||||
|
import { SnapshotManager, ContextSnapshot } from './snapshots';
|
||||||
|
import { SnapshotListModal } from './snapshot-modal';
|
||||||
import { ContentSelector, FileSelection } from './content-selector';
|
import { ContentSelector, FileSelection } from './content-selector';
|
||||||
import { FileSelectorModal } from './file-selector-modal';
|
import { FileSelectorModal } from './file-selector-modal';
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,10 +32,12 @@ import { SmartContextModal } from './smart-context-modal';
|
||||||
export default class PromptfirePlugin extends Plugin {
|
export default class PromptfirePlugin extends Plugin {
|
||||||
settings: PromptfireSettings;
|
settings: PromptfireSettings;
|
||||||
historyManager: HistoryManager;
|
historyManager: HistoryManager;
|
||||||
|
snapshotManager: SnapshotManager;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
this.historyManager = new HistoryManager(this.app, this.settings.history);
|
this.historyManager = new HistoryManager(this.app, this.settings.history);
|
||||||
|
this.snapshotManager = new SnapshotManager(this.app);
|
||||||
|
|
||||||
// Ribbon icon
|
// Ribbon icon
|
||||||
this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => {
|
this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => {
|
||||||
|
|
@ -64,6 +68,12 @@ export default class PromptfirePlugin extends Plugin {
|
||||||
callback: () => this.openHistory()
|
callback: () => this.openHistory()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'view-snapshots',
|
||||||
|
name: 'View context snapshots',
|
||||||
|
callback: () => this.openSnapshots()
|
||||||
|
});
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'copy-context-selective',
|
id: 'copy-context-selective',
|
||||||
name: 'Copy context (select sections)',
|
name: 'Copy context (select sections)',
|
||||||
|
|
@ -120,6 +130,145 @@ export default class PromptfirePlugin extends Plugin {
|
||||||
new HistoryModal(this.app, this, this.historyManager).open();
|
new HistoryModal(this.app, this, this.historyManager).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSnapshots() {
|
||||||
|
new SnapshotListModal(this.app, this).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaySnapshot(snapshot: ContextSnapshot) {
|
||||||
|
// Resolve additional sources
|
||||||
|
const registry = new SourceRegistry();
|
||||||
|
const enabledSources = this.settings.sources.filter(s => s.enabled);
|
||||||
|
const resolvedSources = await registry.resolveAll(enabledSources);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read files and track missing ones
|
||||||
|
const missingFiles: string[] = [];
|
||||||
|
const vaultParts: string[] = [];
|
||||||
|
|
||||||
|
for (const notePath of snapshot.notePaths) {
|
||||||
|
// Strip (partial) suffix — replay always includes full file
|
||||||
|
const cleanPath = notePath.replace(/ \(partial\)$/, '');
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(cleanPath);
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
if (snapshot.includeFilenames) {
|
||||||
|
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
|
||||||
|
} else {
|
||||||
|
vaultParts.push(content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missingFiles.push(cleanPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vaultParts.length === 0 && !snapshot.activeNotePath) {
|
||||||
|
new Notice('All files in this snapshot are missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vaultParts.length > 0) {
|
||||||
|
outputParts.push(vaultParts.join(`\n\n${snapshot.separator}\n\n`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add active note if set
|
||||||
|
let activeNotePath: string | null = null;
|
||||||
|
if (snapshot.activeNotePath) {
|
||||||
|
const activeFile = this.app.vault.getAbstractFileByPath(snapshot.activeNotePath);
|
||||||
|
if (activeFile instanceof TFile) {
|
||||||
|
activeNotePath = activeFile.path;
|
||||||
|
const content = await this.app.vault.read(activeFile);
|
||||||
|
if (snapshot.includeFilenames) {
|
||||||
|
outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`);
|
||||||
|
} else {
|
||||||
|
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missingFiles.push(snapshot.activeNotePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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${snapshot.separator}\n\n`);
|
||||||
|
|
||||||
|
// Apply template if set
|
||||||
|
let templateName: string | null = null;
|
||||||
|
if (snapshot.templateId) {
|
||||||
|
const template = this.settings.promptTemplates.find(t => t.id === snapshot.templateId);
|
||||||
|
if (template) {
|
||||||
|
const engine = new TemplateEngine(this.app);
|
||||||
|
const context = await engine.buildContext(combined);
|
||||||
|
combined = await engine.processTemplate(template.content, context);
|
||||||
|
templateName = template.name;
|
||||||
|
} else {
|
||||||
|
new Notice('Snapshot template no longer exists, continuing without template', 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about missing files
|
||||||
|
if (missingFiles.length > 0) {
|
||||||
|
new Notice(`${missingFiles.length} file(s) missing, skipped:\n${missingFiles.join('\n')}`, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare history metadata
|
||||||
|
const includedFiles = snapshot.notePaths
|
||||||
|
.map(p => p.replace(/ \(partial\)$/, ''))
|
||||||
|
.filter(p => !missingFiles.includes(p));
|
||||||
|
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
|
||||||
|
templateId: snapshot.templateId,
|
||||||
|
templateName,
|
||||||
|
includedFiles,
|
||||||
|
includedSources: [
|
||||||
|
...prefixSources.map(r => r.source.name),
|
||||||
|
...suffixSources.map(r => r.source.name),
|
||||||
|
],
|
||||||
|
activeNote: activeNotePath,
|
||||||
|
userNote: `Replayed snapshot: ${snapshot.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy and save to history
|
||||||
|
const copyAndSave = async () => {
|
||||||
|
await navigator.clipboard.writeText(combined);
|
||||||
|
|
||||||
|
const fileCount = includedFiles.length + (activeNotePath ? 1 : 0);
|
||||||
|
const sourceCount = prefixSources.length + suffixSources.length;
|
||||||
|
let message = `Replayed snapshot "${snapshot.name}": ${fileCount} files`;
|
||||||
|
if (sourceCount > 0) message += `, ${sourceCount} sources`;
|
||||||
|
if (templateName) message += ` using "${templateName}"`;
|
||||||
|
new Notice(message);
|
||||||
|
|
||||||
|
await this.historyManager.saveEntry(combined, historyMetadata);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.settings.showPreview) {
|
||||||
|
const totalCount = includedFiles.length + (activeNotePath ? 1 : 0) + prefixSources.length + suffixSources.length;
|
||||||
|
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
|
||||||
|
} else {
|
||||||
|
await copyAndSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async runHistoryCleanup(): Promise<number> {
|
async runHistoryCleanup(): Promise<number> {
|
||||||
return await this.historyManager.cleanup();
|
return await this.historyManager.cleanup();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,19 @@ export class PromptfireSettingTab extends PluginSettingTab {
|
||||||
new Notice(`Cleaned up ${deleted} old entries`);
|
new Notice(`Cleaned up ${deleted} old entries`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshots subsection (independent of history toggle)
|
||||||
|
const snapshotsSection = new CollapsibleSection(el, 'snapshots', 'Snapshots', this.plugin);
|
||||||
|
const snc = snapshotsSection.contentEl;
|
||||||
|
|
||||||
|
new Setting(snc)
|
||||||
|
.setName('View snapshots')
|
||||||
|
.setDesc('Browse, replay, or delete saved context snapshots')
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('View Snapshots')
|
||||||
|
.onClick(() => {
|
||||||
|
this.plugin.openSnapshots();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// === TAB: Intelligence ===
|
// === TAB: Intelligence ===
|
||||||
|
|
|
||||||
230
src/snapshot-modal.ts
Normal file
230
src/snapshot-modal.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||||
|
import PromptfirePlugin from './main';
|
||||||
|
import { HistoryEntry, formatRelativeTime } from './history';
|
||||||
|
import { ContextSnapshot, SnapshotManager, generateSnapshotId } from './snapshots';
|
||||||
|
|
||||||
|
export class SaveSnapshotModal extends Modal {
|
||||||
|
private plugin: PromptfirePlugin;
|
||||||
|
private entry: HistoryEntry;
|
||||||
|
private snapshotManager: SnapshotManager;
|
||||||
|
private name = '';
|
||||||
|
private description = '';
|
||||||
|
|
||||||
|
constructor(app: App, plugin: PromptfirePlugin, entry: HistoryEntry) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.entry = entry;
|
||||||
|
this.snapshotManager = plugin.snapshotManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('promptfire-save-snapshot');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Save as Snapshot' });
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const summary = contentEl.createDiv();
|
||||||
|
summary.style.marginBottom = '15px';
|
||||||
|
summary.style.padding = '10px';
|
||||||
|
summary.style.backgroundColor = 'var(--background-secondary)';
|
||||||
|
summary.style.borderRadius = '4px';
|
||||||
|
summary.style.fontSize = '12px';
|
||||||
|
|
||||||
|
const fileCount = this.entry.metadata.includedFiles.length;
|
||||||
|
const templateName = this.entry.metadata.templateName || 'None';
|
||||||
|
const activeNote = this.entry.metadata.activeNote || 'None';
|
||||||
|
|
||||||
|
summary.createEl('div', { text: `Files: ${fileCount}` });
|
||||||
|
summary.createEl('div', { text: `Template: ${templateName}` });
|
||||||
|
summary.createEl('div', { text: `Active note: ${activeNote}` });
|
||||||
|
|
||||||
|
// Name field
|
||||||
|
let nameInput: HTMLInputElement;
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Name')
|
||||||
|
.setDesc('A short name for this snapshot (required)')
|
||||||
|
.addText(text => {
|
||||||
|
nameInput = text.inputEl;
|
||||||
|
text.setPlaceholder('e.g., "Auth refactor context"')
|
||||||
|
.onChange(value => { this.name = value; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description field
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Description')
|
||||||
|
.setDesc('Optional notes about when to use this snapshot')
|
||||||
|
.addTextArea(text => {
|
||||||
|
text.setPlaceholder('e.g., "Use when working on auth module"')
|
||||||
|
.onChange(value => { this.description = value; });
|
||||||
|
text.inputEl.rows = 2;
|
||||||
|
text.inputEl.style.width = '100%';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
const buttonContainer = contentEl.createDiv();
|
||||||
|
buttonContainer.style.display = 'flex';
|
||||||
|
buttonContainer.style.justifyContent = 'flex-end';
|
||||||
|
buttonContainer.style.gap = '10px';
|
||||||
|
buttonContainer.style.marginTop = '20px';
|
||||||
|
|
||||||
|
const saveBtn = buttonContainer.createEl('button', { text: 'Save Snapshot', cls: 'mod-cta' });
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.name.trim()) {
|
||||||
|
new Notice('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: ContextSnapshot = {
|
||||||
|
id: generateSnapshotId(),
|
||||||
|
name: this.name.trim(),
|
||||||
|
description: this.description.trim() || undefined,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
notePaths: this.entry.metadata.includedFiles,
|
||||||
|
activeNotePath: this.entry.metadata.activeNote,
|
||||||
|
templateId: this.entry.metadata.templateId,
|
||||||
|
includeFilenames: this.plugin.settings.includeFilenames,
|
||||||
|
separator: this.plugin.settings.separator,
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.snapshotManager.saveSnapshot(snapshot);
|
||||||
|
if (success) {
|
||||||
|
new Notice(`Snapshot saved: ${snapshot.name}`);
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
new Notice('Failed to save snapshot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
|
||||||
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SnapshotListModal extends Modal {
|
||||||
|
private plugin: PromptfirePlugin;
|
||||||
|
private snapshotManager: SnapshotManager;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: PromptfirePlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.snapshotManager = plugin.snapshotManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
await this.loadAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAndRender() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('promptfire-snapshot-list');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Context Snapshots' });
|
||||||
|
|
||||||
|
const snapshots = await this.snapshotManager.loadSnapshots();
|
||||||
|
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
const notice = contentEl.createEl('p', {
|
||||||
|
text: 'No snapshots yet. Open a history entry and click "Save as Snapshot" to create one.'
|
||||||
|
});
|
||||||
|
notice.style.fontStyle = 'italic';
|
||||||
|
notice.style.color = 'var(--text-muted)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countInfo = contentEl.createEl('p', {
|
||||||
|
text: `${snapshots.length} snapshot${snapshots.length !== 1 ? 's' : ''}`
|
||||||
|
});
|
||||||
|
countInfo.style.color = 'var(--text-muted)';
|
||||||
|
countInfo.style.marginBottom = '10px';
|
||||||
|
|
||||||
|
const list = contentEl.createDiv({ cls: 'snapshot-list' });
|
||||||
|
list.style.maxHeight = '400px';
|
||||||
|
list.style.overflow = 'auto';
|
||||||
|
list.style.border = '1px solid var(--background-modifier-border)';
|
||||||
|
list.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const row = list.createDiv({ cls: 'snapshot-row' });
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.padding = '10px 12px';
|
||||||
|
row.style.borderBottom = '1px solid var(--background-modifier-border)';
|
||||||
|
row.style.gap = '10px';
|
||||||
|
|
||||||
|
// Info
|
||||||
|
const infoContainer = row.createDiv();
|
||||||
|
infoContainer.style.flex = '1';
|
||||||
|
|
||||||
|
const header = infoContainer.createDiv();
|
||||||
|
header.style.display = 'flex';
|
||||||
|
header.style.alignItems = 'center';
|
||||||
|
header.style.gap = '8px';
|
||||||
|
|
||||||
|
const nameEl = header.createEl('span', { text: snapshot.name });
|
||||||
|
nameEl.style.fontWeight = '500';
|
||||||
|
|
||||||
|
const timeEl = header.createEl('span', { text: formatRelativeTime(snapshot.createdAt) });
|
||||||
|
timeEl.style.color = 'var(--text-muted)';
|
||||||
|
timeEl.style.fontSize = '12px';
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = infoContainer.createDiv();
|
||||||
|
stats.style.fontSize = '12px';
|
||||||
|
stats.style.color = 'var(--text-muted)';
|
||||||
|
stats.style.marginTop = '4px';
|
||||||
|
|
||||||
|
const fileCount = snapshot.notePaths.length;
|
||||||
|
const templateName = snapshot.templateId
|
||||||
|
? (this.plugin.settings.promptTemplates.find(t => t.id === snapshot.templateId)?.name || 'Unknown template')
|
||||||
|
: 'No template';
|
||||||
|
stats.setText(`${fileCount} files · ${templateName}`);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (snapshot.description) {
|
||||||
|
const desc = infoContainer.createDiv({ text: snapshot.description });
|
||||||
|
desc.style.fontSize = '12px';
|
||||||
|
desc.style.fontStyle = 'italic';
|
||||||
|
desc.style.marginTop = '4px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = row.createDiv();
|
||||||
|
actions.style.display = 'flex';
|
||||||
|
actions.style.gap = '4px';
|
||||||
|
|
||||||
|
const replayBtn = actions.createEl('button', { text: '\u25B6' });
|
||||||
|
replayBtn.title = 'Replay snapshot';
|
||||||
|
replayBtn.style.padding = '4px 8px';
|
||||||
|
replayBtn.addEventListener('click', async () => {
|
||||||
|
this.close();
|
||||||
|
await this.plugin.replaySnapshot(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = actions.createEl('button', { text: '\u2715' });
|
||||||
|
deleteBtn.title = 'Delete snapshot';
|
||||||
|
deleteBtn.style.padding = '4px 8px';
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
await this.snapshotManager.deleteSnapshot(snapshot.id);
|
||||||
|
new Notice('Snapshot deleted');
|
||||||
|
await this.loadAndRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove bottom border from last row
|
||||||
|
const lastRow = list.lastElementChild as HTMLElement;
|
||||||
|
if (lastRow) {
|
||||||
|
lastRow.style.borderBottom = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/snapshots.ts
Normal file
139
src/snapshots.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { App, TFile, TFolder } from 'obsidian';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface ContextSnapshot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
notePaths: string[];
|
||||||
|
activeNotePath: string | null;
|
||||||
|
templateId: string | null;
|
||||||
|
includeFilenames: boolean;
|
||||||
|
separator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ID Generation ===
|
||||||
|
|
||||||
|
export function generateSnapshotId(): string {
|
||||||
|
return 'snap_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Snapshot Manager ===
|
||||||
|
|
||||||
|
const SNAPSHOT_FOLDER = '.context-snapshots';
|
||||||
|
|
||||||
|
export class SnapshotManager {
|
||||||
|
private app: App;
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureFolder(): Promise<TFolder | null> {
|
||||||
|
const existing = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
|
||||||
|
if (existing instanceof TFolder) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.app.vault.createFolder(SNAPSHOT_FOLDER);
|
||||||
|
return this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER) as TFolder;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSnapshotFilename(snapshot: ContextSnapshot): string {
|
||||||
|
const date = new Date(snapshot.createdAt);
|
||||||
|
const dateStr = date.toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||||
|
return `${dateStr}_${snapshot.id}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSnapshot(snapshot: ContextSnapshot): Promise<boolean> {
|
||||||
|
const folder = await this.ensureFolder();
|
||||||
|
if (!folder) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = this.getSnapshotFilename(snapshot);
|
||||||
|
const filePath = `${SNAPSHOT_FOLDER}/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.app.vault.create(filePath, JSON.stringify(snapshot, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSnapshots(): Promise<ContextSnapshot[]> {
|
||||||
|
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
|
||||||
|
if (!folder || !(folder instanceof TFolder)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshots: ContextSnapshot[] = [];
|
||||||
|
|
||||||
|
for (const file of folder.children) {
|
||||||
|
if (file instanceof TFile && file.extension === 'json') {
|
||||||
|
try {
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
const snapshot = JSON.parse(content) as ContextSnapshot;
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
} catch {
|
||||||
|
// Skip invalid files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSnapshot(id: string): Promise<boolean> {
|
||||||
|
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
|
||||||
|
if (!folder || !(folder instanceof TFolder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of folder.children) {
|
||||||
|
if (file instanceof TFile && file.name.includes(id)) {
|
||||||
|
try {
|
||||||
|
await this.app.vault.delete(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSnapshot(id: string, updates: Partial<Pick<ContextSnapshot, 'name' | 'description'>>): Promise<boolean> {
|
||||||
|
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
|
||||||
|
if (!folder || !(folder instanceof TFolder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of folder.children) {
|
||||||
|
if (file instanceof TFile && file.name.includes(id)) {
|
||||||
|
try {
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
const snapshot = JSON.parse(content) as ContextSnapshot;
|
||||||
|
if (updates.name !== undefined) snapshot.name = updates.name;
|
||||||
|
if (updates.description !== undefined) snapshot.description = updates.description;
|
||||||
|
await this.app.vault.modify(file, JSON.stringify(snapshot, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue