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:
Alexandre Teixeira
2026-10-15 09:29:41 +01:00
committed by GitHub
co-authored by Léo
parent 9f8eae72fd
commit f1a47f3812
121 changed files with 15318 additions and 5544 deletions
@@ -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 };
}