Initial commit

This commit is contained in:
2026-07-17 10:00:33 +01:00
commit 634809c6ca
16 changed files with 9694 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
release/
*.log
.vite/
.DS_Store
Thumbs.db

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# All-in-One Dashboard
A local-first Windows desktop app for daily notes, tasks, and Google Calendar events. It uses Electron for the native shell and stores your working data as Markdown files in a local vault.
## Run
```powershell
npm install
npm run dev
```
## Build
```powershell
npm run build
npm run dist
```
## Local files
By default the app creates a vault at:
```text
Documents/All-in-One Vault
```
The vault uses:
```text
Daily/YYYY-MM-DD.md
Notes/*.md
Tasks.md
.all-in-one/settings.json
.all-in-one/calendar-cache.json
```
Daily notes, standalone notes, and tasks are regular Markdown files, so they can be opened in Obsidian or any editor.
## Google Calendar
The first version uses a Google Calendar private iCal URL. In Google Calendar settings, copy the calendar's secret iCal address and paste it into the calendar settings field in the app.
OAuth sync is the natural next step if you want multi-calendar selection, account switching, or write-back to Google Calendar.

899
electron/main.cjs Normal file
View File

@@ -0,0 +1,899 @@
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();
});

19
electron/preload.cjs Normal file
View File

@@ -0,0 +1,19 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("appApi", {
loadDashboard: (dateISO) => ipcRenderer.invoke("dashboard:load", dateISO),
saveNote: (dateISO, content) => ipcRenderer.invoke("notes:save", { dateISO, content }),
updateDailyNoteMetadata: (dateISO, metadata) => ipcRenderer.invoke("notes:updateMetadata", { dateISO, metadata }),
createGeneralNote: (title) => ipcRenderer.invoke("generalNotes:create", title),
readGeneralNote: (id) => ipcRenderer.invoke("generalNotes:read", id),
saveGeneralNote: (id, content) => ipcRenderer.invoke("generalNotes:save", { id, content }),
updateGeneralNoteMetadata: (id, metadata) => ipcRenderer.invoke("generalNotes:updateMetadata", { id, metadata }),
addTask: (title, dueDate, tags, project) => ipcRenderer.invoke("tasks:add", { title, dueDate, tags, project }),
toggleTask: (id, completed) => ipcRenderer.invoke("tasks:toggle", { id, completed }),
updateTaskMetadata: (id, tags, project) => ipcRenderer.invoke("tasks:updateMetadata", { id, tags, project }),
deleteTask: (id) => ipcRenderer.invoke("tasks:delete", id),
updateSettings: (settings) => ipcRenderer.invoke("settings:update", settings),
chooseVault: () => ipcRenderer.invoke("vault:choose"),
openVault: () => ipcRenderer.invoke("vault:open"),
refreshCalendar: (dateISOOrRange) => ipcRenderer.invoke("calendar:refresh", dateISOOrRange)
});

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>All-in-One Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5466
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "all-in-one-app",
"version": "0.1.0",
"private": true,
"description": "A local-first Windows dashboard for daily notes, tasks, and calendar events.",
"main": "electron/main.cjs",
"type": "module",
"scripts": {
"dev": "node scripts/dev.cjs",
"dev:renderer": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"start": "node scripts/start.cjs",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"ical.js": "^1.5.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^42.4.1",
"electron-builder": "^26.15.3",
"typescript": "^5.7.2",
"vite": "^6.0.5"
},
"build": {
"appId": "local.first.allinone",
"productName": "All-in-One Dashboard",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"win": {
"target": "nsis"
}
}
}

68
scripts/dev.cjs Normal file
View File

