900 lines
27 KiB
JavaScript
900 lines
27 KiB
JavaScript
const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require("electron");
|
|
const crypto = require("crypto");
|
|
const fs = require("fs/promises");
|
|
const fsSync = require("fs");
|
|
const path = require("path");
|
|
const ICAL = require("ical.js");
|
|
|
|
const TASKS_FILE = "Tasks.md";
|
|
const NOTES_FOLDER = "Notes";
|
|
const APP_FOLDER = ".all-in-one";
|
|
const SETTINGS_FILE = "settings.json";
|
|
const CACHE_FILE = "calendar-cache.json";
|
|
const CALENDAR_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
const DEFAULT_DAILY_TEMPLATE = "# {{dateLong}}\n\n## Notes\n\n";
|
|
const DEFAULT_THEME_COLORS = {
|
|
light: {
|
|
background: "#f6f7fb",
|
|
surface: "#ffffff",
|
|
text: "#20232a",
|
|
mutedText: "#647084",
|
|
border: "#d9dee8",
|
|
sidebar: "#24262d",
|
|
accent: "#0f766e"
|
|
},
|
|
dark: {
|
|
background: "#0f172a",
|
|
surface: "#111827",
|
|
text: "#e5edf5",
|
|
mutedText: "#94a3b8",
|
|
border: "#273449",
|
|
sidebar: "#111827",
|
|
accent: "#0f766e"
|
|
}
|
|
};
|
|
const DEFAULT_SETTINGS = {
|
|
calendars: [],
|
|
dailyNoteTemplate: DEFAULT_DAILY_TEMPLATE,
|
|
theme: "light",
|
|
themeColors: DEFAULT_THEME_COLORS
|
|
};
|
|
|
|
let mainWindow;
|
|
let state = {};
|
|
let vaultPath = "";
|
|
|
|
function userStatePath() {
|
|
return path.join(app.getPath("userData"), "state.json");
|
|
}
|
|
|
|
function defaultVaultPath() {
|
|
return path.join(app.getPath("documents"), "All-in-One Vault");
|
|
}
|
|
|
|
function appVaultPath(...segments) {
|
|
return path.join(vaultPath, APP_FOLDER, ...segments);
|
|
}
|
|
|
|
async function readJson(filePath, fallback) {
|
|
try {
|
|
const raw = await fs.readFile(filePath, "utf8");
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function writeJson(filePath, data) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function loadState() {
|
|
state = await readJson(userStatePath(), {});
|
|
vaultPath = state.vaultPath || defaultVaultPath();
|
|
state.vaultPath = vaultPath;
|
|
await writeJson(userStatePath(), state);
|
|
}
|
|
|
|
async function saveState(patch) {
|
|
state = { ...state, ...patch };
|
|
vaultPath = state.vaultPath;
|
|
await writeJson(userStatePath(), state);
|
|
}
|
|
|
|
async function ensureFile(filePath, content) {
|
|
if (!fsSync.existsSync(filePath)) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, content, "utf8");
|
|
}
|
|
}
|
|
|
|
async function ensureVault() {
|
|
await fs.mkdir(vaultPath, { recursive: true });
|
|
await fs.mkdir(path.join(vaultPath, "Daily"), { recursive: true });
|
|
await fs.mkdir(path.join(vaultPath, NOTES_FOLDER), { recursive: true });
|
|
await fs.mkdir(appVaultPath(), { recursive: true });
|
|
await ensureFile(path.join(vaultPath, TASKS_FILE), "# Tasks\n\n");
|
|
await ensureFile(appVaultPath(SETTINGS_FILE), `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
|
|
}
|
|
|
|
function dateFromISO(dateISO) {
|
|
const [year, month, day] = dateISO.split("-").map(Number);
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
|
|
function toISODate(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function addDays(date, days) {
|
|
const next = new Date(date);
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
function dayBounds(dateISO) {
|
|
const start = dateFromISO(dateISO);
|
|
const end = addDays(start, 1);
|
|
return { start, end };
|
|
}
|
|
|
|
function rangeBounds(startISO, endISO) {
|
|
const start = dateFromISO(startISO);
|
|
const end = addDays(dateFromISO(endISO), 1);
|
|
return { start, end };
|
|
}
|
|
|
|
function dateLabel(dateISO) {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric"
|
|
}).format(dateFromISO(dateISO));
|
|
}
|
|
|
|
function weekdayLabel(dateISO) {
|
|
return new Intl.DateTimeFormat(undefined, { weekday: "long" }).format(dateFromISO(dateISO));
|
|
}
|
|
|
|
function dailyNotePath(dateISO) {
|
|
return path.join(vaultPath, "Daily", `${dateISO}.md`);
|
|
}
|
|
|
|
function normalizeTags(input) {
|
|
const raw = Array.isArray(input) ? input : String(input || "").split(",");
|
|
const seen = new Set();
|
|
|
|
return raw
|
|
.map((tag) =>
|
|
String(tag || "")
|
|
.trim()
|
|
.replace(/^#+/, "")
|
|
.replace(/\s+/g, "-")
|
|
.slice(0, 48)
|
|
)
|
|
.filter(Boolean)
|
|
.filter((tag) => {
|
|
const key = tag.toLowerCase();
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function normalizeProject(value) {
|
|
return String(value || "").replace(/\s+/g, " ").trim().slice(0, 100);
|
|
}
|
|
|
|
function stripNoteFrontmatter(content) {
|
|
const match = String(content || "").match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
return match ? content.slice(match[0].length) : content;
|
|
}
|
|
|
|
function parseNoteFrontmatter(content) {
|
|
const text = String(content || "");
|
|
const metadata = { tags: [], project: "" };
|
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
|
|
if (!match) {
|
|
return { metadata, body: text };
|
|
}
|
|
|
|
const lines = match[1].split(/\r?\n/);
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
const tagsInline = line.match(/^tags:\s*\[(.*)]\s*$/);
|
|
const tagsValue = line.match(/^tags:\s*(.+)\s*$/);
|
|
const projectValue = line.match(/^project:\s*(.*)\s*$/);
|
|
|
|
if (tagsInline) {
|
|
metadata.tags = normalizeTags(tagsInline[1]);
|
|
continue;
|
|
}
|
|
|
|
if (tagsValue && tagsValue[1].trim()) {
|
|
metadata.tags = normalizeTags(tagsValue[1]);
|
|
continue;
|
|
}
|
|
|
|
if (line.match(/^tags:\s*$/)) {
|
|
const tags = [];
|
|
while (lines[index + 1]?.match(/^\s*-\s+/)) {
|
|
index += 1;
|
|
tags.push(lines[index].replace(/^\s*-\s+/, ""));
|
|
}
|
|
metadata.tags = normalizeTags(tags);
|
|
continue;
|
|
}
|
|
|
|
if (projectValue) {
|
|
metadata.project = normalizeProject(projectValue[1].replace(/^["']|["']$/g, ""));
|
|
}
|
|
}
|
|
|
|
return {
|
|
metadata,
|
|
body: text.slice(match[0].length)
|
|
};
|
|
}
|
|
|
|
function serializeNoteFrontmatter(metadata) {
|
|
const tags = normalizeTags(metadata.tags);
|
|
const project = normalizeProject(metadata.project);
|
|
const lines = ["---"];
|
|
|
|
if (tags.length) {
|
|
lines.push("tags:");
|
|
tags.forEach((tag) => lines.push(` - ${tag}`));
|
|
}
|
|
|
|
if (project) {
|
|
lines.push(`project: ${project}`);
|
|
}
|
|
|
|
lines.push("---");
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function applyNoteMetadata(content, metadata) {
|
|
const parsed = parseNoteFrontmatter(content);
|
|
const next = {
|
|
tags: Object.prototype.hasOwnProperty.call(metadata, "tags") ? normalizeTags(metadata.tags) : parsed.metadata.tags,
|
|
project: Object.prototype.hasOwnProperty.call(metadata, "project")
|
|
? normalizeProject(metadata.project)
|
|
: parsed.metadata.project
|
|
};
|
|
|
|
if (!next.tags.length && !next.project) {
|
|
return parsed.body;
|
|
}
|
|
|
|
return `${serializeNoteFrontmatter(next)}${parsed.body.replace(/^\s+/, "")}`;
|
|
}
|
|
|
|
function renderDailyTemplate(template, dateISO) {
|
|
return String(template || DEFAULT_DAILY_TEMPLATE)
|
|
.replaceAll("{{date}}", dateISO)
|
|
.replaceAll("{{dateLong}}", dateLabel(dateISO))
|
|
.replaceAll("{{weekday}}", weekdayLabel(dateISO));
|
|
}
|
|
|
|
async function readDailyNote(dateISO) {
|
|
const filePath = dailyNotePath(dateISO);
|
|
const settings = await getSettings();
|
|
await ensureFile(filePath, renderDailyTemplate(settings.dailyNoteTemplate, dateISO));
|
|
const content = await fs.readFile(filePath, "utf8");
|
|
const parsed = parseNoteFrontmatter(content);
|
|
|
|
return {
|
|
dateISO,
|
|
filePath,
|
|
content,
|
|
tags: parsed.metadata.tags,
|
|
project: parsed.metadata.project
|
|
};
|
|
}
|
|
|
|
async function saveDailyNote(dateISO, content) {
|
|
const filePath = dailyNotePath(dateISO);
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, content, "utf8");
|
|
return readDailyNote(dateISO);
|
|
}
|
|
|
|
async function updateDailyNoteMetadata(dateISO, metadata) {
|
|
const filePath = dailyNotePath(dateISO);
|
|
const current = (await readDailyNote(dateISO)).content;
|
|
await fs.writeFile(filePath, applyNoteMetadata(current, metadata), "utf8");
|
|
return readDailyNote(dateISO);
|
|
}
|
|
|
|
function generalNotesPath(...segments) {
|
|
return path.join(vaultPath, NOTES_FOLDER, ...segments);
|
|
}
|
|
|
|
function noteTitleFromContent(content, fallback) {
|
|
const heading = stripNoteFrontmatter(content)
|
|
.split(/\r?\n/)
|
|
.find((line) => line.trim().startsWith("# "));
|
|
return (heading ? heading.replace(/^#\s+/, "").trim() : "") || fallback || "Untitled note";
|
|
}
|
|
|
|
function slugifyTitle(title) {
|
|
const slug = String(title || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48);
|
|
|
|
return slug || "untitled-note";
|
|
}
|
|
|
|
function safeGeneralNoteId(id) {
|
|
const value = String(id || "");
|
|
const baseName = path.basename(value);
|
|
|
|
if (value !== baseName || !baseName.toLowerCase().endsWith(".md")) {
|
|
throw new Error("Invalid note id.");
|
|
}
|
|
|
|
return baseName;
|
|
}
|
|
|
|
function publicGeneralNote(id, filePath, content, stats) {
|
|
const fallbackTitle = path.basename(id, ".md").replace(/-[a-f0-9]{8}$/, "").replace(/-/g, " ");
|
|
const parsed = parseNoteFrontmatter(content);
|
|
|
|
return {
|
|
id,
|
|
title: noteTitleFromContent(content, fallbackTitle),
|
|
filePath,
|
|
updatedAt: stats.mtime.toISOString(),
|
|
tags: parsed.metadata.tags,
|
|
project: parsed.metadata.project
|
|
};
|
|
}
|
|
|
|
async function listGeneralNotes() {
|
|
await fs.mkdir(generalNotesPath(), { recursive: true });
|
|
const entries = await fs.readdir(generalNotesPath(), { withFileTypes: true });
|
|
const notes = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
|
|
|
|
const filePath = generalNotesPath(entry.name);
|
|
const [content, stats] = await Promise.all([fs.readFile(filePath, "utf8"), fs.stat(filePath)]);
|
|
notes.push(publicGeneralNote(entry.name, filePath, content, stats));
|
|
}
|
|
|
|
return notes.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
}
|
|
|
|
async function readGeneralNote(id) {
|
|
const noteId = safeGeneralNoteId(id);
|
|
const filePath = generalNotesPath(noteId);
|
|
const [content, stats] = await Promise.all([fs.readFile(filePath, "utf8"), fs.stat(filePath)]);
|
|
|
|
return {
|
|
...publicGeneralNote(noteId, filePath, content, stats),
|
|
content
|
|
};
|
|
}
|
|
|
|
async function createGeneralNote(title) {
|
|
const cleanTitle = String(title || "").trim().slice(0, 120) || "Untitled note";
|
|
const id = `${slugifyTitle(cleanTitle)}-${generateId().slice(0, 8)}.md`;
|
|
const filePath = generalNotesPath(id);
|
|
await fs.writeFile(filePath, `# ${cleanTitle}\n\n`, "utf8");
|
|
return readGeneralNote(id);
|
|
}
|
|
|
|
async function saveGeneralNote(id, content) {
|
|
const noteId = safeGeneralNoteId(id);
|
|
const filePath = generalNotesPath(noteId);
|
|
await fs.writeFile(filePath, content || "", "utf8");
|
|
return readGeneralNote(noteId);
|
|
}
|
|
|
|
async function updateGeneralNoteMetadata(id, metadata) {
|
|
const noteId = safeGeneralNoteId(id);
|
|
const filePath = generalNotesPath(noteId);
|
|
const current = await fs.readFile(filePath, "utf8");
|
|
await fs.writeFile(filePath, applyNoteMetadata(current, metadata), "utf8");
|
|
return readGeneralNote(noteId);
|
|
}
|
|
|
|
function tasksPath() {
|
|
return path.join(vaultPath, TASKS_FILE);
|
|
}
|
|
|
|
function generateId() {
|
|
return crypto.randomBytes(8).toString("hex");
|
|
}
|
|
|
|
function parseMeta(rawMeta = "") {
|
|
return rawMeta
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.reduce((meta, part) => {
|
|
const separator = part.indexOf("=");
|
|
if (separator > 0) {
|
|
meta[part.slice(0, separator)] = part.slice(separator + 1);
|
|
}
|
|
return meta;
|
|
}, {});
|
|
}
|
|
|
|
function encodeMetaValue(value) {
|
|
return encodeURIComponent(String(value || ""));
|
|
}
|
|
|
|
function decodeMetaValue(value) {
|
|
try {
|
|
return decodeURIComponent(String(value || ""));
|
|
} catch {
|
|
return String(value || "");
|
|
}
|
|
}
|
|
|
|
function cleanTaskTitle(title) {
|
|
return title
|
|
.replace(/<!--[\s\S]*?-->/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 300);
|
|
}
|
|
|
|
function serializeTask(task) {
|
|
const checked = task.completed ? "x" : " ";
|
|
const meta = [`id=${task.id}`, `created=${task.createdAt || new Date().toISOString()}`];
|
|
|
|
if (task.dueDate) meta.push(`due=${task.dueDate}`);
|
|
if (task.completedAt) meta.push(`completed=${task.completedAt}`);
|
|
if (task.tags?.length) meta.push(`tags=${encodeMetaValue(task.tags.join(","))}`);
|
|
if (task.project) meta.push(`project=${encodeMetaValue(task.project)}`);
|
|
|
|
return `- [${checked}] ${cleanTaskTitle(task.title) || "Untitled task"} <!-- aio:${meta.join(" ")} -->`;
|
|
}
|
|
|
|
function parseTaskLine(line, lineIndex) {
|
|
const match = line.match(/^(\s*)-\s+\[([ xX])\]\s+(.*?)(?:\s*<!--\s*aio:([^>]*)-->)?\s*$/);
|
|
if (!match) return null;
|
|
|
|
const meta = parseMeta(match[4]);
|
|
const hasId = Boolean(meta.id);
|
|
|
|
return {
|
|
id: meta.id || generateId(),
|
|
title: cleanTaskTitle(match[3]) || "Untitled task",
|
|
completed: match[2].toLowerCase() === "x",
|
|
dueDate: /^\d{4}-\d{2}-\d{2}$/.test(meta.due || "") ? meta.due : "",
|
|
createdAt: meta.created || new Date().toISOString(),
|
|
completedAt: meta.completed || "",
|
|
tags: normalizeTags(decodeMetaValue(meta.tags || "")),
|
|
project: normalizeProject(decodeMetaValue(meta.project || "")),
|
|
lineIndex,
|
|
needsRewrite: !hasId
|
|
};
|
|
}
|
|
|
|
async function readTaskDocument() {
|
|
await ensureFile(tasksPath(), "# Tasks\n\n");
|
|
const markdown = await fs.readFile(tasksPath(), "utf8");
|
|
const lines = markdown.split(/\r?\n/);
|
|
const tasks = [];
|
|
let needsRewrite = false;
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
const task = parseTaskLine(line, lineIndex);
|
|
if (task) {
|
|
tasks.push(task);
|
|
needsRewrite = needsRewrite || task.needsRewrite;
|
|
}
|
|
});
|
|
|
|
if (needsRewrite) {
|
|
tasks.forEach((task) => {
|
|
lines[task.lineIndex] = serializeTask(task);
|
|
});
|
|
await fs.writeFile(tasksPath(), `${lines.join("\n").replace(/\s*$/, "")}\n`, "utf8");
|
|
}
|
|
|
|
return { lines, tasks };
|
|
}
|
|
|
|
function publicTask(task) {
|
|
return {
|
|
id: task.id,
|
|
title: task.title,
|
|
completed: task.completed,
|
|
dueDate: task.dueDate,
|
|
createdAt: task.createdAt,
|
|
completedAt: task.completedAt,
|
|
tags: task.tags || [],
|
|
project: task.project || ""
|
|
};
|
|
}
|
|
|
|
function sortTasks(tasks) {
|
|
return [...tasks].sort((a, b) => {
|
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
|
if (a.dueDate && b.dueDate && a.dueDate !== b.dueDate) return a.dueDate.localeCompare(b.dueDate);
|
|
if (a.dueDate && !b.dueDate) return -1;
|
|
if (!a.dueDate && b.dueDate) return 1;
|
|
return a.createdAt.localeCompare(b.createdAt);
|
|
});
|
|
}
|
|
|
|
async function listTasks() {
|
|
const { tasks } = await readTaskDocument();
|
|
return sortTasks(tasks.map(publicTask));
|
|
}
|
|
|
|
async function addTask(title, dueDate, tags = [], project = "") {
|
|
const cleanTitle = cleanTaskTitle(title);
|
|
if (!cleanTitle) throw new Error("Task title is required.");
|
|
|
|
const task = {
|
|
id: generateId(),
|
|
title: cleanTitle,
|
|
completed: false,
|
|
dueDate: /^\d{4}-\d{2}-\d{2}$/.test(dueDate || "") ? dueDate : "",
|
|
createdAt: new Date().toISOString(),
|
|
completedAt: "",
|
|
tags: normalizeTags(tags),
|
|
project: normalizeProject(project)
|
|
};
|
|
|
|
const markdown = await fs.readFile(tasksPath(), "utf8");
|
|
const nextMarkdown = `${markdown.replace(/\s*$/, "")}\n${serializeTask(task)}\n`;
|
|
await fs.writeFile(tasksPath(), nextMarkdown, "utf8");
|
|
return listTasks();
|
|
}
|
|
|
|
async function updateTask(id, patch) {
|
|
const { lines, tasks } = await readTaskDocument();
|
|
const task = tasks.find((item) => item.id === id);
|
|
if (!task) return listTasks();
|
|
const hasCompletedPatch = Object.prototype.hasOwnProperty.call(patch, "completed");
|
|
const completed = hasCompletedPatch ? Boolean(patch.completed) : task.completed;
|
|
|
|
const nextTask = {
|
|
...task,
|
|
...patch,
|
|
completed,
|
|
tags: Object.prototype.hasOwnProperty.call(patch, "tags") ? normalizeTags(patch.tags) : task.tags,
|
|
project: Object.prototype.hasOwnProperty.call(patch, "project") ? normalizeProject(patch.project) : task.project,
|
|
completedAt: hasCompletedPatch ? (completed ? new Date().toISOString() : "") : task.completedAt
|
|
};
|
|
|
|
lines[task.lineIndex] = serializeTask(nextTask);
|
|
await fs.writeFile(tasksPath(), `${lines.join("\n").replace(/\s*$/, "")}\n`, "utf8");
|
|
return listTasks();
|
|
}
|
|
|
|
async function deleteTask(id) {
|
|
const { lines, tasks } = await readTaskDocument();
|
|
const task = tasks.find((item) => item.id === id);
|
|
if (!task) return listTasks();
|
|
|
|
lines.splice(task.lineIndex, 1);
|
|
await fs.writeFile(tasksPath(), `${lines.join("\n").replace(/\s*$/, "")}\n`, "utf8");
|
|
return listTasks();
|
|
}
|
|
|
|
function ensureCalendarUrl(value) {
|
|
const trimmed = String(value || "").trim();
|
|
if (!trimmed) return "";
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Invalid protocol");
|
|
return url.toString();
|
|
} catch {
|
|
throw new Error("Calendar feed must be a valid http or https URL.");
|
|
}
|
|
}
|
|
|
|
function normalizeCalendarSource(calendar, index) {
|
|
const url = ensureCalendarUrl(calendar?.url || calendar?.calendarFeedUrl || "");
|
|
if (!url) return null;
|
|
|
|
return {
|
|
id: String(calendar?.id || generateId()).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) || generateId(),
|
|
name: String(calendar?.name || `Calendar ${index + 1}`).replace(/\s+/g, " ").trim().slice(0, 80),
|
|
url
|
|
};
|
|
}
|
|
|
|
function normalizeSettings(raw) {
|
|
const migratedCalendars =
|
|
Array.isArray(raw?.calendars)
|
|
? raw.calendars
|
|
: raw?.calendarFeedUrl
|
|
? [{ id: "primary", name: "Google Calendar", url: raw.calendarFeedUrl }]
|
|
: [];
|
|
|
|
return {
|
|
calendars: migratedCalendars
|
|
.map((calendar, index) => normalizeCalendarSource(calendar, index))
|
|
.filter(Boolean),
|
|
dailyNoteTemplate: String(raw?.dailyNoteTemplate || DEFAULT_DAILY_TEMPLATE),
|
|
theme: raw?.theme === "dark" ? "dark" : "light",
|
|
themeColors: normalizeThemeColors(raw?.themeColors)
|
|
};
|
|
}
|
|
|
|
function normalizeHexColor(value, fallback) {
|
|
const color = String(value || "").trim();
|
|
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback;
|
|
}
|
|
|
|
function normalizeThemePalette(raw, defaults) {
|
|
return Object.fromEntries(
|
|
Object.entries(defaults).map(([key, fallback]) => [key, normalizeHexColor(raw?.[key], fallback)])
|
|
);
|
|
}
|
|
|
|
function normalizeThemeColors(raw) {
|
|
return {
|
|
light: normalizeThemePalette(raw?.light, DEFAULT_THEME_COLORS.light),
|
|
dark: normalizeThemePalette(raw?.dark, DEFAULT_THEME_COLORS.dark)
|
|
};
|
|
}
|
|
|
|
async function getSettings() {
|
|
const raw = await readJson(appVaultPath(SETTINGS_FILE), DEFAULT_SETTINGS);
|
|
const settings = normalizeSettings(raw);
|
|
|
|
return {
|
|
...settings,
|
|
calendarFeedUrl: settings.calendars[0]?.url || "",
|
|
vaultPath
|
|
};
|
|
}
|
|
|
|
async function updateSettings(patch) {
|
|
const current = await getSettings();
|
|
const next = normalizeSettings({
|
|
calendars: Object.prototype.hasOwnProperty.call(patch, "calendars") ? patch.calendars : current.calendars,
|
|
calendarFeedUrl: Object.prototype.hasOwnProperty.call(patch, "calendarFeedUrl")
|
|
? patch.calendarFeedUrl
|
|
: current.calendarFeedUrl,
|
|
dailyNoteTemplate: Object.prototype.hasOwnProperty.call(patch, "dailyNoteTemplate")
|
|
? patch.dailyNoteTemplate
|
|
: current.dailyNoteTemplate,
|
|
theme: Object.prototype.hasOwnProperty.call(patch, "theme") ? patch.theme : current.theme,
|
|
themeColors: Object.prototype.hasOwnProperty.call(patch, "themeColors") ? patch.themeColors : current.themeColors
|
|
});
|
|
|
|
await writeJson(appVaultPath(SETTINGS_FILE), next);
|
|
return getSettings();
|
|
}
|
|
|
|
function toDate(value) {
|
|
return value ? value.toJSDate() : null;
|
|
}
|
|
|
|
function overlaps(start, end, rangeStart, rangeEnd) {
|
|
return start < rangeEnd && end > rangeStart;
|
|
}
|
|
|
|
function normalizeCalendarEvent(event, startDate, endDate, occurrenceKey, calendar) {
|
|
const start = toDate(startDate);
|
|
const end = toDate(endDate) || addDays(start, startDate.isDate ? 1 : 0);
|
|
|
|
return {
|
|
id: `${calendar.id}:${event.uid || event.summary || "event"}:${occurrenceKey || start.toISOString()}`,
|
|
title: event.summary || "Untitled event",
|
|
startsAt: start.toISOString(),
|
|
endsAt: end.toISOString(),
|
|
allDay: Boolean(startDate.isDate),
|
|
location: event.location || "",
|
|
description: event.description || "",
|
|
calendarId: calendar.id,
|
|
calendarName: calendar.name
|
|
};
|
|
}
|
|
|
|
function parseCalendarEvents(ics, rangeStart, rangeEnd, calendar) {
|
|
const jcal = ICAL.parse(ics);
|
|
const component = new ICAL.Component(jcal);
|
|
const vevents = component.getAllSubcomponents("vevent");
|
|
const events = [];
|
|
|
|
for (const vevent of vevents) {
|
|
const event = new ICAL.Event(vevent);
|
|
|
|
if (event.isRecurring()) {
|
|
const iterator = event.iterator();
|
|
let next;
|
|
let count = 0;
|
|
|
|
while ((next = iterator.next()) && count < 5000) {
|
|
count += 1;
|
|
const details = event.getOccurrenceDetails(next);
|
|
const start = toDate(details.startDate);
|
|
const end = toDate(details.endDate) || addDays(start, details.startDate.isDate ? 1 : 0);
|
|
|
|
if (start >= rangeEnd) break;
|
|
if (overlaps(start, end, rangeStart, rangeEnd)) {
|
|
events.push(normalizeCalendarEvent(event, details.startDate, details.endDate, next.toString(), calendar));
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
const start = toDate(event.startDate);
|
|
const end = toDate(event.endDate) || addDays(start, event.startDate.isDate ? 1 : 0);
|
|
if (overlaps(start, end, rangeStart, rangeEnd)) {
|
|
events.push(normalizeCalendarEvent(event, event.startDate, event.endDate, undefined, calendar));
|
|
}
|
|
}
|
|
|
|
return events.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
|
|
}
|
|
|
|
async function getCalendarIcs(calendar, force = false) {
|
|
const cachePath = appVaultPath(CACHE_FILE);
|
|
const cache = await readJson(cachePath, { calendars: {} });
|
|
const cachedCalendar = cache.calendars?.[calendar.id];
|
|
const cacheIsFresh =
|
|
cachedCalendar?.ics &&
|
|
cachedCalendar?.syncedAt &&
|
|
Date.now() - Date.parse(cachedCalendar.syncedAt) < CALENDAR_CACHE_TTL_MS;
|
|
|
|
if (!force && cacheIsFresh) {
|
|
return { ics: cachedCalendar.ics, syncedAt: cachedCalendar.syncedAt, status: "cached" };
|
|
}
|
|
|
|
const response = await fetch(calendar.url);
|
|
if (!response.ok) {
|
|
throw new Error(`${calendar.name} returned ${response.status}.`);
|
|
}
|
|
|
|
const ics = await response.text();
|
|
const syncedAt = new Date().toISOString();
|
|
await writeJson(cachePath, {
|
|
calendars: {
|
|
...(cache.calendars || {}),
|
|
[calendar.id]: { ics, syncedAt }
|
|
}
|
|
});
|
|
return { ics, syncedAt, status: "fresh" };
|
|
}
|
|
|
|
async function listCalendarEventsInRange(startISO, endISO, force = false) {
|
|
const { start, end } = rangeBounds(startISO, endISO);
|
|
const settings = await getSettings();
|
|
|
|
if (!settings.calendars.length) {
|
|
return { events: [], syncedAt: "", status: "not_configured", error: "" };
|
|
}
|
|
|
|
const events = [];
|
|
const syncedAtValues = [];
|
|
const statuses = [];
|
|
const errors = [];
|
|
|
|
for (const calendar of settings.calendars) {
|
|
try {
|
|
const feed = await getCalendarIcs(calendar, force);
|
|
syncedAtValues.push(feed.syncedAt);
|
|
statuses.push(feed.status);
|
|
events.push(...parseCalendarEvents(feed.ics, start, end, calendar));
|
|
} catch (error) {
|
|
statuses.push("error");
|
|
errors.push(error.message || `Could not load ${calendar.name}.`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
events: events.sort((a, b) => a.startsAt.localeCompare(b.startsAt)),
|
|
syncedAt: syncedAtValues.sort().at(-1) || "",
|
|
status: errors.length ? "error" : statuses.includes("fresh") ? "fresh" : "cached",
|
|
error: errors.join(" ")
|
|
};
|
|
}
|
|
|
|
async function listCalendarEvents(dateISO, force = false) {
|
|
return listCalendarEventsInRange(dateISO, dateISO, force);
|
|
}
|
|
|
|
async function loadDashboard(dateISO) {
|
|
await ensureVault();
|
|
const [settings, note, generalNotes, tasks, calendar] = await Promise.all([
|
|
getSettings(),
|
|
readDailyNote(dateISO),
|
|
listGeneralNotes(),
|
|
listTasks(),
|
|
listCalendarEvents(dateISO).catch((error) => ({
|
|
events: [],
|
|
syncedAt: "",
|
|
status: "error",
|
|
error: error.message || "Could not load calendar."
|
|
}))
|
|
]);
|
|
|
|
return { settings, note, generalNotes, tasks, calendar };
|
|
}
|
|
|
|
function createWindow() {
|
|
Menu.setApplicationMenu(null);
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1280,
|
|
height: 860,
|
|
minWidth: 960,
|
|
minHeight: 680,
|
|
title: "All-in-One Dashboard",
|
|
autoHideMenuBar: true,
|
|
backgroundColor: "#f6f7fb",
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.cjs"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
});
|
|
|
|
mainWindow.setMenu(null);
|
|
mainWindow.setMenuBarVisibility(false);
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
shell.openExternal(url);
|
|
return { action: "deny" };
|
|
});
|
|
|
|
if (process.env.VITE_DEV_SERVER_URL) {
|
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, "..", "dist", "index.html"));
|
|
}
|
|
}
|
|
|
|
function registerIpc() {
|
|
ipcMain.handle("dashboard:load", (_event, dateISO) => loadDashboard(dateISO || toISODate(new Date())));
|
|
ipcMain.handle("notes:save", (_event, payload) => saveDailyNote(payload.dateISO, payload.content || ""));
|
|
ipcMain.handle("notes:updateMetadata", (_event, payload) => updateDailyNoteMetadata(payload.dateISO, payload.metadata || {}));
|
|
ipcMain.handle("generalNotes:create", (_event, title) => createGeneralNote(title));
|
|
ipcMain.handle("generalNotes:read", (_event, id) => readGeneralNote(id));
|
|
ipcMain.handle("generalNotes:save", (_event, payload) => saveGeneralNote(payload.id, payload.content || ""));
|
|
ipcMain.handle("generalNotes:updateMetadata", (_event, payload) =>
|
|
updateGeneralNoteMetadata(payload.id, payload.metadata || {})
|
|
);
|
|
ipcMain.handle("tasks:add", (_event, payload) => addTask(payload.title, payload.dueDate, payload.tags, payload.project));
|
|
ipcMain.handle("tasks:toggle", (_event, payload) => updateTask(payload.id, { completed: payload.completed }));
|
|
ipcMain.handle("tasks:updateMetadata", (_event, payload) =>
|
|
updateTask(payload.id, { tags: payload.tags || [], project: payload.project || "" })
|
|
);
|
|
ipcMain.handle("tasks:delete", (_event, id) => deleteTask(id));
|
|
ipcMain.handle("settings:update", (_event, patch) => updateSettings(patch || {}));
|
|
ipcMain.handle("calendar:list", (_event, payload) =>
|
|
listCalendarEventsInRange(payload?.startDate || toISODate(new Date()), payload?.endDate || payload?.startDate || toISODate(new Date()))
|
|
);
|
|
ipcMain.handle("calendar:refresh", (_event, payload) => {
|
|
if (typeof payload === "object" && payload) {
|
|
return listCalendarEventsInRange(payload.startDate || toISODate(new Date()), payload.endDate || payload.startDate || toISODate(new Date()), true);
|
|
}
|
|
|
|
return listCalendarEvents(payload || toISODate(new Date()), true);
|
|
});
|
|
ipcMain.handle("vault:open", () => shell.openPath(vaultPath));
|
|
ipcMain.handle("vault:choose", async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
title: "Choose Markdown vault",
|
|
defaultPath: vaultPath,
|
|
properties: ["openDirectory", "createDirectory"]
|
|
});
|
|
|
|
if (result.canceled || !result.filePaths[0]) return getSettings();
|
|
|
|
await saveState({ vaultPath: result.filePaths[0] });
|
|
await ensureVault();
|
|
return getSettings();
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
await loadState();
|
|
await ensureVault();
|
|
registerIpc();
|
|
createWindow();
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") app.quit();
|
|
});
|