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.
@@ -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
+ };