+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index ecdc49a..adb0e16 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -16,7 +16,7 @@ import { undo, canUndo } from './modules/undo.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
-import { startBackgroundSync, triggerSync } from './modules/caldav.js';
+import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings } from './modules/caldav.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -28,13 +28,14 @@ window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
+window._loadCaldavSettings = loadCaldavSettings;
window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
-window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); refreshCalendar(); };
+window._reloadAll = async () => { state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
@@ -52,10 +53,49 @@ window._extractToCard = async (text) => {
// ── Data loading ──
+const CARDS_CACHE_KEY = 'hyprcrd-cards-cache';
+
+function getCachedCards() {
+ try { return JSON.parse(localStorage.getItem(CARDS_CACHE_KEY) || '[]'); } catch { return []; }
+}
+
+function setCachedCards(cards) {
+ try {
+ const slim = cards.map(c => ({ id: c.id, title: c.title, color: c.color, tags: c.tags, bucket_id: c.bucket_id, bucket_name: c.bucket_name, parent_id: c.parent_id, position: c.position, created_at: c.created_at, updated_at: c.updated_at, due_date: c.due_date, caldav_uid: c.caldav_uid, caldav_rrule: c.caldav_rrule }));
+ localStorage.setItem(CARDS_CACHE_KEY, JSON.stringify(slim));
+ } catch {}
+}
+
async function loadCards() {
- state.cards = await api('api/cards');
+ // Phase 0: Show cached cards instantly (from previous session)
+ const cached = getCachedCards();
+ if (cached.length) {
+ state.cards = cached.map(c => ({ ...c, body: null }));
+ updateSortUI();
+ renderBoard();
+ }
+
+ // Phase 1: Load metadata → update board with fresh titles/tags/dates
+ const meta = await api('api/cards/meta');
+ if (!meta) return;
+ state.cards = meta.map(c => ({ ...c, body: null }));
+ setCachedCards(meta);
updateSortUI();
renderBoard();
+
+ // Phase 2: Load full cards in batches → replace spinners with content
+ const batchSize = 25;
+ for (let i = 0; i < meta.length; i += batchSize) {
+ const batch = meta.slice(i, i + batchSize);
+ const ids = batch.map(c => c.id).join(',');
+ const full = await api('api/cards?ids=' + ids);
+ if (!full) continue;
+ for (const card of full) {
+ const idx = state.cards.findIndex(c => c.id === card.id);
+ if (idx >= 0) state.cards[idx] = card;
+ }
+ renderBoard();
+ }
}
// ── Help ──
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index 746ab2c..46c7a1e 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -6,7 +6,8 @@ import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
import { getCalConfig } from './caldav.js';
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
-import { bindCheckboxes } from './detail.js';
+import { addProvider as addNotifProvider, render as renderNotifs } from './notifications.js';
+import { bindCheckboxes, bindDetailImageFullscreen } from './detail.js';
const board = $('board');
const boardPreview = $('board-preview');
@@ -76,6 +77,78 @@ $('select-export')?.addEventListener('click', () => {
if (!selectedIds.size) return;
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
});
+
+$('select-group')?.addEventListener('click', async () => {
+ if (selectedIds.size < 2) return;
+ const ids = [...selectedIds];
+ const parentId = ids[0]; // First selected becomes parent
+ for (let i = 1; i < ids.length; i++) {
+ await api(`api/cards/${ids[i]}`, { method: 'PATCH', body: { parent_id: parentId } });
+ }
+ clearSelection();
+ await window._reloadAll();
+});
+
+$('select-bucket')?.addEventListener('click', async () => {
+ if (!selectedIds.size) return;
+ const { showDialog } = await import('./dialog.js');
+ const bucketOpts = state.buckets.map(b => b.name);
+ const name = await showDialog({ title: 'move to bucket', input: true, placeholder: 'bucket name', okLabel: 'move' });
+ if (!name) return;
+ let bucket = state.buckets.find(b => b.name.toLowerCase() === name.trim().toLowerCase());
+ if (!bucket) {
+ const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
+ if (created?.id) bucket = created;
+ }
+ if (!bucket) return;
+ for (const id of selectedIds) {
+ await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } });
+ }
+ clearSelection();
+ await window._reloadAll();
+});
+
+$('select-color')?.addEventListener('click', async () => {
+ if (!selectedIds.size) return;
+ const { showDialog } = await import('./dialog.js');
+ const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'apply' });
+ if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
+ for (const id of selectedIds) {
+ await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
+ }
+ clearSelection();
+ await window._reloadAll();
+});
+
+$('select-tag')?.addEventListener('click', async () => {
+ if (!selectedIds.size) return;
+ const { showDialog } = await import('./dialog.js');
+ const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'add' });
+ if (!tag) return;
+ const cleanTag = tag.trim().replace(/^#/, '');
+ if (!cleanTag) return;
+ for (const id of selectedIds) {
+ const card = state.cards.find(c => c.id == id);
+ if (!card) continue;
+ const body = (card.body || '').trimEnd() + ' #' + cleanTag;
+ await api(`api/cards/${id}`, { method: 'PATCH', body: { body } });
+ }
+ clearSelection();
+ await window._reloadAll();
+});
+
+$('select-delete')?.addEventListener('click', async () => {
+ if (!selectedIds.size) return;
+ const { showDialog } = await import('./dialog.js');
+ const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
+ if (!ok) return;
+ for (const id of selectedIds) {
+ await api(`api/cards/${id}`, { method: 'DELETE' });
+ }
+ clearSelection();
+ await window._reloadAll();
+});
+
$('select-clear')?.addEventListener('click', clearSelection);
// Custom drag ghost that fades near header
@@ -117,10 +190,13 @@ function showBoardPreview(cardId) {
$('board-preview-content')?.classList.remove('hidden');
if (boardPreviewColor) boardPreviewColor.style.background = card.color;
if (boardPreviewTitle) boardPreviewTitle.textContent = card.title;
+ const doneBadge = $('board-preview-done-badge');
+ if (doneBadge) doneBadge.classList.toggle('hidden', !parseTags(card).includes('done'));
if (boardPreviewBody) {
boardPreviewBody.innerHTML = renderMd(card.body);
addCodeCopyButtons(boardPreviewBody);
formatTodoItems(boardPreviewBody);
+ bindDetailImageFullscreen(boardPreviewBody);
bindCheckboxes(boardPreviewBody, card);
}
const tags = parseTags(card);
@@ -199,6 +275,22 @@ if (state.superView) {
if (btn) btn.classList.add('text-t-accent');
}
+$('hide-done')?.addEventListener('click', () => {
+ state.hideDone = !state.hideDone;
+ localStorage.setItem('hyprcrd-hide-done', state.hideDone ? '1' : '0');
+ const btn = $('hide-done');
+ const icon = btn?.querySelector('i');
+ if (icon) icon.className = `ph ${state.hideDone ? 'ph-check-square' : 'ph-check-circle'} text-xs`;
+ if (btn) btn.classList.toggle('text-t-accent', state.hideDone);
+ renderBoard();
+});
+if (state.hideDone) {
+ const btn = $('hide-done');
+ const icon = btn?.querySelector('i');
+ if (icon) icon.className = 'ph ph-check-square text-xs';
+ if (btn) btn.classList.add('text-t-accent');
+}
+
$('hide-termine')?.addEventListener('click', () => {
state.hideTermine = !state.hideTermine;
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
@@ -224,11 +316,12 @@ $('sort-toggle')?.addEventListener('click', () => {
});
export function renderCardHtml(c, inGroup = false, recent = []) {
- const coverImg = leadingImage(c.body);
- const thumbImg = coverImg ? null : firstImage(c.body);
+ const loaded = c.body != null;
+ const coverImg = loaded ? leadingImage(c.body) : null;
+ const thumbImg = coverImg ? null : (loaded ? firstImage(c.body) : null);
const children = getChildren(c.id);
const isGroup = !inGroup && children.length > 0;
- const todos = getTodos(c.body);
+ const todos = loaded ? getTodos(c.body) : null;
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
const hasCover = !!coverImg;
const tags = parseTags(c);
@@ -237,12 +330,15 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
if (isTermin) { for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
const cardColor = calCfg?.color || c.color;
const calIcon = isTermin ? (calCfg?.icon || 'calendar-dots') : null;
+ const overdue = c.due_date && c.due_date < new Date().toISOString().substring(0, 10);
+ const barColor = overdue ? 'var(--t-danger)' : cardColor;
return `
+ style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${overdue ? 'border-bottom:3px solid var(--t-danger)' : recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
${hasCover ? '
' : ''}
-
+ ${tags.includes('done') ? '
' : ''}
+
${isGroup ? `
${children.length + 1}
` : ''}
${thumbImg ? `
` : ''}
@@ -255,10 +351,12 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
${todos ? `
${Array.from({length: todos.total}, (_, i) => `
`).join('')}
` : ''}
-
${esc(stripMd(c.body))}
+ ${loaded
+ ? `
${esc(stripMd(c.body))}
`
+ : `
`}
${tags.length ? `
${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `+${tags.length - 5}` : ''}
` : ''}
- ${(c.updated_at || c.created_at || '').substring(0, 10)}
+ ${c.due_date ? `${c.due_date}` : `${(c.updated_at || c.created_at || '').substring(0, 10)}`}
${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
@@ -274,13 +372,20 @@ export function renderBoard() {
cards = parent ? [parent, ...getChildren(parent.id)] : [];
} else {
if (state.activeBucket === 'current') {
- // Current shows ALL recently touched cards, including group children
+ // Current: recently opened/edited cards (not just synced)
const recent = getRecent();
const now = Date.now();
- const freshMs = 48 * 60 * 60 * 1000;
+ const currentDays = parseInt(localStorage.getItem('hyprcrd-current-days') || '2');
+ const freshMs = currentDays * 24 * 60 * 60 * 1000;
cards = state.cards.filter(c => {
+ const tags = (c.tags || '').split(',');
+ if (tags.includes('geburtstag')) return false;
+ // Recently opened (tracked by markRecent)
if (recent.includes(String(c.id))) return true;
+ // Recently edited locally (not just synced — check synced_at vs updated_at)
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
+ const synced = c.caldav_synced_at ? new Date(c.caldav_synced_at).getTime() : 0;
+ if (synced && Math.abs(updated - synced) < 3000) return false; // sync-only update
return (now - updated) < freshMs;
});
cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''));
@@ -300,9 +405,13 @@ export function renderBoard() {
}
}
}
- // Hide appointments
+ // Hide appointments + birthdays
if (state.hideTermine) {
- cards = cards.filter(c => !parseTags(c).includes('termin'));
+ cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag'); });
+ }
+ // Hide done
+ if (state.hideDone) {
+ cards = cards.filter(c => !parseTags(c).includes('done'));
}
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
if (state.activeTags.length) {
@@ -454,8 +563,131 @@ export function renderBoard() {
// Refresh split preview if open
if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
+
+ // Notification banner
+ renderNotifs();
}
+// ── Notification providers ──
+
+// Upcoming events (birthdays + appointments)
+addNotifProvider((settings) => {
+ const today = new Date();
+ const todayStr = today.toISOString().substring(0, 10);
+ const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+ const lookahead = settings.lookahead || 3;
+ const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
+ const items = [];
+
+ for (const c of state.cards) {
+ const tags = (c.tags || '').split(',');
+ const isGeb = tags.includes('geburtstag');
+ const isTermin = tags.includes('termin');
+ if (isGeb && !settings.birthdays) continue;
+ if (isTermin && !isGeb && !settings.termine) continue;
+ if (!isGeb && !isTermin) continue;
+
+ const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
+ if (!m) continue;
+
+ let eventDate = m[1];
+ if (isGeb) eventDate = todayStr.substring(0, 4) + eventDate.substring(4);
+ if (eventDate < todayStr || eventDate > aheadStr) continue;
+
+ const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
+ const evParts = eventDate.split('-').map(Number);
+ const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
+ const daysAway = Math.round((evLocal - todayLocal) / 86400000);
+ const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
+
+ items.push({
+ id: String(c.id),
+ icon: isGeb ? 'cake' : 'calendar-dots',
+ iconColor: isGeb ? '#fb7185' : 'var(--t-text)',
+ label: name,
+ when,
+ sort: eventDate,
+ type: isGeb ? 'birthday' : 'appointment',
+ onClick: () => window._openDetail(c.id),
+ });
+ }
+ return items;
+});
+
+// Due dates approaching or overdue
+addNotifProvider((settings) => {
+ const today = new Date();
+ const todayStr = today.toISOString().substring(0, 10);
+ const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+ const lookahead = settings.lookahead || 3;
+ const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
+ const items = [];
+
+ for (const c of state.cards) {
+ if (!c.due_date) continue;
+
+ // Overdue
+ if (c.due_date < todayStr) {
+ if (!settings.overdue) continue;
+ items.push({
+ id: 'due-' + c.id,
+ icon: 'warning',
+ iconColor: 'var(--t-danger)',
+ label: c.title,
+ when: 'overdue',
+ sort: '0' + c.due_date,
+ type: 'overdue',
+ onClick: () => window._openDetail(c.id),
+ });
+ }
+ // Upcoming
+ else if (c.due_date <= aheadStr) {
+ const evParts = c.due_date.split('-').map(Number);
+ const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
+ const daysAway = Math.round((evLocal - todayLocal) / 86400000);
+ const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
+ items.push({
+ id: 'due-' + c.id,
+ icon: 'clock-countdown',
+ iconColor: daysAway === 0 ? 'var(--t-danger)' : 'var(--t-accent)',
+ label: c.title,
+ when,
+ sort: c.due_date,
+ type: 'due',
+ onClick: () => window._openDetail(c.id),
+ });
+ }
+ }
+ return items;
+});
+
+// Overdue tasks (cards with unchecked todos past their date, no due_date set)
+addNotifProvider((settings) => {
+ if (!settings.overdue) return [];
+ const todayStr = new Date().toISOString().substring(0, 10);
+ const items = [];
+
+ for (const c of state.cards) {
+ if (c.due_date) continue; // handled by due-date provider above
+ const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
+ if (!m || m[1] >= todayStr) continue;
+ if (!c.body || !/- \[ \]/.test(c.body)) continue;
+
+ const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
+ items.push({
+ id: 'overdue-' + c.id,
+ icon: 'warning',
+ iconColor: 'var(--t-danger)',
+ label: name,
+ when: 'overdue',
+ sort: '0' + m[1],
+ type: 'overdue',
+ onClick: () => window._openDetail(c.id),
+ });
+ }
+ return items;
+});
+
// ── Group bar ──
function renderGroupBar(cards) {
diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js
index f6a1723..9515702 100644
--- a/public/js/modules/caldav.js
+++ b/public/js/modules/caldav.js
@@ -21,7 +21,7 @@ const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb9
let calendarConfigs = {}; // slug → { color, icon }
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
-const overlay = $('caldav-settings');
+const settingsOverlay = $('settings-overlay');
const urlInput = $('caldav-url');
const userInput = $('caldav-user');
const passInput = $('caldav-pass');
@@ -30,12 +30,15 @@ const syncStatus = $('caldav-sync-status');
const logEl = $('caldav-log');
export function openCaldavSettings() {
- overlay?.classList.add('open');
+ // Open settings overlay on sync tab
+ settingsOverlay?.classList.add('open');
+ settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'sync'));
+ settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-sync'));
loadSettings();
}
export function closeCaldavSettings() {
- overlay?.classList.remove('open');
+ settingsOverlay?.classList.remove('open');
}
function loadCalConfigs() {
@@ -97,7 +100,7 @@ function renderCalList(calendars) {
});
}
-async function loadSettings() {
+export async function loadSettings() {
const s = await api('api/caldav/settings');
if (!s) return;
if (urlInput) urlInput.value = s.url || '';
@@ -115,8 +118,6 @@ async function loadSettings() {
}
}
-$('caldav-close')?.addEventListener('click', closeCaldavSettings);
-overlay?.addEventListener('click', e => { if (e.target === overlay) closeCaldavSettings(); });
$('caldav-test')?.addEventListener('click', async () => {
testStatus.textContent = 'testing...';
@@ -149,6 +150,19 @@ $('caldav-save')?.addEventListener('click', async () => {
});
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
+$('notes-reset')?.addEventListener('click', async () => {
+ if (syncStatus) { syncStatus.textContent = 'resetting notes...'; syncStatus.style.color = 'var(--t-dim)'; }
+ const res = await fetch('api/notes/reset', {
+ method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
+ }).then(r => r.json()).catch(() => null);
+ if (res?.ok) {
+ const s = res.stats || {};
+ if (syncStatus) { syncStatus.textContent = `reset: ${res.reset || 0} cleared, ${s.pulled || 0} re-pulled`; syncStatus.style.color = 'var(--t-accent)'; }
+ await window._reloadAll?.();
+ } else {
+ if (syncStatus) { syncStatus.textContent = res?.error || 'reset failed'; syncStatus.style.color = 'var(--t-danger)'; }
+ }
+});
export async function triggerSync() {
const btn = $('cal-sync');
@@ -161,14 +175,22 @@ export async function triggerSync() {
if (btn) btn.style.opacity = '';
+ // Also sync WebDAV Notes
+ const notesRes = await fetch('api/notes/sync', {
+ method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
+ }).then(r => r.json()).catch(() => null);
+
if (res.ok) {
const s = res.stats || {};
- const hasErrors = (res.log || []).some(l => l.status === 'error');
- const msg = `pushed:${s.pushed || 0} pulled:${s.pulled || 0} del:${s.deleted || 0}${s.conflicts ? ' conflicts:' + s.conflicts : ''}`;
+ const ns = notesRes?.stats || {};
+ const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
+ const allLog = [...(res.log || []), ...(notesRes?.log || [])];
+ const hasErrors = allLog.some(l => l.status === 'error');
+ const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${ns.pulled ? ' notes:' + ns.pulled : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
- showLog(res.log);
- showSyncNotification(s);
+ showLog(allLog);
+ showSyncNotification(total);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
@@ -190,6 +212,25 @@ function showConnectionLog(lines) {
).join('');
}
+$('notes-rekey')?.addEventListener('click', async () => {
+ const { showDialog } = await import('./dialog.js');
+ const oldPass = await showDialog({ title: 're-key notes', input: 'password', placeholder: 'old password', okLabel: 're-key' });
+ if (!oldPass) return;
+ if (syncStatus) { syncStatus.textContent = 're-keying notes...'; syncStatus.style.color = 'var(--t-dim)'; }
+ const res = await fetch('api/notes/rekey', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
+ body: JSON.stringify({ old_password: oldPass }),
+ }).then(r => r.json()).catch(() => null);
+ if (res?.ok) {
+ if (syncStatus) { syncStatus.textContent = `re-keyed ${res.rekeyed} notes — syncing...`; syncStatus.style.color = 'var(--t-accent)'; }
+ // Re-pull with correct key
+ const pullRes = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
+ await window._reloadAll?.();
+ if (syncStatus) syncStatus.textContent = `re-keyed ${res.rekeyed} notes, ${pullRes?.stats?.pulled || 0} re-pulled`;
+ } else {
+ if (syncStatus) { syncStatus.textContent = res?.error || 're-key failed'; syncStatus.style.color = 'var(--t-danger)'; }
+ }
+});
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
@@ -218,25 +259,32 @@ function showSyncNotification(stats) {
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
+let bgSyncRunning = false;
async function backgroundSync() {
+ if (bgSyncRunning) return;
+ bgSyncRunning = true;
try {
- const res = await fetch('api/caldav/sync', {
- method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
- }).then(r => r.json()).catch(() => null);
- if (res?.ok) {
- const s = res.stats || {};
- if (s.pulled > 0 || s.pushed > 0 || s.deleted > 0) {
- await window._reloadAll?.();
- showSyncNotification(s);
- }
+ const headers = { 'X-CSRF-Token': getCsrf() };
+ const [res, notesRes] = await Promise.all([
+ fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
+ fetch('api/notes/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
+ ]);
+ const s = res?.stats || {};
+ const ns = notesRes?.stats || {};
+ const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
+ if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
+ await window._reloadAll?.();
+ showSyncNotification(total);
}
- } catch {}
+ } catch {} finally {
+ bgSyncRunning = false;
+ }
}
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
- backgroundSync(); // first sync immediately
- bgSyncInterval = setInterval(backgroundSync, 120000); // then every 2 minutes
+ backgroundSync();
+ bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
}
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 43c7a96..16c4196 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -35,6 +35,8 @@ export function showContextMenu(x, y, card) {
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `
` : ''}
+
+ ${card.due_date ? `
` : ''}
@@ -188,6 +190,20 @@ export function showContextMenu(x, y, card) {
await window._reloadAll();
if (card.caldav_uid) window._triggerSync?.();
}
+ else if (a === 'due-done') {
+ hideContextMenu();
+ // Clear due date and mark as done
+ const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
+ await window._reloadAll();
+ }
+ else if (a === 'due-date') {
+ hideContextMenu();
+ const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'update' : 'set' });
+ if (newDate === null) return;
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
+ await window._reloadAll();
+ }
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 5d9fe4f..66bb2b0 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -444,9 +444,23 @@ function renderSidebar(card) {
// ── Content ──
+function updateDoneButton(card) {
+ const isDone = (card.tags || '').split(',').includes('done');
+ const btn = $('detail-done');
+ const label = $('detail-done-label');
+ if (btn) {
+ btn.classList.toggle('text-t-accent', isDone);
+ btn.classList.toggle('border-t-accent', isDone);
+ }
+ if (label) label.textContent = isDone ? 'undone' : 'done';
+}
+
function showContent(card) {
$('detail-color').style.background = card.color;
titleEl.textContent = card.title;
+ updateDoneButton(card);
+ const doneCorner = $('detail-done-corner');
+ if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
bodyEl.innerHTML = renderMd(card.body);
addCodeCopyButtons(bodyEl);
formatTodoItems(bodyEl);
@@ -575,7 +589,7 @@ function applyReadingResizeHandles(root) {
});
}
-function bindDetailImageFullscreen(root) {
+export function bindDetailImageFullscreen(root) {
if (!root) return;
// Gallery grids: single click, with prev/next
@@ -978,6 +992,27 @@ $('detail-edit')?.addEventListener('click', () => {
window._openModal(card);
});
+$('detail-done')?.addEventListener('click', async () => {
+ const card = state.viewingCard;
+ if (!card) return;
+ const isDone = (card.tags || '').split(',').includes('done');
+ let newBody;
+ if (isDone) {
+ newBody = card.body.replace(/\s*#done\b/g, '');
+ } else {
+ newBody = (card.body || '').trimEnd() + ' #done';
+ }
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody, due_date: isDone ? card.due_date : null } });
+ card.body = newBody;
+ if (!isDone) card.due_date = null;
+ const c = state.cards.find(x => x.id == card.id);
+ if (c) { c.body = newBody; c.tags = newBody.match(/#[a-z0-9_/-]+/gi)?.map(t => t.substring(1).toLowerCase()).join(',') || ''; if (!isDone) c.due_date = null; }
+ showContent(card);
+ window._renderBoard();
+ const doneCorner = $('detail-done-corner');
+ if (doneCorner) doneCorner.classList.toggle('hidden', !newBody.includes('#done'));
+});
+
$('detail-delete')?.addEventListener('click', async () => {
if (!state.viewingCard) return;
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 6b31b11..ecccfa1 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -112,6 +112,58 @@ CARD_COLORS.forEach(c => {
presets.appendChild(btn);
});
+const inpDueValue = $('inp-due-value');
+const inpDueLabel = $('inp-due-label');
+const inpDueBtn = $('inp-due-date');
+
+function setDueDate(date) {
+ if (inpDueValue) inpDueValue.value = date || '';
+ if (inpDueLabel) inpDueLabel.textContent = date || 'no due';
+ if (inpDueBtn) {
+ inpDueBtn.classList.toggle('text-t-accent', !!date);
+ inpDueBtn.classList.toggle('border-t-accent', !!date);
+ inpDueBtn.classList.toggle('text-t-dim', !date);
+ }
+}
+
+inpDueBtn?.addEventListener('click', async () => {
+ const { showDialog } = await import('./dialog.js');
+ const current = inpDueValue?.value || '';
+ const result = await showDialog({
+ title: current ? 'change due date' : 'set due date',
+ input: 'date',
+ defaultValue: current || new Date().toISOString().substring(0, 10),
+ okLabel: current ? 'update' : 'set',
+ });
+ if (result === null) return;
+ setDueDate(result || '');
+});
+
+const inpDoneToggle = $('inp-done-toggle');
+const inpDoneLabel = $('inp-done-label');
+
+function updateDoneToggle() {
+ const isDone = body.value.includes('#done');
+ if (inpDoneToggle) {
+ inpDoneToggle.classList.toggle('text-t-accent', isDone);
+ inpDoneToggle.classList.toggle('border-t-accent', isDone);
+ inpDoneToggle.classList.toggle('text-t-dim', !isDone);
+ }
+ if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'undone' : 'done';
+}
+
+inpDoneToggle?.addEventListener('click', () => {
+ const isDone = body.value.includes('#done');
+ if (isDone) {
+ body.value = body.value.replace(/\s*#done\b/g, '');
+ } else {
+ body.value = body.value.trimEnd() + ' #done';
+ setDueDate('');
+ }
+ updateDoneToggle();
+ updatePreview();
+});
+
function selectColor(hex) {
inpColor.value = hex;
const bar = $('editor-color');
@@ -137,6 +189,8 @@ export function openModal(card = null) {
inpBucket.value = card?.bucket_id ? card.bucket_id
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
+ setDueDate(card?.due_date || '');
+ updateDoneToggle();
state.previewVisible = true;
preview.classList.remove('hidden');
editorDivider?.classList.remove('hidden');
@@ -328,7 +382,7 @@ function updatePreview() {
}
async function doSave(close, { animate = true } = {}) {
- const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
+ const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
if (state.editingId) {
@@ -975,7 +1029,7 @@ body.addEventListener('drop', e => {
document.addEventListener('mousedown', e => {
const img = e.target.closest('.prose img');
- if (!img) return;
+ if (!img || img.closest('.md-gallery-item')) return;
e.preventDefault();
img.classList.add('resizing');
const startX = e.clientX, startW = img.offsetWidth;
diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js
index 36eb760..aa92066 100644
--- a/public/js/modules/gallery.js
+++ b/public/js/modules/gallery.js
@@ -13,7 +13,7 @@ function mediaTile(url, extraClass = '') {
`;
}
return ``;
}
import { state } from './state.js';
diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js
index 18f97d3..f3cde84 100644
--- a/public/js/modules/markdown.js
+++ b/public/js/modules/markdown.js
@@ -13,7 +13,7 @@ marked.use({ breaks: true, gfm: true, renderer: {
const purifyConfig = {
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
- ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel'],
+ ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
ALLOW_DATA_ATTR: true,
};
@@ -38,7 +38,48 @@ function normalizeListBlock(block) {
}
export function renderMd(src) {
- let processed = String(src || '').replace(/\r\n?/g, '\n')
+ let processed = String(src || '').replace(/\r\n?/g, '\n');
+
+ // Convert Unicode box-drawing tables to HTML
+ processed = processed.replace(/([ \t]*[┌╔][\s\S]*?[┘╝][ \t]*)/g, (block) => {
+ const lines = block.split('\n').filter(l => l.trim());
+ const rows = [];
+ let currentRow = null;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // Skip border lines (┌─┬─┐, ├─┼─┤, └─┴─┘, ╔═╦═╗, etc.)
+ if (/^[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║\s]+$/.test(trimmed)) {
+ if (currentRow) { rows.push(currentRow); currentRow = null; }
+ continue;
+ }
+ // Data row: │ cell │ cell │ or ║ cell ║
+ if (/^[│║]/.test(trimmed)) {
+ const cells = trimmed.split(/[│║]/).slice(1, -1).map(c => c.trim());
+ if (currentRow && currentRow.length === cells.length) {
+ // Continuation line — append to previous cells
+ for (let i = 0; i < cells.length; i++) {
+ if (cells[i]) currentRow[i] = ((currentRow[i] || '') + ' ' + cells[i]).trim();
+ }
+ } else {
+ if (currentRow) rows.push(currentRow);
+ currentRow = cells;
+ }
+ }
+ }
+ if (currentRow) rows.push(currentRow);
+ if (rows.length < 2) return block;
+
+ const header = rows[0];
+ const body = rows.slice(1);
+ return '\n
' +
+ header.map(c => `| ${esc(c)} | `).join('') +
+ '
' +
+ body.map(r => '' + r.map(c => `| ${esc(c)} | `).join('') + '
').join('') +
+ '
\n';
+ });
+
+ processed = processed
// Gallery fenced block: ```gallery
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
const imgs = [];
diff --git a/public/js/modules/notifications.js b/public/js/modules/notifications.js
new file mode 100644
index 0000000..356ab16
--- /dev/null
+++ b/public/js/modules/notifications.js
@@ -0,0 +1,159 @@
+// ── Notification banner system ──
+// Providers register via addProvider(fn). Each fn(settings) returns an array of items.
+
+import { $, esc } from './util.js';
+
+const banner = $('upcoming-banner');
+const providers = [];
+const DISMISS_KEY = 'hyprcrd-dismissed-notifs';
+const DISMISS_DATE_KEY = 'hyprcrd-dismissed-date';
+const SETTINGS_KEY = 'hyprcrd-notif-settings';
+
+// Clear dismissed list daily
+if (localStorage.getItem(DISMISS_DATE_KEY) !== new Date().toISOString().substring(0, 10)) {
+ localStorage.removeItem(DISMISS_KEY);
+ localStorage.setItem(DISMISS_DATE_KEY, new Date().toISOString().substring(0, 10));
+}
+
+function getDismissed() {
+ try { return JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'); } catch { return []; }
+}
+
+function dismiss(id) {
+ const d = getDismissed();
+ if (!d.includes(id)) d.push(id);
+ localStorage.setItem(DISMISS_KEY, JSON.stringify(d));
+ render();
+}
+
+export function getSettings() {
+ const defaults = { lookahead: 3, time: 0, birthdays: true, termine: true, overdue: true };
+ try { return { ...defaults, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }; } catch { return defaults; }
+}
+
+export function saveSettings(s) {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
+ render();
+}
+
+/**
+ * Register a notification provider.
+ * @param {function} fn - Receives settings, returns array of { id, icon, iconColor, label, when, sort, onClick?, dismissable?, type? }
+ */
+export function addProvider(fn) {
+ providers.push(fn);
+}
+
+function getAllItems() {
+ const settings = getSettings();
+ const items = [];
+ for (const fn of providers) {
+ try {
+ const result = fn(settings);
+ if (Array.isArray(result)) items.push(...result);
+ } catch {}
+ }
+ items.sort((a, b) => (a.sort || '').localeCompare(b.sort || ''));
+ return items;
+}
+
+export function render() {
+ if (!banner) return;
+ const dismissed = getDismissed();
+ const visible = getAllItems().filter(i => !dismissed.includes(String(i.id)));
+
+ if (!visible.length) { banner.classList.add('hidden'); banner.innerHTML = ''; return; }
+
+ banner.classList.remove('hidden');
+ banner.innerHTML = `
+
+ ${visible.map(i => `
+
+ ${i.when ? `${esc(i.when)}` : ''}
+ ${esc(i.label)}
+ ${i.dismissable !== false ? `` : ''}
+
`).join('')}
+
`;
+
+ banner.querySelectorAll('[data-notif-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ if (e.target.closest('.upcoming-dismiss')) return;
+ const item = visible.find(i => String(i.id) === el.dataset.notifId);
+ if (item?.onClick) item.onClick();
+ });
+ });
+ banner.querySelectorAll('.upcoming-dismiss').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ dismiss(btn.dataset.dismiss);
+ });
+ });
+}
+
+// ── Settings panel integration ──
+
+export function renderSettingsPanel() {
+ const s = getSettings();
+
+ // Populate controls
+ const lookahead = $('notif-lookahead');
+ if (lookahead) lookahead.value = String(s.lookahead);
+ const time = $('notif-time');
+ if (time) time.value = String(s.time);
+ const cbBday = $('notif-birthdays');
+ if (cbBday) cbBday.checked = s.birthdays;
+ const cbTermin = $('notif-termine');
+ if (cbTermin) cbTermin.checked = s.termine;
+ const cbOverdue = $('notif-overdue');
+ if (cbOverdue) cbOverdue.checked = s.overdue;
+
+ // Auto-save on change (use onchange to avoid stacking listeners)
+ const save = () => saveSettings({
+ lookahead: parseInt(lookahead?.value || '3'),
+ time: parseInt(time?.value || '0'),
+ birthdays: cbBday?.checked ?? true,
+ termine: cbTermin?.checked ?? true,
+ overdue: cbOverdue?.checked ?? true,
+ });
+ if (lookahead) lookahead.onchange = save;
+ if (time) time.onchange = save;
+ if (cbBday) cbBday.onchange = save;
+ if (cbTermin) cbTermin.onchange = save;
+ if (cbOverdue) cbOverdue.onchange = save;
+
+ // Restore dismissed — replace element to avoid duplicate listeners
+ const restoreBtn = $('notif-clear-dismissed');
+ if (restoreBtn) {
+ const fresh = restoreBtn.cloneNode(true);
+ restoreBtn.parentNode.replaceChild(fresh, restoreBtn);
+ fresh.addEventListener('click', () => {
+ localStorage.removeItem(DISMISS_KEY);
+ render();
+ renderStream();
+ });
+ }
+
+ renderStream();
+}
+
+function renderStream() {
+ const stream = $('notif-stream');
+ if (!stream) return;
+ const dismissed = getDismissed();
+ const all = getAllItems();
+
+ if (!all.length) {
+ stream.innerHTML = '
no notifications
';
+ return;
+ }
+
+ stream.innerHTML = all.map(i => {
+ const isDismissed = dismissed.includes(String(i.id));
+ return `
+
+ ${i.when ? `${esc(i.when)}` : ''}
+ ${esc(i.label)}
+ ${isDismissed ? 'dismissed' : ''}
+
`;
+ }).join('');
+}
diff --git a/public/js/modules/state.js b/public/js/modules/state.js
index b2bf37f..aebbd37 100644
--- a/public/js/modules/state.js
+++ b/public/js/modules/state.js
@@ -8,6 +8,7 @@ export const state = {
activeBucket: 'all',
activeTags: [],
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
+ hideDone: localStorage.getItem('hyprcrd-hide-done') === '1',
superView: localStorage.getItem('hyprcrd-superview') === '1',
activeGroup: null,
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
@@ -45,6 +46,7 @@ export function tagChipHtml(tag) {
}
function effectiveDate(c) {
+ if (c.due_date) return c.due_date;
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
return (c.created_at || '').substring(0, 10);
diff --git a/public/js/modules/theme.js b/public/js/modules/theme.js
index 77ef362..51fc841 100644
--- a/public/js/modules/theme.js
+++ b/public/js/modules/theme.js
@@ -1,25 +1,101 @@
-// ── Theme picker ──
+// ── Theme + Settings ──
-import { $ } from './util.js';
+import { $, api, getCsrf } from './util.js';
+import { state } from './state.js';
+import { renderSettingsPanel as renderNotifSettings } from './notifications.js';
-const picker = $('theme-picker');
-const list = $('theme-list');
+const overlay = $('settings-overlay');
+const themeList = $('settings-theme-list');
-if (list) {
+// Render theme list in settings
+function renderThemes() {
+ if (!themeList) return;
+ const current = localStorage.getItem('hyprcrd-theme') || 'slate';
+ themeList.innerHTML = '';
for (const [key, t] of Object.entries(window.themes)) {
const btn = document.createElement('button');
- btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded';
- btn.dataset.themeDot = key;
- btn.innerHTML = `
-
${t.label}`;
- btn.onclick = () => { window.applyTheme(key); window._renderBoard?.(); };
- list.appendChild(btn);
+ const active = key === current;
+ btn.className = `flex items-center gap-3 px-3 py-2.5 text-left text-xs border transition-colors rounded ${active ? 'border-t-accent text-t-bright' : 'border-t-border text-t-muted hover:border-t-accent hover:text-t-bright'}`;
+ btn.innerHTML = `
+
${t.label}
+ ${active ? '
' : ''}`;
+ btn.onclick = () => { window.applyTheme(key); renderThemes(); window._renderBoard?.(); };
+ themeList.appendChild(btn);
}
}
-window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
+// Tab switching
+overlay?.querySelectorAll('.settings-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ overlay.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ const panel = tab.dataset.tab;
+ overlay.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
+ $(`settings-panel-${panel}`)?.classList.remove('hidden');
+ if (panel === 'sync') window._loadCaldavSettings?.();
+ if (panel === 'notifications') renderNotifSettings();
+ if (panel === 'behavior') initBehaviorSettings();
+ });
+});
-if (picker) {
- $('theme-toggle')?.addEventListener('click', () => picker.classList.toggle('open'));
- document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); });
+// Open/Close
+function updateStats() {
+ const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
+ const sb = $('stats-buckets'); if (sb) sb.textContent = state.buckets?.length || 0;
+ const tags = new Set(); (state.cards || []).forEach(c => (c.tags || '').split(',').forEach(t => { if (t) tags.add(t); }));
+ const st = $('stats-tags'); if (st) st.textContent = tags.size;
}
+
+$('settings-btn')?.addEventListener('click', () => {
+ renderThemes();
+ updateStats();
+ overlay?.classList.add('open');
+});
+$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
+overlay?.addEventListener('click', e => { if (e.target === overlay) overlay?.classList.remove('open'); });
+
+// Password change
+$('settings-pw-save')?.addEventListener('click', async () => {
+ const current = $('settings-pw-current')?.value;
+ const newPw = $('settings-pw-new')?.value;
+ const confirm = $('settings-pw-confirm')?.value;
+ const status = $('settings-pw-status');
+
+ if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
+ if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
+ if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
+
+ const res = await fetch('api/auth/password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
+ body: JSON.stringify({ current, new: newPw }),
+ }).then(r => r.json()).catch(() => ({ error: 'network error' }));
+
+ if (res.ok) {
+ if (status) { status.textContent = res.rekey ? 'password changed — re-encrypting notes...' : 'password changed'; status.style.color = 'var(--t-accent)'; }
+ $('settings-pw-current').value = '';
+ $('settings-pw-new').value = '';
+ $('settings-pw-confirm').value = '';
+ // Trigger sync to re-push notes with new encryption key
+ if (res.rekey) {
+ window._triggerSync?.();
+ setTimeout(() => { if (status) status.textContent = 'password changed, notes re-encrypted'; }, 3000);
+ }
+ } else {
+ if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
+ }
+});
+
+function initBehaviorSettings() {
+ const sel = $('setting-current-days');
+ if (sel) {
+ sel.value = localStorage.getItem('hyprcrd-current-days') || '2';
+ sel.onchange = () => {
+ localStorage.setItem('hyprcrd-current-days', sel.value);
+ window._renderBoard?.();
+ };
+ }
+}
+
+// Apply saved theme on load
+window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
diff --git a/src/CalDavSync.php b/src/CalDavSync.php
index 0fa5088..bd2c2ff 100644
--- a/src/CalDavSync.php
+++ b/src/CalDavSync.php
@@ -403,7 +403,7 @@ class CalDavSync
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
// 3. Fetch local appointment cards — only #termin tagged or already synced
- $stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL");
+ $stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR (caldav_uid IS NOT NULL AND caldav_uid NOT LIKE 'note:%')");
$localCards = $stmt->fetchAll();
$localMap = []; // caldav_uid -> card
$localNew = []; // cards without caldav_uid
@@ -415,9 +415,9 @@ class CalDavSync
}
}
- // 4. Tombstones
+ // 4. Tombstones (only CalDAV, not notes)
$tombstones = [];
- foreach ($this->db->query('SELECT * FROM caldav_tombstones')->fetchAll() as $t) {
+ foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid NOT LIKE 'note:%'")->fetchAll() as $t) {
$tombstones[$t['caldav_uid']] = $t;
}
diff --git a/src/Database.php b/src/Database.php
index a8f7700..3e9f82b 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -83,6 +83,10 @@ class Database
}
}
+ if (!in_array('due_date', $colNames)) {
+ self::$pdo->exec('ALTER TABLE cards ADD COLUMN due_date TEXT DEFAULT NULL');
+ }
+
// CalDAV columns
if (!in_array('caldav_uid', $colNames)) {
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_uid TEXT DEFAULT NULL');
diff --git a/src/WebDavNotes.php b/src/WebDavNotes.php
new file mode 100644
index 0000000..a2ec64a
--- /dev/null
+++ b/src/WebDavNotes.php
@@ -0,0 +1,369 @@
+ 0, 'pulled' => 0, 'deleted' => 0];
+
+ public function __construct(PDO $db)
+ {
+ $this->db = $db;
+ }
+
+ private function getEncKey(): string
+ {
+ $hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
+ return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
+ }
+
+ private function decrypt(string $encrypted): string
+ {
+ $key = $this->getEncKey();
+ $raw = base64_decode($encrypted);
+ $iv = substr($raw, 0, 16);
+ $cipher = base64_encode(substr($raw, 16));
+ return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: '';
+ }
+
+ private function encryptContent(string $plain): string
+ {
+ $key = $this->getEncKey();
+ $iv = random_bytes(16);
+ $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
+ return "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($iv . $cipher);
+ }
+
+ private function decryptContent(string $data): ?string
+ {
+ $lines = explode("\n", $data, 2);
+ if ($lines[0] !== 'HYPRCRD-ENCRYPTED-V1' || empty($lines[1])) return null;
+ $key = $this->getEncKey();
+ $raw = base64_decode(trim($lines[1]));
+ if (strlen($raw) < 17) return null;
+ $iv = substr($raw, 0, 16);
+ $cipher = substr($raw, 16);
+ $plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
+ return $plain !== false ? $plain : null;
+ }
+
+ private function isEncrypted(string $data): bool
+ {
+ return str_starts_with($data, 'HYPRCRD-ENCRYPTED-V1');
+ }
+
+ private function getClient(): CalDavClient
+ {
+ if (!$this->client) {
+ // Derive WebDAV files URL from CalDAV URL
+ $caldavUrl = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_url'")->fetchColumn();
+ $user = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_username'")->fetchColumn();
+ $pass = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn();
+ if (!$caldavUrl || !$pass) throw new \Exception('No CalDAV credentials configured');
+
+ $url = $this->decrypt($caldavUrl);
+ // Convert /calendars/user/... to /files/user/Notes/
+ if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) {
+ $notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/';
+ } else {
+ // Fallback: replace calendars path
+ $notesUrl = preg_replace('#/calendars/[^/]+/.*$#', '/files/' . urlencode($this->decrypt($user)) . '/Notes/', $url);
+ }
+
+ $this->client = new CalDavClient($notesUrl, $this->decrypt($user), $this->decrypt($pass));
+ }
+ return $this->client;
+ }
+
+ private function addLog(string $dir, ?int $cardId, string $action, string $status, string $detail = ''): void
+ {
+ $this->log[] = compact('dir', 'cardId', 'action', 'status', 'detail');
+ }
+
+ private function safeFilename(string $title): string
+ {
+ $name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $title);
+ return trim($name) ?: 'untitled';
+ }
+
+ public function listRemoteNotes(): array
+ {
+ $client = $this->getClient();
+ $body = '
';
+ $res = $client->request('PROPFIND', $client->getBaseUrl(), $body, ['Depth: 1']);
+
+ if ($res['status'] === 404) {
+ // Notes folder doesn't exist, create it
+ $client->request('MKCOL', $client->getBaseUrl());
+ return [];
+ }
+ if ($res['status'] !== 207) return [];
+
+ $notes = [];
+ try {
+ $xml = new SimpleXMLElement($res['body']);
+ $xml->registerXPathNamespace('d', 'DAV:');
+ $baseHref = rtrim(parse_url($client->getBaseUrl(), PHP_URL_PATH), '/');
+ foreach ($xml->xpath('//d:response') as $resp) {
+ $resp->registerXPathNamespace('d', 'DAV:');
+ $href = (string)($resp->xpath('.//d:href')[0] ?? '');
+ if (rtrim($href, '/') === $baseHref) continue;
+ if (!preg_match('/\.md$/i', $href)) continue;
+ $etag = (string)($resp->xpath('.//d:getetag')[0] ?? '');
+ $lastmod = (string)($resp->xpath('.//d:getlastmodified')[0] ?? '');
+ $filename = urldecode(basename($href));
+ $notes[] = ['href' => $href, 'filename' => $filename, 'etag' => $etag, 'lastmod' => $lastmod];
+ }
+ } catch (\Exception $e) {
+ $this->addLog('sync', null, 'list', 'error', $e->getMessage());
+ }
+ return $notes;
+ }
+
+ public function sync(): array
+ {
+ try {
+ $client = $this->getClient();
+ } catch (\Exception $e) {
+ return ['ok' => false, 'error' => $e->getMessage(), 'log' => $this->log, 'stats' => $this->stats];
+ }
+
+ $baseUrl = $client->getBaseUrl();
+ $this->addLog('sync', null, 'init', 'ok', "notes: {$baseUrl}");
+
+ // Fetch remote notes
+ $remoteNotes = $this->listRemoteNotes();
+ $this->addLog('sync', null, 'fetch', 'ok', count($remoteNotes) . ' remote notes');
+ $remoteMap = []; // filename -> note data
+ foreach ($remoteNotes as $n) {
+ $remoteMap[$n['filename']] = $n;
+ }
+
+ // Fetch local #notes cards (including previously synced)
+ $localCards = $this->db->query("SELECT * FROM cards WHERE tags = 'notes' OR tags LIKE 'notes,%' OR tags LIKE '%,notes,%' OR tags LIKE '%,notes' OR caldav_uid LIKE 'note:%'")->fetchAll();
+ $this->addLog('sync', null, 'fetch', 'ok', count($localCards) . ' local #notes cards');
+
+ // Tombstones for deleted notes
+ $tombstones = [];
+ foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'")->fetchAll() as $t) {
+ $tombstones[str_replace('note:', '', $t['caldav_uid'])] = $t;
+ }
+
+ $localByFile = []; // filename -> card
+ $localNew = [];
+ foreach ($localCards as $c) {
+ if ($c['caldav_href']) {
+ $localByFile[basename($c['caldav_href'])] = $c;
+ } elseif ($c['caldav_uid'] && str_starts_with($c['caldav_uid'], 'note:')) {
+ $storedFile = str_replace('note:', '', $c['caldav_uid']);
+ $localByFile[$storedFile] = $c;
+ } else {
+ $localNew[] = $c;
+ }
+ }
+
+ // A: Existing synced cards
+ foreach ($localByFile as $filename => $card) {
+ if (isset($remoteMap[$filename])) {
+ $remote = $remoteMap[$filename];
+ $lt = strtotime($card['updated_at'] ?? '2000-01-01');
+ $st = strtotime($card['caldav_synced_at'] ?? '2000-01-01');
+ $localChanged = !$card['caldav_synced_at'] || ($lt - $st) > 2;
+ $remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag'];
+
+ if ($remoteChanged && !$localChanged) {
+ // Pull
+ $this->pullNote($client, $remote, $card);
+ } elseif ($localChanged && !$remoteChanged) {
+ // Push
+ $this->pushNote($client, $card, $filename);
+ } elseif ($localChanged && $remoteChanged) {
+ // Conflict: last modified wins
+ $localTime = strtotime($card['updated_at']);
+ $remoteTime = strtotime($remote['lastmod'] ?? '2000-01-01');
+ if ($localTime >= $remoteTime) {
+ $this->pushNote($client, $card, $filename);
+ } else {
+ $this->pullNote($client, $remote, $card);
+ }
+ }
+ unset($remoteMap[$filename]);
+ } else {
+ // Remote deleted — always respect server deletion for notes
+ $this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]);
+ $this->addLog('pull', $card['id'], 'delete', 'ok', "remote deleted: {$filename}");
+ $this->stats['deleted']++;
+ }
+ }
+
+ // B: New local cards → push (opaque encrypted filename)
+ foreach ($localNew as $card) {
+ $filename = $this->encFilename($card['id']);
+ $this->pushNote($client, $card, $filename);
+ }
+
+ // C: New remote notes → pull (skip tombstoned)
+ foreach ($remoteMap as $filename => $remote) {
+ if (isset($tombstones[$filename])) {
+ // Locally deleted → delete from server
+ $parsed = parse_url($client->getBaseUrl());
+ $base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
+ $client->request('DELETE', $base . $remote['href']);
+ $this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
+ $this->addLog('push', null, 'delete', 'ok', "deleted remote: {$filename}");
+ $this->stats['deleted']++;
+ continue;
+ }
+ $this->pullNote($client, $remote, null);
+ }
+
+ // Clean up remaining tombstones (already gone from server)
+ foreach ($tombstones as $filename => $t) {
+ if (!isset($remoteMap[$filename])) {
+ $this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
+ }
+ }
+
+ return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats];
+ }
+
+ private function encFilename(int $cardId): string
+ {
+ // Deterministic opaque filename from card ID + encryption key
+ return 'enc-' . substr(hash('sha256', $this->getEncKey() . ':' . $cardId), 0, 12) . '.md';
+ }
+
+ private function pushNote(CalDavClient $client, array $card, string $filename): void
+ {
+ // Include metadata for cross-client sync
+ $meta = '';
+ if (!empty($card['bucket_name'])) {
+ $meta = "\n";
+ } elseif (!empty($card['bucket_id'])) {
+ $bname = $this->db->prepare('SELECT name FROM buckets WHERE id = ?');
+ $bname->execute([$card['bucket_id']]);
+ $bn = $bname->fetchColumn();
+ if ($bn) $meta = "\n";
+ }
+ if (!empty($card['color']) && $card['color'] !== '#38bdf8') {
+ $meta .= "\n";
+ }
+ $plain = "{$meta}# {$card['title']}\n\n{$card['body']}";
+ $content = $this->encryptContent($plain);
+
+ // Migrate old plaintext filename to encrypted opaque filename
+ $oldHref = $card['caldav_href'] ? basename($card['caldav_href']) : null;
+ if ($oldHref && !str_starts_with($oldHref, 'enc-')) {
+ // Delete old plaintext file
+ $oldUrl = $client->getBaseUrl() . rawurlencode($oldHref);
+ $client->request('DELETE', $oldUrl);
+ $filename = $this->encFilename($card['id']);
+ } else {
+ $filename = $oldHref ?: $this->encFilename($card['id']);
+ }
+ $url = $client->getBaseUrl() . rawurlencode($filename);
+ $res = $client->request('PUT', $url, $content, ['Content-Type: text/markdown; charset=utf-8']);
+ if ($res['status'] >= 200 && $res['status'] < 300) {
+ $etag = $res['etag'];
+ // If no ETag in PUT response, fetch it
+ if (!$etag) {
+ $prop = $client->request('PROPFIND', $url, '
', ['Depth: 0']);
+ if ($prop['status'] === 207) {
+ try {
+ $xml = new \SimpleXMLElement($prop['body']);
+ $xml->registerXPathNamespace('d', 'DAV:');
+ $found = $xml->xpath('//d:getetag');
+ if ($found) $etag = (string)$found[0];
+ } catch (\Exception $e) {}
+ }
+ }
+ $this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?')
+ ->execute(['note:' . $filename, $etag, $filename, $card['id']]);
+ $this->addLog('push', $card['id'], 'push', 'ok', $filename);
+ $this->stats['pushed']++;
+ } else {
+ $this->addLog('push', $card['id'], 'push', 'error', "HTTP {$res['status']} {$filename}");
+ }
+ }
+
+ private function pullNote(CalDavClient $client, array $remote, ?array $card): void
+ {
+ $parsed = parse_url($client->getBaseUrl());
+ $base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
+ $url = $base . $remote['href'];
+ $res = $client->request('GET', $url);
+ if ($res['status'] !== 200) {
+ $this->addLog('pull', $card['id'] ?? null, 'pull', 'error', "HTTP {$res['status']} {$remote['filename']}");
+ return;
+ }
+
+ $raw = $res['body'];
+ // Decrypt if encrypted, fall back to plain text
+ if ($this->isEncrypted($raw)) {
+ $content = $this->decryptContent($raw);
+ if ($content === null) {
+ // Decryption failed — might be from another instance with different password
+ // Try treating as plain text (skip the header line)
+ $this->addLog('pull', $card['id'] ?? null, 'pull', 'ok', "decrypt failed, importing as plain: {$remote['filename']}");
+ $content = $raw;
+ }
+ } else {
+ $content = $raw;
+ }
+ // Extract metadata comments
+ $bucketName = null;
+ $noteColor = null;
+ if (preg_match('//', $content, $bm)) { $bucketName = $bm[1]; $content = str_replace($bm[0] . "\n", '', $content); }
+ if (preg_match('//', $content, $cm)) { $noteColor = $cm[1]; $content = str_replace($cm[0] . "\n", '', $content); }
+
+ // Resolve or create bucket
+ $bucketId = $card['bucket_id'] ?? null;
+ if ($bucketName) {
+ $stmt = $this->db->prepare('SELECT id FROM buckets WHERE name = ?');
+ $stmt->execute([$bucketName]);
+ $existingBucket = $stmt->fetchColumn();
+ if ($existingBucket) {
+ $bucketId = $existingBucket;
+ } else {
+ $this->db->prepare('INSERT INTO buckets (name, position) VALUES (?, (SELECT COALESCE(MAX(position),0)+1 FROM buckets))')->execute([$bucketName]);
+ $bucketId = $this->db->lastInsertId();
+ $this->addLog('pull', null, 'bucket', 'ok', "created bucket: {$bucketName}");
+ }
+ }
+
+ // Parse title from first # heading or filename
+ $title = preg_replace('/\.md$/i', '', $remote['filename']);
+ $body = $content;
+ if (preg_match('/^#\s+(.+)\n\n?([\s\S]*)$/m', $content, $m)) {
+ $title = trim($m[1]);
+ $body = trim($m[2]);
+ }
+ // Ensure #notes tag
+ if (strpos($body, '#notes') === false) {
+ $body = rtrim($body) . "\n\n#notes";
+ }
+ $tags = $this->extractTags($body);
+
+ $color = $noteColor ?: ($card['color'] ?? '#38bdf8');
+
+ if ($card) {
+ $this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, color=?, bucket_id=?, caldav_etag=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?')
+ ->execute([$title, $body, $tags, $color, $bucketId, $remote['etag'], $card['id']]);
+ $this->addLog('pull', $card['id'], 'update', 'ok', $remote['filename']);
+ } else {
+ $this->db->prepare('INSERT INTO cards (title, body, color, tags, bucket_id, position, caldav_uid, caldav_etag, caldav_href, caldav_synced_at) VALUES (?, ?, ?, ?, ?, -1, ?, ?, ?, datetime("now"))')
+ ->execute([$title, $body, $color, $tags, $bucketId, 'note:' . $remote['filename'], $remote['etag'], $remote['filename']]);
+ $this->addLog('pull', (int)$this->db->lastInsertId(), 'create', 'ok', $remote['filename'] . ($bucketName ? " → {$bucketName}" : ''));
+ }
+ $this->stats['pulled']++;
+ }
+
+ private function extractTags(string $body): string
+ {
+ preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body, $matches);
+ return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
+ }
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
index d09430d..79d644e 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -1,6 +1,12 @@