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>
1365 lines
37 KiB
JavaScript
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;
|
|
}
|