@@ -0,0 +1,68 @@
const { spawn } = require("child_process");
const http = require("http");
const path = require("path");
const electronPath = require("electron");
const vitePath = path.join(path.dirname(require.resolve("vite/package.json")), "bin", "vite.js");
const port = process.env.VITE_PORT || "5173";
const devUrl = `http://127.0.0.1:${port}`;
function electronEnv(extra = {}) {
const env = { ...process.env, ...extra };
delete env.ELECTRON_RUN_AS_NODE;
return env;
}
const vite = spawn(process.execPath, [vitePath, "--host", "127.0.0.1", "--port", port], {
stdio: "inherit",
env: { ...process.env, BROWSER: "none" }
});
function waitForServer(url, retries = 90) {
return new Promise((resolve, reject) => {
const attempt = () => {
const request = http.get(url, (response) => {
response.resume();
resolve();
});
request.on("error", () => {
if (retries <= 0) {
reject(new Error(`Vite did not start at ${url}`));
return;
}
retries -= 1;
setTimeout(attempt, 300);
});
};
attempt();
});
}
let electron;
waitForServer(devUrl)
.then(() => {
electron = spawn(electronPath, ["."], {
stdio: "inherit",
env: electronEnv({ VITE_DEV_SERVER_URL: devUrl })
});
electron.on("exit", (code) => {
vite.kill();
process.exit(code ?? 0);
});
})
.catch((error) => {
console.error(error);
vite.kill();
process.exit(1);
});
process.on("SIGINT", () => {
if (electron) electron.kill();
vite.kill();
process.exit(0);
});

15
scripts/start.cjs Normal file
View File

@@ -0,0 +1,15 @@
const { spawn } = require("child_process");
const electronPath = require("electron");
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const electron = spawn(electronPath, ["."], {
stdio: "inherit",
env
});
electron.on("exit", (code) => {
process.exit(code ?? 0);
});

1438
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1510
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

111
src/types.ts Normal file
View File

@@ -0,0 +1,111 @@
export type Task = {
id: string;
title: string;
completed: boolean;
dueDate: string;
createdAt: string;
completedAt: string;
tags: string[];
project: string;
};
export type DailyNote = {
dateISO: string;
filePath: string;
content: string;
tags: string[];
project: string;
};
export type GeneralNoteSummary = {
id: string;
title: string;
filePath: string;
updatedAt: string;
tags: string[];
project: string;
};
export type GeneralNote = GeneralNoteSummary & {
content: string;
};
export type CalendarEvent = {
id: string;
title: string;
startsAt: string;
endsAt: string;
allDay: boolean;
location: string;
description: string;
calendarId: string;
calendarName: string;
};
export type CalendarState = {
events: CalendarEvent[];
syncedAt: string;
status: "not_configured" | "cached" | "fresh" | "parse_error" | "error";
error: string;
};
export type ThemePalette = {
background: string;
surface: string;
text: string;
mutedText: string;
border: string;
sidebar: string;
accent: string;
};
export type ThemeColors = {
light: ThemePalette;
dark: ThemePalette;
};
export type Settings = {
calendarFeedUrl: string;
calendars: CalendarSource[];
dailyNoteTemplate: string;
theme: "light" | "dark";
themeColors: ThemeColors;
vaultPath: string;
};
export type CalendarSource = {
id: string;
name: string;
url: string;
};
export type NoteMetadata = {
tags: string[];
project: string;
};
export type DashboardPayload = {
settings: Settings;
note: DailyNote;
generalNotes: GeneralNoteSummary[];
tasks: Task[];
calendar: CalendarState;
};
export type AppApi = {
loadDashboard: (dateISO: string) => Promise<DashboardPayload>;
saveNote: (dateISO: string, content: string) => Promise<DailyNote>;
updateDailyNoteMetadata: (dateISO: string, metadata: Partial<NoteMetadata>) => Promise<DailyNote>;
createGeneralNote: (title: string) => Promise<GeneralNote>;
readGeneralNote: (id: string) => Promise<GeneralNote>;
saveGeneralNote: (id: string, content: string) => Promise<GeneralNote>;
updateGeneralNoteMetadata: (id: string, metadata: Partial<NoteMetadata>) => Promise<GeneralNote>;
addTask: (title: string, dueDate?: string, tags?: string[], project?: string) => Promise<Task[]>;
toggleTask: (id: string, completed: boolean) => Promise<Task[]>;
updateTaskMetadata: (id: string, tags: string[], project: string) => Promise<Task[]>;
deleteTask: (id: string) => Promise<Task[]>;
updateSettings: (settings: Partial<Settings>) => Promise<Settings>;
chooseVault: () => Promise<Settings>;
openVault: () => Promise<void>;
refreshCalendar: (dateISOOrRange: string | { startDate: string; endDate: string }) => Promise<CalendarState>;
};

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
import type { AppApi } from "./types";
declare global {
interface Window {
appApi: AppApi;
}
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173
},
build: {
outDir: "dist",
emptyOutDir: true
}
});