@objectivex666/dsh-settings-search 1.13.1 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'settings-search-v5'
32
32
  /** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
33
- const PKG_VERSION = '1.13.1'
33
+ const PKG_VERSION = '1.14.1'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
36
36
  const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
@@ -384,11 +384,13 @@ window.__ModuleLoader__.load({
384
384
  )
385
385
  const CJK_RE = /[\u4e00-\u9fff]/
386
386
  const ALNUM_RE = /[a-zA-Z0-9]/
387
+ const PINYIN_UNKNOWN = '\u0000'
387
388
 
388
389
  /**
389
390
  * Transliterate a label into full-pinyin and initial-letter keys for
390
- * association search. Returns null when any CJK character is outside
391
- * the built-in table (then only substring matching applies).
391
+ * association search. Characters outside the compact table become segment
392
+ * boundaries, so one unknown character does not disable every known pinyin
393
+ * segment in the same label.
392
394
  *
393
395
  * Results are memoized: the same labels and intent keywords are
394
396
  * re-transliterated on every keystroke's re-filter, and this CJK loop
@@ -405,8 +407,9 @@ window.__ModuleLoader__.load({
405
407
  if (CJK_RE.test(ch)) {
406
408
  const py = PINYIN.get(ch)
407
409
  if (py === undefined) {
408
- PINYIN_KEY_CACHE.set(text, null)
409
- return null
410
+ full += PINYIN_UNKNOWN
411
+ initials += PINYIN_UNKNOWN
412
+ continue
410
413
  }
411
414
  full += py
412
415
  initials += py[0]
@@ -416,7 +419,11 @@ window.__ModuleLoader__.load({
416
419
  initials += lower
417
420
  }
418
421
  }
419
- const keys = full === '' ? null : { full, initials }
422
+ const segments = full.split(PINYIN_UNKNOWN).filter(Boolean)
423
+ const initialSegments = initials.split(PINYIN_UNKNOWN).filter(Boolean)
424
+ const keys = segments.length === 0
425
+ ? null
426
+ : { full, initials, segments, initialSegments }
420
427
  PINYIN_KEY_CACHE.set(text, keys)
421
428
  return keys
422
429
  }
@@ -426,7 +433,7 @@ window.__ModuleLoader__.load({
426
433
  if (haystackLower.includes(token)) return 'text'
427
434
  if (keys !== null) {
428
435
  if (keys.full.includes(token)) return 'pinyin'
429
- if (keys.initials.startsWith(token)) return 'initials'
436
+ if (keys.initialSegments.some((segment) => segment.startsWith(token))) return 'initials'
430
437
  }
431
438
  return null
432
439
  }
@@ -500,9 +507,11 @@ window.__ModuleLoader__.load({
500
507
  if (cached !== undefined) return cached
501
508
  const hits = []
502
509
  for (const intent of INTENTS) {
503
- const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
510
+ const phraseHit = intent.phrase === tok ||
511
+ (tok.length >= 2 && (intent.phrase.startsWith(tok) || tok.includes(intent.phrase))) ||
504
512
  (intent.keys !== null &&
505
- (intent.keys.full.startsWith(tok) || intent.keys.initials === tok))
513
+ (intent.keys.initials === tok ||
514
+ (tok.length >= 2 && intent.keys.full.startsWith(tok))))
506
515
  if (phraseHit) {
507
516
  for (const kw of intent.keywords) {
508
517
  if (!hits.includes(kw)) hits.push(kw)
@@ -513,6 +522,47 @@ window.__ModuleLoader__.load({
513
522
  return hits
514
523
  }
515
524
 
525
+ /** Match every whitespace-separated query token against one setting. */
526
+ const directMatch = (item, haystackLower, tokens) =>
527
+ tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
528
+
529
+ const kwMatch = (_item, haystackLower, kw) => {
530
+ if (/^[\x00-\x7f]+$/.test(kw)) {
531
+ if (wordBoundaryRegex(kw).test(haystackLower)) return true
532
+ } else if (haystackLower.includes(kw)) return true
533
+ return false
534
+ }
535
+
536
+ /**
537
+ * Local intent expansion keeps the same AND semantics as direct search:
538
+ * every query token must match literally or through that token's own intent
539
+ * keywords. This stops an unrelated extra token from being ignored.
540
+ */
541
+ const localRelatedKeyword = (item, haystackLower, tokens, expandedKeywords) => {
542
+ if (directMatch(item, haystackLower, tokens)) return null
543
+ let via = null
544
+ for (const tok of tokens) {
545
+ if (tokenMatch(haystackLower, item.keys, tok) !== null) continue
546
+ let tokenVia = null
547
+ for (const kw of expandedKeywords.get(tok) ?? []) {
548
+ if (kwMatch(item, haystackLower, kw)) {
549
+ tokenVia = kw
550
+ break
551
+ }
552
+ }
553
+ if (tokenVia === null) return null
554
+ if (via === null) via = tokenVia
555
+ }
556
+ return via
557
+ }
558
+
559
+ const aiRelatedKeyword = (item, haystackLower, aiKeywords) => {
560
+ for (const kw of aiKeywords) {
561
+ if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
562
+ }
563
+ return null
564
+ }
565
+
516
566
  const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
