dsh-plugin-term-dictionary 1.0.0 → 1.1.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.
@@ -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,