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.
@@ -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
- packSources: readSources(value.packSources)
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
- if (filter === "deleted") return dictionary.listDeleted(state, query);
340
- return listEntries(state, query, filter);
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
  /**
@@ -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",
@@ -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,