-const { app, BrowserWindow, ipcMain } = require("electron");
-const path = require("path");
-const { spawn } = require("child_process");
-const packageInfo = require("./package.json");
-
-let win;
-let syncCalendarProcess = null;
-
-function killChild(proc, name) {
- if (proc && !proc.killed) {
- console.log(`Stopping ${name}`);
- proc.kill("SIGTERM");
- }
+// ============================================================================
+// Entry point
+// ============================================================================
+// All listeners and maybe polling as well?
+// TODO:
+// instead of having Global
+// maybe use event listeners?
+// maybe not?
+import {Global} from "./global.js"
+
+// menus
+import {showModal} from "./components/modal.js"
+import {Popup} from "./components/popup.js"
+import {createContextMenu} from "./components/contextMenu.js"
+
+import {
+ getMonthDates,
+ formatDateLocal,
+ updateHash
+} from "./utils/time.js"
+
+import {scrollToElement} from "./utils/utils.js"
+
+import {
+ Events,
+ loadAllEvents,
+} from "./features/events.js"
+
+import {
+ hideNotes,
+} from "./features/notes.js"
+
+import {
+ Todo,
+ loadAllTodos,
+ loadTodoReferences,
+ openTodoRefs,
+ saveTodosToServer,
+ sendTodos
+} from "./features/todo.js"
+
+import {
+ renderCalendar,
+ selectedCell,
+ selectCell
+} from "./features/calendar.js";
+
+
+import {
+ Vim,
+ vimTodoMode,
+ vimCalendarMode,
+ vimTabContextMenu,
+ vimTodoContextMenu,
+} from "./features/vim.js"
+
+let currentSource = "events"; // "events" or "old"
+let notePollTimer = null;
+let todoTagIdx = 0;
+let todoIndex = 0;
+let contextMenuIdx = 0;
+let iframeScrollPosition = 0; // Initialize a variable to hold the scroll position
+let etagCache = {};
+let pollTimer = null;
+
+/* ================= API ================= */
+async function postEvent(payload) {
+ // payload can include:
+ // { id, source, archive: true/false, targetSource }
+ const res = await fetch("/cgi-bin/update-calendar-events", {
+ method: "POST",
+ headers: {"Content-Type":"application/json"},
+ body: JSON.stringify(payload),
+ cache: "no-store"
+ });
+
+ if (!res.ok) throw new Error(res.status);
+}
+
+/* ================= NAV ================= */
+document.getElementById("prev-month").onclick = () => {
+ Global.currentMonth--; if (Global.currentMonth < 0) { Global.currentMonth=11; Global.currentYear--; }
+ updateHash(formatDateLocal(new Date(Global.currentYear,Global.currentMonth,1)));
+ loadAllEvents();
+};
+
+document.getElementById("next-month").onclick = () => {
+ Global.currentMonth++; if (Global.currentMonth > 11) { Global.currentMonth=0; Global.currentYear++; }
+ updateHash(formatDateLocal(new Date(Global.currentYear,Global.currentMonth,1)));
+ loadAllEvents();
+};
+
+document.getElementById("today-btn").onclick = () => {
+ updateHash(formatDateLocal(new Date()));
+ loadAllEvents();
+};
+
+/* =============== ARCHIVE ================ */
+
+document.getElementById("toggle-old").onchange = e => {
+ Global.showArchive = e.target.checked;
+ loadAllEvents();
+};
+
+/* =============== SEARCH ================= */
+document.getElementById("tag-search").oninput = e => {
+ Global.currentTagFilter = e.target.value;
+ renderCalendar(Global.currentMonth, Global.currentYear);
+};
+
+document.getElementById("clear-tag-search").onclick = () => {
+ Global.currentTagFilter = "";
+ document.getElementById("tag-search").value = "";
+ renderCalendar(Global.currentMonth, Global.currentYear);
+};
+
+document.addEventListener("keydown", async (e) => {
+
+ let modalVisible =
+ Popup.popup.style.display === "flex" ||
+ document.getElementById("custom-modal").style.display === "flex";
+
+ Vim.isControlPressed = e.ctrlKey || e.metaKey;
+ Vim.shiftKeyPressed = e.shiftKey;
+
+ // global controls for any modal
+ if (modalVisible) {
+ console.log("visible")
+ const cancelBtn = document.getElementById("custom-modal-cancel");
+ const okBtn = document.getElementById("custom-modal-ok");
+ if (Vim.isControlPressed && e.key === 'c') {
+ e.preventDefault(); // Prevent default if necessary
+ cancelBtn.click();
+ }
+ switch(e.key) {
+ case "Enter": {
+ if (e.shiftKey) {
+ e.preventDefault();
+ document.querySelector("textarea#custom-modal-input").value += "\n";
+ } else document.getElementById("custom-modal-ok")?.click();
+ break;
+ }
+ }
+ return;
+ }
+
+
+ const todo_ref_overlay = document.querySelector(".todo-ref-overlay");
+ Vim.todo_ref_visible = (todo_ref_overlay != null);
+ Vim.removeRef = () => {
+ todo_ref_overlay.remove()
+ Global.overlay.style.display = "none";
+ }
+ //if (todo_ref_overlay) {e.preventDefault(); return}
+
+ if (Vim.vimTodoContextMenu) Vim.vimTodoContextMenu = getComputedStyle(popupMenu).display !== "none";
+ if (Vim.vimTabContextMenu) Vim.vimTabContextMenu = getComputedStyle(popupMenu).display !== "none";
+
+ const tag = document.activeElement.tagName;
+
+ Vim.typing =
+ tag === "INPUT" ||
+ tag === "TEXTAREA" ||
+ document.activeElement.isContentEditable;
+
+ if (Vim.typing) return;
+
+
+ const todo_display = getComputedStyle(document.querySelector("#all-todos-container")).display;
+
+
+ Vim.inputSelected = false;
+ Vim.todos_visible = todo_display !== "none"
+
+ document.querySelectorAll('input[type="text"]').forEach(e => {
+ if (document.activeElement == e) {
+ Vim.inputSelected = true;
+ return;
+ };
+ })
+
+ if (Vim.inputSelected) return;
+
+ // anything that switches to different modes goes here
+ switch(e.key) {
+ case "Tab": // tab mode
+ e.preventDefault();
+ const container = document.getElementById("all-todos-container");
+ const isHidden = getComputedStyle(container).display === "none";
+ container.style.display = isHidden ? "flex" : "none";
+ Vim.todos_toggle = !Vim.todos_toggle;
+ break;
+
+ }
+
+ if (Vim.vimTabContextMenu || Vim.vimTodoContextMenu) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ }
+
+ if (Vim.vimTabContextMenu) {
+ vimTabContextMenu(e);
+ } else if (Vim.vimTodoContextMenu){
+ vimTodoContextMenu(e);
+ } else if(Vim.todos_visible) {
+ vimTodoMode(e);
+
+ } else {
+ vimCalendarMode(e);
+ }
+}, true);
+
+
+function setSyncIndicator(state) {
+ const ind = document.getElementById("sync-indicator");
+ ind.className = ""; // reset
+ ind.classList.add(state);
+}
+
+
+async function checkETag(url) {
+ const res = await fetch(url, {
+ method: "HEAD",
+ cache: "no-store"
+ });
+
+ return res.headers.get("ETag");
}
-function createWindow() {
- win = new BrowserWindow({
- title: "Calendar",
- width: 1910,
- height: 1045,
- x: -2,
- y: 2,
- skipTaskbar: true,
- frame: false,
- webPreferences: {
- preload: path.join(__dirname, "preload.js"),
- contextIsolation: true,
- nodeIntegration: false
+async function pollForChanges() {
+
+ try {
+ setSyncIndicator("checking"); // show checking state
+
+ const urls = Global.showArchive
+ ? ["/rest/calendar/archive.json"]
+ : ["/rest/calendar/events.json", "/rest/calendar/old.json"];
+
+ let changed = false;
+
+ for (const url of urls) {
+ const etag = await checkETag(url);
+
+ if (etag && etagCache[url] !== etag) {
+ etagCache[url] = etag;
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ await loadAllEvents();
+ setSyncIndicator("synced"); // green when updated
+ } else {
+ setSyncIndicator("synced"); // still green if nothing changed
+ }
+ } catch (err) {
+ console.warn("ETag polling failed", err);
+ setSyncIndicator("error"); // red if polling fails
+ }
+
+ try {
+ setSyncIndicator("checking"); // show checking state
+
+ const url = "/rest/notes/todos.json";
+
+ let changed = false;
+
+ const etag = await checkETag(url);
+
+ if (etag && etagCache[url] !== etag) {
+ etagCache[url] = etag;
+ changed = true;
+ }
+
+ if (changed) {
+ await loadAllTodos();
+ setSyncIndicator("synced"); // green when updated
+ } else {
+ setSyncIndicator("synced"); // still green if nothing changed
+ }
+ } catch (err) {
+ console.warn("ETag polling failed", err);
+ setSyncIndicator("error"); // red if polling fails
+ }
+
+ try {
+ setSyncIndicator("checking"); // show checking state
+
+ const url = "/rest/notes/todo-references.json";
+
+ let changed = false;
+
+ const etag = await checkETag(url);
+
+ if (etag && etagCache[url] !== etag) {
+ etagCache[url] = etag;
+ changed = true;
}
+
+ if (changed) {
+ await loadTodoReferences();
+ setSyncIndicator("synced"); // green when updated
+ } else {
+ setSyncIndicator("synced"); // still green if nothing changed
+ }
+ } catch (err) {
+ console.warn("ETag polling failed", err);
+ setSyncIndicator("error"); // red if polling fails
+ }
+}
+
+
+function startPolling() {
+ if (pollTimer) return;
+ pollTimer = setInterval(pollForChanges, 5000);
+}
+
+function stopPolling() {
+ clearInterval(pollTimer);
+ pollTimer = null;
+}
+
+
+(async () => {
+ await loadAllEvents();
+ await loadAllTodos();
+
+ // Prime ETags
+ //const urls = ["/rest/calendar/events.json", "/rest/calendar/old.json", "/rest/calendar/archive.json"];
+
+ const urls = Global.showArchive
+ ? ["/rest/calendar/archive.json"]
+ : ["/rest/calendar/events.json", "/rest/calendar/old.json"];
+ for (const url of urls) {
+ try {
+ etagCache[url] = await checkETag(url);
+ } catch {}
+ }
+
+ startPolling();
+})();
+
+function onCalendarDateClick(dateStr) {
+ Global.selectedDate = dateStr;
+ loadAllTodos();
+}
+
+async function checkNoteETag() {
+ if (!Global.currentNoteURL) return;
+
+ // Strip cache-busting before HEAD request
+ const baseURL = Global.currentNoteURL.split("?")[0];
+
+ const response = await fetch(baseURL, {
+ method: "HEAD",
+ cache: "no-store"
});
- // Fix: Access config.serverUrl correctly matching package.json
- const targetUrl = `${packageInfo.config.serverUrl}/calendar`;
- console.log(`Loading URL: ${targetUrl}`);
- win.loadURL(targetUrl);
+ const etag = response.headers.get("ETag");
+
+ // First time: just store it
+ if (!Global.noteEtag) {
+ Global.noteEtag = etag;
+ return;
+ }
+
+ // If changed → force iframe reload
+ // update scroll position on iframe update
+ if (etag && etag !== Global.noteEtag) {
+ const iframe = document.getElementById("note-frame");
+
+ const busted = baseURL + "?t=" + Date.now();
+ iframe.src = busted;
+ if (iframe.contentDocument?.readyState === 'complete') {
+ oldIframeHtml = newIframeHtml;
+ newIframeHtml = iframe.contentDocument.documentElement.cloneNode(true);
+ const diff = compareIframes();
+ }
+
+
+ Global.currentNoteURL = busted;
+ }
+
+ // ALWAYS update stored ETag
+ Global.noteEtag = etag;
+}
+
+function compareIframes() {
+ if (!oldIframeHtml || !newIframeHtml) return null;
+
+ const result = {
+ removed: {},
+ added: {},
+ changed: {}
+ };
+
+ function getNodePath(node) {
+ const path = [];
+ while (node && node.nodeType === 1) {
+ let selector = node.tagName.toLowerCase();
+ if (node.id) selector += `#${node.id}`;
+ else if (node.className)
+ selector += "." + node.className.trim().replace(/\s+/g, ".");
+ path.unshift(selector);
+ node = node.parentElement;
+ }
+ return path.join(" > ");
+ }
+
+ function attributesEqual(a, b) {
+ if (a.attributes.length !== b.attributes.length) return false;
+ for (const attr of a.attributes) {
+ if (b.getAttribute(attr.name) !== attr.value) return false;
+ }
+ return true;
+ }
+
+ function walk(oldNode, newNode) {
+ if (!oldNode && newNode) {
+ result.added[getNodePath(newNode)] = newNode;
+ return;
+ }
+
+ if (oldNode && !newNode) {
+ result.removed[getNodePath(oldNode)] = oldNode;
+ return;
+ }
+
+ if (
+ oldNode.tagName !== newNode.tagName ||
+ !attributesEqual(oldNode, newNode) ||
+ oldNode.textContent !== newNode.textContent
+ ) {
+ result.changed[getNodePath(newNode)] = {
+ old: oldNode,
+ new: newNode,
+ changes: {
+ textChanged: oldNode.textContent !== newNode.textContent,
+ attributesChanged: !attributesEqual(oldNode, newNode)
+ }
+ };
+ }
+
+ const oldChildren = Array.from(oldNode.children);
+ const newChildren = Array.from(newNode.children);
+ const max = Math.max(oldChildren.length, newChildren.length);
+
+ for (let i = 0; i < max; i++) {
+ walk(oldChildren[i], newChildren[i]);
+ }
+ }
+
+ walk(oldIframeHtml, newIframeHtml);
+
+ return result;
+}
+
+function startNotePolling() {
+ if (notePollTimer) clearInterval(notePollTimer);
+ notePollTimer = setInterval(checkNoteETag, 1000);
+}
+
+function stopNotePolling() {
+ clearInterval(notePollTimer);
+ notePollTimer = null;
}
-// Ignore self-signed / unverified SSL certificate errors
-app.on("certificate-error", (event, webContents, url, error, certificate, callback) => {
- event.preventDefault();
- callback(true);
+
+// event listeners
+
+
+/* Attach handlers */
+document.addEventListener('click', e => {
+ const btn = e.target.closest('.todo-ref-btn');
+ if (!btn) return;
+
+ const todo = btn.closest('.todo-item');
+ if (!todo) return;
+
+ const id = todo.dataset.todoId;
+ if (id) openTodoRefs(id);
});
-app.whenReady().then(() => {
- createWindow();
- syncCalendarProcess = spawn("/usr/local/bin/sync-calendar", [], {
- stdio: "ignore"
- });
+document.addEventListener("calendar:select", e => {
+ const { date } = e.detail;
+
+ Global.selectedDate = date;
+ updateHash(date);
+ loadAllTodos();
});
-// Listen for launch requests
-ipcMain.on("launch-app", (event, { appName, args }) => {
- const child = spawn(appName, args ?? [], {
- detached: false
- });
-
- child.on("exit", (code, signal) => {
- if (win && !win.isDestroyed()) {
- win.webContents.send("app-closed", {
- appName,
- code,
- signal
- });
- }
- });
+
+document.addEventListener("poll-note", e => {
+ startNotePolling();
+})
+
+window.addEventListener("hashchange", () => {
+ loadAllEvents();
});
-app.on("before-quit", () => {
- killChild(syncCalendarProcess, "sync-calendar");
+const add_tab = document.getElementById("todo-add-tab")
+add_tab.addEventListener('mousedown', function(e) {
+ add_tab.style.backgroundColor = "#0f0b0d";
+ add_tab.style.color = "#83a598";
+
+})
+
+add_tab.addEventListener('mouseup', function(e) {
+ add_tab.style.backgroundColor = "inherit";
+ add_tab.style.color = "#ebdbb2";
+
+})
+
+document.addEventListener("visibilitychange", () => {
+ if (!document.hidden) {
+ pollForChanges(); // immediate refresh
+ startPolling();
+ checkNoteETag();
+ startNotePolling();
+ } else {
+ stopPolling();
+ stopNotePolling();
+ }
});
-process.on("exit", () => {
- killChild(syncCalendarProcess, "sync-calendar");
+add_tab.addEventListener('click', async function(e) {
+ let tagName = null;
+ if ( !(
+ tagName = await showModal({
+ message: "Enter new tab name:",
+ type: "confirm", // OK + Cancel
+ input: true, // show input field
+ inputPlaceholder: "New tab name"
+ })
+ )) return;
+
+ let todoName = null;
+ if ( !(
+ todoName = await showModal({
+ message: "Enter new todo name:",
+ type: "confirm", // OK + Cancel
+ input: true, // show input field
+ inputPlaceholder: "New todo name"
+ })
+ )) return;
+ await sendTodos(tagName, false, todoName);
+ loadAllTodos(tagName, true);
+})
+
+window.addEventListener("message", (e) => {
+ // Uncomment and specify the expected origin for security
+ // if (e.origin !== "https://your-trusted-origin.com") return;
+
+ // Close notes handling
+ if (e.data?.type === "CLOSE_NOTES") {
+ hideNotes();
+ stopNotePolling();
+ document.body.focus();
+ }
+
+ // Handle the scroll position being set from the iframe
+ if (e.data.type === "SCROLL_REQUEST_SET") {
+ iframeScrollPosition = e.data.scrollPosition; // Store the new scroll position
+ }
+
+ // Handle requests from iframe to get the scroll position
+ if (e.data.type === "SCROLL_REQUEST_GET") {
+ const iframe = document.getElementById("note-frame");
+
+ // Send the stored scroll position back to the iframe
+ iframe.contentWindow.postMessage({
+ type: "SCROLL_POSITION", // Indicate this is a scroll position response
+ scrollPosition: iframeScrollPosition // Send the stored scroll position
+ }, "*"); // Replace "*" with the expected origin for security
+ }
});
-process.on("SIGINT", () => process.exit());
-process.on("SIGTERM", () => process.exit());
+// electron
+window.appLauncher.onAppClosed(({ appName, code, signal }) => {
+ if (appName === "alacritty") {
+ hideNotes();
+ stopNotePolling();
+ loadAllTodos(); // Load todos after the application is closed
+ }
+});