dsh-engramory 0.2.7 → 0.3.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 +242 -80
- package/README.zh-CN.md +124 -60
- package/client.js +652 -0
- package/icon.svg +8 -0
- package/index.js +1958 -50
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -1
package/client.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engramory, browser half: the settings on the package's own page in the Plugins page,
|
|
3
|
+
* and the sidebar's Memory entry — a page listing the memory stores by name, with the two
|
|
4
|
+
* settings that matter most at its top. The settings go through the route index.js
|
|
5
|
+
* registers for them (the memory's language follows dsh's); the page lists the stores and
|
|
6
|
+
* opens a note in the desktop's own app through the memory route, so no note's text is
|
|
7
|
+
* ever read into the page.
|
|
8
|
+
*
|
|
9
|
+
* Plain script, no build step, like index.js: React comes from the page's module table,
|
|
10
|
+
* no Harness client package is loaded as a module (dsh's guidance — they change without
|
|
11
|
+
* notice), and every colour is a host theme token, so light, dark and third-party themes
|
|
12
|
+
* follow. Visible text goes through the locale service.
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-engramory',
|
|
16
|
+
factory(require) {
|
|
17
|
+
const React = require('react')
|
|
18
|
+
const h = React.createElement
|
|
19
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
20
|
+
|
|
21
|
+
const NS = 'engramory'
|
|
22
|
+
/** The Plugins page keys a package's own settings by its package name. */
|
|
23
|
+
const PACKAGE = 'dsh-engramory'
|
|
24
|
+
/** The sidebar entry and the main-column page it opens share this id. */
|
|
25
|
+
const PANEL = 'engramory'
|
|
26
|
+
/** Relative to the page, as dsh's own pages fetch, so a mounted deployment keeps its prefix. */
|
|
27
|
+
const SETTINGS_API = 'api/engramory/settings'
|
|
28
|
+
const MEMORY_API = 'api/engramory/memory'
|
|
29
|
+
const DEFAULT_LINES = 200
|
|
30
|
+
const DEFAULT_BYTES = 25600
|
|
31
|
+
/** The order the memory page shows note types in (SKILL.md §2, §2.2). */
|
|
32
|
+
const TYPES = ['user', 'feedback', 'project', 'reference', 'moment', 'upcoming', 'self', 'other']
|
|
33
|
+
|
|
34
|
+
const zh = {
|
|
35
|
+
summary: '大肥鱼模式和记忆权限、索引上限。',
|
|
36
|
+
loading: '正在读取设置…',
|
|
37
|
+
loadFailed: '读不到设置。',
|
|
38
|
+
retry: '重试',
|
|
39
|
+
unavailable: '这个 dsh 不能在页面里保存这些设置(没有配置编辑服务)。请改配置文件 cordis.patch.yml,写法见插件说明。',
|
|
40
|
+
expression: '配置文件里有设置是用 !!js 表达式写的,这里显示不了它的实际值,所以只读。请直接改 cordis.patch.yml。',
|
|
41
|
+
companion: '大肥鱼模式',
|
|
42
|
+
companionHint: '多记三类:经历、约定、我自己,用角色口吻回忆。',
|
|
43
|
+
audience: '记忆权限',
|
|
44
|
+
audiencePrivate: '专属',
|
|
45
|
+
audienceSocial: '社交',
|
|
46
|
+
audiencePrivateHint: '只有你用这个 dsh:每个对话都能用记忆。',
|
|
47
|
+
audienceSocialHint: '别人也能跟你的 AI 聊(接了 QQ、微信等):只有你本人聊、没有别人插过话的对话能用记忆。',
|
|
48
|
+
cap: '索引上限',
|
|
49
|
+
capHint: '默认 200 行 / 25600 字节,超过后 AI 要先压缩才能再写。',
|
|
50
|
+
capLines: '行',
|
|
51
|
+
capBytes: '字节',
|
|
52
|
+
save: '保存',
|
|
53
|
+
saving: '保存中…',
|
|
54
|
+
discard: '放弃修改',
|
|
55
|
+
reloading: '已保存,插件正在用新设置重新加载…',
|
|
56
|
+
saved: '已保存。',
|
|
57
|
+
savedUnconfirmed: '已保存,但重新读到的设置和刚才填的不一样(或者插件还没加载好)。请刷新页面看看。',
|
|
58
|
+
stale: '设置在别处被改过,已重新读取。请再改一次。',
|
|
59
|
+
overridden: '更高一层的配置覆盖了这些值,这里保存不了。',
|
|
60
|
+
invalid: '有一项填得不对:',
|
|
61
|
+
failed: '保存失败:',
|
|
62
|
+
panel: '记忆',
|
|
63
|
+
memoryIntro: 'AI 自己记下的东西,这里只看不改。点名字用电脑上的默认程序打开。',
|
|
64
|
+
memoryIntroNoOpen: 'AI 自己记下的东西,这里只看不改。',
|
|
65
|
+
refresh: '刷新',
|
|
66
|
+
stripNote: '说明和索引上限在「插件 → Engramory 记忆」。',
|
|
67
|
+
stripReadOnly: '这里改不了,见「插件 → Engramory 记忆」。',
|
|
68
|
+
listing: '正在读取记忆…',
|
|
69
|
+
listFailed: '读不到记忆库。',
|
|
70
|
+
listTimeout: '读取超时了,可能有工作区在连不上的网络盘上。稍后点刷新再试。',
|
|
71
|
+
empty: '还没有记忆。AI 第一次存记忆时会自动建好记忆库。',
|
|
72
|
+
pinnedRelative: '配置里固定的索引(indexPath)是相对路径,跟着每个会话的目录走,这里列不出来。改成绝对路径就能在这里看到。',
|
|
73
|
+
storeUser: '全局记忆',
|
|
74
|
+
storePinned: '固定的记忆库',
|
|
75
|
+
usage: '{lines} / {maxLines} 行 · {size} / {maxSize}',
|
|
76
|
+
usageOver: '{size} / {maxSize},超出上限',
|
|
77
|
+
usageSize: '{size} / {maxSize}',
|
|
78
|
+
sizeBytes: '{n} B',
|
|
79
|
+
sizeKB: '{n} KB',
|
|
80
|
+
overHint: '超出上限了,AI 只读得到前面一部分,该压缩了。',
|
|
81
|
+
noIndex: '还没有索引',
|
|
82
|
+
openIndex: '打开索引',
|
|
83
|
+
openFolder: '打开文件夹',
|
|
84
|
+
opened: '已打开 {name}。',
|
|
85
|
+
openUnavailable: '这台 dsh 不能在本机打开文件(比如远程访问时)。文件在上面写的位置。',
|
|
86
|
+
openMissing: '找不到这个文件了,可能刚被改名或删掉。点刷新看看。',
|
|
87
|
+
openBusy: '还在打开上一个,稍等一下再点。',
|
|
88
|
+
openFailed: '打不开:{reason}',
|
|
89
|
+
typeUser: '用户',
|
|
90
|
+
typeFeedback: '反馈',
|
|
91
|
+
typeProject: '项目',
|
|
92
|
+
typeReference: '参考',
|
|
93
|
+
typeMoment: '经历',
|
|
94
|
+
typeUpcoming: '约定',
|
|
95
|
+
typeSelf: '我自己',
|
|
96
|
+
typeOther: '其他',
|
|
97
|
+
group: '{type} · {count}',
|
|
98
|
+
archive: '归档({count})',
|
|
99
|
+
more: '文件太多,这里只列出了一部分。',
|
|
100
|
+
}
|
|
101
|
+
const en = {
|
|
102
|
+
summary: 'Companion mode and memory access, and the index cap.',
|
|
103
|
+
loading: 'Reading the settings…',
|
|
104
|
+
loadFailed: 'Could not read the settings.',
|
|
105
|
+
retry: 'Retry',
|
|
106
|
+
unavailable: 'This dsh cannot save these settings from a page (it has no config editor). Edit cordis.patch.yml instead; the plugin README shows how.',
|
|
107
|
+
expression: 'A setting in the config is written as a !!js expression, whose value this page cannot show, so it is read-only. Edit cordis.patch.yml directly.',
|
|
108
|
+
companion: 'Companion mode (大肥鱼模式)',
|
|
109
|
+
companionHint: 'Keeps three more kinds of note — moments, plans, the character itself — and recalls in character.',
|
|
110
|
+
audience: 'Memory access',
|
|
111
|
+
audiencePrivate: 'Private',
|
|
112
|
+
audienceSocial: 'Social',
|
|
113
|
+
audiencePrivateHint: 'Only you use this dsh: every conversation gets the memory.',
|
|
114
|
+
audienceSocialHint: 'Others can talk to your AI too (through QQ, WeChat…): only conversations you alone have written in get the memory.',
|
|
115
|
+
cap: 'Index cap',
|
|
116
|
+
capHint: 'Default 200 lines / 25600 bytes; past it the AI must compact before writing more.',
|
|
117
|
+
capLines: 'lines',
|
|
118
|
+
capBytes: 'bytes',
|
|
119
|
+
save: 'Save',
|
|
120
|
+
saving: 'Saving…',
|
|
121
|
+
discard: 'Discard changes',
|
|
122
|
+
reloading: 'Saved; the plugin is reloading with the new settings…',
|
|
123
|
+
saved: 'Saved.',
|
|
124
|
+
savedUnconfirmed: 'Saved, but the settings read back differ from what you entered (or the plugin has not finished loading). Reload the page to check.',
|
|
125
|
+
stale: 'The settings changed elsewhere and were read again. Make your change once more.',
|
|
126
|
+
overridden: 'A higher configuration layer overrides these values, so they cannot be saved here.',
|
|
127
|
+
invalid: 'One field is not acceptable: ',
|
|
128
|
+
failed: 'Saving failed: ',
|
|
129
|
+
panel: 'Memory',
|
|
130
|
+
memoryIntro: 'What the AI has noted down for itself — to look at, not to edit. Click a name to open it in your computer\'s default app.',
|
|
131
|
+
memoryIntroNoOpen: 'What the AI has noted down for itself — to look at, not to edit.',
|
|
132
|
+
refresh: 'Refresh',
|
|
133
|
+
stripNote: 'Explanations and the index cap: Plugins → Engramory memory.',
|
|
134
|
+
stripReadOnly: 'Cannot be changed here; see Plugins → Engramory memory.',
|
|
135
|
+
listing: 'Reading the memory…',
|
|
136
|
+
listFailed: 'Could not read the memory stores.',
|
|
137
|
+
listTimeout: 'Reading took too long — a workspace may be on a network drive that does not answer. Refresh to try again.',
|
|
138
|
+
empty: 'No memory yet. The store is created the first time the AI saves something.',
|
|
139
|
+
pinnedRelative: 'The pinned index (indexPath) is a relative path, which follows each conversation\'s directory, so it cannot be listed here. Pin an absolute path to see it here.',
|
|
140
|
+
storeUser: 'Global memory',
|
|
141
|
+
storePinned: 'Pinned store',
|
|
142
|
+
usage: '{lines} / {maxLines} lines · {size} / {maxSize}',
|
|
143
|
+
usageOver: '{size} / {maxSize}, over the cap',
|
|
144
|
+
usageSize: '{size} / {maxSize}',
|
|
145
|
+
sizeBytes: '{n} B',
|
|
146
|
+
sizeKB: '{n} KB',
|
|
147
|
+
overHint: 'Over the cap: the AI reads only the first part. Time to compact it.',
|
|
148
|
+
noIndex: 'No index yet',
|
|
149
|
+
openIndex: 'Open the index',
|
|
150
|
+
openFolder: 'Open the folder',
|
|
151
|
+
opened: 'Opened {name}.',
|
|
152
|
+
openUnavailable: 'This dsh cannot open files on its own computer (when reached remotely, say). The files are where the path above says.',
|
|
153
|
+
openMissing: 'That file is gone — renamed or deleted just now, perhaps. Refresh to see.',
|
|
154
|
+
openBusy: 'Still opening the previous one — click again in a moment.',
|
|
155
|
+
openFailed: 'Could not open it: {reason}',
|
|
156
|
+
typeUser: 'User',
|
|
157
|
+
typeFeedback: 'Feedback',
|
|
158
|
+
typeProject: 'Project',
|
|
159
|
+
typeReference: 'Reference',
|
|
160
|
+
typeMoment: 'Moments',
|
|
161
|
+
typeUpcoming: 'Coming up',
|
|
162
|
+
typeSelf: 'Myself',
|
|
163
|
+
typeOther: 'Other',
|
|
164
|
+
group: '{type} · {count}',
|
|
165
|
+
archive: 'Archive ({count})',
|
|
166
|
+
more: 'Too many files: only some are listed here.',
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const CSS = `
|
|
170
|
+
.eng-page{display:flex;flex-direction:column;gap:18px;font-size:13px;line-height:1.55;color:var(--dsw-alias-label-primary);max-width:760px;}
|
|
171
|
+
.eng-section{display:flex;flex-direction:column;gap:8px;}
|
|
172
|
+
.eng-title{font-size:14px;font-weight:600;margin:0;}
|
|
173
|
+
.eng-heading{display:flex;align-items:center;gap:10px;}
|
|
174
|
+
.eng-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-secondary);}
|
|
175
|
+
.eng-note{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary);}
|
|
176
|
+
.eng-banner{padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-size:12px;}
|
|
177
|
+
.eng-banner[data-tone="error"]{color:var(--dsw-alias-state-error-primary);}
|
|
178
|
+
.eng-banner[data-tone="ok"]{color:var(--dsw-alias-state-success-primary);}
|
|
179
|
+
.eng-segment{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover);align-self:flex-start;}
|
|
180
|
+
.eng-segment button{border:0;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:6px;padding:4px 12px;font:inherit;font-size:12px;cursor:pointer;}
|
|
181
|
+
.eng-segment button[aria-pressed="true"]{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.12);}
|
|
182
|
+
.eng-segment button:disabled{cursor:default;}
|
|
183
|
+
.eng-switch{position:relative;width:32px;height:18px;border-radius:999px;border:0;padding:0;cursor:pointer;background:var(--dsw-alias-border-l2);flex:none;}
|
|
184
|
+
.eng-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary);}
|
|
185
|
+
.eng-switch:disabled{opacity:.5;cursor:default;}
|
|
186
|
+
.eng-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .15s ease;}
|
|
187
|
+
.eng-switch[aria-checked="true"]::after{transform:translateX(14px);}
|
|
188
|
+
.eng-input{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;padding:5px 9px;font:inherit;font-size:12px;outline:none;min-width:0;}
|
|
189
|
+
.eng-input:focus{border-color:var(--dsw-alias-brand-primary);}
|
|
190
|
+
.eng-input[aria-invalid="true"]{border-color:var(--dsw-alias-state-error-primary);}
|
|
191
|
+
.eng-cap{display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
|
|
192
|
+
.eng-cap label{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px;}
|
|
193
|
+
.eng-cap input{width:110px;}
|
|
194
|
+
.eng-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:5px 14px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;}
|
|
195
|
+
.eng-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}
|
|
196
|
+
.eng-btn:disabled{opacity:.5;cursor:default;}
|
|
197
|
+
.eng-btn-primary{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff);}
|
|
198
|
+
.eng-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));opacity:.9;}
|
|
199
|
+
.eng-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
|
200
|
+
.eng-memory{box-sizing:border-box;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:24px;padding:0 clamp(24px,4vw,48px) 48px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;}
|
|
201
|
+
.eng-memory>*{box-sizing:border-box;width:100%;max-width:960px;}
|
|
202
|
+
.eng-memory-head{box-sizing:border-box;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-top:28px;}
|
|
203
|
+
[data-platform=darwin] .eng-memory-head{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px));}
|
|
204
|
+
.eng-memory-title{margin:0;font-size:20px;font-weight:500;line-height:28px;}
|
|
205
|
+
.eng-memory-intro{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;}
|
|
206
|
+
.eng-strip{box-sizing:border-box;display:flex;align-items:center;flex-wrap:wrap;gap:8px 20px;padding:10px 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg,10px);}
|
|
207
|
+
.eng-strip-item{display:inline-flex;align-items:center;gap:8px;}
|
|
208
|
+
.eng-strip-note{flex:1 1 200px;margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary);}
|
|
209
|
+
.eng-store{display:flex;flex-direction:column;gap:12px;padding-top:20px;border-top:.5px solid var(--dsw-alias-border-l2);}
|
|
210
|
+
.eng-store-head{display:flex;align-items:center;gap:6px 12px;flex-wrap:wrap;}
|
|
211
|
+
.eng-store-title{margin:0;font-size:14px;font-weight:500;line-height:22px;}
|
|
212
|
+
.eng-usage{font-size:12px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;}
|
|
213
|
+
.eng-usage[data-over="true"]{color:var(--dsw-alias-state-error-primary);}
|
|
214
|
+
.eng-store-actions{margin-left:auto;display:inline-flex;gap:8px;}
|
|
215
|
+
.eng-path{margin:-8px 0 0;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;}
|
|
216
|
+
.eng-group{display:flex;flex-direction:column;gap:2px;}
|
|
217
|
+
.eng-group-title{margin:0;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-secondary);}
|
|
218
|
+
.eng-names{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0 12px;margin:0 -8px;padding:0;list-style:none;}
|
|
219
|
+
.eng-name{display:block;box-sizing:border-box;width:100%;text-align:left;border:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:20px;padding:3px 8px;border-radius:var(--dsw-radius-sm,6px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
220
|
+
button.eng-name{cursor:pointer;}
|
|
221
|
+
button.eng-name:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}
|
|
222
|
+
button.eng-name:disabled{cursor:default;opacity:.6;}
|
|
223
|
+
.eng-name:focus-visible,.eng-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px;}
|
|
224
|
+
.eng-archive>summary{cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);}
|
|
225
|
+
.eng-archive[open]>summary{margin-bottom:2px;}
|
|
226
|
+
`
|
|
227
|
+
|
|
228
|
+
/** The settings the page edits, in the order the host computes its revision from. */
|
|
229
|
+
const settingsOf = (data) => ({
|
|
230
|
+
companion: data.companion === true,
|
|
231
|
+
audience: data.audience === 'social' ? 'social' : 'private',
|
|
232
|
+
maxLines: data.maxLines,
|
|
233
|
+
maxBytes: data.maxBytes,
|
|
234
|
+
})
|
|
235
|
+
const revisionOf = (settings) => JSON.stringify(settings)
|
|
236
|
+
const capOf = (text, max) => {
|
|
237
|
+
const value = Number(text)
|
|
238
|
+
return /^\s*\d+\s*$/.test(String(text)) && Number.isInteger(value) && value >= 1 && value <= max
|
|
239
|
+
? value
|
|
240
|
+
: undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** fetch with a deadline: a request the host never answers must not hold the page busy. */
|
|
244
|
+
async function request(url, init = {}) {
|
|
245
|
+
const deadline = new AbortController()
|
|
246
|
+
const timer = setTimeout(() => deadline.abort(), 15000)
|
|
247
|
+
try {
|
|
248
|
+
const response = await fetch(url, { cache: 'no-store', ...init, signal: deadline.signal,
|
|
249
|
+
headers: { accept: 'application/json', ...(init.headers ?? {}) } })
|
|
250
|
+
// the body is read under the same deadline: headers alone do not finish a request
|
|
251
|
+
const body = await response.json().catch(() => undefined)
|
|
252
|
+
return { ok: response.ok, status: response.status, body }
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function fetchSettings() {
|
|
259
|
+
const response = await request(SETTINGS_API)
|
|
260
|
+
if (!response.ok || response.body === undefined) throw new Error(`HTTP ${response.status}`)
|
|
261
|
+
return response.body
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The settings as the host has them, and a save that waits for the reloaded plugin to
|
|
266
|
+
* read them back. `onAdopt` hears every settings read, a save's included.
|
|
267
|
+
*/
|
|
268
|
+
function useSettings(t, onAdopt) {
|
|
269
|
+
const [state, setState] = useState({ phase: 'loading' })
|
|
270
|
+
const [busy, setBusy] = useState(false)
|
|
271
|
+
const [banner, setBanner] = useState(undefined)
|
|
272
|
+
const alive = useRef(true)
|
|
273
|
+
// a second click before the page re-renders as busy must not send a second save
|
|
274
|
+
const saving = useRef(false)
|
|
275
|
+
useEffect(() => () => { alive.current = false }, [])
|
|
276
|
+
|
|
277
|
+
const adopt = useCallback((data) => {
|
|
278
|
+
const settings = settingsOf(data)
|
|
279
|
+
setState({ phase: 'ready', data, settings })
|
|
280
|
+
onAdopt?.(settings)
|
|
281
|
+
}, [onAdopt])
|
|
282
|
+
const load = useCallback(async () => {
|
|
283
|
+
setState((s) => (s.phase === 'ready' ? s : { phase: 'loading' }))
|
|
284
|
+
try {
|
|
285
|
+
const data = await fetchSettings()
|
|
286
|
+
if (alive.current) adopt(data)
|
|
287
|
+
} catch {
|
|
288
|
+
if (alive.current) setState({ phase: 'error' })
|
|
289
|
+
}
|
|
290
|
+
}, [adopt])
|
|
291
|
+
useEffect(() => { load() }, [load])
|
|
292
|
+
|
|
293
|
+
const save = async (next) => {
|
|
294
|
+
if (saving.current) return
|
|
295
|
+
saving.current = true
|
|
296
|
+
setBusy(true)
|
|
297
|
+
setBanner(undefined)
|
|
298
|
+
try {
|
|
299
|
+
let result
|
|
300
|
+
try {
|
|
301
|
+
const response = await request(SETTINGS_API, {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'content-type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ revision: state.data.revision, ...next }),
|
|
305
|
+
})
|
|
306
|
+
result = response.body ?? { ok: false, error: 'failed', message: `HTTP ${response.status}` }
|
|
307
|
+
} catch (error) {
|
|
308
|
+
result = { ok: false, error: 'failed', message: String(error?.message ?? error) }
|
|
309
|
+
}
|
|
310
|
+
if (!alive.current) return
|
|
311
|
+
if (result.ok) {
|
|
312
|
+
// The plugin reloads with the new config; its route comes back with it.
|
|
313
|
+
setBanner({ tone: 'ok', text: t('reloading') })
|
|
314
|
+
const wanted = revisionOf(next)
|
|
315
|
+
let fresh
|
|
316
|
+
for (let i = 0; i < 20 && alive.current; i++) {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
318
|
+
try {
|
|
319
|
+
fresh = await fetchSettings()
|
|
320
|
+
if (revisionOf(settingsOf(fresh)) === wanted) break
|
|
321
|
+
} catch { /* still reloading */ }
|
|
322
|
+
}
|
|
323
|
+
if (!alive.current) return
|
|
324
|
+
if (fresh !== undefined) adopt(fresh)
|
|
325
|
+
// The save landed either way; say so plainly when what came back differs.
|
|
326
|
+
setBanner(fresh !== undefined && revisionOf(settingsOf(fresh)) === wanted
|
|
327
|
+
? { tone: 'ok', text: t('saved') }
|
|
328
|
+
: { tone: 'error', text: t('savedUnconfirmed') })
|
|
329
|
+
} else if (result.error === 'stale') {
|
|
330
|
+
await load()
|
|
331
|
+
if (alive.current) setBanner({ tone: 'error', text: t('stale') })
|
|
332
|
+
} else if (result.error === 'expression') {
|
|
333
|
+
// the profile changed under the page: read it again, which makes the page read-only
|
|
334
|
+
await load()
|
|
335
|
+
if (alive.current) setBanner({ tone: 'error', text: t('expression') })
|
|
336
|
+
} else if (result.error === 'overridden') {
|
|
337
|
+
setBanner({ tone: 'error', text: t('overridden') })
|
|
338
|
+
} else if (result.error === 'invalid') {
|
|
339
|
+
setBanner({ tone: 'error', text: t('invalid') + (result.field ?? '') })
|
|
340
|
+
} else if (result.error === 'unavailable') {
|
|
341
|
+
setBanner({ tone: 'error', text: t('unavailable') })
|
|
342
|
+
} else {
|
|
343
|
+
setBanner({ tone: 'error', text: t('failed') + (result.message ?? '') })
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
saving.current = false
|
|
347
|
+
if (alive.current) setBusy(false)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { state, busy, banner, setBanner, load, save }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A host-styled switch: role=switch with aria-checked, as the host's own. */
|
|
354
|
+
function Switch({ checked, disabled, label, onChange }) {
|
|
355
|
+
return h('button', {
|
|
356
|
+
type: 'button', role: 'switch', className: 'eng-switch', 'aria-checked': checked,
|
|
357
|
+
'aria-label': label, disabled, onClick: () => onChange(!checked),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A host-styled two-way choice: buttons with aria-pressed in one group. */
|
|
362
|
+
function Segment({ label, value, options, disabled, onChange }) {
|
|
363
|
+
return h('div', { className: 'eng-segment', role: 'group', 'aria-label': label },
|
|
364
|
+
options.map(([key, text]) => h('button', {
|
|
365
|
+
type: 'button', key, 'aria-pressed': value === key, disabled, onClick: () => onChange(key),
|
|
366
|
+
}, text)))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function CompanionSection({ draft, t, disabled, update }) {
|
|
370
|
+
return h('section', { className: 'eng-section' },
|
|
371
|
+
h('div', { className: 'eng-heading' },
|
|
372
|
+
h('h3', { className: 'eng-title' }, t('companion')),
|
|
373
|
+
h(Switch, { checked: draft.companion, disabled, label: t('companion'),
|
|
374
|
+
onChange: (on) => update({ companion: on }) })),
|
|
375
|
+
h('p', { className: 'eng-hint' }, t('companionHint')))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function AudienceSection({ draft, t, disabled, update }) {
|
|
379
|
+
return h('section', { className: 'eng-section' },
|
|
380
|
+
h('h3', { className: 'eng-title' }, t('audience')),
|
|
381
|
+
h(Segment, { label: t('audience'), value: draft.audience, disabled,
|
|
382
|
+
options: [['private', t('audiencePrivate')], ['social', t('audienceSocial')]],
|
|
383
|
+
onChange: (audience) => update({ audience }) }),
|
|
384
|
+
h('p', { className: 'eng-hint' }, t(draft.audience === 'social' ? 'audienceSocialHint' : 'audiencePrivateHint')))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function CapSection({ caps, t, disabled, setCaps }) {
|
|
388
|
+
const field = (key, label, max) => h('label', null,
|
|
389
|
+
h('input', { className: 'eng-input', inputMode: 'numeric', value: caps[key], disabled,
|
|
390
|
+
'aria-invalid': capOf(caps[key], max) === undefined,
|
|
391
|
+
onChange: (e) => setCaps({ ...caps, [key]: e.target.value }) }),
|
|
392
|
+
label)
|
|
393
|
+
return h('section', { className: 'eng-section' },
|
|
394
|
+
h('h3', { className: 'eng-title' }, t('cap')),
|
|
395
|
+
h('div', { className: 'eng-cap' },
|
|
396
|
+
field('maxLines', t('capLines'), Number.MAX_SAFE_INTEGER), field('maxBytes', t('capBytes'), Number.MAX_SAFE_INTEGER)),
|
|
397
|
+
h('p', { className: 'eng-hint' }, t('capHint')))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The whole form, with what each setting means: the package's page in the Plugins page. */
|
|
401
|
+
function SettingsForm({ t }) {
|
|
402
|
+
const [draft, setDraft] = useState(undefined)
|
|
403
|
+
const [caps, setCaps] = useState({ maxLines: String(DEFAULT_LINES), maxBytes: String(DEFAULT_BYTES) })
|
|
404
|
+
const onAdopt = useCallback((settings) => {
|
|
405
|
+
setDraft(settings)
|
|
406
|
+
setCaps({ maxLines: String(settings.maxLines), maxBytes: String(settings.maxBytes) })
|
|
407
|
+
}, [])
|
|
408
|
+
const { state, busy, banner, setBanner, load, save } = useSettings(t, onAdopt)
|
|
409
|
+
|
|
410
|
+
if (state.phase === 'loading' || draft === undefined && state.phase !== 'error') {
|
|
411
|
+
return h('p', { className: 'eng-note' }, t('loading'))
|
|
412
|
+
}
|
|
413
|
+
if (state.phase === 'error') {
|
|
414
|
+
return h('div', { className: 'eng-page' },
|
|
415
|
+
h('div', { className: 'eng-banner', 'data-tone': 'error' }, t('loadFailed')),
|
|
416
|
+
h('div', { className: 'eng-actions' }, h('button', { type: 'button', className: 'eng-btn', onClick: load }, t('retry'))))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const { data, settings } = state
|
|
420
|
+
const editable = data.editable === true
|
|
421
|
+
const disabled = !editable || busy
|
|
422
|
+
const maxLines = capOf(caps.maxLines, Number.MAX_SAFE_INTEGER)
|
|
423
|
+
const maxBytes = capOf(caps.maxBytes, Number.MAX_SAFE_INTEGER)
|
|
424
|
+
const next = { ...draft, maxLines: maxLines ?? draft.maxLines, maxBytes: maxBytes ?? draft.maxBytes }
|
|
425
|
+
const valid = maxLines !== undefined && maxBytes !== undefined
|
|
426
|
+
const dirty = revisionOf(next) !== revisionOf(settings)
|
|
427
|
+
const update = (patch) => { setDraft({ ...draft, ...patch }); setBanner(undefined) }
|
|
428
|
+
const discard = () => {
|
|
429
|
+
setDraft(settings)
|
|
430
|
+
setCaps({ maxLines: String(settings.maxLines), maxBytes: String(settings.maxBytes) })
|
|
431
|
+
setBanner(undefined)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return h('div', { className: 'eng-page' },
|
|
435
|
+
editable ? null : h('div', { className: 'eng-banner' },
|
|
436
|
+
t(data.readOnly === 'expression' ? 'expression' : 'unavailable')),
|
|
437
|
+
h(CompanionSection, { draft, t, disabled, update }),
|
|
438
|
+
// memory access is a choice inside companion mode: off, it changes nothing
|
|
439
|
+
draft.companion ? h(AudienceSection, { draft, t, disabled, update }) : null,
|
|
440
|
+
h(CapSection, { caps, t, disabled, setCaps }),
|
|
441
|
+
banner ? h('div', { className: 'eng-banner', 'data-tone': banner.tone, role: 'status' }, banner.text) : null,
|
|
442
|
+
editable ? h('div', { className: 'eng-actions' },
|
|
443
|
+
h('button', { type: 'button', className: 'eng-btn eng-btn-primary', disabled: busy || !dirty || !valid, onClick: () => save(next) },
|
|
444
|
+
busy ? t('saving') : t('save')),
|
|
445
|
+
h('button', { type: 'button', className: 'eng-btn', disabled: busy || !dirty, onClick: discard }, t('discard')))
|
|
446
|
+
: null)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function SettingsPage(props) {
|
|
450
|
+
if (props.view === 'summary') return props.t('summary')
|
|
451
|
+
return h(SettingsForm, props)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The two settings that matter most, atop the memory page, each saved as it is switched;
|
|
456
|
+
* what they mean, and the index cap, stay on the package's page.
|
|
457
|
+
*/
|
|
458
|
+
function SettingsStrip({ t }) {
|
|
459
|
+
const { state, busy, banner, load, save } = useSettings(t)
|
|
460
|
+
if (state.phase === 'error') {
|
|
461
|
+
return h('section', { className: 'eng-strip' },
|
|
462
|
+
h('p', { className: 'eng-strip-note' }, t('loadFailed')),
|
|
463
|
+
h('button', { type: 'button', className: 'eng-btn', onClick: load }, t('retry')))
|
|
464
|
+
}
|
|
465
|
+
if (state.phase !== 'ready') return null
|
|
466
|
+
const { data, settings } = state
|
|
467
|
+
const disabled = data.editable !== true || busy
|
|
468
|
+
const change = (patch) => save({ ...settings, ...patch })
|
|
469
|
+
return h('section', { className: 'eng-strip' },
|
|
470
|
+
h('div', { className: 'eng-strip-item' },
|
|
471
|
+
h('span', null, t('companion')),
|
|
472
|
+
h(Switch, { checked: settings.companion, disabled, label: t('companion'),
|
|
473
|
+
onChange: (on) => change({ companion: on }) })),
|
|
474
|
+
// memory access is a choice inside companion mode: off, it changes nothing
|
|
475
|
+
settings.companion ? h('div', { className: 'eng-strip-item' },
|
|
476
|
+
h('span', null, t('audience')),
|
|
477
|
+
h(Segment, { label: t('audience'), value: settings.audience, disabled,
|
|
478
|
+
options: [['private', t('audiencePrivate')], ['social', t('audienceSocial')]],
|
|
479
|
+
onChange: (audience) => { if (audience !== settings.audience) change({ audience }) } })) : null,
|
|
480
|
+
h('p', { className: 'eng-strip-note', role: banner ? 'status' : undefined },
|
|
481
|
+
busy ? t('saving') : banner ? banner.text : t(data.editable === true ? 'stripNote' : 'stripReadOnly')))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The memory route's listing, read again on `load`; a reply that a later read overtook is dropped. */
|
|
485
|
+
function useMemory() {
|
|
486
|
+
const [state, setState] = useState({ phase: 'loading' })
|
|
487
|
+
const alive = useRef(true)
|
|
488
|
+
const generation = useRef(0)
|
|
489
|
+
useEffect(() => () => { alive.current = false }, [])
|
|
490
|
+
const load = useCallback(async () => {
|
|
491
|
+
const mine = ++generation.current
|
|
492
|
+
setState((s) => (s.phase === 'ready' ? s : { phase: 'loading' }))
|
|
493
|
+
let next
|
|
494
|
+
try {
|
|
495
|
+
const response = await request(MEMORY_API)
|
|
496
|
+
next = response.ok && Array.isArray(response.body?.stores)
|
|
497
|
+
? { phase: 'ready', data: response.body }
|
|
498
|
+
: { phase: 'error', reason: response.status === 503 ? 'listTimeout' : 'listFailed' }
|
|
499
|
+
} catch {
|
|
500
|
+
next = { phase: 'error', reason: 'listFailed' }
|
|
501
|
+
}
|
|
502
|
+
if (alive.current && mine === generation.current) setState(next)
|
|
503
|
+
}, [])
|
|
504
|
+
useEffect(() => { load() }, [load])
|
|
505
|
+
return { state, load }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** What the page says for each way the memory route declines to open a file. */
|
|
509
|
+
const OPEN_FAILURES = {
|
|
510
|
+
unavailable: 'openUnavailable', missing: 'openMissing', busy: 'openBusy', timeout: 'listTimeout',
|
|
511
|
+
}
|
|
512
|
+
const nameOf = (file) => file.replace(/\.md$/i, '')
|
|
513
|
+
const sizeOf = (t, bytes) => (bytes < 1024 ? t('sizeBytes', { n: bytes }) : t('sizeKB', { n: (bytes / 1024).toFixed(1) }))
|
|
514
|
+
const typeKey = (type) => `type${type[0].toUpperCase()}${type.slice(1)}`
|
|
515
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
|
516
|
+
const byName = (a, b) => collator.compare(a, b)
|
|
517
|
+
|
|
518
|
+
/** One store: where it is, how full its index is, and its notes' names by type. */
|
|
519
|
+
function StoreSection({ store, cap, canOpen, open, t }) {
|
|
520
|
+
const title = store.kind === 'user' ? [t('storeUser'), ...store.titles].join(' · ')
|
|
521
|
+
: store.kind === 'pinned' ? [t('storePinned'), ...store.titles].join(' · ')
|
|
522
|
+
: store.titles.join(' · ')
|
|
523
|
+
const { index } = store
|
|
524
|
+
const sizes = index === null ? {} : { size: sizeOf(t, index.bytes), maxSize: sizeOf(t, cap.maxBytes) }
|
|
525
|
+
// lines are counted only for an index read whole; past that, its bytes say over or not
|
|
526
|
+
const usage = index === null ? t('noIndex')
|
|
527
|
+
: index.lines !== undefined ? t('usage', { lines: index.lines, maxLines: cap.maxLines, ...sizes })
|
|
528
|
+
: t(index.over ? 'usageOver' : 'usageSize', sizes)
|
|
529
|
+
const names = (list, archive) => h('ul', { className: 'eng-names' },
|
|
530
|
+
[...list].sort(byName).map((file) => h('li', { key: file },
|
|
531
|
+
canOpen
|
|
532
|
+
? h('button', { type: 'button', className: 'eng-name', title: file,
|
|
533
|
+
onClick: () => open(store, file, archive) }, nameOf(file))
|
|
534
|
+
: h('span', { className: 'eng-name', title: file }, nameOf(file)))))
|
|
535
|
+
const groups = TYPES.map((type) => [type, store.notes.filter((note) => note.type === type).map((note) => note.name)])
|
|
536
|
+
.filter(([, list]) => list.length > 0)
|
|
537
|
+
return h('section', { className: 'eng-store' },
|
|
538
|
+
h('div', { className: 'eng-store-head' },
|
|
539
|
+
h('h2', { className: 'eng-store-title' }, title),
|
|
540
|
+
h('span', { className: 'eng-usage', 'data-over': index?.over === true, title: index?.over ? t('overHint') : undefined }, usage),
|
|
541
|
+
// the folder is shown with its index selected: no index, no folder to show
|
|
542
|
+
canOpen && index !== null ? h('div', { className: 'eng-store-actions' },
|
|
543
|
+
h('button', { type: 'button', className: 'eng-btn',
|
|
544
|
+
onClick: () => open(store, index.name, false) }, t('openIndex')),
|
|
545
|
+
h('button', { type: 'button', className: 'eng-btn', onClick: () => open(store, undefined, false) },
|
|
546
|
+
t('openFolder'))) : null),
|
|
547
|
+
h('p', { className: 'eng-path' }, store.dir),
|
|
548
|
+
index?.over ? h('p', { className: 'eng-note' }, t('overHint')) : null,
|
|
549
|
+
...groups.map(([type, list]) => h('div', { className: 'eng-group', key: type },
|
|
550
|
+
h('h3', { className: 'eng-group-title' }, t('group', { type: t(typeKey(type)), count: list.length })),
|
|
551
|
+
names(list, false))),
|
|
552
|
+
store.archive.length > 0 ? h('details', { className: 'eng-archive' },
|
|
553
|
+
h('summary', null, t('archive', { count: store.archive.length })),
|
|
554
|
+
names(store.archive, true)) : null,
|
|
555
|
+
store.more ? h('p', { className: 'eng-note' }, t('more')) : null)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The sidebar's Memory page: the stores recall can use, by name, and two settings. */
|
|
559
|
+
function MemoryPage({ t }) {
|
|
560
|
+
const { state, load } = useMemory()
|
|
561
|
+
const [status, setStatus] = useState(undefined)
|
|
562
|
+
const alive = useRef(true)
|
|
563
|
+
useEffect(() => () => { alive.current = false }, [])
|
|
564
|
+
|
|
565
|
+
const open = async (store, file, archive) => {
|
|
566
|
+
setStatus(undefined)
|
|
567
|
+
let reply
|
|
568
|
+
try {
|
|
569
|
+
const response = await request(MEMORY_API, {
|
|
570
|
+
method: 'POST',
|
|
571
|
+
headers: { 'content-type': 'application/json' },
|
|
572
|
+
body: JSON.stringify({ store: store.id, ...(file === undefined ? {} : { file }), ...(archive ? { archive: true } : {}) }),
|
|
573
|
+
})
|
|
574
|
+
reply = response.body ?? { ok: false, error: 'failed', message: `HTTP ${response.status}` }
|
|
575
|
+
} catch (error) {
|
|
576
|
+
reply = { ok: false, error: 'failed', message: String(error?.message ?? error) }
|
|
577
|
+
}
|
|
578
|
+
if (!alive.current) return
|
|
579
|
+
setStatus(reply.ok ? { tone: 'ok', text: t('opened', { name: file === undefined ? store.dir : nameOf(file) }) }
|
|
580
|
+
: Object.hasOwn(OPEN_FAILURES, reply.error) ? { tone: 'error', text: t(OPEN_FAILURES[reply.error]) }
|
|
581
|
+
: { tone: 'error', text: t('openFailed', { reason: reply.message ?? reply.error ?? '' }) })
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const data = state.phase === 'ready' ? state.data : undefined
|
|
585
|
+
const canOpen = data?.canOpen === true
|
|
586
|
+
let body
|
|
587
|
+
if (state.phase === 'loading') body = h('p', { className: 'eng-note' }, t('listing'))
|
|
588
|
+
else if (state.phase === 'error') body = h('div', { className: 'eng-banner', 'data-tone': 'error' }, t(state.reason))
|
|
589
|
+
else {
|
|
590
|
+
body = [
|
|
591
|
+
data.pinned === 'relative' ? h('p', { className: 'eng-note', key: 'pinned' }, t('pinnedRelative')) : null,
|
|
592
|
+
data.stores.length === 0 && data.pinned === undefined ? h('p', { className: 'eng-note', key: 'empty' }, t('empty')) : null,
|
|
593
|
+
...data.stores.map((store) => h(StoreSection, { key: store.id, store, cap: data.cap, canOpen, open, t })),
|
|
594
|
+
data.more ? h('p', { className: 'eng-note', key: 'more' }, t('more')) : null,
|
|
595
|
+
]
|
|
596
|
+
}
|
|
597
|
+
return h('div', { className: 'eng-memory' },
|
|
598
|
+
h('header', { className: 'eng-memory-head' },
|
|
599
|
+
h('div', null,
|
|
600
|
+
h('h1', { className: 'eng-memory-title' }, t('panel')),
|
|
601
|
+
h('p', { className: 'eng-memory-intro' }, t(data !== undefined && !canOpen ? 'memoryIntroNoOpen' : 'memoryIntro'))),
|
|
602
|
+
h('button', { type: 'button', className: 'eng-btn', onClick: () => { setStatus(undefined); load() } }, t('refresh'))),
|
|
603
|
+
h(SettingsStrip, { t }),
|
|
604
|
+
status ? h('div', { className: 'eng-banner', 'data-tone': status.tone, role: 'status' }, status.text) : null,
|
|
605
|
+
data !== undefined && !canOpen && data.stores.length > 0 ? h('p', { className: 'eng-note' }, t('openUnavailable')) : null,
|
|
606
|
+
body)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The sidebar entry's icon: a book, drawn in the row's own colour at the size it asks for. */
|
|
610
|
+
function MemoryIcon({ size }) {
|
|
611
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
612
|
+
strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
613
|
+
h('path', { d: 'M6 3.5h11.5v14H7.25A1.75 1.75 0 0 0 5.5 19.25V5A1.5 1.5 0 0 1 6 3.5z' }),
|
|
614
|
+
h('path', { d: 'M5.5 19.25A1.75 1.75 0 0 0 7.25 21h10.25v-3.5' }),
|
|
615
|
+
h('path', { d: 'M9 8h5.5M9 11h3.5' }))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
inject: ['slots', 'locale'],
|
|
620
|
+
apply(ctx) {
|
|
621
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-engramory: dictionaries')
|
|
622
|
+
ctx.effect(() => {
|
|
623
|
+
const tag = document.createElement('style')
|
|
624
|
+
tag.dataset.plugin = 'dsh-engramory'
|
|
625
|
+
tag.dataset.pluginCss = 'dsh-engramory/settings.css'
|
|
626
|
+
tag.textContent = CSS
|
|
627
|
+
document.head.appendChild(tag)
|
|
628
|
+
return () => tag.remove()
|
|
629
|
+
}, 'dsh-engramory: styles')
|
|
630
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
|
631
|
+
name: 'plugins.bundle.config',
|
|
632
|
+
key: PACKAGE,
|
|
633
|
+
locale: NS,
|
|
634
|
+
}, SettingsPage))
|
|
635
|
+
const t = ctx.locale.bind(NS)
|
|
636
|
+
ctx.slots.inject('main', () => ctx.slots.register({
|
|
637
|
+
name: 'main',
|
|
638
|
+
key: PANEL,
|
|
639
|
+
locale: NS,
|
|
640
|
+
}, MemoryPage))
|
|
641
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
642
|
+
name: 'sidebar.panellist',
|
|
643
|
+
id: PANEL,
|
|
644
|
+
// after dsh's own entries (Plugins 0, Automations 10)
|
|
645
|
+
order: 20,
|
|
646
|
+
locale: NS,
|
|
647
|
+
label: () => t('panel'),
|
|
648
|
+
}, MemoryIcon))
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
})
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="6" y="6" width="52" height="52" rx="14" fill="#2f6fd6"/>
|
|
3
|
+
<path d="M20 17h20a6 6 0 0 1 6 6v24H26a6 6 0 0 0-6 6V17z" fill="#ffffff" opacity="0.95"/>
|
|
4
|
+
<path d="M20 53a6 6 0 0 1 6-6h20v6H26z" fill="#ffffff" opacity="0.6"/>
|
|
5
|
+
<rect x="26" y="24" width="14" height="3" rx="1.5" fill="#2f6fd6"/>
|
|
6
|
+
<rect x="26" y="31" width="10" height="3" rx="1.5" fill="#2f6fd6"/>
|
|
7
|
+
<rect x="26" y="38" width="12" height="3" rx="1.5" fill="#2f6fd6"/>
|
|
8
|
+
</svg>
|