dsh-plugin-term-dictionary 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -23
- package/README.md +8 -4
- package/lib/client.js +1091 -59
- package/lib/core/copy.js +88 -0
- package/lib/core/dictionary.js +204 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/hover.js +40 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +54 -4
- package/lib/core/styles.js +19 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +481 -26
- package/package.json +1 -1
package/lib/core/settings.js
CHANGED
|
@@ -23,12 +23,34 @@ const SETTINGS_KEY = "dsh-plugin-term-dictionary:settings:v1";
|
|
|
23
23
|
/** The most sources the list will hold. A UI bound, not a security one. */
|
|
24
24
|
const MAX_SOURCES = 12;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The source a fresh install starts with: this plugin's own repository, as a static file.
|
|
28
|
+
*
|
|
29
|
+
* Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
|
|
30
|
+
* to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
|
|
31
|
+
* server we run — is the thing this design refuses to have, so the default is an ordinary source like
|
|
32
|
+
* any other: visible in the list, removable in one press, and fetched only when the reader asks (the
|
|
33
|
+
* page refreshes it on first open, which is a request to the source THEY have configured).
|
|
34
|
+
*
|
|
35
|
+
* `@main` rather than a tag because a default should follow the packs the repository actually has:
|
|
36
|
+
* pinned to a tag, a new pack would need a plugin release before anyone could see it.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* The URL rule, imported rather than restated: the settings store, the page and the host's transport
|
|
28
42
|
* must agree on what a usable source is, and two copies of that rule is how one of them drifts.
|
|
29
43
|
*/
|
|
30
44
|
const { refuseUrl } = require("./pack.js");
|
|
31
45
|
|
|
46
|
+
const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
|
|
47
|
+
|
|
48
|
+
/** The two row-height readings: equal cards, or each card as tall as its own text. */
|
|
49
|
+
const ENTRY_ROWS = ["uniform", "compact"];
|
|
50
|
+
|
|
51
|
+
/** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
|
|
52
|
+
const MIN_COLUMN_PX = 260;
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* The three switches and their defaults.
|
|
34
56
|
*
|
|
@@ -80,6 +102,23 @@ const DEFAULT_SETTINGS = {
|
|
|
80
102
|
* the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
|
|
81
103
|
*/
|
|
82
104
|
hoverOutMs: 0,
|
|
105
|
+
/**
|
|
106
|
+
* How many columns the entry list is laid out in.
|
|
107
|
+
*
|
|
108
|
+
* `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
|
|
109
|
+
* short, a single column of them is a very long strip to read, and a narrow panel simply gets one
|
|
110
|
+
* column back. The fixed values exist for a reader who wants the layout to stop moving under them.
|
|
111
|
+
*/
|
|
112
|
+
entryColumns: "auto",
|
|
113
|
+
/**
|
|
114
|
+
* How tall a row of the entry list is.
|
|
115
|
+
*
|
|
116
|
+
* `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
|
|
117
|
+
* empty strip is left under the short ones. `compact` lets each keep its own height, which reads
|
|
118
|
+
* tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
|
|
119
|
+
* is why it is a preference rather than a constant.
|
|
120
|
+
*/
|
|
121
|
+
entryRows: "uniform",
|
|
83
122
|
/**
|
|
84
123
|
* The term-pack sources the user added: https URLs of static `index.json` files.
|
|
85
124
|
*
|
|
@@ -88,8 +127,13 @@ const DEFAULT_SETTINGS = {
|
|
|
88
127
|
* is not configuration. Validated by the same rule the host applies before it fetches anything
|
|
89
128
|
* ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
|
|
90
129
|
* usable source is.
|
|
130
|
+
*
|
|
131
|
+
* The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
|
|
132
|
+
* the default"; an empty array means "the reader removed them all", which is honoured (§
|
|
133
|
+
* `normalizeSettings`) — the two are different statements and a store that conflated them would
|
|
134
|
+
* resurrect the default every time somebody cleared the list.
|
|
91
135
|
*/
|
|
92
|
-
packSources: []
|
|
136
|
+
packSources: [DEFAULT_PACK_SOURCE]
|
|
93
137
|
};
|
|
94
138
|
|
|
95
139
|
/** The accepted minimum term lengths, for the panel's cycling control. */
|
|
@@ -195,7 +239,13 @@ function normalizeSettings(raw) {
|
|
|
195
239
|
collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
|
|
196
240
|
hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
|
|
197
241
|
hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
|
|
198
|
-
|
|
242
|
+
entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
|
|
243
|
+
entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
|
|
244
|
+
// Absent means "never touched" and gets the shipped default; an empty array means the reader
|
|
245
|
+
// removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
|
|
246
|
+
// identity, so handing back the shared default array would make one page's edit appear in
|
|
247
|
+
// another page's defaults.
|
|
248
|
+
packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
|
|
199
249
|
};
|
|
200
250
|
}
|
|
201
251
|
|
|
@@ -303,6 +353,10 @@ module.exports = {
|
|
|
303
353
|
DEFAULT_SETTINGS,
|
|
304
354
|
normalizeSettings,
|
|
305
355
|
MAX_SOURCES,
|
|
356
|
+
DEFAULT_PACK_SOURCE,
|
|
357
|
+
ENTRY_COLUMNS,
|
|
358
|
+
ENTRY_ROWS,
|
|
359
|
+
MIN_COLUMN_PX,
|
|
306
360
|
EXPLAIN_LANGS,
|
|
307
361
|
EXPLAIN_DEPTHS,
|
|
308
362
|
COLLECT_LENGTHS,
|
package/lib/core/store.js
CHANGED
|
@@ -201,6 +201,44 @@ function createDictionaryStore(options) {
|
|
|
201
201
|
return result.entry;
|
|
202
202
|
},
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Move a group, and everything under it, to another path.
|
|
206
|
+
* @param from - the path to move.
|
|
207
|
+
* @param to - the path to move it to.
|
|
208
|
+
* @returns a promise resolving to `{ moved, error }`.
|
|
209
|
+
*/
|
|
210
|
+
async renameGroup(from, to) {
|
|
211
|
+
const result = dictionary.renameGroup(state, from, to, { now: now() });
|
|
212
|
+
if (result.moved > 0) await commit(result.state);
|
|
213
|
+
return { moved: result.moved, error: result.error };
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Delete a group by deleting everything inside it, which is the only way a group can be deleted.
|
|
218
|
+
* @param from - the path to empty.
|
|
219
|
+
* @returns a promise resolving to `{ removed, error }`.
|
|
220
|
+
*/
|
|
221
|
+
async deleteGroup(from) {
|
|
222
|
+
const result = dictionary.deleteGroup(state, from, { now: now() });
|
|
223
|
+
if (result.removed > 0) await commit(result.state);
|
|
224
|
+
return { removed: result.removed, error: result.error };
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Bring a deleted term back, as an explicit act.
|
|
229
|
+
*
|
|
230
|
+
* See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
|
|
231
|
+
* withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
|
|
232
|
+
* @param idOrTerm - the entry id, term or alias.
|
|
233
|
+
* @param patch - the text to restore it with, if any.
|
|
234
|
+
* @returns a promise resolving to `{ entry, restored }`.
|
|
235
|
+
*/
|
|
236
|
+
async restoreEntry(idOrTerm, patch) {
|
|
237
|
+
const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
|
|
238
|
+
if (result.restored) await commit(result.state);
|
|
239
|
+
return { entry: result.entry, restored: result.restored };
|
|
240
|
+
},
|
|
241
|
+
|
|
204
242
|
/**
|
|
205
243
|
* Record that a term appeared in a message, without overwriting a definition
|
|
206
244
|
* the user wrote.
|
|
@@ -327,17 +365,29 @@ function createDictionaryStore(options) {
|
|
|
327
365
|
return this.saveEntry(found.term, patch);
|
|
328
366
|
},
|
|
329
367
|
|
|
368
|
+
/**
|
|
369
|
+
* The groups directly inside one path, each with how many entries it holds.
|
|
370
|
+
* @param prefix - the path to look inside, `""` for the top level.
|
|
371
|
+
* @returns `[{ group, name, count }]`.
|
|
372
|
+
*/
|
|
373
|
+
groups(prefix) {
|
|
374
|
+
return dictionary.groupsIn(state.entries, prefix);
|
|
375
|
+
},
|
|
376
|
+
|
|
330
377
|
/**
|
|
331
378
|
* The visible list for the panel.
|
|
332
379
|
* @param query - search text.
|
|
333
380
|
* @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
|
|
381
|
+
* @param group - the group path to look inside, `""` for the top level.
|
|
334
382
|
* @returns the ordered entries. For `deleted`, the tombstones.
|
|
335
383
|
*/
|
|
336
|
-
list(query, filter) {
|
|
384
|
+
list(query, filter, group) {
|
|
385
|
+
const at = group === undefined || group === null ? "" : String(group);
|
|
337
386
|
// The deleted view is a different LIST rather than a different filter: tombstones live in
|
|
338
|
-
// `backing`, not in `entries`, so no predicate over the live list could ever show one.
|
|
339
|
-
|
|
340
|
-
return
|
|
387
|
+
// `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
|
|
388
|
+
// still applies — standing in a folder and asking what was deleted there means that folder.
|
|
389
|
+
if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
|
|
390
|
+
return listEntries(state, query, filter, at);
|
|
341
391
|
},
|
|
342
392
|
|
|
343
393
|
/**
|
package/lib/core/styles.js
CHANGED
|
@@ -124,6 +124,25 @@ const applyStyles = {
|
|
|
124
124
|
boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
|
|
125
125
|
},
|
|
126
126
|
rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
|
|
127
|
+
/** Where you are, and the way back up: a directory you can only enter is a trap. */
|
|
128
|
+
breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
|
|
129
|
+
breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
|
|
130
|
+
breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
|
|
131
|
+
breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
|
|
132
|
+
breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
|
|
133
|
+
/** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
|
|
134
|
+
groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
|
|
135
|
+
groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
|
|
136
|
+
/** One line saying what is inside a group, so the card is not a name over a blank space. */
|
|
137
|
+
groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
138
|
+
/**
|
|
139
|
+
* The row's action toolbar: the second line, always.
|
|
140
|
+
*
|
|
141
|
+
* On the title line the controls moved from row to row with the length of the term and the number of
|
|
142
|
+
* badges; a line of their own puts every row's controls in the same place and keeps them out of the
|
|
143
|
+
* way of the text they act on.
|
|
144
|
+
*/
|
|
145
|
+
rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
|
|
127
146
|
termButton: {
|
|
128
147
|
appearance: "none",
|
|
129
148
|
background: "transparent",
|
package/lib/core/transfer.js
CHANGED
|
@@ -70,6 +70,9 @@ function toRecord(entry) {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
|
|
73
|
+
// A group travels through an export, so a backup restores the structure it was filed under; it does NOT
|
|
74
|
+
// travel in a pack, where the reader organizes entries their own way (see `pack.js`'s allowlist).
|
|
75
|
+
if (typeof entry?.group === "string" && entry.group !== "") record.group = entry.group;
|
|
73
76
|
if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
|
|
74
77
|
if (entry?.pinned === true) record.pinned = true;
|
|
75
78
|
if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
|
|
@@ -151,6 +154,9 @@ function toEntry(raw) {
|
|
|
151
154
|
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
152
155
|
},
|
|
153
156
|
domain: typeof raw.domain === "string" ? raw.domain.trim() : "",
|
|
157
|
+
// A group is read back from a file like any other label the user assigned — that is what makes an
|
|
158
|
+
// export a backup of the STRUCTURE and not only of the words.
|
|
159
|
+
group: typeof raw.group === "string" ? raw.group.trim() : "",
|
|
154
160
|
aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
|
|
155
161
|
pinned: raw.pinned === true
|
|
156
162
|
};
|
|
@@ -191,6 +197,64 @@ function parseImport(text) {
|
|
|
191
197
|
return { ok: true, entries, skipped };
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The folder path a file sits in, as a group path.
|
|
202
|
+
*
|
|
203
|
+
* The picked folder's own name IS the first group: choosing a folder called `packs` is how you say "file
|
|
204
|
+
* these under `packs`", and dropping the name would put everything at the top level — which is precisely
|
|
205
|
+
* the structure the user just picked a folder to express.
|
|
206
|
+
*
|
|
207
|
+
* @param path - a `relative/path/file.json`, as `webkitRelativePath` reports it.
|
|
208
|
+
* @returns the group path.
|
|
209
|
+
*/
|
|
210
|
+
function folderPathOf(path) {
|
|
211
|
+
const parts = String(path)
|
|
212
|
+
.split("/")
|
|
213
|
+
.filter((part) => part !== "");
|
|
214
|
+
// Everything but the file name.
|
|
215
|
+
return parts.slice(0, -1).join("/");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read a whole directory into one import.
|
|
220
|
+
*
|
|
221
|
+
* A folder is a mixed bag: several exported dictionaries, somebody's downloaded pack files, an
|
|
222
|
+
* `index.json` that lists packs rather than holding entries, and often a README or a screenshot. So this
|
|
223
|
+
* reports what it could NOT use instead of refusing the folder — the alternative is a user with twenty
|
|
224
|
+
* files and one bad one, told "no" with no idea which file it was. Files that are not JSON are not
|
|
225
|
+
* reported at all: they are not mistakes.
|
|
226
|
+
*
|
|
227
|
+
* @param files - `[{ path, text }]`, the directory's files as the browser hands them over.
|
|
228
|
+
* @param options - `useFolderAsGroup` files each entry under the folder it came from, which is the shape
|
|
229
|
+
* people actually keep: one folder per topic, and the reason the option exists at all.
|
|
230
|
+
* @returns `{ entries, files, used, skipped }`, where `skipped` is `[{ name, error }]`.
|
|
231
|
+
*/
|
|
232
|
+
function collectFolderImport(files, options) {
|
|
233
|
+
const list = Array.isArray(files) ? files : [];
|
|
234
|
+
const entries = [];
|
|
235
|
+
const skipped = [];
|
|
236
|
+
const useFolder = options?.useFolderAsGroup === true;
|
|
237
|
+
let used = 0;
|
|
238
|
+
let jsonFiles = 0;
|
|
239
|
+
for (const file of list) {
|
|
240
|
+
const path = typeof file?.path === "string" ? file.path : "";
|
|
241
|
+
if (!/\.json$/i.test(path)) continue;
|
|
242
|
+
jsonFiles++;
|
|
243
|
+
const parsed = parseImport(typeof file?.text === "string" ? file.text : "");
|
|
244
|
+
if (parsed.ok !== true) {
|
|
245
|
+
skipped.push({ name: path, error: parsed.error });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
used++;
|
|
249
|
+
const folder = useFolder ? folderPathOf(path) : "";
|
|
250
|
+
for (const entry of parsed.entries) {
|
|
251
|
+
const hasGroup = typeof entry.group === "string" && entry.group !== "";
|
|
252
|
+
entries.push(folder !== "" && !hasGroup ? { ...entry, group: folder } : entry);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { entries, files: jsonFiles, used, skipped };
|
|
256
|
+
}
|
|
257
|
+
|
|
194
258
|
/**
|
|
195
259
|
* Decide what an import would do, without doing any of it.
|
|
196
260
|
*
|
|
@@ -236,10 +300,15 @@ function planImport(existing, incoming, deletedKeys, options) {
|
|
|
236
300
|
// Opting in does not empty the `deleted` bucket by accident: it moves those entries into
|
|
237
301
|
// `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
|
|
238
302
|
// means it.
|
|
303
|
+
//
|
|
304
|
+
// `revived` names the accepted entries that were previously DELETED, separately from the ones that are
|
|
305
|
+
// simply new. The caller must not treat the two the same: a new term is a write, while a deleted term is
|
|
306
|
+
// a RESTORATION — a decision that has to beat the tombstone, not an edit that hopes to. Running both
|
|
307
|
+
// through the same write is how "a term that was collected and then deleted cannot be restored" happened.
|
|
239
308
|
const retract = options?.includeDeleted === true;
|
|
240
309
|
return retract
|
|
241
|
-
? { accepted: [...accepted, ...wasDeleted], existing: alreadyHave, deleted: [], duplicates }
|
|
242
|
-
: { accepted, existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
310
|
+
? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
|
|
311
|
+
: { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
243
312
|
}
|
|
244
313
|
|
|
245
314
|
/**
|
|
@@ -301,6 +370,7 @@ module.exports = {
|
|
|
301
370
|
buildExport,
|
|
302
371
|
buildFeedbackReport,
|
|
303
372
|
parseImport,
|
|
373
|
+
collectFolderImport,
|
|
304
374
|
planImport,
|
|
305
375
|
domainsIn,
|
|
306
376
|
toRecord,
|