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,454 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The dictionary entry: its shape, its construction, and the rules for merging
|
|
5
|
+
* two versions of it.
|
|
6
|
+
*
|
|
7
|
+
* One entry per normalized term. The entry is the durable record a user curates;
|
|
8
|
+
* everything the detector knows about a word is derived from it or from the
|
|
9
|
+
* built-in glossary, never stored twice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const core = require("./core.js");
|
|
13
|
+
|
|
14
|
+
/** Where an entry's definition came from, weakest first. */
|
|
15
|
+
const SOURCES = ["auto", "heuristic", "llm", "user", "import"];
|
|
16
|
+
|
|
17
|
+
/** Human-readable source labels, localized at render time. */
|
|
18
|
+
const SOURCE_RANK = {
|
|
19
|
+
auto: 0,
|
|
20
|
+
heuristic: 1,
|
|
21
|
+
llm: 2,
|
|
22
|
+
user: 3,
|
|
23
|
+
import: 1
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Upper bound on one stored definition, so a runaway model cannot bloat the file. */
|
|
27
|
+
const MAX_DEFINITION_CHARS = 600;
|
|
28
|
+
|
|
29
|
+
/** Upper bound on stored context, which is evidence rather than content. */
|
|
30
|
+
const MAX_CONTEXT_CHARS = 400;
|
|
31
|
+
|
|
32
|
+
/** Upper bound on a feedback note, which is a remark rather than an explanation. */
|
|
33
|
+
const MAX_NOTE_CHARS = 200;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Upper bound on an entry's group path.
|
|
37
|
+
*
|
|
38
|
+
* A path rather than a name: a group can hold groups, because that is what a directory does and what a
|
|
39
|
+
* folder import produces. One string covers any depth, which is why nothing else had to be added to the
|
|
40
|
+
* document to get nesting.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_GROUP_CHARS = 120;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a user can say is wrong with an entry.
|
|
46
|
+
*
|
|
47
|
+
* A closed set, because the note travels: the feedback report and (later) a shared pack carry these
|
|
48
|
+
* ids, and free-text categories would make them unreadable to whoever receives them. `other` is the
|
|
49
|
+
* escape hatch, and a note is always allowed on top.
|
|
50
|
+
*/
|
|
51
|
+
const FEEDBACK_KINDS = ["wrong-gloss", "wrong-term", "should-not-collect", "other"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read one feedback value into the stored shape.
|
|
55
|
+
*
|
|
56
|
+
* An unknown kind becomes `other` rather than refusing the record: a remark whose category this
|
|
57
|
+
* version does not know is still a remark the user made, and dropping it would lose their words to a
|
|
58
|
+
* vocabulary change.
|
|
59
|
+
*
|
|
60
|
+
* @param value - `{ kind, note }`, or null to mean "nothing to say".
|
|
61
|
+
* @returns `{ kind, note }`, or null.
|
|
62
|
+
*/
|
|
63
|
+
function normalizeFeedback(value) {
|
|
64
|
+
if (value === null || typeof value !== "object") return null;
|
|
65
|
+
return {
|
|
66
|
+
kind: FEEDBACK_KINDS.includes(value.kind) ? value.kind : "other",
|
|
67
|
+
note: clampText(value.note, MAX_NOTE_CHARS)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the user has said anything about this entry.
|
|
73
|
+
*
|
|
74
|
+
* Two independent facts count: a remark (`feedback`) and a verdict on the explanation
|
|
75
|
+
* (`untrusted`). They are deliberately separate — "here is what is wrong" and "stop treating this as
|
|
76
|
+
* authoritative" are different claims, and the second is what the automatic explainer obeys.
|
|
77
|
+
*
|
|
78
|
+
* @param entry - a normalized entry.
|
|
79
|
+
* @returns true when either is set.
|
|
80
|
+
*/
|
|
81
|
+
function isFlagged(entry) {
|
|
82
|
+
return entry?.feedback !== null && entry?.feedback !== undefined ? true : entry?.untrusted === true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Clamp a string field to a maximum length without cutting mid-surrogate.
|
|
87
|
+
* @param value - the raw value.
|
|
88
|
+
* @param limit - maximum characters to keep.
|
|
89
|
+
* @returns the trimmed, clamped string (empty for non-strings).
|
|
90
|
+
*/
|
|
91
|
+
function clampText(value, limit) {
|
|
92
|
+
if (typeof value !== "string") return "";
|
|
93
|
+
const trimmed = value.trim();
|
|
94
|
+
if (trimmed.length <= limit) return trimmed;
|
|
95
|
+
return `${trimmed.slice(0, limit - 1).trimEnd()}…`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize an untrusted value into a dictionary entry.
|
|
100
|
+
*
|
|
101
|
+
* Every read path (host file, HTTP body, local storage) goes through this, so a
|
|
102
|
+
* corrupt or older record can never introduce a malformed entry into the store.
|
|
103
|
+
*
|
|
104
|
+
* @param value - a candidate entry as plain data.
|
|
105
|
+
* @param options - `now` overrides the timestamp used for missing fields.
|
|
106
|
+
* @returns a complete entry, or null when the record has no usable term.
|
|
107
|
+
*/
|
|
108
|
+
function normalizeEntry(value, options) {
|
|
109
|
+
if (value === null || typeof value !== "object") return null;
|
|
110
|
+
const term = typeof value.term === "string" ? value.term.replace(/\s+/g, " ").trim() : "";
|
|
111
|
+
if (term === "") return null;
|
|
112
|
+
const key = core.normalizeTerm(term);
|
|
113
|
+
if (key === "") return null;
|
|
114
|
+
const now = typeof options?.now === "number" ? options.now : Date.now();
|
|
115
|
+
const definition = value.definition !== null && typeof value.definition === "object" ? value.definition : {};
|
|
116
|
+
const seen = typeof value.seen === "number" && Number.isFinite(value.seen) && value.seen > 0 ? Math.floor(value.seen) : 1;
|
|
117
|
+
const aliases = Array.isArray(value.aliases)
|
|
118
|
+
? [...new Set(value.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "").map((alias) => alias.trim()))].slice(0, 8)
|
|
119
|
+
: [];
|
|
120
|
+
return {
|
|
121
|
+
version: 1,
|
|
122
|
+
id: typeof value.id === "string" && value.id !== "" ? value.id : core.termId(key),
|
|
123
|
+
term,
|
|
124
|
+
key,
|
|
125
|
+
aliases,
|
|
126
|
+
definition: {
|
|
127
|
+
zh: clampText(definition.zh, 60),
|
|
128
|
+
gloss: clampText(definition.gloss, MAX_DEFINITION_CHARS),
|
|
129
|
+
usage: clampText(definition.usage, MAX_DEFINITION_CHARS),
|
|
130
|
+
notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
|
|
131
|
+
},
|
|
132
|
+
domain: clampText(value.domain, 24),
|
|
133
|
+
/**
|
|
134
|
+
* The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
|
|
135
|
+
*
|
|
136
|
+
* A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
|
|
137
|
+
* the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
|
|
138
|
+
* attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
|
|
139
|
+
* the group order-dependently.
|
|
140
|
+
*/
|
|
141
|
+
group: clampText(value.group, MAX_GROUP_CHARS),
|
|
142
|
+
// When the group was last decided, epoch milliseconds, 0 for "never moved".
|
|
143
|
+
//
|
|
144
|
+
// The third use of this pattern, and for the reason the other two exist: a decision the user can
|
|
145
|
+
// make and unmake cannot be carried by content-merging rules.
|
|
146
|
+
// When the user restored this term, epoch milliseconds, 0 for never.
|
|
147
|
+
//
|
|
148
|
+
// The named evidence a merge weighs against a deletion, so a restoration does not have to win a
|
|
149
|
+
// contest over a clock — see `restoreEntry`.
|
|
150
|
+
restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
|
|
151
|
+
groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
|
|
152
|
+
source: SOURCES.includes(value.source) ? value.source : "auto",
|
|
153
|
+
confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
|
|
154
|
+
createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
|
|
155
|
+
updatedAt: typeof value.updatedAt === "number" && value.updatedAt > 0 ? value.updatedAt : now,
|
|
156
|
+
seen,
|
|
157
|
+
lastSeenAt: typeof value.lastSeenAt === "number" && value.lastSeenAt > 0 ? value.lastSeenAt : now,
|
|
158
|
+
context: clampText(value.context, MAX_CONTEXT_CHARS),
|
|
159
|
+
sessionId: typeof value.sessionId === "string" ? clampText(value.sessionId, 80) : "",
|
|
160
|
+
pinned: value.pinned === true,
|
|
161
|
+
// When the pin was last decided, epoch milliseconds, 0 for "never pinned".
|
|
162
|
+
//
|
|
163
|
+
// The boolean needs its OWN clock because `updatedAt` belongs to the definition:
|
|
164
|
+
// the merge advances `updatedAt` only when the arriving content wins, so a
|
|
165
|
+
// pin-only edit carries no timestamp at all — `upsertEntry` merges, sees no
|
|
166
|
+
// definition change, and keeps the old value. Without `pinnedAt` there is nothing
|
|
167
|
+
// for two sides to compare, and the merge can only guess (it used to OR, which
|
|
168
|
+
// made unpinning impossible).
|
|
169
|
+
pinnedAt: typeof value.pinnedAt === "number" && Number.isFinite(value.pinnedAt) && value.pinnedAt > 0 ? Math.floor(value.pinnedAt) : 0,
|
|
170
|
+
// A tombstone: epoch milliseconds of the deletion, or 0 for a live entry.
|
|
171
|
+
// Without it a union merge can only ever add, so a deletion made in the page
|
|
172
|
+
// would be undone by the next round trip. See `mergeRecords`.
|
|
173
|
+
deletedAt:
|
|
174
|
+
typeof value.deletedAt === "number" && Number.isFinite(value.deletedAt) && value.deletedAt > 0 ? Math.floor(value.deletedAt) : 0,
|
|
175
|
+
// What the user said about this entry, and when they last said it.
|
|
176
|
+
//
|
|
177
|
+
// Same reason as `pinnedAt`: the remark is a DECISION the user can retract, so the decision
|
|
178
|
+
// needs a clock of its own. `updatedAt` follows the definition, and a remark changes no
|
|
179
|
+
// definition — without `feedbackAt` a retraction could not be told from silence, and the copy
|
|
180
|
+
// that still carries the remark would win every merge.
|
|
181
|
+
feedback: normalizeFeedback(value.feedback),
|
|
182
|
+
feedbackAt: typeof value.feedbackAt === "number" && Number.isFinite(value.feedbackAt) && value.feedbackAt > 0 ? Math.floor(value.feedbackAt) : 0,
|
|
183
|
+
// "Do not treat this explanation as authoritative." The automatic explainer obeys it; see
|
|
184
|
+
// `requestExplanation` in the shell. Stamped for the same reason as `feedbackAt`, since it is
|
|
185
|
+
// a verdict the user can take back.
|
|
186
|
+
untrusted: value.untrusted === true,
|
|
187
|
+
untrustedAt: typeof value.untrustedAt === "number" && Number.isFinite(value.untrustedAt) && value.untrustedAt > 0 ? Math.floor(value.untrustedAt) : 0,
|
|
188
|
+
// Transient merge directives, consumed by the merge and never stored.
|
|
189
|
+
//
|
|
190
|
+
// `observed` marks the record a sighting produced, so a sighting counts once
|
|
191
|
+
// and a document load counts zero. `edited` marks the record an explicit edit
|
|
192
|
+
// produced, which is the only thing allowed to advance `updatedAt`: without
|
|
193
|
+
// that distinction a merge would stamp the current time onto content it merely
|
|
194
|
+
// received, and the deletion rule would then compare a deletion against a
|
|
195
|
+
// timestamp that never reflected an edit.
|
|
196
|
+
...(value.observed === true ? { observed: true } : {}),
|
|
197
|
+
...(value.edited === true ? { edited: true } : {})
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether an entry is a tombstone rather than a live dictionary entry.
|
|
203
|
+
* @param entry - a normalized entry.
|
|
204
|
+
* @returns true when the entry records a deletion.
|
|
205
|
+
*/
|
|
206
|
+
function isTombstone(entry) {
|
|
207
|
+
return (entry?.deletedAt ?? 0) > 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a new entry for a term the dictionary does not have yet.
|
|
212
|
+
*
|
|
213
|
+
* The definition is filled from the best source available at call time: an
|
|
214
|
+
* explicit user definition, then the built-in glossary, then nothing (the UI
|
|
215
|
+
* offers to generate one). `source` records which of those it was, so the UI can
|
|
216
|
+
* show an auto-detected entry as unverified instead of pretending it is curated.
|
|
217
|
+
*
|
|
218
|
+
* @param input - term plus whatever the caller already knows about it.
|
|
219
|
+
* @param glossary - the glossary record for the term, if any.
|
|
220
|
+
* @param options - `now` overrides the creation timestamp.
|
|
221
|
+
* @returns a complete entry.
|
|
222
|
+
* @throws {TypeError} when `input.term` is missing or blank.
|
|
223
|
+
*/
|
|
224
|
+
function createEntry(input, glossary, options) {
|
|
225
|
+
const term = typeof input?.term === "string" ? input.term.replace(/\s+/g, " ").trim() : "";
|
|
226
|
+
if (term === "") throw new TypeError("createEntry requires a non-empty term");
|
|
227
|
+
const now = typeof options?.now === "number" ? options.now : Date.now();
|
|
228
|
+
const provided = input.definition ?? {};
|
|
229
|
+
const hasUserDefinition = typeof provided.gloss === "string" && provided.gloss.trim() !== "";
|
|
230
|
+
const fromGlossary = !hasUserDefinition && glossary !== undefined && typeof glossary.gloss === "string" && glossary.gloss.trim() !== "";
|
|
231
|
+
const definition = hasUserDefinition
|
|
232
|
+
? {
|
|
233
|
+
zh: clampText(provided.zh ?? "", 60),
|
|
234
|
+
gloss: clampText(provided.gloss, MAX_DEFINITION_CHARS),
|
|
235
|
+
usage: clampText(provided.usage ?? "", MAX_DEFINITION_CHARS),
|
|
236
|
+
notes: clampText(provided.notes ?? "", MAX_DEFINITION_CHARS)
|
|
237
|
+
}
|
|
238
|
+
: fromGlossary
|
|
239
|
+
? {
|
|
240
|
+
zh: clampText(provided.zh ?? glossary.zh ?? "", 60),
|
|
241
|
+
gloss: clampText(glossary.gloss, MAX_DEFINITION_CHARS),
|
|
242
|
+
usage: clampText(provided.usage ?? "", MAX_DEFINITION_CHARS),
|
|
243
|
+
notes: clampText(provided.notes ?? "", MAX_DEFINITION_CHARS)
|
|
244
|
+
}
|
|
245
|
+
: { zh: clampText(provided.zh ?? "", 60), gloss: clampText(provided.gloss ?? "", MAX_DEFINITION_CHARS), usage: "", notes: "" };
|
|
246
|
+
const source = input.source !== undefined && SOURCES.includes(input.source)
|
|
247
|
+
? input.source
|
|
248
|
+
: hasUserDefinition
|
|
249
|
+
? "user"
|
|
250
|
+
: fromGlossary
|
|
251
|
+
? "heuristic"
|
|
252
|
+
: "auto";
|
|
253
|
+
return normalizeEntry({
|
|
254
|
+
term,
|
|
255
|
+
// A brand-new record is not an edit of anything, so it carries no transient
|
|
256
|
+
// directive. Without this, spreading a record that had one would inherit it.
|
|
257
|
+
observed: input.observed === true,
|
|
258
|
+
edited: false,
|
|
259
|
+
aliases: input.aliases ?? [],
|
|
260
|
+
definition,
|
|
261
|
+
domain: input.domain ?? (glossary !== undefined ? glossary.domain : ""),
|
|
262
|
+
source,
|
|
263
|
+
confidence: input.confidence ?? (fromGlossary ? 0.8 : source === "user" ? 1 : 0.45),
|
|
264
|
+
createdAt: now,
|
|
265
|
+
updatedAt: now,
|
|
266
|
+
seen: 1,
|
|
267
|
+
lastSeenAt: now,
|
|
268
|
+
context: input.context ?? "",
|
|
269
|
+
sessionId: input.sessionId ?? "",
|
|
270
|
+
pinned: input.pinned === true,
|
|
271
|
+
pinnedAt: input.pinned === true ? now : 0
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether a patch actually changes the stored definition, so a repeated
|
|
277
|
+
* auto-detection does not rewrite the file on every message.
|
|
278
|
+
* @param entry - the stored entry.
|
|
279
|
+
* @param patch - the candidate replacement fields.
|
|
280
|
+
* @returns true when at least one definition field or the domain differs.
|
|
281
|
+
*/
|
|
282
|
+
function definitionChanged(entry, patch) {
|
|
283
|
+
const next = patch.definition ?? {};
|
|
284
|
+
if ((patch.domain ?? entry.domain) !== entry.domain) return true;
|
|
285
|
+
return ["zh", "gloss", "usage", "notes"].some((field) => {
|
|
286
|
+
const value = next[field];
|
|
287
|
+
if (value === undefined) return false;
|
|
288
|
+
return clampText(value, MAX_DEFINITION_CHARS) !== entry.definition[field];
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Merge one incoming entry's CONTENT into an existing one.
|
|
294
|
+
*
|
|
295
|
+
* This function deliberately does not decide deletion. It keeps whatever deletion
|
|
296
|
+
* state the current record already had, and a missing current record means there is
|
|
297
|
+
* nothing to argue with. Deletion is decided in one place — `mergeRecords` in
|
|
298
|
+
* `dictionary.js` — because two independent decisions is exactly how a merge became
|
|
299
|
+
* order-dependent: a tombstone arriving as `incoming` would resurrect a record, and
|
|
300
|
+
* the same pair merged the other way would not.
|
|
301
|
+
*
|
|
302
|
+
* Later, stronger evidence wins for the content: a user edit replaces an automatic
|
|
303
|
+
* definition, while a later automatic sighting of an entry the user already curated
|
|
304
|
+
* only bumps the sighting counters and never touches the definition.
|
|
305
|
+
*
|
|
306
|
+
* @param current - the stored record.
|
|
307
|
+
* @param incoming - the candidate record, already normalized.
|
|
308
|
+
* @param options - `now` overrides the update timestamp.
|
|
309
|
+
* @returns the merged record.
|
|
310
|
+
*/
|
|
311
|
+
function mergeEntry(current, incoming, options) {
|
|
312
|
+
if (current === undefined || current === null) return incoming;
|
|
313
|
+
const incomingRank = SOURCE_RANK[incoming.source] ?? 0;
|
|
314
|
+
const currentRank = SOURCE_RANK[current.source] ?? 0;
|
|
315
|
+
const takeDefinition =
|
|
316
|
+
incoming.definition.gloss !== "" &&
|
|
317
|
+
(current.definition.gloss === "" || (incomingRank >= currentRank && definitionChanged(current, incoming)));
|
|
318
|
+
// `updatedAt` is the time the kept content was authored, so it follows the content
|
|
319
|
+
// rather than the merge. Re-stamping it with the current time was a real defect:
|
|
320
|
+
// the deletion rule compares a deletion against this value, and a fabricated
|
|
321
|
+
// "now" made content that merely arrived look like an edit made after the
|
|
322
|
+
// deletion. Only `editEntry` advances it, and it does so before the merge.
|
|
323
|
+
const merged = {
|
|
324
|
+
...current,
|
|
325
|
+
// `deletedAt` is NOT inherited from `current`: a content merge must not carry a
|
|
326
|
+
// deletion forward, or the decision that consumes this result cannot tell a
|
|
327
|
+
// live arrival from a deleted one. Whoever merges is responsible for passing
|
|
328
|
+
// the deletion evidence separately.
|
|
329
|
+
deletedAt: 0,
|
|
330
|
+
aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
|
|
331
|
+
definition: takeDefinition ? incoming.definition : current.definition,
|
|
332
|
+
domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
|
|
333
|
+
// Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
|
|
334
|
+
// to the top level is a move, and the old rule ("an empty value never overwrites") would make the
|
|
335
|
+
// top level unreachable for anything that had ever been filed.
|
|
336
|
+
group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
|
|
337
|
+
groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
|
|
338
|
+
restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
|
|
339
|
+
// The STRONGEST provenance survives, not the arriving one. Taking the arriving
|
|
340
|
+
// source let a later automatic copy of an already-curated term downgrade it to
|
|
341
|
+
// `auto`, which then denied that term its own revival — and let its source be
|
|
342
|
+
// laundered. Provenance describes the record, and the record's best evidence of
|
|
343
|
+
// who wrote it does not expire.
|
|
344
|
+
source: incomingRank > currentRank ? incoming.source : current.source,
|
|
345
|
+
confidence: Math.max(current.confidence, incoming.confidence),
|
|
346
|
+
// The record's time belongs to the content it kept, never to content it discarded.
|
|
347
|
+
// An automatic copy that arrives newer but LOSES the content contest must not hand
|
|
348
|
+
// its timestamp to the user's text: the merge would then read the record as "the
|
|
349
|
+
// user asked for this after the deletion", and a passing sighting would undo a
|
|
350
|
+
// deletion. So the kept content's own time wins, and the later of the two is used
|
|
351
|
+
// only when the arriving content is what was kept.
|
|
352
|
+
updatedAt: takeDefinition ? Math.max(current.updatedAt ?? 0, incoming.updatedAt ?? 0) : current.updatedAt ?? 0,
|
|
353
|
+
seen: current.seen + (incoming.observed === true ? 1 : 0),
|
|
354
|
+
lastSeenAt: Math.max(current.lastSeenAt ?? 0, incoming.lastSeenAt ?? 0),
|
|
355
|
+
context: current.context !== "" ? current.context : incoming.context,
|
|
356
|
+
// `pinned` follows the most recent DECISION, not a logical OR.
|
|
357
|
+
//
|
|
358
|
+
// It was `current.pinned || incoming.pinned`, which made unpinning impossible in
|
|
359
|
+
// the running plugin: the user's unpin wrote false locally, the host's copy still
|
|
360
|
+
// said true, the merge OR-ed them back to true, and the page adopts the merged
|
|
361
|
+
// document — so the pin snapped back on every click ("钉选功能无法取消"). An OR can
|
|
362
|
+
// only ever add a pin, and a flag the user toggles needs the later decision to win.
|
|
363
|
+
//
|
|
364
|
+
// The decision is compared on `pinnedAt`, not `updatedAt`: a pin-only edit changes
|
|
365
|
+
// no definition, and `updatedAt` deliberately follows the content, so it cannot
|
|
366
|
+
// carry a pin change. `Math.max` on the timestamp keeps the result independent of
|
|
367
|
+
// merge order, which matters because both sides run this function and must
|
|
368
|
+
// converge on the same document.
|
|
369
|
+
pinned: (incoming.pinnedAt ?? 0) > (current.pinnedAt ?? 0) ? incoming.pinned === true : current.pinned === true,
|
|
370
|
+
pinnedAt: Math.max(incoming.pinnedAt ?? 0, current.pinnedAt ?? 0),
|
|
371
|
+
// The same rule for the two things the user can say about an entry: the later DECISION wins,
|
|
372
|
+
// including a decision to say nothing. An OR here would make retracting a remark impossible
|
|
373
|
+
// in exactly the way it made unpinning impossible.
|
|
374
|
+
feedback: (incoming.feedbackAt ?? 0) > (current.feedbackAt ?? 0) ? incoming.feedback : current.feedback,
|
|
375
|
+
feedbackAt: Math.max(incoming.feedbackAt ?? 0, current.feedbackAt ?? 0),
|
|
376
|
+
untrusted: (incoming.untrustedAt ?? 0) > (current.untrustedAt ?? 0) ? incoming.untrusted === true : current.untrusted === true,
|
|
377
|
+
untrustedAt: Math.max(incoming.untrustedAt ?? 0, current.untrustedAt ?? 0)
|
|
378
|
+
};
|
|
379
|
+
return merged;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Whether an entry still carries an unexplained term: it exists for tracking but
|
|
384
|
+
* the user has no explanation yet, which is what the panel offers to fix. A
|
|
385
|
+
* tombstone counts as explained so it is never offered for editing.
|
|
386
|
+
* @param entry - a normalized entry.
|
|
387
|
+
* @returns true when the entry has no definition text.
|
|
388
|
+
*/
|
|
389
|
+
function isUnexplained(entry) {
|
|
390
|
+
if ((entry.deletedAt ?? 0) > 0) return false;
|
|
391
|
+
return entry.definition.gloss === "" && entry.definition.zh === "";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* One entry as the text a person would paste somewhere.
|
|
396
|
+
*
|
|
397
|
+
* Why this exists as a function rather than as a selection: selecting the text in the panel is a
|
|
398
|
+
* moving target. The list re-sorts itself as sightings arrive, the row under the pointer re-renders,
|
|
399
|
+
* and — the defect that prompted this — the click that ENDS a selection used to open the editor on top
|
|
400
|
+
* of it, so the selection was gone before it could be copied. A copy action needs no selection at all,
|
|
401
|
+
* which is what makes it immune to all of that.
|
|
402
|
+
*
|
|
403
|
+
* The fields are chosen for pasting into a conversation or a note: the term, its Chinese name, the
|
|
404
|
+
* explanation and an example. Aliases and the domain are lookup metadata — a reader searching the
|
|
405
|
+
* panel uses them, a person pasting an entry does not want them in the middle of the sentence.
|
|
406
|
+
*
|
|
407
|
+
* @param entry - a normalized entry.
|
|
408
|
+
* @returns the text, or "" when there is no term to copy.
|
|
409
|
+
*/
|
|
410
|
+
function entryAsText(entry) {
|
|
411
|
+
if (entry === null || entry === undefined || typeof entry.term !== "string" || entry.term === "") return "";
|
|
412
|
+
const definition = entry.definition ?? {};
|
|
413
|
+
const lines = [definition.zh === undefined || definition.zh === "" ? entry.term : `${entry.term}(${definition.zh})`];
|
|
414
|
+
if (definition.gloss !== undefined && definition.gloss !== "") lines.push(definition.gloss);
|
|
415
|
+
if (definition.usage !== undefined && definition.usage !== "") lines.push(`例:${definition.usage}`);
|
|
416
|
+
return lines.join("\n");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Case-insensitive free-text match over a term, its translation, its definition
|
|
421
|
+
* and its aliases, used by the panel's search box.
|
|
422
|
+
* @param entry - a normalized entry.
|
|
423
|
+
* @param query - raw search text.
|
|
424
|
+
* @returns true when the entry matches.
|
|
425
|
+
*/
|
|
426
|
+
function matchesQuery(entry, query) {
|
|
427
|
+
const needle = typeof query === "string" ? query.trim().toLowerCase() : "";
|
|
428
|
+
if (needle === "") return true;
|
|
429
|
+
const haystack = [entry.term, entry.definition.zh, entry.definition.gloss, entry.domain, ...entry.aliases]
|
|
430
|
+
.join("\n")
|
|
431
|
+
.toLowerCase();
|
|
432
|
+
return haystack.includes(needle);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
module.exports = {
|
|
436
|
+
SOURCES,
|
|
437
|
+
FEEDBACK_KINDS,
|
|
438
|
+
SOURCE_RANK,
|
|
439
|
+
MAX_DEFINITION_CHARS,
|
|
440
|
+
MAX_CONTEXT_CHARS,
|
|
441
|
+
MAX_NOTE_CHARS,
|
|
442
|
+
MAX_GROUP_CHARS,
|
|
443
|
+
normalizeEntry,
|
|
444
|
+
normalizeFeedback,
|
|
445
|
+
entryAsText,
|
|
446
|
+
isFlagged,
|
|
447
|
+
isTombstone,
|
|
448
|
+
createEntry,
|
|
449
|
+
mergeEntry,
|
|
450
|
+
definitionChanged,
|
|
451
|
+
isUnexplained,
|
|
452
|
+
matchesQuery,
|
|
453
|
+
clampText
|
|
454
|
+
};
|