dsh-plugin-term-dictionary 0.0.0-stage → 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.
- package/CHANGELOG.md +189 -0
- package/LICENSE +21 -0
- package/README.md +776 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +13354 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +614 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1187 -0
- package/lib/core/entries.js +454 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +372 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +366 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +397 -0
- package/lib/core/styles.js +574 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +382 -0
- package/lib/core/views.js +2428 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Import and export: the dictionary as a file, and a file as dictionary entries.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is PURE — no store, no document, no React. The decisions the feature is made of
|
|
7
|
+
* live in this one file so they can be checked without a browser: what a file contains, which
|
|
8
|
+
* entries a scope selects, and what an import does with an entry it cannot take.
|
|
9
|
+
*
|
|
10
|
+
* Groups are `domain`
|
|
11
|
+
* -------------------
|
|
12
|
+
* "Import as a group" and "export by category" both mean the entry's `domain`, which every entry
|
|
13
|
+
* already has, which the panel already shows, and which the merged document already carries. A
|
|
14
|
+
* separate folder concept would have meant a schema change in the one part of this plugin that has
|
|
15
|
+
* tombstones and union-merge rules to get wrong — for no expressive gain the data was asking for.
|
|
16
|
+
*
|
|
17
|
+
* An import never surprises
|
|
18
|
+
* -------------------------
|
|
19
|
+
* The dictionary refuses to re-collect a term it has deleted (that is what its tombstones are for),
|
|
20
|
+
* so an import does NOT silently reinstate one: a file is not a reason to override a decision the
|
|
21
|
+
* user made by hand. Existing entries are left alone for the same reason — the local explanation may
|
|
22
|
+
* be edited, pinned or better than the file's. What the import does instead is report exactly what it
|
|
23
|
+
* took and what it passed over, because "12 imported" and "12 imported, 3 skipped" are different
|
|
24
|
+
* facts and only one of them is the truth.
|
|
25
|
+
*
|
|
26
|
+
* Retracting a deletion is a decision, so it is asked for
|
|
27
|
+
* ------------------------------------------------------
|
|
28
|
+
* `includeDeleted` is how the user says "and bring back the ones I struck out". It is an explicit
|
|
29
|
+
* opt-in rather than a default because the tombstone list IS the blacklist; emptying it because a
|
|
30
|
+
* file happened to contain those terms would be the import overruling the user. When it is set, the
|
|
31
|
+
* entries move into `accepted` and the caller writes them through the store's ordinary save path —
|
|
32
|
+
* which stamps a crafted revival strictly past the deletion, the one thing that makes a revived
|
|
33
|
+
* entry survive the next merge.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The envelope's marker, so a wrong file is refused with a sentence rather than a stack. */
|
|
37
|
+
const TRANSFER_KIND = "dsh-term-dictionary";
|
|
38
|
+
|
|
39
|
+
/** The envelope version. Bumped if the shape ever changes in a way an older reader must know about. */
|
|
40
|
+
const TRANSFER_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
/** The feedback report's own marker, so it is never mistaken for a dictionary to import. */
|
|
43
|
+
const FEEDBACK_KIND = "dsh-term-dictionary-feedback";
|
|
44
|
+
|
|
45
|
+
/** The feedback report's version. */
|
|
46
|
+
const FEEDBACK_VERSION = 1;
|
|
47
|
+
|
|
48
|
+
/** The longest a term may be in an imported file, mirroring the collect path's limit. */
|
|
49
|
+
const MAX_TERM_LENGTH = 120;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The fields one entry contributes to a file.
|
|
53
|
+
*
|
|
54
|
+
* A deliberate subset: what a reader needs to use the term, and nothing about this machine. `seen`,
|
|
55
|
+
* `lastSeenAt` and `createdAt` are local history — importing them would claim a term was seen here
|
|
56
|
+
* when it was not, and would make two files of the same dictionary differ for no useful reason.
|
|
57
|
+
*
|
|
58
|
+
* @param entry - an entry from the store.
|
|
59
|
+
* @returns the portable record.
|
|
60
|
+
*/
|
|
61
|
+
function toRecord(entry) {
|
|
62
|
+
const definition = entry?.definition ?? {};
|
|
63
|
+
const record = {
|
|
64
|
+
term: typeof entry?.term === "string" ? entry.term : "",
|
|
65
|
+
key: typeof entry?.key === "string" ? entry.key : "",
|
|
66
|
+
definition: {
|
|
67
|
+
zh: typeof definition.zh === "string" ? definition.zh : "",
|
|
68
|
+
gloss: typeof definition.gloss === "string" ? definition.gloss : "",
|
|
69
|
+
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
70
|
+
}
|
|
71
|
+
};
|
|
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;
|
|
76
|
+
if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
|
|
77
|
+
if (entry?.pinned === true) record.pinned = true;
|
|
78
|
+
if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
|
|
79
|
+
return record;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The domains present in a set of entries, with how many entries each holds.
|
|
84
|
+
*
|
|
85
|
+
* This is the checklist the export offers, so it must come from the data rather than from a
|
|
86
|
+
* hard-coded list: the categories that exist are the categories the user made.
|
|
87
|
+
*
|
|
88
|
+
* @param entries - the entries to survey.
|
|
89
|
+
* @returns `[{ domain, count }]`, most-populated first, then alphabetical.
|
|
90
|
+
*/
|
|
91
|
+
function domainsIn(entries) {
|
|
92
|
+
const counts = new Map();
|
|
93
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
94
|
+
const domain = typeof entry?.domain === "string" ? entry.domain.trim() : "";
|
|
95
|
+
if (domain === "") continue;
|
|
96
|
+
counts.set(domain, (counts.get(domain) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
return [...counts.entries()]
|
|
99
|
+
.map(([domain, count]) => ({ domain, count }))
|
|
100
|
+
.sort((left, right) => right.count - left.count || left.domain.localeCompare(right.domain));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the file's contents.
|
|
105
|
+
*
|
|
106
|
+
* @param entries - every entry the caller may export.
|
|
107
|
+
* @param options - `scope` (`"all"` or `"domains"`) and `domains` (the chosen ones).
|
|
108
|
+
* @param now - the timestamp to stamp, for a deterministic check.
|
|
109
|
+
* @returns the envelope, ready to be serialised.
|
|
110
|
+
*/
|
|
111
|
+
function buildExport(entries, options, now) {
|
|
112
|
+
const all = Array.isArray(entries) ? entries : [];
|
|
113
|
+
const scope = options?.scope === "domains" ? "domains" : "all";
|
|
114
|
+
const chosen = new Set(Array.isArray(options?.domains) ? options.domains : []);
|
|
115
|
+
// An export of "domains" with nothing chosen is an empty file, not a whole one: selecting no
|
|
116
|
+
// categories is a statement, and quietly sending everything would be the wrong reading of it.
|
|
117
|
+
const picked = scope === "all" ? all : all.filter((entry) => chosen.has(typeof entry?.domain === "string" ? entry.domain.trim() : ""));
|
|
118
|
+
return {
|
|
119
|
+
kind: TRANSFER_KIND,
|
|
120
|
+
version: TRANSFER_VERSION,
|
|
121
|
+
exportedAt: typeof now === "number" ? now : Date.now(),
|
|
122
|
+
scope,
|
|
123
|
+
domains: scope === "domains" ? [...chosen].sort() : domainsIn(all).map((entry) => entry.domain),
|
|
124
|
+
entries: picked.map(toRecord)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read one record from a file into an entry-shaped object.
|
|
130
|
+
*
|
|
131
|
+
* Tolerant about the envelope and about extra keys, strict about the one thing that identifies an
|
|
132
|
+
* entry: a term. A file whose entries have no terms is not a dictionary, and saying so is better
|
|
133
|
+
* than importing four hundred empty rows.
|
|
134
|
+
*
|
|
135
|
+
* Provenance is deliberately NOT read from the file. The store's save path stamps every entry the
|
|
136
|
+
* user asked for as `user`, and that is the only source the merge counts as intent — a file claiming
|
|
137
|
+
* `source: "auto"` would produce an entry that the next sync quietly drops. The file's own `source`
|
|
138
|
+
* field is therefore recorded by {@link toRecord} for a human reader and ignored here.
|
|
139
|
+
*
|
|
140
|
+
* @param raw - one record from the file.
|
|
141
|
+
* @returns `{ term, key, definition, domain, aliases, pinned }`, or null.
|
|
142
|
+
*/
|
|
143
|
+
function toEntry(raw) {
|
|
144
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
145
|
+
const term = typeof raw.term === "string" ? raw.term.trim() : "";
|
|
146
|
+
if (term === "" || term.length > MAX_TERM_LENGTH) return null;
|
|
147
|
+
const definition = raw.definition !== null && typeof raw.definition === "object" ? raw.definition : {};
|
|
148
|
+
const entry = {
|
|
149
|
+
term,
|
|
150
|
+
key: typeof raw.key === "string" && raw.key.trim() !== "" ? raw.key.trim() : term.toLowerCase().replace(/\s+/g, " "),
|
|
151
|
+
definition: {
|
|
152
|
+
zh: typeof definition.zh === "string" ? definition.zh : "",
|
|
153
|
+
gloss: typeof definition.gloss === "string" ? definition.gloss : "",
|
|
154
|
+
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
155
|
+
},
|
|
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() : "",
|
|
160
|
+
aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
|
|
161
|
+
pinned: raw.pinned === true
|
|
162
|
+
};
|
|
163
|
+
return entry;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse a file's text.
|
|
168
|
+
*
|
|
169
|
+
* Accepts the envelope this plugin writes, a bare array of entries, and an object with only an
|
|
170
|
+
* `entries` array — because a file someone built by hand or by another tool is still worth reading,
|
|
171
|
+
* and refusing it on the envelope alone would be pedantry with a cost.
|
|
172
|
+
*
|
|
173
|
+
* @param text - the file's contents.
|
|
174
|
+
* @returns `{ ok: true, entries, skipped }` or `{ ok: false, error }`.
|
|
175
|
+
*/
|
|
176
|
+
function parseImport(text) {
|
|
177
|
+
if (typeof text !== "string" || text.trim() === "") return { ok: false, error: "empty" };
|
|
178
|
+
let parsed = null;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(text);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { ok: false, error: "not-json" };
|
|
183
|
+
}
|
|
184
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.entries) ? parsed.entries : null;
|
|
185
|
+
if (list === null) return { ok: false, error: "no-entries" };
|
|
186
|
+
const entries = [];
|
|
187
|
+
let skipped = 0;
|
|
188
|
+
for (const raw of list) {
|
|
189
|
+
const entry = toEntry(raw);
|
|
190
|
+
if (entry === null) {
|
|
191
|
+
skipped++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
entries.push(entry);
|
|
195
|
+
}
|
|
196
|
+
if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
|
|
197
|
+
return { ok: true, entries, skipped };
|
|
198
|
+
}
|
|
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
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Decide what an import would do, without doing any of it.
|
|
260
|
+
*
|
|
261
|
+
* The policy in one place: an entry already present is left alone, an entry the user deleted is left
|
|
262
|
+
* deleted unless the caller asked for those too, and everything else is taken. Duplicates WITHIN the
|
|
263
|
+
* file collapse to one, on the same rule the store merges on — the key.
|
|
264
|
+
*
|
|
265
|
+
* @param existing - the entries the dictionary already holds.
|
|
266
|
+
* @param incoming - the entries the file offers, already parsed.
|
|
267
|
+
* @param deletedKeys - the keys the dictionary has tombstones for.
|
|
268
|
+
* @param options - `includeDeleted` also takes the entries the user deleted.
|
|
269
|
+
* @returns `{ accepted, existing, deleted, duplicates }`. `accepted` is what the caller writes.
|
|
270
|
+
*/
|
|
271
|
+
function planImport(existing, incoming, deletedKeys, options) {
|
|
272
|
+
const have = new Set();
|
|
273
|
+
for (const entry of Array.isArray(existing) ? existing : []) {
|
|
274
|
+
if (typeof entry?.key === "string" && entry.key !== "") have.add(entry.key);
|
|
275
|
+
if (typeof entry?.term === "string" && entry.term.trim() !== "") have.add(entry.term.trim().toLowerCase().replace(/\s+/g, " "));
|
|
276
|
+
}
|
|
277
|
+
const gone = deletedKeys instanceof Set ? deletedKeys : new Set(Array.isArray(deletedKeys) ? deletedKeys : []);
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const accepted = [];
|
|
280
|
+
const alreadyHave = [];
|
|
281
|
+
const wasDeleted = [];
|
|
282
|
+
let duplicates = 0;
|
|
283
|
+
for (const entry of Array.isArray(incoming) ? incoming : []) {
|
|
284
|
+
const key = typeof entry?.key === "string" && entry.key !== "" ? entry.key : "";
|
|
285
|
+
if (key === "" || seen.has(key)) {
|
|
286
|
+
duplicates++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
seen.add(key);
|
|
290
|
+
if (gone.has(key)) {
|
|
291
|
+
wasDeleted.push(entry);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (have.has(key)) {
|
|
295
|
+
alreadyHave.push(entry);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
accepted.push(entry);
|
|
299
|
+
}
|
|
300
|
+
// Opting in does not empty the `deleted` bucket by accident: it moves those entries into
|
|
301
|
+
// `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
|
|
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.
|
|
308
|
+
const retract = options?.includeDeleted === true;
|
|
309
|
+
return retract
|
|
310
|
+
? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
|
|
311
|
+
: { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The feedback report: what the user said about which entries, as a file.
|
|
316
|
+
*
|
|
317
|
+
* This is the half of feedback the host's channel cannot carry. The host records a session-level
|
|
318
|
+
* remark into the SESSION LOG — which stays on the machine that made it — so "this explanation is
|
|
319
|
+
* wrong" would never reach the person who could fix it. A report is a carrier the user controls: they
|
|
320
|
+
* export it and send it wherever they like.
|
|
321
|
+
*
|
|
322
|
+
* What it deliberately does NOT contain:
|
|
323
|
+
*
|
|
324
|
+
* - **the conversation context.** An entry remembers the sentence a term appeared in, and that
|
|
325
|
+
* sentence is a piece of a private conversation. A remark about a glossary entry is not a reason
|
|
326
|
+
* to export somebody's transcript, so the field is absent by construction rather than by a flag
|
|
327
|
+
* somebody could flip.
|
|
328
|
+
* - **the explanation**, unless `includeGloss` asks for it. The gloss is model output about a term,
|
|
329
|
+
* and it is often exactly what the remark is about — so it can be attached, but only on purpose.
|
|
330
|
+
*
|
|
331
|
+
* @param entries - every live entry; the flagged ones are selected here.
|
|
332
|
+
* @param options - `now`, `build` (the plugin build string), `includeGloss`.
|
|
333
|
+
* @returns the report envelope, ready to be serialised.
|
|
334
|
+
*/
|
|
335
|
+
function buildFeedbackReport(entries, options) {
|
|
336
|
+
const now = typeof options?.now === "number" ? options.now : Date.now();
|
|
337
|
+
const includeGloss = options?.includeGloss === true;
|
|
338
|
+
const flagged = (Array.isArray(entries) ? entries : [])
|
|
339
|
+
// A remark and a verdict are both things the user said, and a report that dropped one of them
|
|
340
|
+
// would be reporting half of what they said.
|
|
341
|
+
.filter((entry) => entry?.feedback !== null && entry?.feedback !== undefined ? true : entry?.untrusted === true)
|
|
342
|
+
// Newest remark first: the report is read top-down by whoever receives it.
|
|
343
|
+
.sort((left, right) => Math.max(right.feedbackAt ?? 0, right.untrustedAt ?? 0) - Math.max(left.feedbackAt ?? 0, left.untrustedAt ?? 0) || left.term.localeCompare(right.term));
|
|
344
|
+
return {
|
|
345
|
+
kind: FEEDBACK_KIND,
|
|
346
|
+
version: FEEDBACK_VERSION,
|
|
347
|
+
exportedAt: now,
|
|
348
|
+
// Which generation produced the report, so a receiver can tell a bug report from an old one.
|
|
349
|
+
build: typeof options?.build === "string" ? options.build : "",
|
|
350
|
+
count: flagged.length,
|
|
351
|
+
entries: flagged.map((entry) => {
|
|
352
|
+
const record = {
|
|
353
|
+
term: entry.term,
|
|
354
|
+
key: entry.key,
|
|
355
|
+
untrusted: entry.untrusted === true,
|
|
356
|
+
untrustedAt: entry.untrustedAt ?? 0
|
|
357
|
+
};
|
|
358
|
+
if (entry.feedback !== null && entry.feedback !== undefined) {
|
|
359
|
+
record.kind = entry.feedback.kind;
|
|
360
|
+
record.note = entry.feedback.note;
|
|
361
|
+
record.at = entry.feedbackAt ?? 0;
|
|
362
|
+
}
|
|
363
|
+
if (includeGloss) record.gloss = entry.definition?.gloss ?? "";
|
|
364
|
+
return record;
|
|
365
|
+
})
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = {
|
|
370
|
+
buildExport,
|
|
371
|
+
buildFeedbackReport,
|
|
372
|
+
parseImport,
|
|
373
|
+
collectFolderImport,
|
|
374
|
+
planImport,
|
|
375
|
+
domainsIn,
|
|
376
|
+
toRecord,
|
|
377
|
+
toEntry,
|
|
378
|
+
TRANSFER_KIND,
|
|
379
|
+
TRANSFER_VERSION,
|
|
380
|
+
FEEDBACK_KIND,
|
|
381
|
+
FEEDBACK_VERSION
|
|
382
|
+
};
|