@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/README.en.md +17 -10
- package/README.md +66 -56
- package/lib/client.js +280 -70
- package/lib/index.js +44 -5
- package/package.json +3 -2
- package/scripts/ai.test.mjs +352 -13
- package/scripts/compat.mjs +269 -0
- package/scripts/log.test.mjs +52 -2
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.
|
|
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.
|
|
391
|
-
*
|
|
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
|
-
|
|
409
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 !==
|
|
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
|
-
|
|
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
|
|
1468
|
-
|
|
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
|
|
1539
|
-
rec.inner
|
|
1540
|
-
rec.text
|
|
1541
|
-
|
|
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:
|
|
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) =>
|
|
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
|
|
1822
|
-
if (
|
|
1823
|
-
await
|
|
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
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
2597
|
+
.map((o) => ({ item: o, kind: 'option', via: localRelatedKeyword(o, o.hay, tokens, expandedKeywords) }))
|
|
2400
2598
|
.filter((r) => r.via !== null),
|
|
2401
|
-
]
|
|
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 &&
|
|
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
|
-
|
|
269
|
+
fd = io.openSync(path, 'r')
|
|
270
|
+
const buffer = Buffer.alloc(COUNT_CHUNK_BYTES)
|
|
268
271
|
let lines = 0
|
|
269
|
-
|
|
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
|
|
386
|
-
if (text ===
|
|
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: '
|
|
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.
|
|
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"
|