(function () { "use strict"; const PREFIX = "jess:mealie:"; const PROGRESS_PREFIX = `${PREFIX}cook-progress:`; const NOTES_PREFIX = `${PREFIX}recipe-notes:`; const LAST_RECIPE_KEY = `${PREFIX}last-recipe`; const ACTIVE_RECIPE_KEY = `${PREFIX}active-recipe`; const TIMER_KEY = `${PREFIX}cook-timer`; const RESTORE_MARK = "data-jess-cook-restored"; const TOOLBAR_ID = "jess-mealie-cook-tools"; const TIMER_ID = "jess-mealie-timer-toast"; const RECIPE_PATH = /\/(?:g\/[^/]+\/r|recipes?|recipe)\//i; const DURATION_TEXT = /(\d{1,3})(?:\s*(?:-|to|–|—)\s*(\d{1,3}))?\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?)\b/gi; function normalize(value) { return (value || "").replace(/\s+/g, " ").trim().slice(0, 160); } function storageGet(key, fallback) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (_error) { return fallback; } } function storageSet(key, value) { localStorage.setItem(key, JSON.stringify(value)); window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(value) })); } function routeText() { return `${window.location.pathname}${window.location.hash}${window.location.search}`; } function checkboxCount() { return allControls().length; } function onRecipePage() { if (RECIPE_PATH.test(routeText())) return true; const text = normalize(document.body ? document.body.textContent : ""); const hasRecipeText = text.includes("Ingredients") && text.includes("Instructions"); const hasCookModeText = text.includes("Cook Mode") || text.includes("Exit Cook Mode"); return (hasRecipeText || hasCookModeText) && checkboxCount() > 0; } function recipeSlug() { const path = `${window.location.pathname}${window.location.hash}`.replace(/\/+$/, ""); const slugMatch = path.match(/\/r\/([^/?#]+)/i) || path.match(/\/recipes?\/([^/?#]+)/i) || path.match(/\/recipe\/([^/?#]+)/i); if (slugMatch) return decodeURIComponent(slugMatch[1]); const heading = document.querySelector("h1, .v-toolbar-title, [class*='title']"); return normalize(heading ? heading.textContent : document.title || path) || path; } function recipeKey(prefix) { return `${prefix}${recipeSlug()}`; } function recipeTitle() { const candidates = [ document.querySelector("h1"), document.querySelector(".v-toolbar-title"), document.querySelector("[class*='title']"), ].filter(Boolean); for (const item of candidates) { const text = normalize(item.textContent); if (text && !["Mealie", "Recipes"].includes(text)) return text; } const title = normalize(document.title.replace(/[-|].*$/, "")); return title || recipeSlug(); } function recipeInfo(extra) { return { title: recipeTitle(), slug: recipeSlug(), path: `${window.location.pathname}${window.location.hash}`, url: window.location.href, updatedAt: new Date().toISOString(), ...extra, }; } function updateRecipeStatus(extra) { if (!onRecipePage()) return; const info = recipeInfo(extra); storageSet(LAST_RECIPE_KEY, info); storageSet(ACTIVE_RECIPE_KEY, { ...info, active: true }); } function allControls() { const selector = [ "main input[type='checkbox']", ".v-main input[type='checkbox']", "main [role='checkbox']", ".v-main [role='checkbox']", "input[type='checkbox']", ].join(", "); return Array.from(document.querySelectorAll(selector)).filter((control) => { if (control.closest(`#${TOOLBAR_ID}`) || control.closest(`#${TIMER_ID}`)) return false; if (control instanceof HTMLInputElement && control.disabled) return false; if (control.getAttribute("aria-disabled") === "true") return false; return true; }); } function labelText(control) { if (control.id) { const explicit = document.querySelector(`label[for="${CSS.escape(control.id)}"]`); if (explicit) return normalize(explicit.textContent); } const direct = normalize(control.getAttribute("aria-label") || control.getAttribute("title") || ""); if (direct) return direct; const label = control.closest("label"); if (label) return normalize(label.textContent); const nearby = control.closest(".v-list-item, .v-selection-control, .v-checkbox, li, tr, .v-card, .v-card-text"); if (nearby) { const text = normalize(nearby.textContent); if (text) return text; } let parent = control.parentElement; for (let depth = 0; parent && depth < 5; depth += 1) { const text = normalize(parent.textContent); if (text) return text; parent = parent.parentElement; } if (control instanceof HTMLInputElement) return normalize(control.name || control.value || ""); return ""; } function candidates() { if (!onRecipePage()) return []; return allControls(); } function indexedKeys(controls) { const seen = new Map(); return controls.map((control, index) => { const label = labelText(control) || `checkbox-${index}`; const count = seen.get(label) || 0; seen.set(label, count + 1); return `${index}:${count}:${label}`; }); } function isChecked(control) { if (control instanceof HTMLInputElement) return !!control.checked; return control.getAttribute("aria-checked") === "true"; } function setChecked(control, checked) { if (isChecked(control) !== checked) { control.click(); } if (control instanceof HTMLInputElement) { window.setTimeout(() => { if (control.checked !== checked) { control.checked = checked; control.dispatchEvent(new Event("input", { bubbles: true })); control.dispatchEvent(new Event("change", { bubbles: true })); } control.setAttribute(RESTORE_MARK, "true"); }, 0); return; } window.setTimeout(() => { if (isChecked(control) !== checked) { control.setAttribute("aria-checked", checked ? "true" : "false"); } control.setAttribute(RESTORE_MARK, "true"); }, 0); } function readProgress() { return storageGet(recipeKey(PROGRESS_PREFIX), {}); } function writeProgress() { const controls = candidates(); if (!controls.length) return; const keys = indexedKeys(controls); const checked = {}; controls.forEach((control, index) => { checked[keys[index]] = isChecked(control); }); storageSet(recipeKey(PROGRESS_PREFIX), { updatedAt: new Date().toISOString(), checked, }); updateRecipeStatus({ active: true }); } function restoreProgress() { const controls = candidates(); if (!controls.length) return; const state = readProgress(); if (!state.checked) return; const keys = indexedKeys(controls); controls.forEach((control, index) => { const saved = state.checked[keys[index]]; if (typeof saved !== "boolean") return; if (control.getAttribute(RESTORE_MARK) === "true" && isChecked(control) === saved) return; setChecked(control, saved); }); } function clearRecipeProgress() { localStorage.removeItem(recipeKey(PROGRESS_PREFIX)); candidates().forEach((control) => { setChecked(control, false); control.removeAttribute(RESTORE_MARK); }); updateRecipeStatus({ active: false, finishedAt: new Date().toISOString() }); } function noteKey() { return recipeKey(NOTES_PREFIX); } function getNotes() { return storageGet(noteKey(), []); } function saveLocalNote(text) { const notes = getNotes(); notes.push({ text, createdAt: new Date().toISOString() }); storageSet(noteKey(), notes); } function authHeaders() { const headers = { "Content-Type": "application/json" }; const sources = [localStorage, sessionStorage]; for (const source of sources) { for (let index = 0; index < source.length; index += 1) { const key = source.key(index); const value = source.getItem(key) || ""; if (!/token|auth|jwt/i.test(key + value)) continue; const tokenMatch = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); if (tokenMatch) { headers.Authorization = `Bearer ${tokenMatch[0]}`; return headers; } } } return headers; } async function addNote() { const text = window.prompt("Recipe note"); if (!normalize(text)) return; let savedToMealie = false; try { const recipe = await fetch(`/api/recipes/${encodeURIComponent(recipeSlug())}`, { credentials: "include", headers: authHeaders(), }).then((response) => (response.ok ? response.json() : null)); if (recipe && recipe.id) { const response = await fetch("/api/comments", { method: "POST", credentials: "include", headers: authHeaders(), body: JSON.stringify({ recipeId: recipe.id, text }), }); savedToMealie = response.ok; } } catch (_error) { savedToMealie = false; } if (!savedToMealie) saveLocalNote(text); flash(savedToMealie ? "Note added to Mealie" : "Note saved on this device"); renderToolbar(); } function durationSeconds(low, high, unit) { const amount = Math.max(Number(low), high ? Number(high) : Number(low)); const lowerUnit = unit.toLowerCase(); const seconds = /hour|hr/.test(lowerUnit) ? amount * 3600 : /sec/.test(lowerUnit) ? amount : amount * 60; if (seconds < 30 || seconds > 4 * 3600) return null; return seconds; } function timerRoots() { const roots = Array.from(document.querySelectorAll("main, .v-main")); return roots.length ? roots : [document.body].filter(Boolean); } function skipTimerTextNode(node) { const parent = node.parentElement; if (!parent) return true; DURATION_TEXT.lastIndex = 0; if (!DURATION_TEXT.test(node.nodeValue || "")) return true; DURATION_TEXT.lastIndex = 0; return !!parent.closest([ `#${TOOLBAR_ID}`, `#${TIMER_ID}`, ".jess-mealie-inline-timer", "[data-jess-inline-timer]", "a", "button", "input", "textarea", "select", "script", "style", "nav", "header", "aside", ".v-navigation-drawer", ".v-app-bar", ".v-toolbar", ].join(", ")); } function linkifyInstructionTimers() { if (!onRecipePage()) return; const nodes = []; timerRoots().forEach((root) => { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return skipTimerTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }, }); let node; while ((node = walker.nextNode())) nodes.push(node); }); nodes.forEach((node) => { const text = node.nodeValue || ""; const fragment = document.createDocumentFragment(); let lastIndex = 0; let changed = false; DURATION_TEXT.lastIndex = 0; let match; while ((match = DURATION_TEXT.exec(text))) { const seconds = durationSeconds(match[1], match[2], match[3]); if (!seconds) continue; fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); const button = document.createElement("button"); button.type = "button"; button.className = "jess-mealie-inline-timer"; button.setAttribute("data-jess-inline-timer", String(seconds)); button.textContent = match[0]; button.title = `Start ${match[0]} timer`; fragment.append(button); lastIndex = match.index + match[0].length; changed = true; } if (!changed) return; fragment.append(document.createTextNode(text.slice(lastIndex))); node.parentNode.replaceChild(fragment, node); }); } function startTimer(seconds, label) { const timer = { label, seconds, startedAt: Date.now(), endsAt: Date.now() + seconds * 1000, recipe: recipeTitle(), }; storageSet(TIMER_KEY, timer); ensureTimerToast(); updateTimerToast(); } function currentTimer() { const timer = storageGet(TIMER_KEY, null); if (!timer || !timer.endsAt) return null; return timer; } function stopTimer() { localStorage.removeItem(TIMER_KEY); const toast = document.getElementById(TIMER_ID); if (toast) toast.remove(); } function remainingSeconds(timer) { return Math.max(0, Math.ceil((timer.endsAt - Date.now()) / 1000)); } function formatSeconds(seconds) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; if (mins >= 60) { const hours = Math.floor(mins / 60); const rem = mins % 60; return `${hours}:${String(rem).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } return `${mins}:${String(secs).padStart(2, "0")}`; } function beep() { try { const context = new (window.AudioContext || window.webkitAudioContext)(); for (let i = 0; i < 3; i += 1) { const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.frequency.value = 880; gain.gain.value = 0.08; oscillator.connect(gain); gain.connect(context.destination); oscillator.start(context.currentTime + i * 0.35); oscillator.stop(context.currentTime + i * 0.35 + 0.22); } } catch (_error) { // Browser audio can be blocked until the page has user interaction. } } function ensureTimerToast() { if (document.getElementById(TIMER_ID)) return; const toast = document.createElement("div"); toast.id = TIMER_ID; toast.style.position = "fixed"; toast.style.left = "16px"; toast.style.bottom = "16px"; toast.style.zIndex = "2147483647"; toast.style.borderRadius = "8px"; toast.style.padding = "12px"; toast.style.background = "rgba(23, 31, 42, 0.94)"; toast.style.color = "#fff"; toast.style.font = "600 14px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"; toast.style.boxShadow = "0 6px 22px rgba(0, 0, 0, 0.24)"; toast.style.minWidth = "190px"; document.body.appendChild(toast); } function updateTimerToast() { const timer = currentTimer(); if (!timer) return; ensureTimerToast(); const toast = document.getElementById(TIMER_ID); const remaining = remainingSeconds(timer); if (remaining <= 0) { if (!timer.done) { beep(); storageSet(TIMER_KEY, { ...timer, done: true }); } toast.innerHTML = `
${escapeHtml(timer.label)} done
`; return; } toast.innerHTML = `
${escapeHtml(timer.recipe || "Recipe timer")}
${formatSeconds(remaining)}
${escapeHtml(timer.label)}
`; } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); } function flash(message) { const existing = document.getElementById("jess-mealie-flash"); if (existing) existing.remove(); const item = document.createElement("div"); item.id = "jess-mealie-flash"; item.textContent = message; item.style.position = "fixed"; item.style.right = "16px"; item.style.bottom = "92px"; item.style.zIndex = "2147483647"; item.style.background = "rgba(23, 31, 42, 0.94)"; item.style.color = "#fff"; item.style.borderRadius = "8px"; item.style.padding = "10px 12px"; item.style.boxShadow = "0 6px 22px rgba(0, 0, 0, 0.24)"; item.style.font = "600 14px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"; document.body.appendChild(item); window.setTimeout(() => item.remove(), 2200); } function injectStyles() { if (document.getElementById("jess-mealie-styles")) return; const style = document.createElement("style"); style.id = "jess-mealie-styles"; style.textContent = ` #${TOOLBAR_ID} { position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; max-width: min(320px, calc(100vw - 32px)); padding: 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.94); color: #1f2937; box-shadow: 0 10px 32px rgba(0, 0, 0, 0.22); font: 600 14px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; } #${TOOLBAR_ID} .jess-row { display: flex; flex-wrap: wrap; gap: 6px; } #${TOOLBAR_ID} button, #${TOOLBAR_ID} a { border: 0; border-radius: 8px; padding: 8px 10px; background: #eef2ff; color: #26324a; text-decoration: none; cursor: pointer; line-height: 1; white-space: nowrap; } #${TOOLBAR_ID} button[data-primary] { background: #7b9cff; color: #fff; } #${TOOLBAR_ID} .jess-title { font-weight: 700; font-size: 13px; opacity: .8; } .jess-mealie-inline-timer { display: inline-flex; align-items: center; margin: 0 2px; padding: 2px 7px; border: 0; border-radius: 8px; background: #fff3c4; color: #4b3b00; font: inherit; font-weight: 700; line-height: 1.35; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(75, 59, 0, 0.16); } @media (max-width: 720px) { #${TOOLBAR_ID} { left: 12px; right: 12px; bottom: 12px; max-width: none; } } `; document.head.appendChild(style); } function renderToolbar() { injectStyles(); let toolbar = document.getElementById(TOOLBAR_ID); if (!onRecipePage()) { if (toolbar) toolbar.remove(); return; } if (!toolbar) { toolbar = document.createElement("div"); toolbar.id = TOOLBAR_ID; document.body.appendChild(toolbar); } toolbar.innerHTML = `
${escapeHtml(recipeTitle())}
`; } let lastPath = window.location.pathname; let lastHash = window.location.hash; let restoreTimer = null; function scheduleRestore() { window.clearTimeout(restoreTimer); restoreTimer = window.setTimeout(() => { renderToolbar(); linkifyInstructionTimers(); restoreProgress(); updateRecipeStatus({ active: true }); }, 250); } function scheduleWrite() { window.setTimeout(writeProgress, 50); window.setTimeout(writeProgress, 250); window.setTimeout(writeProgress, 1000); } function maybeSave(event) { const target = event.target; if (!(target instanceof Element) || !onRecipePage()) return; if ( target.matches("input[type='checkbox'], [role='checkbox']") || target.closest("input[type='checkbox'], [role='checkbox'], .v-selection-control, .v-checkbox") ) { scheduleWrite(); } } function inlineTimerTarget(event) { const target = event.target; if (!(target instanceof Element)) return null; return target.closest("[data-jess-inline-timer]"); } function protectInlineTimerTap(event) { if (!inlineTimerTarget(event)) return; event.stopPropagation(); } document.addEventListener("pointerdown", protectInlineTimerTap, true); document.addEventListener("mousedown", protectInlineTimerTap, true); document.addEventListener("touchstart", protectInlineTimerTap, true); document.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; const timerButton = target.closest("[data-jess-timer], [data-jess-inline-timer]"); if (timerButton) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); startTimer( Number(timerButton.getAttribute("data-jess-timer") || timerButton.getAttribute("data-jess-inline-timer")), timerButton.textContent || "Timer", ); return; } if (target.closest("[data-jess-stop-timer]")) { stopTimer(); return; } if (target.closest("[data-jess-finished]")) { const confirmed = window.confirm("Clear saved cooking progress for this recipe?"); if (!confirmed) return; clearRecipeProgress(); flash("Progress cleared"); return; } }, true); document.addEventListener("change", maybeSave, true); document.addEventListener("input", maybeSave, true); document.addEventListener("click", maybeSave, true); const observer = new MutationObserver(() => { if (window.location.pathname !== lastPath || window.location.hash !== lastHash) { lastPath = window.location.pathname; lastHash = window.location.hash; window.setTimeout(scheduleRestore, 500); } scheduleRestore(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener("pageshow", scheduleRestore); window.addEventListener("popstate", () => window.setTimeout(scheduleRestore, 500)); window.addEventListener("hashchange", scheduleRestore); window.setInterval(scheduleRestore, 2000); window.setInterval(updateTimerToast, 1000); scheduleRestore(); if (currentTimer()) ensureTimerToast(); console.info("Mealie cook tools loaded"); })();