/** * Menu Library * Central place for modules to register frontend-facing menu items * Hierarchical menu structure with root items for each menu directory */ import { normalizeRightsInput } from '../security/model/rights.js'; import { evaluateAuthRequirements } from '../security/runtime/access-rules.js'; import { getProvider } from './storage.js'; // Menu root structure: Map of root directory IDs to MenuItem instances const menuRoot = new Map(); const menuPreferenceStorage = getProvider('kv', 'menu.ui'); const MENU_PREFERENCES_KEY = 'menu.preferences'; const DEFAULT_MENU_PREFERENCES = { visibility: {}, expanded: {} }; // Menu change listeners for reactive updates const menuChangeListeners = new Set(); let menuVersion = 0; let menuPreferences = { visibility: {}, expanded: {} }; let menuPreferencesLoaded = false; let menuPreferencePersistTimer = null; // ============================================================================ // Invoke Handlers // Global handlers for different invoke types (to be set by App) // ============================================================================ /** * Global invoke handlers for menu item actions * These should be set early in the application (e.g., in App.jsx) * to work together with routing and navigation */ export const InvokeHandlers = { /** * Navigate to an internal page/route * @param {MenuItem} menuItem - The menu item being invoked * @param {HTMLElement|null} eventSource - Source element that triggered the action * @param {Event|null} event - Original event object */ goToPage: (menuItem, eventSource = null, event = null) => { // To be implemented in App.jsx }, /** * Navigate to an external URL * @param {MenuItem} menuItem - The menu item being invoked * @param {HTMLElement|null} eventSource - Source element that triggered the action * @param {Event|null} event - Original event object */ goToOutside: (menuItem, eventSource = null, event = null) => { // To be implemented in App.jsx }, /** * Open a modal dialog * @param {MenuItem} menuItem - The menu item being invoked * @param {HTMLElement|null} eventSource - Source element that triggered the action * @param {Event|null} event - Original event object */ goToModal: (menuItem, eventSource = null, event = null) => { // To be implemented in App.jsx } }; /** * Subscribe to menu changes * @param {Function} listener - Callback function called when menu changes * @returns {Function} Unsubscribe function */ export function subscribeToMenuChanges(listener) { menuChangeListeners.add(listener); return () => { menuChangeListeners.delete(listener); }; } /** * Notify all listeners of menu changes */ function notifyMenuChange() { menuVersion++; menuChangeListeners.forEach(listener => { try { listener(menuVersion); } catch (error) { console.warn('[Menu] Listener error:', error); } }); } /** * Get current menu version (for use as dependency in React hooks) * @returns {number} */ export function getMenuVersion() { return menuVersion; } export function getMenuPreferencesSnapshot() { return cloneMenuPreferences(); } export function getMenuItemExpandedPreference(path, defaultValue = false) { if (!path) { return defaultValue; } if (Object.prototype.hasOwnProperty.call(menuPreferences.expanded, path)) { return menuPreferences.expanded[path] !== false; } return defaultValue; } /** * MenuItem Class * Well-defined structure for menu items with hierarchical children */ export class MenuItem { /** * @param {Object} config - Menu item configuration * @param {string} [config.id] - Unique identifier within parent (auto-generated from path if not provided) * @param {string} config.label - Display text (required) * @param {string} [config.tag='link'] - HTML tag or component type * @param {string} [config.icon] - Icon identifier/URL * @param {Function} [config.invoke] - Custom invoke function: invoke(menuItem, eventSource, event) * @param {string} [config.invoke_type] - Invoke type: 'page', 'external', 'modal', or 'action' * @param {string} [config.invoke_target] - Target route/URL (used with invoke_type) * @param {Function} [config.handler] - Legacy: Click/action handler (deprecated, use invoke instead) * @param {Map|Object} [config.items] - Nested menu items as Map (id -> MenuItem) * @param {string} [config.path] - Full path (auto-set by menu system) * @param {boolean} [config.is_active=true] - Whether menu item is active/enabled * @param {string} [config.style='both'] - Display style: 'both', 'label_only', or 'icon_only' * @param {string} [config.with_permits=''] - Comma-delimited permissions required (e.g., "read,write") * @param {Object} [config.auth] - Auth visibility requirements * @param {Object} [config.attrs={}] - Additional attributes */ constructor(config = {}) { // Required properties if (!config.label && !config.id) { throw new Error('MenuItem requires at least "label" or "id"'); } // Core properties this.id = config.id || null; this.label = config.label || ''; this.tag = config.tag || 'link'; this.icon = config.icon || null; this.path = config.path || null; // Invoke properties (new system) this.invoke = config.invoke || null; this.invoke_type = config.invoke_type || null; this.invoke_target = config.invoke_target || null; // Legacy handler support (for backward compatibility) if (config.handler && !config.invoke) { console.warn('[MenuItem] "handler" is deprecated, use "invoke" instead'); this.invoke = config.handler; } // Items as Map (id -> MenuItem) this.items = new Map(); if (config.items) { if (config.items instanceof Map) { // Copy from existing Map for (const [id, item] of config.items.entries()) { this.items.set(id, item instanceof MenuItem ? item : new MenuItem(item)); } } else if (Array.isArray(config.items)) { // Legacy support: convert array to Map config.items.forEach(item => { const menuItem = item instanceof MenuItem ? item : new MenuItem(item); const itemId = menuItem.id || menuItem.label?.toLowerCase().replace(/\s+/g, '-') || `item-${this.items.size}`; this.items.set(itemId, menuItem); }); } else if (typeof config.items === 'object') { // Object with id keys for (const [id, item] of Object.entries(config.items)) { this.items.set(id, item instanceof MenuItem ? item : new MenuItem(item)); } } } // New properties this.is_active = config.is_active !== undefined ? config.is_active : true; this.is_visible = config.is_visible !== undefined ? config.is_visible : true; this.style = config.style || 'both'; this.with_permits = config.with_permits || ''; this.tags = Array.isArray(config.tags) ? [...config.tags] : (typeof config.tags === 'string' ? [config.tags] : []); this.visible_when_permitted = config.visible_when_permitted || null; this.auth = config.auth || null; // Validate style const validStyles = ['both', 'label_only', 'icon_only']; if (!validStyles.includes(this.style)) { console.warn(`[MenuItem] Invalid style "${this.style}", defaulting to "both"`); this.style = 'both'; } // Additional attributes this.attrs = config.attrs || {}; // Validate invoke_type if (this.invoke_type) { const validTypes = ['page', 'external', 'modal', 'action']; if (!validTypes.includes(this.invoke_type)) { console.warn(`[MenuItem] Invalid invoke_type "${this.invoke_type}", must be one of: ${validTypes.join(', ')}`); this.invoke_type = null; } } // Validate invoke_target is set when invoke_type requires it if (this.invoke_type && ['page', 'external', 'modal'].includes(this.invoke_type) && !this.invoke_target) { console.warn(`[MenuItem] invoke_type "${this.invoke_type}" requires invoke_target to be set`); } // Copy any other properties Object.keys(config).forEach(key => { if (!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'handler', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'auth', 'attrs'].includes(key)) { this[key] = config[key]; } }); } /** * Convert to plain object (for serialization) * @returns {Object} Plain object representation */ toObject() { const itemsObj = {}; for (const [id, item] of this.items.entries()) { itemsObj[id] = item instanceof MenuItem ? item.toObject() : item; } return { id: this.id, label: this.label, tag: this.tag, icon: this.icon, invoke: this.invoke, invoke_type: this.invoke_type, invoke_target: this.invoke_target, items: itemsObj, path: this.path, is_active: this.is_active, is_visible: this.is_visible, style: this.style, with_permits: this.with_permits, tags: [...this.tags], visible_when_permitted: this.visible_when_permitted ? { ...this.visible_when_permitted } : null, auth: this.auth ? { ...this.auth } : null, ...this.attrs, // Include any additional properties ...Object.fromEntries( Object.entries(this).filter(([key]) => !['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'auth', 'attrs'].includes(key) ) ) }; } /** * Check if menu item has items * @returns {boolean} */ hasItems() { return this.items.size > 0; } /** * Check if menu item is actionable (has invoke function OR invoke_type + invoke_target) * @returns {boolean} */ isActionable() { if (!this.is_active) return false; // Has custom invoke function if (typeof this.invoke === 'function') return true; // Has invoke_type with invoke_target (automatic routing) if (this.invoke_type && this.invoke_target) { if (['page', 'external', 'modal'].includes(this.invoke_type)) { return true; } } return false; } /** * Check if menu item requires permissions * @returns {boolean} */ requiresPermits() { return this.with_permits && this.with_permits.trim().length > 0; } /** * Get required permissions as array * @returns {string[]} Array of permission strings */ getPermits() { if (!this.requiresPermits()) return []; return this.with_permits.split(',').map(p => p.trim()).filter(p => p.length > 0); } /** * Check generic app-driven visibility. * @returns {boolean} */ isVisible() { return this.is_visible !== false; } /** * Check whether this item matches a tag. * @param {string} tag * @returns {boolean} */ matchesTag(tag) { if (!tag) return false; return this.tags.includes(tag); } /** * Set visibility for this item. * @param {boolean} yesOrNo * @returns {MenuItem} */ setVisible(yesOrNo) { this.is_visible = yesOrNo !== false; return this; } /** * Set visibility on items matching the given tag. * @param {boolean} yesOrNo * @param {string} tag * @param {Object} [options] * @param {boolean} [options.recursive=true] * @returns {number} Number of affected items */ setVisibleByTag(yesOrNo, tag, options = {}) { const { recursive = true } = options; let changed = 0; if (this.matchesTag(tag)) { this.setVisible(yesOrNo); changed += 1; } if (recursive) { for (const child of this.items.values()) { changed += child.setVisibleByTag(yesOrNo, tag, options); } } return changed; } /** * Internal helper for permission visibility metadata. * @returns {{resource_path: string|null, rights: number}|null} */ getPermissionVisibilityRule() { if (this.visible_when_permitted && typeof this.visible_when_permitted === 'object') { return { resource_path: this.visible_when_permitted.resource_path || this.invoke_target || this.path || null, rights: normalizeRightsInput(this.visible_when_permitted.rights || 0) }; } if (this.requiresPermits()) { return { resource_path: this.invoke_target || this.path || null, rights: normalizeRightsInput(this.getPermits()) }; } return null; } /** * Check security-driven visibility. * @param {Object} security * @returns {boolean} */ isPermitted(security = null) { if (this.auth && typeof this.auth === 'object') { const authResult = evaluateAuthRequirements(security, this.auth); if (!authResult.allowed) { return false; } } const rule = this.getPermissionVisibilityRule(); if (!rule) { return true; } if (!security || security.enabled === false) { return true; } if (typeof security.isPermitted === 'function') { return security.isPermitted(rule.rights, rule.resource_path, { menuItem: this }) !== false; } return false; } /** * Combined renderability check. * @param {Object} security * @returns {boolean} */ isRenderable(security = null) { return this.isVisible() && this.isPermitted(security); } /** * Execute the invoke function or automatic handler based on invoke_type * @param {HTMLElement|null} eventSource - Source element that triggered the action * @param {Event|null} event - Original event object * @returns {any} Return value from invoke function or handler */ execute(eventSource = null, event = null) { if (!this.isActionable()) { return null; } // If custom invoke function is provided, use it if (typeof this.invoke === 'function') { return this.invoke(this, eventSource, event); } // Otherwise, use automatic routing based on invoke_type if (this.invoke_type && this.invoke_target) { switch (this.invoke_type) { case 'page': if (typeof InvokeHandlers.goToPage === 'function') { return InvokeHandlers.goToPage(this, eventSource, event); } console.warn('[MenuItem] InvokeHandlers.goToPage is not set'); break; case 'external': if (typeof InvokeHandlers.goToOutside === 'function') { return InvokeHandlers.goToOutside(this, eventSource, event); } console.warn('[MenuItem] InvokeHandlers.goToOutside is not set'); break; case 'modal': if (typeof InvokeHandlers.goToModal === 'function') { return InvokeHandlers.goToModal(this, eventSource, event); } console.warn('[MenuItem] InvokeHandlers.goToModal is not set'); break; case 'action': // Action type requires custom invoke function console.warn('[MenuItem] invoke_type "action" requires a custom invoke function'); break; default: console.warn(`[MenuItem] Unknown invoke_type: ${this.invoke_type}`); } } return null; } /** * Create a copy of this menu item * @returns {MenuItem} */ clone() { return new MenuItem(this.toObject()); } /** * Get item by ID * @param {string} id - Item ID * @returns {MenuItem|null} */ getItem(id) { return this.items.get(id) || null; } /** * Add item to this menu item * @param {string} id - Item ID (must be unique within this parent) * @param {MenuItem} item - Menu item to add * @returns {boolean} Success */ addItem(id, item) { if (!id) { console.warn('[MenuItem] Cannot add item without ID'); return false; } if (this.items.has(id)) { console.warn(`[MenuItem] Item with ID "${id}" already exists, overwriting`); } const menuItem = item instanceof MenuItem ? item : new MenuItem(item); menuItem.id = id; this.items.set(id, menuItem); return true; } /** * Remove item by ID * @param {string} id - Item ID * @returns {boolean} Success */ removeItem(id) { return this.items.delete(id); } } /** * Factory function to create MenuItem instances * @param {Object} config - Menu item configuration * @returns {MenuItem} */ export function createMenuItem(config) { return new MenuItem(config); } /** * Validate menu item structure * @param {any} item - Item to validate * @returns {boolean} */ export function isValidMenuItem(item) { if (!item) return false; if (item instanceof MenuItem) return true; if (typeof item === 'object' && (item.label || item.id)) return true; return false; } /** * Menu Directory Constants * Helper methods to generate prefixed paths for different menu areas */ export const MENU_DIRS = { /** * Primary menu directory * @param {string} relativePath - Relative path (e.g., "home" or "dashboard/stats") * @returns {string} Full path starting with "/primary/" */ PRIMARY: (relativePath = '') => { const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; return `/primary/${cleanPath}`.replace(/\/+$/, ''); // Remove trailing slashes }, /** * Secondary menu directory * @param {string} relativePath - Relative path * @returns {string} Full path starting with "/secondary/" */ SECONDARY: (relativePath = '') => { const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; return `/secondary/${cleanPath}`.replace(/\/+$/, ''); }, /** * Settings menu directory * @param {string} relativePath - Relative path * @returns {string} Full path starting with "/settings/" */ SETTINGS: (relativePath = '') => { const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; return `/settings/${cleanPath}`.replace(/\/+$/, ''); }, /** * Personal menu directory * @param {string} relativePath - Relative path * @returns {string} Full path starting with "/personal/" */ PERSONAL: (relativePath = '') => { const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; return `/personal/${cleanPath}`.replace(/\/+$/, ''); } }; /** * Initialize menu root with 4 top-level directory items */ function initializeMenuRoot() { if (menuRoot.size > 0) { return; // Already initialized } // Create root items for each directory const rootItems = [ { id: 'primary', label: 'Primary', path: '/primary' }, { id: 'secondary', label: 'Secondary', path: '/secondary' }, { id: 'settings', label: 'Settings', path: '/settings' }, { id: 'personal', label: 'Personal', path: '/personal' } ]; for (const rootConfig of rootItems) { const rootItem = new MenuItem(rootConfig); menuRoot.set(rootConfig.id, rootItem); } console.log('[Menu] Initialized menu root with 4 directory items'); } // Initialize on module load initializeMenuRoot(); /** * Normalize path to ensure it starts with "/" * @param {string} path - Path to normalize * @returns {string} Normalized path */ function normalizePath(path) { if (!path) return '/'; // Ensure path starts with "/" return path.startsWith('/') ? path : `/${path}`; } /** * Split path into parts (excluding empty parts) * @param {string} path - Path to split * @returns {string[]} Array of path parts */ function splitPath(path) { const normalized = normalizePath(path); return normalized.split('/').filter(part => part.length > 0); } /** * Get or create menu item at path, creating parent items as needed * @param {string} path - Full path (e.g., "/primary/itemA/itemB") * @returns {MenuItem|null} Menu item at path, or null if path is invalid */ function getOrCreateMenuItemAtPath(path) { const parts = splitPath(path); if (parts.length === 0) { return null; } // First part must be a root directory const rootId = parts[0]; if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { console.warn(`[Menu] Invalid root directory: ${rootId}`); return null; } // Get or create root item let current = menuRoot.get(rootId); if (!current) { current = new MenuItem({ id: rootId, label: rootId.charAt(0).toUpperCase() + rootId.slice(1), path: `/${rootId}` }); menuRoot.set(rootId, current); } // Navigate/create through path parts for (let i = 1; i < parts.length; i++) { const part = parts[i]; let child = current.getItem(part); if (!child) { // Create new item child = new MenuItem({ id: part, label: part.charAt(0).toUpperCase() + part.slice(1).replace(/-/g, ' '), path: `/${parts.slice(0, i + 1).join('/')}` }); current.addItem(part, child); } current = child; } return current; } /** * Get menu item at path (does not create) * @param {string} path - Full path * @returns {MenuItem|null} Menu item at path, or null if not found */ function getMenuItemAtPath(path) { const parts = splitPath(path); if (parts.length === 0) { return null; } // First part must be a root directory const rootId = parts[0]; if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { return null; } // Get root item let current = menuRoot.get(rootId); if (!current) { return null; } // Navigate through path parts for (let i = 1; i < parts.length; i++) { const part = parts[i]; current = current.getItem(part); if (!current) { return null; } } return current; } /** * Publish a menu item at the given path * Creates parent items as needed and registers item hierarchically * @param {string} path - Path like "/primary/home" or "/primary/itemA/itemB" (must start with "/") * @param {MenuItem|Object} item - Menu item (MenuItem instance or plain object) */ export function publishMenuItem(path, item) { if (!path || !item) { console.warn('[Menu] Invalid path or item'); return false; } // Validate menu item if (!isValidMenuItem(item)) { console.warn('[Menu] Invalid menu item structure', item); return false; } // Normalize path to ensure it starts with "/" const normalizedPath = normalizePath(path); const parts = splitPath(normalizedPath); if (parts.length === 0) { console.warn('[Menu] Cannot publish to root path'); return false; } // Get or create parent item const itemId = parts[parts.length - 1]; const parentPath = parts.length > 1 ? `/${parts.slice(0, -1).join('/')}` : `/${parts[0]}`; let parent; if (parts.length === 1) { // Publishing directly to root directory const rootId = parts[0]; if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { console.warn(`[Menu] Invalid root directory: ${rootId}`); return false; } parent = menuRoot.get(rootId); if (!parent) { parent = new MenuItem({ id: rootId, label: rootId.charAt(0).toUpperCase() + rootId.slice(1), path: `/${rootId}` }); menuRoot.set(rootId, parent); } } else { // Publishing to nested path - get or create parent parent = getOrCreateMenuItemAtPath(parentPath); if (!parent) { console.warn(`[Menu] Failed to get/create parent at: ${parentPath}`); return false; } } // Convert to MenuItem if it's a plain object let menuItem; if (item instanceof MenuItem) { menuItem = item; } else { menuItem = new MenuItem(item); } // Set ID from path if not provided (last token in path) if (!menuItem.id) { menuItem.id = itemId; } else if (menuItem.id !== itemId) { // Warn if provided ID doesn't match path (this is expected when publishing same item under different paths) console.warn(`[Menu] Item ID "${menuItem.id}" doesn't match path token "${itemId}" (expected when publishing same item under different paths). Using "${itemId}" from path.`); menuItem.id = itemId; } // Set full path on item menuItem.path = normalizedPath; if (menuPreferencesLoaded) { applyStoredVisibilityPreference(menuItem); } // Add to parent's items Map parent.addItem(itemId, menuItem); console.log(`[Menu] Published item at: ${normalizedPath} (ID: ${itemId})`); // Notify listeners of menu change notifyMenuChange(); return true; } /** * Retract (remove) a menu item by path * @param {string} path - Path to remove (will be normalized to start with "/") */ export function retractMenuItem(path) { if (!path) { console.warn('[Menu] Invalid path'); return false; } // Normalize path const normalizedPath = normalizePath(path); const parts = splitPath(normalizedPath); if (parts.length === 0) { console.warn('[Menu] Cannot retract root path'); return false; } // Get parent item const itemId = parts[parts.length - 1]; let parent; if (parts.length === 1) { // Retracting root directory item const rootId = parts[0]; if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { console.warn(`[Menu] Invalid root directory: ${rootId}`); return false; } parent = menuRoot.get(rootId); if (!parent) { console.warn(`[Menu] Root item not found: ${rootId}`); return false; } // Cannot remove root items console.warn(`[Menu] Cannot remove root directory item: ${rootId}`); return false; } else { // Retracting nested item const parentPath = `/${parts.slice(0, -1).join('/')}`; parent = getMenuItemAtPath(parentPath); if (!parent) { console.warn(`[Menu] Parent not found at: ${parentPath}`); return false; } } const removed = parent.removeItem(itemId); if (removed) { console.log(`[Menu] Retracted item at: ${normalizedPath}`); // Notify listeners of menu change notifyMenuChange(); } else { console.warn(`[Menu] Item not found at: ${normalizedPath}`); } return removed; } /** * Query menu items by path prefix * Returns all items under the given path (including nested items) * @param {string} pathPrefix - Path prefix to match (e.g., "/primary" matches all items under primary) * If empty string, returns all root items * @returns {Array} List of matching menu items (as plain objects) */ export function queryMenuItems(pathPrefix = '') { const results = []; if (!pathPrefix || pathPrefix === '/') { // Return all root items for (const rootItem of menuRoot.values()) { results.push(rootItem.toObject()); } return results; } // Normalize prefix const normalizedPrefix = normalizePath(pathPrefix); const prefixParts = splitPath(normalizedPrefix); // Get root item if (prefixParts.length === 0) { return results; } const rootId = prefixParts[0]; if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { return results; } const rootItem = menuRoot.get(rootId); if (!rootItem) { return results; } // If querying root, return root item if (prefixParts.length === 1) { results.push(rootItem.toObject()); // Also include all nested items function collectItems(item) { for (const child of item.items.values()) { results.push(child.toObject()); if (child.hasItems()) { collectItems(child); } } } collectItems(rootItem); } else { // Navigate to specific path and collect items let current = rootItem; for (let i = 1; i < prefixParts.length; i++) { current = current.getItem(prefixParts[i]); if (!current) { return results; // Path not found } } // Add current item and all its children results.push(current.toObject()); function collectItems(item) { for (const child of item.items.values()) { results.push(child.toObject()); if (child.hasItems()) { collectItems(child); } } } collectItems(current); } // Sort by path for consistent ordering results.sort((a, b) => (a.path || '').localeCompare(b.path || '')); return results; } /** * Get a specific menu item by path * @param {string} path - Exact path (will be normalized to start with "/") * @returns {Object|null} Menu item (as plain object) or null */ export function getMenuItem(path) { if (!path) return null; const item = getMenuItemAtPath(path); if (!item) return null; return item.toObject(); } /** * Get all menu items (flattened structure) * @returns {Array} All menu items (as plain objects) */ export function getAllMenuItems() { const results = []; function collectItems(item) { results.push(item.toObject()); for (const child of item.items.values()) { collectItems(child); } } for (const rootItem of menuRoot.values()) { collectItems(rootItem); } return results; } /** * Clear all menu items (except root items) */ export function clearMenu() { for (const rootItem of menuRoot.values()) { rootItem.items.clear(); } console.log('[Menu] Cleared all items (root items preserved)'); } export async function restoreMenuPreferences() { try { const storedPreferences = await menuPreferenceStorage?.get(MENU_PREFERENCES_KEY, DEFAULT_MENU_PREFERENCES); menuPreferences = normalizeMenuPreferences(storedPreferences || DEFAULT_MENU_PREFERENCES); } catch (error) { console.warn('[Menu] Failed to restore menu preferences:', error); menuPreferences = normalizeMenuPreferences(DEFAULT_MENU_PREFERENCES); } menuPreferencesLoaded = true; applyStoredVisibilityPreferencesToRegisteredItems(); notifyMenuChange(); return cloneMenuPreferences(); } export function setMenuItemExpandedPreference(path, yesOrNo) { if (!path) { return false; } setStoredExpandedPreference(path, yesOrNo); notifyMenuChange(); return true; } export async function clearMenuPreferences(options = {}) { const { visibility = true, expanded = true } = options; if (visibility) { menuPreferences.visibility = {}; for (const rootItem of menuRoot.values()) { walkMenuItems(rootItem, (item) => { item.setVisible(true); }); } } if (expanded) { menuPreferences.expanded = {}; } if (menuPreferencePersistTimer) { clearTimeout(menuPreferencePersistTimer); menuPreferencePersistTimer = null; } await persistMenuPreferencesNow(); notifyMenuChange(); return cloneMenuPreferences(); } export async function clearMenuItemPreferences(path, options = {}) { if (!path) { return false; } const { visibility = true, expanded = true } = options; if (visibility) { delete menuPreferences.visibility[path]; const item = getMenuItemAtPath(path); if (item) { item.setVisible(true); } } if (expanded) { delete menuPreferences.expanded[path]; } if (menuPreferencePersistTimer) { clearTimeout(menuPreferencePersistTimer); menuPreferencePersistTimer = null; } await persistMenuPreferencesNow(); notifyMenuChange(); return true; } function normalizePreferenceMap(value) { if (!value || typeof value !== 'object') { return {}; } return Object.fromEntries( Object.entries(value) .filter(([path]) => typeof path === 'string' && path.length > 0) .map(([path, flag]) => [path, flag !== false]) ); } function normalizeMenuPreferences(value = {}) { return { visibility: normalizePreferenceMap(value.visibility), expanded: normalizePreferenceMap(value.expanded) }; } function cloneMenuPreferences() { return { visibility: { ...menuPreferences.visibility }, expanded: { ...menuPreferences.expanded } }; } async function persistMenuPreferencesNow() { if (!menuPreferenceStorage) { return; } try { await menuPreferenceStorage.set(MENU_PREFERENCES_KEY, cloneMenuPreferences()); } catch (error) { console.warn('[Menu] Failed to persist menu preferences:', error); } } function scheduleMenuPreferencePersist() { if (menuPreferencePersistTimer) { clearTimeout(menuPreferencePersistTimer); } menuPreferencePersistTimer = setTimeout(() => { menuPreferencePersistTimer = null; persistMenuPreferencesNow(); }, 160); } function setStoredVisibilityPreference(path, yesOrNo) { if (!path) { return; } menuPreferences.visibility[path] = yesOrNo !== false; scheduleMenuPreferencePersist(); } function setStoredExpandedPreference(path, yesOrNo) { if (!path) { return; } menuPreferences.expanded[path] = yesOrNo !== false; scheduleMenuPreferencePersist(); } function applyStoredVisibilityPreference(item) { if (!item?.path) { return; } if (Object.prototype.hasOwnProperty.call(menuPreferences.visibility, item.path)) { item.setVisible(menuPreferences.visibility[item.path]); } } function walkMenuItems(startItem, visitor) { if (!startItem) { return; } visitor(startItem); for (const child of startItem.items.values()) { walkMenuItems(child, visitor); } } function applyStoredVisibilityPreferencesToRegisteredItems() { for (const rootItem of menuRoot.values()) { walkMenuItems(rootItem, (item) => { applyStoredVisibilityPreference(item); }); } } /** * Get menu structure as nested tree * @returns {Object} Nested menu structure with root items */ export function getMenuTree() { const tree = {}; for (const [rootId, rootItem] of menuRoot.entries()) { tree[rootId] = rootItem.toObject(); } return tree; } /** * Get root menu item by directory ID * @param {string} rootId - Root directory ID ('primary', 'secondary', 'settings', 'personal') * @returns {MenuItem|null} */ export function getRootItem(rootId) { return menuRoot.get(rootId) || null; } /** * Get all root items * @returns {Map} Map of root items */ export function getRootItems() { return new Map(menuRoot); } /** * Set visibility for one menu item by exact path. * @param {string} path * @param {boolean} yesOrNo * @returns {boolean} */ export function setMenuItemVisibility(path, yesOrNo) { if (!path) { return false; } const item = getMenuItemAtPath(path); if (!item) { return false; } item.setVisible(yesOrNo); setStoredVisibilityPreference(item.path, yesOrNo); notifyMenuChange(); return true; } /** * Set visibility for all items matching a tag. * @param {string} tag * @param {boolean} yesOrNo * @param {Object} [options] * @param {boolean} [options.recursive=true] * @returns {number} */ export function setMenuItemsVisibilityByTag(tag, yesOrNo, options = {}) { if (!tag) { return 0; } let changed = 0; for (const rootItem of menuRoot.values()) { changed += rootItem.setVisibleByTag(yesOrNo, tag, options); } if (changed > 0) { for (const rootItem of menuRoot.values()) { walkMenuItems(rootItem, (item) => { if (item.matchesTag(tag)) { setStoredVisibilityPreference(item.path, yesOrNo); } }); } notifyMenuChange(); } return changed; } /** * Set visibility for all items under a path prefix. * @param {string} pathPrefix * @param {boolean} yesOrNo * @param {Object} [options] * @param {boolean} [options.includeRoot=true] * @param {boolean} [options.recursive=false] * @returns {number} */ export function setMenuItemsVisibilityUnderPath(pathPrefix, yesOrNo, options = {}) { const { includeRoot = true, recursive = false } = options; if (!pathPrefix) { return 0; } const rootItem = getMenuItemAtPath(pathPrefix); if (!rootItem) { return 0; } let changed = 0; if (includeRoot) { rootItem.setVisible(yesOrNo); setStoredVisibilityPreference(rootItem.path, yesOrNo); changed += 1; } if (recursive) { for (const child of rootItem.items.values()) { walkMenuItems(child, (item) => { item.setVisible(yesOrNo); setStoredVisibilityPreference(item.path, yesOrNo); changed += 1; }); } } if (changed > 0) { notifyMenuChange(); } return changed; }