517
567
  const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
518
568
  /**
@@ -713,6 +763,8 @@ window.__ModuleLoader__.load({
713
763
  return lines.join('\n')
714
764
  }
715
765
  const AI_CACHE = new Map()
766
+ /** Bound untrusted provider bodies before decoding/parsing them. */
767
+ const AI_RESPONSE_MAX_BYTES = 1024 * 1024
716
768
  const AI_STORAGE_KEY = 'dsh-settings-search:ai-enabled'
717
769
  const AI_PROVIDER_KEY = 'dsh-settings-search:ai-provider'
718
770
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
@@ -732,6 +784,63 @@ window.__ModuleLoader__.load({
732
784
  const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
733
785
  return Boolean(baseUrl && model && (provider === 'ollama' || apiKey))
734
786
  }
787
+ const aiBadResponse = (message) => {
788
+ const error = new Error(message)
789
+ error.name = 'AI_BAD_RESPONSE'
790
+ return error
791
+ }
792
+ /**
793
+ * Parse a provider response with a byte ceiling. A configured provider is
794
+ * still an untrusted network peer, so its response must not become an
795
+ * unbounded browser allocation merely because it is valid JSON.
796
+ */
797
+ const readAiJson = async (response) => {
798
+ const declared = Number(response.headers?.get?.('content-length'))
799
+ if (Number.isFinite(declared) && declared > AI_RESPONSE_MAX_BYTES) {
800
+ try { await response.body?.cancel?.() } catch {}
801
+ throw aiBadResponse('AI response too large')
802
+ }
803
+ const parse = (text) => {
804
+ try { return JSON.parse(text) } catch {
805
+ throw aiBadResponse('Invalid AI JSON response')
806
+ }
807
+ }
808
+ if (response.body !== null && typeof response.body?.getReader === 'function') {
809
+ const reader = response.body.getReader()
810
+ const chunks = []
811
+ let bytes = 0
812
+ try {
813
+ while (true) {
814
+ const { done, value } = await reader.read()
815
+ if (done) break
816
+ if (!ArrayBuffer.isView(value) || value.BYTES_PER_ELEMENT !== 1) {
817
+ throw aiBadResponse('Invalid AI response body')
818
+ }
819
+ bytes += value.byteLength
820
+ if (bytes > AI_RESPONSE_MAX_BYTES) {
821
+ try { await reader.cancel() } catch {}
822
+ throw aiBadResponse('AI response too large')
823
+ }
824
+ chunks.push(value)
825
+ }
826
+ } finally {
827
+ try { reader.releaseLock() } catch {}
828
+ }
829
+ const merged = new Uint8Array(bytes)
830
+ let offset = 0
831
+ for (const chunk of chunks) {
832
+ merged.set(chunk, offset)
833
+ offset += chunk.byteLength
834
+ }
835
+ return parse(new TextDecoder().decode(merged))
836
+ }
837
+ if (typeof response.text !== 'function') throw aiBadResponse('Invalid AI response')
838
+ const text = await response.text()
839
+ if (new TextEncoder().encode(text).byteLength > AI_RESPONSE_MAX_BYTES) {
840
+ throw aiBadResponse('AI response too large')
841
+ }
842
+ return parse(text)
843
+ }
735
844
  /** Session-only API key storage avoids durable browser storage exposure. */
736
845
  const readApiKey = () => {
737
846
  const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
@@ -786,7 +895,16 @@ window.__ModuleLoader__.load({
786
895
  const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
787
896
  const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
788
897
  const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
789
- const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v1'
898
+ /**
899
+ * Persisted index schema. Bumped whenever the harvest gains entries an old
900
+ * cache cannot contain (v3: the settings-search page, merged inline options,
901
+ * and controls revealed by plugin cards), so an upgrade rebuilds instead of
902
+ * serving a stale index.
903
+ */
904
+ const INDEX_STORAGE_VERSION = 3
905
+ const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v3'
906
+ /** Older schema keys, removed when the index is written again. */
907
+ const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v2', 'dsh-settings-search:index:v1']
790
908
  const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
791
909
  const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
792
910
  const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
@@ -950,10 +1068,7 @@ window.__ModuleLoader__.load({
950
1068
  error.status = response.status
951
1069
  throw error
952
1070
  }
953
- const data = await response.json().catch((error) => {
954
- if (error.name === 'AbortError') throw error
955
- return null
956
- })
1071
+ const data = await readAiJson(response)
957
1072
  if (data?.error) {
958
1073
  const error = new Error('Unexpected model list response')
959
1074
  error.name = 'AI_BAD_RESPONSE'
@@ -1040,10 +1155,7 @@ window.__ModuleLoader__.load({
1040
1155
  error.status = response.status
1041
1156
  throw error
1042
1157
  }
1043
- const data = await response.json().catch((error) => {
1044
- if (error.name === 'AbortError') throw error
1045
- return null
1046
- })
1158
+ const data = await readAiJson(response)
1047
1159
  if (data?.error || typeof aiResponseContent(provider, data) !== 'string') {
1048
1160
  const error = new Error('Unexpected AI response')
1049
1161
  error.name = 'AI_BAD_RESPONSE'
@@ -1341,7 +1453,7 @@ window.__ModuleLoader__.load({
1341
1453
  if (typeof localStorage === 'undefined') return null
1342
1454
  try {
1343
1455
  const raw = JSON.parse(localStorage.getItem(INDEX_STORAGE_KEY) ?? 'null')
1344
- if (!raw || raw.version !== 1 || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
1456
+ if (!raw || raw.version !== INDEX_STORAGE_VERSION || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
1345
1457
  return {
1346
1458
  fingerprint: typeof raw.fingerprint === 'string' ? raw.fingerprint : null,
1347
1459
  harvest: deserializeHarvest(raw.harvest),
@@ -1432,7 +1544,10 @@ window.__ModuleLoader__.load({
1432
1544
  const bestTitle = (row) => {
1433
1545
  let best = ''
1434
1546
  let bestLen = 1e9
1435
- const sels = ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', '[class*="name"]', '[class*="label"]', 'label', 'strong', 'b']
1547
+ // Class matching is case-insensitive (`i`): dsh's CSS-module hashes
1548
+ // keep the source camelCase (`RotMhW_groupTitle`), so a lower-case
1549
+ // substring test would miss every one of them.
1550
+ const sels = ['h1,h2,h3,h4,h5', '[class*="title" i]', '[class*="heading" i]', '[class*="name" i]', '[class*="label" i]', 'label', 'strong', 'b']
1436
1551
  for (const sel of sels) {
1437
1552
  for (const el of row.querySelectorAll(sel)) {
1438
1553
  if (el === row) continue
@@ -1453,6 +1568,27 @@ window.__ModuleLoader__.load({
1453
1568
  seen.add(clean)
1454
1569
  out.push({ label: clean, text })
1455
1570
  }
1571
+ /**
1572
+ * A page-level filter box is not a setting: the Plugins page renders
1573
+ * its own search input inside a `<label>`, whose text would otherwise
1574
+ * be indexed as an in-page option named after the box.
1575
+ */
1576
+ const isPageFilter = (el) => String(el.tagName ?? '').toUpperCase() === 'LABEL'
1577
+ && el.querySelector('input[type="search"]') !== null
1578
+ const depthOf = (el) => {
1579
+ let depth = 0
1580
+ for (let cur = el; cur && cur !== node; cur = cur.parentElement) depth += 1
1581
+ return depth
1582
+ }
1583
+ // Structural headings go first: the Plugins page can render dozens
1584
+ // of card titles before its next group title, and `out` is capped
1585
+ // below. Shallower matches are the page/group headings.
1586
+ const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,li,[role="listitem"],[class*="title" i],[class*="heading" i],[class*="label" i]')]
1587
+ .filter((el) => !isPageFilter(el))
1588
+ .sort((a, b) => depthOf(a) - depthOf(b))
1589
+ for (const h of heads) {
1590
+ push(bestTitle(h) || titleOf(h), h)
1591
+ }
1456
1592
  const ctrls = node.querySelectorAll('input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="radio"], button[aria-pressed], button[aria-checked]')
1457
1593
  for (const ctl of ctrls) {
1458
1594
  let row = ctl
@@ -1464,14 +1600,29 @@ window.__ModuleLoader__.load({
1464
1600
  }
1465
1601
  push(bestTitle(row), row)
1466
1602
  }
1467
- const heads = node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title"],[class*="heading"],[class*="label"],[class*="name"]')
1468
- for (const h of heads) push(bestTitle(h) || titleOf(h), h)
1469
- return out.slice(0, 24)
1603
+ for (const h of node.querySelectorAll('[class*="name" i]')) push(bestTitle(h) || titleOf(h), h)
1604
+ return out.slice(0, 64)
1470
1605
  } catch {
1471
1606
  return []
1472
1607
  }
1473
1608
  }
1474
1609
 
1610
+ /**
1611
+ * Keep options discovered while visiting other tabs/cards and merge any
1612
+ * controls revealed by a later DOM pass. Replacing the list would either
1613
+ * forget previously visited tabs or fail to add a freshly expanded card.
1614
+ */
1615
+ const mergeInnerOptions = (previous, incoming) => {
1616
+ const merged = []
1617
+ const seen = new Set()
1618
+ for (const option of [...(Array.isArray(incoming) ? incoming : []), ...(Array.isArray(previous) ? previous : [])]) {
1619
+ if (!option || typeof option.label !== 'string' || option.label === '' || seen.has(option.label)) continue
1620
+ seen.add(option.label)
1621
+ merged.push(option)
1622
+ }
1623
+ return merged
1624
+ }
1625
+
1475
1626
 
1476
1627
  const runHarvest = () => {
1477
1628
  let grew = false
@@ -1535,10 +1686,17 @@ window.__ModuleLoader__.load({
1535
1686
  if (!rec) {
1536
1687
  map.set(active.id, { title: active.label, idx: 0, text, inner })
1537
1688
  grew = true
1538
- } else if ((rec.inner?.length ?? 0) === 0 && inner.length > 0) {
1539
- rec.inner = inner
1540
- rec.text = text
1541
- grew = true
1689
+ } else {
1690
+ const mergedInner = mergeInnerOptions(rec.inner, inner)
1691
+ const nextText = text.length > (rec.text?.length ?? 0) ? text : rec.text
1692
+ if (JSON.stringify(mergedInner) !== JSON.stringify(rec.inner ?? []) ||
1693
+ nextText !== rec.text ||
1694
+ rec.title !== active.label) {
1695
+ rec.title = active.label
1696
+ rec.inner = mergedInner
1697
+ rec.text = nextText
1698
+ grew = true
1699
+ }
1542
1700
  }
1543
1701
  }
1544
1702
  }
@@ -1553,7 +1711,7 @@ window.__ModuleLoader__.load({
1553
1711
  if (text === '' || text.length > 200) return ''
1554
1712
  return trimDescription(text)
1555
1713
  }
1556
- for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
1714
+ for (const sel of ['h1,h2,h3,h4,h5', '[class*="title" i]', '[class*="heading" i]', 'label', 'p']) {
1557
1715
  const hit = node.querySelector(sel)
1558
1716
  if (hit) { const text = pick(hit); if (text !== '') return text }
1559
1717
  }
@@ -1565,7 +1723,6 @@ window.__ModuleLoader__.load({
1565
1723
  }
1566
1724
 
1567
1725
  const readSections = () => sortEntries(entriesOf('settings.section'))
1568
- .filter((e) => e.options?.id !== 'settings-search')
1569
1726
  .map((e) => ({
1570
1727
  kind: 'page',
1571
1728
  id: String(e.options?.id ?? ''),
@@ -1710,11 +1867,14 @@ window.__ModuleLoader__.load({
1710
1867
  if (typeof localStorage === 'undefined') return false
1711
1868
  try {
1712
1869
  localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
1713
- version: 1,
1870
+ version: INDEX_STORAGE_VERSION,
1714
1871
  fingerprint,
1715
1872
  updatedAt: Date.now(),
1716
1873
  harvest: serializeHarvest(harvest),
1717
1874
  }))
1875
+ for (const key of LEGACY_INDEX_STORAGE_KEYS) {
1876
+ try { localStorage.removeItem(key) } catch {}
1877
+ }
1718
1878
  return true
1719
1879
  } catch {
1720
1880
  return false
@@ -1767,6 +1927,40 @@ window.__ModuleLoader__.load({
1767
1927
  return true
1768
1928
  }
1769
1929
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1930
+ const settingSectionRoot = () => {
1931
+ const root = panelRoot()
1932
+ if (root === null) return null
1933
+ return root.querySelector('[data-slot="settings.section"]')
1934
+ }
1935
+ /**
1936
+ * DSH plugin settings are often rendered inside collapsed disclosure cards.
1937
+ * Their controls do not exist in the DOM until the card is expanded, so the
1938
+ * indexer must reveal them before harvesting.
1939
+ */
1940
+ const collapsedSettingCards = () => {
1941
+ const section = settingSectionRoot()
1942
+ if (section === null) return []
1943
+ return [...section.querySelectorAll('button[aria-expanded="false"]')].filter((button) => {
1944
+ const label = `${button.getAttribute?.('aria-label') ?? ''} ${button.textContent ?? ''}`
1945
+ return /展开|expand/i.test(label)
1946
+ })
1947
+ }
1948
+ const expandSettingCards = async () => {
1949
+ const buttons = collapsedSettingCards()
1950
+ for (const button of buttons) {
1951
+ button.click()
1952
+ await delay(90)
1953
+ }
1954
+ return buttons
1955
+ }
1956
+ const restoreSettingCards = async (buttons) => {
1957
+ for (const button of [...buttons].reverse()) {
1958
+ if (button?.isConnected !== false && button?.getAttribute?.('aria-expanded') === 'true') {
1959
+ button.click()
1960
+ await delay(60)
1961
+ }
1962
+ }
1963
+ }
1770
1964
  /** Label of the currently-active first-level settings nav item. */
1771
1965
  const activeSectionLabel = () => {
1772
1966
  const root = panelRoot()
@@ -1797,7 +1991,9 @@ window.__ModuleLoader__.load({
1797
1991
  for (const sec of sections.slice(0, 40)) {
1798
1992
  if (!clickNavButton(sec.label)) continue
1799
1993
  await delay(180)
1994
+ const expanded = await expandSettingCards()
1800
1995
  model.runHarvest()
1996
+ await restoreSettingCards(expanded)
1801
1997
  visited += 1
1802
1998
  }
1803
1999
  if (clickNavButton(original)) {
@@ -1814,20 +2010,44 @@ window.__ModuleLoader__.load({
1814
2010
  }
1815
2011
 
1816
2012
  function createNavigator(ctx) {
2013
+ const waitForActiveSection = async (label) => {
2014
+ for (let attempt = 0; attempt < 5; attempt += 1) {
2015
+ if (activeSectionLabel() === label) return true
2016
+ await delay(80)
2017
+ }
2018
+ return false
2019
+ }
1817
2020
  /** Jump to a page result; falls back to showing a manual hint. */
1818
- const jumpToPage = async (item) => clickNavButton(item.label)
2021
+ const jumpToPage = async (item) => {
2022
+ if (!clickNavButton(item.label)) return false
2023
+ return waitForActiveSection(String(item.label ?? '').trim())
2024
+ }
1819
2025
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
1820
2026
  const jumpToOption = async (item) => {
1821
- const opened = clickNavButton(item.pageLabel ?? item.parentLabel)
1822
- if (opened !== true) { flashOptionRow(item); return false }
1823
- await delay(160)
2027
+ const pageLabel = String(item.pageLabel ?? item.parentLabel ?? '').trim()
2028
+ if (!clickNavButton(pageLabel)) return false
2029
+ if (!await waitForActiveSection(pageLabel)) return false
1824
2030
  const label = item.label
1825
2031
  const buttons = panelButtons()
1826
2032
  const tab = buttons.find((b) =>
1827
2033
  b.getAttribute('role') === 'tab' && (b.textContent ?? '').trim() === label)
1828
2034
  if (tab) { tab.click(); await delay(120) }
1829
- flashOptionRow(item)
1830
- return opened
2035
+ const waitForRow = async () => {
2036
+ for (let attempt = 0; attempt < 4; attempt += 1) {
2037
+ if (flashOptionRow(item)) return true
2038
+ await delay(80)
2039
+ }
2040
+ return false
2041
+ }
2042
+ if (await waitForRow()) return true
2043
+ // Plugin settings can live inside a collapsed disclosure card. Reveal
2044
+ // cards one at a time until the indexed row exists, then flash it.
2045
+ for (const button of collapsedSettingCards()) {
2046
+ button.click()
2047
+ await delay(110)
2048
+ if (await waitForRow()) return true
2049
+ }
2050
+ return false
1831
2051
  }
1832
2052
  /** Highlight the rendered row card for one indexed option. */
1833
2053
  const flashOptionRow = (item) => {
@@ -1862,13 +2082,16 @@ window.__ModuleLoader__.load({
1862
2082
  target = card
1863
2083
  }
1864
2084
  }
1865
- if (!target) return
2085
+ if (!target) return false
1866
2086
  target.scrollIntoView({ block: 'nearest' })
1867
2087
  target.classList.remove('sss-flash')
1868
2088
  void target.offsetWidth // restart animation if re-flashed
1869
2089
  target.classList.add('sss-flash')
1870
2090
  setTimeout(() => target.classList.remove('sss-flash'), 1900)
1871
- } catch { /* visual nicety only */ }
2091
+ return true
2092
+ } catch {
2093
+ return false
2094
+ }
1872
2095
  }
1873
2096
  void ctx
1874
2097
  return { jumpToPage, jumpToOption }
@@ -2354,29 +2577,6 @@ window.__ModuleLoader__.load({
2354
2577
  const sections = model.readSections()
2355
2578
  const deeps = q !== '' ? model.readDeep() : []
2356
2579
  const expandedKeywords = new Map(tokens.map((tok) => [tok, intentKeywords(tok)]))
2357
- const directMatch = (item, haystackLower) =>
2358
- tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
2359
- const kwMatch = (item, haystackLower, kw) => {
2360
- if (/^[\x00-\x7f]+$/.test(kw)) {
2361
- if (wordBoundaryRegex(kw).test(haystackLower)) return true
2362
- } else if (haystackLower.includes(kw)) return true
2363
- if (item.keys === null) return false
2364
- const kwKeys = pinyinKeys(kw)
2365
- if (kwKeys === null) return false
2366
- return item.keys.full.includes(kwKeys.full) || item.keys.initials.startsWith(kwKeys.initials)
2367
- }
2368
- const relatedKeyword = (item, haystackLower) => {
2369
- if (directMatch(item, haystackLower)) return null
2370
- for (const tok of tokens) {
2371
- for (const kw of expandedKeywords.get(tok) ?? []) {
2372
- if (kwMatch(item, haystackLower, kw)) return kw
2373
- }
2374
- }
2375
- for (const kw of aiKeywords) {
2376
- if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
2377
- }
2378
- return null
2379
- }
2380
2580
  const mappedSections = q === '' ? [] : sections.map((s) => ({
2381
2581
  ...s,
2382
2582
  keys: pinyinKeys(s.label),
@@ -2387,24 +2587,34 @@ window.__ModuleLoader__.load({
2387
2587
  keys: pinyinKeys(`${o.label} ${o.parentLabel}`),
2388
2588
  hay: `${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase(),
2389
2589
  }))
2390
- const shownPages = mappedSections.filter((s) => directMatch(s, s.hay))
2391
- const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay))
2392
- const noLocalMatch = shownPages.length === 0 && shownOptions.length === 0 &&
2393
- tokens.every((tok) => (expandedKeywords.get(tok) ?? []).length === 0)
2394
- const related = [
2590
+ const shownPages = mappedSections.filter((s) => directMatch(s, s.hay, tokens))
2591
+ const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay, tokens))
2592
+ const intentRelated = [
2395
2593
  ...mappedSections
2396
- .map((s) => ({ item: s, kind: 'page', via: relatedKeyword(s, s.hay) }))
2594
+ .map((s) => ({ item: s, kind: 'page', via: localRelatedKeyword(s, s.hay, tokens, expandedKeywords) }))
2397
2595
  .filter((r) => r.via !== null),
2398
2596
  ...mappedOptions
2399
- .map((o) => ({ item: o, kind: 'option', via: relatedKeyword(o, o.hay) }))
2597
+ .map((o) => ({ item: o, kind: 'option', via: localRelatedKeyword(o, o.hay, tokens, expandedKeywords) }))
2400
2598
  .filter((r) => r.via !== null),
2401
- ].slice(0, 8)
2599
+ ]
2600
+ const localResultsEmpty = shownPages.length === 0 && shownOptions.length === 0 && intentRelated.length === 0
2601
+ const aiRelated = localResultsEmpty
2602
+ ? [
2603
+ ...mappedSections
2604
+ .map((s) => ({ item: s, kind: 'page', via: aiRelatedKeyword(s, s.hay, aiKeywords) }))
2605
+ .filter((r) => r.via !== null),
2606
+ ...mappedOptions
2607
+ .map((o) => ({ item: o, kind: 'option', via: aiRelatedKeyword(o, o.hay, aiKeywords) }))
2608
+ .filter((r) => r.via !== null),
2609
+ ]
2610
+ : []
2611
+ const related = [...intentRelated, ...aiRelated].slice(0, 8)
2402
2612
  const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0 && related.length === 0
2403
2613
  const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
2404
2614
 
2405
2615
  const cfg = aiConfig()
2406
2616
  const aiConfigured = aiConfigReady(cfg)
2407
- const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
2617
+ const aiReady = aiEnabled && aiConfigured && q.length >= 2 && localResultsEmpty
2408
2618
  React.useEffect(() => {
2409
2619
  setAiKeywords([])
2410
2620
  setAiStatus('idle')
package/lib/index.js CHANGED
@@ -60,6 +60,7 @@ const TAIL_BYTES = 256 * 1024
60
60
  const MAX_LINE_LENGTH = 1000
61
61
  const MAX_LINES_PER_REQUEST = 200
62
62
  const MAX_REQUEST_BYTES = 256 * 1024
63
+ const COUNT_CHUNK_BYTES = 64 * 1024
63
64
  /** How long a folder launcher may run before it counts as launched. */
64
65
  const LAUNCH_WATCH_MS = 1500
65
66
 
@@ -262,14 +263,27 @@ export function createLogStore(options = {}) {
262
263
  }
263
264
  }
264
265
  const countLines = (path) => {
266
+ let fd
265
267
  try {
266
268
  if (!existsSync(path)) return 0
267
- const text = readFileSync(path, 'utf8')
269
+ fd = io.openSync(path, 'r')
270
+ const buffer = Buffer.alloc(COUNT_CHUNK_BYTES)
268
271
  let lines = 0
269
- for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) lines += 1
272
+ let position = 0
273
+ while (true) {
274
+ const readBytes = io.readSync(fd, buffer, 0, buffer.length, position)
275
+ if (!Number.isFinite(readBytes) || readBytes <= 0) break
276
+ const filled = Math.min(readBytes, buffer.length)
277
+ for (let i = 0; i < filled; i += 1) if (buffer[i] === 10) lines += 1
278
+ position += filled
279
+ }
270
280
  return lines
271
281
  } catch {
272
282
  return 0
283
+ } finally {
284
+ if (fd !== undefined) {
285
+ try { io.closeSync(fd) } catch {}
286
+ }
273
287
  }
274
288
  }
275
289
  // The host is a file's only writer, so its entry count is tracked in memory
@@ -379,11 +393,36 @@ export function createLogStore(options = {}) {
379
393
  }
380
394
  }
381
395
 
396
+ /** Read one request body without buffering past the route's size budget. */
397
+ const readLimitedBody = async (request) => {
398
+ if (request.body === null) return ''
399
+ const reader = request.body.getReader()
400
+ const chunks = []
401
+ let bytes = 0
402
+ try {
403
+ while (true) {
404
+ const { done, value } = await reader.read()
405
+ if (done) return Buffer.concat(chunks, bytes).toString('utf8')
406
+ if (!(value instanceof Uint8Array)) return null
407
+ bytes += value.byteLength
408
+ if (bytes > MAX_REQUEST_BYTES) {
409
+ try { await reader.cancel() } catch {}
410
+ return null
411
+ }
412
+ chunks.push(value)
413
+ }
414
+ } catch {
415
+ return null
416
+ } finally {
417
+ try { reader.releaseLock() } catch {}
418
+ }
419
+ }
420
+
382
421
  /** Parse one JSON request body within the route's own size budget. */
383
422
  const readJsonBody = async (request) => {
384
423
  try {
385
- const text = await request.text()
386
- if (text === '' || text.length > MAX_REQUEST_BYTES) return null
424
+ const text = await readLimitedBody(request)
425
+ if (text === null || text === '') return null
387
426
  const parsed = JSON.parse(text)
388
427
  return parsed !== null && typeof parsed === 'object' ? parsed : null
389
428
  } catch {
@@ -405,7 +444,7 @@ export function createLogRoute(store) {
405
444
  return {
406
445
  path: ROUTE_PATH,
407
446
  methods: ['GET', 'POST'],
408
- requestBody: 'buffered',
447
+ requestBody: 'streaming',
409
448
  fetch: async (request) => {
410
449
  try {
411
450
  if (request.method === 'GET') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.13.1",
3
+ "version": "1.14.1",
4
4
  "description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -56,7 +56,8 @@
56
56
  "node": ">=18"
57
57
  },
58
58
  "scripts": {
59
- "test": "node scripts/check.mjs && node --test scripts/ai.test.mjs scripts/log.test.mjs"
59
+ "test": "node scripts/check.mjs && node --test scripts/ai.test.mjs scripts/log.test.mjs",
60
+ "compat": "node scripts/compat.mjs"
60
61
  },
61
62
  "publishConfig": {
62
63
  "access": "public"