@ncukondo/search-hub 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/cli/commands/fulltext/check.d.ts +4 -0
  2. package/dist/cli/commands/fulltext/check.d.ts.map +1 -1
  3. package/dist/cli/commands/fulltext/check.js +41 -4
  4. package/dist/cli/commands/fulltext/check.js.map +1 -1
  5. package/dist/cli/commands/fulltext/index.d.ts.map +1 -1
  6. package/dist/cli/commands/fulltext/index.js +10 -0
  7. package/dist/cli/commands/fulltext/index.js.map +1 -1
  8. package/dist/cli/commands/fulltext/verify-pmcid.d.ts +28 -0
  9. package/dist/cli/commands/fulltext/verify-pmcid.d.ts.map +1 -0
  10. package/dist/cli/commands/fulltext/verify-pmcid.js +51 -0
  11. package/dist/cli/commands/fulltext/verify-pmcid.js.map +1 -0
  12. package/dist/cli/commands/register.d.ts +4 -0
  13. package/dist/cli/commands/register.d.ts.map +1 -1
  14. package/dist/cli/commands/register.js +62 -13
  15. package/dist/cli/commands/register.js.map +1 -1
  16. package/dist/cli/commands/upgrade.d.ts +28 -0
  17. package/dist/cli/commands/upgrade.d.ts.map +1 -0
  18. package/dist/cli/commands/upgrade.js +96 -0
  19. package/dist/cli/commands/upgrade.js.map +1 -0
  20. package/dist/cli/index.d.ts.map +1 -1
  21. package/dist/cli/index.js +11 -2
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/utils/argv.d.ts +34 -0
  24. package/dist/cli/utils/argv.d.ts.map +1 -0
  25. package/dist/cli/utils/argv.js +63 -0
  26. package/dist/cli/utils/argv.js.map +1 -0
  27. package/dist/config/paths.js +4 -0
  28. package/dist/config/paths.js.map +1 -1
  29. package/dist/integration/csl-json.d.ts +10 -0
  30. package/dist/integration/csl-json.d.ts.map +1 -1
  31. package/dist/integration/csl-json.js +12 -0
  32. package/dist/integration/csl-json.js.map +1 -1
  33. package/dist/integration/register.d.ts.map +1 -1
  34. package/dist/integration/register.js +1 -1
  35. package/dist/integration/register.js.map +1 -1
  36. package/dist/node_modules/@ncukondo/academic-fulltext/dist/convert/jats-parser.js +25 -7
  37. package/dist/node_modules/@ncukondo/academic-fulltext/dist/convert/jats-parser.js.map +1 -1
  38. package/dist/node_modules/@ncukondo/academic-fulltext/dist/discovery/index.js +67 -25
  39. package/dist/node_modules/@ncukondo/academic-fulltext/dist/discovery/index.js.map +1 -1
  40. package/dist/node_modules/@ncukondo/academic-fulltext/dist/discovery/pmc.js +3 -3
  41. package/dist/node_modules/@ncukondo/academic-fulltext/dist/discovery/pmc.js.map +1 -1
  42. package/dist/package.json.js +1 -1
  43. package/dist/upgrade/apply-binary.d.ts +37 -0
  44. package/dist/upgrade/apply-binary.d.ts.map +1 -0
  45. package/dist/upgrade/apply-binary.js +200 -0
  46. package/dist/upgrade/apply-binary.js.map +1 -0
  47. package/dist/upgrade/apply-npm.d.ts +23 -0
  48. package/dist/upgrade/apply-npm.d.ts.map +1 -0
  49. package/dist/upgrade/apply-npm.js +96 -0
  50. package/dist/upgrade/apply-npm.js.map +1 -0
  51. package/dist/upgrade/check.d.ts +21 -0
  52. package/dist/upgrade/check.d.ts.map +1 -0
  53. package/dist/upgrade/check.js +91 -0
  54. package/dist/upgrade/check.js.map +1 -0
  55. package/dist/upgrade/detect.d.ts +16 -0
  56. package/dist/upgrade/detect.d.ts.map +1 -0
  57. package/dist/upgrade/detect.js +65 -0
  58. package/dist/upgrade/detect.js.map +1 -0
  59. package/dist/upgrade/notifier.d.ts +26 -0
  60. package/dist/upgrade/notifier.d.ts.map +1 -0
  61. package/dist/upgrade/notifier.js +111 -0
  62. package/dist/upgrade/notifier.js.map +1 -0
  63. package/package.json +2 -2
