mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-17 04:12:24 +02:00
refactor(notes): decompose Notes into feature-owned modules (#6713)
* refactor(notes): decompose Notes into feature-owned modules * fix(notes): avoid HTML interpolation in reminder picker * docs(notes): drop checkpoint archives, session logs and publication drafts The N0-N6 tarballs and raw pytest/node logs are not read by any script or test, and the logs carry absolute home-directory paths. Once squashed into dev they would stay in history for good. notes-pr.md and notes-issue.md were the drafts used to open #6712 and #6713, not documentation. notes-validation.md keeps the counts, the checkpoint hashes and implementation.sha256 as the record; local interpreter paths and the local Docker image name are replaced with portable placeholders. * fix(notes): keep the state store raw and stop masonry re-observing after close - state: writes built from views (filter, spread, splice) stored nested read-only views in the store, so structuredClone on a stored note threw DataCloneError. Store writes now unwrap nested views. - layout: a save that resolves after closePanel re-renders the leaving pane and re-created the masonry ResizeObserver that teardown had disposed. Skip creating it once Notes is closed. - index.js: drop 30 imported or declared names that were never used and group the imports at the top. Import order is unchanged. - Revert unrelated punctuation edits in .gitattributes and keep the header comment and docstring first in compare/vote.js and test_notes_dom_xss_helpers.py. Both fixes have a node test that fails on the previous head. * test(notes): make script rejection assertion case-insensitive Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
co-authored by
Léo
parent
9f8eae72fd
commit
f1a47f3812
@@ -0,0 +1,67 @@
|
||||
/** Create an agent session, link it to a note and drain its background stream. */
|
||||
import { _noteToAgentPrompt } from '../domain/noteRules.js';
|
||||
export function createAgentActions({
|
||||
_patchNote,
|
||||
_renderNotes,
|
||||
api,
|
||||
app,
|
||||
createFormData,
|
||||
state,
|
||||
streamComplete,
|
||||
uiModule
|
||||
}) {
|
||||
async function _agentSolveNote(id) {
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
const prompt = _noteToAgentPrompt(note);
|
||||
if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; }
|
||||
try {
|
||||
const dc = await (await api.defaultChat()).json();
|
||||
if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; }
|
||||
|
||||
// 1. Create the session server-side (no UI switch). skip_validation
|
||||
// avoids re-probing : the default-chat endpoint is already known good.
|
||||
const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40);
|
||||
const csFd = createFormData();
|
||||
csFd.append('name', 'Agent: ' + label);
|
||||
csFd.append('endpoint_url', dc.endpoint_url);
|
||||
csFd.append('model', dc.model);
|
||||
if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id);
|
||||
csFd.append('skip_validation', 'true');
|
||||
const csRes = await api.createSession(csFd);
|
||||
if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; }
|
||||
const sess = await csRes.json();
|
||||
const sid = sess.id;
|
||||
|
||||
// 2. Link the session to the note right away so the tag appears.
|
||||
const n = state.notes.find(x => x.id === id);
|
||||
if (n) app.mergeNote(n, { agent_session_id: sid });
|
||||
_renderNotes();
|
||||
_patchNote(id, { agent_session_id: sid }).catch(() => {});
|
||||
|
||||
// 3. Kick off the agent run in the background. POST to chat_stream in
|
||||
// agent mode and drain the SSE so the server runs the loop to
|
||||
// completion + saves : without rendering anything in the chat UI.
|
||||
const fd = createFormData();
|
||||
fd.append('message', prompt);
|
||||
fd.append('session', sid);
|
||||
fd.append('mode', 'agent');
|
||||
fd.append('disabled_tools', JSON.stringify(['manage_notes']));
|
||||
api.startChat(fd)
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
// Drain to completion (server finishes + persists the run).
|
||||
while (true) { const { done } = await reader.read(); if (done) break; }
|
||||
streamComplete(sid);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
uiModule.showToast('Agent working in background — tap the Agent tag when ready');
|
||||
} catch (e) {
|
||||
uiModule.showError('Agent failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
return { _agentSolveNote };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Optimistic card mutations and rollback. Animation completion is a UI-supplied boundary. */
|
||||
export function createCardOperations({
|
||||
app,
|
||||
api,
|
||||
effects,
|
||||
pushUndo,
|
||||
undoArchive
|
||||
}) {
|
||||
const state = app.state;
|
||||
function togglePin(id) {
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
const prevPinned = note.pinned, prevSortOrder = note.sort_order;
|
||||
app.mergeNote(note, { pinned: !prevPinned });
|
||||
const patch = { pinned: note.pinned };
|
||||
if (note.pinned) {
|
||||
const minPinned = state.notes.filter(n => n.pinned && n.id !== id).reduce((m, n) => Math.min(m, n.sort_order || 0), 0);
|
||||
app.mergeNote(note, { sort_order: minPinned - 1 });
|
||||
patch.sort_order = note.sort_order;
|
||||
}
|
||||
effects.render();
|
||||
api.patchNote(id, patch).catch(() => {
|
||||
app.mergeNote(note, { pinned: prevPinned, sort_order: prevSortOrder });
|
||||
effects.render(); effects.showError('Failed to pin');
|
||||
});
|
||||
}
|
||||
async function updateColor(id, color) {
|
||||
try {
|
||||
await api.patchNote(id, { color: color || null });
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (note) app.mergeNote(note, { color });
|
||||
} catch { effects.showError('Failed to update color'); }
|
||||
}
|
||||
function stageArchive(id) {
|
||||
const index = state.notes.findIndex(n => n.id === id);
|
||||
if (index < 0) return null;
|
||||
const removed = app.removeNoteAt(index);
|
||||
const undo = () => undoArchive(removed, index);
|
||||
pushUndo({ label: 'archive', run: undo });
|
||||
return () => {
|
||||
effects.render();
|
||||
api.patchNote(id, { archived: true }).then(() => effects.archived(undo)).catch(() => {
|
||||
app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to archive');
|
||||
});
|
||||
};
|
||||
}
|
||||
function unarchive(id) {
|
||||
const index = state.notes.findIndex(n => n.id === id);
|
||||
if (index < 0) return;
|
||||
const removed = app.removeNoteAt(index);
|
||||
effects.render();
|
||||
api.patchNote(id, { archived: false }).then(() => effects.showToast('Unarchived')).catch(() => {
|
||||
app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to unarchive');
|
||||
});
|
||||
}
|
||||
function deleteOptimistically(id, toast = false) {
|
||||
const index = state.notes.findIndex(n => n.id === id);
|
||||
if (index < 0) return;
|
||||
const removed = app.removeNoteAt(index);
|
||||
effects.render();
|
||||
const deletion = api.deleteNote(id);
|
||||
const completion = toast ? deletion.then(() => effects.showToast('Deleted')) : deletion;
|
||||
completion.catch(() => {
|
||||
app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to delete');
|
||||
});
|
||||
}
|
||||
// Desktop deliberately persists without updating local sort_order, as before.
|
||||
function persistOrder(ids) { return api.reorder(ids); }
|
||||
async function commitMobileOrder(ids) {
|
||||
try {
|
||||
await api.reorder(ids);
|
||||
ids.forEach((id, index) => {
|
||||
const note = state.notes.find(note => note.id === id);
|
||||
if (note) app.mergeNote(note, { sort_order: index });
|
||||
});
|
||||
} catch (error) { console.warn('reorder failed', error); }
|
||||
}
|
||||
return { togglePin, updateColor, stageArchive, unarchive, deleteOptimistically, persistOrder, commitMobileOrder };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Checklist item workflows. Supplied feedback callbacks update the invoking view. */
|
||||
export function createChecklistOperations({
|
||||
app,
|
||||
api,
|
||||
effects,
|
||||
uid
|
||||
}) {
|
||||
const state = app.state;
|
||||
function itemNote(id, index) {
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
return note && Array.isArray(note.items) && note.items[index] ? note : null;
|
||||
}
|
||||
function removeItem(id, index) {
|
||||
const note = itemNote(id, index);
|
||||
if (!note) return;
|
||||
const removed = note.items[index];
|
||||
app.mergeNote(note, { items: note.items.filter((_, i) => i !== index) });
|
||||
effects.render();
|
||||
api.patchNote(id, { items: note.items }).catch(() => {
|
||||
app.spliceItems(note, index, 0, removed); effects.render(); effects.showError('Failed to remove item');
|
||||
});
|
||||
}
|
||||
function editItem(note, index, text) {
|
||||
const oldText = (note.items[index].text || '').trim();
|
||||
if (text === oldText) { effects.render(); return; }
|
||||
const oldItem = note.items[index];
|
||||
if (!text) app.spliceItems(note, index, 1);
|
||||
else app.updateItem(note, index, { text });
|
||||
api.patchNote(note.id, { items: note.items }).catch(() => {
|
||||
if (!text) app.spliceItems(note, index, 0, oldItem);
|
||||
else app.updateItem(note, index, { text: oldText });
|
||||
effects.render(); effects.showError('Failed to update item');
|
||||
});
|
||||
effects.render();
|
||||
}
|
||||
function addItem(id, text, added) {
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
const items = Array.isArray(note.items) ? [...note.items] : [];
|
||||
items.push({ id: uid(), text, done: false });
|
||||
app.mergeNote(note, { items });
|
||||
added(); effects.render();
|
||||
return () => api.patchNote(id, { items }).catch(() => {
|
||||
app.mergeNote(note, { items: items.slice(0, -1) }); effects.render(); effects.showError('Failed to add item');
|
||||
});
|
||||
}
|
||||
function toggleItem(id, index, changed, completed) {
|
||||
const note = itemNote(id, index);
|
||||
if (!note) return;
|
||||
const wasDone = note.items.length > 0 && note.items.every(it => it.done);
|
||||
app.updateItem(note, index, { done: !note.items[index].done });
|
||||
changed(note.items[index].done);
|
||||
if (!wasDone && note.items.length > 0 && note.items.every(it => it.done)) completed();
|
||||
api.patchNote(id, { items: note.items }).catch(() => {
|
||||
app.updateItem(note, index, { done: !note.items[index].done }); changed(note.items[index].done);
|
||||
});
|
||||
}
|
||||
async function toggleTodayStep(id, index, changed, completed) {
|
||||
const note = itemNote(id, index);
|
||||
if (!note) return;
|
||||
app.updateItem(note, index, { done: !note.items[index].done }); changed();
|
||||
try {
|
||||
await api.patchNote(id, { items: note.items }); effects.render();
|
||||
if (note.items.every(it => it.done)) completed();
|
||||
} catch { app.updateItem(note, index, { done: !note.items[index].done }); }
|
||||
}
|
||||
return { removeItem, editItem, addItem, toggleItem, toggleTodayStep };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Confirmed deletion and past-reminder clearing workflows. */
|
||||
export function createDeletion({
|
||||
app,
|
||||
_deleteNoteApi,
|
||||
_fetchNotes,
|
||||
_isPastReminder,
|
||||
_renderNotes,
|
||||
effects,
|
||||
state
|
||||
}) {
|
||||
async function _deleteNote(id) {
|
||||
const ok = effects.styledConfirm
|
||||
? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true })
|
||||
: effects.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); effects.showToast('Deleted'); }
|
||||
catch (err) { effects.showError(err.message); }
|
||||
}
|
||||
|
||||
async function _clearPastReminders() {
|
||||
const targets = state.notes.filter(n => !n.archived && _isPastReminder(n));
|
||||
if (!targets.length) {
|
||||
effects.showToast?.('No past reminders to clear');
|
||||
return;
|
||||
}
|
||||
const ok = effects.styledConfirm
|
||||
? await effects.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true })
|
||||
: effects.confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`);
|
||||
if (!ok) return;
|
||||
await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {})));
|
||||
await _fetchNotes();
|
||||
_renderNotes();
|
||||
effects.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
async function deleteFromEditor(id) {
|
||||
const ok = effects.styledConfirm
|
||||
? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true })
|
||||
: effects.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
const index = state.notes.findIndex(note => note.id === id);
|
||||
if (index >= 0) app.removeNoteAt(index);
|
||||
app.setEditingId(null);
|
||||
_renderNotes();
|
||||
_deleteNoteApi(id).then(() => effects.showToast('Deleted')).catch(() => {
|
||||
effects.showError('Failed to delete');
|
||||
_fetchNotes().then(() => _renderNotes());
|
||||
});
|
||||
}
|
||||
return { _deleteNote, _clearPastReminders, deleteFromEditor };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Serialized autosave and synchronous local draft backup. DOM collection is a supplied port. */
|
||||
export function createNotesDrafts({
|
||||
app,
|
||||
api,
|
||||
persistence,
|
||||
scheduler,
|
||||
effects
|
||||
}) {
|
||||
const state = app.state;
|
||||
const { _draftKey, _clearDraft } = persistence;
|
||||
function bindDraft({ collect, id, view }) {
|
||||
let t = null;
|
||||
let pending = Promise.resolve();
|
||||
let stopped = false;
|
||||
let warned = false;
|
||||
const save = () => {
|
||||
const d = collect();
|
||||
// An empty edit can be intentional: don't restore stale server text over it.
|
||||
try { persistence.setItem(_draftKey(id), JSON.stringify(d)); }
|
||||
catch {
|
||||
if (!warned) effects.showError('Draft backup unavailable. Keep this note open until saved.');
|
||||
warned = true;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const persist = (d) => {
|
||||
if (!id || id === '__new__' || d.note_type === 'draw') return pending;
|
||||
const { _ts, ...payload } = d;
|
||||
payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null;
|
||||
pending = pending.then(async () => {
|
||||
try {
|
||||
await api.patchNote(id, payload);
|
||||
view.failed = false;
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (note) app.mergeNote(note, payload);
|
||||
if (persistence.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id);
|
||||
} catch {
|
||||
if (!view.failed) effects.showError('Note not synced. Your edits are kept on this device; retry Save.');
|
||||
view.failed = true;
|
||||
}
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
const flush = () => { scheduler.clearTimeout(t); return save(); };
|
||||
const stop = () => { stopped = true; scheduler.clearTimeout(t); return pending; };
|
||||
const sched = () => {
|
||||
const d = save();
|
||||
if (stopped) return;
|
||||
scheduler.clearTimeout(t);
|
||||
t = scheduler.setTimeout(() => persist(d), 500);
|
||||
};
|
||||
return { flush, stop, schedule: sched };
|
||||
}
|
||||
return { bindDraft };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Application-owned filtering, searching and note ordering. */
|
||||
import { _noteTags, _visibleNoteTags, _nextGoalStep, _isCalendarReminderNote } from '../domain/noteRules.js';
|
||||
import { _hasTimeComponent } from '../domain/recurrence.js';
|
||||
export function createQueries({
|
||||
_hasActiveReminder,
|
||||
state
|
||||
}) {
|
||||
function _sortedVisibleNotes() {
|
||||
const displayNotes = _userVisibleNotes();
|
||||
let filtered = state.activeLabel ? displayNotes.filter(n => _noteTags(n).includes(state.activeLabel)) : displayNotes;
|
||||
if (state.activeFilter === 'reminders') {
|
||||
filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date));
|
||||
} else if (state.activeFilter === 'no-reminders') {
|
||||
filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date)));
|
||||
} else if (state.activeFilter === 'default') {
|
||||
filtered = filtered.filter(n => _visibleNoteTags(n).length === 0);
|
||||
} else if (state.activeFilter === 'goals') {
|
||||
filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived);
|
||||
} else if (state.activeFilter === 'today') {
|
||||
// Today view: only goals that still have an unchecked step.
|
||||
filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n));
|
||||
}
|
||||
if (state.searchQuery) {
|
||||
filtered = filtered.filter(n => {
|
||||
const q = state.searchQuery;
|
||||
if ((n.title || '').toLowerCase().includes(q)) return true;
|
||||
if ((n.content || '').toLowerCase().includes(q)) return true;
|
||||
if ((n.label || '').toLowerCase().includes(q)) return true;
|
||||
if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
// In reminders view: sort by due date ascending (soonest first)
|
||||
if (state.activeFilter === 'reminders') {
|
||||
const da = new Date(a.due_date || 0).getTime();
|
||||
const db = new Date(b.due_date || 0).getTime();
|
||||
return da - db;
|
||||
}
|
||||
// Archived view: newest archived first (ignore manual sort_order).
|
||||
if (state.showingArchived) {
|
||||
return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);
|
||||
}
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
// Active reminders (due date in the past, not done/archived) rank
|
||||
// immediately under the pinned block.
|
||||
const aActive = _hasActiveReminder(a);
|
||||
const bActive = _hasActiveReminder(b);
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
const so = (a.sort_order || 0) - (b.sort_order || 0);
|
||||
if (so !== 0) return so;
|
||||
return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);
|
||||
});
|
||||
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function _userVisibleNotes() {
|
||||
return state.notes.filter(n => !_isCalendarReminderNote(n));
|
||||
}
|
||||
|
||||
return { _sortedVisibleNotes, _userVisibleNotes };
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/** Page-owned reminder scheduler and notification delivery workflow. */
|
||||
import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js';
|
||||
export function createNotesReminders({
|
||||
app,
|
||||
api,
|
||||
persistence,
|
||||
notifications,
|
||||
clock,
|
||||
scheduler,
|
||||
effects,
|
||||
fetchNotes,
|
||||
isNoteFullyDone,
|
||||
hasItems,
|
||||
dismissedKey
|
||||
}) {
|
||||
const state = app.state;
|
||||
const { _loadFiredReminders, _saveFiredReminders } = persistence;
|
||||
const _patchNote = api.patchNote;
|
||||
const _fetchNotes = fetchNotes;
|
||||
const _isNoteFullyDone = isNoteFullyDone;
|
||||
const _hasItems = hasItems;
|
||||
const REMINDER_DISMISSED_AT_KEY = dismissedKey;
|
||||
const _updateRailBadge = effects.badge;
|
||||
let _reminderTimer = null;
|
||||
let startupTimer = null;
|
||||
let started = false;
|
||||
let disposed = false;
|
||||
function _checkReminders() {
|
||||
if (!state.notes.length) return;
|
||||
const now = clock.now();
|
||||
const fired = _loadFiredReminders();
|
||||
let changed = false;
|
||||
for (const note of state.notes) {
|
||||
if (!note.due_date || note.archived) continue;
|
||||
if (!_hasTimeComponent(note.due_date)) continue;
|
||||
if (fired.has(note.id)) continue;
|
||||
const due = new Date(note.due_date).getTime();
|
||||
if (isNaN(due)) continue;
|
||||
if (due <= now && due > now - 60000) {
|
||||
_fireReminder(note);
|
||||
// Recurring? advance the due_date instead of marking as fired
|
||||
if (note.repeat && note.repeat !== 'none') {
|
||||
const next = _advanceRecurring(note.due_date, note.repeat, clock);
|
||||
if (next) {
|
||||
app.mergeNote(note, { due_date: next });
|
||||
_patchNote(note.id, { due_date: next }).catch(() => {});
|
||||
// Don't add to fired : new due_date is in the future
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fired.add(note.id);
|
||||
changed = true;
|
||||
} else if (due <= now - 60000) {
|
||||
// Past, never seen : silently advance recurring or mark fired
|
||||
if (note.repeat && note.repeat !== 'none') {
|
||||
const next = _advanceRecurring(note.due_date, note.repeat, clock);
|
||||
if (next) {
|
||||
app.mergeNote(note, { due_date: next });
|
||||
_patchNote(note.id, { due_date: next }).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fired.add(note.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) _saveFiredReminders(fired);
|
||||
// Always refresh badge : fired state may have changed visually without note mutation
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
function _fireReminder(note) {
|
||||
const title = note.title || 'Note reminder';
|
||||
// Include the verbatim note content so the email/notification actually
|
||||
// shows what to do, not just a count. Cap the per-item lines (8 max) and
|
||||
// total length so the body stays inbox-friendly.
|
||||
let rawBody;
|
||||
if (_hasItems(note)) {
|
||||
const pending = (note.items || [])
|
||||
.filter(i => !i.done && !i.checked)
|
||||
.map(i => (i.text || '').trim())
|
||||
.filter(Boolean);
|
||||
if (pending.length) {
|
||||
const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n');
|
||||
const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : '';
|
||||
rawBody = `Pending (${pending.length}):\n${shown}${extra}`;
|
||||
} else {
|
||||
rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
rawBody = (note.content || '').slice(0, 400);
|
||||
}
|
||||
|
||||
// Ask the server to dispatch according to user settings. The server may
|
||||
// return an LLM-written synthesis line and/or send an email. We still show
|
||||
// a local browser notification so the user gets immediate feedback even if
|
||||
// the server path is disabled or slow.
|
||||
const showLocal = body => notifications.show(title, body, note.id);
|
||||
|
||||
// Fire-and-forget server dispatch. If synthesis comes back quickly enough,
|
||||
// use it as the notification body; otherwise the local notification has
|
||||
// already shown with the raw body.
|
||||
let shown = false;
|
||||
const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500);
|
||||
|
||||
api.fireReminder({ note_id: note.id, title, body: rawBody })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
scheduler.clearTimeout(timer);
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
const body = (data && data.synthesis) ? data.synthesis : rawBody;
|
||||
showLocal(body);
|
||||
})
|
||||
.catch(() => {
|
||||
scheduler.clearTimeout(timer);
|
||||
if (!shown) { shown = true; showLocal(rawBody); }
|
||||
});
|
||||
|
||||
// Pulse the card if visible; otherwise queue it so the next time the user
|
||||
// opens the notes panel the card gets a brief glow.
|
||||
effects.highlight(note.id);
|
||||
}
|
||||
|
||||
function _startReminderLoop() {
|
||||
if (disposed || _reminderTimer) return;
|
||||
_reminderTimer = scheduler.setInterval(_checkReminders, 30000);
|
||||
_checkReminders(); // run once immediately
|
||||
}
|
||||
|
||||
function _countDueReminders() {
|
||||
return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length;
|
||||
}
|
||||
|
||||
function _countFiredReminders() {
|
||||
// Reminders whose time has actually passed (not just date-today),
|
||||
// and which fired after the last user dismissal.
|
||||
const now = clock.now();
|
||||
return state.notes.filter(n => {
|
||||
if (n.archived || _isNoteFullyDone(n)) return false;
|
||||
if (!n.due_date || !_hasTimeComponent(n.due_date)) return false;
|
||||
const t = new Date(n.due_date).getTime();
|
||||
if (isNaN(t) || t > now) return false;
|
||||
return t > state.firedDotDismissedAt;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function dismissFiredReminderDot() {
|
||||
app.setFiredDotDismissedAt(clock.now());
|
||||
try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {}
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
async function refreshDueBadge(opts = {}) {
|
||||
// Usually lightweight, but callers that just created a note reminder can
|
||||
// force a refresh so the background reminder loop sees it immediately.
|
||||
if (opts.force || state.notes.length === 0) {
|
||||
try {
|
||||
const wasArchived = state.showingArchived;
|
||||
app.setShowingArchived(false);
|
||||
await _fetchNotes();
|
||||
app.setShowingArchived(wasArchived);
|
||||
} catch {}
|
||||
}
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
async function _initReminders() {
|
||||
try {
|
||||
const res = await api.listNotes(false);
|
||||
if (res.ok && !disposed) {
|
||||
const data = await res.json();
|
||||
app.replaceNotes(data.notes || data || []);
|
||||
_startReminderLoop();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); }
|
||||
function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; }
|
||||
return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Selected-note attachment retains synchronous acceptance; bulk requests retain partial-failure behavior. */
|
||||
export function createSelectionOperations({
|
||||
app,
|
||||
api,
|
||||
effects,
|
||||
sendContext,
|
||||
exitSelection,
|
||||
fetchNotes
|
||||
}) {
|
||||
const state = app.state;
|
||||
function attachSelected() {
|
||||
const ids = [...state.selectedIds];
|
||||
if (!ids.length) return;
|
||||
const detail = { noteIds: ids, accepted: false };
|
||||
sendContext(detail);
|
||||
if (!detail.accepted) {
|
||||
effects.showToast('Choose a chat or model before attaching notes');
|
||||
return;
|
||||
}
|
||||
exitSelection();
|
||||
}
|
||||
async function archiveSelected() {
|
||||
const ids = [...state.selectedIds];
|
||||
if (!ids.length) return;
|
||||
await Promise.all(ids.map(id => api.patchNote(id, { archived: true }).catch(() => {})));
|
||||
exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Archived ${ids.length}`);
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const ids = [...state.selectedIds];
|
||||
if (!ids.length) return;
|
||||
if (effects.styledConfirm) {
|
||||
const ok = await effects.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
await Promise.all(ids.map(id => api.deleteNote(id).catch(() => {})));
|
||||
exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Deleted ${ids.length}`);
|
||||
}
|
||||
return { attachSelected, archiveSelected, deleteSelected };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** The sole Notes state owner. Views have live read-only access; writes use operations. */
|
||||
export function createNotesState(initial = {}) {
|
||||
const values = {
|
||||
open: false, notes: [], editingId: null, selectedIds: new Set(),
|
||||
activeLabel: null, activeFilter: null, reminderChipNext: 'reminders', searchQuery: '',
|
||||
viewMode: initial.viewMode || 'list', showingArchived: false, selectMode: false,
|
||||
suppressNextNoteTapId: null, labelsCollapsed: !!initial.labelsCollapsed,
|
||||
loading: false, firedDotDismissedAt: initial.firedDotDismissedAt || 0,
|
||||
};
|
||||
// Preserve captured note identity across asynchronous completions without
|
||||
// giving a view a writable reference to the application store.
|
||||
const views = new WeakMap(), targets = new WeakMap();
|
||||
const unwrap = value => targets.get(value) || value;
|
||||
// Copies built from views (spread, filter, map) still hold nested views;
|
||||
// store only raw data so the owner never keeps its own read-only views.
|
||||
function unwrapDeep(value, seen = new WeakSet()) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (targets.has(value)) return targets.get(value);
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) value.forEach((item, index) => { value[index] = unwrapDeep(item, seen); });
|
||||
else if (Object.getPrototypeOf(value) === Object.prototype) for (const key of Object.keys(value)) value[key] = unwrapDeep(value[key], seen);
|
||||
return value;
|
||||
}
|
||||
function readonly(value) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (targets.has(value)) return value;
|
||||
if (views.has(value)) return views.get(value);
|
||||
const view = new Proxy(value, {
|
||||
get(target, key) {
|
||||
if (target instanceof Set) {
|
||||
if (['add', 'delete', 'clear'].includes(key)) return () => { throw new TypeError('Use Notes selection operations'); };
|
||||
if (key === 'valueOf') return () => views.get(target);
|
||||
if (key === 'forEach') return (callback, thisArg) => target.forEach(value => callback.call(thisArg, value, value, views.get(target)));
|
||||
const member = Reflect.get(target, key, target);
|
||||
return typeof member === 'function' ? member.bind(target) : member;
|
||||
}
|
||||
return readonly(Reflect.get(target, key));
|
||||
},
|
||||
set() { throw new TypeError('Use Notes state operations'); },
|
||||
deleteProperty() { throw new TypeError('Use Notes state operations'); },
|
||||
defineProperty() { throw new TypeError('Use Notes state operations'); },
|
||||
setPrototypeOf() { throw new TypeError('Use Notes state operations'); },
|
||||
preventExtensions() { throw new TypeError('Use Notes state operations'); },
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
|
||||
if (descriptor && 'value' in descriptor) return { ...descriptor, value: readonly(descriptor.value) };
|
||||
return descriptor;
|
||||
},
|
||||
});
|
||||
views.set(value, view); targets.set(view, value);
|
||||
return view;
|
||||
}
|
||||
const state = Object.freeze(Object.fromEntries(Object.keys(values).map(key => [key, () => readonly(values[key])])));
|
||||
const view = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.entries(state).map(([key, get]) => [key, { get, enumerable: true }]))));
|
||||
return {
|
||||
state: view,
|
||||
setOpen: value => { values.open = value; },
|
||||
setEditingId: value => { values.editingId = value; },
|
||||
setActiveLabel: value => { values.activeLabel = value; },
|
||||
setActiveFilter: value => { values.activeFilter = value; },
|
||||
setReminderChipNext: value => { values.reminderChipNext = value; },
|
||||
setSearchQuery: value => { values.searchQuery = value; },
|
||||
setViewMode: value => { values.viewMode = value; },
|
||||
setShowingArchived: value => { values.showingArchived = value; },
|
||||
setSelectMode: value => { values.selectMode = value; },
|
||||
setSuppressNextNoteTapId: value => { values.suppressNextNoteTapId = value; },
|
||||
setLabelsCollapsed: value => { values.labelsCollapsed = value; },
|
||||
setLoading: value => { values.loading = value; },
|
||||
setFiredDotDismissedAt: value => { values.firedDotDismissedAt = value; },
|
||||
replaceNotes: notes => { values.notes = notes; },
|
||||
removeNoteAt: index => readonly(values.notes.splice(index, 1)[0]),
|
||||
insertNoteAt: (index, note) => { values.notes.splice(index, 0, unwrapDeep(note)); },
|
||||
prependNote: note => { values.notes.unshift(unwrapDeep(note)); },
|
||||
replaceNoteAt: (index, note) => { values.notes[index] = unwrapDeep(note); },
|
||||
mergeNote: (note, patch) => { for (const [key, value] of Object.entries(patch)) unwrap(note)[key] = unwrapDeep(value); },
|
||||
updateItem: (note, index, patch) => { Object.assign(unwrap(unwrap(note).items[index]), unwrapDeep({ ...patch })); },
|
||||
spliceItems: (note, ...args) => unwrap(unwrap(note).items).splice(...args.map(arg => unwrapDeep(arg))),
|
||||
selectId: id => { values.selectedIds.add(id); },
|
||||
deselectId: id => values.selectedIds.delete(id),
|
||||
clearSelection: () => values.selectedIds.clear(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Editor submission ordering, optimistic state, reminder rearming and save recovery. */
|
||||
export function createNotesSubmission({
|
||||
app,
|
||||
api,
|
||||
persistence,
|
||||
clock,
|
||||
uid,
|
||||
effects,
|
||||
fetchNotes
|
||||
}) {
|
||||
const state = app.state;
|
||||
const { _loadFiredReminders, _saveFiredReminders, _loadGlowedReminders, _saveGlowedReminders, _loadDraft, _clearDraft } = persistence;
|
||||
function submitNote(payload, note, isEdit) {
|
||||
// Reset fired reminder if due_date changed (so re-arm works), and also
|
||||
// clear the entry-glow seen flag so the new firing glows again on the
|
||||
// next time the user opens the panel.
|
||||
if (isEdit && note.due_date !== payload.due_date) {
|
||||
const fired = _loadFiredReminders();
|
||||
fired.delete(note.id);
|
||||
_saveFiredReminders(fired);
|
||||
const glowed = _loadGlowedReminders();
|
||||
glowed.delete(note.id);
|
||||
_saveGlowedReminders(glowed);
|
||||
effects.clearGlow(note.id);
|
||||
}
|
||||
// Edited notes move to the top of their section (under pinned). Compute
|
||||
// sort_order = (min unpinned sort_order) - 1 so the saved note sorts above
|
||||
// siblings; the pin block keeps its own ordering above this.
|
||||
if (!payload.pinned) {
|
||||
// Both edits AND newly-created notes anchor above the rest of the
|
||||
// unpinned section. Without this, freshly created notes sit at the
|
||||
// bottom because manually-reordered siblings already carry negative
|
||||
// sort_order values.
|
||||
const minUnpinned = state.notes
|
||||
.filter(n => !n.pinned && (!isEdit || n.id !== note.id))
|
||||
.reduce((m, n) => Math.min(m, n.sort_order || 0), 0);
|
||||
payload.sort_order = minUnpinned - 1;
|
||||
}
|
||||
// Optimistic update : update local state first, render, then save in background
|
||||
app.setEditingId(null);
|
||||
const draftId = isEdit ? note.id : '__new__';
|
||||
const submittedDraft = JSON.stringify(_loadDraft(draftId));
|
||||
if (isEdit) {
|
||||
const idx = state.notes.findIndex(n => n.id === note.id);
|
||||
if (idx >= 0) app.replaceNoteAt(idx, { ...state.notes[idx], ...payload });
|
||||
} else {
|
||||
app.prependNote({ ...payload, id: 'tmp_' + uid(), created_at: new Date(clock.now()).toISOString(), updated_at: new Date(clock.now()).toISOString() });
|
||||
}
|
||||
effects.render();
|
||||
// Background save
|
||||
api.saveNote(payload).then(saved => {
|
||||
if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId);
|
||||
if (!isEdit && saved && saved.id) {
|
||||
// Replace temp ID with real one from server. AND re-render : the
|
||||
// existing card's `data-note-id="tmp_xxx"` is stale after Object.assign
|
||||
// bumps the in-memory id, so all subsequent clicks (edit, done, copy,
|
||||
// archive, delete) silently fail to find the note in `_notes`.
|
||||
const tmp = state.notes.find(n => n.id.startsWith('tmp_'));
|
||||
if (tmp) app.mergeNote(tmp, saved);
|
||||
effects.render();
|
||||
}
|
||||
}).catch(err => {
|
||||
effects.showError('Save failed: ' + err.message);
|
||||
fetchNotes().then(() => effects.render());
|
||||
});
|
||||
|
||||
}
|
||||
return { submitNote };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Notes CRUD, optimistic archive and in-memory undo workflows. */
|
||||
export function createNotesWorkflows({
|
||||
app,
|
||||
api,
|
||||
effects
|
||||
}) {
|
||||
const state = app.state;
|
||||
const _undoStack = [];
|
||||
const _renderNotes = effects.render;
|
||||
const _patchNote = api.patchNote;
|
||||
const uiModule = effects;
|
||||
function _pushUndo(entry) {
|
||||
_undoStack.push(entry);
|
||||
if (_undoStack.length > 20) _undoStack.shift();
|
||||
}
|
||||
|
||||
function _popAndRunUndo() {
|
||||
const entry = _undoStack.pop();
|
||||
if (entry) entry.run();
|
||||
return !!entry;
|
||||
}
|
||||
|
||||
function _undoArchive(note, prevIdx) {
|
||||
// Re-insert at original position and clear archived flag on the server.
|
||||
const safeIdx = Math.min(Math.max(prevIdx, 0), state.notes.length);
|
||||
app.insertNoteAt(safeIdx, { ...note, archived: false });
|
||||
_renderNotes();
|
||||
_patchNote(note.id, { archived: false }).catch(() => {
|
||||
// Roll back local insertion if the server refuses
|
||||
const i = state.notes.findIndex(n => n.id === note.id);
|
||||
if (i >= 0) app.removeNoteAt(i);
|
||||
_renderNotes();
|
||||
uiModule.showError('Undo failed');
|
||||
});
|
||||
}
|
||||
|
||||
function _archiveNoteById(id, { card = null, celebrate = false } = {}) {
|
||||
if (!id) return false;
|
||||
const idx = state.notes.findIndex(n => n.id === id);
|
||||
if (idx < 0) return false;
|
||||
const note = state.notes[idx];
|
||||
if (celebrate && card && note) effects.celebrateArchive(note, card);
|
||||
const removed = app.removeNoteAt(idx);
|
||||
app.setEditingId(null);
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, idx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
effects.archived(undo);
|
||||
}).catch(() => {
|
||||
app.insertNoteAt(idx, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _fetchNotes() {
|
||||
app.setLoading(true);
|
||||
try {
|
||||
const res = await api.listNotes(state.showingArchived);
|
||||
if (!res.ok) { app.replaceNotes([]); return; }
|
||||
const data = await res.json();
|
||||
app.replaceNotes(data.notes || data || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notes:', e);
|
||||
app.replaceNotes([]);
|
||||
} finally {
|
||||
app.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function _saveNote(note) { return api.saveNote(note); }
|
||||
|
||||
function _deleteNoteApi(id) { return api.deleteNote(id); }
|
||||
|
||||
return { _pushUndo, _popAndRunUndo, _undoArchive, _archiveNoteById, _fetchNotes, _saveNote, _deleteNoteApi, _patchNote, undoCount: () => _undoStack.length };
|
||||
}
|
||||
Reference in New Issue
Block a user