@consilioweb/spellcheck 0.10.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.
- package/LICENSE +21 -0
- package/README.md +567 -0
- package/dist/client.cjs +1711 -0
- package/dist/client.d.cts +77 -0
- package/dist/client.d.ts +77 -0
- package/dist/client.js +1702 -0
- package/dist/index.cjs +1691 -0
- package/dist/index.d.cts +268 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +1677 -0
- package/dist/views.cjs +32 -0
- package/dist/views.d.cts +11 -0
- package/dist/views.d.ts +11 -0
- package/dist/views.js +30 -0
- package/package.json +102 -0
- package/scripts/debug-check.mjs +295 -0
- package/scripts/install.mjs +236 -0
- package/scripts/uninstall.mjs +350 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1691 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/collections/SpellCheckResults.ts
|
|
4
|
+
function createSpellCheckResultsCollection() {
|
|
5
|
+
return {
|
|
6
|
+
slug: "spellcheck-results",
|
|
7
|
+
admin: {
|
|
8
|
+
hidden: true
|
|
9
|
+
},
|
|
10
|
+
access: {
|
|
11
|
+
read: ({ req }) => !!req.user,
|
|
12
|
+
create: ({ req }) => !!req.user,
|
|
13
|
+
update: ({ req }) => !!req.user,
|
|
14
|
+
delete: ({ req }) => !!req.user
|
|
15
|
+
},
|
|
16
|
+
timestamps: false,
|
|
17
|
+
fields: [
|
|
18
|
+
{
|
|
19
|
+
name: "docId",
|
|
20
|
+
type: "text",
|
|
21
|
+
required: true,
|
|
22
|
+
index: true,
|
|
23
|
+
admin: {
|
|
24
|
+
description: "ID of the checked document"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "collection",
|
|
29
|
+
type: "text",
|
|
30
|
+
required: true,
|
|
31
|
+
index: true,
|
|
32
|
+
admin: {
|
|
33
|
+
description: "Collection slug (e.g. 'pages', 'posts')"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "title",
|
|
38
|
+
type: "text",
|
|
39
|
+
admin: {
|
|
40
|
+
description: "Document title (for dashboard display)"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "slug",
|
|
45
|
+
type: "text",
|
|
46
|
+
admin: {
|
|
47
|
+
description: "Document slug"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "score",
|
|
52
|
+
type: "number",
|
|
53
|
+
required: true,
|
|
54
|
+
min: 0,
|
|
55
|
+
max: 100,
|
|
56
|
+
admin: {
|
|
57
|
+
description: "Spellcheck score (0-100, 100 = no issues)"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "issueCount",
|
|
62
|
+
type: "number",
|
|
63
|
+
required: true,
|
|
64
|
+
min: 0,
|
|
65
|
+
admin: {
|
|
66
|
+
description: "Number of issues found"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "wordCount",
|
|
71
|
+
type: "number",
|
|
72
|
+
min: 0,
|
|
73
|
+
admin: {
|
|
74
|
+
description: "Word count of the extracted text"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "issues",
|
|
79
|
+
type: "json",
|
|
80
|
+
admin: {
|
|
81
|
+
description: "JSON array of SpellCheckIssue objects"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "ignoredIssues",
|
|
86
|
+
type: "json",
|
|
87
|
+
admin: {
|
|
88
|
+
description: "JSON array of { ruleId, original } \u2014 issues ignored by the user, filtered on rescan"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "lastChecked",
|
|
93
|
+
type: "date",
|
|
94
|
+
required: true,
|
|
95
|
+
index: true,
|
|
96
|
+
defaultValue: () => (/* @__PURE__ */ new Date()).toISOString(),
|
|
97
|
+
admin: {
|
|
98
|
+
description: "Date of the last spellcheck"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/collections/SpellCheckDictionary.ts
|
|
106
|
+
function createSpellCheckDictionaryCollection() {
|
|
107
|
+
return {
|
|
108
|
+
slug: "spellcheck-dictionary",
|
|
109
|
+
admin: {
|
|
110
|
+
hidden: true
|
|
111
|
+
},
|
|
112
|
+
access: {
|
|
113
|
+
read: ({ req }) => !!req.user,
|
|
114
|
+
create: ({ req }) => !!req.user,
|
|
115
|
+
update: ({ req }) => !!req.user,
|
|
116
|
+
delete: ({ req }) => !!req.user
|
|
117
|
+
},
|
|
118
|
+
hooks: {
|
|
119
|
+
beforeValidate: [
|
|
120
|
+
({ data }) => {
|
|
121
|
+
if (data?.word && typeof data.word === "string") {
|
|
122
|
+
data.word = data.word.trim().toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
fields: [
|
|
129
|
+
{
|
|
130
|
+
name: "word",
|
|
131
|
+
type: "text",
|
|
132
|
+
required: true,
|
|
133
|
+
unique: true,
|
|
134
|
+
index: true,
|
|
135
|
+
admin: {
|
|
136
|
+
description: "Dictionary word (auto-lowercased)"
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "addedBy",
|
|
141
|
+
type: "relationship",
|
|
142
|
+
relationTo: "users",
|
|
143
|
+
admin: {
|
|
144
|
+
description: "User who added this word"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/engine/shared.ts
|
|
152
|
+
var SKIP_TYPES = /* @__PURE__ */ new Set(["code", "code-block", "codeBlock"]);
|
|
153
|
+
function isLexicalJson(value) {
|
|
154
|
+
if (!value || typeof value !== "object") return false;
|
|
155
|
+
const obj = value;
|
|
156
|
+
return Boolean(
|
|
157
|
+
obj.root && typeof obj.root === "object" || Array.isArray(obj.children) && obj.type !== void 0
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
161
|
+
"id",
|
|
162
|
+
"_order",
|
|
163
|
+
"_parent_id",
|
|
164
|
+
"_path",
|
|
165
|
+
"_locale",
|
|
166
|
+
"_uuid",
|
|
167
|
+
"blockType",
|
|
168
|
+
"blockName",
|
|
169
|
+
"icon",
|
|
170
|
+
"color",
|
|
171
|
+
"link",
|
|
172
|
+
"link_url",
|
|
173
|
+
"enable_link",
|
|
174
|
+
"image",
|
|
175
|
+
"media",
|
|
176
|
+
"form",
|
|
177
|
+
"form_id",
|
|
178
|
+
"rating",
|
|
179
|
+
"size",
|
|
180
|
+
"position",
|
|
181
|
+
"relationTo",
|
|
182
|
+
"value",
|
|
183
|
+
"updatedAt",
|
|
184
|
+
"createdAt",
|
|
185
|
+
"_status",
|
|
186
|
+
"slug",
|
|
187
|
+
"meta",
|
|
188
|
+
"publishedAt",
|
|
189
|
+
"populatedAuthors"
|
|
190
|
+
]);
|
|
191
|
+
var PLAIN_TEXT_KEYS = /* @__PURE__ */ new Set([
|
|
192
|
+
"title",
|
|
193
|
+
"description",
|
|
194
|
+
"heading",
|
|
195
|
+
"subheading",
|
|
196
|
+
"subtitle",
|
|
197
|
+
"quote",
|
|
198
|
+
"author",
|
|
199
|
+
"role",
|
|
200
|
+
"label",
|
|
201
|
+
"link_label",
|
|
202
|
+
"block_name",
|
|
203
|
+
"caption",
|
|
204
|
+
"alt",
|
|
205
|
+
"text",
|
|
206
|
+
"summary",
|
|
207
|
+
"excerpt"
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
// src/engine/lexicalParser.ts
|
|
211
|
+
function extractAllTextFromDocWithSources(doc, contentField = "content") {
|
|
212
|
+
const rawSegments = [];
|
|
213
|
+
const visited = /* @__PURE__ */ new WeakSet();
|
|
214
|
+
if (doc.title && typeof doc.title === "string") {
|
|
215
|
+
rawSegments.push({ text: doc.title, source: { type: "title" } });
|
|
216
|
+
}
|
|
217
|
+
if (doc.hero?.richText) {
|
|
218
|
+
rawSegments.push({
|
|
219
|
+
text: extractTextFromLexical(doc.hero.richText),
|
|
220
|
+
source: { type: "lexical", data: doc.hero.richText, topField: "hero" }
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (doc[contentField] && isLexicalJson(doc[contentField])) {
|
|
224
|
+
rawSegments.push({
|
|
225
|
+
text: extractTextFromLexical(doc[contentField]),
|
|
226
|
+
source: { type: "lexical", data: doc[contentField], topField: contentField }
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(doc.layout)) {
|
|
230
|
+
for (const block of doc.layout) {
|
|
231
|
+
extractBlockSegments(block, rawSegments, visited, "layout");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const segments = rawSegments.filter((s) => Boolean(s.text));
|
|
235
|
+
const fullText = segments.map((s) => s.text).join("\n").trim();
|
|
236
|
+
return { fullText, segments };
|
|
237
|
+
}
|
|
238
|
+
function extractAllTextFromDoc(doc, contentField = "content") {
|
|
239
|
+
return extractAllTextFromDocWithSources(doc, contentField).fullText;
|
|
240
|
+
}
|
|
241
|
+
function extractBlockSegments(obj, segments, visited, topField, depth = 0) {
|
|
242
|
+
if (!obj || typeof obj !== "object" || depth > 10) return;
|
|
243
|
+
if (visited.has(obj)) return;
|
|
244
|
+
visited.add(obj);
|
|
245
|
+
if (Array.isArray(obj)) {
|
|
246
|
+
for (const item of obj) {
|
|
247
|
+
extractBlockSegments(item, segments, visited, topField, depth + 1);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const record = obj;
|
|
252
|
+
for (const [key, value] of Object.entries(record)) {
|
|
253
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
254
|
+
if (isLexicalJson(value)) {
|
|
255
|
+
segments.push({
|
|
256
|
+
text: extractTextFromLexical(value),
|
|
257
|
+
source: { type: "lexical", data: value, topField }
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (typeof value === "string" && value.length > 2 && value.length < 5e3) {
|
|
262
|
+
if (/^(https?:|\/|#|\d{4}-\d{2}|[0-9a-f-]{36}|data:|mailto:)/i.test(value)) continue;
|
|
263
|
+
if (/^\{.*\}$/.test(value) || /^\[.*\]$/.test(value)) continue;
|
|
264
|
+
if (PLAIN_TEXT_KEYS.has(key)) {
|
|
265
|
+
segments.push({
|
|
266
|
+
text: value,
|
|
267
|
+
source: { type: "plain", parent: record, key, topField }
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (typeof value === "object" && value !== null) {
|
|
272
|
+
extractBlockSegments(value, segments, visited, topField, depth + 1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function extractTextFromLexical(node, maxDepth = 50) {
|
|
277
|
+
return extractRecursive(node, 0, maxDepth).trim();
|
|
278
|
+
}
|
|
279
|
+
function extractRecursive(node, depth, maxDepth) {
|
|
280
|
+
if (!node || depth > maxDepth) return "";
|
|
281
|
+
if (Array.isArray(node)) {
|
|
282
|
+
let text2 = "";
|
|
283
|
+
for (const item of node) {
|
|
284
|
+
text2 += extractRecursive(item, depth + 1, maxDepth);
|
|
285
|
+
}
|
|
286
|
+
return text2;
|
|
287
|
+
}
|
|
288
|
+
if (typeof node !== "object") return "";
|
|
289
|
+
if (node.type && SKIP_TYPES.has(node.type)) return "";
|
|
290
|
+
let text = "";
|
|
291
|
+
if (node.type === "text" && typeof node.text === "string") {
|
|
292
|
+
text += node.text;
|
|
293
|
+
}
|
|
294
|
+
if (node.type === "paragraph" || node.type === "heading" || node.type === "listitem") {
|
|
295
|
+
for (const child of node.children || []) {
|
|
296
|
+
text += extractRecursive(child, depth + 1, maxDepth);
|
|
297
|
+
}
|
|
298
|
+
text += "\n";
|
|
299
|
+
return text;
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(node.children)) {
|
|
302
|
+
for (const child of node.children) {
|
|
303
|
+
text += extractRecursive(child, depth + 1, maxDepth);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (node.root) {
|
|
307
|
+
text += extractRecursive(node.root, depth + 1, maxDepth);
|
|
308
|
+
}
|
|
309
|
+
return text;
|
|
310
|
+
}
|
|
311
|
+
function countWords(text) {
|
|
312
|
+
return text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/engine/languagetool.ts
|
|
316
|
+
var LANGUAGETOOL_API = "https://api.languagetool.org/v2/check";
|
|
317
|
+
var MAX_TEXT_LENGTH = 18e3;
|
|
318
|
+
var REQUEST_TIMEOUT = 3e4;
|
|
319
|
+
async function checkWithLanguageTool(text, language, config) {
|
|
320
|
+
if (!text.trim()) return [];
|
|
321
|
+
const truncatedText = text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
|
|
322
|
+
const disabledRules = [
|
|
323
|
+
...config.skipRules || [],
|
|
324
|
+
"WHITESPACE_RULE",
|
|
325
|
+
"COMMA_PARENTHESIS_WHITESPACE",
|
|
326
|
+
"UNPAIRED_BRACKETS"
|
|
327
|
+
].join(",");
|
|
328
|
+
const params = new URLSearchParams({
|
|
329
|
+
text: truncatedText,
|
|
330
|
+
language,
|
|
331
|
+
disabledRules
|
|
332
|
+
});
|
|
333
|
+
const controller = new AbortController();
|
|
334
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
|
335
|
+
try {
|
|
336
|
+
const response = await fetch(LANGUAGETOOL_API, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
339
|
+
body: params.toString(),
|
|
340
|
+
signal: controller.signal
|
|
341
|
+
});
|
|
342
|
+
clearTimeout(timeoutId);
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
throw new Error(`LanguageTool API error: ${response.status} ${response.statusText}`);
|
|
345
|
+
}
|
|
346
|
+
const data = await response.json();
|
|
347
|
+
return parseMatches(data.matches || []);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
clearTimeout(timeoutId);
|
|
350
|
+
if (error.name === "AbortError") {
|
|
351
|
+
console.error("[spellcheck] LanguageTool request timed out");
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
console.error("[spellcheck] LanguageTool error:", error);
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function parseMatches(matches) {
|
|
359
|
+
return matches.map((m) => ({
|
|
360
|
+
ruleId: m.rule.id,
|
|
361
|
+
category: m.rule.category.id,
|
|
362
|
+
message: m.message,
|
|
363
|
+
context: m.context.text,
|
|
364
|
+
contextOffset: m.context.offset,
|
|
365
|
+
offset: m.offset,
|
|
366
|
+
length: m.length,
|
|
367
|
+
original: m.context.text.slice(m.context.offset, m.context.offset + m.context.length),
|
|
368
|
+
replacements: m.replacements.slice(0, 3).map((r) => r.value),
|
|
369
|
+
source: "languagetool",
|
|
370
|
+
isPremium: m.rule.isPremium ?? false
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/engine/claude.ts
|
|
375
|
+
var ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
|
|
376
|
+
var REQUEST_TIMEOUT2 = 6e4;
|
|
377
|
+
var MAX_TEXT_LENGTH2 = 8e3;
|
|
378
|
+
async function checkWithClaude(text, language, apiKey) {
|
|
379
|
+
if (!text.trim() || !apiKey) return [];
|
|
380
|
+
const truncatedText = text.length > MAX_TEXT_LENGTH2 ? text.slice(0, MAX_TEXT_LENGTH2) : text;
|
|
381
|
+
const langLabel = language === "fr" ? "French" : "English";
|
|
382
|
+
const prompt = `Analyze this ${langLabel} web content for semantic issues ONLY (NOT spelling/grammar \u2014 a separate tool handles that). Check for:
|
|
383
|
+
1. Inconsistent tone or register (formal vs informal mixing)
|
|
384
|
+
2. Incoherent statements or contradictions
|
|
385
|
+
3. Awkward phrasing that a spellchecker wouldn't catch
|
|
386
|
+
4. Missing words that change meaning
|
|
387
|
+
|
|
388
|
+
Return a JSON array of issues found. Each issue: { "message": "...", "context": "10-word excerpt around issue", "original": "problematic phrase", "suggestion": "improved version", "category": "COHERENCE|TONE|PHRASING|MISSING_WORD" }
|
|
389
|
+
|
|
390
|
+
Return [] if no issues found. Be strict \u2014 only flag clear problems, not style preferences.
|
|
391
|
+
|
|
392
|
+
Text:
|
|
393
|
+
${truncatedText}`;
|
|
394
|
+
const controller = new AbortController();
|
|
395
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT2);
|
|
396
|
+
try {
|
|
397
|
+
const response = await fetch(ANTHROPIC_API, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: {
|
|
400
|
+
"Content-Type": "application/json",
|
|
401
|
+
"x-api-key": apiKey,
|
|
402
|
+
"anthropic-version": "2023-06-01"
|
|
403
|
+
},
|
|
404
|
+
body: JSON.stringify({
|
|
405
|
+
model: "claude-haiku-4-5-20251001",
|
|
406
|
+
max_tokens: 2048,
|
|
407
|
+
messages: [
|
|
408
|
+
{ role: "user", content: prompt }
|
|
409
|
+
]
|
|
410
|
+
}),
|
|
411
|
+
signal: controller.signal
|
|
412
|
+
});
|
|
413
|
+
clearTimeout(timeoutId);
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
console.error(`[spellcheck] Claude API error: ${response.status}`);
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
const data = await response.json();
|
|
419
|
+
const responseText = data.content?.[0]?.text || "[]";
|
|
420
|
+
const jsonMatch = responseText.match(/\[[\s\S]*\]/);
|
|
421
|
+
if (!jsonMatch) return [];
|
|
422
|
+
const issues = JSON.parse(jsonMatch[0]);
|
|
423
|
+
return issues.map((issue) => ({
|
|
424
|
+
ruleId: `CLAUDE_${issue.category}`,
|
|
425
|
+
category: issue.category,
|
|
426
|
+
message: issue.message,
|
|
427
|
+
context: issue.context,
|
|
428
|
+
contextOffset: issue.context ? issue.context.indexOf(issue.original) : 0,
|
|
429
|
+
offset: 0,
|
|
430
|
+
length: issue.original.length,
|
|
431
|
+
original: issue.original,
|
|
432
|
+
replacements: issue.suggestion ? [issue.suggestion] : [],
|
|
433
|
+
source: "claude"
|
|
434
|
+
}));
|
|
435
|
+
} catch (error) {
|
|
436
|
+
clearTimeout(timeoutId);
|
|
437
|
+
console.error("[spellcheck] Claude error:", error);
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/endpoints/dictionary.ts
|
|
443
|
+
function createDictionaryListHandler() {
|
|
444
|
+
return async (req) => {
|
|
445
|
+
if (!req.user) {
|
|
446
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const result = await req.payload.find({
|
|
450
|
+
collection: "spellcheck-dictionary",
|
|
451
|
+
limit: 0,
|
|
452
|
+
sort: "word",
|
|
453
|
+
overrideAccess: true
|
|
454
|
+
});
|
|
455
|
+
return Response.json({
|
|
456
|
+
words: result.docs,
|
|
457
|
+
count: result.totalDocs
|
|
458
|
+
});
|
|
459
|
+
} catch (error) {
|
|
460
|
+
console.error("[spellcheck/dictionary] List error:", error);
|
|
461
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function createDictionaryAddHandler() {
|
|
466
|
+
return async (req) => {
|
|
467
|
+
if (!req.user) {
|
|
468
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const body = await req.json().catch(() => ({}));
|
|
472
|
+
const { word, words } = body;
|
|
473
|
+
const wordsToAdd = [];
|
|
474
|
+
if (word) wordsToAdd.push(word);
|
|
475
|
+
if (Array.isArray(words)) wordsToAdd.push(...words);
|
|
476
|
+
if (wordsToAdd.length === 0) {
|
|
477
|
+
return Response.json({ error: "Provide { word } or { words: [] }" }, { status: 400 });
|
|
478
|
+
}
|
|
479
|
+
const added = [];
|
|
480
|
+
const skipped = [];
|
|
481
|
+
for (const w of wordsToAdd) {
|
|
482
|
+
const cleaned = w.trim().toLowerCase();
|
|
483
|
+
if (!cleaned) continue;
|
|
484
|
+
try {
|
|
485
|
+
const existing = await req.payload.find({
|
|
486
|
+
collection: "spellcheck-dictionary",
|
|
487
|
+
where: { word: { equals: cleaned } },
|
|
488
|
+
limit: 1,
|
|
489
|
+
overrideAccess: true
|
|
490
|
+
});
|
|
491
|
+
if (existing.docs.length > 0) {
|
|
492
|
+
skipped.push(cleaned);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
await req.payload.create({
|
|
496
|
+
collection: "spellcheck-dictionary",
|
|
497
|
+
data: {
|
|
498
|
+
word: cleaned,
|
|
499
|
+
addedBy: typeof req.user.id !== "undefined" ? req.user.id : void 0
|
|
500
|
+
},
|
|
501
|
+
overrideAccess: true
|
|
502
|
+
});
|
|
503
|
+
added.push(cleaned);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
skipped.push(cleaned);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
invalidateDictionaryCache();
|
|
509
|
+
return Response.json({ added, skipped, count: added.length });
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error("[spellcheck/dictionary] Add error:", error);
|
|
512
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function createDictionaryDeleteHandler() {
|
|
517
|
+
return async (req) => {
|
|
518
|
+
if (!req.user) {
|
|
519
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const body = await req.json().catch(() => ({}));
|
|
523
|
+
const { id: bodyId, ids } = body;
|
|
524
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
525
|
+
const queryId = url.searchParams.get("id");
|
|
526
|
+
const idsToDelete = [];
|
|
527
|
+
if (bodyId) idsToDelete.push(bodyId);
|
|
528
|
+
if (queryId) idsToDelete.push(queryId);
|
|
529
|
+
if (Array.isArray(ids)) idsToDelete.push(...ids);
|
|
530
|
+
if (idsToDelete.length === 0) {
|
|
531
|
+
return Response.json({ error: "Provide { id } or { ids: [] }" }, { status: 400 });
|
|
532
|
+
}
|
|
533
|
+
let deleted = 0;
|
|
534
|
+
for (const deleteId of idsToDelete) {
|
|
535
|
+
try {
|
|
536
|
+
await req.payload.delete({
|
|
537
|
+
collection: "spellcheck-dictionary",
|
|
538
|
+
id: deleteId,
|
|
539
|
+
overrideAccess: true
|
|
540
|
+
});
|
|
541
|
+
deleted++;
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
invalidateDictionaryCache();
|
|
546
|
+
return Response.json({ deleted });
|
|
547
|
+
} catch (error) {
|
|
548
|
+
console.error("[spellcheck/dictionary] Delete error:", error);
|
|
549
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
var cachedWords = null;
|
|
554
|
+
var cacheTimestamp = 0;
|
|
555
|
+
var CACHE_TTL = 5 * 60 * 1e3;
|
|
556
|
+
function invalidateDictionaryCache() {
|
|
557
|
+
cachedWords = null;
|
|
558
|
+
cacheTimestamp = 0;
|
|
559
|
+
}
|
|
560
|
+
async function loadDictionaryWords(payload) {
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
if (cachedWords && now - cacheTimestamp < CACHE_TTL) {
|
|
563
|
+
return cachedWords;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const result = await payload.find({
|
|
567
|
+
collection: "spellcheck-dictionary",
|
|
568
|
+
limit: 0,
|
|
569
|
+
overrideAccess: true
|
|
570
|
+
});
|
|
571
|
+
cachedWords = result.docs.map((doc) => doc.word.toLowerCase());
|
|
572
|
+
cacheTimestamp = now;
|
|
573
|
+
return cachedWords;
|
|
574
|
+
} catch {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/engine/filters.ts
|
|
580
|
+
var DEFAULT_SKIP_RULES = /* @__PURE__ */ new Set([
|
|
581
|
+
"WHITESPACE_RULE",
|
|
582
|
+
"COMMA_PARENTHESIS_WHITESPACE",
|
|
583
|
+
"UNPAIRED_BRACKETS",
|
|
584
|
+
"UPPERCASE_SENTENCE_START",
|
|
585
|
+
// Headings/titles don't start with uppercase
|
|
586
|
+
"FRENCH_WHITESPACE",
|
|
587
|
+
// Non-breaking spaces are inconsistent in CMS
|
|
588
|
+
"MORFOLOGIK_RULE_FR_FR",
|
|
589
|
+
// Overly aggressive French spelling (flags proper nouns)
|
|
590
|
+
"APOS_TYP",
|
|
591
|
+
// Typography apostrophe (curly vs straight)
|
|
592
|
+
"APOS_INCORRECT",
|
|
593
|
+
// Backtick/apostrophe in code contexts
|
|
594
|
+
"POINT_VIRGULE",
|
|
595
|
+
// Semicolon spacing
|
|
596
|
+
"DASH_RULE",
|
|
597
|
+
// Dash types (em vs en)
|
|
598
|
+
"FRENCH_WORD_REPEAT_RULE",
|
|
599
|
+
// Repetitions from heading+body extraction
|
|
600
|
+
"MOT_TRAIT_MOT",
|
|
601
|
+
// Hyphenated English tech terms (mobile-first, utility-first)
|
|
602
|
+
"PAS_DE_TRAIT_UNION",
|
|
603
|
+
// Prefix hyphenation (multi-appareils)
|
|
604
|
+
"D_N",
|
|
605
|
+
// Determiner gender mismatch on English terms (un pull, du June)
|
|
606
|
+
"DOUBLES_ESPACES",
|
|
607
|
+
// Double spaces — auto-fixable but noisy in CMS content
|
|
608
|
+
"ESPACE_ENTRE_VIRGULE_ET_MOT",
|
|
609
|
+
// Grammalecte: space after comma (often CMS extraction artifacts)
|
|
610
|
+
"ESPACE_ENTRE_POINT_ET_MOT",
|
|
611
|
+
// Grammalecte: space after period (extraction artifacts)
|
|
612
|
+
"PRONOMS_PERSONNELS_MINUSCULE",
|
|
613
|
+
// False positive from paragraph boundary extraction (heading + body)
|
|
614
|
+
"DET_MAJ_SENT_START",
|
|
615
|
+
// Same issue: paragraph boundary makes it look like missing capital
|
|
616
|
+
"FR_SPLIT_WORDS_HYPHEN"
|
|
617
|
+
// Suggests splitting hyphenated words (éco-responsable → éco responsable)
|
|
618
|
+
]);
|
|
619
|
+
var DEFAULT_SKIP_CATEGORIES = /* @__PURE__ */ new Set([
|
|
620
|
+
// English categories
|
|
621
|
+
"TYPOGRAPHY",
|
|
622
|
+
"TYPOS",
|
|
623
|
+
"STYLE",
|
|
624
|
+
// French categories (LanguageTool uses different IDs for French)
|
|
625
|
+
"CAT_TYPOGRAPHIE",
|
|
626
|
+
// French typography rules
|
|
627
|
+
"REPETITIONS_STYLE",
|
|
628
|
+
// French style/repetition suggestions
|
|
629
|
+
"CAT_REGLES_DE_BASEE"
|
|
630
|
+
// French basic rules (word repetition from CMS extraction)
|
|
631
|
+
]);
|
|
632
|
+
var SKIP_PATTERNS = [
|
|
633
|
+
/^https?:\/\//i,
|
|
634
|
+
// URLs
|
|
635
|
+
/^mailto:/i,
|
|
636
|
+
// Email links
|
|
637
|
+
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
|
|
638
|
+
// Emails
|
|
639
|
+
/^[A-Z][a-z]+[A-Z]/,
|
|
640
|
+
// CamelCase (JavaScript, TypeScript)
|
|
641
|
+
/^[A-Z]{2,}$/,
|
|
642
|
+
// All caps abbreviations (API, CSS, SEO)
|
|
643
|
+
/^\d+[\d.,]*$/,
|
|
644
|
+
// Numbers
|
|
645
|
+
/^\d+[a-zA-Z]+$/,
|
|
646
|
+
// Numbers with units (80px, 24h, mp3)
|
|
647
|
+
/^[#@]/,
|
|
648
|
+
// Hashtags, mentions
|
|
649
|
+
/^[€$£¥]/,
|
|
650
|
+
// Currency amounts
|
|
651
|
+
/^0[1-9]\d{8}$/,
|
|
652
|
+
// French phone numbers
|
|
653
|
+
/^\+?\d[\d\s.-]{8,}$/,
|
|
654
|
+
// International phone numbers
|
|
655
|
+
/^[a-z-]+\.[a-z]{2,}$/i,
|
|
656
|
+
// Domain names
|
|
657
|
+
/^`.*`$/,
|
|
658
|
+
// Inline code (backticks)
|
|
659
|
+
/^[a-z]+_[a-z]+/i,
|
|
660
|
+
// snake_case identifiers
|
|
661
|
+
/^--[a-z]/
|
|
662
|
+
// CSS custom properties (--color-primary)
|
|
663
|
+
];
|
|
664
|
+
var CODE_CONTEXT_PATTERNS = [
|
|
665
|
+
/`[^`]*$/,
|
|
666
|
+
// Inside backtick code span (opening but no close before issue)
|
|
667
|
+
/\b(npm|pnpm|yarn|git|docker|curl|wget)\b/i
|
|
668
|
+
// CLI commands
|
|
669
|
+
];
|
|
670
|
+
async function filterFalsePositives(issues, config, payload) {
|
|
671
|
+
const skipRules = /* @__PURE__ */ new Set([
|
|
672
|
+
...DEFAULT_SKIP_RULES,
|
|
673
|
+
...config.skipRules || []
|
|
674
|
+
]);
|
|
675
|
+
const skipCategories = /* @__PURE__ */ new Set([
|
|
676
|
+
...DEFAULT_SKIP_CATEGORIES,
|
|
677
|
+
...config.skipCategories || []
|
|
678
|
+
]);
|
|
679
|
+
const configWords = (config.customDictionary || []).map((w) => w.toLowerCase());
|
|
680
|
+
const dbWords = payload ? await loadDictionaryWords(payload) : [];
|
|
681
|
+
const dictionaryWords = [.../* @__PURE__ */ new Set([...configWords, ...dbWords])];
|
|
682
|
+
const dictionary = new Set(dictionaryWords);
|
|
683
|
+
return issues.filter((issue) => {
|
|
684
|
+
if (issue.isPremium) return false;
|
|
685
|
+
if (skipRules.has(issue.ruleId)) return false;
|
|
686
|
+
if (skipCategories.has(issue.category)) return false;
|
|
687
|
+
if (issue.original && dictionary.has(issue.original.toLowerCase())) return false;
|
|
688
|
+
if (issue.original) {
|
|
689
|
+
const lower = issue.original.toLowerCase();
|
|
690
|
+
for (const word of dictionaryWords) {
|
|
691
|
+
if (lower.includes(word) || word.includes(lower)) return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (issue.context) {
|
|
695
|
+
const ctxLower = issue.context.toLowerCase();
|
|
696
|
+
for (const word of dictionaryWords) {
|
|
697
|
+
if (word.includes(" ") && ctxLower.includes(word)) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (issue.original && issue.original.length <= 1 && issue.category !== "GRAMMAR") return false;
|
|
703
|
+
if (issue.original) {
|
|
704
|
+
for (const pattern of SKIP_PATTERNS) {
|
|
705
|
+
if (pattern.test(issue.original)) return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (issue.context) {
|
|
709
|
+
for (const pattern of CODE_CONTEXT_PATTERNS) {
|
|
710
|
+
if (pattern.test(issue.context)) return false;
|
|
711
|
+
}
|
|
712
|
+
if (issue.original && issue.context.includes("`" + issue.original + "`")) return false;
|
|
713
|
+
}
|
|
714
|
+
if (issue.replacements.length > 0 && issue.replacements[0] === issue.original) return false;
|
|
715
|
+
if (issue.context && issue.context.trim().length < 5) return false;
|
|
716
|
+
if (issue.ruleId.includes("REPET") || issue.category === "CAT_REGLES_DE_BASE") {
|
|
717
|
+
if (issue.original) {
|
|
718
|
+
const lower = issue.original.toLowerCase();
|
|
719
|
+
for (const word of dictionaryWords) {
|
|
720
|
+
if (lower.includes(word) || word.includes(lower)) return false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (issue.replacements.length > 0 && issue.replacements[0] === issue.original) return false;
|
|
724
|
+
if (issue.replacements.length > 0 && issue.replacements[0] === "") return false;
|
|
725
|
+
}
|
|
726
|
+
return true;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
function calculateScore(wordCount, issueCount) {
|
|
730
|
+
if (wordCount === 0) return 100;
|
|
731
|
+
if (issueCount === 0) return 100;
|
|
732
|
+
const issuesPerHundredWords = issueCount / wordCount * 100;
|
|
733
|
+
const score = Math.max(0, Math.round(100 - issuesPerHundredWords * 10));
|
|
734
|
+
return Math.min(100, score);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/endpoints/validate.ts
|
|
738
|
+
function createValidateHandler(pluginConfig) {
|
|
739
|
+
return async (req) => {
|
|
740
|
+
try {
|
|
741
|
+
if (!req.user) {
|
|
742
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
743
|
+
}
|
|
744
|
+
const body = await req.json().catch(() => ({}));
|
|
745
|
+
const {
|
|
746
|
+
id,
|
|
747
|
+
collection,
|
|
748
|
+
text: rawText,
|
|
749
|
+
language: bodyLanguage
|
|
750
|
+
} = body;
|
|
751
|
+
const language = bodyLanguage || pluginConfig.language || "fr";
|
|
752
|
+
const contentField = pluginConfig.contentField || "content";
|
|
753
|
+
let textToCheck;
|
|
754
|
+
let fetchedDoc = null;
|
|
755
|
+
if (rawText) {
|
|
756
|
+
textToCheck = rawText;
|
|
757
|
+
} else if (id && collection) {
|
|
758
|
+
const findResult = await req.payload.find({
|
|
759
|
+
collection,
|
|
760
|
+
where: { id: { equals: id } },
|
|
761
|
+
limit: 1,
|
|
762
|
+
depth: 0,
|
|
763
|
+
draft: true,
|
|
764
|
+
overrideAccess: true
|
|
765
|
+
});
|
|
766
|
+
if (!findResult.docs.length) {
|
|
767
|
+
return Response.json({ error: "Document not found" }, { status: 404 });
|
|
768
|
+
}
|
|
769
|
+
fetchedDoc = findResult.docs[0];
|
|
770
|
+
textToCheck = extractAllTextFromDoc(fetchedDoc, contentField);
|
|
771
|
+
} else {
|
|
772
|
+
return Response.json(
|
|
773
|
+
{ error: "Provide { id, collection } or { text }" },
|
|
774
|
+
{ status: 400 }
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
const wordCount = countWords(textToCheck);
|
|
778
|
+
let issues = await checkWithLanguageTool(textToCheck, language, pluginConfig);
|
|
779
|
+
if (pluginConfig.enableAiFallback && pluginConfig.anthropicApiKey) {
|
|
780
|
+
const claudeIssues = await checkWithClaude(textToCheck, language, pluginConfig.anthropicApiKey);
|
|
781
|
+
issues = [...issues, ...claudeIssues];
|
|
782
|
+
}
|
|
783
|
+
issues = await filterFalsePositives(issues, pluginConfig, req.payload);
|
|
784
|
+
let ignoredIssues = [];
|
|
785
|
+
let existingDoc = null;
|
|
786
|
+
if (id && collection) {
|
|
787
|
+
try {
|
|
788
|
+
const existing = await req.payload.find({
|
|
789
|
+
collection: "spellcheck-results",
|
|
790
|
+
where: {
|
|
791
|
+
docId: { equals: String(id) },
|
|
792
|
+
collection: { equals: collection }
|
|
793
|
+
},
|
|
794
|
+
limit: 1,
|
|
795
|
+
overrideAccess: true
|
|
796
|
+
});
|
|
797
|
+
if (existing.docs.length > 0) {
|
|
798
|
+
existingDoc = existing.docs[0];
|
|
799
|
+
ignoredIssues = Array.isArray(existingDoc.ignoredIssues) ? existingDoc.ignoredIssues : [];
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (ignoredIssues.length > 0) {
|
|
805
|
+
issues = issues.filter(
|
|
806
|
+
(issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
const score = calculateScore(wordCount, issues.length);
|
|
810
|
+
if (id && collection) {
|
|
811
|
+
const docIdStr = String(id);
|
|
812
|
+
try {
|
|
813
|
+
const title = fetchedDoc?.title || "";
|
|
814
|
+
const slug = fetchedDoc?.slug || "";
|
|
815
|
+
const resultData = {
|
|
816
|
+
docId: docIdStr,
|
|
817
|
+
collection,
|
|
818
|
+
title,
|
|
819
|
+
slug,
|
|
820
|
+
score,
|
|
821
|
+
issueCount: issues.length,
|
|
822
|
+
wordCount,
|
|
823
|
+
issues,
|
|
824
|
+
ignoredIssues,
|
|
825
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
826
|
+
};
|
|
827
|
+
if (existingDoc) {
|
|
828
|
+
await req.payload.update({
|
|
829
|
+
collection: "spellcheck-results",
|
|
830
|
+
id: existingDoc.id,
|
|
831
|
+
data: resultData,
|
|
832
|
+
overrideAccess: true
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
await req.payload.create({
|
|
836
|
+
collection: "spellcheck-results",
|
|
837
|
+
data: resultData,
|
|
838
|
+
overrideAccess: true
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
} catch (err) {
|
|
842
|
+
console.error("[spellcheck] Failed to store result:", err);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const result = {
|
|
846
|
+
docId: id ? String(id) : "",
|
|
847
|
+
collection: collection || "",
|
|
848
|
+
score,
|
|
849
|
+
issueCount: issues.length,
|
|
850
|
+
wordCount,
|
|
851
|
+
issues,
|
|
852
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
853
|
+
};
|
|
854
|
+
return Response.json(result);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
console.error("[spellcheck/validate] Error:", error);
|
|
857
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/endpoints/fix.ts
|
|
863
|
+
function fixInLexicalTree(node, targetOffset, targetLength, replacement, currentPos, depth = 0, maxDepth = 50) {
|
|
864
|
+
if (!node || depth > maxDepth) return { fixed: false, chars: 0 };
|
|
865
|
+
if (Array.isArray(node)) {
|
|
866
|
+
let chars2 = 0;
|
|
867
|
+
for (const item of node) {
|
|
868
|
+
const r = fixInLexicalTree(item, targetOffset, targetLength, replacement, currentPos + chars2, depth + 1, maxDepth);
|
|
869
|
+
chars2 += r.chars;
|
|
870
|
+
if (r.fixed) return { fixed: true, chars: chars2 };
|
|
871
|
+
}
|
|
872
|
+
return { fixed: false, chars: chars2 };
|
|
873
|
+
}
|
|
874
|
+
if (typeof node !== "object") return { fixed: false, chars: 0 };
|
|
875
|
+
if (node.type && SKIP_TYPES.has(node.type)) return { fixed: false, chars: 0 };
|
|
876
|
+
if (node.type === "text" && typeof node.text === "string") {
|
|
877
|
+
const nodeStart = currentPos;
|
|
878
|
+
const nodeEnd = currentPos + node.text.length;
|
|
879
|
+
if (targetOffset >= nodeStart && targetOffset < nodeEnd) {
|
|
880
|
+
const posInNode = targetOffset - nodeStart;
|
|
881
|
+
node.text = node.text.slice(0, posInNode) + replacement + node.text.slice(posInNode + targetLength);
|
|
882
|
+
return { fixed: true, chars: node.text.length };
|
|
883
|
+
}
|
|
884
|
+
return { fixed: false, chars: node.text.length };
|
|
885
|
+
}
|
|
886
|
+
if (node.type === "paragraph" || node.type === "heading" || node.type === "listitem") {
|
|
887
|
+
let chars2 = 0;
|
|
888
|
+
for (const child of node.children || []) {
|
|
889
|
+
const r = fixInLexicalTree(child, targetOffset, targetLength, replacement, currentPos + chars2, depth + 1, maxDepth);
|
|
890
|
+
chars2 += r.chars;
|
|
891
|
+
if (r.fixed) return { fixed: true, chars: chars2 + 1 };
|
|
892
|
+
}
|
|
893
|
+
chars2 += 1;
|
|
894
|
+
return { fixed: false, chars: chars2 };
|
|
895
|
+
}
|
|
896
|
+
let chars = 0;
|
|
897
|
+
if (Array.isArray(node.children)) {
|
|
898
|
+
for (const child of node.children) {
|
|
899
|
+
const r = fixInLexicalTree(child, targetOffset, targetLength, replacement, currentPos + chars, depth + 1, maxDepth);
|
|
900
|
+
chars += r.chars;
|
|
901
|
+
if (r.fixed) return { fixed: true, chars };
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (node.root) {
|
|
905
|
+
const r = fixInLexicalTree(node.root, targetOffset, targetLength, replacement, currentPos + chars, depth + 1, maxDepth);
|
|
906
|
+
chars += r.chars;
|
|
907
|
+
if (r.fixed) return { fixed: true, chars };
|
|
908
|
+
}
|
|
909
|
+
return { fixed: false, chars };
|
|
910
|
+
}
|
|
911
|
+
function applyFixAtOffset(segments, fullText, targetOffset, targetLength, replacement, docClone) {
|
|
912
|
+
const rawJoined = segments.map((s) => s.text).join("\n");
|
|
913
|
+
const trimOffset = rawJoined.length - rawJoined.trimStart().length;
|
|
914
|
+
const rawTargetOffset = targetOffset + trimOffset;
|
|
915
|
+
let pos = 0;
|
|
916
|
+
for (const segment of segments) {
|
|
917
|
+
const segEnd = pos + segment.text.length;
|
|
918
|
+
if (rawTargetOffset >= pos && rawTargetOffset < segEnd) {
|
|
919
|
+
const localOffset = rawTargetOffset - pos;
|
|
920
|
+
return applyFixToSegment(segment, localOffset, targetLength, replacement, docClone);
|
|
921
|
+
}
|
|
922
|
+
pos = segEnd + 1;
|
|
923
|
+
}
|
|
924
|
+
return { fixed: false, modifiedField: null };
|
|
925
|
+
}
|
|
926
|
+
function applyFixToSegment(segment, localOffset, targetLength, replacement, docClone) {
|
|
927
|
+
const { source } = segment;
|
|
928
|
+
switch (source.type) {
|
|
929
|
+
case "title": {
|
|
930
|
+
const title = docClone.title;
|
|
931
|
+
docClone.title = title.slice(0, localOffset) + replacement + title.slice(localOffset + targetLength);
|
|
932
|
+
return { fixed: true, modifiedField: "title" };
|
|
933
|
+
}
|
|
934
|
+
case "lexical": {
|
|
935
|
+
const r = fixInLexicalTree(source.data, localOffset, targetLength, replacement, 0);
|
|
936
|
+
return r.fixed ? { fixed: true, modifiedField: source.topField } : { fixed: false, modifiedField: null };
|
|
937
|
+
}
|
|
938
|
+
case "plain": {
|
|
939
|
+
const currentVal = source.parent[source.key];
|
|
940
|
+
source.parent[source.key] = currentVal.slice(0, localOffset) + replacement + currentVal.slice(localOffset + targetLength);
|
|
941
|
+
return { fixed: true, modifiedField: source.topField };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function legacyFixSubstring(docClone, original, replacement, contentField) {
|
|
946
|
+
if (typeof docClone.title === "string" && docClone.title.includes(original)) {
|
|
947
|
+
docClone.title = docClone.title.replace(original, replacement);
|
|
948
|
+
return { fixed: true, modifiedField: "title" };
|
|
949
|
+
}
|
|
950
|
+
if (docClone.hero?.richText) {
|
|
951
|
+
const state = { done: false };
|
|
952
|
+
const fixed = legacyApplyToLexical(docClone.hero.richText, original, replacement, state);
|
|
953
|
+
if (fixed) return { fixed: true, modifiedField: "hero" };
|
|
954
|
+
}
|
|
955
|
+
if (docClone[contentField]) {
|
|
956
|
+
const state = { done: false };
|
|
957
|
+
const fixed = legacyApplyToLexical(docClone[contentField], original, replacement, state);
|
|
958
|
+
if (fixed) return { fixed: true, modifiedField: contentField };
|
|
959
|
+
}
|
|
960
|
+
if (Array.isArray(docClone.layout)) {
|
|
961
|
+
for (const block of docClone.layout) {
|
|
962
|
+
const state = { done: false };
|
|
963
|
+
const fixed = legacyApplyInObject(block, original, replacement, state);
|
|
964
|
+
if (fixed) return { fixed: true, modifiedField: "layout" };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return { fixed: false, modifiedField: null };
|
|
968
|
+
}
|
|
969
|
+
function legacyApplyToLexical(node, original, replacement, state) {
|
|
970
|
+
if (!node || state.done) return false;
|
|
971
|
+
if (Array.isArray(node)) {
|
|
972
|
+
for (const item of node) {
|
|
973
|
+
if (state.done) break;
|
|
974
|
+
if (legacyApplyToLexical(item, original, replacement, state)) return true;
|
|
975
|
+
}
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
if (typeof node !== "object") return false;
|
|
979
|
+
if (node.type === "text" && typeof node.text === "string") {
|
|
980
|
+
if (!state.done && node.text.includes(original)) {
|
|
981
|
+
node.text = node.text.replace(original, replacement);
|
|
982
|
+
state.done = true;
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (Array.isArray(node.children)) {
|
|
987
|
+
for (const child of node.children) {
|
|
988
|
+
if (state.done) break;
|
|
989
|
+
if (legacyApplyToLexical(child, original, replacement, state)) return true;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (node.root && !state.done) {
|
|
993
|
+
if (legacyApplyToLexical(node.root, original, replacement, state)) return true;
|
|
994
|
+
}
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
function legacyApplyInObject(obj, original, replacement, state, depth = 0) {
|
|
998
|
+
if (!obj || typeof obj !== "object" || state.done || depth > 10) return false;
|
|
999
|
+
if (Array.isArray(obj)) {
|
|
1000
|
+
for (const item of obj) {
|
|
1001
|
+
if (state.done) break;
|
|
1002
|
+
if (legacyApplyInObject(item, original, replacement, state, depth + 1)) return true;
|
|
1003
|
+
}
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
const record = obj;
|
|
1007
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1008
|
+
if (state.done) break;
|
|
1009
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1010
|
+
const v = value;
|
|
1011
|
+
if (v.root && typeof v.root === "object") {
|
|
1012
|
+
if (legacyApplyToLexical(value, original, replacement, state)) return true;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (typeof value === "string" && value.includes(original)) {
|
|
1017
|
+
if (PLAIN_TEXT_KEYS.has(key)) {
|
|
1018
|
+
record[key] = value.replace(original, replacement);
|
|
1019
|
+
state.done = true;
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof value === "object" && value !== null) {
|
|
1024
|
+
if (legacyApplyInObject(value, original, replacement, state, depth + 1)) return true;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
function findClosestMatch(text, needle, expectedOffset) {
|
|
1030
|
+
if (!needle) return -1;
|
|
1031
|
+
const occurrences = [];
|
|
1032
|
+
let idx = text.indexOf(needle);
|
|
1033
|
+
while (idx !== -1) {
|
|
1034
|
+
occurrences.push(idx);
|
|
1035
|
+
idx = text.indexOf(needle, idx + 1);
|
|
1036
|
+
}
|
|
1037
|
+
if (occurrences.length === 0) return -1;
|
|
1038
|
+
if (occurrences.length === 1) return occurrences[0];
|
|
1039
|
+
return occurrences.reduce(
|
|
1040
|
+
(closest, curr) => Math.abs(curr - expectedOffset) < Math.abs(closest - expectedOffset) ? curr : closest
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
function createFixHandler(pluginConfig) {
|
|
1044
|
+
return async (req) => {
|
|
1045
|
+
try {
|
|
1046
|
+
if (!req.user) {
|
|
1047
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1048
|
+
}
|
|
1049
|
+
const body = await req.json();
|
|
1050
|
+
const { id, collection, original, replacement, offset, length, field } = body;
|
|
1051
|
+
if (!id || !collection || !original || replacement === void 0) {
|
|
1052
|
+
return Response.json(
|
|
1053
|
+
{ error: "Missing required fields: id, collection, original, replacement" },
|
|
1054
|
+
{ status: 400 }
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
const contentField = field || pluginConfig.contentField || "content";
|
|
1058
|
+
const findResult = await req.payload.find({
|
|
1059
|
+
collection,
|
|
1060
|
+
where: { id: { equals: id } },
|
|
1061
|
+
limit: 1,
|
|
1062
|
+
depth: 0,
|
|
1063
|
+
draft: true,
|
|
1064
|
+
overrideAccess: true
|
|
1065
|
+
});
|
|
1066
|
+
if (!findResult.docs.length) {
|
|
1067
|
+
return Response.json({ error: "Document not found" }, { status: 404 });
|
|
1068
|
+
}
|
|
1069
|
+
const doc = findResult.docs[0];
|
|
1070
|
+
const { fullText, segments } = extractAllTextFromDocWithSources(doc, contentField);
|
|
1071
|
+
let result;
|
|
1072
|
+
let method = "legacy";
|
|
1073
|
+
if (typeof offset === "number" && typeof length === "number") {
|
|
1074
|
+
const actual = fullText.slice(offset, offset + length);
|
|
1075
|
+
if (actual === original) {
|
|
1076
|
+
result = applyFixAtOffset(segments, fullText, offset, length, replacement, doc);
|
|
1077
|
+
method = "offset";
|
|
1078
|
+
} else {
|
|
1079
|
+
const foundOffset = findClosestMatch(fullText, original, offset);
|
|
1080
|
+
if (foundOffset >= 0) {
|
|
1081
|
+
console.log(
|
|
1082
|
+
`[spellcheck/fix] Offset drift corrected: "${original}" at ${foundOffset} (stored: ${offset}, drift: ${foundOffset - offset})`
|
|
1083
|
+
);
|
|
1084
|
+
result = applyFixAtOffset(segments, fullText, foundOffset, original.length, replacement, doc);
|
|
1085
|
+
method = "search";
|
|
1086
|
+
} else {
|
|
1087
|
+
console.warn(
|
|
1088
|
+
`[spellcheck/fix] "${original}" not found in extracted text (${fullText.length} chars)`
|
|
1089
|
+
);
|
|
1090
|
+
result = { fixed: false, modifiedField: null };
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!result.fixed) {
|
|
1094
|
+
result = legacyFixSubstring(doc, original, replacement, contentField);
|
|
1095
|
+
if (result.fixed) method = "legacy";
|
|
1096
|
+
}
|
|
1097
|
+
} else {
|
|
1098
|
+
result = legacyFixSubstring(doc, original, replacement, contentField);
|
|
1099
|
+
}
|
|
1100
|
+
if (!result.fixed || !result.modifiedField) {
|
|
1101
|
+
return Response.json({
|
|
1102
|
+
success: false,
|
|
1103
|
+
fixesApplied: 0,
|
|
1104
|
+
error: "Could not locate the text to fix",
|
|
1105
|
+
original,
|
|
1106
|
+
replacement
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
const updateData = {};
|
|
1110
|
+
switch (result.modifiedField) {
|
|
1111
|
+
case "title":
|
|
1112
|
+
updateData.title = doc.title;
|
|
1113
|
+
break;
|
|
1114
|
+
case "hero":
|
|
1115
|
+
updateData.hero = doc.hero;
|
|
1116
|
+
break;
|
|
1117
|
+
case "layout":
|
|
1118
|
+
updateData.layout = doc.layout;
|
|
1119
|
+
break;
|
|
1120
|
+
default:
|
|
1121
|
+
updateData[result.modifiedField] = doc[result.modifiedField];
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
await req.payload.update({
|
|
1125
|
+
collection,
|
|
1126
|
+
id,
|
|
1127
|
+
data: updateData,
|
|
1128
|
+
overrideAccess: true
|
|
1129
|
+
});
|
|
1130
|
+
return Response.json({
|
|
1131
|
+
success: true,
|
|
1132
|
+
fixesApplied: 1,
|
|
1133
|
+
original,
|
|
1134
|
+
replacement,
|
|
1135
|
+
method
|
|
1136
|
+
});
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
console.error("[spellcheck/fix] Error:", error);
|
|
1139
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/endpoints/bulk.ts
|
|
1145
|
+
var RATE_LIMIT_DELAY = 3e3;
|
|
1146
|
+
var STALE_TIMEOUT = 10 * 60 * 1e3;
|
|
1147
|
+
function sleep(ms) {
|
|
1148
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1149
|
+
}
|
|
1150
|
+
var currentJob = null;
|
|
1151
|
+
function isJobStale() {
|
|
1152
|
+
if (!currentJob || currentJob.status !== "running") return false;
|
|
1153
|
+
return Date.now() - currentJob.lastActivity > STALE_TIMEOUT;
|
|
1154
|
+
}
|
|
1155
|
+
function resetIfStale() {
|
|
1156
|
+
if (isJobStale() && currentJob) {
|
|
1157
|
+
console.warn(`[spellcheck/bulk] Job stale (no progress for ${STALE_TIMEOUT / 1e3}s), auto-resetting`);
|
|
1158
|
+
currentJob.status = "error";
|
|
1159
|
+
currentJob.error = "Scan timed out (no progress)";
|
|
1160
|
+
currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function runBulkScan(payload, collectionsToScan, idsFilter, pluginConfig) {
|
|
1164
|
+
const language = pluginConfig.language || "fr";
|
|
1165
|
+
const contentField = pluginConfig.contentField || "content";
|
|
1166
|
+
const results = [];
|
|
1167
|
+
try {
|
|
1168
|
+
let totalToScan = 0;
|
|
1169
|
+
const docsByCollection = /* @__PURE__ */ new Map();
|
|
1170
|
+
for (const collectionSlug of collectionsToScan) {
|
|
1171
|
+
const idsForCollection = idsFilter ? idsFilter.filter((i) => i.collection === collectionSlug).map((i) => i.id) : null;
|
|
1172
|
+
const allDocs = await payload.find({
|
|
1173
|
+
collection: collectionSlug,
|
|
1174
|
+
limit: 0,
|
|
1175
|
+
depth: 0,
|
|
1176
|
+
// depth:0 — must match fix.ts for offset alignment
|
|
1177
|
+
draft: true,
|
|
1178
|
+
// Read latest version (including unpublished edits)
|
|
1179
|
+
overrideAccess: true,
|
|
1180
|
+
where: {
|
|
1181
|
+
...idsForCollection ? { id: { in: idsForCollection } } : { _status: { equals: "published" } }
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
docsByCollection.set(collectionSlug, allDocs.docs);
|
|
1185
|
+
totalToScan += allDocs.docs.length;
|
|
1186
|
+
}
|
|
1187
|
+
if (currentJob) {
|
|
1188
|
+
currentJob.total = totalToScan;
|
|
1189
|
+
currentJob.lastActivity = Date.now();
|
|
1190
|
+
}
|
|
1191
|
+
let processed = 0;
|
|
1192
|
+
let totalIssues = 0;
|
|
1193
|
+
let skipped = 0;
|
|
1194
|
+
for (const collectionSlug of collectionsToScan) {
|
|
1195
|
+
const docs = docsByCollection.get(collectionSlug) || [];
|
|
1196
|
+
for (const doc of docs) {
|
|
1197
|
+
processed++;
|
|
1198
|
+
const docAny = doc;
|
|
1199
|
+
const docTitle = docAny.title || docAny.slug || String(doc.id);
|
|
1200
|
+
if (currentJob) {
|
|
1201
|
+
currentJob.current = processed;
|
|
1202
|
+
currentJob.currentDoc = docTitle;
|
|
1203
|
+
currentJob.lastActivity = Date.now();
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const text = extractAllTextFromDoc(docAny, contentField);
|
|
1207
|
+
if (!text.trim()) {
|
|
1208
|
+
skipped++;
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const wordCount = countWords(text);
|
|
1212
|
+
let issues = await checkWithLanguageTool(text, language, pluginConfig);
|
|
1213
|
+
issues = await filterFalsePositives(issues, pluginConfig, payload);
|
|
1214
|
+
let existingDoc = null;
|
|
1215
|
+
try {
|
|
1216
|
+
const existing = await payload.find({
|
|
1217
|
+
collection: "spellcheck-results",
|
|
1218
|
+
where: {
|
|
1219
|
+
docId: { equals: String(doc.id) },
|
|
1220
|
+
collection: { equals: collectionSlug }
|
|
1221
|
+
},
|
|
1222
|
+
limit: 1,
|
|
1223
|
+
overrideAccess: true
|
|
1224
|
+
});
|
|
1225
|
+
if (existing.docs.length > 0) {
|
|
1226
|
+
existingDoc = existing.docs[0];
|
|
1227
|
+
}
|
|
1228
|
+
} catch {
|
|
1229
|
+
}
|
|
1230
|
+
const ignoredIssues = Array.isArray(existingDoc?.ignoredIssues) ? existingDoc.ignoredIssues : [];
|
|
1231
|
+
if (ignoredIssues.length > 0) {
|
|
1232
|
+
issues = issues.filter(
|
|
1233
|
+
(issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const score = calculateScore(wordCount, issues.length);
|
|
1237
|
+
totalIssues += issues.length;
|
|
1238
|
+
const result = {
|
|
1239
|
+
docId: String(doc.id),
|
|
1240
|
+
collection: collectionSlug,
|
|
1241
|
+
score,
|
|
1242
|
+
issueCount: issues.length,
|
|
1243
|
+
wordCount,
|
|
1244
|
+
issues,
|
|
1245
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
1246
|
+
};
|
|
1247
|
+
results.push(result);
|
|
1248
|
+
try {
|
|
1249
|
+
const resultData = {
|
|
1250
|
+
docId: String(doc.id),
|
|
1251
|
+
collection: collectionSlug,
|
|
1252
|
+
title: docAny.title || "",
|
|
1253
|
+
slug: docAny.slug || "",
|
|
1254
|
+
score,
|
|
1255
|
+
issueCount: issues.length,
|
|
1256
|
+
wordCount,
|
|
1257
|
+
issues,
|
|
1258
|
+
ignoredIssues,
|
|
1259
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
1260
|
+
};
|
|
1261
|
+
if (existingDoc) {
|
|
1262
|
+
await payload.update({
|
|
1263
|
+
collection: "spellcheck-results",
|
|
1264
|
+
id: existingDoc.id,
|
|
1265
|
+
data: resultData,
|
|
1266
|
+
overrideAccess: true
|
|
1267
|
+
});
|
|
1268
|
+
} else {
|
|
1269
|
+
await payload.create({
|
|
1270
|
+
collection: "spellcheck-results",
|
|
1271
|
+
data: resultData,
|
|
1272
|
+
overrideAccess: true
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.error(`[spellcheck/bulk] Failed to store result for ${docTitle}:`, err);
|
|
1277
|
+
}
|
|
1278
|
+
} catch (docErr) {
|
|
1279
|
+
console.error(`[spellcheck/bulk] Error processing "${docTitle}":`, docErr);
|
|
1280
|
+
}
|
|
1281
|
+
if (currentJob) {
|
|
1282
|
+
currentJob.totalIssues = totalIssues;
|
|
1283
|
+
currentJob.totalDocuments = processed;
|
|
1284
|
+
currentJob.lastActivity = Date.now();
|
|
1285
|
+
}
|
|
1286
|
+
await sleep(RATE_LIMIT_DELAY);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const averageScore = results.length > 0 ? Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length) : 100;
|
|
1290
|
+
if (currentJob) {
|
|
1291
|
+
currentJob.status = "completed";
|
|
1292
|
+
currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1293
|
+
currentJob.averageScore = averageScore;
|
|
1294
|
+
currentJob.totalDocuments = processed;
|
|
1295
|
+
currentJob.totalIssues = totalIssues;
|
|
1296
|
+
currentJob.lastActivity = Date.now();
|
|
1297
|
+
}
|
|
1298
|
+
console.log(`[spellcheck/bulk] Scan completed: ${processed} docs (${skipped} skipped), ${totalIssues} issues, avg score ${averageScore}`);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
console.error("[spellcheck/bulk] Scan error:", error);
|
|
1301
|
+
if (currentJob) {
|
|
1302
|
+
currentJob.status = "error";
|
|
1303
|
+
currentJob.error = error.message;
|
|
1304
|
+
currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1305
|
+
currentJob.lastActivity = Date.now();
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function createBulkHandler(targetCollections, pluginConfig) {
|
|
1310
|
+
return async (req) => {
|
|
1311
|
+
try {
|
|
1312
|
+
if (!req.user) {
|
|
1313
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1314
|
+
}
|
|
1315
|
+
resetIfStale();
|
|
1316
|
+
const body = await req.json().catch(() => ({}));
|
|
1317
|
+
const { collection: targetCollection, ids, force } = body;
|
|
1318
|
+
if (currentJob?.status === "running") {
|
|
1319
|
+
if (force) {
|
|
1320
|
+
console.warn("[spellcheck/bulk] Force-resetting stuck scan");
|
|
1321
|
+
currentJob.status = "error";
|
|
1322
|
+
currentJob.error = "Force reset by user";
|
|
1323
|
+
currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
|
+
} else {
|
|
1325
|
+
return Response.json({
|
|
1326
|
+
error: "Scan already in progress",
|
|
1327
|
+
...currentJob
|
|
1328
|
+
}, { status: 409 });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const scanSpecificIds = Array.isArray(ids) && ids.length > 0;
|
|
1332
|
+
const collectionsToScan = scanSpecificIds ? [...new Set(ids.map((i) => i.collection))] : targetCollection ? [targetCollection] : targetCollections;
|
|
1333
|
+
const idsFilter = scanSpecificIds ? ids : null;
|
|
1334
|
+
currentJob = {
|
|
1335
|
+
status: "running",
|
|
1336
|
+
current: 0,
|
|
1337
|
+
total: 0,
|
|
1338
|
+
currentDoc: "",
|
|
1339
|
+
totalIssues: 0,
|
|
1340
|
+
totalDocuments: 0,
|
|
1341
|
+
averageScore: 0,
|
|
1342
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1343
|
+
completedAt: null,
|
|
1344
|
+
error: null,
|
|
1345
|
+
lastActivity: Date.now()
|
|
1346
|
+
};
|
|
1347
|
+
runBulkScan(req.payload, collectionsToScan, idsFilter, pluginConfig);
|
|
1348
|
+
return Response.json({
|
|
1349
|
+
message: "Scan started",
|
|
1350
|
+
status: "running"
|
|
1351
|
+
});
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
console.error("[spellcheck/bulk] Error:", error);
|
|
1354
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function createStatusHandler() {
|
|
1359
|
+
return async (req) => {
|
|
1360
|
+
if (!req.user) {
|
|
1361
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1362
|
+
}
|
|
1363
|
+
resetIfStale();
|
|
1364
|
+
if (!currentJob) {
|
|
1365
|
+
return Response.json({ status: "idle" });
|
|
1366
|
+
}
|
|
1367
|
+
return Response.json({ ...currentJob });
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/hooks/afterChangeCheck.ts
|
|
1372
|
+
function createAfterChangeCheckHook(pluginConfig) {
|
|
1373
|
+
return ({ doc, collection, req }) => {
|
|
1374
|
+
(async () => {
|
|
1375
|
+
try {
|
|
1376
|
+
const contentField = pluginConfig.contentField || "content";
|
|
1377
|
+
const language = pluginConfig.language || "fr";
|
|
1378
|
+
const text = extractAllTextFromDoc(doc, contentField);
|
|
1379
|
+
if (!text) return;
|
|
1380
|
+
const wordCount = countWords(text);
|
|
1381
|
+
let issues = await checkWithLanguageTool(text, language, pluginConfig);
|
|
1382
|
+
issues = await filterFalsePositives(issues, pluginConfig, req.payload);
|
|
1383
|
+
const collectionSlug = typeof collection === "string" ? collection : collection.slug;
|
|
1384
|
+
const existing = await req.payload.find({
|
|
1385
|
+
collection: "spellcheck-results",
|
|
1386
|
+
where: {
|
|
1387
|
+
docId: { equals: String(doc.id) },
|
|
1388
|
+
collection: { equals: collectionSlug }
|
|
1389
|
+
},
|
|
1390
|
+
limit: 1,
|
|
1391
|
+
overrideAccess: true
|
|
1392
|
+
});
|
|
1393
|
+
const existingDoc = existing.docs.length > 0 ? existing.docs[0] : null;
|
|
1394
|
+
const ignoredIssues = Array.isArray(existingDoc?.ignoredIssues) ? existingDoc.ignoredIssues : [];
|
|
1395
|
+
if (ignoredIssues.length > 0) {
|
|
1396
|
+
issues = issues.filter(
|
|
1397
|
+
(issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
const score = calculateScore(wordCount, issues.length);
|
|
1401
|
+
const docAny = doc;
|
|
1402
|
+
const resultData = {
|
|
1403
|
+
docId: String(doc.id),
|
|
1404
|
+
collection: collectionSlug,
|
|
1405
|
+
title: docAny.title || "",
|
|
1406
|
+
slug: docAny.slug || "",
|
|
1407
|
+
score,
|
|
1408
|
+
issueCount: issues.length,
|
|
1409
|
+
wordCount,
|
|
1410
|
+
issues,
|
|
1411
|
+
ignoredIssues,
|
|
1412
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
1413
|
+
};
|
|
1414
|
+
if (existingDoc) {
|
|
1415
|
+
await req.payload.update({
|
|
1416
|
+
collection: "spellcheck-results",
|
|
1417
|
+
id: existingDoc.id,
|
|
1418
|
+
data: resultData,
|
|
1419
|
+
overrideAccess: true
|
|
1420
|
+
});
|
|
1421
|
+
} else {
|
|
1422
|
+
await req.payload.create({
|
|
1423
|
+
collection: "spellcheck-results",
|
|
1424
|
+
data: resultData,
|
|
1425
|
+
overrideAccess: true
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
console.log(`[spellcheck] Auto-check: ${collectionSlug}/${doc.id} \u2014 score ${score}, ${issues.length} issues`);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
console.error("[spellcheck] afterChange hook error:", err);
|
|
1431
|
+
}
|
|
1432
|
+
})();
|
|
1433
|
+
return doc;
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// src/plugin.ts
|
|
1438
|
+
async function autoFixSchema(payload) {
|
|
1439
|
+
try {
|
|
1440
|
+
await payload.find({ collection: "spellcheck-dictionary", limit: 1, overrideAccess: true });
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
const msg = String(e?.message || "");
|
|
1443
|
+
if (!msg.includes("no such column") && !msg.includes("spellcheck_dictionary")) return;
|
|
1444
|
+
payload.logger.info("[spellcheck] Detected missing schema column, attempting auto-fix...");
|
|
1445
|
+
const alterSQL = "ALTER TABLE payload_locked_documents_rels ADD COLUMN spellcheck_dictionary_id integer";
|
|
1446
|
+
const db = payload.db;
|
|
1447
|
+
const rawClient = db.pool || db.client || db.drizzle?.session?.client;
|
|
1448
|
+
if (!rawClient?.exec) {
|
|
1449
|
+
payload.logger.warn(`[spellcheck] Auto-fix: could not access raw DB client. Run manually: ${alterSQL}`);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
try {
|
|
1453
|
+
rawClient.exec(alterSQL);
|
|
1454
|
+
payload.logger.info("[spellcheck] Auto-fixed: added spellcheck_dictionary_id to payload_locked_documents_rels");
|
|
1455
|
+
} catch (fixErr) {
|
|
1456
|
+
const fixMsg = String(fixErr?.message || "");
|
|
1457
|
+
if (fixMsg.includes("duplicate column") || fixMsg.includes("already exists")) return;
|
|
1458
|
+
payload.logger.warn(`[spellcheck] Auto-fix failed: ${fixMsg}. Run manually: ${alterSQL}`);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
var spellcheckPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
1463
|
+
const config = { ...incomingConfig };
|
|
1464
|
+
const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
|
|
1465
|
+
const basePath = pluginConfig.endpointBasePath ?? "/spellcheck";
|
|
1466
|
+
const checkOnSave = pluginConfig.checkOnSave !== false;
|
|
1467
|
+
const addSidebarField = pluginConfig.addSidebarField !== false;
|
|
1468
|
+
const addDashboardView = pluginConfig.addDashboardView !== false;
|
|
1469
|
+
if (config.collections) {
|
|
1470
|
+
config.collections = config.collections.map((collection) => {
|
|
1471
|
+
if (!targetCollections.includes(collection.slug)) return collection;
|
|
1472
|
+
const updated = { ...collection };
|
|
1473
|
+
if (checkOnSave) {
|
|
1474
|
+
const existingHooks = updated.hooks?.afterChange || [];
|
|
1475
|
+
updated.hooks = {
|
|
1476
|
+
...updated.hooks,
|
|
1477
|
+
afterChange: [
|
|
1478
|
+
...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
|
|
1479
|
+
createAfterChangeCheckHook(pluginConfig)
|
|
1480
|
+
]
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
if (addSidebarField) {
|
|
1484
|
+
updated.fields = [
|
|
1485
|
+
...updated.fields || [],
|
|
1486
|
+
{
|
|
1487
|
+
name: "_spellcheck",
|
|
1488
|
+
type: "ui",
|
|
1489
|
+
admin: {
|
|
1490
|
+
position: "sidebar",
|
|
1491
|
+
components: {
|
|
1492
|
+
Field: "@consilioweb/spellcheck/client#SpellCheckField"
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
];
|
|
1497
|
+
}
|
|
1498
|
+
if (pluginConfig.addListColumn !== false) {
|
|
1499
|
+
updated.fields = [
|
|
1500
|
+
...updated.fields || [],
|
|
1501
|
+
{
|
|
1502
|
+
name: "_spellcheckScore",
|
|
1503
|
+
type: "ui",
|
|
1504
|
+
label: "Ortho",
|
|
1505
|
+
admin: {
|
|
1506
|
+
components: {
|
|
1507
|
+
Cell: "@consilioweb/spellcheck/client#SpellCheckScoreCell"
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
];
|
|
1512
|
+
if (updated.admin?.defaultColumns) {
|
|
1513
|
+
const cols = [...updated.admin.defaultColumns];
|
|
1514
|
+
if (!cols.includes("_spellcheckScore")) {
|
|
1515
|
+
cols.push("_spellcheckScore");
|
|
1516
|
+
}
|
|
1517
|
+
updated.admin = { ...updated.admin, defaultColumns: cols };
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return updated;
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
config.collections = [
|
|
1524
|
+
...config.collections || [],
|
|
1525
|
+
createSpellCheckResultsCollection(),
|
|
1526
|
+
createSpellCheckDictionaryCollection()
|
|
1527
|
+
];
|
|
1528
|
+
config.endpoints = [
|
|
1529
|
+
...config.endpoints || [],
|
|
1530
|
+
{
|
|
1531
|
+
path: `${basePath}/validate`,
|
|
1532
|
+
method: "post",
|
|
1533
|
+
handler: createValidateHandler(pluginConfig)
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
path: `${basePath}/fix`,
|
|
1537
|
+
method: "post",
|
|
1538
|
+
handler: createFixHandler(pluginConfig)
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
path: `${basePath}/bulk`,
|
|
1542
|
+
method: "post",
|
|
1543
|
+
handler: createBulkHandler(targetCollections, pluginConfig)
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
path: `${basePath}/status`,
|
|
1547
|
+
method: "get",
|
|
1548
|
+
handler: createStatusHandler()
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
path: `${basePath}/dictionary`,
|
|
1552
|
+
method: "get",
|
|
1553
|
+
handler: createDictionaryListHandler()
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
path: `${basePath}/dictionary`,
|
|
1557
|
+
method: "post",
|
|
1558
|
+
handler: createDictionaryAddHandler()
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
path: `${basePath}/dictionary`,
|
|
1562
|
+
method: "delete",
|
|
1563
|
+
handler: createDictionaryDeleteHandler()
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
path: `${basePath}/collections`,
|
|
1567
|
+
method: "get",
|
|
1568
|
+
handler: (async (req) => {
|
|
1569
|
+
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1570
|
+
return Response.json({ collections: targetCollections });
|
|
1571
|
+
})
|
|
1572
|
+
}
|
|
1573
|
+
];
|
|
1574
|
+
if (addDashboardView) {
|
|
1575
|
+
if (!config.admin) config.admin = {};
|
|
1576
|
+
if (!config.admin.components) config.admin.components = {};
|
|
1577
|
+
if (!config.admin.components.views) config.admin.components.views = {};
|
|
1578
|
+
config.admin.components.views.spellcheck = {
|
|
1579
|
+
Component: "@consilioweb/spellcheck/views#SpellCheckView",
|
|
1580
|
+
path: "/spellcheck"
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
const existingOnInit = config.onInit;
|
|
1584
|
+
config.onInit = async (payload) => {
|
|
1585
|
+
if (existingOnInit) await existingOnInit(payload);
|
|
1586
|
+
await autoFixSchema(payload);
|
|
1587
|
+
};
|
|
1588
|
+
return config;
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
// src/i18n.ts
|
|
1592
|
+
var fr = {
|
|
1593
|
+
dashboardTitle: "Correcteur orthographique",
|
|
1594
|
+
dashboardDescription: "Analyse orthographique et grammaticale du contenu",
|
|
1595
|
+
scanAll: "Scanner tout",
|
|
1596
|
+
scanning: "Analyse en cours...",
|
|
1597
|
+
scanComplete: "Analyse termin\xE9e",
|
|
1598
|
+
noIssues: "Aucun probl\xE8me d\xE9tect\xE9",
|
|
1599
|
+
issuesFound: (count) => `${count} probl\xE8me${count > 1 ? "s" : ""} d\xE9tect\xE9${count > 1 ? "s" : ""}`,
|
|
1600
|
+
score: "Score",
|
|
1601
|
+
wordCount: "Mots",
|
|
1602
|
+
lastChecked: "Derni\xE8re v\xE9rification",
|
|
1603
|
+
collection: "Collection",
|
|
1604
|
+
document: "Document",
|
|
1605
|
+
issues: "Probl\xE8mes",
|
|
1606
|
+
actions: "Actions",
|
|
1607
|
+
fieldTitle: "Orthographe",
|
|
1608
|
+
fieldDescription: "V\xE9rification orthographique et grammaticale",
|
|
1609
|
+
checkNow: "V\xE9rifier",
|
|
1610
|
+
checking: "V\xE9rification...",
|
|
1611
|
+
fixAll: "Tout corriger",
|
|
1612
|
+
fix: "Corriger",
|
|
1613
|
+
ignore: "Ignorer",
|
|
1614
|
+
autoChecked: "V\xE9rifi\xE9 automatiquement",
|
|
1615
|
+
suggestion: "Suggestion",
|
|
1616
|
+
noSuggestion: "Aucune suggestion",
|
|
1617
|
+
context: "Contexte",
|
|
1618
|
+
rule: "R\xE8gle",
|
|
1619
|
+
category: "Cat\xE9gorie",
|
|
1620
|
+
applied: "Corrig\xE9",
|
|
1621
|
+
source: "Source",
|
|
1622
|
+
excellent: "Excellent",
|
|
1623
|
+
good: "Bon",
|
|
1624
|
+
needsWork: "\xC0 am\xE9liorer",
|
|
1625
|
+
poor: "Insuffisant",
|
|
1626
|
+
errorFetching: "Erreur lors de l'analyse",
|
|
1627
|
+
errorFixing: "Erreur lors de la correction",
|
|
1628
|
+
unauthorized: "Non autoris\xE9"
|
|
1629
|
+
};
|
|
1630
|
+
var en = {
|
|
1631
|
+
dashboardTitle: "Spellchecker",
|
|
1632
|
+
dashboardDescription: "Spelling and grammar analysis of content",
|
|
1633
|
+
scanAll: "Scan all",
|
|
1634
|
+
scanning: "Scanning...",
|
|
1635
|
+
scanComplete: "Scan complete",
|
|
1636
|
+
noIssues: "No issues found",
|
|
1637
|
+
issuesFound: (count) => `${count} issue${count > 1 ? "s" : ""} found`,
|
|
1638
|
+
score: "Score",
|
|
1639
|
+
wordCount: "Words",
|
|
1640
|
+
lastChecked: "Last checked",
|
|
1641
|
+
collection: "Collection",
|
|
1642
|
+
document: "Document",
|
|
1643
|
+
issues: "Issues",
|
|
1644
|
+
actions: "Actions",
|
|
1645
|
+
fieldTitle: "Spelling",
|
|
1646
|
+
fieldDescription: "Spelling and grammar check",
|
|
1647
|
+
checkNow: "Check",
|
|
1648
|
+
checking: "Checking...",
|
|
1649
|
+
fixAll: "Fix all",
|
|
1650
|
+
fix: "Fix",
|
|
1651
|
+
ignore: "Ignore",
|
|
1652
|
+
autoChecked: "Auto-checked",
|
|
1653
|
+
suggestion: "Suggestion",
|
|
1654
|
+
noSuggestion: "No suggestion",
|
|
1655
|
+
context: "Context",
|
|
1656
|
+
rule: "Rule",
|
|
1657
|
+
category: "Category",
|
|
1658
|
+
applied: "Fixed",
|
|
1659
|
+
source: "Source",
|
|
1660
|
+
excellent: "Excellent",
|
|
1661
|
+
good: "Good",
|
|
1662
|
+
needsWork: "Needs work",
|
|
1663
|
+
poor: "Poor",
|
|
1664
|
+
errorFetching: "Error during analysis",
|
|
1665
|
+
errorFixing: "Error applying fix",
|
|
1666
|
+
unauthorized: "Unauthorized"
|
|
1667
|
+
};
|
|
1668
|
+
var translations = { fr, en };
|
|
1669
|
+
function getTranslations(locale = "fr") {
|
|
1670
|
+
return translations[locale] || translations.fr;
|
|
1671
|
+
}
|
|
1672
|
+
function getScoreLabel(score, t) {
|
|
1673
|
+
if (score >= 95) return t.excellent;
|
|
1674
|
+
if (score >= 80) return t.good;
|
|
1675
|
+
if (score >= 50) return t.needsWork;
|
|
1676
|
+
return t.poor;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
exports.calculateScore = calculateScore;
|
|
1680
|
+
exports.checkWithClaude = checkWithClaude;
|
|
1681
|
+
exports.checkWithLanguageTool = checkWithLanguageTool;
|
|
1682
|
+
exports.countWords = countWords;
|
|
1683
|
+
exports.extractAllTextFromDoc = extractAllTextFromDoc;
|
|
1684
|
+
exports.extractAllTextFromDocWithSources = extractAllTextFromDocWithSources;
|
|
1685
|
+
exports.extractTextFromLexical = extractTextFromLexical;
|
|
1686
|
+
exports.filterFalsePositives = filterFalsePositives;
|
|
1687
|
+
exports.getScoreLabel = getScoreLabel;
|
|
1688
|
+
exports.getTranslations = getTranslations;
|
|
1689
|
+
exports.invalidateDictionaryCache = invalidateDictionaryCache;
|
|
1690
|
+
exports.loadDictionaryWords = loadDictionaryWords;
|
|
1691
|
+
exports.spellcheckPlugin = spellcheckPlugin;
|