import { getLocalizedString } from "@olenbetong/appframe-core"; import type { AppMenuCategory } from "./types.js"; /** A single row from the articles data object backing the app list. */ export type AppMenuArticle = { ArticleId: string; Title: string | null; MenuGroup: string | null; Path: string | null; IsFavorite?: boolean | null; }; /** Sentinel group key for apps that have no MenuGroup set. */ export const UNGROUPED = "\u0000ungrouped"; /** Sentinel group key for the virtual group holding the user's favorites. */ export const FAVORITES = "\u0000favorites"; /** Strip version suffixes (`my-app.0000012`) from an ArticleId. */ export function stripVersionSuffix(articleId: string): string { return articleId.replace(/\.\d+$/, ""); } export type AppEntry = { id: string; title: string; group: string; href: string; isFavorite: boolean; }; /** The category currently selected in the rail. `null` means "all applications". */ export type Selection = { type: "group"; key: string } | { type: "category"; id: string } | null; /** * Normalize the raw article rows into a deduplicated, sorted list of apps. * * `aviw_WebShared_Articles` contains one row per published article version, so * the same app can appear dozens of times. Deduplicating on the version-less * ArticleId guarantees a single entry per app even if the data object's where * clause is relaxed. */ export function toAppEntries(articles: readonly AppMenuArticle[]): AppEntry[] { let byId = new Map(); for (let article of articles) { let id = stripVersionSuffix(article.ArticleId); if (byId.has(id)) continue; byId.set(id, { id, title: article.Title?.trim() || id, group: article.MenuGroup?.trim() || UNGROUPED, href: `/${article.Path?.trim() || id}`, isFavorite: article.IsFavorite === true, }); } return [...byId.values()].sort((a, b) => a.title.localeCompare(b.title)); } /** * Group entries by group key. The favorites are a virtual group listed first, * and ungrouped apps are always listed last. */ export function groupEntries(entries: readonly AppEntry[]): [string, AppEntry[]][] { let map = new Map(); for (let entry of entries) { let list = map.get(entry.group); if (!list) { list = []; map.set(entry.group, list); } list.push(entry); } let groups = [...map.entries()].sort(([a], [b]) => { if (a === b) return 0; if (a === UNGROUPED) return 1; if (b === UNGROUPED) return -1; return a.localeCompare(b); }); let favorites = entries.filter((entry) => entry.isFavorite); return favorites.length > 0 ? [[FAVORITES, favorites], ...groups] : groups; } /** Human readable title for a group key, resolving the sentinel keys. */ export function groupLabel(group: string) { if (group === UNGROUPED) return getLocalizedString("Other"); if (group === FAVORITES) return getLocalizedString("Favorites"); return group; } /** Drop entries that don't match the search term, then empty groups. */ export function filterCategory(category: AppMenuCategory, term: string): AppMenuCategory { if (!term) return category; let groups = category.groups .map((group) => ({ ...group, items: group.items.filter((item) => item.label.toLowerCase().includes(term)) })) .filter((group) => group.items.length > 0); return { ...category, groups }; } export function countItems(category: AppMenuCategory) { return category.groups.reduce((total, group) => total + group.items.length, 0); } /** Split the categories into their rail sections, preserving the given order. */ export function toRailSections(categories: readonly AppMenuCategory[]): [string, AppMenuCategory[]][] { let sections = new Map(); for (let category of categories) { let key = category.section ?? ""; let list = sections.get(key); if (!list) { list = []; sections.set(key, list); } list.push(category); } return [...sections.entries()]; }