From: A-Larsen Date: Tue, 6 Oct 2026 13:20:22 +0000 (-0400) Subject: update X-Git-Url: https://nyquistdev.net/git/?a=commitdiff_plain;h=db1afbf9baaba01c6fd79214bc90b333dba372ce;p=calendar.git update --- diff --git a/docinfo.conf b/docinfo.conf new file mode 100644 index 0000000..57fa5b9 --- /dev/null +++ b/docinfo.conf @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +HOSTNAME=nyquistdev.net +DOCINFODIR=/usr/local/share/readme-to-html diff --git a/src/features/events.js b/src/features/events.js index 32c6d84..c4f2c6e 100644 --- a/src/features/events.js +++ b/src/features/events.js @@ -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), diff --git a/src/features/todo.js b/src/features/todo.js index f249dde..8c5314a 100644 --- a/src/features/todo.js +++ b/src/features/todo.js @@ -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), diff --git a/src/index.js b/src/index.js index b99cc3d..83c1fda 100644 --- a/src/index.js +++ b/src/index.js @@ -1,77 +1,585 @@ -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 + } +});