dsh-plugin-look-up 0.1.0 → 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 +53 -43
- package/bin/dsh-lookup-dict +0 -0
- package/client.js +240 -19
- package/docs/preview-bilingual.jpg +0 -0
- package/docs/preview-chinese.jpg +0 -0
- package/docs/preview-english.jpg +0 -0
- package/index.js +123 -42
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/native/dsh-lookup-dict.m +162 -29
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,86 +1,96 @@
|
|
|
1
1
|
# dsh-plugin-look-up
|
|
2
2
|
|
|
3
|
-
macOS
|
|
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
|
-
|
|
5
|
+
Everything comes from the dictionaries you already have installed in Dictionary.app. No network, no API key, no accessibility permission.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
|
-
## What
|
|
9
|
+
## What you get
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
|  |  |
|
|
23
19
|
|
|
24
20
|
## Requirements
|
|
25
21
|
|
|
26
22
|
- macOS 11 or newer.
|
|
27
|
-
- DSH Desktop
|
|
28
|
-
- At least one dictionary installed in **Dictionary.app** (Oxford, 现代汉语词典, 新华字典, 牛津英汉汉英词典 … all work).
|
|
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
|
|
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
|
|
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
|
|
45
|
-
2. Click the **查词 / Look Up** chip
|
|
46
|
-
3.
|
|
47
|
-
4.
|
|
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
|
-
|
|
|
45
|
+
| File | Responsibility |
|
|
54
46
|
| --- | --- |
|
|
55
|
-
| `client.js` |
|
|
56
|
-
| `index.js` | Host routes
|
|
57
|
-
| `bin/dsh-lookup-dict` |
|
|
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
|
-
|
|
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
|
|
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
|
|
80
|
+
在 DSH 对话里选中单词,点击「查词」,直接在原位展开 **完整的 macOS 系统词典词条**:义项、例句、词性、词源,排版和「词典」App 本体一致。
|
|
77
81
|
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
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
|
-
|
|
93
|
+
已知边界:原生 Look Up 面板无法被第三方插件直接调用(Host 在 Electron utilityProcess 里,渲染层 preload 只暴露固定动作白名单),所以用同一份系统词典数据和词典自带样式在对话内复刻;词典里的发音按钮和交叉引用链接不可交互。
|
|
84
94
|
|
|
85
95
|
## License
|
|
86
96
|
|
package/bin/dsh-lookup-dict
CHANGED
|
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 =
|
|
21
|
+
const CARD_WIDTH = 420
|
|
22
22
|
|
|
23
23
|
/** Runtime copy; the package locale files carry only the plugin metadata. */
|
|
24
24
|
const STRINGS = {
|
|
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
helperMissing: 'The dictionary helper is missing. Reinstall the plugin.',
|
|
31
31
|
timeout: 'The dictionary took too long. Try again.',
|
|
32
32
|
failed: 'Lookup failed. Try again.',
|
|
33
|
-
|
|
33
|
+
copyWord: 'Copy word',
|
|
34
34
|
copied: 'Copied',
|
|
35
35
|
openInDictionary: 'Open in Dictionary',
|
|
36
36
|
source: 'macOS system dictionaries · offline',
|
|
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
helperMissing: '词典组件缺失,请重新安装插件。',
|
|
46
46
|
timeout: '查询超时,请重试。',
|
|
47
47
|
failed: '查询失败,请重试。',
|
|
48
|
-
|
|
48
|
+
copyWord: '复制单词',
|
|
49
49
|
copied: '已复制',
|
|
50
50
|
openInDictionary: '在词典中打开',
|
|
51
51
|
source: 'macOS 系统词典 · 本地查询',
|
|
@@ -121,9 +121,9 @@ window.__ModuleLoader__.load({
|
|
|
121
121
|
}
|
|
122
122
|
.dlu-head {
|
|
123
123
|
display: flex;
|
|
124
|
-
align-items:
|
|
125
|
-
gap:
|
|
126
|
-
padding:
|
|
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({
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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
|
-
|
|
590
|
-
h('div', { className: 'dlu-
|
|
591
|
-
|
|
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', {
|
|
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' &&
|
|
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', {
|
|
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',
|
|
@@ -626,7 +847,7 @@ window.__ModuleLoader__.load({
|
|
|
626
847
|
'data-copied': copied ? 'true' : undefined,
|
|
627
848
|
onMouseDown: (event) => event.stopPropagation(),
|
|
628
849
|
onClick: () => copyWord(result.word ?? card.word)
|
|
629
|
-
}, copied ? t('copied') : t('
|
|
850
|
+
}, copied ? t('copied') : t('copyWord'))
|
|
630
851
|
: null,
|
|
631
852
|
card.status === 'ready' && navigator.platform !== undefined && /Mac/u.test(navigator.platform)
|
|
632
853
|
? h('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
|
|
6
|
-
* universal helper that
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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
|
|
39
|
-
const
|
|
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
|
|
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
|
|
65
|
-
return
|
|
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
|
|
128
|
+
/** Store one result in the definition cache, evicting the oldest entries. */
|
|
115
129
|
function remember(word, value) {
|
|
116
|
-
|
|
117
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
|
@@ -171,7 +250,7 @@ async function lookupWord(word) {
|
|
|
171
250
|
async function openInDictionary(word) {
|
|
172
251
|
if (process.platform !== 'darwin') return { ok: false, code: 'unsupported' }
|
|
173
252
|
try {
|
|
174
|
-
await execFileAsync('/usr/bin/open', [
|
|
253
|
+
await execFileAsync('/usr/bin/open', [`dict://${encodeURIComponent(word)}`], {
|
|
175
254
|
timeout: HELPER_TIMEOUT_MS,
|
|
176
255
|
encoding: 'utf8'
|
|
177
256
|
})
|
|
@@ -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
|
|
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
package/native/dsh-lookup-dict.m
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-lookup-dict —
|
|
2
|
+
* dsh-lookup-dict — macOS system-dictionary bridge for dsh-plugin-look-up.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* That is the same
|
|
7
|
-
*
|
|
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>
|
|
11
|
-
* dsh-lookup-dict --
|
|
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
|
|
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("
|
|
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 *
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
4
|
-
"description": "macOS
|
|
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
|
],
|