Initial commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.vite/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
43
README.md
Normal file
43
README.md
Normal 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
899
electron/main.cjs
Normal 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
19
electron/preload.cjs
Normal 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
13
index.html
Normal 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
5466
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal 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
68
scripts/dev.cjs
Normal 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
15
scripts/start.cjs
Normal 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
1438
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
src/main.tsx
Normal file
11
src/main.tsx
Normal 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
1510
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
111
src/types.ts
Normal file
111
src/types.ts
Normal 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
10
src/vite-env.d.ts
vendored
Normal 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
22
tsconfig.json
Normal 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
15
vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user