feat: integrate todo plugin into desktop workspace

This commit is contained in:
mirivlad 2026-07-10 21:09:02 +08:00
parent 8dafde4519
commit 2007b909d1
6 changed files with 649 additions and 23 deletions

View File

@ -78,7 +78,7 @@ test.describe('E: Plugin Manager layout', () => {
const health = page.locator('[data-plugin-manager-summary="health"]');
await expect(health).toBeVisible();
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('10');
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('11');
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');

148
frontend/e2e/todo.spec.js Normal file
View File

@ -0,0 +1,148 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Todo plugin workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace Todo supports CRUD, reminders, and manual Journal conversion', async ({ page }) => {
await page.getByRole('tab', { name: 'Todos' }).click();
const todos = page.locator('.todo-root');
await expect(todos).toBeVisible({ timeout: 10000 });
await expect(todos).toContainText('Todos · Project');
await todos.locator('[data-todo-action="add"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review');
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
await expect(todos).toContainText('Reminder due');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01T09:00');
await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
await todos.locator('[data-todo-action="create-journal-entry"]').click();
const journal = page.locator('.journal-root');
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal).toContainText('Create journal entry from completed todo');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('Prepare project review updated');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('Collect factual review notes.');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('0');
await journal.locator('[data-journal-input="title"]').fill('Prepare project review handoff');
await journal.locator('[data-journal-input="summary"]').fill('Reviewed factual project notes before handoff.');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const todoResult = await window.go.api.App.ReadPluginSettings('verstak.todo');
const todoSettings = Array.isArray(todoResult) ? todoResult[0] : todoResult;
const todo = todoSettings['todos:global'].find((item) => item.title === 'Prepare project review updated');
const journalResult = await window.go.api.App.ReadPluginSettings('verstak.journal');
const journalSettings = Array.isArray(journalResult) ? journalResult[0] : journalResult;
const entry = journalSettings['worklog:workspace:Project'].find((item) => item.sourceTodoId === todo.id);
return entry && [entry.title, entry.summary, entry.minutes].join('|');
})).toBe('Prepare project review handoff|Reviewed factual project notes before handoff.|0');
await page.getByRole('tab', { name: 'Todos' }).click();
await page.locator('.todo-root [data-todo-action="delete"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
return settings['todos:global'].some((item) => item.title === 'Prepare project review updated');
})).toBe(false);
});
test('global Todos filter by workspace and Overview only exposes current workspace attention', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.todo', {
'todos:global': [
{
id: 'todo-project-open',
title: 'Project deadline',
workspaceRootPath: 'Project',
status: 'open',
priority: 'high',
dueAt: '2000-01-01',
reminderAt: '2000-01-01T09:00',
createdAt: '2026-06-30T08:00:00.000Z',
updatedAt: '2026-06-30T08:00:00.000Z',
},
{
id: 'todo-project-done',
title: 'Project completed item',
workspaceRootPath: 'Project',
status: 'done',
priority: 'normal',
createdAt: '2026-06-30T08:01:00.000Z',
updatedAt: '2026-06-30T08:01:00.000Z',
completedAt: '2026-06-30T08:02:00.000Z',
},
{
id: 'todo-test-open',
title: 'Test workspace item',
workspaceRootPath: 'Test',
status: 'open',
priority: 'normal',
dueAt: '2000-01-01',
createdAt: '2026-06-30T08:03:00.000Z',
updatedAt: '2026-06-30T08:03:00.000Z',
},
],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Project deadline');
await expect(attention).not.toContainText('Test workspace item');
await attention.locator('.overview-attention-row', { hasText: 'Project deadline' }).getByRole('button', { name: 'Open Todos' }).click();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveAttribute('aria-selected', 'true');
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' }).click();
const globalTodos = page.locator('.todo-root');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="workspace"]').selectOption('Project');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="status"]').selectOption('done');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Project deadline');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.getByRole('tab', { name: 'Todos' }).click();
const workspaceTodos = page.locator('.todo-root');
await expect(workspaceTodos).toContainText('Test workspace item');
await expect(workspaceTodos).not.toContainText('Project deadline');
});
});

