mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
+32
-8
@@ -12,6 +12,10 @@
|
|||||||
* - `ollama` → http://localhost:11434/v1 (OpenAI-compatible)
|
* - `ollama` → http://localhost:11434/v1 (OpenAI-compatible)
|
||||||
* - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible)
|
* - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible)
|
||||||
* - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema
|
* - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema
|
||||||
|
* - `anthropic-compatible` → any baseUrl speaking the Anthropic messages
|
||||||
|
* schema (LiteLLM proxies, Bedrock gateways, local Claude-compatible
|
||||||
|
* servers); baseUrl is required, the API key is optional because many
|
||||||
|
* proxies are keyless or front their own auth
|
||||||
*
|
*
|
||||||
* The module takes an injectable `fetchImpl` (defaulting to global fetch) so
|
* The module takes an injectable `fetchImpl` (defaulting to global fetch) so
|
||||||
* tests can stub the network without monkey-patching.
|
* tests can stub the network without monkey-patching.
|
||||||
@@ -33,6 +37,7 @@ const PROVIDER_DEFAULTS = {
|
|||||||
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' },
|
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' },
|
||||||
lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' },
|
lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' },
|
||||||
'openai-compatible': { baseUrl: '', defaultModel: '' },
|
'openai-compatible': { baseUrl: '', defaultModel: '' },
|
||||||
|
'anthropic-compatible': { baseUrl: '', defaultModel: 'claude-3-5-sonnet-latest' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provider ids that speak the OpenAI chat-completions schema. */
|
/** Provider ids that speak the OpenAI chat-completions schema. */
|
||||||
@@ -72,8 +77,13 @@ function resolveSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local providers (ollama/lmstudio) don't need a key; remote ones do.
|
// Local providers (ollama/lmstudio) and self-hosted *-compatible proxies
|
||||||
const needsKey = requireKey && provider !== 'ollama' && provider !== 'lmstudio';
|
// don't need a key; branded cloud endpoints do.
|
||||||
|
const needsKey =
|
||||||
|
requireKey &&
|
||||||
|
provider !== 'ollama' &&
|
||||||
|
provider !== 'lmstudio' &&
|
||||||
|
provider !== 'anthropic-compatible';
|
||||||
if (needsKey && !apiKey) {
|
if (needsKey && !apiKey) {
|
||||||
throw new AiProviderError(
|
throw new AiProviderError(
|
||||||
`The "${provider}" provider needs an API key. Add one in AI Assistant settings.`,
|
`The "${provider}" provider needs an API key. Add one in AI Assistant settings.`,
|
||||||
@@ -158,13 +168,27 @@ async function callAnthropic(settings, { system, messages }, fetchImpl, timeoutM
|
|||||||
system: system || undefined,
|
system: system || undefined,
|
||||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
};
|
};
|
||||||
const response = await fetchImpl(`${settings.baseUrl}/v1/messages`, {
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
};
|
||||||
|
if (settings.apiKey) {
|
||||||
|
// Official API keys travel via x-api-key; many compatible gateways
|
||||||
|
// (LiteLLM proxies, Bedrock fronts) expect a Bearer token instead.
|
||||||
|
// Sending both is harmless for the official endpoint and maximizes
|
||||||
|
// proxy compatibility.
|
||||||
|
headers['x-api-key'] = settings.apiKey;
|
||||||
|
headers.Authorization = `Bearer ${settings.apiKey}`;
|
||||||
|
}
|
||||||
|
// Bases may or may not already carry the /v1 prefix — handle both so a
|
||||||
|
// "http://host:4000" proxy base and an "http://host:4000/v1" style base
|
||||||
|
// both land on a single /v1/messages path.
|
||||||
|
const messagesUrl = settings.baseUrl.endsWith('/v1')
|
||||||
|
? `${settings.baseUrl}/messages`
|
||||||
|
: `${settings.baseUrl}/v1/messages`;
|
||||||
|
const response = await fetchImpl(messagesUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': settings.apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Brings LLM assistance into the editor with multi-provider support:
|
* Brings LLM assistance into the editor with multi-provider support:
|
||||||
* - OpenAI / Anthropic (cloud, API key required)
|
* - 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
|
* All provider traffic is proxied through the main process
|
||||||
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
|
* (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="openai">OpenAI</option>
|
||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="openai-compatible">OpenAI-compatible…</option>
|
<option value="openai-compatible">OpenAI-compatible…</option>
|
||||||
|
<option value="anthropic-compatible">Anthropic-compatible…</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></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>Base URL
|
||||||
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local)" /></label>
|
<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>
|
<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>
|
<p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
|
||||||
<div class="ai-settings-actions">
|
<div class="ai-settings-actions">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"key": "provider",
|
"key": "provider",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"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" },
|
{ "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
|
* 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).
|
* 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 {string} docPath Document path
|
||||||
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
|
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
|
||||||
* @param {Function} pathUtil injected path module
|
* @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 [];
|
if (!docPath) return [];
|
||||||
const file = commentsFilePathFor(docPath, pathUtil);
|
const file = commentsFilePathFor(docPath, pathUtil);
|
||||||
try {
|
try {
|
||||||
if (!io.fileExists(file)) return [];
|
if (!(await io.fileExists(file))) return [];
|
||||||
const raw = io.readFile(file);
|
const raw = await io.readFile(file);
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (!Array.isArray(data.comments)) return [];
|
if (!Array.isArray(data.comments)) return [];
|
||||||
return normalizeComments(data.comments);
|
return normalizeComments(data.comments);
|
||||||
@@ -69,10 +72,10 @@ function loadComments(docPath, io, pathUtil) {
|
|||||||
* @param {object} io - { writeFile, ensureDirectory }
|
* @param {object} io - { writeFile, ensureDirectory }
|
||||||
* @param {Function} pathUtil injected path module
|
* @param {Function} pathUtil injected path module
|
||||||
*/
|
*/
|
||||||
function saveComments(docPath, comments, io, pathUtil) {
|
async function saveComments(docPath, comments, io, pathUtil) {
|
||||||
const file = commentsFilePathFor(docPath, pathUtil);
|
const file = commentsFilePathFor(docPath, pathUtil);
|
||||||
io.ensureDirectory(pathUtil.dirname(file));
|
await io.ensureDirectory(pathUtil.dirname(file));
|
||||||
io.writeFile(
|
await io.writeFile(
|
||||||
file,
|
file,
|
||||||
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
|
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 {Function} deps.pathUtil injected path module
|
||||||
* @param {string} [deps.author='me'] Display name for new comments
|
* @param {string} [deps.author='me'] Display name for new comments
|
||||||
*/
|
*/
|
||||||
function renderCommentsPanel(container, deps) {
|
async function renderCommentsPanel(container, deps) {
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
const { editor, io, pathUtil } = deps;
|
const { editor, io, pathUtil } = deps;
|
||||||
const docPath = editor.getCurrentFilePath();
|
const docPath = editor.getCurrentFilePath();
|
||||||
@@ -74,14 +74,14 @@ function renderCommentsPanel(container, deps) {
|
|||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'ws-btn ws-btn-primary';
|
addBtn.className = 'ws-btn ws-btn-primary';
|
||||||
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
|
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', async () => {
|
||||||
const text = textarea.value.trim();
|
const text = textarea.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const line = editor.getCurrentLine();
|
const line = editor.getCurrentLine();
|
||||||
const anchorText = (getLines(editor)[line - 1] || '').trim();
|
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.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
|
renderCommentsPanel(container, deps); // re-render with fresh list
|
||||||
});
|
});
|
||||||
composer.appendChild(textarea);
|
composer.appendChild(textarea);
|
||||||
@@ -89,7 +89,7 @@ function renderCommentsPanel(container, deps) {
|
|||||||
panel.appendChild(composer);
|
panel.appendChild(composer);
|
||||||
|
|
||||||
// --- List -----------------------------------------------------------------
|
// --- List -----------------------------------------------------------------
|
||||||
const comments = store.loadComments(docPath, io, pathUtil);
|
const comments = await store.loadComments(docPath, io, pathUtil);
|
||||||
const lines = getLines(editor);
|
const lines = getLines(editor);
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@@ -122,16 +122,16 @@ function renderCommentsPanel(container, deps) {
|
|||||||
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
|
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
|
||||||
editor.jumpToLine(comment.line);
|
editor.jumpToLine(comment.line);
|
||||||
});
|
});
|
||||||
item.querySelector('[data-action="resolve"]').addEventListener('click', () => {
|
item.querySelector('[data-action="resolve"]').addEventListener('click', async () => {
|
||||||
const updated = store.loadComments(docPath, io, pathUtil);
|
const updated = await store.loadComments(docPath, io, pathUtil);
|
||||||
store.toggleResolved(updated, comment.id);
|
store.toggleResolved(updated, comment.id);
|
||||||
store.saveComments(docPath, updated, io, pathUtil);
|
await store.saveComments(docPath, updated, io, pathUtil);
|
||||||
renderCommentsPanel(container, deps);
|
renderCommentsPanel(container, deps);
|
||||||
});
|
});
|
||||||
item.querySelector('[data-action="delete"]').addEventListener('click', () => {
|
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||||||
const updated = store.loadComments(docPath, io, pathUtil);
|
const updated = await store.loadComments(docPath, io, pathUtil);
|
||||||
store.deleteComment(updated, comment.id);
|
store.deleteComment(updated, comment.id);
|
||||||
store.saveComments(docPath, updated, io, pathUtil);
|
await store.saveComments(docPath, updated, io, pathUtil);
|
||||||
renderCommentsPanel(container, deps);
|
renderCommentsPanel(container, deps);
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ class CollaborationPlugin extends PluginAPI {
|
|||||||
document.addEventListener('keydown', this._f8Handler);
|
document.addEventListener('keydown', this._f8Handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
_jumpToNextComment() {
|
async _jumpToNextComment() {
|
||||||
const docPath = this.context.editor.getCurrentFilePath();
|
const docPath = this.context.editor.getCurrentFilePath();
|
||||||
if (!docPath) return;
|
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 fromLine = this.context.editor.getCurrentLine();
|
||||||
const next = store.nextUnresolved(comments, fromLine);
|
const next = store.nextUnresolved(comments, fromLine);
|
||||||
if (next) this.context.editor.jumpToLine(next.line);
|
if (next) this.context.editor.jumpToLine(next.line);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
const HISTORY_KEY = 'plugins.writing-studio.history';
|
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 {
|
class GoalTracker {
|
||||||
/**
|
/**
|
||||||
* @param {object} store - { get(key), set(key, value) } settings backend
|
* @param {object} store - { get(key), set(key, value) } settings backend
|
||||||
@@ -8,41 +15,41 @@ class GoalTracker {
|
|||||||
this.store = store;
|
this.store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getHistory() {
|
async _getHistory() {
|
||||||
const raw = this.store.get(HISTORY_KEY);
|
const raw = await this.store.get(HISTORY_KEY);
|
||||||
return raw ? JSON.parse(raw) : {};
|
return raw ? JSON.parse(raw) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHistory(history) {
|
async _setHistory(history) {
|
||||||
this.store.set(HISTORY_KEY, JSON.stringify(history));
|
await this.store.set(HISTORY_KEY, JSON.stringify(history));
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHistoryDay(dateStr, data) {
|
async _setHistoryDay(dateStr, data) {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
history[dateStr] = data;
|
history[dateStr] = data;
|
||||||
this._setHistory(history);
|
await this._setHistory(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
addWords(count) {
|
async addWords(count) {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
if (!history[today]) {
|
if (!history[today]) {
|
||||||
history[today] = { words: 0, sessions: 0 };
|
history[today] = { words: 0, sessions: 0 };
|
||||||
}
|
}
|
||||||
history[today].words += count;
|
history[today].words += count;
|
||||||
history[today].sessions += 1;
|
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 today = new Date().toISOString().split('T')[0];
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
const written = history[today]?.words || 0;
|
const written = history[today]?.words || 0;
|
||||||
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
getStreak(goal) {
|
async getStreak(goal) {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
let streak = 0;
|
let streak = 0;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 365; i++) {
|
for (let i = 0; i < 365; i++) {
|
||||||
@@ -58,8 +65,8 @@ class GoalTracker {
|
|||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLast30Days() {
|
async getLast30Days() {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
const days = [];
|
const days = [];
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
@@ -71,8 +78,8 @@ class GoalTracker {
|
|||||||
return days.reverse();
|
return days.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
getWeeklyTotal() {
|
async getWeeklyTotal() {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
_registerCommands(context) {
|
_registerCommands(context) {
|
||||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
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(
|
context.commands.register(
|
||||||
'start-sprint',
|
'start-sprint',
|
||||||
'Studio: Start Sprint',
|
'Studio: Start Sprint',
|
||||||
() => {
|
async () => {
|
||||||
const duration = context.settings.get('sprintDuration') || 25;
|
const duration = (await context.settings.get('sprintDuration')) || 25;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
sprintEngine.start(duration, words);
|
sprintEngine.start(duration, words);
|
||||||
@@ -50,12 +53,12 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'stop-sprint',
|
'stop-sprint',
|
||||||
'Studio: Stop Sprint',
|
'Studio: Stop Sprint',
|
||||||
() => {
|
async () => {
|
||||||
if (!sprintEngine.isActive()) return;
|
if (!sprintEngine.isActive()) return;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
const result = sprintEngine.stop(words);
|
const result = sprintEngine.stop(words);
|
||||||
goalTracker.addWords(result.wordDelta);
|
await goalTracker.addWords(result.wordDelta);
|
||||||
context.events.emit('sprint:stopped', result);
|
context.events.emit('sprint:stopped', result);
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+Shift+S'
|
'Ctrl+Alt+Shift+S'
|
||||||
@@ -64,9 +67,9 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'take-snapshot',
|
'take-snapshot',
|
||||||
'Studio: Take Snapshot',
|
'Studio: Take Snapshot',
|
||||||
() => {
|
async () => {
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
snapshotManager.create(content, 'manual');
|
await snapshotManager.create(content, 'manual');
|
||||||
context.events.emit('snapshot:created', {});
|
context.events.emit('snapshot:created', {});
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+N'
|
'Ctrl+Alt+N'
|
||||||
@@ -75,10 +78,10 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'restore-last-snapshot',
|
'restore-last-snapshot',
|
||||||
'Studio: Restore Last Snapshot',
|
'Studio: Restore Last Snapshot',
|
||||||
() => {
|
async () => {
|
||||||
const snaps = snapshotManager.list();
|
const snaps = await snapshotManager.list();
|
||||||
if (snaps.length === 0) return;
|
if (snaps.length === 0) return;
|
||||||
const content = snapshotManager.restore(snaps[0].id);
|
const content = await snapshotManager.restore(snaps[0].id);
|
||||||
context.editor.insertAtCursor(content);
|
context.editor.insertAtCursor(content);
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+Z'
|
'Ctrl+Alt+Z'
|
||||||
@@ -134,9 +137,9 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
extension: 'txt',
|
extension: 'txt',
|
||||||
handler: async (markdownContent, outputPath) => {
|
handler: async (markdownContent, outputPath) => {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const goal = context.settings.get('dailyGoal') || 1000;
|
const goal = (await context.settings.get('dailyGoal')) || 1000;
|
||||||
const progress = goalTracker.getDailyProgress(goal);
|
const progress = await goalTracker.getDailyProgress(goal);
|
||||||
const streak = goalTracker.getStreak(goal);
|
const streak = await goalTracker.getStreak(goal);
|
||||||
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
|
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
function renderGoalsPanel(container, { engines, settings }) {
|
async function renderGoalsPanel(container, { engines, settings }) {
|
||||||
const dailyGoal = settings.get('dailyGoal') || 1000;
|
// settings.get and the GoalTracker are async (IPC-backed store) — the whole
|
||||||
const progress = engines.goals.getDailyProgress(dailyGoal);
|
// panel renders after its data resolves.
|
||||||
const streak = engines.goals.getStreak(dailyGoal);
|
const dailyGoal = (await settings.get('dailyGoal')) || 1000;
|
||||||
const weekly = engines.goals.getWeeklyTotal();
|
const progress = await engines.goals.getDailyProgress(dailyGoal);
|
||||||
const last30 = engines.goals.getLast30Days();
|
const streak = await engines.goals.getStreak(dailyGoal);
|
||||||
|
const weekly = await engines.goals.getWeeklyTotal();
|
||||||
|
const last30 = await engines.goals.getLast30Days();
|
||||||
|
|
||||||
container.replaceChildren();
|
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();
|
container.replaceChildren();
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
@@ -16,10 +23,14 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
btn.id = 'ws-new-project';
|
btn.id = 'ws-new-project';
|
||||||
btn.textContent = 'New Project';
|
btn.textContent = 'New Project';
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const name = prompt('Project name:');
|
// Inline name input (window.prompt is unavailable in Electron)
|
||||||
if (!name) return;
|
const name = askForProjectName();
|
||||||
settings.set('projectDir', name);
|
name.then((result) => {
|
||||||
renderManuscriptPanel(container, { engines, editor, settings });
|
if (!result) return;
|
||||||
|
settings.set('projectDir', result).then(() => {
|
||||||
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
empty.appendChild(btn);
|
empty.appendChild(btn);
|
||||||
panel.appendChild(empty);
|
panel.appendChild(empty);
|
||||||
@@ -27,7 +38,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = engines.projects.loadProject(projectDir);
|
const project = await engines.projects.loadProject(projectDir);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'ws-empty';
|
empty.className = 'ws-empty';
|
||||||
@@ -39,8 +50,9 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
btn.id = 'ws-clear-project';
|
btn.id = 'ws-clear-project';
|
||||||
btn.textContent = 'Clear Project';
|
btn.textContent = 'Clear Project';
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
settings.set('projectDir', null);
|
settings.set('projectDir', null).then(() => {
|
||||||
renderManuscriptPanel(container, { engines, editor, settings });
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
empty.appendChild(btn);
|
empty.appendChild(btn);
|
||||||
panel.appendChild(empty);
|
panel.appendChild(empty);
|
||||||
@@ -48,7 +60,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = engines.projects.getStats(projectDir);
|
const stats = await engines.projects.getStats(projectDir);
|
||||||
|
|
||||||
// Project title + progress
|
// Project title + progress
|
||||||
const section1 = document.createElement('div');
|
const section1 = document.createElement('div');
|
||||||
@@ -112,8 +124,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
compileBtn.className = 'ws-btn ws-btn-primary';
|
compileBtn.className = 'ws-btn ws-btn-primary';
|
||||||
compileBtn.id = 'ws-compile';
|
compileBtn.id = 'ws-compile';
|
||||||
compileBtn.textContent = 'Compile Manuscript';
|
compileBtn.textContent = 'Compile Manuscript';
|
||||||
compileBtn.addEventListener('click', () => {
|
compileBtn.addEventListener('click', async () => {
|
||||||
const compiled = engines.projects.compileManuscript(projectDir);
|
const compiled = await engines.projects.compileManuscript(projectDir);
|
||||||
editor.insertAtCursor(compiled);
|
editor.insertAtCursor(compiled);
|
||||||
});
|
});
|
||||||
section3.appendChild(compileBtn);
|
section3.appendChild(compileBtn);
|
||||||
@@ -122,4 +134,42 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
container.appendChild(panel);
|
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 };
|
module.exports = { renderManuscriptPanel };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function renderSnapshotsPanel(container, { engines, editor }) {
|
async function renderSnapshotsPanel(container, { engines, editor }) {
|
||||||
const snapshots = engines.snapshots.list();
|
const snapshots = await engines.snapshots.list();
|
||||||
|
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
|
|
||||||
@@ -53,16 +53,16 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||||
actionBtn.textContent = text;
|
actionBtn.textContent = text;
|
||||||
actionBtn.addEventListener('click', () => {
|
actionBtn.addEventListener('click', async () => {
|
||||||
if (action === 'restore') {
|
if (action === 'restore') {
|
||||||
const content = engines.snapshots.restore(s.id);
|
const content = await engines.snapshots.restore(s.id);
|
||||||
editor.insertAtCursor(content);
|
editor.insertAtCursor(content);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
engines.snapshots.delete(s.id);
|
await engines.snapshots.delete(s.id);
|
||||||
renderSnapshotsPanel(container, { engines, editor });
|
renderSnapshotsPanel(container, { engines, editor });
|
||||||
} else if (action === 'diff') {
|
} else if (action === 'diff') {
|
||||||
const current = editor.getContent() || '';
|
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');
|
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -75,9 +75,9 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
container.appendChild(panel);
|
container.appendChild(panel);
|
||||||
|
|
||||||
// Take snapshot button handler
|
// Take snapshot button handler
|
||||||
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
|
container.querySelector('#ws-take-snapshot').addEventListener('click', async () => {
|
||||||
const content = editor.getContent() || '';
|
const content = editor.getContent() || '';
|
||||||
engines.snapshots.create(content, 'manual');
|
await engines.snapshots.create(content, 'manual');
|
||||||
renderSnapshotsPanel(container, { engines, editor });
|
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 {
|
class ProjectManager {
|
||||||
/**
|
/**
|
||||||
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
||||||
@@ -6,7 +14,7 @@ class ProjectManager {
|
|||||||
this.fs = fs;
|
this.fs = fs;
|
||||||
}
|
}
|
||||||
|
|
||||||
createProject(dir, opts) {
|
async createProject(dir, opts) {
|
||||||
const project = {
|
const project = {
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
type: opts.type || 'manuscript',
|
type: opts.type || 'manuscript',
|
||||||
@@ -14,51 +22,53 @@ class ProjectManager {
|
|||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: opts.metadata || {},
|
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;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProject(dir) {
|
async loadProject(dir) {
|
||||||
const raw = this.fs.readFile(dir + '/.project.json');
|
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;
|
if (!raw) return null;
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveProject(dir, project) {
|
async _saveProject(dir, project) {
|
||||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
addChapter(dir, chapter) {
|
async addChapter(dir, chapter) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
project.chapters.push(chapter);
|
project.chapters.push(chapter);
|
||||||
this._saveProject(dir, project);
|
await this._saveProject(dir, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChapter(dir, index, updates) {
|
async updateChapter(dir, index, updates) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
Object.assign(project.chapters[index], updates);
|
Object.assign(project.chapters[index], updates);
|
||||||
this._saveProject(dir, project);
|
await this._saveProject(dir, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
compileManuscript(dir) {
|
async compileManuscript(dir) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (const ch of project.chapters) {
|
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);
|
if (content) parts.push(content);
|
||||||
}
|
}
|
||||||
return parts.join('\n\n---\n\n');
|
return parts.join('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats(dir) {
|
async getStats(dir) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
let totalWords = 0;
|
let totalWords = 0;
|
||||||
for (const ch of project.chapters) {
|
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;
|
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
||||||
}
|
}
|
||||||
const target = project.target.words || 0;
|
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 {
|
class SnapshotManager {
|
||||||
/**
|
/**
|
||||||
* @param {object} store - { get(key), set(key, value) }
|
* @param {object} store - { get(key), set(key, value) }
|
||||||
@@ -8,17 +14,17 @@ class SnapshotManager {
|
|||||||
this.storeKey = storeKey;
|
this.storeKey = storeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAll() {
|
async _getAll() {
|
||||||
const raw = this.store.get(this.storeKey);
|
const raw = await this.store.get(this.storeKey);
|
||||||
return raw ? JSON.parse(raw) : [];
|
return raw ? JSON.parse(raw) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveAll(snaps) {
|
async _saveAll(snaps) {
|
||||||
this.store.set(this.storeKey, JSON.stringify(snaps));
|
await this.store.set(this.storeKey, JSON.stringify(snaps));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(content, label = 'manual') {
|
async create(content, label = 'manual') {
|
||||||
const snaps = this._getAll();
|
const snaps = await this._getAll();
|
||||||
const snap = {
|
const snap = {
|
||||||
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -27,31 +33,31 @@ class SnapshotManager {
|
|||||||
label,
|
label,
|
||||||
};
|
};
|
||||||
snaps.unshift(snap);
|
snaps.unshift(snap);
|
||||||
this._saveAll(snaps);
|
await this._saveAll(snaps);
|
||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
list() {
|
async list() {
|
||||||
return this._getAll();
|
return this._getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(id) {
|
async getById(id) {
|
||||||
return this._getAll().find((s) => s.id === id) || null;
|
return (await this._getAll()).find((s) => s.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(id) {
|
async restore(id) {
|
||||||
const snap = this.getById(id);
|
const snap = await this.getById(id);
|
||||||
if (!snap) throw new Error('Snapshot not found');
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
return snap.content;
|
return snap.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id) {
|
async delete(id) {
|
||||||
const snaps = this._getAll().filter((s) => s.id !== id);
|
const snaps = (await this._getAll()).filter((s) => s.id !== id);
|
||||||
this._saveAll(snaps);
|
await this._saveAll(snaps);
|
||||||
}
|
}
|
||||||
|
|
||||||
diff(id, currentContent) {
|
async diff(id, currentContent) {
|
||||||
const snap = this.getById(id);
|
const snap = await this.getById(id);
|
||||||
if (!snap) throw new Error('Snapshot not found');
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
const oldLines = snap.content.split('\n');
|
const oldLines = snap.content.split('\n');
|
||||||
const newLines = currentContent.split('\n');
|
const newLines = currentContent.split('\n');
|
||||||
@@ -68,9 +74,9 @@ class SnapshotManager {
|
|||||||
return { added, removed };
|
return { added, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
prune(keepCount) {
|
async prune(keepCount) {
|
||||||
const snaps = this._getAll();
|
const snaps = await this._getAll();
|
||||||
this._saveAll(snaps.slice(0, keepCount));
|
await this._saveAll(snaps.slice(0, keepCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -4184,7 +4184,12 @@ ipcRenderer.on('show-table-generator', () => {
|
|||||||
|
|
||||||
// Show PDF Editor Dialog
|
// Show PDF Editor Dialog
|
||||||
ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => {
|
ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => {
|
||||||
currentPDFOperation = operation;
|
if (!operation) {
|
||||||
|
// File > Open PDF sends null — legit, but log unexpected variants so
|
||||||
|
// stray senders are traceable in the console.
|
||||||
|
console.info('[pdf-editor] opening with default section; operation =', operation);
|
||||||
|
}
|
||||||
|
currentPDFOperation = operation || 'merge';
|
||||||
showPDFEditorDialog(operation, openedFilePath);
|
showPDFEditorDialog(operation, openedFilePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4373,8 +4378,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title.textContent = titleText;
|
|
||||||
document.getElementById(sectionId).classList.remove('hidden');
|
// Unknown/null operations (e.g. File > Open PDF sends null meaning "open
|
||||||
|
// the editor with this file") fall back to the merge section instead of
|
||||||
|
// crashing on a missing element.
|
||||||
|
const section = document.getElementById(sectionId || 'pdf-merge-section');
|
||||||
|
title.textContent = titleText || 'PDF Editor';
|
||||||
|
if (section) section.classList.remove('hidden');
|
||||||
window.modals.pdfEditorModal.open();
|
window.modals.pdfEditorModal.open();
|
||||||
}
|
}
|
||||||
function hidePDFEditorDialog() {
|
function hidePDFEditorDialog() {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* @module BacklinksPanel
|
* @module BacklinksPanel
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { collectMarkdownFiles, findBacklinks } = require('../../utils/backlinks');
|
const { collectMarkdownFiles, findBacklinks } = require('../utils/backlinks');
|
||||||
const { docNameFor } = require('../../utils/wiki-links');
|
const { docNameFor } = require('../utils/wiki-links');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the backlinks panel.
|
* Render the backlinks panel.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ function makeIo(initial = {}) {
|
|||||||
const files = new Map(Object.entries(initial));
|
const files = new Map(Object.entries(initial));
|
||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
readFile: (p) => (files.has(p) ? files.get(p) : null),
|
readFile: async (p) => (files.has(p) ? files.get(p) : null),
|
||||||
writeFile: (p, c) => {
|
writeFile: async (p, c) => {
|
||||||
files.set(p, c);
|
files.set(p, c);
|
||||||
},
|
},
|
||||||
fileExists: (p) => files.has(p),
|
fileExists: async (p) => files.has(p),
|
||||||
ensureDirectory: () => {},
|
ensureDirectory: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ describe('comment-store', () => {
|
|||||||
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
||||||
|
|
||||||
describe('commentsFilePathFor', () => {
|
describe('commentsFilePathFor', () => {
|
||||||
it('places the sidecar in .comments/ next to the document', () => {
|
it('places the sidecar in .comments/ next to the document', async () => {
|
||||||
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
||||||
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
||||||
path.join('/a/b/.comments', 'c.md.json')
|
path.join('/a/b/.comments', 'c.md.json')
|
||||||
@@ -33,29 +33,29 @@ describe('comment-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('load/save round-trip', () => {
|
describe('load/save round-trip', () => {
|
||||||
it('returns [] for documents with no sidecar yet', () => {
|
it('returns [] for documents with no sidecar yet', async () => {
|
||||||
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
|
expect(await store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns [] for corrupt JSON instead of throwing', () => {
|
it('returns [] for corrupt JSON instead of throwing', async () => {
|
||||||
const io = makeIo({ [sidecar]: '{oops' });
|
const io = makeIo({ [sidecar]: '{oops' });
|
||||||
expect(store.loadComments(doc, io, path)).toEqual([]);
|
expect(await store.loadComments(doc, io, path)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists and reloads comments sorted by line', () => {
|
it('persists and reloads comments sorted by line', async () => {
|
||||||
const io = makeIo();
|
const io = makeIo();
|
||||||
const comments = [];
|
const comments = [];
|
||||||
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
|
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
|
||||||
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
|
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
|
||||||
store.saveComments(doc, comments, io, path);
|
await store.saveComments(doc, comments, io, path);
|
||||||
|
|
||||||
const loaded = store.loadComments(doc, io, path);
|
const loaded = await store.loadComments(doc, io, path);
|
||||||
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
|
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
|
||||||
expect(loaded[0].text).toBe('strong opener');
|
expect(loaded[0].text).toBe('strong opener');
|
||||||
expect(loaded[0].resolved).toBe(false);
|
expect(loaded[0].resolved).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes malformed entries on load', () => {
|
it('normalizes malformed entries on load', async () => {
|
||||||
const io = makeIo({
|
const io = makeIo({
|
||||||
[sidecar]: JSON.stringify({
|
[sidecar]: JSON.stringify({
|
||||||
comments: [
|
comments: [
|
||||||
@@ -64,7 +64,7 @@ describe('comment-store', () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const loaded = store.loadComments(doc, io, path);
|
const loaded = await store.loadComments(doc, io, path);
|
||||||
// '7' (string line) is dropped; the valid one survives normalization
|
// '7' (string line) is dropped; the valid one survives normalization
|
||||||
expect(loaded).toHaveLength(1);
|
expect(loaded).toHaveLength(1);
|
||||||
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
|
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
|
||||||
@@ -72,7 +72,7 @@ describe('comment-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mutators', () => {
|
describe('mutators', () => {
|
||||||
it('addComment clamps lines and caps field lengths', () => {
|
it('addComment clamps lines and caps field lengths', async () => {
|
||||||
const comments = [];
|
const comments = [];
|
||||||
const c = store.addComment(comments, {
|
const c = store.addComment(comments, {
|
||||||
line: -5,
|
line: -5,
|
||||||
@@ -86,7 +86,7 @@ describe('comment-store', () => {
|
|||||||
expect(c.id).toBeTruthy();
|
expect(c.id).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggleResolved flips state and returns null for unknown ids', () => {
|
it('toggleResolved flips state and returns null for unknown ids', async () => {
|
||||||
const comments = [];
|
const comments = [];
|
||||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||||
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
||||||
@@ -94,7 +94,7 @@ describe('comment-store', () => {
|
|||||||
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deleteComment reports whether anything was removed', () => {
|
it('deleteComment reports whether anything was removed', async () => {
|
||||||
const comments = [];
|
const comments = [];
|
||||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||||
expect(store.deleteComment(comments, c.id)).toBe(true);
|
expect(store.deleteComment(comments, c.id)).toBe(true);
|
||||||
@@ -104,7 +104,7 @@ describe('comment-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('nextUnresolved (F8 navigation)', () => {
|
describe('nextUnresolved (F8 navigation)', () => {
|
||||||
it('returns the first open comment below the cursor line', () => {
|
it('returns the first open comment below the cursor line', async () => {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ id: 'a', line: 5, resolved: false },
|
{ id: 'a', line: 5, resolved: false },
|
||||||
{ id: 'b', line: 20, resolved: false },
|
{ id: 'b', line: 20, resolved: false },
|
||||||
@@ -112,7 +112,7 @@ describe('comment-store', () => {
|
|||||||
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps to the top when no open comments are below', () => {
|
it('wraps to the top when no open comments are below', async () => {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ id: 'a', line: 5, resolved: false },
|
{ id: 'a', line: 5, resolved: false },
|
||||||
{ id: 'b', line: 20, resolved: true },
|
{ id: 'b', line: 20, resolved: true },
|
||||||
@@ -120,7 +120,7 @@ describe('comment-store', () => {
|
|||||||
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when everything is resolved or empty', () => {
|
it('returns null when everything is resolved or empty', async () => {
|
||||||
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
||||||
expect(store.nextUnresolved([], 1)).toBeNull();
|
expect(store.nextUnresolved([], 1)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -129,20 +129,20 @@ describe('comment-store', () => {
|
|||||||
describe('anchorStatus (drift detection)', () => {
|
describe('anchorStatus (drift detection)', () => {
|
||||||
const base = { line: 2, anchorText: 'original text' };
|
const base = { line: 2, anchorText: 'original text' };
|
||||||
|
|
||||||
it('reports ok when the line still matches', () => {
|
it('reports ok when the line still matches', async () => {
|
||||||
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports changed when the line text differs', () => {
|
it('reports changed when the line text differs', async () => {
|
||||||
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports missing when the line is beyond the document', () => {
|
it('reports missing when the line is beyond the document', async () => {
|
||||||
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
||||||
expect(store.anchorStatus(base, [])).toBe('missing');
|
expect(store.anchorStatus(base, [])).toBe('missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports moved for comments without an anchor snippet', () => {
|
it('reports moved for comments without an anchor snippet', async () => {
|
||||||
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-25
@@ -1,5 +1,8 @@
|
|||||||
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
||||||
|
|
||||||
|
// All GoalTracker methods are async (the real settings backend is the
|
||||||
|
// IPC-backed SettingsStore; these tests inject a synchronous fake, which
|
||||||
|
// `await` handles transparently).
|
||||||
describe('GoalTracker', () => {
|
describe('GoalTracker', () => {
|
||||||
let tracker;
|
let tracker;
|
||||||
let store;
|
let store;
|
||||||
@@ -14,71 +17,89 @@ describe('GoalTracker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addWords records words for today', () => {
|
test('addWords records words for today', async () => {
|
||||||
tracker.addWords(500);
|
await tracker.addWords(500);
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
expect(store['plugins.writing-studio.history']).toBeDefined();
|
expect(store['plugins.writing-studio.history']).toBeDefined();
|
||||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||||
expect(history[today].words).toBe(500);
|
expect(history[today].words).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addWords accumulates across multiple calls', () => {
|
test('addWords accumulates across multiple calls', async () => {
|
||||||
tracker.addWords(300);
|
await tracker.addWords(300);
|
||||||
tracker.addWords(200);
|
await tracker.addWords(200);
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||||
expect(history[today].words).toBe(500);
|
expect(history[today].words).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDailyProgress returns 0 when no history', () => {
|
test('works with a Promise-returning (IPC-style) backend', async () => {
|
||||||
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 });
|
const asyncTracker = new GoalTracker({
|
||||||
|
get: async (key) => store[key],
|
||||||
|
set: async (key, value) => {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await asyncTracker.addWords(250);
|
||||||
|
await expect(asyncTracker.getDailyProgress(1000)).resolves.toEqual({
|
||||||
|
written: 250,
|
||||||
|
goal: 1000,
|
||||||
|
pct: 25,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDailyProgress returns percentage', () => {
|
test('getDailyProgress returns 0 when no history', async () => {
|
||||||
tracker.addWords(500);
|
await expect(tracker.getDailyProgress(1000)).resolves.toEqual({
|
||||||
const progress = tracker.getDailyProgress(1000);
|
written: 0,
|
||||||
|
goal: 1000,
|
||||||
|
pct: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDailyProgress returns percentage', async () => {
|
||||||
|
await tracker.addWords(500);
|
||||||
|
const progress = await tracker.getDailyProgress(1000);
|
||||||
expect(progress.written).toBe(500);
|
expect(progress.written).toBe(500);
|
||||||
expect(progress.pct).toBe(50);
|
expect(progress.pct).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getStreak counts consecutive days meeting goal', () => {
|
test('getStreak counts consecutive days meeting goal', async () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const d = new Date(today);
|
const d = new Date(today);
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const key = d.toISOString().split('T')[0];
|
const key = d.toISOString().split('T')[0];
|
||||||
tracker._setHistoryDay(key, { words: 1200 });
|
await tracker._setHistoryDay(key, { words: 1200 });
|
||||||
}
|
}
|
||||||
const streak = tracker.getStreak(1000);
|
await expect(tracker.getStreak(1000)).resolves.toBe(3);
|
||||||
expect(streak).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getStreak breaks on missed day', () => {
|
test('getStreak breaks on missed day', async () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
await tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||||
const yesterday = new Date(today);
|
const yesterday = new Date(today);
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
await tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||||
const dayBefore = new Date(today);
|
const dayBefore = new Date(today);
|
||||||
dayBefore.setDate(dayBefore.getDate() - 2);
|
dayBefore.setDate(dayBefore.getDate() - 2);
|
||||||
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
await tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||||
expect(tracker.getStreak(1000)).toBe(1);
|
await expect(tracker.getStreak(1000)).resolves.toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getLast30Days returns array of 30 entries', () => {
|
test('getLast30Days returns array of 30 entries', async () => {
|
||||||
tracker.addWords(100);
|
await tracker.addWords(100);
|
||||||
const days = tracker.getLast30Days();
|
const days = await tracker.getLast30Days();
|
||||||
expect(days.length).toBe(30);
|
expect(days.length).toBe(30);
|
||||||
expect(days[29].words).toBe(100);
|
expect(days[29].words).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getWeeklyTotal sums last 7 days', () => {
|
test('getWeeklyTotal sums last 7 days', async () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const d = new Date(today);
|
const d = new Date(today);
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
await tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||||
}
|
}
|
||||||
expect(tracker.getWeeklyTotal()).toBe(1400);
|
await expect(tracker.getWeeklyTotal()).resolves.toBe(1400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ describe('AiProviders', () => {
|
|||||||
it('does not require an API key for local providers', () => {
|
it('does not require an API key for local providers', () => {
|
||||||
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
|
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
|
||||||
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
|
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
|
||||||
|
// Self-hosted Anthropic-compatible proxies may front their own auth
|
||||||
|
expect(() =>
|
||||||
|
resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' })
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires an API key for remote providers', () => {
|
it('requires an API key for remote providers', () => {
|
||||||
@@ -109,6 +113,52 @@ describe('AiProviders', () => {
|
|||||||
expect(body.system).toBe('be nice');
|
expect(body.system).toBe('be nice');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => {
|
||||||
|
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] });
|
||||||
|
const result = await complete(
|
||||||
|
{
|
||||||
|
provider: 'anthropic-compatible',
|
||||||
|
baseUrl: 'http://localhost:4000',
|
||||||
|
apiKey: 'proxy-key',
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
{ fetchImpl }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content).toBe('proxy ok');
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0];
|
||||||
|
// Base without a /v1 suffix gets one appended
|
||||||
|
expect(url).toBe('http://localhost:4000/v1/messages');
|
||||||
|
// Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it
|
||||||
|
expect(init.headers['x-api-key']).toBe('proxy-key');
|
||||||
|
expect(init.headers.Authorization).toBe('Bearer proxy-key');
|
||||||
|
expect(init.headers['anthropic-version']).toBe('2023-06-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double the /v1 segment when the base already ends with it', async () => {
|
||||||
|
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] });
|
||||||
|
await complete(
|
||||||
|
{
|
||||||
|
provider: 'anthropic-compatible',
|
||||||
|
baseUrl: 'http://localhost:4000/v1/',
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
{ fetchImpl }
|
||||||
|
);
|
||||||
|
// Trailing slash is stripped by resolveSettings, so the base ends in /v1
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4000/v1/messages');
|
||||||
|
// Keyless proxies must work without auth headers
|
||||||
|
expect(init.headers.Authorization).toBeUndefined();
|
||||||
|
expect(init.headers['x-api-key']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anthropic-compatible without a base URL', async () => {
|
||||||
|
await expect(
|
||||||
|
complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) })
|
||||||
|
).rejects.toThrow(/Invalid API base URL/);
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces HTTP failures as user-safe errors without the body', async () => {
|
it('surfaces HTTP failures as user-safe errors without the body', async () => {
|
||||||
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||||
|
|
||||||
|
// All ProjectManager methods are async (the real fs backend is the IPC-backed
|
||||||
|
// file API; these tests inject a synchronous fake, which `await` handles).
|
||||||
describe('ProjectManager', () => {
|
describe('ProjectManager', () => {
|
||||||
let pm;
|
let pm;
|
||||||
let files;
|
let files;
|
||||||
@@ -19,8 +21,8 @@ describe('ProjectManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createProject writes .project.json', () => {
|
test('createProject writes .project.json', async () => {
|
||||||
const project = pm.createProject('/manuscripts/novel', {
|
const project = await pm.createProject('/manuscripts/novel', {
|
||||||
title: 'My Novel',
|
title: 'My Novel',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
targetWords: 80000,
|
targetWords: 80000,
|
||||||
@@ -31,7 +33,7 @@ describe('ProjectManager', () => {
|
|||||||
expect(parsed.target.words).toBe(80000);
|
expect(parsed.target.words).toBe(80000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loadProject reads and returns project data', () => {
|
test('loadProject reads and returns project data', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -39,15 +41,15 @@ describe('ProjectManager', () => {
|
|||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
const project = pm.loadProject('/manuscripts/novel');
|
const project = await pm.loadProject('/manuscripts/novel');
|
||||||
expect(project.title).toBe('Test');
|
expect(project.title).toBe('Test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loadProject returns null if no project file', () => {
|
test('loadProject returns null if no project file', async () => {
|
||||||
expect(pm.loadProject('/nonexistent')).toBeNull();
|
await expect(pm.loadProject('/nonexistent')).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addChapter appends chapter and saves', () => {
|
test('addChapter appends chapter and saves', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -55,7 +57,7 @@ describe('ProjectManager', () => {
|
|||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
pm.addChapter('/manuscripts/novel', {
|
await pm.addChapter('/manuscripts/novel', {
|
||||||
file: '01-chapter.md',
|
file: '01-chapter.md',
|
||||||
title: 'Chapter One',
|
title: 'Chapter One',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
@@ -65,7 +67,7 @@ describe('ProjectManager', () => {
|
|||||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compileManuscript concatenates chapter files', () => {
|
test('compileManuscript concatenates chapter files', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -78,11 +80,11 @@ describe('ProjectManager', () => {
|
|||||||
});
|
});
|
||||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||||
const result = pm.compileManuscript('/manuscripts/novel');
|
const result = await pm.compileManuscript('/manuscripts/novel');
|
||||||
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compileManuscript skips missing files', () => {
|
test('compileManuscript skips missing files', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -94,11 +96,11 @@ describe('ProjectManager', () => {
|
|||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||||
const result = pm.compileManuscript('/manuscripts/novel');
|
const result = await pm.compileManuscript('/manuscripts/novel');
|
||||||
expect(result).toBe('Only chapter one.');
|
expect(result).toBe('Only chapter one.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getStats returns total word count across chapters', () => {
|
test('getStats returns total word count across chapters', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -111,14 +113,14 @@ describe('ProjectManager', () => {
|
|||||||
});
|
});
|
||||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||||
const stats = pm.getStats('/manuscripts/novel');
|
const stats = await pm.getStats('/manuscripts/novel');
|
||||||
expect(stats.totalWords).toBeGreaterThan(0);
|
expect(stats.totalWords).toBeGreaterThan(0);
|
||||||
expect(stats.chapterCount).toBe(2);
|
expect(stats.chapterCount).toBe(2);
|
||||||
expect(stats.targetWords).toBe(50000);
|
expect(stats.targetWords).toBe(50000);
|
||||||
expect(stats.pctComplete).toBeDefined();
|
expect(stats.pctComplete).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateChapter modifies a chapter by index', () => {
|
test('updateChapter modifies a chapter by index', async () => {
|
||||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
type: 'manuscript',
|
type: 'manuscript',
|
||||||
@@ -126,7 +128,7 @@ describe('ProjectManager', () => {
|
|||||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
await pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
expect(parsed.chapters[0].title).toBe('New Title');
|
expect(parsed.chapters[0].title).toBe('New Title');
|
||||||
expect(parsed.chapters[0].status).toBe('revised');
|
expect(parsed.chapters[0].status).toBe('revised');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
||||||
|
|
||||||
|
// All SnapshotManager methods are async (the real settings backend is the
|
||||||
|
// IPC-backed SettingsStore; these tests inject a synchronous fake).
|
||||||
describe('SnapshotManager', () => {
|
describe('SnapshotManager', () => {
|
||||||
let manager;
|
let manager;
|
||||||
let store;
|
let store;
|
||||||
@@ -14,62 +16,63 @@ describe('SnapshotManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create stores snapshot with timestamp, content, wordCount', () => {
|
test('create stores snapshot with timestamp, content, wordCount', async () => {
|
||||||
const snap = manager.create('Hello world this is a test', 'auto');
|
const snap = await manager.create('Hello world this is a test', 'auto');
|
||||||
expect(snap).toHaveProperty('id');
|
expect(snap).toHaveProperty('id');
|
||||||
expect(snap.content).toBe('Hello world this is a test');
|
expect(snap.content).toBe('Hello world this is a test');
|
||||||
expect(snap.wordCount).toBe(6);
|
expect(snap.wordCount).toBe(6);
|
||||||
expect(snap.label).toBe('auto');
|
expect(snap.label).toBe('auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list returns snapshots ordered newest first', () => {
|
test('list returns snapshots ordered newest first', async () => {
|
||||||
manager.create('first', 'auto');
|
await manager.create('first', 'auto');
|
||||||
manager.create('second', 'auto');
|
await manager.create('second', 'auto');
|
||||||
const list = manager.list();
|
const list = await manager.list();
|
||||||
expect(list.length).toBe(2);
|
expect(list.length).toBe(2);
|
||||||
expect(list[0].content).toBe('second');
|
expect(list[0].content).toBe('second');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getById returns specific snapshot', () => {
|
test('getById returns specific snapshot', async () => {
|
||||||
const snap = manager.create('find me', 'manual');
|
const snap = await manager.create('find me', 'manual');
|
||||||
const found = manager.getById(snap.id);
|
const found = await manager.getById(snap.id);
|
||||||
expect(found.content).toBe('find me');
|
expect(found.content).toBe('find me');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getById returns null for missing id', () => {
|
test('getById returns null for missing id', async () => {
|
||||||
expect(manager.getById('nope')).toBeNull();
|
await expect(manager.getById('nope')).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restore returns content of snapshot', () => {
|
test('restore returns content of snapshot', async () => {
|
||||||
const snap = manager.create('restore this', 'manual');
|
const snap = await manager.create('restore this', 'manual');
|
||||||
expect(manager.restore(snap.id)).toBe('restore this');
|
await expect(manager.restore(snap.id)).resolves.toBe('restore this');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restore throws for missing snapshot', () => {
|
test('restore throws for missing snapshot', async () => {
|
||||||
expect(() => manager.restore('nope')).toThrow('Snapshot not found');
|
await expect(manager.restore('nope')).rejects.toThrow('Snapshot not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete removes a snapshot', () => {
|
test('delete removes a snapshot', async () => {
|
||||||
const snap = manager.create('delete me', 'auto');
|
const snap = await manager.create('delete me', 'auto');
|
||||||
manager.delete(snap.id);
|
await manager.delete(snap.id);
|
||||||
expect(manager.getById(snap.id)).toBeNull();
|
await expect(manager.getById(snap.id)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('diff returns added/removed line counts', () => {
|
test('diff returns added/removed line counts', async () => {
|
||||||
const snap = manager.create('line one\nline two\nline three', 'auto');
|
const snap = await manager.create('line one\nline two\nline three', 'auto');
|
||||||
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
const result = await manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||||
expect(result.added).toBe(2);
|
expect(result.added).toBe(2);
|
||||||
expect(result.removed).toBe(1);
|
expect(result.removed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('diff throws for missing snapshot', () => {
|
test('diff throws for missing snapshot', async () => {
|
||||||
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found');
|
await expect(manager.diff('nope', 'new content')).rejects.toThrow('Snapshot not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prune keeps only the N most recent snapshots', () => {
|
test('prune keeps only the N most recent snapshots', async () => {
|
||||||
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto');
|
for (let i = 0; i < 10; i++) await manager.create('snap ' + i, 'auto');
|
||||||
manager.prune(5);
|
await manager.prune(5);
|
||||||
expect(manager.list().length).toBe(5);
|
const list = await manager.list();
|
||||||
expect(manager.list()[0].content).toBe('snap 9');
|
expect(list.length).toBe(5);
|
||||||
|
expect(list[0].content).toBe('snap 9');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user