'use strict'; 'require baseclass'; 'require fs-menutree as tree'; 'require fs-prefs as prefs'; 'require fs-router as router'; 'require fs-widgets as widgets'; /* Find a page by typing its name instead of knowing which section owns it. A loaded router * carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears * in no menu list until you are already there; this indexes every node the dispatcher would * render, tabs included. * * It costs no request: the tree is the same ACL-filtered /admin/menu blob the chrome loaded * (fs-menutree), so the palette lists exactly the pages this session may open. The index is built * on the first open, not at init — a user who never searches pays nothing, and only a full load * can change the tree. * * Navigation is deliberately not a call into the router: every result is a real , so a * click bubbles to the router's own document-level handler and no copy of that decision lives * here. Enter synthesises the same click. */ /* ---- the index ---------------------------------------------------------- */ /* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node * that already has two segments, so 4 admits admin/
/// — one level * more than LuCI renders, so a deeper third-party node is still findable and the depth term in * search() ranks it last. */ const MAX_DEPTH = 4; let _index = null; /* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not * through getChildren(): on an alias node it returns the alias TARGET's children, which is right * for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones` * leaf, so all five of its tabs vanish from the index (78 nodes instead of 238). * * Order does not matter (search ranks by score), so only the two filters are reimplemented, not * the sort. */ function childrenOf(node) { const kids = node.children || {}; const out = []; for (const name in kids) { const c = kids[name]; if (!c || !c.satisfied || !c.title) continue; out.push({ name: name, node: c }); } return out; } function walk(node, segs, trail, out, depth) { childrenOf(node).forEach((entry) => { const child = entry.node; /* the chrome carries its own Logout (partials/logout.ut); indexing it would let a search * open a confirmation the user did not ask for */ if (depth === 1 && entry.name === 'logout') return; const title = _(child.title); const csegs = segs.concat([ entry.name ]); out.push({ segs: csegs, path: csegs.join('/'), title: title, trail: trail, depth: depth, /* Three haystacks, in this order of preference (see tokenScore()). The English * node name is indexed on purpose: a translated UI otherwise hides the page from * an admin who knows OpenWrt by its English docs. */ t: title.toLowerCase(), p: trail.join(' ').toLowerCase(), /* …minus the root segment, `admin`, which every node shares: indexed, every * substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */ n: csegs.slice(1).join(' ').toLowerCase() }); if (depth < MAX_DEPTH) walk(child, csegs, trail.concat([ title ]), out, depth + 1); }); } function buildIndex() { const root = tree.tree(); const out = []; if (!root) return out; /* the mode (admin) is a container, not a destination: start one level in */ childrenOf(root).forEach((mode) => { walk(mode.node, [ mode.name ], [], out, 1); }); return out; } /* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for * the life of the document, so the palette is exactly as fresh as the sidebar beside it. * Invalidating only this half would let the two disagree, which is worse than both being stale; * the reload a package install prompts for refreshes them together. */ function index() { if (!_index) _index = buildIndex(); return _index; } /* ---- extra sources ------------------------------------------------------- * * An optional package can add rows to the same list — one indexes the SECTION titles inside each * page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape * buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here, * or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`, * called when the row is chosen, and `key`, what the recents list stores it under when its `path` * is not its own (see keyOf). * * Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the * first gesture and most sessions never make it; a package that had to `require` it to register * would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function * onto `window.__fsSearchSources` costs the package nothing and names no one in either direction. * * `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session * — and the stamp below is what rebuilds the pool when it does. */ const _sources = []; let _pool = null, _stamp = -1; function globalSources() { return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : []; } function addSource(fn) { _sources.push(fn); _pool = null; } function refresh() { _pool = null; } function pool() { const all = _sources.concat(globalSources()); const stamp = all.length + (window.__fsSearchGen || 0); if (_pool && stamp === _stamp) return _pool; _stamp = stamp; _pool = all.reduce((rows, fn) => { try { return rows.concat(fn() || []); } catch (e) { console.error('footstrap: a search source threw', e); return rows; } }, index().slice()); return _pool; } /* ---- matching ----------------------------------------------------------- */ /* Every whitespace-separated token must hit something: a second word means AND. Deliberately not * a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the * ranking to decide everything, which reads as random. */ const HIT_NONE = 99; function tokenScore(e, tok) { if (e.t.startsWith(tok)) return 0; /* the title begins with it */ if (e.t.includes(tok)) return 1; /* somewhere in the title */ if (e.n.includes(tok)) return 2; /* the English path segment */ if (e.p.includes(tok)) return 3; /* an ancestor's title */ return HIT_NONE; } function search(q, limit) { const toks = q.toLowerCase().split(/\s+/).filter(Boolean); if (!toks.length) return []; const hits = []; for (const e of pool()) { let sum = 0; for (const tok of toks) { const s = tokenScore(e, tok); if (s === HIT_NONE) { sum = HIT_NONE; break; } sum += s; } if (sum !== HIT_NONE) hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */ } hits.sort((a, b) => a.score - b.score); return hits.slice(0, limit).map((h) => h.e); } /* ---- recently visited --------------------------------------------------- */ /* What the palette shows before anything is typed: an admin lives in three or four pages, so the * empty state is its most-used view. * * Only the KEY is stored, never the title — the title is resolved through the pool on every * render, so it follows the UI language and a row whose package went away drops out instead of * lingering as a dead row. A page's key is its menu path; a row from a source carries its own * `key`, because a section has no dispatcher node and therefore no path that is only its own — * the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own * heading. */ const RECENT_KEY = 'fs-recent'; const RECENT_MAX = 8; /* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */ function keyOf(e) { return e.key || e.path; } /* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any * more, and a palette that only loads when it is opened cannot be what records where the admin has * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the * corruption guard and the Array check; only the "these are keys" filter belongs here. */ function recentEntries() { const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); /* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a * section key resolved to nothing and the row silently vanished, so taking "Footstrap" left * only "System" in the list — the page path is all either row carries. */ const byKey = new Map(pool().map((e) => [ keyOf(e), e ])); return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX); } /* ---- the palette -------------------------------------------------------- */ const MAX_RESULTS = 20; /* Built on the first open and kept — the overlay, its listeners and the index survive for the life * of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched: * menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */ let _built = null; function build() { const btn = document.getElementById('fs-search-btn'); if (!btn) return null; /* the list's id, said three times below as an id, a class and aria-controls (measured: 17 B x3 * -> 26 B, 25 B saved) */ const ID_SEARCH_LIST = 'fs-search-list'; /* the attribute name toggled below and stated once more as its own starting value (measured: * 15 B x3 -> 27 B, 18 B saved) */ const ATTR_EXPANDED = 'aria-expanded'; const input = E('input', { 'type': 'text', 'class': 'fs-search-input', 'role': 'combobox', 'aria-controls': ID_SEARCH_LIST, [ATTR_EXPANDED]: 'true', 'aria-autocomplete': 'list', 'aria-label': _('Search pages', 'footstrap'), 'placeholder': _('Search pages…', 'footstrap'), 'autocomplete': 'off', 'autocapitalize': 'off', 'spellcheck': 'false' }); const list = E('div', { 'id': ID_SEARCH_LIST, 'class': ID_SEARCH_LIST, 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') }); const note = E('div', { 'class': 'fs-search-note' }); const ico = E('span', { 'class': 'fs-search-ico' }); ico.innerHTML = widgets.svgIcon(''); const box = E('div', { 'class': 'fs-search-box' }, [ E('div', { 'class': 'fs-search-row' }, [ ico, input ]), /* the note captions the rows below it ("Recently visited") and doubles as the empty * state, so it belongs above the list in both readings */ note, list ]); /* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to * , outside the