@@ -7,6 +7,10 @@ export interface FulltextCheckOptions {
7
7
  export interface FulltextCheckArticleResult {
8
8
  doi?: string;
9
9
  pmid?: string;
10
+ /** PMCID discovered during OA check and verified against the article */
11
+ pmcid?: string;
12
+ /** PMCID discovered during OA check but rejected because its metadata did not match */
13
+ rejectedPmcid?: string;
10
14
  title: string;
11
15
  oaStatus: OAStatus;
12
16
  locationCount: number;
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/fulltext/check.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,EAAkC,KAAK,eAAe,EAAyB,KAAK,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAMzI,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,QAAQ,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE;QACP,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,EAAE,0BAA0B,EAAE,CAAC;CACxC;AAoHD;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC,CAiC9B"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/fulltext/check.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,EAAkC,KAAK,eAAe,EAAyB,KAAK,QAAQ,EAAmB,MAAM,6BAA6B,CAAC;AAO1J,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uFAAuF;IACvF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,QAAQ,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE;QACP,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,EAAE,0BAA0B,EAAE,CAAC;CACxC;AAiLD;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC,CAiC9B"}
@@ -5,6 +5,7 @@ import { discoverOA } from "../../../node_modules/@ncukondo/academic-fulltext/di
5
5
  import { loadMeta, saveMeta } from "../../../node_modules/@ncukondo/academic-fulltext/dist/meta.js";
6
6
  import "../../../node_modules/@ncukondo/academic-fulltext/dist/convert/jats-parser.js";
7
7
  import "node:crypto";
8
+ import { verifyPmcid } from "./verify-pmcid.js";
8
9
  const DEFAULT_CONCURRENCY = 3;
9
10
  async function loadIncludedArticles(sessionDir) {
10
11
  const reviewsPath = join(sessionDir, ".internal", "reviews.yaml");
@@ -37,26 +38,62 @@ async function findArticleDir(sessionDir, article) {
37
38
  }
38
39
  return null;
39
40
  }
41
+ async function validateDiscoveredPmcid(article, config, discoveredPmcid, oaStatus, locations) {
42
+ const hasPmcLocations = locations.some((loc) => loc.source === "pmc");
43
+ if (!discoveredPmcid || !hasPmcLocations) {
44
+ return { oaStatus, locations };
45
+ }
46
+ const verifyArticle = {
47
+ title: article.title
48
+ };
49
+ if (article.doi) verifyArticle.doi = article.doi;
50
+ if (article.pmid) verifyArticle.pmid = article.pmid;
51
+ const verifyOptions = {};
52
+ if (config.ncbiEmail) verifyOptions.ncbiEmail = config.ncbiEmail;
53
+ if (config.ncbiTool) verifyOptions.ncbiTool = config.ncbiTool;
54
+ const verification = await verifyPmcid(discoveredPmcid, verifyArticle, verifyOptions);
55
+ if (verification === "match") {
56
+ return { oaStatus, locations, verifiedPmcid: discoveredPmcid };
57
+ }
58
+ if (verification === "mismatch") {
59
+ const filtered = locations.filter((loc) => loc.source !== "pmc");
60
+ const status = filtered.length === 0 && oaStatus === "open" ? "unknown" : oaStatus;
61
+ return { oaStatus: status, locations: filtered, rejectedPmcid: discoveredPmcid };
62
+ }
63
+ return { oaStatus, locations };
64
+ }
40
65
  async function processArticle(article, sessionDir, config) {
41
66
  const discoveryArticle = {};
42
67
  if (article.doi) discoveryArticle.doi = article.doi;
43
68
  if (article.pmid) discoveryArticle.pmid = article.pmid;
44
69
  if (article.arxivId) discoveryArticle.arxivId = article.arxivId;
45
70
  const discoveryResult = await discoverOA(discoveryArticle, config);
71
+ const validated = await validateDiscoveredPmcid(
72
+ article,
73
+ config,
74
+ discoveryResult.discoveredIds.pmcid,
75
+ discoveryResult.oaStatus,
76
+ discoveryResult.locations
77
+ );
46
78
  const articleResult = {
47
79
  title: article.title,
48
- oaStatus: discoveryResult.oaStatus,
49
- locationCount: discoveryResult.locations.length
80
+ oaStatus: validated.oaStatus,
81
+ locationCount: validated.locations.length
50
82
  };
51
83
  if (article.doi) articleResult.doi = article.doi;
52
84
  if (article.pmid) articleResult.pmid = article.pmid;
85
+ if (validated.verifiedPmcid) articleResult.pmcid = validated.verifiedPmcid;
86
+ if (validated.rejectedPmcid) articleResult.rejectedPmcid = validated.rejectedPmcid;
53
87
  const dirName = await findArticleDir(sessionDir, article);
54
88
  if (dirName) {
55
89
  try {
56
90
  const metaPath = join(sessionDir, "fulltext", dirName, "meta.json");
57
91
  const meta = await loadMeta(metaPath);
58
- meta.oaStatus = discoveryResult.oaStatus;
59
- meta.oaLocations = discoveryResult.locations;
92
+ meta.oaStatus = validated.oaStatus;
93
+ meta.oaLocations = validated.locations;
94
+ if (validated.verifiedPmcid && !meta.pmcid) {
95
+ meta.pmcid = validated.verifiedPmcid;
96
+ }
60
97
  meta.checkedAt = (/* @__PURE__ */ new Date()).toISOString();
61
98
  await saveMeta(metaPath, meta);
62
99
  } catch {
@@ -1 +1 @@
1
- {"version":3,"file":"check.js","sources":["../../../../src/cli/commands/fulltext/check.ts"],"sourcesContent":["/**\n * Fulltext check command.\n * Checks OA availability for included articles in a session.\n */\n\nimport { readFile, readdir, access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { parse as parseYaml } from 'yaml';\nimport { discoverOA, loadMeta, saveMeta, type DiscoveryConfig, type DiscoveryArticle, type OAStatus } from '@ncukondo/academic-fulltext';\nimport type { ReviewFile, ArticleEntry } from '../review/types';\n\n/** Default concurrency for parallel article processing */\nconst DEFAULT_CONCURRENCY = 3;\n\nexport interface FulltextCheckOptions {\n sessionDir: string;\n config: DiscoveryConfig;\n concurrency?: number;\n}\n\nexport interface FulltextCheckArticleResult {\n doi?: string;\n pmid?: string;\n title: string;\n oaStatus: OAStatus;\n locationCount: number;\n}\n\nexport interface FulltextCheckResult {\n summary: {\n total: number;\n open: number;\n closed: number;\n unknown: number;\n };\n articles: FulltextCheckArticleResult[];\n}\n\n/**\n * Load included articles from reviews.yaml\n */\nasync function loadIncludedArticles(sessionDir: string): Promise<ArticleEntry[]> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n return reviewFile.articles.filter((a) => a.finalDecision === 'include');\n}\n\n/**\n * Try to find a meta.json matching an article in the fulltext directory.\n * Returns the dirName if found, null otherwise.\n */\nasync function findArticleDir(\n sessionDir: string,\n article: ArticleEntry\n): Promise<string | null> {\n const fulltextDir = join(sessionDir, 'fulltext');\n try {\n await access(fulltextDir);\n } catch {\n return null;\n }\n\n let entries: string[];\n try {\n const dirEntries = await readdir(fulltextDir);\n entries = dirEntries.map(String);\n } catch {\n return null;\n }\n\n for (const entry of entries) {\n try {\n const metaPath = join(fulltextDir, entry, 'meta.json');\n const meta = await loadMeta(metaPath);\n // Match by DOI or PMID\n if (article.doi && meta.doi === article.doi) return entry;\n if (article.pmid && meta.pmid === article.pmid) return entry;\n } catch {\n // Skip entries without valid meta.json\n }\n }\n return null;\n}\n\n/**\n * Process a single article: run OA discovery and optionally update meta.json.\n */\nasync function processArticle(\n article: ArticleEntry,\n sessionDir: string,\n config: DiscoveryConfig\n): Promise<FulltextCheckArticleResult> {\n const discoveryArticle: DiscoveryArticle = {};\n if (article.doi) discoveryArticle.doi = article.doi;\n if (article.pmid) discoveryArticle.pmid = article.pmid;\n if (article.arxivId) discoveryArticle.arxivId = article.arxivId;\n const discoveryResult = await discoverOA(discoveryArticle, config);\n\n const articleResult: FulltextCheckArticleResult = {\n title: article.title,\n oaStatus: discoveryResult.oaStatus,\n locationCount: discoveryResult.locations.length,\n };\n if (article.doi) articleResult.doi = article.doi;\n if (article.pmid) articleResult.pmid = article.pmid;\n\n // Try to update meta.json if a fulltext directory exists for this article\n const dirName = await findArticleDir(sessionDir, article);\n if (dirName) {\n try {\n const metaPath = join(sessionDir, 'fulltext', dirName, 'meta.json');\n const meta = await loadMeta(metaPath);\n meta.oaStatus = discoveryResult.oaStatus;\n meta.oaLocations = discoveryResult.locations;\n meta.checkedAt = new Date().toISOString();\n await saveMeta(metaPath, meta);\n } catch {\n // Meta update is best-effort\n }\n }\n\n return articleResult;\n}\n\n/**\n * Run async tasks with a concurrency limit.\n */\nasync function runWithConcurrency<T>(\n tasks: Array<() => Promise<T>>,\n concurrency: number\n): Promise<PromiseSettledResult<T>[]> {\n const results: PromiseSettledResult<T>[] = new Array(tasks.length);\n let nextIndex = 0;\n\n async function worker(): Promise<void> {\n while (nextIndex < tasks.length) {\n const index = nextIndex++;\n try {\n const value = await tasks[index]!();\n results[index] = { status: 'fulfilled', value };\n } catch (reason) {\n results[index] = { status: 'rejected', reason };\n }\n }\n }\n\n const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());\n await Promise.all(workers);\n return results;\n}\n\n/**\n * Execute the fulltext check command.\n * Checks OA availability for all included articles in a session,\n * processing articles in parallel with a concurrency limit.\n */\nexport async function executeFulltextCheck(\n options: FulltextCheckOptions\n): Promise<FulltextCheckResult> {\n const { sessionDir, config, concurrency = DEFAULT_CONCURRENCY } = options;\n\n // Load included articles\n const articles = await loadIncludedArticles(sessionDir);\n\n const summary = { total: articles.length, open: 0, closed: 0, unknown: 0 };\n\n const tasks = articles.map(\n (article) => () => processArticle(article, sessionDir, config)\n );\n\n const settled = await runWithConcurrency(tasks, concurrency);\n\n const results: FulltextCheckArticleResult[] = [];\n for (const result of settled) {\n if (result.status === 'fulfilled') {\n results.push(result.value);\n switch (result.value.oaStatus) {\n case 'open':\n summary.open++;\n break;\n case 'closed':\n summary.closed++;\n break;\n case 'unknown':\n summary.unknown++;\n break;\n }\n }\n }\n\n return { summary, articles: results };\n}\n"],"names":["parseYaml"],"mappings":"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"}
1
+ {"version":3,"file":"check.js","sources":["../../../../src/cli/commands/fulltext/check.ts"],"sourcesContent":["/**\n * Fulltext check command.\n * Checks OA availability for included articles in a session.\n */\n\nimport { readFile, readdir, access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { parse as parseYaml } from 'yaml';\nimport { discoverOA, loadMeta, saveMeta, type DiscoveryConfig, type DiscoveryArticle, type OAStatus, type OALocation } from '@ncukondo/academic-fulltext';\nimport type { ReviewFile, ArticleEntry } from '../review/types';\nimport { verifyPmcid } from './verify-pmcid';\n\n/** Default concurrency for parallel article processing */\nconst DEFAULT_CONCURRENCY = 3;\n\nexport interface FulltextCheckOptions {\n sessionDir: string;\n config: DiscoveryConfig;\n concurrency?: number;\n}\n\nexport interface FulltextCheckArticleResult {\n doi?: string;\n pmid?: string;\n /** PMCID discovered during OA check and verified against the article */\n pmcid?: string;\n /** PMCID discovered during OA check but rejected because its metadata did not match */\n rejectedPmcid?: string;\n title: string;\n oaStatus: OAStatus;\n locationCount: number;\n}\n\nexport interface FulltextCheckResult {\n summary: {\n total: number;\n open: number;\n closed: number;\n unknown: number;\n };\n articles: FulltextCheckArticleResult[];\n}\n\n/**\n * Load included articles from reviews.yaml\n */\nasync function loadIncludedArticles(sessionDir: string): Promise<ArticleEntry[]> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n return reviewFile.articles.filter((a) => a.finalDecision === 'include');\n}\n\n/**\n * Try to find a meta.json matching an article in the fulltext directory.\n * Returns the dirName if found, null otherwise.\n */\nasync function findArticleDir(\n sessionDir: string,\n article: ArticleEntry\n): Promise<string | null> {\n const fulltextDir = join(sessionDir, 'fulltext');\n try {\n await access(fulltextDir);\n } catch {\n return null;\n }\n\n let entries: string[];\n try {\n const dirEntries = await readdir(fulltextDir);\n entries = dirEntries.map(String);\n } catch {\n return null;\n }\n\n for (const entry of entries) {\n try {\n const metaPath = join(fulltextDir, entry, 'meta.json');\n const meta = await loadMeta(metaPath);\n // Match by DOI or PMID\n if (article.doi && meta.doi === article.doi) return entry;\n if (article.pmid && meta.pmid === article.pmid) return entry;\n } catch {\n // Skip entries without valid meta.json\n }\n }\n return null;\n}\n\n/** Outcome of validating a discovered PMCID against the article. */\ninterface ValidatedDiscovery {\n oaStatus: OAStatus;\n locations: OALocation[];\n verifiedPmcid?: string;\n rejectedPmcid?: string;\n}\n\n/**\n * Validate a PMCID discovered during OA discovery against the article's\n * own metadata. On mismatch, drop all PMC locations so an unrelated\n * paper's fulltext is never fetched (issue #146).\n */\nasync function validateDiscoveredPmcid(\n article: ArticleEntry,\n config: DiscoveryConfig,\n discoveredPmcid: string | undefined,\n oaStatus: OAStatus,\n locations: OALocation[]\n): Promise<ValidatedDiscovery> {\n const hasPmcLocations = locations.some((loc) => loc.source === 'pmc');\n if (!discoveredPmcid || !hasPmcLocations) {\n return { oaStatus, locations };\n }\n\n const verifyArticle: { doi?: string; pmid?: string; title?: string } = {\n title: article.title,\n };\n if (article.doi) verifyArticle.doi = article.doi;\n if (article.pmid) verifyArticle.pmid = article.pmid;\n const verifyOptions: { ncbiEmail?: string; ncbiTool?: string } = {};\n if (config.ncbiEmail) verifyOptions.ncbiEmail = config.ncbiEmail;\n if (config.ncbiTool) verifyOptions.ncbiTool = config.ncbiTool;\n\n const verification = await verifyPmcid(discoveredPmcid, verifyArticle, verifyOptions);\n\n if (verification === 'match') {\n return { oaStatus, locations, verifiedPmcid: discoveredPmcid };\n }\n if (verification === 'mismatch') {\n const filtered = locations.filter((loc) => loc.source !== 'pmc');\n const status = filtered.length === 0 && oaStatus === 'open' ? 'unknown' : oaStatus;\n return { oaStatus: status, locations: filtered, rejectedPmcid: discoveredPmcid };\n }\n // Unverified: keep locations but do not vouch for the PMCID\n return { oaStatus, locations };\n}\n\n/**\n * Process a single article: run OA discovery and optionally update meta.json.\n */\nasync function processArticle(\n article: ArticleEntry,\n sessionDir: string,\n config: DiscoveryConfig\n): Promise<FulltextCheckArticleResult> {\n const discoveryArticle: DiscoveryArticle = {};\n if (article.doi) discoveryArticle.doi = article.doi;\n if (article.pmid) discoveryArticle.pmid = article.pmid;\n if (article.arxivId) discoveryArticle.arxivId = article.arxivId;\n const discoveryResult = await discoverOA(discoveryArticle, config);\n\n const validated = await validateDiscoveredPmcid(\n article,\n config,\n discoveryResult.discoveredIds.pmcid,\n discoveryResult.oaStatus,\n discoveryResult.locations\n );\n\n const articleResult: FulltextCheckArticleResult = {\n title: article.title,\n oaStatus: validated.oaStatus,\n locationCount: validated.locations.length,\n };\n if (article.doi) articleResult.doi = article.doi;\n if (article.pmid) articleResult.pmid = article.pmid;\n if (validated.verifiedPmcid) articleResult.pmcid = validated.verifiedPmcid;\n if (validated.rejectedPmcid) articleResult.rejectedPmcid = validated.rejectedPmcid;\n\n // Try to update meta.json if a fulltext directory exists for this article\n const dirName = await findArticleDir(sessionDir, article);\n if (dirName) {\n try {\n const metaPath = join(sessionDir, 'fulltext', dirName, 'meta.json');\n const meta = await loadMeta(metaPath);\n meta.oaStatus = validated.oaStatus;\n meta.oaLocations = validated.locations;\n if (validated.verifiedPmcid && !meta.pmcid) {\n meta.pmcid = validated.verifiedPmcid;\n }\n meta.checkedAt = new Date().toISOString();\n await saveMeta(metaPath, meta);\n } catch {\n // Meta update is best-effort\n }\n }\n\n return articleResult;\n}\n\n/**\n * Run async tasks with a concurrency limit.\n */\nasync function runWithConcurrency<T>(\n tasks: Array<() => Promise<T>>,\n concurrency: number\n): Promise<PromiseSettledResult<T>[]> {\n const results: PromiseSettledResult<T>[] = new Array(tasks.length);\n let nextIndex = 0;\n\n async function worker(): Promise<void> {\n while (nextIndex < tasks.length) {\n const index = nextIndex++;\n try {\n const value = await tasks[index]!();\n results[index] = { status: 'fulfilled', value };\n } catch (reason) {\n results[index] = { status: 'rejected', reason };\n }\n }\n }\n\n const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());\n await Promise.all(workers);\n return results;\n}\n\n/**\n * Execute the fulltext check command.\n * Checks OA availability for all included articles in a session,\n * processing articles in parallel with a concurrency limit.\n */\nexport async function executeFulltextCheck(\n options: FulltextCheckOptions\n): Promise<FulltextCheckResult> {\n const { sessionDir, config, concurrency = DEFAULT_CONCURRENCY } = options;\n\n // Load included articles\n const articles = await loadIncludedArticles(sessionDir);\n\n const summary = { total: articles.length, open: 0, closed: 0, unknown: 0 };\n\n const tasks = articles.map(\n (article) => () => processArticle(article, sessionDir, config)\n );\n\n const settled = await runWithConcurrency(tasks, concurrency);\n\n const results: FulltextCheckArticleResult[] = [];\n for (const result of settled) {\n if (result.status === 'fulfilled') {\n results.push(result.value);\n switch (result.value.oaStatus) {\n case 'open':\n summary.open++;\n break;\n case 'closed':\n summary.closed++;\n break;\n case 'unknown':\n summary.unknown++;\n break;\n }\n }\n }\n\n return { summary, articles: results };\n}\n"],"names":["parseYaml"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/fulltext/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAMpD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,cAAc,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,OAAO,CAAC,MAAM,CAAC,GACvD,IAAI,CAkVN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/fulltext/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAMpD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,cAAc,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,OAAO,CAAC,MAAM,CAAC,GACvD,IAAI,CA2VN"}
@@ -183,6 +183,16 @@ OA Status Summary:`);
183
183
  console.log(` Closed Access: ${result.summary.closed}`);
184
184
  console.log(` Unknown: ${result.summary.unknown}`);
185
185
  console.log(` Total: ${result.summary.total}`);
186
+ const rejected = result.articles.filter((a) => a.rejectedPmcid);
187
+ if (rejected.length > 0) {
188
+ console.log(`
189
+ Warnings:`);
190
+ for (const article of rejected) {
191
+ console.log(
192
+ ` Discovered ${article.rejectedPmcid} does not match "${article.title}" — PMC locations dropped.`
193
+ );
194
+ }
195
+ }
186
196
  if (result.summary.open > 0) {
187
197
  console.log(`
188
198
  Run \`fulltext fetch\` to download available OA articles.`);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../../../src/cli/commands/fulltext/index.ts"],"sourcesContent":["/**\n * Fulltext command group registration.\n */\n\nimport type { Command } from 'commander';\nimport { executeFulltextInit } from './init.js';\nimport { executeFulltextSync } from './sync.js';\nimport { executeFulltextConvert } from './convert.js';\nimport { executeFulltextCheck } from './check.js';\nimport { executeFulltextFetch } from './fetch.js';\nimport { executeFulltextAttach } from './attach.js';\nimport { executeFulltextStatus, type FulltextStatusResult } from './status.js';\nimport { executeFulltextPending, type PendingArticle } from './pending.js';\nimport { formatInitOutput, formatSyncOutput } from './format.js';\nimport type { GlobalOptions } from '../../index.js';\nimport { EXIT_CODES } from '../../exit-codes.js';\nimport { sessionExists } from '../../../session/manager.js';\nimport { loadConfig } from '../../../config/index.js';\nimport { join } from 'node:path';\n\nexport function registerFulltextCommands(\n program: Command,\n getSessionsDir: (opts: GlobalOptions) => Promise<string>,\n): void {\n const fulltextCommand = program\n .command('fulltext')\n .description('Fulltext management: retrieval, conversion, attachment')\n .addHelpText('after', `\nExamples:\n $ search-hub fulltext init SESSION_ID # Create directories for included articles\n $ search-hub fulltext init SESSION_ID --dry-run # Preview what would be created\n $ search-hub fulltext sync SESSION_ID # Detect and register added files\n $ search-hub fulltext sync SESSION_ID --dry-run # Preview what would be synced\n $ search-hub fulltext convert SESSION_ID # Convert PMC XML to Markdown\n $ search-hub fulltext check --session SESSION_ID # Check OA availability\n $ search-hub fulltext fetch SESSION_ID # Download available OA articles\n $ search-hub fulltext fetch SESSION_ID --dry-run # Preview what would be downloaded\n $ search-hub fulltext fetch SESSION_ID --source pmc # Download from specific sources\n $ search-hub fulltext attach SESSION_ID # Attach fulltexts to ref entries\n $ search-hub fulltext attach SESSION_ID --dry-run # Preview what would be attached\n $ search-hub fulltext status SESSION_ID # Show fulltext retrieval status\n $ search-hub fulltext pending SESSION_ID # List articles needing download`);\n\n fulltextCommand\n .command('init')\n .description('Create directories for included articles with meta.json and README')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be created without creating', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const result = await executeFulltextInit({\n sessionId,\n sessionsDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n console.log(formatInitOutput(result));\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('sync')\n .description('Detect and register manually added fulltext files')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be synced without modifying', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const result = await executeFulltextSync({\n sessionId,\n sessionsDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n console.log(formatSyncOutput(result));\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('convert')\n .description('Convert PMC XML files to Markdown')\n .argument('<session-id>', 'session ID')\n .option('--article <dir>', 'convert specific article directory')\n .action(async (sessionId: string, options?: { article?: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n if (!(await sessionExists(sessionId, sessionsDir))) {\n if (!globalOpts.quiet) {\n console.error(`Error: session '${sessionId}' not found`);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n return;\n }\n\n const convertOpts: Parameters<typeof executeFulltextConvert>[0] = { sessionId };\n if (options?.article) convertOpts.article = options.article;\n const result = await executeFulltextConvert(convertOpts, sessionsDir);\n\n if (!globalOpts.quiet) {\n console.log(`Converted: ${result.converted} Skipped: ${result.skipped} Failed: ${result.failed}`);\n for (const article of result.articles) {\n const icon = article.status === 'converted' ? '+' : article.status === 'skipped' ? '-' : '!';\n console.log(` [${icon}] ${article.dirName}: ${article.title}`);\n }\n }\n\n process.exitCode = result.success ? EXIT_CODES.SUCCESS : EXIT_CODES.SESSION_ERROR;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('attach')\n .description('Attach fulltext files to reference-manager entries')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be attached without attaching', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n if (!(await sessionExists(sessionId, sessionsDir))) {\n if (!globalOpts.quiet) {\n console.error(`Error: session '${sessionId}' not found`);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n return;\n }\n\n const sessionDir = join(sessionsDir, sessionId);\n\n const result = await executeFulltextAttach({\n sessionDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n const prefix = options.dryRun ? 'Would attach' : 'Attached';\n console.log(`\\nFulltext ${prefix.toLowerCase()}:`);\n if (result.summary.attached > 0) {\n const totalFiles = result.attached.reduce((sum, a) => sum + a.files.length, 0);\n console.log(` ✓ ${result.summary.attached} articles (${totalFiles} files)`);\n for (const item of result.attached) {\n console.log(` ${item.refId}: ${item.files.join(', ')}`);\n }\n }\n if (result.summary.skipped > 0) {\n console.log(` ⚠ ${result.summary.skipped} skipped`);\n }\n if (result.summary.failed > 0) {\n console.log(` ✗ ${result.summary.failed} failed`);\n for (const item of result.failed) {\n console.log(` ${item.dirName}: ${item.reason}`);\n }\n }\n if (result.summary.total === 0) {\n console.log(' No fulltext directories found.');\n }\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('check')\n .description('Check Open Access availability for included articles')\n .requiredOption('--session <id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .action(async (options: { session: string; format: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, options.session);\n const config = await loadConfig(\n globalOpts.config ? { globalConfigPath: globalOpts.config } : {}\n );\n const result = await executeFulltextCheck({\n sessionDir,\n config: {\n unpaywallEmail: config.fulltext?.sources?.unpaywall_email ?? '',\n coreApiKey: config.fulltext?.sources?.core_api_key ?? '',\n ncbiEmail: config.fulltext?.sources?.ncbi_email ?? '',\n ncbiTool: config.fulltext?.sources?.ncbi_tool ?? 'search-hub',\n preferSources: config.fulltext?.sources?.prefer_sources ?? ['pmc', 'arxiv', 'unpaywall', 'core'],\n },\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(`\\nOA Status Summary:`);\n console.log(` Open Access: ${result.summary.open}`);\n console.log(` Closed Access: ${result.summary.closed}`);\n console.log(` Unknown: ${result.summary.unknown}`);\n console.log(` Total: ${result.summary.total}`);\n if (result.summary.open > 0) {\n console.log(`\\nRun \\`fulltext fetch\\` to download available OA articles.`);\n }\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('fetch')\n .description('Download available OA fulltexts')\n .argument('<session-id>', 'session ID')\n .option('--source <sources>', 'filter by source (comma-separated: pmc,arxiv,unpaywall,core)')\n .option('--no-convert-markdown', 'skip auto-conversion of PMC XML to Markdown')\n .option('--dry-run', 'show what would be downloaded without downloading', false)\n .action(async (sessionId: string, options: { source?: string; convertMarkdown: boolean; dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const fetchOpts: Parameters<typeof executeFulltextFetch>[0] = {\n sessionId,\n sessionsDir,\n convertMarkdown: options.convertMarkdown,\n dryRun: options.dryRun,\n };\n if (options.source) fetchOpts.source = options.source.split(',');\n const result = await executeFulltextFetch(fetchOpts);\n\n if (!globalOpts.quiet) {\n if (result.dryRun) {\n console.log(`\\nDry run: would fetch ${result.articles.length} articles`);\n for (const article of result.articles) {\n console.log(` ${article.dirName}: ${article.title} (${article.locationCount} sources)`);\n }\n } else {\n console.log(`\\nFulltext Fetch Summary:`);\n console.log(` Downloaded: ${result.summary.downloaded}`);\n console.log(` Failed: ${result.summary.failed}`);\n console.log(` Skipped: ${result.summary.skipped}`);\n console.log(` Total: ${result.summary.total}`);\n }\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('status')\n .description('Show overall fulltext retrieval status')\n .argument('<session-id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .action(async (sessionId: string, options: { format: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, sessionId);\n const result = await executeFulltextStatus({\n sessionDir,\n format: options.format as 'table' | 'json',\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(formatStatusOutput(result, sessionId));\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('pending')\n .description('List articles needing manual fulltext download')\n .argument('<session-id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .option('--export <file>', 'export URLs to file for batch download')\n .action(async (sessionId: string, options: { format: string; export?: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, sessionId);\n const result = await executeFulltextPending({\n sessionDir,\n format: options.format as 'table' | 'json',\n exportPath: options.export,\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(formatPendingOutput(result.articles, result.totalPending));\n if (options.export) {\n console.log(`\\nExported URLs to ${options.export}`);\n } else if (result.totalPending > 0) {\n console.log(`\\nExport URLs: fulltext pending ${sessionId} --export urls.txt`);\n }\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n}\n\nfunction formatStatusOutput(result: FulltextStatusResult, sessionId: string): string {\n const lines = [\n `Fulltext Status: ${sessionId}`,\n '',\n ` Included articles: ${result.totalIncluded}`,\n ` With fulltext: ${result.withFulltext}`,\n ` - PDF only: ${result.pdfOnly}`,\n ` - Markdown only: ${result.markdownOnly}`,\n ` - Both: ${result.both}`,\n ` Pending: ${result.pending} (directories created, no files)`,\n ` Not initialized: ${result.notInitialized} (no directory)`,\n ];\n return lines.join('\\n');\n}\n\nfunction formatPendingOutput(articles: PendingArticle[], total: number): string {\n if (total === 0) {\n return 'All included articles have fulltext.';\n }\n\n const lines: string[] = [`${total} articles need fulltext:`, ''];\n\n for (const [i, article] of articles.entries()) {\n const num = i + 1;\n const identifier = article.dirName ?? '(not initialized)';\n lines.push(`${num}. ${identifier} - \"${article.title}\"`);\n if (article.doi) {\n lines.push(` DOI: ${article.doi}`);\n }\n if (article.publisherUrl) {\n lines.push(` Publisher: ${article.publisherUrl}`);\n }\n if (article.oaLocations) {\n for (const loc of article.oaLocations) {\n lines.push(` ${loc.source}: ${loc.url}`);\n }\n }\n lines.push('');\n }\n\n return lines.join('\\n');\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../../../../src/cli/commands/fulltext/index.ts"],"sourcesContent":["/**\n * Fulltext command group registration.\n */\n\nimport type { Command } from 'commander';\nimport { executeFulltextInit } from './init.js';\nimport { executeFulltextSync } from './sync.js';\nimport { executeFulltextConvert } from './convert.js';\nimport { executeFulltextCheck } from './check.js';\nimport { executeFulltextFetch } from './fetch.js';\nimport { executeFulltextAttach } from './attach.js';\nimport { executeFulltextStatus, type FulltextStatusResult } from './status.js';\nimport { executeFulltextPending, type PendingArticle } from './pending.js';\nimport { formatInitOutput, formatSyncOutput } from './format.js';\nimport type { GlobalOptions } from '../../index.js';\nimport { EXIT_CODES } from '../../exit-codes.js';\nimport { sessionExists } from '../../../session/manager.js';\nimport { loadConfig } from '../../../config/index.js';\nimport { join } from 'node:path';\n\nexport function registerFulltextCommands(\n program: Command,\n getSessionsDir: (opts: GlobalOptions) => Promise<string>,\n): void {\n const fulltextCommand = program\n .command('fulltext')\n .description('Fulltext management: retrieval, conversion, attachment')\n .addHelpText('after', `\nExamples:\n $ search-hub fulltext init SESSION_ID # Create directories for included articles\n $ search-hub fulltext init SESSION_ID --dry-run # Preview what would be created\n $ search-hub fulltext sync SESSION_ID # Detect and register added files\n $ search-hub fulltext sync SESSION_ID --dry-run # Preview what would be synced\n $ search-hub fulltext convert SESSION_ID # Convert PMC XML to Markdown\n $ search-hub fulltext check --session SESSION_ID # Check OA availability\n $ search-hub fulltext fetch SESSION_ID # Download available OA articles\n $ search-hub fulltext fetch SESSION_ID --dry-run # Preview what would be downloaded\n $ search-hub fulltext fetch SESSION_ID --source pmc # Download from specific sources\n $ search-hub fulltext attach SESSION_ID # Attach fulltexts to ref entries\n $ search-hub fulltext attach SESSION_ID --dry-run # Preview what would be attached\n $ search-hub fulltext status SESSION_ID # Show fulltext retrieval status\n $ search-hub fulltext pending SESSION_ID # List articles needing download`);\n\n fulltextCommand\n .command('init')\n .description('Create directories for included articles with meta.json and README')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be created without creating', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const result = await executeFulltextInit({\n sessionId,\n sessionsDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n console.log(formatInitOutput(result));\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('sync')\n .description('Detect and register manually added fulltext files')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be synced without modifying', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const result = await executeFulltextSync({\n sessionId,\n sessionsDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n console.log(formatSyncOutput(result));\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('convert')\n .description('Convert PMC XML files to Markdown')\n .argument('<session-id>', 'session ID')\n .option('--article <dir>', 'convert specific article directory')\n .action(async (sessionId: string, options?: { article?: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n if (!(await sessionExists(sessionId, sessionsDir))) {\n if (!globalOpts.quiet) {\n console.error(`Error: session '${sessionId}' not found`);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n return;\n }\n\n const convertOpts: Parameters<typeof executeFulltextConvert>[0] = { sessionId };\n if (options?.article) convertOpts.article = options.article;\n const result = await executeFulltextConvert(convertOpts, sessionsDir);\n\n if (!globalOpts.quiet) {\n console.log(`Converted: ${result.converted} Skipped: ${result.skipped} Failed: ${result.failed}`);\n for (const article of result.articles) {\n const icon = article.status === 'converted' ? '+' : article.status === 'skipped' ? '-' : '!';\n console.log(` [${icon}] ${article.dirName}: ${article.title}`);\n }\n }\n\n process.exitCode = result.success ? EXIT_CODES.SUCCESS : EXIT_CODES.SESSION_ERROR;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('attach')\n .description('Attach fulltext files to reference-manager entries')\n .argument('<session-id>', 'session ID')\n .option('--dry-run', 'show what would be attached without attaching', false)\n .action(async (sessionId: string, options: { dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n if (!(await sessionExists(sessionId, sessionsDir))) {\n if (!globalOpts.quiet) {\n console.error(`Error: session '${sessionId}' not found`);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n return;\n }\n\n const sessionDir = join(sessionsDir, sessionId);\n\n const result = await executeFulltextAttach({\n sessionDir,\n dryRun: options.dryRun,\n });\n\n if (!globalOpts.quiet) {\n const prefix = options.dryRun ? 'Would attach' : 'Attached';\n console.log(`\\nFulltext ${prefix.toLowerCase()}:`);\n if (result.summary.attached > 0) {\n const totalFiles = result.attached.reduce((sum, a) => sum + a.files.length, 0);\n console.log(` ✓ ${result.summary.attached} articles (${totalFiles} files)`);\n for (const item of result.attached) {\n console.log(` ${item.refId}: ${item.files.join(', ')}`);\n }\n }\n if (result.summary.skipped > 0) {\n console.log(` ⚠ ${result.summary.skipped} skipped`);\n }\n if (result.summary.failed > 0) {\n console.log(` ✗ ${result.summary.failed} failed`);\n for (const item of result.failed) {\n console.log(` ${item.dirName}: ${item.reason}`);\n }\n }\n if (result.summary.total === 0) {\n console.log(' No fulltext directories found.');\n }\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error(\n 'Error:',\n error instanceof Error ? error.message : error,\n );\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('check')\n .description('Check Open Access availability for included articles')\n .requiredOption('--session <id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .action(async (options: { session: string; format: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, options.session);\n const config = await loadConfig(\n globalOpts.config ? { globalConfigPath: globalOpts.config } : {}\n );\n const result = await executeFulltextCheck({\n sessionDir,\n config: {\n unpaywallEmail: config.fulltext?.sources?.unpaywall_email ?? '',\n coreApiKey: config.fulltext?.sources?.core_api_key ?? '',\n ncbiEmail: config.fulltext?.sources?.ncbi_email ?? '',\n ncbiTool: config.fulltext?.sources?.ncbi_tool ?? 'search-hub',\n preferSources: config.fulltext?.sources?.prefer_sources ?? ['pmc', 'arxiv', 'unpaywall', 'core'],\n },\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(`\\nOA Status Summary:`);\n console.log(` Open Access: ${result.summary.open}`);\n console.log(` Closed Access: ${result.summary.closed}`);\n console.log(` Unknown: ${result.summary.unknown}`);\n console.log(` Total: ${result.summary.total}`);\n const rejected = result.articles.filter((a) => a.rejectedPmcid);\n if (rejected.length > 0) {\n console.log(`\\nWarnings:`);\n for (const article of rejected) {\n console.log(\n ` Discovered ${article.rejectedPmcid} does not match \"${article.title}\" — PMC locations dropped.`\n );\n }\n }\n if (result.summary.open > 0) {\n console.log(`\\nRun \\`fulltext fetch\\` to download available OA articles.`);\n }\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('fetch')\n .description('Download available OA fulltexts')\n .argument('<session-id>', 'session ID')\n .option('--source <sources>', 'filter by source (comma-separated: pmc,arxiv,unpaywall,core)')\n .option('--no-convert-markdown', 'skip auto-conversion of PMC XML to Markdown')\n .option('--dry-run', 'show what would be downloaded without downloading', false)\n .action(async (sessionId: string, options: { source?: string; convertMarkdown: boolean; dryRun: boolean }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n\n const fetchOpts: Parameters<typeof executeFulltextFetch>[0] = {\n sessionId,\n sessionsDir,\n convertMarkdown: options.convertMarkdown,\n dryRun: options.dryRun,\n };\n if (options.source) fetchOpts.source = options.source.split(',');\n const result = await executeFulltextFetch(fetchOpts);\n\n if (!globalOpts.quiet) {\n if (result.dryRun) {\n console.log(`\\nDry run: would fetch ${result.articles.length} articles`);\n for (const article of result.articles) {\n console.log(` ${article.dirName}: ${article.title} (${article.locationCount} sources)`);\n }\n } else {\n console.log(`\\nFulltext Fetch Summary:`);\n console.log(` Downloaded: ${result.summary.downloaded}`);\n console.log(` Failed: ${result.summary.failed}`);\n console.log(` Skipped: ${result.summary.skipped}`);\n console.log(` Total: ${result.summary.total}`);\n }\n }\n\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('status')\n .description('Show overall fulltext retrieval status')\n .argument('<session-id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .action(async (sessionId: string, options: { format: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, sessionId);\n const result = await executeFulltextStatus({\n sessionDir,\n format: options.format as 'table' | 'json',\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(formatStatusOutput(result, sessionId));\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n\n fulltextCommand\n .command('pending')\n .description('List articles needing manual fulltext download')\n .argument('<session-id>', 'session ID')\n .option('--format <format>', 'output format (table or json)', 'table')\n .option('--export <file>', 'export URLs to file for batch download')\n .action(async (sessionId: string, options: { format: string; export?: string }) => {\n const globalOpts = program.opts() as GlobalOptions;\n try {\n const sessionsDir = await getSessionsDir(globalOpts);\n const sessionDir = join(sessionsDir, sessionId);\n const result = await executeFulltextPending({\n sessionDir,\n format: options.format as 'table' | 'json',\n exportPath: options.export,\n });\n\n if (options.format === 'json') {\n console.log(JSON.stringify(result, null, 2));\n } else if (!globalOpts.quiet) {\n console.log(formatPendingOutput(result.articles, result.totalPending));\n if (options.export) {\n console.log(`\\nExported URLs to ${options.export}`);\n } else if (result.totalPending > 0) {\n console.log(`\\nExport URLs: fulltext pending ${sessionId} --export urls.txt`);\n }\n }\n process.exitCode = EXIT_CODES.SUCCESS;\n } catch (error) {\n if (!globalOpts.quiet) {\n console.error('Error:', error instanceof Error ? error.message : error);\n }\n process.exitCode = EXIT_CODES.SESSION_ERROR;\n }\n });\n}\n\nfunction formatStatusOutput(result: FulltextStatusResult, sessionId: string): string {\n const lines = [\n `Fulltext Status: ${sessionId}`,\n '',\n ` Included articles: ${result.totalIncluded}`,\n ` With fulltext: ${result.withFulltext}`,\n ` - PDF only: ${result.pdfOnly}`,\n ` - Markdown only: ${result.markdownOnly}`,\n ` - Both: ${result.both}`,\n ` Pending: ${result.pending} (directories created, no files)`,\n ` Not initialized: ${result.notInitialized} (no directory)`,\n ];\n return lines.join('\\n');\n}\n\nfunction formatPendingOutput(articles: PendingArticle[], total: number): string {\n if (total === 0) {\n return 'All included articles have fulltext.';\n }\n\n const lines: string[] = [`${total} articles need fulltext:`, ''];\n\n for (const [i, article] of articles.entries()) {\n const num = i + 1;\n const identifier = article.dirName ?? '(not initialized)';\n lines.push(`${num}. ${identifier} - \"${article.title}\"`);\n if (article.doi) {\n lines.push(` DOI: ${article.doi}`);\n }\n if (article.publisherUrl) {\n lines.push(` Publisher: ${article.publisherUrl}`);\n }\n if (article.oaLocations) {\n for (const loc of article.oaLocations) {\n lines.push(` ${loc.source}: ${loc.url}`);\n }\n }\n lines.push('');\n }\n\n return lines.join('\\n');\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * PMCID verification against source article metadata.
3
+ *
4
+ * OA discovery can resolve a PMCID that belongs to a different paper
5
+ * (e.g. a citing article returned by elink). Before attaching PMC
6
+ * locations to an article, cross-check the PMC record's DOI/PMID/title
7
+ * against the source article via NCBI esummary (issue #146).
8
+ */
9
+ export interface PmcidVerifyArticle {
10
+ doi?: string;
11
+ pmid?: string;
12
+ title?: string;
13
+ }
14
+ export type PmcidVerification = 'match' | 'mismatch' | 'unverified';
15
+ export interface VerifyPmcidOptions {
16
+ ncbiEmail?: string;
17
+ ncbiTool?: string;
18
+ /** Injectable fetch for testing */
19
+ fetchFn?: typeof fetch;
20
+ }
21
+ /**
22
+ * Verify that a PMCID actually refers to the given article.
23
+ * Compares by DOI first, then PMID, then normalized title.
24
+ * Returns 'unverified' when the PMC record cannot be fetched or
25
+ * shares no comparable field with the article.
26
+ */
27
+ export declare function verifyPmcid(pmcid: string, article: PmcidVerifyArticle, options?: VerifyPmcidOptions): Promise<PmcidVerification>;
28
+ //# sourceMappingURL=verify-pmcid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-pmcid.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/fulltext/verify-pmcid.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,UAAU,GAAG,YAAY,CAAC;AAEpE,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB;AAuDD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,kBAAkB,EAC3B,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC,CAuB5B"}
@@ -0,0 +1,51 @@
1
+ const ESUMMARY_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi";
2
+ function normalizeDoi(doi) {
3
+ return doi.trim().toLowerCase().replace(/^https?:\/\/(dx\.)?doi\.org\//, "").replace(/^doi:/, "");
4
+ }
5
+ function normalizeTitle(title) {
6
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "");
7
+ }
8
+ async function fetchPmcRecord(pmcid, options) {
9
+ const numericId = pmcid.replace(/^PMC/i, "");
10
+ const params = new URLSearchParams({ db: "pmc", id: numericId, retmode: "json" });
11
+ if (options.ncbiTool) params.set("tool", options.ncbiTool);
12
+ if (options.ncbiEmail) params.set("email", options.ncbiEmail);
13
+ const fetchFn = options.fetchFn ?? fetch;
14
+ try {
15
+ const response = await fetchFn(`${ESUMMARY_URL}?${params.toString()}`);
16
+ if (!response.ok) return null;
17
+ const data = await response.json();
18
+ const record = data.result?.[numericId];
19
+ if (!record || typeof record !== "object") return null;
20
+ return record;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function findArticleId(record, idtype) {
26
+ return record.articleids?.find((id) => id.idtype === idtype)?.value;
27
+ }
28
+ async function verifyPmcid(pmcid, article, options = {}) {
29
+ const record = await fetchPmcRecord(pmcid, options);
30
+ if (!record) return "unverified";
31
+ const recordDoi = findArticleId(record, "doi");
32
+ if (article.doi && recordDoi) {
33
+ return normalizeDoi(article.doi) === normalizeDoi(recordDoi) ? "match" : "mismatch";
34
+ }
35
+ const recordPmid = findArticleId(record, "pmid");
36
+ if (article.pmid && recordPmid) {
37
+ return article.pmid.trim() === recordPmid.trim() ? "match" : "mismatch";
38
+ }
39
+ if (article.title && record.title) {
40
+ const articleTitle = normalizeTitle(article.title);
41
+ const recordTitle = normalizeTitle(record.title);
42
+ if (articleTitle && recordTitle) {
43
+ return articleTitle === recordTitle ? "match" : "mismatch";
44
+ }
45
+ }
46
+ return "unverified";
47
+ }
48
+ export {
49
+ verifyPmcid
50
+ };
51
+ //# sourceMappingURL=verify-pmcid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-pmcid.js","sources":["../../../../src/cli/commands/fulltext/verify-pmcid.ts"],"sourcesContent":["/**\n * PMCID verification against source article metadata.\n *\n * OA discovery can resolve a PMCID that belongs to a different paper\n * (e.g. a citing article returned by elink). Before attaching PMC\n * locations to an article, cross-check the PMC record's DOI/PMID/title\n * against the source article via NCBI esummary (issue #146).\n */\n\nconst ESUMMARY_URL = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi';\n\nexport interface PmcidVerifyArticle {\n doi?: string;\n pmid?: string;\n title?: string;\n}\n\nexport type PmcidVerification = 'match' | 'mismatch' | 'unverified';\n\nexport interface VerifyPmcidOptions {\n ncbiEmail?: string;\n ncbiTool?: string;\n /** Injectable fetch for testing */\n fetchFn?: typeof fetch;\n}\n\ninterface EsummaryArticleId {\n idtype?: string;\n value?: string;\n}\n\ninterface EsummaryRecord {\n title?: string;\n articleids?: EsummaryArticleId[];\n}\n\n/** Lowercase a DOI and strip url/prefix decorations for comparison. */\nfunction normalizeDoi(doi: string): string {\n return doi\n .trim()\n .toLowerCase()\n .replace(/^https?:\\/\\/(dx\\.)?doi\\.org\\//, '')\n .replace(/^doi:/, '');\n}\n\n/** Reduce a title to lowercase alphanumerics for tolerant comparison. */\nfunction normalizeTitle(title: string): string {\n return title.toLowerCase().replace(/[^a-z0-9]+/g, '');\n}\n\n/** Fetch the esummary record for a PMC ID. Returns null on any failure. */\nasync function fetchPmcRecord(\n pmcid: string,\n options: VerifyPmcidOptions\n): Promise<EsummaryRecord | null> {\n const numericId = pmcid.replace(/^PMC/i, '');\n const params = new URLSearchParams({ db: 'pmc', id: numericId, retmode: 'json' });\n if (options.ncbiTool) params.set('tool', options.ncbiTool);\n if (options.ncbiEmail) params.set('email', options.ncbiEmail);\n\n const fetchFn = options.fetchFn ?? fetch;\n try {\n const response = await fetchFn(`${ESUMMARY_URL}?${params.toString()}`);\n if (!response.ok) return null;\n const data = (await response.json()) as {\n result?: Record<string, unknown>;\n };\n const record = data.result?.[numericId];\n if (!record || typeof record !== 'object') return null;\n return record as EsummaryRecord;\n } catch {\n return null;\n }\n}\n\nfunction findArticleId(record: EsummaryRecord, idtype: string): string | undefined {\n return record.articleids?.find((id) => id.idtype === idtype)?.value;\n}\n\n/**\n * Verify that a PMCID actually refers to the given article.\n * Compares by DOI first, then PMID, then normalized title.\n * Returns 'unverified' when the PMC record cannot be fetched or\n * shares no comparable field with the article.\n */\nexport async function verifyPmcid(\n pmcid: string,\n article: PmcidVerifyArticle,\n options: VerifyPmcidOptions = {}\n): Promise<PmcidVerification> {\n const record = await fetchPmcRecord(pmcid, options);\n if (!record) return 'unverified';\n\n const recordDoi = findArticleId(record, 'doi');\n if (article.doi && recordDoi) {\n return normalizeDoi(article.doi) === normalizeDoi(recordDoi) ? 'match' : 'mismatch';\n }\n\n const recordPmid = findArticleId(record, 'pmid');\n if (article.pmid && recordPmid) {\n return article.pmid.trim() === recordPmid.trim() ? 'match' : 'mismatch';\n }\n\n if (article.title && record.title) {\n const articleTitle = normalizeTitle(article.title);\n const recordTitle = normalizeTitle(record.title);\n if (articleTitle && recordTitle) {\n return articleTitle === recordTitle ? 'match' : 'mismatch';\n }\n }\n\n return 'unverified';\n}\n"],"names":[],"mappings":"AASA,MAAM,eAAe;AA4BrB,SAAS,aAAa,KAAqB;AACzC,SAAO,IACJ,OACA,cACA,QAAQ,iCAAiC,EAAE,EAC3C,QAAQ,SAAS,EAAE;AACxB;AAGA,SAAS,eAAe,OAAuB;AAC7C,SAAO,MAAM,YAAA,EAAc,QAAQ,eAAe,EAAE;AACtD;AAGA,eAAe,eACb,OACA,SACgC;AAChC,QAAM,YAAY,MAAM,QAAQ,SAAS,EAAE;AAC3C,QAAM,SAAS,IAAI,gBAAgB,EAAE,IAAI,OAAO,IAAI,WAAW,SAAS,QAAQ;AAChF,MAAI,QAAQ,SAAU,QAAO,IAAI,QAAQ,QAAQ,QAAQ;AACzD,MAAI,QAAQ,UAAW,QAAO,IAAI,SAAS,QAAQ,SAAS;AAE5D,QAAM,UAAU,QAAQ,WAAW;AACnC,MAAI;AACF,UAAM,WAAW,MAAM,QAAQ,GAAG,YAAY,IAAI,OAAO,SAAA,CAAU,EAAE;AACrE,QAAI,CAAC,SAAS,GAAI,QAAO;AACzB,UAAM,OAAQ,MAAM,SAAS,KAAA;AAG7B,UAAM,SAAS,KAAK,SAAS,SAAS;AACtC,QAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,cAAc,QAAwB,QAAoC;AACjF,SAAO,OAAO,YAAY,KAAK,CAAC,OAAO,GAAG,WAAW,MAAM,GAAG;AAChE;AAQA,eAAsB,YACpB,OACA,SACA,UAA8B,CAAA,GACF;AAC5B,QAAM,SAAS,MAAM,eAAe,OAAO,OAAO;AAClD,MAAI,CAAC,OAAQ,QAAO;AAEpB,QAAM,YAAY,cAAc,QAAQ,KAAK;AAC7C,MAAI,QAAQ,OAAO,WAAW;AAC5B,WAAO,aAAa,QAAQ,GAAG,MAAM,aAAa,SAAS,IAAI,UAAU;AAAA,EAC3E;AAEA,QAAM,aAAa,cAAc,QAAQ,MAAM;AAC/C,MAAI,QAAQ,QAAQ,YAAY;AAC9B,WAAO,QAAQ,KAAK,KAAA,MAAW,WAAW,KAAA,IAAS,UAAU;AAAA,EAC/D;AAEA,MAAI,QAAQ,SAAS,OAAO,OAAO;AACjC,UAAM,eAAe,eAAe,QAAQ,KAAK;AACjD,UAAM,cAAc,eAAe,OAAO,KAAK;AAC/C,QAAI,gBAAgB,aAAa;AAC/B,aAAO,iBAAiB,cAAc,UAAU;AAAA,IAClD;AAAA,EACF;AAEA,SAAO;AACT;"}
@@ -74,6 +74,10 @@ export declare function getReviewSummary(sessionId: string, sessionsDir: string)
74
74
  * Get articles with finalDecision='include' from review file.
75
75
  * Converts from ArticleEntry format to Article format.
76
76
  *
77
+ * Authors are taken from the session's structured search results when the
78
+ * article can be matched by identifier; the authors string in reviews.yaml
79
+ * is only used as a fallback.
80
+ *
77
81
  * @throws Error if mergedFrom is missing or empty (indicates legacy review file)
78
82
  */
79
83
  export declare function getIncludedArticles(sessionId: string, sessionsDir: string): Promise<Article[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/register.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/register.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ import { access, readFile } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { createInterface } from "node:readline";
5
5
  import { parse } from "yaml";
6
+ import { loadResults } from "../../session/results-io.js";
6
7
  import { parseProviderNames } from "../utils/validation.js";
7
8
  import { classifyStatus } from "./review/types.js";
8
9
  function parseRegisterOptions(sessionId, options) {
@@ -50,6 +51,15 @@ function getRegistrationId(article) {
50
51
  if (article.doi) {
51
52
  return article.doi;
52
53
  }
54
+ if (article.arxivId) {
55
+ return `arxiv:${article.arxivId}`;
56
+ }
57
+ if (article.ericId) {
58
+ return `eric:${article.ericId}`;
59
+ }
60
+ if (article.scopusId) {
61
+ return `scopus:${article.scopusId}`;
62
+ }
53
63
  return null;
54
64
  }
55
65
  function formatDryRunOutput(articles) {
@@ -74,15 +84,13 @@ function formatDryRunOutput(articles) {
74
84
  if (withoutId.length > 0) {
75
85
  lines.push("");
76
86
  lines.push(
77
- `${withoutId.length} article${withoutId.length !== 1 ? "s" : ""} will be skipped (no DOI or PMID):`
87
+ `${withoutId.length} article${withoutId.length !== 1 ? "s" : ""} will be skipped (no identifier):`
78
88
  );
79
89
  const maxDisplay = 10;
80
90
  const displayed = withoutId.slice(0, maxDisplay);
81
91
  for (const article of displayed) {
82
92
  const truncatedTitle = article.title.length > 50 ? article.title.substring(0, 50) + "..." : article.title;
83
- const altIds = getAlternativeIds(article);
84
- const hasAltIds = altIds.length > 0 ? `, has: ${altIds.join(", ")}` : "";
85
- lines.push(` - "${truncatedTitle}" (source: ${article.source}${hasAltIds})`);
93
+ lines.push(` - "${truncatedTitle}" (source: ${article.source})`);
86
94
  }
87
95
  if (withoutId.length > maxDisplay) {
88
96
  lines.push(` ... and ${withoutId.length - maxDisplay} more`);
@@ -90,13 +98,6 @@ function formatDryRunOutput(articles) {
90
98
  }
91
99
  return lines.join("\n");
92
100
  }
93
- function getAlternativeIds(article) {
94
- const ids = [];
95
- if (article.arxivId) ids.push(`arxiv:${article.arxivId}`);
96
- if (article.ericId) ids.push(`eric:${article.ericId}`);
97
- if (article.scopusId) ids.push(`scopus:${article.scopusId}`);
98
- return ids;
99
- }
100
101
  async function hasReviewFile(sessionId, sessionsDir) {
101
102
  const reviewsPath = join(sessionsDir, sessionId, ".internal", "reviews.yaml");
102
103
  try {
@@ -139,14 +140,62 @@ function parseAuthorName(name) {
139
140
  if (parts.length === 1) {
140
141
  return { family: parts[0] ?? "" };
141
142
  }
143
+ const last = parts[parts.length - 1];
144
+ if (/^[A-Z]{1,3}$/.test(last)) {
145
+ parts.pop();
146
+ return { family: parts.join(" "), given: last };
147
+ }
142
148
  const family = parts.pop() ?? "";
143
149
  const given = parts.join(" ");
144
150
  return { family, given };
145
151
  }
152
+ function authorLookupKeys(ref) {
153
+ const keys = [];
154
+ if (ref.pmid) keys.push(`pmid:${ref.pmid}`);
155
+ if (ref.doi) keys.push(`doi:${ref.doi.toLowerCase()}`);
156
+ if (ref.arxivId) keys.push(`arxiv:${ref.arxivId}`);
157
+ if (ref.scopusId) keys.push(`scopus:${ref.scopusId}`);
158
+ if (ref.ericId) keys.push(`eric:${ref.ericId}`);
159
+ return keys;
160
+ }
161
+ async function buildAuthorLookup(sessionDir, providers) {
162
+ const lookup = /* @__PURE__ */ new Map();
163
+ for (const provider of providers) {
164
+ const articles = await loadResults(sessionDir, provider);
165
+ for (const article of articles) {
166
+ if (!article.authors || article.authors.length === 0) continue;
167
+ for (const key of authorLookupKeys(article)) {
168
+ if (!lookup.has(key)) {
169
+ lookup.set(key, article.authors);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return lookup;
175
+ }
176
+ function resolveAuthors(entry, lookup) {
177
+ const keys = [
178
+ ...authorLookupKeys(entry),
179
+ ...(entry.mergedFrom ?? []).flatMap((source) => authorLookupKeys(source))
180
+ ];
181
+ for (const key of keys) {
182
+ const authors = lookup.get(key);
183
+ if (authors) return authors;
184
+ }
185
+ return entry.authors ? entry.authors.split(/,\s*/).map(parseAuthorName) : [];
186
+ }
146
187
  async function getIncludedArticles(sessionId, sessionsDir) {
147
188
  const reviewFile = await loadReviewFile(sessionId, sessionsDir);
148
189
  const articles = reviewFile.articles ?? [];
149
- return articles.filter((entry) => entry.finalDecision === "include").map((entry) => {
190
+ const included = articles.filter((entry) => entry.finalDecision === "include");
191
+ const providers = /* @__PURE__ */ new Set();
192
+ for (const entry of included) {
193
+ for (const source of entry.mergedFrom ?? []) {
194
+ providers.add(source.source);
195
+ }
196
+ }
197
+ const authorLookup = await buildAuthorLookup(join(sessionsDir, sessionId), providers);
198
+ return included.map((entry) => {
150
199
  if (!entry.mergedFrom) {
151
200
  throw new Error(
152
201
  `Article "${entry.title}" has mergedFrom missing. This may be a legacy review file created before source tracking was fixed. Please re-run 'review init' to regenerate the review file with source tracking.`
@@ -157,7 +206,7 @@ async function getIncludedArticles(sessionId, sessionsDir) {
157
206
  `Article "${entry.title}" has empty mergedFrom array. This is an invalid state - please re-run 'review init' to regenerate.`
158
207
  );
159
208
  }
160
- const authors = entry.authors ? entry.authors.split(/,\s*/).map(parseAuthorName) : [];
209
+ const authors = resolveAuthors(entry, authorLookup);
161
210
  const source = entry.mergedFrom[0].source;
162
211
  const article = {
163
212
  title: entry.title,