View File

@ -31,13 +31,15 @@
let captures = [];
let activityEvents = [];
let journalEntries = [];
let todos = [];
let workSessionCandidates = [];
let keyResources = [];
let totalNotes = 0;
let loadedWorkspaceRoot = '';
let toolProbe = 0;
$: hasNotes = hasTool('notes');
$: hasNotes = hasTool('notes', availableTools);
$: hasTodos = hasTool('todo', availableTools);
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: filteredRecentChanges = activeFilter === 'all'
? recentChanges
@ -47,10 +49,11 @@
$: unprocessedCaptures = captures.filter(item => item?.processed !== true);
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries);
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries);
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [
{ key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' },
{ key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' },
@ -68,10 +71,10 @@
loadOverview();
}
function hasTool(name) {
function hasTool(name, tools = availableTools) {
toolProbe;
name = String(name || '').toLowerCase();
const fromProps = (availableTools || []).some(tool => {
const fromProps = (tools || []).some(tool => {
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name);
});
@ -129,6 +132,47 @@
});
}
function todoRowsForWorkspace(settings) {
const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return [];
const seen = new Set();
return normalizeRows(settings?.['todos:global']).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
if (tagged !== workspace) return false;
const todoId = String(item.id || '');
if (!todoId || seen.has(todoId)) return !todoId;
seen.add(todoId);
return true;
});
}
function todoDateMs(value) {
const raw = String(value || '').trim();
if (!raw) return 0;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
}
function todoTitle(item) {
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
}
function todoAttentionState(item) {
if (String(item?.status || 'open').toLowerCase() !== 'open') return '';
const now = Date.now();
const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return 'Reminder due';
if (dueAt && dueAt < now) return 'Overdue';
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
return '';
}
function todoTimeValue(item) {
return item?.dueAt || item?.reminderAt || item?.updatedAt || item?.createdAt || '';
}
function timeValue(item) {
return item?.capturedAt || item?.endedAt || item?.startedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || '';
}
@ -321,7 +365,17 @@
};
}
function buildContinueItems(events, captureRows, journalRows) {
function buildContinueItems(events, captureRows, journalRows, todoRows) {
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
category: 'todo',
title: `Todo ${quoted(todoTitle(item))}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo',
actionLabel: 'Open Todos',
}));
const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures',
@ -348,10 +402,10 @@
actionKind: 'journal',
actionLabel: 'Open Journal',
}));
return [...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
}
function buildNeedsAttention(captureRows, candidates) {
function buildNeedsAttention(captureRows, candidates, todoRows) {
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item),
@ -367,7 +421,14 @@
actionLabel: 'Review candidate',
toolRequest: { type: 'work-session-candidate', candidate: item },
}));
return [...captureItems, ...candidateItems].slice(0, 6);
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
actionKind: 'todo',
actionLabel: 'Open Todos',
}));
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
}
function countCategory(items, category) {
@ -390,8 +451,8 @@
return `${count} journal entr${count === 1 ? 'y' : 'ies'}`;
}
function lastActiveDate(items, captureRows, journalRows) {
const source = sortByTime([...items, ...captureRows, ...journalRows])[0];
function lastActiveDate(items, captureRows, journalRows, todoRows) {
const source = sortByTime([...items, ...captureRows, ...journalRows, ...todoRows])[0];
return timeValue(source);
}
@ -429,10 +490,11 @@
const workspaceAtStart = String(workspaceRootPath || '').trim();
loadedWorkspaceRoot = workspaceAtStart;
loading = true;
const [browserSettings, activitySettings, journalSettings, resources] = await Promise.all([
const [browserSettings, activitySettings, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'),
loadWorkspaceResources(),
]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
@ -447,6 +509,7 @@
workspaceKey('worklog:workspace:'),
'worklog',
]);
todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'),
]);
@ -583,7 +646,7 @@
<div class="today-panel-head overview-panel-head">
<div>
<h3>Needs attention</h3>
<p>Pending captures and possible journal entries.</p>
<p>Pending captures, urgent todos, and possible journal entries.</p>
</div>
</div>
{#if loading}
@ -599,7 +662,7 @@
{/each}
</div>
{:else}
<p class="today-empty compact">No pending captures or possible journal entries.</p>
<p class="today-empty compact">No pending captures, urgent todos, or possible journal entries.</p>
{/if}
</section>
{/if}

View File

@ -23,6 +23,7 @@
const toolOrder = new Map([
['notes', 10],
['files', 20],
['todo', 30],
['activity', 40],
['browser', 50],
['inbox', 50],

View File

@ -254,6 +254,7 @@
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
error: ''
},
'verstak.todo': makeTodoPluginState(),
'verstak.search': {
status: 'loaded',
enabled: true,
@ -285,6 +286,8 @@
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
vaultPluginState.enabledPlugins.push('verstak.trash');
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.todo');
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {};
var openedResources = [];
@ -375,6 +378,33 @@
};
}
function makeTodoPluginState() {
return {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.todo',
name: 'Todos',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global and workspace todo tracking with due and reminder metadata.',
source: 'official',
icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'],
permissions: ['files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
sidebarItems: [{ id: 'verstak.todo.sidebar', title: 'Todos', icon: 'list-todo', view: 'verstak.todo.view', position: 35 }],
workspaceItems: [{ id: 'verstak.todo.workspace', title: 'Todos', icon: 'list-todo', component: 'TodoView' }]
}
},
rootPath: '/tmp/verstak-test/plugins/todo',
error: ''
};
}
function makeDefaultSyncState() {
return {
configured: false,
@ -1942,6 +1972,19 @@
};
}
function completedTodoFromProps(props, root) {
var request = props && props.toolRequest;
var todo = request && request.type === 'completed-todo' ? request.todo : null;
if (!todo || todo.workspaceRootPath !== root || !todo.id || !todo.title) return null;
return {
id: String(todo.id),
title: String(todo.title),
description: String(todo.description || todo.body || ''),
workspaceRootPath: root,
completedAt: String(todo.completedAt || '')
};
}
function candidateDate(value) {
var date = new Date(value || '');
return isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
@ -1984,12 +2027,13 @@
return api.settings.write(key, entries);
}
function showEntryModal(candidate) {
function showEntryModal(candidate, completedTodo) {
var reviewing = !!candidate;
var titleInput = el('input', { className: 'journal-input', type: 'text', value: '', 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', value: '', 'data-journal-input': 'summary' });
var minutesInput = el('input', { className: 'journal-input', type: 'number', value: reviewing ? String(candidate.estimatedMinutes) : '30', 'data-journal-input': 'minutes' });
var dateInput = el('input', { className: 'journal-input', type: 'date', value: reviewing ? candidateDate(candidate.startedAt) : new Date().toISOString().slice(0, 10), 'data-journal-input': 'date' });
var reviewingTodo = !reviewing && !!completedTodo;
var titleInput = el('input', { className: 'journal-input', type: 'text', value: reviewingTodo ? completedTodo.title : '', 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', value: reviewingTodo ? completedTodo.description : '', 'data-journal-input': 'summary' });
var minutesInput = el('input', { className: 'journal-input', type: 'number', value: reviewing ? String(candidate.estimatedMinutes) : (reviewingTodo ? '0' : '30'), 'data-journal-input': 'minutes' });
var dateInput = el('input', { className: 'journal-input', type: 'date', value: reviewing ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : new Date().toISOString().slice(0, 10)), 'data-journal-input': 'date' });
var billableInput = el('input', { type: 'checkbox', checked: false, 'data-journal-input': 'billable' });
var activityInputs = reviewing ? candidate.activities.map(function (activity) {
return { activity: activity, input: el('input', { type: 'checkbox', checked: true, 'data-journal-candidate-activity': activity.activityId }) };
@ -2001,6 +2045,11 @@
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: 'Activities: ' + candidate.activityCount })
]) : null;
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
el('strong', { textContent: 'Completed todo' }),
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }),
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null
]) : null;
var linked = reviewing ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: 'Linked activities' })
].concat(activityInputs.map(function (item) {
@ -2010,6 +2059,7 @@
function save() {
var title = String(titleInput.value || '').trim();
if (!title) return;
if (reviewingTodo && entries.some(function (entry) { return entry.sourceTodoId === completedTodo.id; })) return;
var entry = {
entryId: 'journal:' + Date.now(),
workspaceRootPath: rootPath,
@ -2019,6 +2069,7 @@
minutes: Number(minutesInput.value || 0),
billable: billableInput.checked === true,
sourceCandidateId: reviewing ? candidate.candidateId : '',
sourceTodoId: reviewingTodo ? completedTodo.id : '',
activityIds: reviewing ? activityInputs.filter(function (item) { return item.input.checked; }).map(function (item) { return item.activity.activityId; }) : []
};
entries = [entry].concat(entries);
@ -2030,8 +2081,9 @@
modalHost.hidden = false;
modalHost.appendChild(el('div', { className: 'journal-modal-overlay' }, [
el('div', { className: 'journal-modal' }, [
el('div', { className: 'journal-modal-title', textContent: reviewing ? 'Review possible journal entry' : 'Add journal entry' }),
el('div', { className: 'journal-modal-title', textContent: reviewing ? 'Review possible journal entry' : (reviewingTodo ? 'Create journal entry from completed todo' : 'Add journal entry') }),
context,
todoContext,
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, ['Date', dateInput]),
el('label', { className: 'journal-field' }, ['Minutes', minutesInput]),
@ -2063,7 +2115,7 @@
el('div', {}, [
el('div', { className: 'journal-entry-title', textContent: entry.title }),
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (activityIds.length ? ' · ' + activityIds.length + ' linked activities' : '') })
el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (activityIds.length ? ' · ' + activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') })
]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' })
]));
@ -2074,7 +2126,9 @@
entries = rows(stored);
render();
var candidate = candidateFromProps(props || {}, rootPath);
var completedTodo = completedTodoFromProps(props || {}, rootPath);
if (candidate) showEntryModal(candidate);
else if (completedTodo) showEntryModal(null, completedTodo);
}).catch(function () { render(); });
render();
};
@ -2525,6 +2579,357 @@
}.toString() + ')();';
}
function todoBundle() {
return '(' + function () {
var PLUGIN_ID = 'verstak.todo';
var GLOBAL_KEY = 'todos:global';
function injectStyles() {
if (document.getElementById('mock-todo-style')) return;
var style = document.createElement('style');
style.id = 'mock-todo-style';
style.textContent = [
'.todo-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}',
'.todo-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}.todo-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.todo-count,.todo-status{font-size:.74rem;color:#8b8ba8}.todo-spacer{flex:1}.todo-filters{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}',
'.todo-input,.todo-select{min-height:1.85rem;box-sizing:border-box;border:1px solid #1a3a5c;border-radius:4px;background:#101020;color:#e0e0f0;padding:.28rem .42rem;font-size:.76rem}.todo-input.search{width:12rem}.todo-input.textarea{min-height:6rem;resize:vertical}',
'.todo-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.todo-btn.primary{background:#4ecca3;border-color:#4ecca3;color:#102018}.todo-btn.danger{border-color:#633;color:#ffb0b0}',
'.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem}.todo-empty{padding:1.5rem;color:#8b8ba8}.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.7rem;padding:.7rem 0;border-bottom:1px solid rgba(22,33,62,.75)}.todo-row.done .todo-row-title{text-decoration:line-through;color:#8b8ba8}.todo-row-title{font-weight:600}.todo-row-description,.todo-row-meta{margin-top:.25rem;font-size:.76rem;color:#aaa}.todo-row-actions{display:flex;justify-content:flex-end;gap:.35rem;flex-wrap:wrap}.todo-badge{display:inline-flex;margin-right:.3rem;padding:.08rem .28rem;border:1px solid #24304f;border-radius:4px}.todo-badge.overdue,.todo-badge.reminder-due{border-color:#633;color:#ffb0b0}.todo-badge.due-soon{border-color:#7a6633;color:#f2d17b}',
'.todo-modal-host[hidden]{display:none}.todo-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.todo-modal{width:540px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid #2c456a;border-radius:8px;background:#15152c;box-shadow:0 18px 44px rgba(0,0,0,.38)}.todo-modal-title{font-size:.95rem;font-weight:600}.todo-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}.todo-field{display:grid;gap:.3rem;font-size:.72rem;color:#8b8ba8}.todo-field.wide{grid-column:1/-1}.todo-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}'
].join('');
document.head.appendChild(style);
}
function el(tag, attrs, children) {
var node = document.createElement(tag);
attrs = attrs || {};
Object.keys(attrs).forEach(function (key) {
if (key === 'textContent') node.textContent = attrs[key];
else if (key === 'className') node.className = attrs[key];
else if (key === 'value') node.value = attrs[key];
else if (key === 'checked') node.checked = !!attrs[key];
else if (key === 'disabled') node.disabled = !!attrs[key];
else if (key.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
else if (key === 'onChange') node.addEventListener('change', attrs[key]);
else if (key === 'onInput') node.addEventListener('input', attrs[key]);
else node[key] = attrs[key];
});
(children || []).forEach(function (child) {
if (child) node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
return node;
}
function text(value) { return String(value == null ? '' : value); }
function rootFromProps(props) {
return text((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId)) || (props && props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id))).trim();
}
function rows(value) { return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : []; }
function now() { return new Date().toISOString(); }
function dateMs(value) {
value = text(value).trim();
if (!value) return 0;
var normalized = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value + 'T00:00:00' : value;
var date = new Date(normalized);
return isNaN(date.getTime()) ? 0 : date.getTime();
}
function todoId(root, title) { return 'todo:' + (root || 'global') + ':' + Date.now() + ':' + text(title).trim().replace(/\s+/g, '-'); }
function normalizeTodo(value) {
value = value || {};
var status = ['open', 'done', 'cancelled'].indexOf(text(value.status).toLowerCase()) === -1 ? 'open' : text(value.status).toLowerCase();
var createdAt = text(value.createdAt).trim() || now();
return {
id: text(value.id).trim() || todoId(text(value.workspaceRootPath).trim(), value.title),
title: text(value.title).trim(),
description: text(value.description || value.body),
workspaceRootPath: text(value.workspaceRootPath || value.workspaceName).trim(),
workspaceName: text(value.workspaceName || value.workspaceRootPath).trim(),
status: status,
priority: ['low', 'normal', 'high'].indexOf(text(value.priority).toLowerCase()) === -1 ? 'normal' : text(value.priority).toLowerCase(),
dueAt: /^\d{4}-\d{2}-\d{2}$/.test(text(value.dueAt)) ? text(value.dueAt) : '',
reminderAt: text(value.reminderAt),
createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: status === 'done' ? (text(value.completedAt).trim() || createdAt) : '',
sourceUrl: text(value.sourceUrl),
linkedJournalEntryId: text(value.linkedJournalEntryId)
};
}
function sortTodos(list, sortMode) {
return list.slice().sort(function (a, b) {
if (sortMode === 'updated') return text(b.updatedAt).localeCompare(text(a.updatedAt));
var aValue = dateMs(sortMode === 'reminder' ? a.reminderAt : a.dueAt) || Number.MAX_SAFE_INTEGER;
var bValue = dateMs(sortMode === 'reminder' ? b.reminderAt : b.dueAt) || Number.MAX_SAFE_INTEGER;
return aValue - bValue || text(b.updatedAt).localeCompare(text(a.updatedAt));
});
}
function dueState(todo) {
var dueAt = dateMs(todo.dueAt);
if (!dueAt || todo.status !== 'open') return '';
var nowMs = Date.now();
if (dueAt < nowMs) return 'overdue';
if (dueAt <= nowMs + 3 * 24 * 60 * 60 * 1000) return 'due-soon';
return '';
}
function reminderIsDue(todo) { return todo.status === 'open' && dateMs(todo.reminderAt) > 0 && dateMs(todo.reminderAt) <= Date.now(); }
function TodoView() {}
TodoView.mount = function (containerEl, props, api) {
injectStyles();
var workspaceRoot = rootFromProps(props || {});
var isWorkspace = !!workspaceRoot;
var todos = [];
var statusFilter = 'all';
var workspaceFilter = '';
var sortMode = 'due';
var searchQuery = '';
var modalHost = el('div', { className: 'todo-modal-host', hidden: true });
var titleEl = el('span', { className: 'todo-title', textContent: isWorkspace ? 'Todos · ' + workspaceRoot : 'Todos' });
var countEl = el('span', { className: 'todo-count' });
var statusEl = el('span', { className: 'todo-status' });
var statusFilterEl = el('select', { className: 'todo-select', 'data-todo-filter': 'status', onChange: function (event) { statusFilter = event.target.value; render(); } }, [
el('option', { value: 'all', textContent: 'All statuses' }),
el('option', { value: 'open', textContent: 'Open' }),
el('option', { value: 'done', textContent: 'Done' }),
el('option', { value: 'cancelled', textContent: 'Cancelled' })
]);
var workspaceFilterEl = el('select', { className: 'todo-select', 'data-todo-filter': 'workspace', onChange: function (event) { workspaceFilter = event.target.value; render(); } });
var sortEl = el('select', { className: 'todo-select', 'data-todo-filter': 'sort', onChange: function (event) { sortMode = event.target.value; render(); } }, [
el('option', { value: 'due', textContent: 'Sort by due date' }),
el('option', { value: 'reminder', textContent: 'Sort by reminder' }),
el('option', { value: 'updated', textContent: 'Sort by updated' })
]);
var searchEl = el('input', { className: 'todo-input search', type: 'search', placeholder: 'Search todos', 'data-todo-filter': 'search', onInput: function (event) { searchQuery = text(event.target.value).trim().toLowerCase(); render(); } });
var addBtn = el('button', { className: 'todo-btn primary', 'data-todo-action': 'add', textContent: 'Add Todo', onClick: function () { showTodoModal(null); } });
var listEl = el('div', { className: 'todo-list' });
var root = el('div', { className: 'todo-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'todo-toolbar' });
var filters = el('div', { className: 'todo-filters' });
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
filters.appendChild(statusFilterEl);
if (!isWorkspace) filters.appendChild(workspaceFilterEl);
filters.appendChild(sortEl);
filters.appendChild(searchEl);
toolbar.appendChild(filters);
toolbar.appendChild(el('span', { className: 'todo-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(addBtn);
root.appendChild(toolbar);
root.appendChild(listEl);
root.appendChild(modalHost);
containerEl.innerHTML = '';
containerEl.appendChild(root);
function workspaceRoots() {
var found = {};
todos.forEach(function (todo) { if (todo.workspaceRootPath) found[todo.workspaceRootPath] = true; });
if (workspaceRoot) found[workspaceRoot] = true;
return Object.keys(found).sort();
}
function renderWorkspaceOptions() {
if (isWorkspace) return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(el('option', { value: '', textContent: 'All workspaces' }));
workspaceFilterEl.appendChild(el('option', { value: '__unassigned__', textContent: 'Unassigned' }));
workspaceRoots().forEach(function (rootName) {
workspaceFilterEl.appendChild(el('option', { value: rootName, textContent: rootName }));
});
workspaceFilterEl.value = workspaceFilter;
}
function visibleTodos() {
return sortTodos(todos.filter(function (todo) {
if (isWorkspace && todo.workspaceRootPath !== workspaceRoot) return false;
if (!isWorkspace && workspaceFilter === '__unassigned__' && todo.workspaceRootPath) return false;
if (!isWorkspace && workspaceFilter && workspaceFilter !== '__unassigned__' && todo.workspaceRootPath !== workspaceFilter) return false;
if (statusFilter !== 'all' && todo.status !== statusFilter) return false;
if (!searchQuery) return true;
return [todo.title, todo.description, todo.workspaceRootPath].join('\n').toLowerCase().indexOf(searchQuery) !== -1;
}), sortMode);
}
function persist() { return api.settings.write(GLOBAL_KEY, todos); }
function closeTodoModal() { modalHost.innerHTML = ''; modalHost.hidden = true; }
function showTodoModal(existingTodo) {
var editing = !!existingTodo;
var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', 'data-todo-input': 'title' });
var descriptionInput = el('textarea', { className: 'todo-input textarea', value: editing ? existingTodo.description : '', 'data-todo-input': 'description' });
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [
el('option', { value: 'low', textContent: 'Low' }),
el('option', { value: 'normal', textContent: 'Normal' }),
el('option', { value: 'high', textContent: 'High' })
]);
priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' });
var workspaceInput = null;
if (!isWorkspace) {
workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' });
workspaceInput.appendChild(el('option', { value: '', textContent: 'Unassigned' }));
workspaceRoots().forEach(function (rootName) { workspaceInput.appendChild(el('option', { value: rootName, textContent: rootName })); });
workspaceInput.value = editing ? existingTodo.workspaceRootPath : '';
}
function saveTodo() {
var title = text(titleInput.value).trim();
if (!title) return;
var rootName = isWorkspace ? workspaceRoot : text(workspaceInput && workspaceInput.value).trim();
var timestamp = now();
var todo = normalizeTodo({
id: editing ? existingTodo.id : todoId(rootName, title),
title: title,
description: descriptionInput.value,
workspaceRootPath: rootName,
workspaceName: rootName,
status: editing ? existingTodo.status : 'open',
priority: priorityInput.value,
dueAt: dueInput.value,
reminderAt: reminderInput.value,
createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '',
sourceUrl: editing ? existingTodo.sourceUrl : '',
linkedJournalEntryId: editing ? existingTodo.linkedJournalEntryId : ''
});
todos = editing ? todos.map(function (item) { return item.id === existingTodo.id ? todo : item; }) : [todo].concat(todos);
closeTodoModal();
statusEl.textContent = editing ? 'Todo updated' : 'Todo added';
persist().then(render);
}
var fields = [
el('label', { className: 'todo-field wide' }, ['Title', titleInput]),
el('label', { className: 'todo-field wide' }, ['Description', descriptionInput]),
el('label', { className: 'todo-field' }, ['Priority', priorityInput]),
el('label', { className: 'todo-field' }, ['Due date', dueInput]),
el('label', { className: 'todo-field' }, ['Reminder', reminderInput])
];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, ['Workspace', workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: 'Workspace: ' + workspaceRoot }));
modalHost.innerHTML = '';
modalHost.hidden = false;
modalHost.appendChild(el('div', { className: 'todo-modal-overlay' }, [
el('div', { className: 'todo-modal' }, [
el('div', { className: 'todo-modal-title', textContent: editing ? 'Edit Todo' : 'Add Todo' }),
el('div', { className: 'todo-form-grid' }, fields),
el('div', { className: 'todo-modal-actions' }, [
el('button', { className: 'todo-btn', textContent: 'Cancel', onClick: closeTodoModal }),
el('button', { className: 'todo-btn primary', 'data-todo-action': 'save', textContent: editing ? 'Save changes' : 'Add Todo', onClick: saveTodo })
])
])
]));
titleInput.focus();
}
function setTodoStatus(todo, status) {
var timestamp = now();
todos = todos.map(function (item) {
return item.id === todo.id ? Object.assign({}, item, { status: status, completedAt: status === 'done' ? timestamp : '', updatedAt: timestamp }) : item;
});
statusEl.textContent = status === 'done' ? 'Todo marked done' : (status === 'cancelled' ? 'Todo cancelled' : 'Todo reopened');
persist().then(render);
}
function deleteTodo(todo) {
todos = todos.filter(function (item) { return item.id !== todo.id; });
statusEl.textContent = 'Todo deleted';
persist().then(render);
}
function openWorkspace(todo) {
if (!todo.workspaceRootPath) return;
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', { detail: { workspaceName: todo.workspaceRootPath } }));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { detail: { kind: 'todo' } }));
}
function createJournalEntry(todo) {
if (!isWorkspace || todo.status !== 'done') return;
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: {
kind: 'journal',
toolRequest: {
type: 'completed-todo',
todo: {
id: todo.id,
title: todo.title,
description: todo.description,
workspaceRootPath: workspaceRoot,
completedAt: todo.completedAt
}
}
}
}));
}
function todoMeta(todo) {
var due = dueState(todo);
var reminderDue = reminderIsDue(todo);
var badges = [
el('span', { className: 'todo-badge', textContent: todo.priority + ' priority' }),
el('span', { className: 'todo-badge', textContent: todo.status })
];
if (!isWorkspace) badges.unshift(el('span', { className: 'todo-badge', textContent: todo.workspaceRootPath || 'Unassigned' }));
if (todo.dueAt) badges.push(el('span', { className: 'todo-badge ' + due, textContent: (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + todo.dueAt }));
if (todo.reminderAt) badges.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: (reminderDue ? 'Reminder due ' : 'Reminder ') + todo.reminderAt }));
return el('div', { className: 'todo-row-meta' }, badges);
}
function renderList() {
var visible = visibleTodos();
listEl.innerHTML = '';
if (!visible.length) {
listEl.appendChild(el('div', { className: 'todo-empty', textContent: todos.length ? 'No todos match the current filters.' : 'No todos yet.' }));
return;
}
visible.forEach(function (todo) {
var actions = [];
if (!isWorkspace && todo.workspaceRootPath) actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'open-workspace', textContent: 'Open workspace', onClick: function () { openWorkspace(todo); } }));
if (todo.status === 'open') {
actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'mark-done', textContent: 'Done', onClick: function () { setTodoStatus(todo, 'done'); } }));
actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'cancel', textContent: 'Cancel', onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
} else {
actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'reopen', textContent: 'Reopen', onClick: function () { setTodoStatus(todo, 'open'); } }));
}
if (isWorkspace && todo.status === 'done') actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'create-journal-entry', textContent: 'Create Journal Entry', onClick: function () { createJournalEntry(todo); } }));
actions.push(el('button', { className: 'todo-btn', 'data-todo-action': 'edit', textContent: 'Edit', onClick: function () { showTodoModal(todo); } }));
actions.push(el('button', { className: 'todo-btn danger', 'data-todo-action': 'delete', textContent: 'Delete', onClick: function () { deleteTodo(todo); } }));
listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [
el('div', {}, [
el('div', { className: 'todo-row-title', textContent: todo.title || 'Untitled todo' }),
todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null,
todoMeta(todo)
]),
el('div', { className: 'todo-row-actions' }, actions)
]));
});
}
function render() {
var visible = visibleTodos();
countEl.textContent = visible.length === todos.length ? todos.length + ' todo' + (todos.length === 1 ? '' : 's') : visible.length + ' of ' + todos.length + ' todos';
statusFilterEl.value = statusFilter;
sortEl.value = sortMode;
searchEl.value = searchQuery;
renderWorkspaceOptions();
renderList();
}
api.settings.read().then(function (settings) {
todos = rows((settings || {})[GLOBAL_KEY]).map(normalizeTodo);
render();
}).catch(render);
render();
};
TodoView.unmount = function (containerEl) { containerEl.innerHTML = ''; };
window.VerstakPluginRegister('verstak.todo', { components: { TodoView: TodoView } });
}.toString() + ')();';
}
function searchPluginBundle() {
return '(' + function () {
function el(tag, attrs, children) {
@ -2873,6 +3278,9 @@
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle());
}
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoBundle());
}
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle());
}
@ -3482,6 +3890,7 @@
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
error: ''
},
'verstak.todo': makeTodoPluginState(),
'verstak.search': {
status: 'loaded',
enabled: true,
@ -3512,6 +3921,8 @@
vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
vaultPluginState.enabledPlugins.push('verstak.trash');
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.todo');
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {};
openedResources = [];

View File

@ -21,6 +21,7 @@
import Folder from 'lucide-svelte/icons/folder';
import Inbox from 'lucide-svelte/icons/inbox';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListTodo from 'lucide-svelte/icons/list-todo';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
@ -47,6 +48,7 @@
folder: Folder,
gear: Settings,
inbox: Inbox,
'list-todo': ListTodo,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
@ -61,6 +63,7 @@
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
todo: 'list-todo',
};
$: iconKey = aliases[name] || name || 'dot';