feat(ai): add Anthropic-compatible provider; fix async plugin backends

Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
This commit is contained in:
2026-09-05 21:53:34 +05:30
parent efca458495
commit 7ab5a0ddb4
20 changed files with 426 additions and 225 deletions
+13 -3
View File
@@ -3,7 +3,10 @@
*
* Brings LLM assistance into the editor with multi-provider support:
* - OpenAI / Anthropic (cloud, API key required)
* - Ollama / LM Studio / any OpenAI-compatible endpoint (local, no key)
* - Ollama / LM Studio (local, no key)
* - Any OpenAI-compatible or Anthropic-compatible endpoint (custom base
* URL; Anthropic-compatible gateways like LiteLLM proxies work with or
* without a key)
*
* All provider traffic is proxied through the main process
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
@@ -231,11 +234,18 @@ class AiAssistantPlugin extends PluginAPI {
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI-compatible…</option>
<option value="anthropic-compatible">Anthropic-compatible…</option>
</select>
</label>
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></label>
<label>Base URL <input data-role="baseUrl" type="text" placeholder="http://localhost:11434/v1" /></label>
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local)" /></label>
<label>Base URL
<input
data-role="baseUrl"
type="text"
placeholder="Required for …-compatible providers, e.g. http://localhost:4000"
/>
</label>
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local providers)" /></label>
<label>Temperature <input data-role="temperature" type="number" min="0" max="2" step="0.1" /></label>
<p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
<div class="ai-settings-actions">
@@ -19,7 +19,7 @@
"key": "provider",
"type": "string",
"default": "",
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible)"
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible|anthropic-compatible)"
},
{ "key": "model", "type": "string", "default": "", "label": "Model name" },
{
@@ -42,17 +42,20 @@ function newCommentId(now = Date.now()) {
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh
* document simply has no comments yet; a corrupt store must not crash the app).
*
* Async: `io.readFile`/`io.fileExists` are IPC-backed Promises in the app
* (plain values in tests — `await` handles both).
*
* @param {string} docPath Document path
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
* @param {Function} pathUtil injected path module
* @returns {Array<object>} comments sorted by line then createdAt
* @returns {Promise<Array<object>>} comments sorted by line then createdAt
*/
function loadComments(docPath, io, pathUtil) {
async function loadComments(docPath, io, pathUtil) {
if (!docPath) return [];
const file = commentsFilePathFor(docPath, pathUtil);
try {
if (!io.fileExists(file)) return [];
const raw = io.readFile(file);
if (!(await io.fileExists(file))) return [];
const raw = await io.readFile(file);
const data = JSON.parse(raw);
if (!Array.isArray(data.comments)) return [];
return normalizeComments(data.comments);
@@ -69,10 +72,10 @@ function loadComments(docPath, io, pathUtil) {
* @param {object} io - { writeFile, ensureDirectory }
* @param {Function} pathUtil injected path module
*/
function saveComments(docPath, comments, io, pathUtil) {
async function saveComments(docPath, comments, io, pathUtil) {
const file = commentsFilePathFor(docPath, pathUtil);
io.ensureDirectory(pathUtil.dirname(file));
io.writeFile(
await io.ensureDirectory(pathUtil.dirname(file));
await io.writeFile(
file,
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
);
@@ -31,7 +31,7 @@ function esc(text) {
* @param {Function} deps.pathUtil injected path module
* @param {string} [deps.author='me'] Display name for new comments
*/
function renderCommentsPanel(container, deps) {
async function renderCommentsPanel(container, deps) {
container.replaceChildren();
const { editor, io, pathUtil } = deps;
const docPath = editor.getCurrentFilePath();
@@ -74,14 +74,14 @@ function renderCommentsPanel(container, deps) {
const addBtn = document.createElement('button');
addBtn.className = 'ws-btn ws-btn-primary';
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
addBtn.addEventListener('click', () => {
addBtn.addEventListener('click', async () => {
const text = textarea.value.trim();
if (!text) return;
const line = editor.getCurrentLine();
const anchorText = (getLines(editor)[line - 1] || '').trim();
const comments = store.loadComments(docPath, io, pathUtil);
const comments = await store.loadComments(docPath, io, pathUtil);
store.addComment(comments, { line, anchorText, author: deps.author || 'me', text });
store.saveComments(docPath, comments, io, pathUtil);
await store.saveComments(docPath, comments, io, pathUtil);
renderCommentsPanel(container, deps); // re-render with fresh list
});
composer.appendChild(textarea);
@@ -89,7 +89,7 @@ function renderCommentsPanel(container, deps) {
panel.appendChild(composer);
// --- List -----------------------------------------------------------------
const comments = store.loadComments(docPath, io, pathUtil);
const comments = await store.loadComments(docPath, io, pathUtil);
const lines = getLines(editor);
const list = document.createElement('div');
@@ -122,16 +122,16 @@ function renderCommentsPanel(container, deps) {
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
editor.jumpToLine(comment.line);
});
item.querySelector('[data-action="resolve"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
item.querySelector('[data-action="resolve"]').addEventListener('click', async () => {
const updated = await store.loadComments(docPath, io, pathUtil);
store.toggleResolved(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
await store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
item.querySelector('[data-action="delete"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
const updated = await store.loadComments(docPath, io, pathUtil);
store.deleteComment(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
await store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
list.appendChild(item);
+2 -2
View File
@@ -85,10 +85,10 @@ class CollaborationPlugin extends PluginAPI {
document.addEventListener('keydown', this._f8Handler);
}
_jumpToNextComment() {
async _jumpToNextComment() {
const docPath = this.context.editor.getCurrentFilePath();
if (!docPath) return;
const comments = store.loadComments(docPath, this._io, require('path'));
const comments = await store.loadComments(docPath, this._io, require('path'));
const fromLine = this.context.editor.getCurrentLine();
const next = store.nextUnresolved(comments, fromLine);
if (next) this.context.editor.jumpToLine(next.line);
@@ -1,5 +1,12 @@
const HISTORY_KEY = 'plugins.writing-studio.history';
/**
* Tracks daily word-count history for goals, streaks, and the heatmap.
*
* All methods are async: the settings backend in the real app is the
* IPC-backed SettingsStore (get/set return Promises), while unit tests inject
* a plain synchronous object — `await` transparently handles both.
*/
class GoalTracker {
/**
* @param {object} store - { get(key), set(key, value) } settings backend
@@ -8,41 +15,41 @@ class GoalTracker {
this.store = store;
}
_getHistory() {
const raw = this.store.get(HISTORY_KEY);
async _getHistory() {
const raw = await this.store.get(HISTORY_KEY);
return raw ? JSON.parse(raw) : {};
}
_setHistory(history) {
this.store.set(HISTORY_KEY, JSON.stringify(history));
async _setHistory(history) {
await this.store.set(HISTORY_KEY, JSON.stringify(history));
}
_setHistoryDay(dateStr, data) {
const history = this._getHistory();
async _setHistoryDay(dateStr, data) {
const history = await this._getHistory();
history[dateStr] = data;
this._setHistory(history);
await this._setHistory(history);
}
addWords(count) {
async addWords(count) {
const today = new Date().toISOString().split('T')[0];
const history = this._getHistory();
const history = await this._getHistory();
if (!history[today]) {
history[today] = { words: 0, sessions: 0 };
}
history[today].words += count;
history[today].sessions += 1;
this._setHistory(history);
await this._setHistory(history);
}
getDailyProgress(goal) {
async getDailyProgress(goal) {
const today = new Date().toISOString().split('T')[0];
const history = this._getHistory();
const history = await this._getHistory();
const written = history[today]?.words || 0;
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
}
getStreak(goal) {
const history = this._getHistory();
async getStreak(goal) {
const history = await this._getHistory();
let streak = 0;
const d = new Date();
for (let i = 0; i < 365; i++) {
@@ -58,8 +65,8 @@ class GoalTracker {
return streak;
}
getLast30Days() {
const history = this._getHistory();
async getLast30Days() {
const history = await this._getHistory();
const days = [];
const d = new Date();
for (let i = 0; i < 30; i++) {
@@ -71,8 +78,8 @@ class GoalTracker {
return days.reverse();
}
getWeeklyTotal() {
const history = this._getHistory();
async getWeeklyTotal() {
const history = await this._getHistory();
let total = 0;
const d = new Date();
for (let i = 0; i < 7; i++) {
+15 -12
View File
@@ -35,11 +35,14 @@ class WritingStudioPlugin extends PluginAPI {
_registerCommands(context) {
const { sprintEngine, snapshotManager, goalTracker } = this;
// NOTE: settings.get and all engine methods are async (IPC-backed
// backend) — every handler below awaits what it reads.
context.commands.register(
'start-sprint',
'Studio: Start Sprint',
() => {
const duration = context.settings.get('sprintDuration') || 25;
async () => {
const duration = (await context.settings.get('sprintDuration')) || 25;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
sprintEngine.start(duration, words);
@@ -50,12 +53,12 @@ class WritingStudioPlugin extends PluginAPI {
context.commands.register(
'stop-sprint',
'Studio: Stop Sprint',
() => {
async () => {
if (!sprintEngine.isActive()) return;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta);
await goalTracker.addWords(result.wordDelta);
context.events.emit('sprint:stopped', result);
},
'Ctrl+Alt+Shift+S'
@@ -64,9 +67,9 @@ class WritingStudioPlugin extends PluginAPI {
context.commands.register(
'take-snapshot',
'Studio: Take Snapshot',
() => {
async () => {
const content = context.editor.getContent() || '';
snapshotManager.create(content, 'manual');
await snapshotManager.create(content, 'manual');
context.events.emit('snapshot:created', {});
},
'Ctrl+Alt+N'
@@ -75,10 +78,10 @@ class WritingStudioPlugin extends PluginAPI {
context.commands.register(
'restore-last-snapshot',
'Studio: Restore Last Snapshot',
() => {
const snaps = snapshotManager.list();
async () => {
const snaps = await snapshotManager.list();
if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id);
const content = await snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content);
},
'Ctrl+Alt+Z'
@@ -134,9 +137,9 @@ class WritingStudioPlugin extends PluginAPI {
extension: 'txt',
handler: async (markdownContent, outputPath) => {
const fs = require('fs');
const goal = context.settings.get('dailyGoal') || 1000;
const progress = goalTracker.getDailyProgress(goal);
const streak = goalTracker.getStreak(goal);
const goal = (await context.settings.get('dailyGoal')) || 1000;
const progress = await goalTracker.getDailyProgress(goal);
const streak = await goalTracker.getStreak(goal);
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
const lines = [
@@ -1,9 +1,11 @@
function renderGoalsPanel(container, { engines, settings }) {
const dailyGoal = settings.get('dailyGoal') || 1000;
const progress = engines.goals.getDailyProgress(dailyGoal);
const streak = engines.goals.getStreak(dailyGoal);
const weekly = engines.goals.getWeeklyTotal();
const last30 = engines.goals.getLast30Days();
async function renderGoalsPanel(container, { engines, settings }) {
// settings.get and the GoalTracker are async (IPC-backed store) — the whole
// panel renders after its data resolves.
const dailyGoal = (await settings.get('dailyGoal')) || 1000;
const progress = await engines.goals.getDailyProgress(dailyGoal);
const streak = await engines.goals.getStreak(dailyGoal);
const weekly = await engines.goals.getWeeklyTotal();
const last30 = await engines.goals.getLast30Days();
container.replaceChildren();
@@ -1,5 +1,12 @@
function renderManuscriptPanel(container, { engines, editor, settings }) {
const projectDir = settings.get('projectDir');
/**
* Manuscript sidebar panel (writing-studio).
*
* All project/settings access is async (IPC-backed store), and the
* "New Project" flow uses an inline dialog — Electron renderers have no
* window.prompt, so the old prompt() call threw on click.
*/
async function renderManuscriptPanel(container, { engines, editor, settings }) {
const projectDir = await settings.get('projectDir');
container.replaceChildren();
const panel = document.createElement('div');
@@ -16,10 +23,14 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
btn.id = 'ws-new-project';
btn.textContent = 'New Project';
btn.addEventListener('click', () => {
const name = prompt('Project name:');
if (!name) return;
settings.set('projectDir', name);
renderManuscriptPanel(container, { engines, editor, settings });
// Inline name input (window.prompt is unavailable in Electron)
const name = askForProjectName();
name.then((result) => {
if (!result) return;
settings.set('projectDir', result).then(() => {
renderManuscriptPanel(container, { engines, editor, settings });
});
});
});
empty.appendChild(btn);
panel.appendChild(empty);
@@ -27,7 +38,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
return;
}
const project = engines.projects.loadProject(projectDir);
const project = await engines.projects.loadProject(projectDir);
if (!project) {
const empty = document.createElement('div');
empty.className = 'ws-empty';
@@ -39,8 +50,9 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
btn.id = 'ws-clear-project';
btn.textContent = 'Clear Project';
btn.addEventListener('click', () => {
settings.set('projectDir', null);
renderManuscriptPanel(container, { engines, editor, settings });
settings.set('projectDir', null).then(() => {
renderManuscriptPanel(container, { engines, editor, settings });
});
});
empty.appendChild(btn);
panel.appendChild(empty);
@@ -48,7 +60,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
return;
}
const stats = engines.projects.getStats(projectDir);
const stats = await engines.projects.getStats(projectDir);
// Project title + progress
const section1 = document.createElement('div');
@@ -112,8 +124,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
compileBtn.className = 'ws-btn ws-btn-primary';
compileBtn.id = 'ws-compile';
compileBtn.textContent = 'Compile Manuscript';
compileBtn.addEventListener('click', () => {
const compiled = engines.projects.compileManuscript(projectDir);
compileBtn.addEventListener('click', async () => {
const compiled = await engines.projects.compileManuscript(projectDir);
editor.insertAtCursor(compiled);
});
section3.appendChild(compileBtn);
@@ -122,4 +134,42 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
container.appendChild(panel);
}
/**
* Minimal promise-based name input (Electron has no window.prompt).
* @returns {Promise<string|null>} project name, or null when cancelled
*/
function askForProjectName() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="ai-settings-dialog">
<h3>New manuscript project</h3>
<input type="text" data-role="name" placeholder="Project name" aria-label="Project name" />
<div class="ai-settings-actions">
<button type="button" data-role="ok">Create</button>
<button type="button" data-role="cancel">Cancel</button>
</div>
</div>`;
const input = overlay.querySelector('[data-role="name"]');
const done = (value) => {
overlay.remove();
resolve(value);
};
overlay.querySelector('[data-role="ok"]').addEventListener('click', () => done(input.value.trim() || null));
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) done(null);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') done(input.value.trim() || null);
if (event.key === 'Escape') done(null);
});
document.body.appendChild(overlay);
input.focus();
});
}
module.exports = { renderManuscriptPanel };
@@ -1,5 +1,5 @@
function renderSnapshotsPanel(container, { engines, editor }) {
const snapshots = engines.snapshots.list();
async function renderSnapshotsPanel(container, { engines, editor }) {
const snapshots = await engines.snapshots.list();
container.replaceChildren();
@@ -53,16 +53,16 @@ function renderSnapshotsPanel(container, { engines, editor }) {
const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
actionBtn.textContent = text;
actionBtn.addEventListener('click', () => {
actionBtn.addEventListener('click', async () => {
if (action === 'restore') {
const content = engines.snapshots.restore(s.id);
const content = await engines.snapshots.restore(s.id);
editor.insertAtCursor(content);
} else if (action === 'delete') {
engines.snapshots.delete(s.id);
await engines.snapshots.delete(s.id);
renderSnapshotsPanel(container, { engines, editor });
} else if (action === 'diff') {
const current = editor.getContent() || '';
const result = engines.snapshots.diff(s.id, current);
const result = await engines.snapshots.diff(s.id, current);
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
}
});
@@ -75,9 +75,9 @@ function renderSnapshotsPanel(container, { engines, editor }) {
container.appendChild(panel);
// Take snapshot button handler
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
container.querySelector('#ws-take-snapshot').addEventListener('click', async () => {
const content = editor.getContent() || '';
engines.snapshots.create(content, 'manual');
await engines.snapshots.create(content, 'manual');
renderSnapshotsPanel(container, { engines, editor });
});
}
@@ -1,3 +1,11 @@
/**
* Manuscript project management (.project.json + chapter files).
*
* All methods are async: the fs backend injected by the plugin is the
* IPC-backed file API (readFile/writeFile/fileExists/listDir return
* Promises); `await` also works against the synchronous fakes used in unit
* tests.
*/
class ProjectManager {
/**
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
@@ -6,7 +14,7 @@ class ProjectManager {
this.fs = fs;
}
createProject(dir, opts) {
async createProject(dir, opts) {
const project = {
title: opts.title,
type: opts.type || 'manuscript',
@@ -14,51 +22,53 @@ class ProjectManager {
chapters: [],
metadata: opts.metadata || {},
};
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project;
}
loadProject(dir) {
const raw = this.fs.readFile(dir + '/.project.json');
async loadProject(dir) {
const exists = await this.fs.fileExists(dir + '/.project.json');
if (!exists) return null;
const raw = await this.fs.readFile(dir + '/.project.json');
if (!raw) return null;
return JSON.parse(raw);
}
_saveProject(dir, project) {
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
async _saveProject(dir, project) {
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
}
addChapter(dir, chapter) {
const project = this.loadProject(dir);
async addChapter(dir, chapter) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
project.chapters.push(chapter);
this._saveProject(dir, project);
await this._saveProject(dir, project);
}
updateChapter(dir, index, updates) {
const project = this.loadProject(dir);
async updateChapter(dir, index, updates) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
Object.assign(project.chapters[index], updates);
this._saveProject(dir, project);
await this._saveProject(dir, project);
}
compileManuscript(dir) {
const project = this.loadProject(dir);
async compileManuscript(dir) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
const parts = [];
for (const ch of project.chapters) {
const content = this.fs.readFile(dir + '/' + ch.file);
const content = await this.fs.readFile(dir + '/' + ch.file);
if (content) parts.push(content);
}
return parts.join('\n\n---\n\n');
}
getStats(dir) {
const project = this.loadProject(dir);
async getStats(dir) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
let totalWords = 0;
for (const ch of project.chapters) {
const content = this.fs.readFile(dir + '/' + ch.file);
const content = await this.fs.readFile(dir + '/' + ch.file);
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
}
const target = project.target.words || 0;
@@ -1,3 +1,9 @@
/**
* Content snapshots with a rolling window, stored via the plugin settings
* backend. All methods are async because the real backend is the IPC-backed
* SettingsStore (get/set return Promises); `await` also works against the
* synchronous fakes used in unit tests.
*/
class SnapshotManager {
/**
* @param {object} store - { get(key), set(key, value) }
@@ -8,17 +14,17 @@ class SnapshotManager {
this.storeKey = storeKey;
}
_getAll() {
const raw = this.store.get(this.storeKey);
async _getAll() {
const raw = await this.store.get(this.storeKey);
return raw ? JSON.parse(raw) : [];
}
_saveAll(snaps) {
this.store.set(this.storeKey, JSON.stringify(snaps));
async _saveAll(snaps) {
await this.store.set(this.storeKey, JSON.stringify(snaps));
}
create(content, label = 'manual') {
const snaps = this._getAll();
async create(content, label = 'manual') {
const snaps = await this._getAll();
const snap = {
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
timestamp: new Date().toISOString(),
@@ -27,31 +33,31 @@ class SnapshotManager {
label,
};
snaps.unshift(snap);
this._saveAll(snaps);
await this._saveAll(snaps);
return snap;
}
list() {
async list() {
return this._getAll();
}
getById(id) {
return this._getAll().find((s) => s.id === id) || null;
async getById(id) {
return (await this._getAll()).find((s) => s.id === id) || null;
}
restore(id) {
const snap = this.getById(id);
async restore(id) {
const snap = await this.getById(id);
if (!snap) throw new Error('Snapshot not found');
return snap.content;
}
delete(id) {
const snaps = this._getAll().filter((s) => s.id !== id);
this._saveAll(snaps);
async delete(id) {
const snaps = (await this._getAll()).filter((s) => s.id !== id);
await this._saveAll(snaps);
}
diff(id, currentContent) {
const snap = this.getById(id);
async diff(id, currentContent) {
const snap = await this.getById(id);
if (!snap) throw new Error('Snapshot not found');
const oldLines = snap.content.split('\n');
const newLines = currentContent.split('\n');
@@ -68,9 +74,9 @@ class SnapshotManager {
return { added, removed };
}
prune(keepCount) {
const snaps = this._getAll();
this._saveAll(snaps.slice(0, keepCount));
async prune(keepCount) {
const snaps = await this._getAll();
await this._saveAll(snaps.slice(0, keepCount));
}
}