]> nyquistdev.net Git - calendar.git/commitdiff
update
authorA-Larsen <austin.larsen@mailfence.com>
Tue, 6 Oct 2026 13:20:22 +0000 (09:20 -0400)
committerA-Larsen <austin.larsen@mailfence.com>
Tue, 6 Oct 2026 13:20:22 +0000 (09:20 -0400)
docinfo.conf [new file with mode: 0644]
src/features/events.js
src/features/todo.js
src/index.js

diff --git a/docinfo.conf b/docinfo.conf
new file mode 100644 (file)
index 0000000..57fa5b9
--- /dev/null
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+HOSTNAME=nyquistdev.net
+DOCINFODIR=/usr/local/share/readme-to-html
index 32c6d84e2f0bfb2fad1541ce6290d412d8f2057a..c4f2c6e3bf35c805412bdd5a56e15126c41f4838 100644 (file)
@@ -2,6 +2,8 @@ import {Global} from "../global.js"
 import {parseDateFromHash,formatDateLocal,updateHash} from "../utils/time.js"
 import {renderCalendar,selectCell} from "../features/calendar.js"
 
+const SERVER_ORIGIN = "https://nyquistdev";
+
 export let Events = { }
 Events.allEvents = [];
 
@@ -78,7 +80,7 @@ export async function loadAllEvents() {
 export async function postEvent(payload) {
     // payload can include:
     // { id, source, archive: true/false, targetSource }
-    const res = await fetch("/cgi-bin/update-calendar-events", {
+    const res = await fetch(`${SERVER_ORIGIN}/cgi-bin/update-calendar-events`, {
         method: "POST",
         headers: {"Content-Type":"application/json"},
         body: JSON.stringify(payload),
index f249dde300677977830abdfe76cea14e40d9a8f8..8c5314a73b6ceab85ffb81ebef7b97343ec14578 100644 (file)
@@ -13,6 +13,8 @@
 import {showModal} from "../components/modal.js"
 import {Global} from "../global.js"
 
+const SERVER_ORIGIN = "https://nyquistdev";
+
 import {
     createContextMenu,
     selectContextMenuLabel
@@ -252,7 +254,7 @@ export async function loadAllTodos(newTagName = null, scrollNew = false) {
 
     try {
         const res = await fetch(
-            "/rest/notes/todos.json",
+            `${SERVER_ORIGIN}/rest/notes/todos.json`,
             { cache: "no-store" }
         );
         if (!res.ok) throw new Error("Failed to fetch todos");
@@ -450,13 +452,13 @@ function todoContextMenu(div, todo, callback) {
 }
 
 export async function loadTodoReferences() {
-  const res = await fetch('/rest/notes/todo-references.json', {cache: "no-store"});
+  const res = await fetch(`${SERVER_ORIGIN}/rest/notes/todo-references.json`, {cache: "no-store"});
   const data = await res.json();
   return data["todo-references"] || {};
 }
 
 export async function saveTodosToServer(todos) {
-    const res = await fetch("/cgi-bin/update-todos", {
+    const res = await fetch(`${SERVER_ORIGIN}/cgi-bin/update-todos`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ todos }),
@@ -566,7 +568,7 @@ async function saveTodos() {
     });
 
     try {
-        const res = await fetch("/cgi-bin/update-todos.py", {
+        const res = await fetch(`${SERVER_ORIGIN}/cgi-bin/update-todos.py`, {
             method: "POST",
             headers: {"Content-Type":"application/json"},
             body: JSON.stringify(todos),
index b99cc3d10f4712619a727c08b22e6472a6a6326a..83c1fdaf37fc857b647274611c208243c41cf6ec 100644 (file)
-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
+    }
+});