Files
bface/src/platform/menu.js
Amer Agovic aa872bdd6b Release 1.0.10 with subpath deployment and modal menu invokes.
Add app_base/router_base config, compat path helpers, and scoped service worker
registration so apps can mount under a URL prefix. Wire invoke_type modal through
a handler registry and open the notification center from the standard menu flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:44:32 -05:00

1365 lines
37 KiB
JavaScript

/**
* 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';
import { isDevelopment } from './env.js';
function menuDebug(...args) {
if (isDevelopment()) {
console.log(...args);
}
}
// 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) => {
return invokeRegisteredModal(menuItem, eventSource, event);
}
};
const modalHandlers = new Map();
/**
* Register a handler for invoke_type "modal" targets (e.g. notifications panel).
* @param {string} target - invoke_target value from the menu item
* @param {Function} handler - (menuItem, eventSource, event) => any
* @returns {Function} Unregister function
*/
export function registerModalHandler(target, handler) {
const key = String(target || '').trim();
if (!key || typeof handler !== 'function') {
return () => {};
}
modalHandlers.set(key, handler);
return () => {
modalHandlers.delete(key);
};
}
/**
* Resolve a registered modal handler by invoke_target.
* @param {string} target
* @returns {Function|null}
*/
export function resolveModalHandler(target) {
const key = String(target || '').trim();
return key ? modalHandlers.get(key) || null : null;
}
export function invokeRegisteredModal(menuItem, eventSource = null, event = null) {
if (!menuItem?.invoke_target) {
console.warn('[MenuItem] invoke_type "modal" requires invoke_target to be set');
return null;
}
const handler = resolveModalHandler(menuItem.invoke_target);
if (!handler) {
console.warn(`[MenuItem] No modal handler registered for "${menuItem.invoke_target}"`);
return null;
}
return handler(menuItem, eventSource, event);
}
/**
* 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<string, MenuItem>|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);
}
menuDebug('[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);
menuDebug(`[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) {
menuDebug(`[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();
}
menuDebug('[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<string, MenuItem>} 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;
}