mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +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,244 @@
|
||||
/** Render note cards from application queries and read-only state. */
|
||||
import { _nextGoalStep, _goalProgress, _hasItems, _standardVisualVariant, _visibleNoteTags } from '../domain/noteRules.js';
|
||||
import { _hasTimeComponent, _formatRepeatLabel } from '../domain/recurrence.js';
|
||||
export function createCardRendering({
|
||||
COLORS,
|
||||
COLOR_HEX,
|
||||
_animateReflow,
|
||||
_applyMasonry,
|
||||
_bindCardEvents,
|
||||
_captureCardPositions,
|
||||
_customColorStyle,
|
||||
_dotBg,
|
||||
_dotIsActive,
|
||||
_esc,
|
||||
_formatDueDate,
|
||||
_formatReminderTag,
|
||||
_hasActiveReminder,
|
||||
_isBgImage,
|
||||
_isDueOverdue,
|
||||
_isNotesMobileMode,
|
||||
_linkify,
|
||||
_loadActiveHighlights,
|
||||
_renderLabelsInto,
|
||||
_renderQuickAdd,
|
||||
_safeImgSrc,
|
||||
_sortedVisibleNotes,
|
||||
_updateRailBadge,
|
||||
_wireTodayView,
|
||||
state,
|
||||
uiModule
|
||||
}) {
|
||||
function _renderLoadingSkeleton() {
|
||||
const body = document.querySelector('#notes-pane .notes-pane-body');
|
||||
if (!body) return;
|
||||
body.innerHTML = '';
|
||||
_renderLabelsInto(body);
|
||||
_renderQuickAdd(body);
|
||||
const skel = document.createElement('div');
|
||||
skel.className = 'notes-skeleton';
|
||||
skel.innerHTML = `
|
||||
<div class="notes-skeleton-card"></div>
|
||||
<div class="notes-skeleton-card"></div>
|
||||
<div class="notes-skeleton-card short"></div>
|
||||
<div class="notes-skeleton-card"></div>
|
||||
`;
|
||||
body.appendChild(skel);
|
||||
}
|
||||
|
||||
function _renderNotes() {
|
||||
_updateRailBadge();
|
||||
const body = document.querySelector('#notes-pane .notes-pane-body');
|
||||
if (!body) return;
|
||||
const prevPositions = _captureCardPositions();
|
||||
const activeReminderHighlights = _loadActiveHighlights();
|
||||
|
||||
const sorted = _sortedVisibleNotes();
|
||||
|
||||
let html = '';
|
||||
// Today view: render a compact card listing the next-unchecked step from
|
||||
// each active goal. Tapping a step toggles it done (same idx-based wiring
|
||||
// as regular checkboxes). Tapping the title opens the goal note for full
|
||||
// editing.
|
||||
if (state.activeFilter === 'today') {
|
||||
body.innerHTML = '';
|
||||
_renderLabelsInto(body);
|
||||
_renderQuickAdd(body);
|
||||
if (sorted.length === 0) {
|
||||
body.insertAdjacentHTML('beforeend', `<div class="notes-empty">All caught up — no pending goal steps right now.</div>`);
|
||||
} else {
|
||||
let todayHtml = `<div class="notes-today-wrap">
|
||||
<div class="notes-today-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Today · one step per goal</span>
|
||||
</div>
|
||||
<div class="notes-today-list">`;
|
||||
for (const note of sorted) {
|
||||
const next = _nextGoalStep(note);
|
||||
if (!next) continue;
|
||||
const progress = _goalProgress(note).trim();
|
||||
todayHtml += `<div class="notes-today-row" data-note-id="${note.id}">
|
||||
<span class="note-check-dot" data-note-id="${note.id}" data-idx="${next.idx}" title="Mark step done"></span>
|
||||
<div class="notes-today-text">
|
||||
<div class="notes-today-title" data-action="edit" data-note-id="${note.id}">${_esc(note.title || '(untitled goal)')}</div>
|
||||
<div class="notes-today-step">${_linkify(next.item.text || '')}</div>
|
||||
</div>
|
||||
<span class="notes-today-progress">${_esc(progress)}</span>
|
||||
</div>`;
|
||||
}
|
||||
todayHtml += `</div></div>`;
|
||||
body.insertAdjacentHTML('beforeend', todayHtml);
|
||||
}
|
||||
_wireTodayView(body);
|
||||
return;
|
||||
}
|
||||
for (const note of sorted) {
|
||||
if (state.editingId === note.id) continue; // skip : form is shown instead
|
||||
const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)';
|
||||
const dueFmt = _formatDueDate(note.due_date);
|
||||
const overdue = _isDueOverdue(note.due_date);
|
||||
|
||||
let contentHtml = '';
|
||||
if (_hasItems(note) && Array.isArray(note.items)) {
|
||||
// Goal notes can carry a free-form description above the step list :
|
||||
// todos rarely do, but the same render works for both.
|
||||
if (note.note_type === 'goal' && (note.content || '').trim()) {
|
||||
const fullText = note.content || '';
|
||||
const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText;
|
||||
contentHtml += `<div class="note-goal-desc">${_esc(preview)}</div>`;
|
||||
}
|
||||
contentHtml += '<div class="note-checklist-preview">';
|
||||
// Show ALL items : the preview container is scrollable (CSS caps
|
||||
// its max-height + overflow-y:auto), so there's no need to truncate.
|
||||
for (let i = 0; i < note.items.length; i++) {
|
||||
const item = note.items[i];
|
||||
const doneClass = item.done ? ' done' : '';
|
||||
const indent = Math.min(item.indent || 0, 3);
|
||||
contentHtml += `<div class="note-checkbox${doneClass}" data-note-id="${note.id}" data-idx="${i}" style="padding-left:${indent * 16}px">
|
||||
<span class="note-check-dot" title="Mark done"></span>
|
||||
<span class="note-check-text">${_linkify(item.text)}</span>
|
||||
<button class="note-checkbox-edit" data-note-id="${note.id}" data-idx="${i}" title="Edit item">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="note-checkbox-rm" data-note-id="${note.id}" data-idx="${i}" title="Delete item">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
contentHtml += '</div>';
|
||||
} else {
|
||||
const fullText = note.content || '';
|
||||
const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText;
|
||||
// _linkify already calls _esc internally, so URLs become clickable
|
||||
// anchors (used by e.g. the "remind me to reply" email deep-link).
|
||||
contentHtml = preview ? `<div class="note-content-preview">${_linkify(preview)}</div>` : '';
|
||||
}
|
||||
|
||||
const isBg = _isBgImage(note.color);
|
||||
const cc = (note.color && !isBg) ? ' note-color-' + note.color : '';
|
||||
const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : '';
|
||||
const isStandard = !note.note_type || note.note_type === 'note';
|
||||
const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : '';
|
||||
const sel = state.selectedIds.has(note.id) ? ' note-card-selected' : '';
|
||||
const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date)
|
||||
? `<div class="note-card-reminder${overdue ? ' overdue' : ''}">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span>${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''}</span>
|
||||
</div>`
|
||||
: '';
|
||||
const noteTags = _visibleNoteTags(note);
|
||||
const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `<span class="note-due-inline${overdue ? ' note-due-overdue' : ''}">${dueFmt}</span>` : '';
|
||||
const colorDots = COLORS.map(c => `<span class="note-card-color-dot${_dotIsActive(c.value, note.color) ? ' active' : ''}" data-color="${c.value}" style="background:${_dotBg(c.value, note.color)}" title="${c.name || 'default'}"></span>`).join('');
|
||||
const goalClass = note.note_type === 'goal' ? ' note-card-goal' : '';
|
||||
const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : '';
|
||||
const goalPill = note.note_type === 'goal'
|
||||
? `<span class="note-goal-pill" title="AI-broken-down goal">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Goal${_goalProgress(note)}
|
||||
</span>`
|
||||
: '';
|
||||
const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : '';
|
||||
html += `<div class="note-card${note.pinned ? ' note-card-pinned' : ''}${cc}${visualClass}${sel}${goalClass}${drawingClass}${reminderGlowClass}${state.selectMode ? ' note-card-selectmode' : ''}" draggable="${(state.selectMode || _isNotesMobileMode()) ? 'false' : 'true'}" data-note-id="${note.id}"${cardStyle}>
|
||||
${state.selectMode ? `<input type="checkbox" class="memory-select-cb note-card-cb" data-note-id="${note.id}" ${state.selectedIds.has(note.id) ? 'checked' : ''} />` : ''}
|
||||
${goalPill}
|
||||
<button class="note-card-pin${note.pinned ? ' active' : ''}" data-note-id="${note.id}" title="${note.pinned ? 'Unpin' : 'Pin'}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 28" fill="${note.pinned ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${note.pinned ? ' style="color:var(--accent,var(--red));"' : ''}><g transform="rotate(${note.pinned ? 0 : 45} 12 14)" style="transition:transform 0.2s ease;"><line x1="12" y1="17" x2="12" y2="27"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"/></g></svg>
|
||||
</button>
|
||||
${state.showingArchived
|
||||
? `<button class="note-card-corner-trash" data-note-id="${note.id}" title="Delete forever" aria-label="Delete forever">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
<button class="note-card-corner-unarchive" data-note-id="${note.id}" title="Unarchive" aria-label="Unarchive note">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14l-5-5 5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>
|
||||
</button>`
|
||||
: `<button class="note-card-done" data-note-id="${note.id}" title="Mark done" aria-label="Mark done">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
${_hasItems(note) ? `<button class="note-card-copy note-card-copy-corner" data-note-id="${note.id}" title="Copy all items" aria-label="Copy all items">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>` : ''}`}
|
||||
<div class="note-card-header">
|
||||
<div class="note-card-title${note.title ? '' : ' empty'}" data-action="edit">${_esc(note.title || '')}</div>
|
||||
${dueBadge}
|
||||
</div>
|
||||
${_safeImgSrc(note.image_url) ? `<img class="note-card-image" src="${_esc(_safeImgSrc(note.image_url))}" alt="" draggable="false" />` : ''}
|
||||
${contentHtml}
|
||||
${_hasItems(note) ? `<div class="note-cl-quickadd"><input type="text" class="note-cl-quickadd-input" placeholder="+ Add item" data-note-id="${note.id}" /></div>` : ''}
|
||||
${reminderTagHtml}
|
||||
${noteTags.length ? `<div class="note-card-label">${noteTags.map(t => `<button type="button" class="note-card-label-chip" data-note-label-filter="${_esc(t)}" title="Filter #${_esc(t)}">#${_esc(t)}</button>`).join(' ')}</div>` : ''}
|
||||
<div class="note-card-actions">
|
||||
<div class="note-card-colors">${colorDots}</div>
|
||||
<span style="flex:1"></span>
|
||||
${state.showingArchived ? `
|
||||
<button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete permanently">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
<button class="note-card-action note-card-unarchive" data-note-id="${note.id}" title="Unarchive">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>
|
||||
</button>` : `
|
||||
${_hasItems(note) ? `
|
||||
<button class="note-card-action note-card-copy" data-note-id="${note.id}" title="Copy all items">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>` : ''}
|
||||
<button class="note-card-action note-card-archive" data-note-id="${note.id}" title="Save (archive)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
</button>
|
||||
<button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="note-card-action note-card-corner-menu" data-note-id="${note.id}" title="More" aria-label="More actions">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>
|
||||
</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Always render quick-add at top (collapsed unless user is typing)
|
||||
const existingForm = body.querySelector('.note-form');
|
||||
if (existingForm && state.editingId === '__new__') {
|
||||
// Keep the expanded form, replace cards after it
|
||||
const next = [...body.children].filter(c => c !== existingForm);
|
||||
next.forEach(c => c.remove());
|
||||
if (sorted.length === 0) {
|
||||
body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>');
|
||||
} else {
|
||||
existingForm.insertAdjacentHTML('afterend', html);
|
||||
}
|
||||
} else {
|
||||
body.innerHTML = '';
|
||||
_renderLabelsInto(body);
|
||||
_renderQuickAdd(body);
|
||||
if (sorted.length === 0) {
|
||||
body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes yet <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>');
|
||||
} else {
|
||||
body.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
}
|
||||
|
||||
_bindCardEvents(body);
|
||||
_animateReflow(prevPositions);
|
||||
_applyMasonry(body);
|
||||
}
|
||||
|
||||
return { _renderLoadingSkeleton, _renderNotes };
|
||||
}
|
||||
Reference in New Issue
Block a user