From cf095e8043bccdddc291a572b264210892595b37 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 7 Aug 2026 12:43:53 +0530 Subject: [PATCH 1/8] Intial Verison --- electron/lib/core/appMenu.cjs | 11 + electron/lib/tasks/TaskDatabase.cjs | 600 +++++++++++++++++++ electron/lib/tasks/taskIpc.cjs | 396 +++++++++++++ electron/lib/web/websiteRenderer.cjs | 19 + electron/main.cjs | 10 +- electron/preload.cjs | 15 + package-lock.json | 246 +++++++- package.json | 2 + src/App.jsx | 88 +++ src/components/CalendarPage.jsx | 274 +++++++++ src/components/DashboardPanels.jsx | 39 +- src/components/TaskWorkspacePage.jsx | 503 ++++++++++++++++ src/components/layout/TitleBar.jsx | 6 + src/contexts/UIStateContext.jsx | 6 + src/hooks/useTaskWorkspace.js | 172 ++++++ src/services/electronService.js | 78 +++ src/styles/CalendarPage.css | 335 +++++++++++ src/styles/TaskWorkspacePage.css | 825 +++++++++++++++++++++++++++ 18 files changed, 3620 insertions(+), 5 deletions(-) create mode 100644 electron/lib/tasks/TaskDatabase.cjs create mode 100644 electron/lib/tasks/taskIpc.cjs create mode 100644 src/components/CalendarPage.jsx create mode 100644 src/components/TaskWorkspacePage.jsx create mode 100644 src/hooks/useTaskWorkspace.js create mode 100644 src/styles/CalendarPage.css create mode 100644 src/styles/TaskWorkspacePage.css diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 2167b987..3f70ad71 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -548,6 +548,17 @@ function buildAppMenuTemplate(win, context = {}) { { label: "Workspace", submenu: [ + { + label: "Tasks", + accelerator: "CmdOrCtrl+Shift+T", + click: () => sendMenuAction(win, "open-tasks-workspace") + }, + { + label: "Calendar", + accelerator: "CmdOrCtrl+Shift+L", + click: () => sendMenuAction(win, "open-calendar") + }, + { type: "separator" }, { label: "Workspace Information", click: () => sendMenuAction(win, "open-workspace-info") diff --git a/electron/lib/tasks/TaskDatabase.cjs b/electron/lib/tasks/TaskDatabase.cjs new file mode 100644 index 00000000..c9401a5f --- /dev/null +++ b/electron/lib/tasks/TaskDatabase.cjs @@ -0,0 +1,600 @@ +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const TASK_DB_DIR = '.notes_app'; +const TASK_DB_FILE = 'task-db.sqlite'; +const TASK_JSON_FILE = 'tasks.json'; + +function hashSource(filePath, lineText) { + return crypto.createHash('sha1').update(`${filePath}\x00${lineText}`).digest('hex'); +} + +function randomId() { + return crypto.randomBytes(10).toString('hex'); +} + +function nowISO() { + return new Date().toISOString(); +} + +const SCHEMA = ` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 0, + source_path TEXT, + source_line INTEGER, + source_hash TEXT UNIQUE, + user_managed INTEGER NOT NULL DEFAULT 0, + due_date TEXT, + scheduled_start TEXT, + scheduled_end TEXT, + is_all_day INTEGER NOT NULL DEFAULT 1, + reminder TEXT, + person_tags TEXT NOT NULL DEFAULT '[]', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT + ); + + CREATE TABLE IF NOT EXISTS task_comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author TEXT NOT NULL DEFAULT 'me', + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS persons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); + CREATE INDEX IF NOT EXISTS idx_tasks_source_path ON tasks(source_path); + CREATE INDEX IF NOT EXISTS idx_task_comments_task_id ON task_comments(task_id); +`; + +class TaskDatabase { + constructor(workspaceRoot) { + this.workspaceRoot = workspaceRoot; + this.db = null; + this.jsonState = null; + this.jsonPath = null; + this._open(); + } + + _open() { + if (!this.workspaceRoot) return; + const dir = path.join(this.workspaceRoot, TASK_DB_DIR); + if (!fs.existsSync(dir)) { + try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } + } + + // Try SQLite first + try { + const sqliteModule = require('node:sqlite'); + const DatabaseSync = sqliteModule?.DatabaseSync; + if (DatabaseSync) { + const dbPath = path.join(dir, TASK_DB_FILE); + this.db = new DatabaseSync(dbPath); + this.db.exec(SCHEMA); + return; + } + } catch (err) { + console.warn('[TaskDatabase] SQLite unavailable, using JSON fallback:', err?.message || err); + this.db = null; + } + + // JSON fallback + this.jsonPath = path.join(dir, TASK_JSON_FILE); + try { + if (fs.existsSync(this.jsonPath)) { + this.jsonState = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8')); + } + } catch { + /* ignore */ + } + if (!this.jsonState) { + this.jsonState = { tasks: [], comments: [], persons: [] }; + } + } + + _saveJson() { + if (!this.jsonPath || !this.jsonState) return; + try { + fs.writeFileSync(this.jsonPath, JSON.stringify(this.jsonState, null, 2), 'utf8'); + } catch { /* ignore */ } + } + + close() { + try { this.db?.close(); } catch { /* ignore */ } + this.db = null; + } + + // ── Tasks ───────────────────────────────────────────────────────────────── + + syncFromNote(filePath, parsedTasks) { + const now = nowISO(); + let inserted = 0; + let updated = 0; + + if (this.db) { + for (const t of parsedTasks) { + const hash = hashSource(filePath, t.lineText); + const existing = this.db.prepare( + 'SELECT id, status, user_managed FROM tasks WHERE source_hash = ?' + ).get(hash); + + if (!existing) { + this.db.prepare(` + INSERT INTO tasks (id, title, status, source_path, source_line, source_hash, + user_managed, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + `).run(randomId(), t.title, t.status, filePath, t.line, hash, now, now); + inserted++; + } else if (!existing.user_managed && existing.status !== t.status) { + const completedAt = t.status === 'done' ? now : null; + this.db.prepare( + 'UPDATE tasks SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(t.status, now, completedAt, existing.id); + updated++; + } + } + return { inserted, updated }; + } + + if (this.jsonState) { + for (const t of parsedTasks) { + const hash = hashSource(filePath, t.lineText); + const existing = this.jsonState.tasks.find(x => x.source_hash === hash); + + if (!existing) { + this.jsonState.tasks.push({ + id: randomId(), + title: t.title, + description: '', + status: t.status, + priority: 0, + source_path: filePath, + source_line: t.line, + source_hash: hash, + user_managed: 0, + due_date: null, + scheduled_start: null, + scheduled_end: null, + is_all_day: 1, + reminder: null, + person_tags: [], + metadata: {}, + created_at: now, + updated_at: now, + completed_at: t.status === 'done' ? now : null, + }); + inserted++; + } else if (!existing.user_managed && existing.status !== t.status) { + existing.status = t.status; + existing.updated_at = now; + existing.completed_at = t.status === 'done' ? now : null; + updated++; + } + } + this._saveJson(); + } + + return { inserted, updated }; + } + + listTasks({ status, priority, dueBefore, noteFilter, search, limit = 200, offset = 0 } = {}) { + if (this.db) { + let sql = 'SELECT * FROM tasks WHERE 1=1'; + const params = []; + + if (status && status !== 'all') { + if (status === 'overdue') { + sql += " AND status = 'open' AND due_date IS NOT NULL AND due_date < date('now')"; + } else if (status === 'today') { + sql += " AND status = 'open' AND due_date = date('now')"; + } else if (status === 'upcoming') { + sql += " AND status = 'open' AND (due_date IS NULL OR due_date >= date('now'))"; + } else { + sql += ' AND status = ?'; + params.push(status); + } + } + + if (priority != null && priority >= 0) { + sql += ' AND priority = ?'; + params.push(priority); + } + + if (dueBefore) { + sql += ' AND due_date IS NOT NULL AND due_date <= ?'; + params.push(dueBefore); + } + + if (noteFilter) { + sql += ' AND source_path = ?'; + params.push(noteFilter); + } + + if (search) { + sql += ' AND (title LIKE ? OR description LIKE ?)'; + const needle = `%${search}%`; + params.push(needle, needle); + } + + sql += " ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, due_date ASC NULLS LAST, priority DESC, updated_at DESC"; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return this.db.prepare(sql).all(...params).map(r => this._deserialize(r)); + } + + if (this.jsonState) { + const todayStr = new Date().toISOString().slice(0, 10); + let list = [...this.jsonState.tasks]; + + if (status && status !== 'all') { + if (status === 'overdue') { + list = list.filter(t => t.status === 'open' && t.due_date && t.due_date < todayStr); + } else if (status === 'today') { + list = list.filter(t => t.status === 'open' && t.due_date === todayStr); + } else if (status === 'upcoming') { + list = list.filter(t => t.status === 'open' && (!t.due_date || t.due_date >= todayStr)); + } else { + list = list.filter(t => t.status === status); + } + } + + if (priority != null && priority >= 0) { + list = list.filter(t => (t.priority ?? 0) === priority); + } + + if (dueBefore) { + list = list.filter(t => t.due_date && t.due_date <= dueBefore); + } + + if (noteFilter) { + list = list.filter(t => t.source_path === noteFilter); + } + + if (search) { + const needle = search.toLowerCase(); + list = list.filter(t => (t.title || '').toLowerCase().includes(needle) || (t.description || '').toLowerCase().includes(needle)); + } + + list.sort((a, b) => { + if (a.status !== b.status) return a.status === 'open' ? -1 : 1; + if (a.due_date !== b.due_date) { + if (!a.due_date) return 1; + if (!b.due_date) return -1; + return a.due_date.localeCompare(b.due_date); + } + return (b.priority ?? 0) - (a.priority ?? 0); + }); + + return list.slice(offset, offset + limit); + } + + return []; + } + + getTask(id) { + if (this.db) { + const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + return row ? this._deserialize(row) : null; + } + if (this.jsonState) { + return this.jsonState.tasks.find(t => t.id === id) ?? null; + } + return null; + } + + createTask({ title, description = '', status = 'open', priority = 0, sourcePath, sourceLine, + dueDate, scheduledStart, scheduledEnd, isAllDay = 1, + reminder, personTags = [], metadata = {} }) { + const id = randomId(); + const now = nowISO(); + + if (this.db) { + this.db.prepare(` + INSERT INTO tasks (id, title, description, status, priority, source_path, source_line, + due_date, scheduled_start, scheduled_end, is_all_day, reminder, + person_tags, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, title, description, status, priority, sourcePath ?? null, sourceLine ?? null, + dueDate ?? null, scheduledStart ?? null, scheduledEnd ?? null, isAllDay ? 1 : 0, + reminder ?? null, JSON.stringify(personTags), JSON.stringify(metadata), now, now); + return this.getTask(id); + } + + if (this.jsonState) { + const task = { + id, title, description, status, priority, + source_path: sourcePath ?? null, + source_line: sourceLine ?? null, + source_hash: null, + user_managed: 1, + due_date: dueDate ?? null, + scheduled_start: scheduledStart ?? null, + scheduled_end: scheduledEnd ?? null, + is_all_day: isAllDay ? 1 : 0, + reminder: reminder ?? null, + person_tags: personTags, + metadata, + created_at: now, + updated_at: now, + completed_at: status === 'done' ? now : null, + }; + this.jsonState.tasks.push(task); + this._saveJson(); + return task; + } + + return null; + } + + updateTask(id, fields) { + const now = nowISO(); + + if (this.db) { + const allowed = ['title', 'description', 'status', 'priority', 'due_date', 'scheduled_start', + 'scheduled_end', 'is_all_day', 'reminder', 'person_tags', 'metadata', + 'user_managed', 'completed_at']; + const sets = []; + const params = []; + + for (const [key, val] of Object.entries(fields)) { + const col = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + if (!allowed.includes(col)) continue; + const stored = (col === 'person_tags' || col === 'metadata') ? JSON.stringify(val) : val; + sets.push(`${col} = ?`); + params.push(stored); + } + + if (!sets.length) return this.getTask(id); + + if (fields.status === 'done' && !fields.completedAt) { + sets.push('completed_at = ?'); + params.push(now); + } else if (fields.status === 'open') { + sets.push('completed_at = ?'); + params.push(null); + } + + sets.push('updated_at = ?', 'user_managed = 1'); + params.push(now); + + this.db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params, id); + return this.getTask(id); + } + + if (this.jsonState) { + const task = this.jsonState.tasks.find(t => t.id === id); + if (!task) return null; + + for (const [key, val] of Object.entries(fields)) { + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + task[snakeKey] = val; + } + task.user_managed = 1; + task.updated_at = now; + + if (fields.status === 'done' && !fields.completedAt) { + task.completed_at = now; + } else if (fields.status === 'open') { + task.completed_at = null; + } + + this._saveJson(); + return task; + } + + return null; + } + + deleteTask(id) { + if (this.db) { + this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + return true; + } + if (this.jsonState) { + const idx = this.jsonState.tasks.findIndex(t => t.id === id); + if (idx !== -1) { + this.jsonState.tasks.splice(idx, 1); + this._saveJson(); + return true; + } + } + return false; + } + + getOverdueTasks() { + return this.listTasks({ status: 'overdue', limit: 1000 }); + } + + // ── Comments ────────────────────────────────────────────────────────────── + + addComment(taskId, { body, author = 'me' }) { + const id = randomId(); + const now = nowISO(); + + if (this.db) { + this.db.prepare( + 'INSERT INTO task_comments (id, task_id, author, body, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(id, taskId, author, body, now); + return { id, taskId, author, body, createdAt: now }; + } + + if (this.jsonState) { + const comment = { id, taskId, author, body, createdAt: now }; + this.jsonState.comments.push(comment); + this._saveJson(); + return comment; + } + + return null; + } + + getComments(taskId) { + if (this.db) { + return this.db.prepare( + 'SELECT id, task_id AS taskId, author, body, created_at AS createdAt FROM task_comments WHERE task_id = ? ORDER BY created_at ASC' + ).all(taskId); + } + if (this.jsonState) { + return this.jsonState.comments + .filter(c => c.taskId === taskId) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + return []; + } + + // ── Persons ─────────────────────────────────────────────────────────────── + + listPersons() { + if (this.db) { + return this.db.prepare('SELECT * FROM persons ORDER BY name ASC').all(); + } + if (this.jsonState) { + return [...this.jsonState.persons].sort((a, b) => a.name.localeCompare(b.name)); + } + return []; + } + + upsertPerson({ id, name, email = '', avatar = '' }) { + const now = nowISO(); + const pid = id || randomId(); + + if (this.db) { + this.db.prepare(` + INSERT INTO persons (id, name, email, avatar, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, email = excluded.email, avatar = excluded.avatar + `).run(pid, name, email, avatar, now); + return this.db.prepare('SELECT * FROM persons WHERE id = ?').get(pid); + } + + if (this.jsonState) { + let p = this.jsonState.persons.find(x => x.id === pid); + if (p) { + p.name = name; + p.email = email; + p.avatar = avatar; + } else { + p = { id: pid, name, email, avatar, created_at: now }; + this.jsonState.persons.push(p); + } + this._saveJson(); + return p; + } + + return null; + } + + deletePerson(id) { + if (this.db) { + this.db.prepare('DELETE FROM persons WHERE id = ?').run(id); + return true; + } + if (this.jsonState) { + const idx = this.jsonState.persons.findIndex(p => p.id === id); + if (idx !== -1) { + this.jsonState.persons.splice(idx, 1); + this._saveJson(); + return true; + } + } + return false; + } + + // ── Calendar Events ─────────────────────────────────────────────────────── + + getCalendarTaskEvents(startDate, endDate) { + if (this.db) { + const rows = this.db.prepare(` + SELECT id, title, status, due_date, scheduled_start, scheduled_end, + is_all_day, priority, source_path, completed_at + FROM tasks + WHERE ( + (due_date IS NOT NULL AND due_date BETWEEN ? AND ?) + OR (scheduled_start IS NOT NULL AND date(scheduled_start) BETWEEN ? AND ?) + OR (completed_at IS NOT NULL AND date(completed_at) BETWEEN ? AND ?) + ) + `).all(startDate, endDate, startDate, endDate, startDate, endDate); + + return rows.map(r => ({ + ...this._deserialize(r), + _eventType: r.completed_at ? 'task-completed' + : r.scheduled_start ? 'task-scheduled' + : 'task-due', + })); + } + + if (this.jsonState) { + return this.jsonState.tasks + .filter(t => { + const due = t.due_date && t.due_date >= startDate && t.due_date <= endDate; + const sched = t.scheduled_start && t.scheduled_start.slice(0, 10) >= startDate && t.scheduled_start.slice(0, 10) <= endDate; + const comp = t.completed_at && t.completed_at.slice(0, 10) >= startDate && t.completed_at.slice(0, 10) <= endDate; + return due || sched || comp; + }) + .map(t => ({ + ...t, + _eventType: t.completed_at ? 'task-completed' + : t.scheduled_start ? 'task-scheduled' + : 'task-due', + })); + } + + return []; + } + + _deserialize = (row) => { + return { + ...row, + personTags: this._parseJson(row.person_tags, []), + metadata: this._parseJson(row.metadata, {}), + isAllDay: Boolean(row.is_all_day), + userManaged: Boolean(row.user_managed), + }; + }; + + _parseJson = (val, fallback) => { + try { return JSON.parse(val); } catch { return fallback; } + }; +} + +const _instances = new Map(); + +function getTaskDatabase(workspaceRoot) { + if (!workspaceRoot) return null; + const key = String(workspaceRoot).toLowerCase(); + if (!_instances.has(key)) { + _instances.set(key, new TaskDatabase(workspaceRoot)); + } + return _instances.get(key); +} + +function closeTaskDatabase(workspaceRoot) { + if (!workspaceRoot) return; + const key = String(workspaceRoot).toLowerCase(); + const db = _instances.get(key); + if (db) { db.close(); _instances.delete(key); } +} + +module.exports = { TaskDatabase, getTaskDatabase, closeTaskDatabase, hashSource }; diff --git a/electron/lib/tasks/taskIpc.cjs b/electron/lib/tasks/taskIpc.cjs new file mode 100644 index 00000000..d3653122 --- /dev/null +++ b/electron/lib/tasks/taskIpc.cjs @@ -0,0 +1,396 @@ +'use strict'; + +const { getTaskDatabase } = require('./TaskDatabase.cjs'); +const { assertTrustedIpcSender } = require('../ipc/ipcSecurity.cjs'); +const path = require('node:path'); +const fs = require('node:fs'); + +// Parse open/closed task lines from markdown text +const OPEN_REGEX = /^[ \t]*[-*+]?[ \t]*\[ \][ \t]+(.+)$/gm; +const DONE_REGEX = /^[ \t]*[-*+]?[ \t]*\[(?:x|X)\][ \t]+(.+)$/gm; + +function parseMarkdownTasks(content) { + const tasks = []; + const src = String(content || ''); + + const tryRegex = (re, status) => { + re.lastIndex = 0; + let m; + while ((m = re.exec(src)) !== null) { + const line = src.slice(0, m.index).split(/\r?\n/).length; + const lineText = m[0].trim(); + tasks.push({ title: (m[1] || '').trim(), status, line, lineText }); + } + }; + + tryRegex(OPEN_REGEX, 'open'); + tryRegex(DONE_REGEX, 'done'); + return tasks.sort((a, b) => a.line - b.line); +} + +/** + * Recursively walk workspace root and sync all markdown tasks into TaskDatabase. + */ +function syncAllWorkspaceNotes(notesRoot, db) { + if (!notesRoot || !fs.existsSync(notesRoot) || !db) return; + try { + const walk = (dir) => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + const parsed = parseMarkdownTasks(content); + db.syncFromNote(fullPath, parsed); + } catch { /* ignore single file error */ } + } + } + }; + walk(notesRoot); + } catch (err) { + console.error('[taskIpc] syncAllWorkspaceNotes error:', err.message); + } +} + +/** + * Append a task line to a note file if not already present. + */ +function appendTaskToNote(filePath, title) { + if (!filePath || !title) return null; + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + let content = ''; + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, 'utf8'); + } + const lines = content ? content.split(/\r?\n/) : []; + const taskLineText = `- [ ] ${title.trim()}`; + + // Append newline if content doesn't end with one + if (lines.length > 0 && lines[lines.length - 1] !== '') { + lines.push(''); + } + lines.push(taskLineText); + fs.writeFileSync(filePath, lines.join('\n'), 'utf8'); + return { line: lines.length, lineText: taskLineText }; + } catch (err) { + console.error('[taskIpc] appendTaskToNote failed:', err.message); + return null; + } +} + +/** + * Write [x] or [ ] back to a note file when a task is completed via the UI. + */ +function writeTaskStatusToNote(filePath, sourceLine, newStatus) { + if (!filePath || !fs.existsSync(filePath)) return false; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const lineIdx = (sourceLine ?? 1) - 1; + + let updatedLine = null; + let updatedIdx = lineIdx; + + if (lineIdx >= 0 && lineIdx < lines.length) { + const line = lines[lineIdx]; + if (newStatus === 'done' && /\[[ ]\]/.test(line)) { + updatedLine = line.replace(/\[[ ]\]/, '[x]'); + } else if (newStatus === 'open' && /\[[xX]\]/.test(line)) { + updatedLine = line.replace(/\[[xX]\]/, '[ ]'); + } + } + + // Fallback: search lines if exact line number didn't match + if (!updatedLine) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (newStatus === 'done' && /\[[ ]\]/.test(line)) { + updatedLine = line.replace(/\[[ ]\]/, '[x]'); + updatedIdx = i; + break; + } else if (newStatus === 'open' && /\[[xX]\]/.test(line)) { + updatedLine = line.replace(/\[[xX]\]/, '[ ]'); + updatedIdx = i; + break; + } + } + } + + if (!updatedLine || updatedIdx < 0) return false; + + lines[updatedIdx] = updatedLine; + fs.writeFileSync(filePath, lines.join('\n'), 'utf8'); + return true; + } catch (err) { + console.error('[taskIpc] writeTaskStatusToNote failed:', err.message); + return false; + } +} + +/** + * Cross-workspace persons cache stored in appData/persons.json for auto-tagging suggestions. + */ +function loadAppDataPersons(appDataDir) { + const p = path.join(appDataDir, 'persons.json'); + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return []; } +} + +function saveAppDataPersons(appDataDir, persons) { + try { + const p = path.join(appDataDir, 'persons.json'); + fs.writeFileSync(p, JSON.stringify(persons, null, 2), 'utf8'); + } catch { /* ignore */ } +} + +function registerTaskIpc(ipcMain, deps) { + const { BrowserWindow, getNotesRoot, getActiveProject, getMetadataStore, getAppDataDir } = deps; + + function trusted(channel, handler) { + ipcMain.handle(channel, (event, payload) => { + assertTrustedIpcSender(BrowserWindow, event, channel); + return handler(event, payload); + }); + } + + function getRoot() { + const project = getActiveProject?.(); + return project?.rootPath || getNotesRoot?.() || ''; + } + + function getDb() { + const root = getRoot(); + return getTaskDatabase(root); + } + + // ── Sync ────────────────────────────────────────────────────────────────── + + trusted('tasks:sync-from-note', (_event, { filePath, content }) => { + const db = getDb(); + if (!db) return { inserted: 0, updated: 0 }; + const parsed = parseMarkdownTasks(content); + return db.syncFromNote(filePath, parsed); + }); + + trusted('tasks:sync-all', (_event) => { + const db = getDb(); + const root = getRoot(); + if (!db || !root) return false; + syncAllWorkspaceNotes(root, db); + return true; + }); + + // ── CRUD ────────────────────────────────────────────────────────────────── + + trusted('tasks:list', (_event, filters = {}) => { + const db = getDb(); + const root = getRoot(); + if (db && root) { + // Auto-scan workspace markdown notes on list query so all notes' tasks are up-to-date + syncAllWorkspaceNotes(root, db); + } + return db ? db.listTasks(filters) : []; + }); + + trusted('tasks:get', (_event, { id }) => { + const db = getDb(); + return db ? db.getTask(id) : null; + }); + + trusted('tasks:create', (_event, payload) => { + const db = getDb(); + const root = getRoot(); + if (!db) return null; + + let targetFile = payload.sourcePath; + if (!targetFile && root) { + targetFile = path.join(root, 'Tasks.md'); + } + + let appendedLine = null; + if (targetFile && payload.title) { + appendedLine = appendTaskToNote(targetFile, payload.title); + } + + const task = db.createTask({ + ...payload, + sourcePath: targetFile || null, + sourceLine: appendedLine?.line || null, + }); + + return task; + }); + + trusted('tasks:update', (_event, { id, ...fields }) => { + const db = getDb(); + return db ? db.updateTask(id, fields) : null; + }); + + trusted('tasks:complete', (_event, { id, status = 'done' }) => { + const db = getDb(); + if (!db) return null; + + const task = db.getTask(id); + if (!task) return null; + + const updated = db.updateTask(id, { status }); + + // Two-way sync: write back to source note + if (task.source_path) { + writeTaskStatusToNote(task.source_path, task.source_line, status); + } + + return updated; + }); + + trusted('tasks:delete', (_event, { id }) => { + const db = getDb(); + return db ? db.deleteTask(id) : false; + }); + + trusted('tasks:get-overdue', () => { + const db = getDb(); + const root = getRoot(); + if (db && root) { + syncAllWorkspaceNotes(root, db); + } + return db ? db.getOverdueTasks() : []; + }); + + // ── Comments ────────────────────────────────────────────────────────────── + + const handleAddComment = (_event, { taskId, body, author }) => { + const db = getDb(); + return db ? db.addComment(taskId, { body, author }) : null; + }; + + const handleGetComments = (_event, { taskId }) => { + const db = getDb(); + return db ? db.getComments(taskId) : []; + }; + + trusted('tasks:add-comment', handleAddComment); + trusted('tasks:get-comments', handleGetComments); + trusted('tasks:comments:add', handleAddComment); + trusted('tasks:comments:list', handleGetComments); + + // ── Persons ─────────────────────────────────────────────────────────────── + + trusted('persons:list', (_event) => { + const db = getDb(); + const workspacePersons = db ? db.listPersons() : []; + const appDataPersons = loadAppDataPersons(getAppDataDir?.() || ''); + + // Merge by id/name + const map = new Map(); + for (const p of appDataPersons) map.set(p.name.toLowerCase(), p); + for (const p of workspacePersons) map.set(p.name.toLowerCase(), p); + return Array.from(map.values()); + }); + + trusted('persons:upsert', (_event, payload) => { + const db = getDb(); + const person = db ? db.upsertPerson(payload) : null; + + // Cache in appData + if (person && getAppDataDir?.()) { + const all = loadAppDataPersons(getAppDataDir()); + const idx = all.findIndex(p => p.id === person.id || p.name.toLowerCase() === person.name.toLowerCase()); + if (idx !== -1) all[idx] = person; + else all.push(person); + saveAppDataPersons(getAppDataDir(), all); + } + + return person; + }); + + trusted('persons:delete', (_event, { id }) => { + const db = getDb(); + return db ? db.deletePerson(id) : false; + }); + + // ── Calendar ────────────────────────────────────────────────────────────── + + trusted('calendar:get-events', (_event, { startDate, endDate, types = [] } = {}) => { + const db = getDb(); + const root = getRoot(); + const events = []; + + if (db && root) { + // Sync notes first so calendar events match all workspace markdown notes + syncAllWorkspaceNotes(root, db); + + // Task events + const taskEvents = db.getCalendarTaskEvents(startDate, endDate); + for (const t of taskEvents) { + if (!types.length || types.includes(t._eventType)) { + events.push({ + id: `task-${t.id}`, + title: t.title, + start: t.scheduled_start || t.due_date || t.completed_at, + end: t.scheduled_end || t.due_date || t.completed_at, + allDay: Boolean(t.isAllDay), + type: t._eventType, + taskId: t.id, + sourcePath: t.source_path, + priority: t.priority, + status: t.status, + }); + } + } + } + + // Note events from metadataStore + try { + const store = getMetadataStore?.(); + if (store) { + const notes = store.listNotes?.() || []; + for (const n of notes) { + const createdStr = n.created_at || n.createdAt; + const updatedStr = n.updated_at || n.updatedAt; + + if (createdStr && createdStr.slice(0, 10) >= startDate && createdStr.slice(0, 10) <= endDate) { + if (!types.length || types.includes('note-created')) { + events.push({ + id: `note-created-${n.path}`, + title: `Created: ${n.title || path.basename(n.path, '.md')}`, + start: createdStr, + end: createdStr, + allDay: true, + type: 'note-created', + sourcePath: n.path, + }); + } + } + + if (updatedStr && updatedStr.slice(0, 10) >= startDate && updatedStr.slice(0, 10) <= endDate) { + if (!types.length || types.includes('note-updated')) { + events.push({ + id: `note-updated-${n.path}`, + title: `Updated: ${n.title || path.basename(n.path, '.md')}`, + start: updatedStr, + end: updatedStr, + allDay: true, + type: 'note-updated', + sourcePath: n.path, + }); + } + } + } + } + } catch (err) { + console.error('[taskIpc] calendar note events failed:', err.message); + } + + return events; + }); +} + +module.exports = { registerTaskIpc, parseMarkdownTasks, writeTaskStatusToNote }; diff --git a/electron/lib/web/websiteRenderer.cjs b/electron/lib/web/websiteRenderer.cjs index 72c0c526..f15ffc1c 100644 --- a/electron/lib/web/websiteRenderer.cjs +++ b/electron/lib/web/websiteRenderer.cjs @@ -23,6 +23,25 @@ function createWebsiteRenderer(deps) { getNotesRoot } = deps; +function processCallouts(html) { + if (!html) return ""; + return html.replace( + /
\s*

\s*\[!(NOTE|WARNING|TIP|TODO|IMPORTANT|CAUTION)\][ \t]*(.*?)(?:|\n)?([\s\S]*?)<\/p>\s*<\/blockquote>/gi, + (_match, type, title, rest) => { + const calloutType = type.toLowerCase(); + const displayTitle = title.trim() || type; + const icons = { note: "📝", warning: "⚠️", tip: "💡", todo: "☑️", important: "ℹ️", caution: "🚨" }; + const icon = icons[calloutType] || "📌"; + const bodyHtml = rest.trim() ? `

${rest.trim()}

` : ""; + return `
+
${icon} ${escapeCodeHtml(displayTitle)}
+
${bodyHtml}
+
`; + } + ); +} + + function escapeCodeHtml(value) { return String(value || "") .replace(/&/g, "&") diff --git a/electron/main.cjs b/electron/main.cjs index bddc02ce..9dee0996 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -45,7 +45,7 @@ const { initializeAIHandlers } = require("./ai/aiHandlers.cjs"); const { registerGitIpcHandlers } = require("./lib/git/gitIpc.cjs"); const gitService = require("./lib/git/gitService.cjs"); const { registerNotePackageIpc } = require("./lib/export/notePackageIpc.cjs"); - +const { registerTaskIpc } = require("./lib/tasks/taskIpc.cjs"); const rendererUrl = process.env.ELECTRON_RENDERER_URL; const projectRoot = app.getAppPath(); @@ -1271,3 +1271,11 @@ registerGitIpcHandlers(ipcMain, { BrowserWindow, getNotesRoot: () => notesRoot, }); + +registerTaskIpc(ipcMain, { + BrowserWindow, + getNotesRoot: () => notesRoot, + getActiveProject, + getMetadataStore: () => metadataStore, + getAppDataDir: () => appDataDir, +}); diff --git a/electron/preload.cjs b/electron/preload.cjs index b0e8fecc..04a01689 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -358,5 +358,20 @@ contextBridge.exposeInMainWorld("notesApi", { openExternal: (url) => ipcRenderer.invoke("shell:open-external", { url }), executeTool: (payload) => ipcRenderer.invoke("tool:execute", payload), listTools: () => ipcRenderer.invoke("tool:list"), + + // ── Tasks & Calendar ───────────────────────────────────────────────────── + syncTasksFromNote: (p) => ipcRenderer.invoke("tasks:sync-from-note", p), + listTasks: (p) => ipcRenderer.invoke("tasks:list", p), + getTask: (p) => ipcRenderer.invoke("tasks:get", p), + createTask: (p) => ipcRenderer.invoke("tasks:create", p), + updateTask: (p) => ipcRenderer.invoke("tasks:update", p), + completeTask: (p) => ipcRenderer.invoke("tasks:complete", p), + deleteTask: (p) => ipcRenderer.invoke("tasks:delete", p), + addTaskComment: (p) => ipcRenderer.invoke("tasks:add-comment", p), + getTaskComments: (p) => ipcRenderer.invoke("tasks:get-comments", p), + getCalendarEvents: (p) => ipcRenderer.invoke("calendar:get-events", p), + listPersons: () => ipcRenderer.invoke("persons:list"), + upsertPerson: (p) => ipcRenderer.invoke("persons:upsert", p), + deletePerson: (p) => ipcRenderer.invoke("persons:delete", p), }); diff --git a/package-lock.json b/package-lock.json index 95c2b208..c7714ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notely", - "version": "0.1.32", + "version": "0.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notely", - "version": "0.1.32", + "version": "0.1.37", "hasInstallScript": true, "license": "CC-BY-NC-4.0", "dependencies": { @@ -41,6 +41,7 @@ "@xyflow/react": "^12.11.1", "concurrently": "^9.1.2", "dagre": "^0.8.5", + "date-fns": "^4.4.0", "dictionary-en-us": "^2.2.1", "electron": "^37.2.0", "electron-builder": "^24.13.3", @@ -60,6 +61,7 @@ "prettier": "^3.4.0", "rcedit": "^5.0.2", "react": "^19.1.0", + "react-big-calendar": "^1.20.0", "react-dom": "^19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", @@ -4422,6 +4424,17 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4959,6 +4972,19 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -5963,6 +5989,16 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -5996,6 +6032,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/warning": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", + "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -7803,6 +7846,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cldrjs": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz", + "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==", + "dev": true + }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -8812,6 +8861,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-arithmetic": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", + "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -9155,6 +9222,17 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz", + "integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "csstype": "^3.1.3" + } + }, "node_modules/dompurify": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", @@ -10634,6 +10712,16 @@ "node": ">=10" } }, + "node_modules/globalize": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz", + "integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cldrjs": "^0.5.4" + } + }, "node_modules/globals": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", @@ -11184,6 +11272,16 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -12274,6 +12372,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -12747,6 +12855,13 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13585,6 +13700,29 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14695,6 +14833,45 @@ "node": ">=0.10.0" } }, + "node_modules/react-big-calendar": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz", + "integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "clsx": "^2.1.1", + "date-arithmetic": "^4.1.0", + "dayjs": "^1.11.21", + "dom-helpers": "^6.0.1", + "globalize": "^1.7.1", + "invariant": "^2.2.4", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "luxon": "^3.7.2", + "memoize-one": "^6.0.0", + "moment": "^2.29.4", + "moment-timezone": "^0.5.48", + "prop-types": "^15.8.1", + "react-overlays": "^5.2.1", + "uncontrollable": "^7.2.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17 || ^18 || ^19", + "react-dom": "^16.14.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-big-calendar/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-dom": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", @@ -14715,6 +14892,45 @@ "dev": true, "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-overlays": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", + "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.8", + "@popperjs/core": "^2.11.6", + "@restart/hooks": "^0.4.7", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-overlays/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -16810,6 +17026,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -18998,6 +19230,16 @@ "node": ">=12.0.0" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index c32f4d6f..0bbc8919 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@xyflow/react": "^12.11.1", "concurrently": "^9.1.2", "dagre": "^0.8.5", + "date-fns": "^4.4.0", "dictionary-en-us": "^2.2.1", "electron": "^37.2.0", "electron-builder": "^24.13.3", @@ -87,6 +88,7 @@ "prettier": "^3.4.0", "rcedit": "^5.0.2", "react": "^19.1.0", + "react-big-calendar": "^1.20.0", "react-dom": "^19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", diff --git a/src/App.jsx b/src/App.jsx index dc6f7142..8e7e4b11 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -24,6 +24,9 @@ const EmbeddingsPage = lazy(() => import("./components/EmbeddingsPage")); const AIPersonasManager = lazy(() => import("./components/AIPersonasManager")); const AIHealthPage = lazy(() => import("./components/AIHealthPage")); const AppLogsPage = lazy(() => import("./components/AppLogsPage")); +const TaskWorkspacePage = lazy(() => import("./components/TaskWorkspacePage").then((m) => ({ default: m.TaskWorkspacePage }))); +const CalendarPage = lazy(() => import("./components/CalendarPage").then((m) => ({ default: m.CalendarPage }))); + import { SettingsModal } from "./components/SettingsModal"; import { WorkspaceModal } from "./components/WorkspaceModal"; @@ -366,6 +369,9 @@ export default function App() { recentNotesPanelOpen, setRecentNotesPanelOpen, favoritesPanelOpen, setFavoritesPanelOpen, trashDialogOpen, setTrashDialogOpen, + taskWorkspaceOpen, setTaskWorkspaceOpen, + taskWorkspaceContext, setTaskWorkspaceContext, + calendarPageOpen, setCalendarPageOpen, onboardingComplete, setOnboardingCompleteState, defaultNotesPath, setDefaultNotesPath, themePreference, setThemePreferenceState, @@ -1604,8 +1610,23 @@ export default function App() { setHealthPageOpen(false); setAppLogsOpen(false); setGitVCOpen(false); + setTaskWorkspaceOpen(false); + setCalendarPageOpen(false); }; + if (action === "open-tasks-workspace") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "open-calendar") { + closeAllFullscreenViews(); + setCalendarPageOpen(true); + return; + } + if (action === "open-workspace-graph") { closeAllFullscreenViews(); setGraphPanelOpen(true); @@ -1704,7 +1725,28 @@ export default function App() { + if (action === "tasks-workspace") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "tasks-overdue") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "calendar") { + closeAllFullscreenViews(); + setCalendarPageOpen(true); + return; + } + if (action === "open-p2p-sync-help") { + setP2PSyncHelpOpen(true); return; } @@ -2726,6 +2768,17 @@ export default function App() { } function handleDashboardAction(action) { + if (action === "tasks-workspace" || action === "tasks-overdue") { + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "calendar") { + setCalendarPageOpen(true); + return; + } + if (action === "new-note") { setNoteDialogOpen(true); return; @@ -3953,6 +4006,41 @@ export default function App() { )} + {taskWorkspaceOpen && ( +
+ Loading Task Workspace…
}> + setTaskWorkspaceOpen(false)} + onOpenNote={(filePath) => { + setTaskWorkspaceOpen(false); + void handleOpenReferencedDocument(filePath); + }} + noteFilter={taskWorkspaceContext?.noteFilter ?? null} + /> + + + )} + + {calendarPageOpen && ( +
+ Loading Calendar…
}> + setCalendarPageOpen(false)} + onOpenNote={(filePath) => { + setCalendarPageOpen(false); + void handleOpenReferencedDocument(filePath); + }} + onOpenTask={(task) => { + setCalendarPageOpen(false); + setTaskWorkspaceContext(task?.source_path ? { noteFilter: task.source_path } : null); + setTaskWorkspaceOpen(true); + }} + /> + + + )} + + onToggle(type)} + title={meta.label} + > + + {meta.label} + + ); +} + +function EventCard({ event, onOpenNote, onOpenTask, onClose }) { + const type = event.resource?._eventType ?? ""; + const meta = EVENT_TYPE_META[type] ?? {}; + const Icon = meta.Icon ?? CalendarIcon; + const task = type?.startsWith("task") ? event.resource : null; + const note = type?.startsWith("note") ? event.resource : null; + + return ( +
+
+ {meta.label} + +
+
{event.title}
+ {event.start && ( +
+ {event.allDay ? format(event.start, "MMM d, yyyy") : format(event.start, "MMM d, yyyy 'at' h:mm a")} + {event.end && !event.allDay && ` – ${format(event.end, "h:mm a")}`} +
+ )} +
+ {task && ( + + )} + {note && ( + + )} + {task?.source_path && ( + + )} +
+
+ ); +} + +export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { + const [date, setDate] = useState(new Date()); + const [view, setView] = useState("month"); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [popupPos, setPopupPos] = useState({ top: 0, left: 0 }); + const [activeFilters, setActiveFilters] = useState(new Set(Object.keys(EVENT_TYPE_META))); + const [error, setError] = useState(null); + + const loadEvents = useCallback(async (currentDate) => { + setLoading(true); + setError(null); + try { + const start = startOfMonth(currentDate); + const end = endOfMonth(currentDate); + // Pad a week on each side so the calendar grid edges are covered + const padStart = new Date(start.getTime() - 7 * 24 * 60 * 60000); + const padEnd = new Date(end.getTime() + 7 * 24 * 60 * 60000); + const result = await getCalendarEvents( + format(padStart, "yyyy-MM-dd"), + format(padEnd, "yyyy-MM-dd") + ); + const built = buildRbcEvents(result.taskEvents ?? [], result.noteEvents ?? []); + setEvents(built); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void loadEvents(date); }, [date, loadEvents]); + + const toggleFilter = useCallback(type => { + setActiveFilters(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); else next.add(type); + return next; + }); + }, []); + + const visibleEvents = events.filter(e => activeFilters.has(e.resource?._eventType)); + + const handleSelectEvent = useCallback((event, syntheticEvent) => { + const rect = syntheticEvent?.target?.getBoundingClientRect?.(); + if (rect) { + setPopupPos({ top: rect.bottom + 8, left: Math.min(rect.left, window.innerWidth - 320) }); + } else { + setPopupPos({ top: 120, left: 80 }); + } + setSelectedEvent(event); + }, []); + + const eventPropGetter = useCallback(event => ({ + className: `cal-rbc-event ${event.className ?? ""}`, + }), []); + + return ( +
+ {/* Header */} +
+ +
+ +
+ Calendar + + {format(date, "MMMM yyyy")} + +
+
+
+ + + +
+
+ + {visibleEvents.length} event{visibleEvents.length !== 1 ? "s" : ""} + +
+ {["month", "week", "day"].map(v => ( + + ))} +
+
+
+ + {/* Filter bar */} +
+ {Object.keys(EVENT_TYPE_META).map(type => ( + + ))} + {loading && Loading…} +
+ + {/* Calendar */} +
+ {error &&
{error}
} + +
+ + {/* Event popup */} + {selectedEvent && ( + <> +
setSelectedEvent(null)} /> +
+ setSelectedEvent(null)} + /> +
+ + )} +
+ ); +} diff --git a/src/components/DashboardPanels.jsx b/src/components/DashboardPanels.jsx index 2681285e..af369609 100644 --- a/src/components/DashboardPanels.jsx +++ b/src/components/DashboardPanels.jsx @@ -1,7 +1,8 @@ -import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles } from "lucide-react"; -import { useMemo } from "react"; +import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles, Calendar, AlertTriangle } from "lucide-react"; +import { useMemo, useEffect, useState } from "react"; import { formatDate } from "../utils/dateUtils"; import { extractOpenTasksFromDocuments, getTaskCountsFromDocuments } from "../utils/taskUtils"; +import { listTasks } from "../services/electronService"; const DASHBOARD_SECTION_LIMIT = 3; @@ -82,6 +83,14 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, ); } + const [overdueCount, setOverdueCount] = useState(0); + + useEffect(() => { + listTasks({ status: "overdue" }) + .then(tasks => setOverdueCount(Array.isArray(tasks) ? tasks.length : 0)) + .catch(() => setOverdueCount(0)); + }, []); + if (loading) return null; if (layout === "rail") { @@ -101,6 +110,12 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, + + @@ -190,6 +205,18 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading,

Open Tasks

+ {overdueCount > 0 && ( + + )} {taskCounts.total > 0 && ( + + +
+ {task.title || "Untitled"} +
+ {task.due_date && ( + + {task.due_date} + + )} + + {task.source_path && ( + + )} +
+
+ +
+ ); +} + +function TaskDetail({ task, comments, commentsLoading, persons, onUpdate, onComplete, onDelete, onAddComment, onOpenNote, onClose }) { + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(task.title); + const [descDraft, setDescDraft] = useState(task.description || ""); + const [commentDraft, setCommentDraft] = useState(""); + const titleRef = useRef(null); + + const saveTitle = () => { + if (titleDraft.trim() && titleDraft !== task.title) { + onUpdate(task.id, { title: titleDraft.trim() }); + } + setEditingTitle(false); + }; + + const saveDesc = () => { + if (descDraft !== (task.description || "")) { + onUpdate(task.id, { description: descDraft }); + } + }; + + const submitComment = (e) => { + e.preventDefault(); + if (!commentDraft.trim()) return; + onAddComment(task.id, commentDraft.trim()); + setCommentDraft(""); + }; + + const isOverdue = task.due_date && task.status === "open" && task.due_date < new Date().toISOString().slice(0, 10); + + return ( +
+
+
+ + {editingTitle ? ( + setTitleDraft(e.target.value)} + onBlur={saveTitle} + onKeyDown={e => { if (e.key === "Enter") saveTitle(); if (e.key === "Escape") setEditingTitle(false); }} + autoFocus + /> + ) : ( +

{ setEditingTitle(true); setTitleDraft(task.title); }}> + {task.title || "Untitled"} + +

+ )} +
+ +
+ + {/* Meta fields */} +
+ {/* Due date */} +
+ + onUpdate(task.id, { dueDate: e.target.value || null })} + /> +
+ {/* Scheduled */} +
+ + onUpdate(task.id, { scheduledStart: e.target.value || null, isAllDay: 0 })} + /> +
+
+ + onUpdate(task.id, { scheduledEnd: e.target.value || null })} + /> +
+ {/* Priority */} +
+ + +
+ {/* Person tags */} +
+ +
+ {(task.personTags || []).map(pid => { + const person = (persons.persons || []).find(p => p.id === pid); + return person ? ( + + {person.name} + + + ) : null; + })} + +
+
+ {/* Source note */} + {task.source_path && ( +
+ + +
+ )} +
+ + {/* Description */} +
+

Description

+