dsh-plugin-look-up 0.1.1 → 0.2.0

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.md CHANGED
@@ -1,86 +1,96 @@
1
1
  # dsh-plugin-look-up
2
2
 
3
- macOS-style **Look Up** inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) chat: select a word in any conversation, click the small **Look Up / 查词** chip, and read the definition in a card anchored to the selection.
3
+ macOS **Look Up** inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) chat: select a word in any message, and read the **complete Dictionary.app entry** in a card anchored to the selection — senses, examples, grammar labels, etymology, and the dictionary's own typography.
4
4
 
5
- The definitions come from the **dictionaries you already have in Dictionary.app** — the same local, offline data macOS Look Up reads. No network request, no API key, no accessibility permission.
5
+ Everything comes from the dictionaries you already have installed in Dictionary.app. No network, no API key, no accessibility permission.
6
6
 
7
- ![version](https://img.shields.io/npm/v/dsh-plugin-look-up) ![license](https://img.shields.io/npm/l/dsh-plugin-look-up)
7
+ ![Full macOS Dictionary entry](https://raw.githubusercontent.com/huanglianqi/dsh-plugin-look-up/main/docs/preview-english.jpg)
8
8
 
9
- ## What it looks like
9
+ ## What you get
10
10
 
11
- ```
12
- 选中 “serendipity” ──► [ 🔍 查词 ] ──► ┌─────────────────────────────┐
13
- │ serendipity ✕ │
14
- │ ˌsɛr(ə)nˈdɪpɪti │
15
- │ noun formal │
16
- │ ① uncountable (luck) … │
17
- │ ② countable … │
18
- ├─────────────────────────────┤
19
- │ macOS 系统词典 · 本地查询 │
20
- │ 复制 在词典中打开 │
21
- └─────────────────────────────┘
22
- ```
11
+ - **Full entries, not a summary.** The card renders the same HTML entry and the same `DefaultStyle.css` macOS Dictionary uses, so examples and secondary senses are not flattened away.
12
+ - **Multiple dictionaries.** A word that appears in several local dictionaries gets tabs (for example the bilingual 牛津英汉汉英词典 and New Oxford American Dictionary). The system's default pick comes first when its headword matches the query; for an English word that collides with a Chinese pinyin entry, the English entry is moved up.
13
+ - **Offline and local.** Definitions are read from local dictionary bundles. The only process boundary is a local helper executable.
14
+ - **Theme aware.** The entry is rendered in a sandboxed iframe with `color-scheme` following the DSH light/dark theme; the card itself uses the DSH theme tokens.
15
+
16
+ | Bilingual entry | Chinese entry (light) |
17
+ | --- | --- |
18
+ | ![Bilingual entry](https://raw.githubusercontent.com/huanglianqi/dsh-plugin-look-up/main/docs/preview-bilingual.jpg) | ![Chinese entry](https://raw.githubusercontent.com/huanglianqi/dsh-plugin-look-up/main/docs/preview-chinese.jpg) |
23
19
 
24
20
  ## Requirements
25
21
 
26
22
  - macOS 11 or newer.
27
- - DSH Desktop (or the DSH Web GUI served on the same Mac).
28
- - At least one dictionary installed in **Dictionary.app** (Oxford, 现代汉语词典, 新华字典, 牛津英汉汉英词典 … all work). Check with Dictionary.app → Settings → Dictionaries.
23
+ - DSH Desktop, or the DSH Web GUI served on the same Mac.
24
+ - At least one dictionary installed in **Dictionary.app** (Oxford, 现代汉语词典, 新华字典, 牛津英汉汉英词典 … all work).
29
25
 
30
26
  ## Install
31
27
 
32
- From the DSH plugin CLI:
33
-
34
28
  ```bash
35
- dsh plugin --profile <profile> add dsh-plugin-look-up
29
+ dsh plugin --profile <profile> add dsh-plugin-look-up@0.2.0
36
30
  ```
37
31
 
38
- Or install the bundle manually in DSH: **Settings → Plugins → add** and give the package name `dsh-plugin-look-up`, then enable the **Look Up** row.
32
+ Or install the bundle in DSH: **Settings → Plugins → add** `dsh-plugin-look-up@0.2.0`, enable the **Look Up** row, and restart DSH once so the new Host and Client modules load.
39
33
 
40
- The package ships a small signed universal helper (`bin/dsh-lookup-dict`, arm64 + x86_64). Nothing is compiled on install.
34
+ The package ships a signed universal helper (`bin/dsh-lookup-dict`, arm64 + x86_64). Nothing is compiled on install.
41
35
 
42
36
  ## Use
43
37
 
44
- 1. Select a word or a short phrase in a chat message.
45
- 2. Click the **查词 / Look Up** chip that appears next to the selection.
46
- 3. Read the definition; **Escape**, an outside click, or scrolling closes the card.
47
- 4. `复制` copies the word; `在词典中打开` opens the full entry in Dictionary.app through the `dict:` URL scheme.
48
-
49
- The card follows the DSH theme (light/dark, Dream Skin tokens) and the active UI language (中文 / English).
38
+ 1. Select a word or short phrase in a chat message.
39
+ 2. Click the **查词 / Look Up** chip next to the selection.
40
+ 3. Switch dictionaries with the tabs above the entry.
41
+ 4. `Escape`, an outside click, or scrolling closes the card. `复制单词` copies the word; `在词典中打开` opens the full entry in Dictionary.app.
50
42
 
51
43
  ## How it works
52
44
 
53
- | Layer | Responsibility |
45
+ | File | Responsibility |
54
46
  | --- | --- |
55
- | `client.js` | Reads the current selection, places the chip/card on `shell.overlay`, renders definitions. |
56
- | `index.js` | Host routes under `/plugin/look-up/*`: loopback-only, bounded JSON body, in-memory cache. |
57
- | `bin/dsh-lookup-dict` | Objective-C helper calling `DCSCopyTextDefinition` from DictionaryServices — the same API behind Look Up. |
47
+ | `client.js` | Selection pill, dictionary tabs, sandboxed iframe, theme detection, dismissal. |
48
+ | `index.js` | Loopback-only Host routes, cache, and a fan-out to one helper per dictionary. |
49
+ | `bin/dsh-lookup-dict` | One DictionaryServices `DCSCopyDefinitionMarkup` call, plus the dictionary's `DefaultStyle.css`. |
58
50
 
59
- Only the selected word is passed to the helper. The helper reads local dictionary data and prints JSON. There is no telemetry, no network call, and the route rejects non-loopback hosts.
51
+ The markup API is a private DictionaryServices entry point. It is **not stable when several dictionaries are queried in one process** (some streamed or auxiliary bundles fault inside the framework), so the helper performs exactly one markup call per process and the Host runs a few helpers in parallel. It also takes a trailing `CFURLRef *` out-parameter that returns the dictionary's stylesheet; leaving that uninitialized makes the framework write through garbage.
52
+
53
+ Only dictionaries that expose a local bundle URL are queried. The default entry is always included; for Latin words the Host additionally asks the bilingual Oxford and the two Oxford English dictionaries, and for Chinese words the modern-Chinese and bilingual dictionaries.
60
54
 
61
55
  ### Why not the native Look Up panel?
62
56
 
63
- The system definition panel belongs to the Electron shell: DSH runs its Host in an Electron `utilityProcess` without `webContents`, and the renderer preload exposes a fixed action whitelist, so a plugin cannot call `webContents.showDefinitionForSelection()`. A native helper can *display* the panel, but it steals keyboard focus and dismisses itself when the user clicks back into DSH — a poor fit for a chat overlay. This plugin instead reproduces the content and the interaction inside the conversation, using the same dictionaries.
57
+ The system panel belongs to the Electron shell: DSH runs its Host in an Electron `utilityProcess` without `webContents`, and the renderer preload exposes a fixed action whitelist, so a plugin cannot call `webContents.showDefinitionForSelection()`. A native helper can display the panel, but it steals keyboard focus and dismisses itself when the user clicks back into DSH — a poor fit for a chat overlay. The card reproduces the native content and stylesheet inside the conversation instead.
58
+
59
+ ## Privacy
60
+
61
+ The selected word is passed to a local helper executable. The helper reads local dictionary bundles and prints JSON. There is no telemetry and no network call; the routes reject non-loopback hosts.
62
+
63
+ ## Limits
64
+
65
+ - macOS only.
66
+ - Only dictionaries installed and enabled in Dictionary.app are used; streamed/partially downloaded dictionaries are skipped.
67
+ - Audio pronunciation buttons and cross-reference links in the entry HTML are inert; images that point into the dictionary bundle are not proxied.
68
+ - A word with no entry in any local dictionary shows “no entry”.
64
69
 
65
70
  ## Development
66
71
 
67
72
  ```bash
68
73
  npm run build:helper # rebuild + re-sign bin/dsh-lookup-dict
74
+ npm test # Host route tests (real dictionary lookups)
69
75
  npm run check # syntax-check the two entry points
70
76
  ```
71
77
 
72
- Source of the helper: [`native/dsh-lookup-dict.m`](native/dsh-lookup-dict.m). The ad-hoc signature is applied by the build script; no Developer ID is required for local execution.
73
-
74
78
  ## 中文说明
75
79
 
76
- 在 DSH 对话里选中一个单词,点击旁边出现的「查词」小胶囊,就在原位弹出释义卡片。词条来自本机「词典」App 里已安装的词典(牛津、现代汉语词典、新华字典等),与 macOS 的 Look Up 同源,**完全离线**,不联网、不需要 API key、不需要辅助功能权限。
80
+ 在 DSH 对话里选中单词,点击「查词」,直接在原位展开 **完整的 macOS 系统词典词条**:义项、例句、词性、词源,排版和「词典」App 本体一致。
77
81
 
78
- - 系统要求:macOS 11+,DSH Desktop 或同一台 Mac 上的 DSH Web GUI。
79
- - 安装:`dsh plugin --profile <profile> add dsh-plugin-look-up`。
80
- - 关闭:Esc、点击卡片外、滚动页面。
81
- - 复制 / 在词典中打开(走 `dict:` URL scheme)。
82
+ - 词条来自本机「词典」App 已安装的本地词典;同一个词命中多本词典时,卡片顶部会出现词典标签页。
83
+ - 默认优先显示系统默认词典;英文词如果误命中中文拼音词条(例如 `run`),会把英文词典排到前面。
84
+ - 完全离线,不联网、不需要 API key、不需要辅助功能权限。
85
+ - Esc、点击卡片外、滚动页面关闭;支持「复制单词」和「在词典中打开」。
86
+
87
+ 安装:
88
+
89
+ ```bash
90
+ dsh plugin --profile <profile> add dsh-plugin-look-up@0.2.0
91
+ ```
82
92
 
83
- 已知边界:Electron 的系统释义面板无法被第三方插件直接调用(Host 在 utilityProcess 里,渲染层 preload 只暴露固定动作白名单),所以这里用同一个系统词典数据在对话内复刻了交互。
93
+ 已知边界:原生 Look Up 面板无法被第三方插件直接调用(Host 在 Electron utilityProcess 里,渲染层 preload 只暴露固定动作白名单),所以用同一份系统词典数据和词典自带样式在对话内复刻;词典里的发音按钮和交叉引用链接不可交互。
84
94
 
85
95
  ## License
86
96
 
Binary file
package/client.js CHANGED
@@ -18,7 +18,7 @@ window.__ModuleLoader__.load({
18
18
  const LOOKUP_URL = '/plugin/look-up/lookup'
19
19
  const OPEN_URL = '/plugin/look-up/open'
20
20
  const MAX_WORD_LENGTH = 64
21
- const CARD_WIDTH = 380
21
+ const CARD_WIDTH = 420
22
22
 
23
23
  /** Runtime copy; the package locale files carry only the plugin metadata. */
24
24
  const STRINGS = {
@@ -121,9 +121,9 @@ window.__ModuleLoader__.load({
121
121
  }
122
122
  .dlu-head {
123
123
  display: flex;
124
- align-items: flex-start;
125
- gap: 10px;
126
- padding: 13px 12px 9px 16px;
124
+ align-items: center;
125
+ gap: 8px;
126
+ padding: 9px 10px 7px 12px;
127
127
  }
128
128
  .dlu-titles { min-width: 0; }
129
129
  .dlu-word {
@@ -171,6 +171,42 @@ window.__ModuleLoader__.load({
171
171
  .dlu-body p { margin: 0 0 9px; }
172
172
  .dlu-body p:last-child { margin-bottom: 0; }
173
173
  .dlu-body b, .dlu-body strong { font-weight: 640; }
174
+ .dlu-body.is-frame { padding: 0; overflow: hidden; }
175
+ .dlu-tabs {
176
+ flex: 1;
177
+ min-width: 0;
178
+ display: flex;
179
+ gap: 4px;
180
+ padding: 0;
181
+ overflow-x: auto;
182
+ scrollbar-width: none;
183
+ }
184
+ .dlu-dict-label {
185
+ flex: 1;
186
+ min-width: 0;
187
+ font-size: 11px;
188
+ color: var(--dsw-alias-label-secondary, GrayText);
189
+ white-space: nowrap;
190
+ overflow: hidden;
191
+ text-overflow: ellipsis;
192
+ }
193
+ .dlu-tabs::-webkit-scrollbar { display: none; }
194
+ .dlu-tab {
195
+ flex: none;
196
+ height: 24px;
197
+ padding: 0 9px;
198
+ border: 0;
199
+ border-radius: 6px;
200
+ background: transparent;
201
+ color: var(--dsw-alias-label-secondary, GrayText);
202
+ font: 550 11px/1 -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
203
+ white-space: nowrap;
204
+ cursor: pointer;
205
+ }
206
+ .dlu-tab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, CanvasText); }
207
+ .dlu-tab.is-active { background: var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.2)); color: var(--dsw-alias-label-primary, CanvasText); }
208
+ .dlu-tab:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, Highlight); outline-offset: 1px; }
209
+ .dlu-frame { display: block; width: 100%; border: 0; background: transparent; }
174
210
  .dlu-empty {
175
211
  color: var(--dsw-alias-label-secondary, GrayText);
176
212
  font-size: 13px;
@@ -346,6 +382,90 @@ window.__ModuleLoader__.load({
346
382
  return { phonetic, paragraphs: paragraphs.length > 0 ? paragraphs : [body] }
347
383
  }
348
384
 
385
+ /** `cjk`, `latin`, or `other`. */
386
+ function scriptOf(text) {
387
+ if (/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/u.test(text)) return 'cjk'
388
+ if (/[A-Za-z\u00c0-\u024f]/u.test(text)) return 'latin'
389
+ return 'other'
390
+ }
391
+
392
+ /** Keep the system default (when its headword matches the query) near the top. */
393
+ function sortEntries(entries, word) {
394
+ const queryScript = scriptOf(word)
395
+ const scored = entries.map((entry, index) => {
396
+ const titleScript = scriptOf(entry.title || entry.dictionary || '')
397
+ let score = 0
398
+ if (titleScript === queryScript) score += 10
399
+ if (entry.default === true) score += 5
400
+ if (!/thesaurus|同义|同義/iu.test(entry.dictionary || '')) score += 2
401
+ if (/Oxford|牛津/iu.test(entry.dictionary || '')) score += 1
402
+ return { entry, score, index }
403
+ })
404
+ scored.sort((a, b) => b.score - a.score || a.index - b.index)
405
+ return scored.map((item) => item.entry)
406
+ }
407
+
408
+ /** Compact tab label from the dictionary name / short name. */
409
+ function shortLabel(entry) {
410
+ const full = String(entry.dictionary || '').trim()
411
+ const short = String(entry.shortName || '').trim()
412
+ if (full.length > 0 && full.length <= 18) return full
413
+ if (short.length > 0) return short.length <= 22 ? short : `${short.slice(0, 20)}…`
414
+ return full.length <= 26 ? full : `${full.slice(0, 24)}…`
415
+ }
416
+
417
+ /** Resolved theme, using the shell's marker and the page background as fallbacks. */
418
+ function isDarkTheme() {
419
+ const root = document.documentElement
420
+ const body = document.body
421
+ if (root.hasAttribute('data-ds-dark-theme') || body.hasAttribute('data-ds-dark-theme')) return true
422
+ if (root.classList.contains('dark') || body.classList.contains('dark')) return true
423
+ if (root.getAttribute('data-theme') === 'dark' || body.getAttribute('data-theme') === 'dark') return true
424
+ try {
425
+ const background = getComputedStyle(body).backgroundColor
426
+ const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/u.exec(background)
427
+ if (match !== null) return (Number(match[1]) + Number(match[2]) + Number(match[3])) / 3 < 128
428
+ } catch {
429
+ // fall through to the system preference
430
+ }
431
+ try { return window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return false }
432
+ }
433
+
434
+ /** The dictionary's own CSS with a transparent, card-sized frame around it. */
435
+ function frameDocument(entry, dark) {
436
+ const theme = dark
437
+ ? { scheme: 'dark', text: '#eef2f8', secondary: '#9aa6b8', scroll: 'rgba(255,255,255,.18)' }
438
+ : { scheme: 'light', text: '#101828', secondary: '#475467', scroll: 'rgba(15,23,42,.18)' }
439
+ const nativeCss = String(entry.css || '')
440
+ const fallbackCss = nativeCss === ''
441
+ ? `<style>
442
+ .entry, .semb, .se1, .se2, .gramb, .eg, .ex, .def, .trans, .msDict { display: block; }
443
+ .semb, .se2 { margin: .35em 0 .35em 1em; }
444
+ .hw, .hg, .hwg { font-weight: 650; }
445
+ .ph, .pr, .prx { color: ${theme.secondary}; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
446
+ .eg, .ex { color: ${theme.secondary}; font-style: italic; }
447
+ </style>`
448
+ : ''
449
+ return `<!doctype html><html><head><meta charset="utf-8">${fallbackCss}<style>${nativeCss}</style><style>
450
+ :root { color-scheme: ${theme.scheme}; }
451
+ html, body { margin: 0 !important; padding: 0 !important; background: transparent !important; height: auto !important; min-height: 0 !important; }
452
+ body {
453
+ padding: 2px 16px 14px !important;
454
+ color: ${theme.text};
455
+ font-size: 13.5px;
456
+ line-height: 1.62;
457
+ overflow-wrap: anywhere;
458
+ -webkit-font-smoothing: antialiased;
459
+ }
460
+ a { color: inherit; text-decoration: none; pointer-events: none; }
461
+ img, svg, table { max-width: 100% !important; }
462
+ img { height: auto; }
463
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
464
+ ::-webkit-scrollbar-thumb { background: ${theme.scroll}; border-radius: 4px; }
465
+ ::-webkit-scrollbar-track { background: transparent; }
466
+ </style></head><body>${String(entry.html || '')}</body></html>`
467
+ }
468
+
349
469
  /** Re-render whenever the locale dictionary or active locale changes. */
350
470
  function useLocaleVersion(locale) {
351
471
  const [, setVersion] = useState(0)
@@ -362,6 +482,8 @@ window.__ModuleLoader__.load({
362
482
  const [opening, setOpening] = useState(false)
363
483
  const cardRef = useRef(null)
364
484
  const requestRef = useRef(0)
485
+ const frameRef = useRef(null)
486
+ const [frameHeight, setFrameHeight] = useState(0)
365
487
 
366
488
  useEffect(() => {
367
489
  cardRef.current = card
@@ -387,10 +509,38 @@ window.__ModuleLoader__.load({
387
509
  status: 'loading',
388
510
  result: null,
389
511
  code: null,
512
+ active: 0,
390
513
  position: cardPosition(selection.rect)
391
514
  })
392
515
  }, [])
393
516
 
517
+ // The iframe is cross-document but same-origin, so its real height is
518
+ // readable without running scripts inside the dictionary HTML.
519
+ const measureFrame = useCallback(() => {
520
+ const frame = frameRef.current
521
+ if (frame === null) return
522
+ let doc = null
523
+ try { doc = frame.contentDocument } catch { doc = null }
524
+ if (doc === null || doc.documentElement === null) return
525
+ const body = doc.body
526
+ const last = body !== null ? body.lastElementChild : null
527
+ const padding = body !== null ? Number.parseFloat(getComputedStyle(body).paddingBottom) || 0 : 0
528
+ const contentBottom = last !== null ? last.getBoundingClientRect().bottom : 0
529
+ const measured = Math.max(
530
+ contentBottom > 0 ? contentBottom + padding + 2 : 0,
531
+ body !== null ? body.scrollHeight : 0
532
+ )
533
+ const height = Math.ceil(measured)
534
+ if (height <= 0) return
535
+ const current = cardRef.current
536
+ const limit = Math.max(160, Math.min((current !== null ? current.position.maxHeight : 360) - 36, 560))
537
+ setFrameHeight(Math.min(height + 2, limit))
538
+ }, [])
539
+
540
+ useEffect(() => {
541
+ setFrameHeight(0)
542
+ }, [card === null ? null : card.requestId, card === null ? null : card.active])
543
+
394
544
  // Ask the Host once per opened card.
395
545
  useEffect(() => {
396
546
  if (card === null || card.status !== 'loading') return undefined
@@ -413,11 +563,19 @@ window.__ModuleLoader__.load({
413
563
  })
414
564
  .then((response) => response.json())
415
565
  .then((data) => {
416
- if (data !== null && typeof data === 'object' && data.ok === true) {
417
- settle({ status: 'ready', result: data })
418
- } else {
419
- settle({ status: 'error', code: data !== null && typeof data === 'object' ? data.code ?? 'failed' : 'failed' })
566
+ if (data !== null && typeof data === 'object' && data.ok === true && Array.isArray(data.entries) && data.entries.length > 0) {
567
+ settle({
568
+ status: 'ready',
569
+ active: 0,
570
+ result: { ...data, entries: sortEntries(data.entries, data.word ?? card.word) }
571
+ })
572
+ return
573
+ }
574
+ if (data !== null && typeof data === 'object' && data.ok === true && typeof data.definition === 'string') {
575
+ settle({ status: 'ready', active: 0, result: { ...data, entries: [] } })
576
+ return
420
577
  }
578
+ settle({ status: 'error', code: data !== null && typeof data === 'object' ? data.code ?? 'failed' : 'failed' })
421
579
  })
422
580
  .catch((error) => {
423
581
  settle({ status: 'error', code: error !== null && error !== undefined && error.name === 'AbortError' ? 'timeout' : 'failed' })
@@ -540,7 +698,14 @@ window.__ModuleLoader__.load({
540
698
  }, [])
541
699
 
542
700
  const result = card !== null && card.status === 'ready' ? card.result : null
543
- const parsed = result !== null ? parseDefinition(result.definition, result.word ?? card.word) : null
701
+ const entries = result !== null && Array.isArray(result.entries) ? result.entries : []
702
+ const activeIndex = card !== null && Number.isInteger(card.active)
703
+ ? Math.min(Math.max(card.active, 0), Math.max(0, entries.length - 1))
704
+ : 0
705
+ const activeEntry = entries.length > 0 ? entries[activeIndex] ?? entries[0] : null
706
+ const fallbackText = result !== null && entries.length === 0 ? String(result.definition ?? result.text ?? '') : ''
707
+ const parsed = fallbackText !== '' ? parseDefinition(fallbackText, result.word ?? card.word) : null
708
+ const dark = isDarkTheme()
544
709
 
545
710
  const errorText = (() => {
546
711
  if (card === null || card.status !== 'error') return ''
@@ -586,10 +751,34 @@ window.__ModuleLoader__.load({
586
751
  }
587
752
  },
588
753
  h('div', { className: 'dlu-head' },
589
- h('div', { className: 'dlu-titles' },
590
- h('div', { className: 'dlu-word' }, result !== null ? result.word ?? card.word : card.word),
591
- parsed !== null && parsed.phonetic !== '' ? h('div', { className: 'dlu-phonetic' }, parsed.phonetic) : null
592
- ),
754
+ entries.length > 1
755
+ ? h('div', { className: 'dlu-tabs', role: 'tablist' },
756
+ entries.map((entry, index) => h('button', {
757
+ key: `${entry.id || entry.dictionary}-${index}`,
758
+ type: 'button',
759
+ role: 'tab',
760
+ 'aria-selected': index === activeIndex,
761
+ className: `dlu-tab${index === activeIndex ? ' is-active' : ''}`,
762
+ title: entry.dictionary || undefined,
763
+ onMouseDown: (event) => {
764
+ event.preventDefault()
765
+ event.stopPropagation()
766
+ },
767
+ onClick: (event) => {
768
+ event.stopPropagation()
769
+ setCard((current) => current !== null ? { ...current, active: index } : current)
770
+ }
771
+ }, shortLabel(entry)))
772
+ )
773
+ : entries.length === 1
774
+ ? h('span', {
775
+ className: 'dlu-dict-label',
776
+ title: activeEntry.dictionary || undefined
777
+ }, activeEntry.dictionary || t('source'))
778
+ : h('div', { className: 'dlu-titles' },
779
+ h('div', { className: 'dlu-word' }, card.word),
780
+ parsed !== null && parsed.phonetic !== '' ? h('div', { className: 'dlu-phonetic' }, parsed.phonetic) : null
781
+ ),
593
782
  h('button', {
594
783
  type: 'button',
595
784
  className: 'dlu-close',
@@ -599,7 +788,10 @@ window.__ModuleLoader__.load({
599
788
  onClick: () => setCard(null)
600
789
  }, ICON_CLOSE)
601
790
  ),
602
- h('div', { className: 'dlu-body', style: { maxHeight: `${card.position.maxHeight}px` } },
791
+ h('div', {
792
+ className: `dlu-body${entries.length > 0 ? ' is-frame' : ''}`,
793
+ style: entries.length > 0 ? undefined : { maxHeight: `${card.position.maxHeight}px` }
794
+ },
603
795
  card.status === 'loading'
604
796
  ? h('div', { className: 'dlu-loading' },
605
797
  h('span', null, t('loading')),
@@ -613,12 +805,41 @@ window.__ModuleLoader__.load({
613
805
  card.status === 'error'
614
806
  ? h('div', { className: 'dlu-empty is-error' }, errorText)
615
807
  : null,
616
- card.status === 'ready' && parsed !== null
808
+ card.status === 'ready' && activeEntry !== null
809
+ ? h('iframe', {
810
+ ref: frameRef,
811
+ className: 'dlu-frame',
812
+ title: activeEntry.dictionary || card.word,
813
+ sandbox: 'allow-same-origin',
814
+ srcDoc: frameDocument(activeEntry, dark),
815
+ style: { height: `${frameHeight > 0 ? frameHeight : 220}px` },
816
+ onLoad: () => {
817
+ // The iframe is a separate document; forward Escape so
818
+ // the card can be dismissed while the entry has focus.
819
+ try {
820
+ const doc = frameRef.current !== null ? frameRef.current.contentDocument : null
821
+ if (doc !== null) {
822
+ doc.addEventListener('keydown', (event) => {
823
+ if (event.key === 'Escape') setCard(null)
824
+ })
825
+ }
826
+ } catch {
827
+ // cross-document access is best-effort
828
+ }
829
+ measureFrame()
830
+ window.setTimeout(measureFrame, 160)
831
+ }
832
+ })
833
+ : null,
834
+ card.status === 'ready' && activeEntry === null && parsed !== null
617
835
  ? parsed.paragraphs.map((paragraph, index) => h('p', { key: index }, paragraph))
618
836
  : null
619
837
  ),
620
838
  h('div', { className: 'dlu-foot' },
621
- h('span', { className: 'dlu-source' }, t('source')),
839
+ h('span', {
840
+ className: 'dlu-source',
841
+ title: activeEntry !== null && activeEntry.dictionary !== '' ? activeEntry.dictionary : undefined
842
+ }, activeEntry !== null && activeEntry.dictionary !== '' ? activeEntry.dictionary : t('source')),
622
843
  card.status === 'ready'
623
844
  ? h('button', {
624
845
  type: 'button',
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -2,14 +2,19 @@
2
2
  * dsh-plugin-look-up — Host half.
3
3
  *
4
4
  * The browser half owns the interaction; this half owns the only privileged
5
- * step: asking macOS for a definition. `bin/dsh-lookup-dict` is a small signed
6
- * universal helper that calls DictionaryServices' `DCSCopyTextDefinition`, the
7
- * same local dictionary data macOS Look Up reads. The Host never needs network
8
- * access, accessibility permission, or an Apple Events grant.
5
+ * step: asking macOS for full dictionary entries. `bin/dsh-lookup-dict` is a
6
+ * small signed universal helper that renders one entry through
7
+ * DictionaryServices (the same content macOS Dictionary shows), including the
8
+ * dictionary's own stylesheet. The Host never needs network access,
9
+ * accessibility permission, or an Apple Events grant.
10
+ *
11
+ * One helper process performs one dictionary lookup: the private markup API is
12
+ * not stable when several dictionaries are queried in one process, so the Host
13
+ * fans out to a few dictionary-specific helpers in parallel and merges them.
9
14
  *
10
15
  * Routes (loopback only, same-origin JSON):
11
16
  * GET /plugin/look-up/health → mount, platform and helper status
12
- * POST /plugin/look-up/lookup {word} → { ok, word, definition } | { ok:false, code }
17
+ * POST /plugin/look-up/lookup {word} → { ok, word, entries[] } | { ok:false, code }
13
18
  * POST /plugin/look-up/open {word} → open the word in Dictionary.app
14
19
  */
15
20
 
@@ -35,11 +40,20 @@ const MANIFEST = JSON.parse(readFileSync(new URL('./package.json', import.meta.u
35
40
  const HELPER_PATH = fileURLToPath(new URL('./bin/dsh-lookup-dict', import.meta.url))
36
41
  const MAX_WORD_LENGTH = 64
37
42
  const MAX_BODY_BYTES = 8 * 1024
38
- const MAX_CACHE_ENTRIES = 256
39
- const HELPER_TIMEOUT_MS = 5000
43
+ const HELPER_TIMEOUT_MS = 8000
44
+ const MAX_HELPER_BUFFER = 8 * 1024 * 1024
45
+ const MAX_ENTRIES = 6
46
+ const MAX_HTML_BYTES = 2 * 1024 * 1024
47
+ const MAX_CSS_BYTES = 512 * 1024
48
+ const CACHE_BUDGET_BYTES = 24 * 1024 * 1024
49
+
50
+ /** Dictionary name fragments queried in addition to the system default. */
51
+ const LATIN_FILTERS = ['牛津英汉', 'New Oxford American', 'Oxford Dictionary of English']
52
+ const CJK_FILTERS = ['现代汉语规范', '牛津英汉', '商務新詞典']
40
53
 
41
- /** In-memory definition cache; definitions are immutable for a given word. */
54
+ /** In-memory definition cache, bounded by total bytes as entries are large. */
42
55
  const cache = new Map()
56
+ let cacheBytes = 0
43
57
 
44
58
  /** Whether the packaged helper exists and is executable. */
45
59
  function helperStatus() {
@@ -61,8 +75,8 @@ export function loopbackHost(req) {
61
75
  const host = req.headers.host
62
76
  if (typeof host !== 'string' || host === '') return false
63
77
  if (host.startsWith('[')) return host.startsWith('[::1]')
64
- const name = host.split(':')[0]
65
- return name === '127.0.0.1' || name === 'localhost'
78
+ const namePart = host.split(':')[0]
79
+ return namePart === '127.0.0.1' || namePart === 'localhost'
66
80
  }
67
81
 
68
82
  /**
@@ -111,55 +125,120 @@ async function readJson(req) {
111
125
  return parsed
112
126
  }
113
127
 
114
- /** Store one result in the definition cache, evicting the oldest entry. */
128
+ /** Store one result in the definition cache, evicting the oldest entries. */
115
129
  function remember(word, value) {
116
- cache.set(word, value)
117
- if (cache.size > MAX_CACHE_ENTRIES) {
130
+ const bytes = Buffer.byteLength(JSON.stringify(value))
131
+ if (bytes > 4 * 1024 * 1024) return
132
+ const previous = cache.get(word)
133
+ if (previous !== undefined) cacheBytes -= previous.bytes
134
+ cache.set(word, { value, bytes })
135
+ cacheBytes += bytes
136
+ while (cacheBytes > CACHE_BUDGET_BYTES && cache.size > 1) {
118
137
  const oldest = cache.keys().next().value
119
- if (oldest !== undefined) cache.delete(oldest)
138
+ if (oldest === undefined) break
139
+ const entry = cache.get(oldest)
140
+ if (entry !== undefined) cacheBytes -= entry.bytes
141
+ cache.delete(oldest)
142
+ }
143
+ }
144
+
145
+ /** `latin`, `cjk`, or `other`, used to choose which dictionaries to ask. */
146
+ function scriptOf(word) {
147
+ if (/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/u.test(word)) return 'cjk'
148
+ if (/[A-Za-z\u00c0-\u024f]/u.test(word)) return 'latin'
149
+ return 'other'
150
+ }
151
+
152
+ /**
153
+ * Run one helper process: the system default entry or one named dictionary.
154
+ * @param word - a normalized word.
155
+ * @param filter - dictionary name fragment, or null for the system default.
156
+ * @returns the parsed helper payload, or null when it failed or missed.
157
+ */
158
+ async function runHelper(word, filter) {
159
+ const args = filter === null ? [word, '--entries'] : [word, '--dictionary', filter]
160
+ try {
161
+ const { stdout } = await execFileAsync(HELPER_PATH, args, {
162
+ timeout: HELPER_TIMEOUT_MS,
163
+ maxBuffer: MAX_HELPER_BUFFER,
164
+ encoding: 'utf8',
165
+ windowsHide: true
166
+ })
167
+ const parsed = JSON.parse(stdout.trim())
168
+ return parsed !== null && typeof parsed === 'object' ? parsed : null
169
+ } catch {
170
+ // A helper that cannot find the dictionary exits 0 with an empty list; a
171
+ // crash or timeout only loses this one perspective.
172
+ return null
173
+ }
174
+ }
175
+
176
+ /** Validate and bound one helper entry. */
177
+ function normalizeEntry(entry, isDefault) {
178
+ if (entry === null || typeof entry !== 'object' || typeof entry.html !== 'string') return null
179
+ if (entry.html.length === 0 || entry.html.length > MAX_HTML_BYTES) return null
180
+ return {
181
+ dictionary: typeof entry.dictionary === 'string' ? entry.dictionary : '',
182
+ shortName: typeof entry.shortName === 'string' ? entry.shortName : '',
183
+ title: typeof entry.title === 'string' ? entry.title : '',
184
+ id: typeof entry.id === 'string' ? entry.id : '',
185
+ html: entry.html,
186
+ css: typeof entry.css === 'string' ? entry.css.slice(0, MAX_CSS_BYTES) : '',
187
+ default: isDefault
120
188
  }
121
189
  }
122
190
 
191
+ /** Merge entries by dictionary entry id, preferring a named duplicate over `System default`. */
192
+ function mergeEntries(entries) {
193
+ const merged = new Map()
194
+ for (const entry of entries) {
195
+ const key = entry.id !== '' ? entry.id : `${entry.dictionary}\u0000${entry.title}`
196
+ const previous = merged.get(key)
197
+ if (previous === undefined) {
198
+ merged.set(key, entry)
199
+ continue
200
+ }
201
+ const keepDefault = previous.default || entry.default
202
+ const preferred = previous.default && !entry.default ? entry : previous
203
+ merged.set(key, { ...preferred, default: keepDefault })
204
+ }
205
+ return [...merged.values()].slice(0, MAX_ENTRIES)
206
+ }
207
+
123
208
  /**
124
- * Ask the macOS helper for one definition.
209
+ * Ask the macOS helper(s) for one word.
125
210
  * @param word - a normalized word.
126
211
  * @returns a client-facing result object.
127
212
  */
128
213
  async function lookupWord(word) {
129
214
  const cached = cache.get(word)
130
- if (cached !== undefined) return cached
215
+ if (cached !== undefined) return cached.value
131
216
 
132
217
  if (process.platform !== 'darwin') return { ok: false, code: 'unsupported', word }
133
218
  if (helperStatus() !== 'ok') return { ok: false, code: 'helper-missing', word }
134
219
 
135
- let stdout
136
- try {
137
- const result = await execFileAsync(HELPER_PATH, [word], {
138
- timeout: HELPER_TIMEOUT_MS,
139
- maxBuffer: 1024 * 1024,
140
- encoding: 'utf8',
141
- windowsHide: true
142
- })
143
- stdout = result.stdout
144
- } catch (error) {
145
- if (error?.code === 'ETIMEDOUT' || error?.killed === true) return { ok: false, code: 'timeout', word }
146
- // The helper reports "no entry" as JSON with exit code 0, so a thrown
147
- // error here means the process itself failed. Some spawn errors still
148
- // carry usable stdout, but we do not trust partial output.
149
- return { ok: false, code: 'helper-failed', word }
150
- }
220
+ const script = scriptOf(word)
221
+ const filters = script === 'latin' ? LATIN_FILTERS : script === 'cjk' ? CJK_FILTERS : []
222
+ const payloads = await Promise.all([runHelper(word, null), ...filters.map((filter) => runHelper(word, filter))])
151
223
 
152
- let parsed
153
- try {
154
- parsed = JSON.parse(stdout.trim())
155
- } catch {
156
- return { ok: false, code: 'helper-failed', word }
224
+ const entries = []
225
+ for (const payload of payloads) {
226
+ if (payload === null || payload.ok !== true || !Array.isArray(payload.entries)) continue
227
+ const isDefault = payload.default === true
228
+ for (const raw of payload.entries) {
229
+ const entry = normalizeEntry(raw, isDefault)
230
+ if (entry !== null) entries.push(entry)
231
+ }
157
232
  }
158
233
 
159
- const result = parsed.ok === true
160
- ? { ok: true, word, definition: String(parsed.definition ?? ''), source: 'DictionaryServices' }
161
- : { ok: false, code: typeof parsed.error === 'string' ? parsed.error : 'no-entry', word }
162
- if (result.ok) remember(word, result)
234
+ if (entries.length === 0) return { ok: false, code: 'no-entry', word }
235
+ const result = {
236
+ ok: true,
237
+ word,
238
+ entries: mergeEntries(entries),
239
+ source: 'DictionaryServices'
240
+ }
241
+ remember(word, result)
163
242
  return result
164
243
  }
165
244
 
@@ -210,7 +289,9 @@ async function serve(ctx, req, res) {
210
289
  version: MANIFEST.version,
211
290
  platform: process.platform,
212
291
  helper: helperStatus(),
292
+ helperVersion: 4,
213
293
  cacheEntries: cache.size,
294
+ cacheBytes,
214
295
  clientRegistered: clientBundle !== undefined,
215
296
  clientBundle: clientBundle ?? null
216
297
  })
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Look Up",
4
- "description": "Select a word in chat and read macOS system-dictionary definitions in place — offline, no network."
4
+ "description": "Select a word in chat and read the complete macOS Dictionary entry — senses, examples and the dictionary's own typography — offline, with no network."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "查词",
4
- "description": "在对话里选中单词,就地查看 macOS 系统词典释义——离线、不联网。"
4
+ "description": "在对话里选中单词,就地阅读完整的 macOS 系统词典词条——义项、例句和词典原生排版,离线、不联网。"
5
5
  }
6
6
  }
@@ -1,21 +1,31 @@
1
1
  /**
2
- * dsh-lookup-dict — tiny macOS system-dictionary bridge for dsh-plugin-look-up.
2
+ * dsh-lookup-dict — macOS system-dictionary bridge for dsh-plugin-look-up.
3
3
  *
4
- * Reads the definition of one word from the dictionaries the user configured in
5
- * Dictionary.app by calling DictionaryServices' public `DCSCopyTextDefinition`.
6
- * That is the same local, offline data source macOS Look Up uses; no network,
7
- * no accessibility permission, no Apple Events.
4
+ * One process performs exactly one DictionaryServices `DCSCopyDefinitionMarkup`
5
+ * call and returns the full entry HTML plus the dictionary's own
6
+ * `DefaultStyle.css`. That is the same content and stylesheet macOS Dictionary
7
+ * renders, so the in-chat card can show the complete entry.
8
+ *
9
+ * The private markup function takes two trailing pointers after the range (a
10
+ * reserved slot and a `CFURLRef *` for the stylesheet). Leaving them
11
+ * uninitialized makes the framework write through garbage and crash.
8
12
  *
9
13
  * Usage:
10
- * dsh-lookup-dict <word> -> one JSON object on stdout
11
- * dsh-lookup-dict --version-> "1"
14
+ * dsh-lookup-dict <word> -> the system default entry
15
+ * dsh-lookup-dict <word> --dictionary <substring> -> first matching dictionary
16
+ * dsh-lookup-dict --version -> "4"
12
17
  *
13
- * Exit codes: 0 when a JSON object was written (including `ok:false`),
14
- * 2 on usage errors. Output is always valid JSON otherwise.
18
+ * Exit codes: 0 when JSON was written (including `ok:false`), 2 on usage errors.
15
19
  */
16
20
 
17
21
  #import <Foundation/Foundation.h>
18
22
  #import <CoreServices/CoreServices.h>
23
+ #include <dlfcn.h>
24
+
25
+ typedef CFStringRef (*MarkupFn)(DCSDictionaryRef, CFStringRef, CFRange, void *, CFURLRef *);
26
+ typedef CFTypeRef (*AvailableFn)(void);
27
+ typedef CFStringRef (*NameFn)(DCSDictionaryRef);
28
+ typedef CFStringRef (*ShortNameFn)(DCSDictionaryRef);
19
29
 
20
30
  static void write_json(NSDictionary *payload) {
21
31
  NSError *error = nil;
@@ -28,10 +38,84 @@ static void write_json(NSDictionary *payload) {
28
38
  fputc('\n', stdout);
29
39
  }
30
40
 
41
+ static NSString *body_of(NSString *html) {
42
+ NSRange open = [html rangeOfString:@"<body>"];
43
+ NSRange close = [html rangeOfString:@"</body>"];
44
+ if (open.location == NSNotFound || close.location == NSNotFound || close.location <= open.location) return html;
45
+ return [html substringWithRange:NSMakeRange(open.location + open.length, close.location - open.location - open.length)];
46
+ }
47
+
48
+ static NSString *first_match(NSString *text, NSString *pattern) {
49
+ NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil];
50
+ NSTextCheckingResult *match = [regex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)];
51
+ if (match == nil || match.numberOfRanges < 2) return nil;
52
+ return [text substringWithRange:[match rangeAtIndex:1]];
53
+ }
54
+
55
+ static NSString *sanitize_fragment(NSString *html) {
56
+ NSRegularExpression *blocks = [NSRegularExpression regularExpressionWithPattern:@"<(script|iframe|object|embed)\\b[\\s\\S]*?</\\1\\s*>" options:NSRegularExpressionCaseInsensitive error:nil];
57
+ NSString *clean = [blocks stringByReplacingMatchesInString:html options:0 range:NSMakeRange(0, html.length) withTemplate:@""];
58
+ NSRegularExpression *handlers = [NSRegularExpression regularExpressionWithPattern:@"\\s+on[a-zA-Z]+\\s*=\\s*(\"[^\"]*\"|'[^']*')" options:0 error:nil];
59
+ clean = [handlers stringByReplacingMatchesInString:clean options:0 range:NSMakeRange(0, clean.length) withTemplate:@""];
60
+ return [clean stringByReplacingOccurrencesOfString:@"javascript:" withString:@"about:blank#"];
61
+ }
62
+
63
+ /** The dictionary's stylesheet, delivered by the markup call itself. */
64
+ static NSString *read_style(CFURLRef url) {
65
+ if (url == NULL) return @"";
66
+ NSData *data = [NSData dataWithContentsOfURL:(__bridge NSURL *)url];
67
+ if (data == nil) return @"";
68
+ NSString *css = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
69
+ if (css == nil) css = [[NSString alloc] initWithData:data encoding:NSUnicodeStringEncoding];
70
+ return css ?: @"";
71
+ }
72
+
73
+ /** `<...>/Name.dictionary/Contents/Resources/DefaultStyle.css` -> `Name`. */
74
+ static NSString *dictionary_name_from_style(CFURLRef url) {
75
+ if (url == NULL) return @"System dictionary";
76
+ NSString *path = [(__bridge NSURL *)url path];
77
+ NSRange marker = [path rangeOfString:@".dictionary/"];
78
+ if (marker.location == NSNotFound) return @"System dictionary";
79
+ NSString *prefix = [path substringToIndex:marker.location];
80
+ NSString *name = [prefix lastPathComponent];
81
+ return name.length > 0 ? name : @"System dictionary";
82
+ }
83
+
84
+ /** First dictionary whose display name contains `filter`; NULL when absent. */
85
+ static DCSDictionaryRef find_dictionary(AvailableFn available, NameFn name_of, NSString *filter) {
86
+ if (available == NULL || name_of == NULL) return NULL;
87
+ CFTypeRef dictionaries = available();
88
+ if (dictionaries == NULL) return NULL;
89
+
90
+ CFIndex count = 0;
91
+ const void **values = NULL;
92
+ if (CFGetTypeID(dictionaries) == CFSetGetTypeID()) {
93
+ count = CFSetGetCount((CFSetRef)dictionaries);
94
+ values = malloc(sizeof(void *) * (size_t)count);
95
+ CFSetGetValues((CFSetRef)dictionaries, values);
96
+ } else if (CFGetTypeID(dictionaries) == CFArrayGetTypeID()) {
97
+ count = CFArrayGetCount((CFArrayRef)dictionaries);
98
+ values = malloc(sizeof(void *) * (size_t)count);
99
+ for (CFIndex i = 0; i < count; i++) values[i] = CFArrayGetValueAtIndex((CFArrayRef)dictionaries, i);
100
+ }
101
+
102
+ DCSDictionaryRef found = NULL;
103
+ for (CFIndex i = 0; i < count; i++) {
104
+ DCSDictionaryRef candidate = (DCSDictionaryRef)values[i];
105
+ CFStringRef name = name_of(candidate);
106
+ if (name != NULL && [(__bridge NSString *)name containsString:filter]) {
107
+ found = candidate;
108
+ break;
109
+ }
110
+ }
111
+ free(values);
112
+ return found;
113
+ }
114
+
31
115
  int main(int argc, const char *argv[]) {
32
116
  @autoreleasepool {
33
117
  if (argc > 1 && strcmp(argv[1], "--version") == 0) {
34
- fputs("1\n", stdout);
118
+ fputs("4\n", stdout);
35
119
  return 0;
36
120
  }
37
121
  if (argc < 2) {
@@ -39,12 +123,16 @@ int main(int argc, const char *argv[]) {
39
123
  return 2;
40
124
  }
41
125
 
42
- NSString *raw = [[NSString alloc] initWithUTF8String:argv[1]];
43
- if (raw == nil) {
44
- write_json(@{@"ok": @NO, @"error": @"encoding"});
45
- return 0;
126
+ NSString *word = [[[NSString alloc] initWithUTF8String:argv[1]] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
127
+ BOOL entries_mode = NO;
128
+ NSString *filter = nil;
129
+ if (argc > 2 && strcmp(argv[2], "--entries") == 0) {
130
+ entries_mode = YES;
131
+ } else if (argc > 3 && strcmp(argv[2], "--dictionary") == 0) {
132
+ entries_mode = YES;
133
+ filter = [[NSString alloc] initWithUTF8String:argv[3]];
46
134
  }
47
- NSString *word = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
135
+
48
136
  if (word.length == 0) {
49
137
  write_json(@{@"ok": @NO, @"error": @"empty"});
50
138
  return 0;
@@ -54,26 +142,71 @@ int main(int argc, const char *argv[]) {
54
142
  return 0;
55
143
  }
56
144
 
57
- CFRange range = CFRangeMake(0, (CFIndex)word.length);
58
- CFStringRef copied = DCSCopyTextDefinition(NULL, (__bridge CFStringRef)word, range);
59
- if (copied == NULL) {
60
- write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
145
+ MarkupFn markup = (MarkupFn)dlsym(RTLD_DEFAULT, "DCSCopyDefinitionMarkup");
146
+ AvailableFn available = (AvailableFn)dlsym(RTLD_DEFAULT, "DCSCopyAvailableDictionaries");
147
+ NameFn name_of = (NameFn)dlsym(RTLD_DEFAULT, "DCSDictionaryGetName");
148
+ ShortNameFn short_name_of = (ShortNameFn)dlsym(RTLD_DEFAULT, "DCSDictionaryGetShortName");
149
+ if (markup == NULL) {
150
+ write_json(@{@"ok": @NO, @"error": @"no-markup-api"});
151
+ return 0;
152
+ }
153
+
154
+ CFStringRef word_ref = CFStringCreateWithCString(NULL, word.UTF8String, kCFStringEncodingUTF8);
155
+ if (word_ref == NULL) {
156
+ write_json(@{@"ok": @NO, @"error": @"encoding"});
61
157
  return 0;
62
158
  }
159
+ CFRange range = CFRangeMake(0, CFStringGetLength(word_ref));
63
160
 
64
- NSString *definition = (__bridge_transfer NSString *)copied;
65
- NSString *trimmed = [definition stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
66
- if (trimmed.length == 0) {
67
- write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
161
+ // 0.1.x compatibility: the old Host calls the helper without a mode
162
+ // flag and expects the flat plain-text definition.
163
+ if (!entries_mode) {
164
+ CFStringRef plain = DCSCopyTextDefinition(NULL, word_ref, range);
165
+ if (plain == NULL) {
166
+ write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
167
+ } else {
168
+ write_json(@{@"ok": @YES, @"word": word, @"definition": (__bridge_transfer NSString *)plain});
169
+ }
68
170
  return 0;
69
171
  }
70
172
 
71
- write_json(@{
72
- @"ok": @YES,
73
- @"word": word,
74
- @"definition": trimmed,
75
- @"source": @"DictionaryServices"
76
- });
173
+ DCSDictionaryRef dictionary = NULL;
174
+ NSString *dictionary_name = nil;
175
+ NSString *short_name = @"";
176
+
177
+ if (filter != nil) {
178
+ dictionary = find_dictionary(available, name_of, filter);
179
+ if (dictionary == NULL) {
180
+ write_json(@{@"ok": @YES, @"word": word, @"entries": @[]});
181
+ return 0;
182
+ }
183
+ CFStringRef name = name_of != NULL ? name_of(dictionary) : NULL;
184
+ if (name != NULL) dictionary_name = (__bridge NSString *)name;
185
+ CFStringRef short_ref = short_name_of != NULL ? short_name_of(dictionary) : NULL;
186
+ if (short_ref != NULL) short_name = (__bridge NSString *)short_ref;
187
+ }
188
+
189
+ CFURLRef style_url = NULL;
190
+ CFStringRef copied = markup(dictionary, word_ref, range, NULL, &style_url);
191
+ if (copied == NULL) {
192
+ write_json(@{@"ok": @YES, @"word": word, @"entries": @[]});
193
+ return 0;
194
+ }
195
+
196
+ NSString *html = (__bridge_transfer NSString *)copied;
197
+ NSString *title = first_match(html, @"<d:entry[^>]*\\bd:title=\"([^\"]*)\"");
198
+ NSString *identifier = first_match(html, @"<d:entry[^>]*\\bid=\"([^\"]*)\"");
199
+ if (dictionary_name.length == 0) dictionary_name = dictionary_name_from_style(style_url);
200
+
201
+ NSDictionary *entry = @{
202
+ @"dictionary": dictionary_name ?: @"System dictionary",
203
+ @"shortName": short_name,
204
+ @"title": title ?: word,
205
+ @"id": identifier ?: @"",
206
+ @"html": sanitize_fragment(body_of(html)),
207
+ @"css": read_style(style_url)
208
+ };
209
+ write_json(@{@"ok": @YES, @"word": word, @"entries": @[entry], @"default": filter == nil ? @YES : @NO});
77
210
  }
78
211
  return 0;
79
212
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-look-up",
3
- "version": "0.1.1",
4
- "description": "macOS Look Up inside DSH chat: select a word and read system-dictionary definitions in place, offline.",
3
+ "version": "0.2.0",
4
+ "description": "Full macOS Dictionary entries inside DSH chat: select a word and read the complete system-dictionary entry, offline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "huanglianqi",
@@ -41,6 +41,7 @@
41
41
  "bin/dsh-lookup-dict",
42
42
  "native/dsh-lookup-dict.m",
43
43
  "scripts/build-helper.sh",
44
+ "docs/*.jpg",
44
45
  "README.md",
45
46
  "LICENSE"
46
47
  ],