docrev 0.10.2 → 0.11.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/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/dist/lib/anchor-match.d.ts +3 -1
- package/dist/lib/anchor-match.d.ts.map +1 -1
- package/dist/lib/anchor-match.js +5 -7
- package/dist/lib/anchor-match.js.map +1 -1
- package/dist/lib/annotations.d.ts +0 -6
- package/dist/lib/annotations.d.ts.map +1 -1
- package/dist/lib/annotations.js +26 -8
- package/dist/lib/annotations.js.map +1 -1
- package/dist/lib/build.d.ts +52 -0
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +135 -6
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +39 -12
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/doi.d.ts.map +1 -1
- package/dist/lib/commands/doi.js +14 -2
- package/dist/lib/commands/doi.js.map +1 -1
- package/dist/lib/commands/sync.d.ts.map +1 -1
- package/dist/lib/commands/sync.js +24 -11
- package/dist/lib/commands/sync.js.map +1 -1
- package/dist/lib/comment-realign.d.ts +12 -21
- package/dist/lib/comment-realign.d.ts.map +1 -1
- package/dist/lib/comment-realign.js +40 -348
- package/dist/lib/comment-realign.js.map +1 -1
- package/dist/lib/dependencies.d.ts +10 -0
- package/dist/lib/dependencies.d.ts.map +1 -1
- package/dist/lib/dependencies.js +41 -9
- package/dist/lib/dependencies.js.map +1 -1
- package/dist/lib/doi.d.ts.map +1 -1
- package/dist/lib/doi.js +68 -80
- package/dist/lib/doi.js.map +1 -1
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/import.d.ts +7 -4
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/import.js +58 -62
- package/dist/lib/import.js.map +1 -1
- package/dist/lib/ooxml.d.ts +207 -0
- package/dist/lib/ooxml.d.ts.map +1 -0
- package/dist/lib/ooxml.js +634 -0
- package/dist/lib/ooxml.js.map +1 -0
- package/dist/lib/rate-limiter.d.ts +8 -0
- package/dist/lib/rate-limiter.d.ts.map +1 -1
- package/dist/lib/rate-limiter.js +41 -4
- package/dist/lib/rate-limiter.js.map +1 -1
- package/dist/lib/trackchanges.d.ts +41 -25
- package/dist/lib/trackchanges.d.ts.map +1 -1
- package/dist/lib/trackchanges.js +90 -151
- package/dist/lib/trackchanges.js.map +1 -1
- package/dist/lib/types.d.ts +9 -8
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/word-extraction.d.ts.map +1 -1
- package/dist/lib/word-extraction.js +48 -307
- package/dist/lib/word-extraction.js.map +1 -1
- package/dist/lib/word.d.ts.map +1 -1
- package/dist/lib/word.js +80 -175
- package/dist/lib/word.js.map +1 -1
- package/dist/lib/wordcomments.d.ts.map +1 -1
- package/dist/lib/wordcomments.js +53 -92
- package/dist/lib/wordcomments.js.map +1 -1
- package/lib/anchor-match.ts +6 -7
- package/lib/annotations.ts +28 -8
- package/lib/build.ts +196 -6
- package/lib/commands/build.ts +42 -12
- package/lib/commands/doi.ts +17 -1
- package/lib/commands/sync.ts +26 -13
- package/lib/comment-realign.ts +46 -464
- package/lib/dependencies.ts +43 -9
- package/lib/doi.ts +76 -94
- package/lib/errors.ts +5 -1
- package/lib/import.ts +65 -70
- package/lib/ooxml.ts +768 -0
- package/lib/rate-limiter.ts +37 -4
- package/lib/trackchanges.ts +102 -183
- package/lib/types.ts +9 -13
- package/lib/word-extraction.ts +49 -330
- package/lib/word.ts +86 -203
- package/lib/wordcomments.ts +53 -104
- package/package.json +137 -137
- package/skill/REFERENCE.md +14 -3
- package/skill/SKILL.md +12 -5
- package/types/index.d.ts +16 -10
package/lib/dependencies.ts
CHANGED
|
@@ -2,39 +2,68 @@
|
|
|
2
2
|
* Dependency checking utilities for pandoc, LaTeX, and related tools
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Run `<file> --version` without a shell and return its stdout, or null when
|
|
9
|
+
* the binary is absent (ENOENT) or exits non-zero (present but broken). Using
|
|
10
|
+
* execFileSync avoids shell quoting and treats both failure modes uniformly.
|
|
9
11
|
*/
|
|
10
|
-
function
|
|
12
|
+
function runVersion(file: string, args: string[] = ['--version']): string | null {
|
|
11
13
|
try {
|
|
12
|
-
|
|
13
|
-
return true;
|
|
14
|
+
return execFileSync(file, args, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
14
15
|
} catch {
|
|
15
|
-
return
|
|
16
|
+
return null;
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function commandExists(file: string, args: string[] = ['--version']): boolean {
|
|
21
|
+
return runVersion(file, args) !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
/**
|
|
20
25
|
* Check if pandoc-crossref is available
|
|
21
26
|
*/
|
|
22
27
|
export function hasPandocCrossref(): boolean {
|
|
23
|
-
return commandExists('pandoc-crossref
|
|
28
|
+
return commandExists('pandoc-crossref');
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* Check if pandoc is available
|
|
28
33
|
*/
|
|
29
34
|
export function hasPandoc(): boolean {
|
|
30
|
-
return commandExists('pandoc
|
|
35
|
+
return commandExists('pandoc');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parsed pandoc version (e.g. "3.9"), or null when pandoc is unavailable.
|
|
40
|
+
*/
|
|
41
|
+
export function getPandocVersion(): string | null {
|
|
42
|
+
const out = runVersion('pandoc');
|
|
43
|
+
if (!out) return null;
|
|
44
|
+
const m = out.match(/pandoc(?:\.exe)?\s+(\d+\.\d+(?:\.\d+)?)/i);
|
|
45
|
+
return m ? m[1]! : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether pandoc bundles citeproc and accepts `--citeproc`. That flag and the
|
|
50
|
+
* built-in citeproc arrived in pandoc 2.11; earlier versions need the separate
|
|
51
|
+
* pandoc-citeproc filter.
|
|
52
|
+
*/
|
|
53
|
+
export function pandocSupportsCiteproc(): boolean {
|
|
54
|
+
const v = getPandocVersion();
|
|
55
|
+
if (!v) return false;
|
|
56
|
+
const parts = v.split('.').map((n) => parseInt(n, 10));
|
|
57
|
+
const major = parts[0] ?? 0;
|
|
58
|
+
const minor = parts[1] ?? 0;
|
|
59
|
+
return major > 2 || (major === 2 && minor >= 11);
|
|
31
60
|
}
|
|
32
61
|
|
|
33
62
|
/**
|
|
34
63
|
* Check if LaTeX is available (for PDF generation)
|
|
35
64
|
*/
|
|
36
65
|
export function hasLatex(): boolean {
|
|
37
|
-
return commandExists('pdflatex
|
|
66
|
+
return commandExists('pdflatex') || commandExists('xelatex');
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
/**
|
|
@@ -86,6 +115,11 @@ export function checkDependencies(): DependencyStatus {
|
|
|
86
115
|
|
|
87
116
|
if (!status.pandoc) {
|
|
88
117
|
status.messages.push(`Pandoc not found. Install with: ${getInstallInstructions('pandoc')}`);
|
|
118
|
+
} else if (!pandocSupportsCiteproc()) {
|
|
119
|
+
const version = getPandocVersion();
|
|
120
|
+
status.messages.push(
|
|
121
|
+
`Pandoc ${version ?? '(unknown version)'} is older than 2.11; citation processing (--citeproc) needs 2.11+. Upgrade with: ${getInstallInstructions('pandoc')}`,
|
|
122
|
+
);
|
|
89
123
|
}
|
|
90
124
|
if (!status.latex) {
|
|
91
125
|
status.messages.push(`LaTeX not found (required for PDF). Install with: ${getInstallInstructions('latex')}`);
|
package/lib/doi.ts
CHANGED
|
@@ -22,6 +22,16 @@ const NO_DOI_TYPES = new Set([
|
|
|
22
22
|
'booklet',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
// Thresholds for the heuristic DOI-match score (title + author + year +
|
|
26
|
+
// journal). `high` auto-accepts, `medium` asks the user, below `medium` is a
|
|
27
|
+
// weak guess. `runnerUpMargin` is how far the best must beat the second-best
|
|
28
|
+
// to count as high — guards against picking the wrong one of two near-ties.
|
|
29
|
+
const DOI_CONFIDENCE = {
|
|
30
|
+
high: 120,
|
|
31
|
+
medium: 70,
|
|
32
|
+
runnerUpMargin: 30,
|
|
33
|
+
};
|
|
34
|
+
|
|
25
35
|
// Entry types that should have DOIs
|
|
26
36
|
const EXPECT_DOI_TYPES = new Set([
|
|
27
37
|
'article', // Journal articles should have DOIs
|
|
@@ -142,52 +152,6 @@ export function isValidDoiFormat(doi: string): boolean {
|
|
|
142
152
|
return /^10\.\d{4,}\/[^\s]+$/.test(doi);
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
/**
|
|
146
|
-
* Check if DOI resolves via DataCite (for Zenodo, Figshare, etc.)
|
|
147
|
-
*/
|
|
148
|
-
async function checkDoiDataCite(doi: string): Promise<DoiCheckResult> {
|
|
149
|
-
try {
|
|
150
|
-
const response = await dataciteLimiter.fetchWithRetry(
|
|
151
|
-
`https://api.datacite.org/dois/${encodeURIComponent(doi)}`,
|
|
152
|
-
{
|
|
153
|
-
headers: {
|
|
154
|
-
'Accept': 'application/vnd.api+json',
|
|
155
|
-
'User-Agent': 'docrev/0.6.0 (https://github.com/gcol33/docrev)',
|
|
156
|
-
},
|
|
157
|
-
}
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
if (response.status === 404) {
|
|
161
|
-
return { valid: false, error: 'DOI not found in DataCite' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (!response.ok) {
|
|
165
|
-
return { valid: false, error: `HTTP ${response.status}` };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const data = await response.json() as any;
|
|
169
|
-
const attrs = data.data?.attributes;
|
|
170
|
-
|
|
171
|
-
if (!attrs) {
|
|
172
|
-
return { valid: false, error: 'Invalid DataCite response' };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
valid: true,
|
|
177
|
-
source: 'datacite',
|
|
178
|
-
metadata: {
|
|
179
|
-
title: attrs.titles?.[0]?.title || '',
|
|
180
|
-
authors: attrs.creators?.map((c: any) => `${c.givenName || ''} ${c.familyName || ''}`.trim()) || [],
|
|
181
|
-
year: attrs.publicationYear,
|
|
182
|
-
journal: attrs.publisher || '',
|
|
183
|
-
type: attrs.types?.resourceTypeGeneral || '',
|
|
184
|
-
},
|
|
185
|
-
};
|
|
186
|
-
} catch (err) {
|
|
187
|
-
return { valid: false, error: (err as Error).message };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
155
|
interface CheckDoiOptions {
|
|
192
156
|
skipCache?: boolean;
|
|
193
157
|
}
|
|
@@ -209,72 +173,80 @@ export async function checkDoi(doi: string, options: CheckDoiOptions = {}): Prom
|
|
|
209
173
|
}
|
|
210
174
|
}
|
|
211
175
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const isDataCiteLikely = isZenodo || isFigshare;
|
|
216
|
-
|
|
217
|
-
if (isDataCiteLikely) {
|
|
218
|
-
const dataciteResult = await checkDoiDataCite(doi);
|
|
219
|
-
if (dataciteResult.valid) {
|
|
220
|
-
cacheDoi(doi, dataciteResult);
|
|
221
|
-
return dataciteResult;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
176
|
+
// One resolution path: doi.org content negotiation returns CSL-JSON and
|
|
177
|
+
// federates every registration agency (Crossref, DataCite, mEDRA, ...), so
|
|
178
|
+
// there is no registrar to guess and no Crossref-then-DataCite fallback.
|
|
225
179
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
`https://api.crossref.org/works/${encodeURIComponent(doi)}`,
|
|
180
|
+
const response = await doiOrgLimiter.fetchWithRetry(
|
|
181
|
+
`https://doi.org/${encodeURIComponent(doi)}`,
|
|
229
182
|
{
|
|
230
183
|
headers: {
|
|
231
|
-
'
|
|
184
|
+
'Accept': 'application/vnd.citationstyles.csl+json',
|
|
185
|
+
'User-Agent': 'docrev (https://github.com/gcol33/docrev)',
|
|
232
186
|
},
|
|
233
|
-
|
|
187
|
+
redirect: 'follow',
|
|
188
|
+
},
|
|
234
189
|
);
|
|
235
190
|
|
|
236
|
-
if (response.status === 404) {
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
const dataciteResult = await checkDoiDataCite(doi);
|
|
240
|
-
if (dataciteResult.valid) {
|
|
241
|
-
cacheDoi(doi, dataciteResult);
|
|
242
|
-
return dataciteResult;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
const result = { valid: false, error: 'DOI not found' };
|
|
191
|
+
if (response.status === 404 || response.status === 410) {
|
|
192
|
+
// Definitive: the registry resolved and the DOI is not registered.
|
|
193
|
+
const result: DoiCheckResult = { valid: false, error: 'DOI not found' };
|
|
246
194
|
cacheDoi(doi, result);
|
|
247
195
|
return result;
|
|
248
196
|
}
|
|
249
197
|
|
|
250
198
|
if (!response.ok) {
|
|
251
|
-
//
|
|
252
|
-
|
|
199
|
+
// 5xx or unexpected: the registry is reachable but not answering — the
|
|
200
|
+
// DOI's status is unknown, so do not declare it invalid or cache it.
|
|
201
|
+
return { valid: false, unreachable: true, error: `HTTP ${response.status}` };
|
|
253
202
|
}
|
|
254
203
|
|
|
255
|
-
const
|
|
256
|
-
const work = data.message;
|
|
257
|
-
|
|
204
|
+
const work = await response.json() as any;
|
|
258
205
|
const result: DoiCheckResult = {
|
|
259
206
|
valid: true,
|
|
260
|
-
source: '
|
|
261
|
-
metadata:
|
|
262
|
-
title: work.title?.[0] || '',
|
|
263
|
-
authors: work.author?.map((a: any) => `${a.given || ''} ${a.family || ''}`.trim()) || [],
|
|
264
|
-
year: work.published?.['date-parts']?.[0]?.[0] || work.created?.['date-parts']?.[0]?.[0],
|
|
265
|
-
journal: work['container-title']?.[0] || '',
|
|
266
|
-
type: work.type,
|
|
267
|
-
},
|
|
207
|
+
source: 'doi.org',
|
|
208
|
+
metadata: metadataFromCsl(work),
|
|
268
209
|
};
|
|
269
210
|
|
|
270
211
|
cacheDoi(doi, result);
|
|
271
212
|
return result;
|
|
272
213
|
} catch (err) {
|
|
273
|
-
//
|
|
274
|
-
return { valid: false, error: (err as Error).message };
|
|
214
|
+
// Network failure / timeout: cannot reach the resolver. Unknown, not invalid.
|
|
215
|
+
return { valid: false, unreachable: true, error: (err as Error).message };
|
|
275
216
|
}
|
|
276
217
|
}
|
|
277
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Extract docrev's metadata fields from a CSL-JSON record — the shape doi.org
|
|
221
|
+
* content negotiation returns for any registrar.
|
|
222
|
+
*/
|
|
223
|
+
function metadataFromCsl(work: any): DoiCheckResult['metadata'] {
|
|
224
|
+
const dateParts =
|
|
225
|
+
work?.issued?.['date-parts']?.[0] ||
|
|
226
|
+
work?.published?.['date-parts']?.[0] ||
|
|
227
|
+
work?.created?.['date-parts']?.[0];
|
|
228
|
+
const year = Array.isArray(dateParts) ? dateParts[0] : undefined;
|
|
229
|
+
|
|
230
|
+
const authors = Array.isArray(work?.author)
|
|
231
|
+
? work.author
|
|
232
|
+
.map((a: any) => `${a.given || a.givenName || ''} ${a.family || a.familyName || ''}`.trim())
|
|
233
|
+
.filter((s: string) => s.length > 0)
|
|
234
|
+
: [];
|
|
235
|
+
|
|
236
|
+
const title = Array.isArray(work?.title) ? work.title[0] : work?.title;
|
|
237
|
+
const journal = Array.isArray(work?.['container-title'])
|
|
238
|
+
? work['container-title'][0]
|
|
239
|
+
: work?.['container-title'] || work?.publisher;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
title: title || '',
|
|
243
|
+
authors,
|
|
244
|
+
year: year || 0,
|
|
245
|
+
journal: journal || '',
|
|
246
|
+
type: work?.type,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
278
250
|
/**
|
|
279
251
|
* Fetch BibTeX from DOI using content negotiation
|
|
280
252
|
*/
|
|
@@ -330,6 +302,7 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
|
|
|
330
302
|
|
|
331
303
|
let valid = 0;
|
|
332
304
|
let invalid = 0;
|
|
305
|
+
let unreachable = 0;
|
|
333
306
|
let missing = 0;
|
|
334
307
|
let skipped = 0;
|
|
335
308
|
|
|
@@ -372,6 +345,11 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
|
|
|
372
345
|
if (check.valid) {
|
|
373
346
|
valid++;
|
|
374
347
|
return { ...entry, status: 'valid', metadata: check.metadata };
|
|
348
|
+
} else if (check.unreachable) {
|
|
349
|
+
// Registry unreachable — the DOI may be perfectly valid; don't
|
|
350
|
+
// condemn it. Offline runs report unreachable, not a wall of invalid.
|
|
351
|
+
unreachable++;
|
|
352
|
+
return { ...entry, status: 'unreachable', message: check.error };
|
|
375
353
|
} else {
|
|
376
354
|
invalid++;
|
|
377
355
|
return { ...entry, status: 'invalid', message: check.error };
|
|
@@ -387,7 +365,7 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
|
|
|
387
365
|
}
|
|
388
366
|
}
|
|
389
367
|
|
|
390
|
-
return { entries: results, valid, invalid, missing, skipped };
|
|
368
|
+
return { entries: results, valid, invalid, unreachable, missing, skipped };
|
|
391
369
|
}
|
|
392
370
|
|
|
393
371
|
interface DataCiteItem {
|
|
@@ -725,10 +703,14 @@ export async function lookupDoi(
|
|
|
725
703
|
return { found: false, error: 'No valid results found' };
|
|
726
704
|
}
|
|
727
705
|
|
|
728
|
-
// Confidence
|
|
706
|
+
// Confidence: a "high" pick must clear the high threshold AND beat the
|
|
707
|
+
// runner-up by a margin, so two near-identical candidates are reported as
|
|
708
|
+
// "medium" (needs review) instead of one being auto-written as if certain.
|
|
709
|
+
const runnerUp = (mainPapers.length > 0 ? mainPapers : scored).find(s => s.doi !== best.doi);
|
|
710
|
+
const margin = runnerUp ? best.score - runnerUp.score : Infinity;
|
|
729
711
|
let confidence: 'low' | 'medium' | 'high' = 'low';
|
|
730
|
-
if (best.score >=
|
|
731
|
-
else if (best.score >=
|
|
712
|
+
if (best.score >= DOI_CONFIDENCE.high && margin >= DOI_CONFIDENCE.runnerUpMargin) confidence = 'high';
|
|
713
|
+
else if (best.score >= DOI_CONFIDENCE.medium) confidence = 'medium';
|
|
732
714
|
|
|
733
715
|
// === NEW: Try DataCite if Crossref confidence is low ===
|
|
734
716
|
if (confidence === 'low' && !likelyZenodo) {
|
|
@@ -758,7 +740,7 @@ export async function lookupDoi(
|
|
|
758
740
|
return {
|
|
759
741
|
found: true,
|
|
760
742
|
doi: dcItem.DOI,
|
|
761
|
-
confidence: dcScore >=
|
|
743
|
+
confidence: dcScore >= DOI_CONFIDENCE.high ? 'high' : dcScore >= DOI_CONFIDENCE.medium ? 'medium' : 'low',
|
|
762
744
|
score: dcScore,
|
|
763
745
|
metadata: {
|
|
764
746
|
title: dcTitle,
|
package/lib/errors.ts
CHANGED
|
@@ -44,7 +44,11 @@ export function getFileNotFoundSuggestions(filePath: string): string[] {
|
|
|
44
44
|
// Check if directory exists
|
|
45
45
|
if (!fs.existsSync(dir)) {
|
|
46
46
|
suggestions.push(`Directory does not exist: ${dir}`);
|
|
47
|
-
suggestions.push(
|
|
47
|
+
suggestions.push(
|
|
48
|
+
process.platform === 'win32'
|
|
49
|
+
? `Create it with: New-Item -ItemType Directory -Force "${dir}"`
|
|
50
|
+
: `Create it with: mkdir -p "${dir}"`,
|
|
51
|
+
);
|
|
48
52
|
return suggestions;
|
|
49
53
|
}
|
|
50
54
|
|
package/lib/import.ts
CHANGED
|
@@ -53,6 +53,7 @@ function pickBestOccurrence(
|
|
|
53
53
|
after: string,
|
|
54
54
|
anchorLen: number,
|
|
55
55
|
usedPositions: Set<number>,
|
|
56
|
+
nearPos?: number,
|
|
56
57
|
): number {
|
|
57
58
|
if (occurrences.length === 0) return -1;
|
|
58
59
|
if (occurrences.length === 1) {
|
|
@@ -63,6 +64,13 @@ function pickBestOccurrence(
|
|
|
63
64
|
if (bestIdx < 0) return -1;
|
|
64
65
|
let bestScore = -1;
|
|
65
66
|
|
|
67
|
+
// Tie-break by context score first; when scores tie, prefer the occurrence
|
|
68
|
+
// nearest `nearPos` (a section-position estimate) if given, else the
|
|
69
|
+
// leftmost. The estimate only orders equally-scored candidates — it is never
|
|
70
|
+
// the returned position.
|
|
71
|
+
const closer = (pos: number, incumbent: number) =>
|
|
72
|
+
nearPos === undefined ? pos < incumbent : Math.abs(pos - nearPos) < Math.abs(incumbent - nearPos);
|
|
73
|
+
|
|
66
74
|
for (const pos of occurrences) {
|
|
67
75
|
if (usedPositions.has(pos)) continue;
|
|
68
76
|
let score = 0;
|
|
@@ -87,7 +95,7 @@ function pickBestOccurrence(
|
|
|
87
95
|
if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
if (score > bestScore || (score === bestScore && pos
|
|
98
|
+
if (score > bestScore || (score === bestScore && closer(pos, bestIdx))) {
|
|
91
99
|
bestScore = score;
|
|
92
100
|
bestIdx = pos;
|
|
93
101
|
}
|
|
@@ -160,12 +168,24 @@ export interface InsertCommentsOptions {
|
|
|
160
168
|
wrapAnchor?: boolean;
|
|
161
169
|
/**
|
|
162
170
|
* Mutable output: when provided, the function fills in counters so callers
|
|
163
|
-
* can distinguish placement outcomes in their summary. `placed` counts
|
|
164
|
-
* insertions
|
|
165
|
-
*
|
|
166
|
-
*
|
|
171
|
+
* can distinguish placement outcomes in their summary. `placed` counts
|
|
172
|
+
* insertions made at a matched anchor or context, `lowConfidence` counts
|
|
173
|
+
* insertions made at an approximate (proportional / context-only) position
|
|
174
|
+
* that should be reviewed with `rev verify-anchors`, `deduped` counts
|
|
175
|
+
* comments already present at their anchor (skipped on re-sync), and
|
|
176
|
+
* `unmatched` counts comments that could not be placed at all.
|
|
167
177
|
*/
|
|
168
|
-
outStats?: { placed: number; deduped: number; unmatched: number };
|
|
178
|
+
outStats?: { placed: number; deduped: number; unmatched: number; lowConfidence?: number };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a resolved placement landed on matched anchor/context text (high) or
|
|
183
|
+
* on an approximate position the user should review (low). Low-confidence
|
|
184
|
+
* strategies place the comment without matching the anchor word itself.
|
|
185
|
+
*/
|
|
186
|
+
function placementConfidence(strategy: string | undefined): 'high' | 'low' {
|
|
187
|
+
if (!strategy) return 'high';
|
|
188
|
+
return /proportional|position-only|context/.test(strategy) ? 'low' : 'high';
|
|
169
189
|
}
|
|
170
190
|
|
|
171
191
|
export interface CommentWithPos {
|
|
@@ -319,6 +339,7 @@ export function insertCommentsIntoMarkdown(
|
|
|
319
339
|
let result = markdown;
|
|
320
340
|
let unmatchedCount = 0;
|
|
321
341
|
let placedCount = 0;
|
|
342
|
+
let lowConfidenceCount = 0;
|
|
322
343
|
const duplicateWarnings: string[] = [];
|
|
323
344
|
const usedPositions = new Set<number>(); // For tie-breaking: track used positions
|
|
324
345
|
|
|
@@ -370,24 +391,19 @@ export function insertCommentsIntoMarkdown(
|
|
|
370
391
|
const isEmpty = typeof anchorData === 'object' && anchorData.isEmpty;
|
|
371
392
|
const docPosition = typeof anchorData === 'object' ? anchorData.docPosition : undefined;
|
|
372
393
|
|
|
373
|
-
//
|
|
394
|
+
// Section-scoped insertion. The comment is known to belong to this
|
|
395
|
+
// section (the caller already filtered by docPosition); place it by
|
|
396
|
+
// matching its anchor text within the section, never by mapping a length
|
|
397
|
+
// ratio across the docx and markdown coordinate systems. The section
|
|
398
|
+
// estimate only seeds duplicate tie-breaking and the final fallback.
|
|
374
399
|
if (sectionBoundary && docPosition !== undefined) {
|
|
375
400
|
const sectionLength = sectionBoundary.end - sectionBoundary.start;
|
|
376
401
|
if (sectionLength > 0) {
|
|
377
|
-
|
|
378
|
-
if (docPosition < sectionBoundary.start) {
|
|
379
|
-
relativePos = 0;
|
|
380
|
-
} else {
|
|
381
|
-
relativePos = docPosition - sectionBoundary.start;
|
|
382
|
-
}
|
|
383
|
-
|
|
402
|
+
const relativePos = docPosition < sectionBoundary.start ? 0 : docPosition - sectionBoundary.start;
|
|
384
403
|
const proportion = Math.min(relativePos / sectionLength, 1.0);
|
|
385
|
-
const
|
|
404
|
+
const estimatedPos = Math.floor(proportion * result.length);
|
|
386
405
|
|
|
387
|
-
//
|
|
388
|
-
// pinpoints the original split — without it, proportional placement
|
|
389
|
-
// can land mid-word or split unrelated phrases. Try context match
|
|
390
|
-
// first; only fall through to proportional when context is gone.
|
|
406
|
+
// Empty anchor: before/after context is the only text signal.
|
|
391
407
|
if ((!anchor || isEmpty) && (before || after)) {
|
|
392
408
|
const ctx = findAnchorInText('', result, before, after);
|
|
393
409
|
if (ctx.occurrences.length > 0) {
|
|
@@ -396,62 +412,32 @@ export function insertCommentsIntoMarkdown(
|
|
|
396
412
|
}
|
|
397
413
|
}
|
|
398
414
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
const searchWindow = result.slice(Math.max(0, markdownPos - 25), Math.min(result.length, markdownPos + 25));
|
|
403
|
-
const spaceIdx = searchWindow.indexOf(' ', 25);
|
|
404
|
-
if (spaceIdx !== -1 && spaceIdx < 50) {
|
|
405
|
-
insertPos = Math.max(0, markdownPos - 25) + spaceIdx;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// If we have anchor text, try to find it near this position.
|
|
409
|
-
// Collect ALL occurrences in the local window, then disambiguate
|
|
410
|
-
// via before/after context + usedPositions — otherwise two
|
|
411
|
-
// comments sharing the same anchor word would both collide at
|
|
412
|
-
// the leftmost match. The context-scoring helper handles the
|
|
413
|
-
// "repeated formulaic prose" case using docx-side context, which
|
|
414
|
-
// is a stronger signal than raw distance to the proportional
|
|
415
|
-
// insertPos (insertPos is itself an approximation).
|
|
415
|
+
// Non-empty anchor: locate it anywhere in the section text. Duplicates
|
|
416
|
+
// are disambiguated by before/after context and, only as a tie-break,
|
|
417
|
+
// by proximity to the section estimate.
|
|
416
418
|
if (anchor && !isEmpty) {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const chosen = pickBestOccurrence(localHits, result, before, after, anchor.length, usedPositions);
|
|
425
|
-
if (chosen >= 0) {
|
|
426
|
-
if (localHits.length > 1) {
|
|
427
|
-
duplicateWarnings.push(`"${anchor.slice(0, 40)}${anchor.length > 40 ? '...' : ''}" appears ${localHits.length} times in section window`);
|
|
428
|
-
}
|
|
429
|
-
usedPositions.add(chosen);
|
|
430
|
-
return { ...c, pos: chosen, anchorText: anchor, anchorEnd: chosen + anchor.length, strategy: 'position+text' };
|
|
419
|
+
const { occurrences, matchedAnchor, strategy } = findAnchorInText(anchor, result, before, after);
|
|
420
|
+
if (occurrences.length > 0) {
|
|
421
|
+
const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
|
|
422
|
+
const chosen = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions, estimatedPos);
|
|
423
|
+
const finalIdx = chosen >= 0 ? chosen : occurrences[0];
|
|
424
|
+
if (occurrences.length > 1 && matchedAnchor) {
|
|
425
|
+
duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times in section`);
|
|
431
426
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const words = anchor.split(/\s+/).slice(0, 4).join(' ').toLowerCase();
|
|
436
|
-
if (words.length >= 10) {
|
|
437
|
-
const partialHits = findAllOccurrences(localSearch, words).map(i => searchStart + i);
|
|
438
|
-
if (partialHits.length > 0) {
|
|
439
|
-
const chosen = pickBestOccurrence(partialHits, result, before, after, words.length, usedPositions);
|
|
440
|
-
if (chosen >= 0) {
|
|
441
|
-
usedPositions.add(chosen);
|
|
442
|
-
return { ...c, pos: chosen, anchorText: words, anchorEnd: chosen + words.length, strategy: 'position+partial' };
|
|
443
|
-
}
|
|
427
|
+
usedPositions.add(finalIdx);
|
|
428
|
+
if (matchedAnchor) {
|
|
429
|
+
return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen, strategy: `section:${strategy}` };
|
|
444
430
|
}
|
|
431
|
+
return { ...c, pos: finalIdx, anchorText: null, strategy: `section:${strategy}` };
|
|
445
432
|
}
|
|
446
433
|
}
|
|
447
434
|
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
insertPos = pushPastSectionHeading(result,
|
|
453
|
-
|
|
454
|
-
return { ...c, pos: insertPos, anchorText: null, strategy: 'position-only' };
|
|
435
|
+
// Anchor text and context are both gone from the section. The docx
|
|
436
|
+
// marker's offset is the only remaining signal: map it proportionally
|
|
437
|
+
// and snap to a word boundary. This is an approximate placement, not a
|
|
438
|
+
// match — flagged low-confidence so the summary surfaces it.
|
|
439
|
+
const insertPos = pushPastSectionHeading(result, snapToWordBoundary(result, estimatedPos));
|
|
440
|
+
return { ...c, pos: insertPos, anchorText: null, strategy: 'proportional-fallback' };
|
|
455
441
|
}
|
|
456
442
|
}
|
|
457
443
|
|
|
@@ -599,11 +585,17 @@ export function insertCommentsIntoMarkdown(
|
|
|
599
585
|
} else {
|
|
600
586
|
result = result.slice(0, c.pos) + combined + result.slice(c.pos);
|
|
601
587
|
}
|
|
602
|
-
|
|
588
|
+
// Replies inherit the root's confidence — they ride the same position.
|
|
589
|
+
if (placementConfidence(c.strategy) === 'low') {
|
|
590
|
+
lowConfidenceCount += 1 + replies.length;
|
|
591
|
+
} else {
|
|
592
|
+
placedCount += 1 + replies.length;
|
|
593
|
+
}
|
|
603
594
|
}
|
|
604
595
|
|
|
605
596
|
if (outStats) {
|
|
606
597
|
outStats.placed = placedCount;
|
|
598
|
+
outStats.lowConfidence = lowConfidenceCount;
|
|
607
599
|
outStats.deduped = dedupedCount;
|
|
608
600
|
outStats.unmatched = unmatchedCount;
|
|
609
601
|
}
|
|
@@ -613,6 +605,9 @@ export function insertCommentsIntoMarkdown(
|
|
|
613
605
|
if (unmatchedCount > 0) {
|
|
614
606
|
console.warn(`Warning: ${unmatchedCount} comment(s) could not be matched to anchor text`);
|
|
615
607
|
}
|
|
608
|
+
if (lowConfidenceCount > 0) {
|
|
609
|
+
console.warn(`Note: ${lowConfidenceCount} comment(s) placed approximately — run \`rev verify-anchors\` to review`);
|
|
610
|
+
}
|
|
616
611
|
if (dedupedCount > 0) {
|
|
617
612
|
console.warn(`Note: ${dedupedCount} comment(s) already present at anchor — skipped to avoid duplication`);
|
|
618
613
|
}
|