(function () { const NAV_ENDPOINT = "/navigation/sidebar"; const SIDEBAR_SELECTOR = ".sidebar-nav"; const DATA_SOURCE_ATTR = "navigationSource"; const ROLE_ATTR = "navigationRoles"; const NAV_PREV_ID = "nav-prev"; const NAV_NEXT_ID = "nav-next"; const CACHE_KEY = "calminer:navigation:sidebar"; const CACHE_VERSION = 1; const CACHE_TTL_MS = 2 * 60 * 1000; function hasStorage() { try { return typeof window.localStorage !== "undefined"; } catch (error) { return false; } } function loadCacheRoot() { if (!hasStorage()) { return null; } let raw; try { raw = window.localStorage.getItem(CACHE_KEY); } catch (error) { return null; } if (!raw) { return { version: CACHE_VERSION, entries: {} }; } try { const parsed = JSON.parse(raw); if ( !parsed || typeof parsed !== "object" || parsed.version !== CACHE_VERSION || typeof parsed.entries !== "object" ) { return { version: CACHE_VERSION, entries: {} }; } return parsed; } catch (error) { clearCache(); return { version: CACHE_VERSION, entries: {} }; } } function persistCache(root) { if (!hasStorage()) { return; } try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(root)); } catch (error) { /* ignore storage write failures */ } } function clearCache() { if (!hasStorage()) { return; } try { window.localStorage.removeItem(CACHE_KEY); } catch (error) { /* ignore */ } } function normaliseRoles(roles) { if (!Array.isArray(roles)) { return []; } const seen = new Set(); const cleaned = []; for (const value of roles) { const role = typeof value === "string" ? value.trim() : ""; if (!role || seen.has(role)) { continue; } seen.add(role); cleaned.push(role); } cleaned.sort(); return cleaned; } function serialiseRoles(roles) { const cleaned = normaliseRoles(roles); if (cleaned.length === 0) { return "anonymous"; } return cleaned.join("|"); } function getCurrentRoles(navContainer) { const attr = navContainer.dataset[ROLE_ATTR]; if (!attr) { return null; } const roles = attr .split(",") .map((role) => role.trim()) .filter(Boolean); if (roles.length === 0) { return null; } return roles; } function readCache(rolesKey) { if (!rolesKey) { return null; } const root = loadCacheRoot(); if (!root || !root.entries || typeof root.entries !== "object") { return null; } const entry = root.entries[rolesKey]; if (!entry || !entry.payload) { return null; } const cachedAt = typeof entry.cachedAt === "number" ? entry.cachedAt : 0; const expired = Date.now() - cachedAt > CACHE_TTL_MS; return { payload: entry.payload, expired }; } function saveCache(rolesKey, payload) { if (!rolesKey || !payload) { return; } const root = loadCacheRoot(); if (!root) { return; } if (!root.entries || typeof root.entries !== "object") { root.entries = {}; } root.entries[rolesKey] = { cachedAt: Date.now(), payload, }; root.version = CACHE_VERSION; persistCache(root); } function onReady(callback) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback, { once: true }); } else { callback(); } } function isActivePath(pathname, matchPrefix) { if (!matchPrefix) { return false; } if (matchPrefix === "/") { return pathname === "/"; } return pathname.startsWith(matchPrefix); } function createAnchor({ href, label, matchPrefix, tooltip, isExternal, isActive, className, }) { const anchor = document.createElement("a"); anchor.href = href; anchor.className = className + (isActive ? " is-active" : ""); anchor.dataset.matchPrefix = matchPrefix || href; if (tooltip) { anchor.title = tooltip; } if (isExternal) { anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; anchor.classList.add("is-external"); } anchor.textContent = label; return anchor; } function buildLinkBlock(link, pathname) { if (!link || !link.href) { return null; } const matchPrefix = link.match_prefix || link.matchPrefix || link.href; const isActive = isActivePath(pathname, matchPrefix); const block = document.createElement("div"); block.className = "sidebar-link-block"; if (typeof link.id === "number") { block.dataset.linkId = String(link.id); } const anchor = createAnchor({ href: link.href, label: link.label, matchPrefix, tooltip: link.tooltip, isExternal: Boolean(link.is_external ?? link.isExternal), isActive, className: "sidebar-link", }); block.appendChild(anchor); const children = Array.isArray(link.children) ? link.children : []; if (children.length > 0) { const container = document.createElement("div"); container.className = "sidebar-sublinks"; for (const child of children) { if (!child || !child.href) { continue; } const childMatch = child.match_prefix || child.matchPrefix || child.href; const childActive = isActivePath(pathname, childMatch); const childAnchor = createAnchor({ href: child.href, label: child.label, matchPrefix: childMatch, tooltip: child.tooltip, isExternal: Boolean(child.is_external ?? child.isExternal), isActive: childActive, className: "sidebar-sublink", }); container.appendChild(childAnchor); } if (container.children.length > 0) { block.appendChild(container); } } return block; } function buildGroupSection(group, pathname) { if (!group) { return null; } const links = Array.isArray(group.links) ? group.links : []; if (links.length === 0) { return null; } const section = document.createElement("div"); section.className = "sidebar-section"; if (typeof group.id === "number") { section.dataset.groupId = String(group.id); } const label = document.createElement("div"); label.className = "sidebar-section-label"; label.textContent = group.label; section.appendChild(label); const linksContainer = document.createElement("div"); linksContainer.className = "sidebar-section-links"; for (const link of links) { const block = buildLinkBlock(link, pathname); if (block) { linksContainer.appendChild(block); } } if (linksContainer.children.length === 0) { return null; } section.appendChild(linksContainer); return section; } function buildEmptyState() { const section = document.createElement("div"); section.className = "sidebar-section sidebar-empty-state"; const label = document.createElement("div"); label.className = "sidebar-section-label"; label.textContent = "Navigation"; section.appendChild(label); const copyWrapper = document.createElement("div"); copyWrapper.className = "sidebar-section-links"; const copy = document.createElement("p"); copy.className = "sidebar-empty-copy"; copy.textContent = "Navigation is unavailable."; copyWrapper.appendChild(copy); section.appendChild(copyWrapper); return section; } function resolvePath(input) { if (!input) { return null; } try { return new URL(input, window.location.origin).pathname; } catch (error) { if (input.startsWith("/")) { return input; } return `/${input}`; } } function flattenNavigation(groups) { const sequence = []; for (const group of groups) { if (!group || !Array.isArray(group.links)) { continue; } for (const link of group.links) { if (!link || !link.href) { continue; } const isExternal = Boolean(link.is_external ?? link.isExternal); if (!isExternal) { sequence.push({ href: link.href, matchPrefix: link.match_prefix || link.matchPrefix || link.href, }); } const children = Array.isArray(link.children) ? link.children : []; for (const child of children) { if (!child || !child.href) { continue; } const childExternal = Boolean(child.is_external ?? child.isExternal); if (childExternal) { continue; } sequence.push({ href: child.href, matchPrefix: child.match_prefix || child.matchPrefix || child.href, }); } } } return sequence; } function configureChevronButtons(sequence) { const prevButton = document.getElementById(NAV_PREV_ID); const nextButton = document.getElementById(NAV_NEXT_ID); if (!prevButton || !nextButton) { return; } const pathname = window.location.pathname; const normalised = sequence .map((item) => ({ href: item.href, matchPrefix: item.matchPrefix, path: resolvePath(item.matchPrefix || item.href), })) .filter((item) => Boolean(item.path)); const currentIndex = normalised.findIndex((item) => isActivePath(pathname, item.matchPrefix || item.path) ); prevButton.disabled = true; prevButton.onclick = null; nextButton.disabled = true; nextButton.onclick = null; if (currentIndex === -1) { return; } if (currentIndex > 0) { const target = normalised[currentIndex - 1].href; prevButton.disabled = false; prevButton.onclick = () => { window.location.href = target; }; } if (currentIndex < normalised.length - 1) { const target = normalised[currentIndex + 1].href; nextButton.disabled = false; nextButton.onclick = () => { window.location.href = target; }; } } function renderSidebar(navContainer, payload) { const pathname = window.location.pathname; const groups = Array.isArray(payload?.groups) ? payload.groups : []; navContainer.replaceChildren(); const rendered = []; for (const group of groups) { const section = buildGroupSection(group, pathname); if (section) { rendered.push(section); } } if (rendered.length === 0) { navContainer.appendChild(buildEmptyState()); navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty"; delete navContainer.dataset[ROLE_ATTR]; configureChevronButtons([]); return; } for (const section of rendered) { navContainer.appendChild(section); } navContainer.dataset[DATA_SOURCE_ATTR] = "client"; const roles = Array.isArray(payload?.roles) ? payload.roles : []; if (roles.length > 0) { navContainer.dataset[ROLE_ATTR] = roles.join(","); } else { delete navContainer.dataset[ROLE_ATTR]; } configureChevronButtons(flattenNavigation(groups)); } async function hydrateSidebar(navContainer) { const roles = getCurrentRoles(navContainer); const rolesKey = roles ? serialiseRoles(roles) : null; const cached = readCache(rolesKey); if (cached && cached.payload) { renderSidebar(navContainer, cached.payload); if (!cached.expired) { return; } } try { const response = await fetch(NAV_ENDPOINT, { method: "GET", credentials: "include", headers: { Accept: "application/json", }, }); if (!response.ok) { if (!cached || !cached.payload) { configureChevronButtons([]); } if (response.status !== 401 && response.status !== 403) { console.warn( "Navigation sidebar hydration failed with status", response.status ); } return; } const payload = await response.json(); renderSidebar(navContainer, payload); const payloadRoles = Array.isArray(payload?.roles) ? payload.roles : roles || []; saveCache(serialiseRoles(payloadRoles), payload); } catch (error) { console.warn("Navigation sidebar hydration failed", error); if (!cached || !cached.payload) { configureChevronButtons([]); } } } onReady(() => { const navContainer = document.querySelector(SIDEBAR_SELECTOR); if (!navContainer) { configureChevronButtons([]); return; } hydrateSidebar(navContainer); }); })();