@ncukondo/reference-manager 0.1.0 → 0.3.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 (116) hide show
  1. package/README.md +40 -0
  2. package/dist/chunks/detector-DHztTaFY.js +619 -0
  3. package/dist/chunks/detector-DHztTaFY.js.map +1 -0
  4. package/dist/chunks/{detector-BF8Mcc72.js → loader-mQ25o6cV.js} +303 -664
  5. package/dist/chunks/loader-mQ25o6cV.js.map +1 -0
  6. package/dist/chunks/search-Be9vzUIH.js +29541 -0
  7. package/dist/chunks/search-Be9vzUIH.js.map +1 -0
  8. package/dist/cli/commands/add.d.ts +44 -16
  9. package/dist/cli/commands/add.d.ts.map +1 -1
  10. package/dist/cli/commands/cite.d.ts +49 -0
  11. package/dist/cli/commands/cite.d.ts.map +1 -0
  12. package/dist/cli/commands/fulltext.d.ts +101 -0
  13. package/dist/cli/commands/fulltext.d.ts.map +1 -0
  14. package/dist/cli/commands/index.d.ts +14 -10
  15. package/dist/cli/commands/index.d.ts.map +1 -1
  16. package/dist/cli/commands/list.d.ts +23 -6
  17. package/dist/cli/commands/list.d.ts.map +1 -1
  18. package/dist/cli/commands/remove.d.ts +47 -12
  19. package/dist/cli/commands/remove.d.ts.map +1 -1
  20. package/dist/cli/commands/search.d.ts +24 -7
  21. package/dist/cli/commands/search.d.ts.map +1 -1
  22. package/dist/cli/commands/update.d.ts +26 -13
  23. package/dist/cli/commands/update.d.ts.map +1 -1
  24. package/dist/cli/execution-context.d.ts +60 -0
  25. package/dist/cli/execution-context.d.ts.map +1 -0
  26. package/dist/cli/helpers.d.ts +18 -0
  27. package/dist/cli/helpers.d.ts.map +1 -1
  28. package/dist/cli/index.d.ts.map +1 -1
  29. package/dist/cli/server-client.d.ts +73 -10
  30. package/dist/cli/server-client.d.ts.map +1 -1
  31. package/dist/cli.js +1200 -528
  32. package/dist/cli.js.map +1 -1
  33. package/dist/config/csl-styles.d.ts +83 -0
  34. package/dist/config/csl-styles.d.ts.map +1 -0
  35. package/dist/config/defaults.d.ts +10 -0
  36. package/dist/config/defaults.d.ts.map +1 -1
  37. package/dist/config/loader.d.ts.map +1 -1
  38. package/dist/config/schema.d.ts +84 -0
  39. package/dist/config/schema.d.ts.map +1 -1
  40. package/dist/core/csl-json/types.d.ts +15 -3
  41. package/dist/core/csl-json/types.d.ts.map +1 -1
  42. package/dist/core/library.d.ts +60 -0
  43. package/dist/core/library.d.ts.map +1 -1
  44. package/dist/features/format/bibtex.d.ts +6 -0
  45. package/dist/features/format/bibtex.d.ts.map +1 -0
  46. package/dist/features/format/citation-csl.d.ts +41 -0
  47. package/dist/features/format/citation-csl.d.ts.map +1 -0
  48. package/dist/features/format/citation-fallback.d.ts +24 -0
  49. package/dist/features/format/citation-fallback.d.ts.map +1 -0
  50. package/dist/features/format/index.d.ts +10 -0
  51. package/dist/features/format/index.d.ts.map +1 -0
  52. package/dist/features/format/json.d.ts +6 -0
  53. package/dist/features/format/json.d.ts.map +1 -0
  54. package/dist/features/format/pretty.d.ts +6 -0
  55. package/dist/features/format/pretty.d.ts.map +1 -0
  56. package/dist/features/fulltext/filename.d.ts +17 -0
  57. package/dist/features/fulltext/filename.d.ts.map +1 -0
  58. package/dist/features/fulltext/index.d.ts +7 -0
  59. package/dist/features/fulltext/index.d.ts.map +1 -0
  60. package/dist/features/fulltext/manager.d.ts +109 -0
  61. package/dist/features/fulltext/manager.d.ts.map +1 -0
  62. package/dist/features/fulltext/types.d.ts +12 -0
  63. package/dist/features/fulltext/types.d.ts.map +1 -0
  64. package/dist/features/import/cache.d.ts +37 -0
  65. package/dist/features/import/cache.d.ts.map +1 -0
  66. package/dist/features/import/detector.d.ts +42 -0
  67. package/dist/features/import/detector.d.ts.map +1 -0
  68. package/dist/features/import/fetcher.d.ts +49 -0
  69. package/dist/features/import/fetcher.d.ts.map +1 -0
  70. package/dist/features/import/importer.d.ts +61 -0
  71. package/dist/features/import/importer.d.ts.map +1 -0
  72. package/dist/features/import/index.d.ts +8 -0
  73. package/dist/features/import/index.d.ts.map +1 -0
  74. package/dist/features/import/normalizer.d.ts +15 -0
  75. package/dist/features/import/normalizer.d.ts.map +1 -0
  76. package/dist/features/import/parser.d.ts +33 -0
  77. package/dist/features/import/parser.d.ts.map +1 -0
  78. package/dist/features/import/rate-limiter.d.ts +45 -0
  79. package/dist/features/import/rate-limiter.d.ts.map +1 -0
  80. package/dist/features/operations/add.d.ts +65 -0
  81. package/dist/features/operations/add.d.ts.map +1 -0
  82. package/dist/features/operations/cite.d.ts +48 -0
  83. package/dist/features/operations/cite.d.ts.map +1 -0
  84. package/dist/features/operations/list.d.ts +28 -0
  85. package/dist/features/operations/list.d.ts.map +1 -0
  86. package/dist/features/operations/remove.d.ts +29 -0
  87. package/dist/features/operations/remove.d.ts.map +1 -0
  88. package/dist/features/operations/search.d.ts +30 -0
  89. package/dist/features/operations/search.d.ts.map +1 -0
  90. package/dist/features/operations/update.d.ts +39 -0
  91. package/dist/features/operations/update.d.ts.map +1 -0
  92. package/dist/index.js +18 -16
  93. package/dist/index.js.map +1 -1
  94. package/dist/server/index.d.ts +3 -1
  95. package/dist/server/index.d.ts.map +1 -1
  96. package/dist/server/routes/add.d.ts +11 -0
  97. package/dist/server/routes/add.d.ts.map +1 -0
  98. package/dist/server/routes/cite.d.ts +9 -0
  99. package/dist/server/routes/cite.d.ts.map +1 -0
  100. package/dist/server/routes/list.d.ts +25 -0
  101. package/dist/server/routes/list.d.ts.map +1 -0
  102. package/dist/server/routes/references.d.ts.map +1 -1
  103. package/dist/server/routes/search.d.ts +26 -0
  104. package/dist/server/routes/search.d.ts.map +1 -0
  105. package/dist/server.js +215 -32
  106. package/dist/server.js.map +1 -1
  107. package/package.json +15 -4
  108. package/dist/chunks/detector-BF8Mcc72.js.map +0 -1
  109. package/dist/cli/output/bibtex.d.ts +0 -6
  110. package/dist/cli/output/bibtex.d.ts.map +0 -1
  111. package/dist/cli/output/index.d.ts +0 -7
  112. package/dist/cli/output/index.d.ts.map +0 -1
  113. package/dist/cli/output/json.d.ts +0 -6
  114. package/dist/cli/output/json.d.ts.map +0 -1
  115. package/dist/cli/output/pretty.d.ts +0 -6
  116. package/dist/cli/output/pretty.d.ts.map +0 -1
package/dist/cli.js CHANGED
@@ -1,11 +1,21 @@
1
1
  import { Command } from "commander";
2
2
  import { z } from "zod";
3
- import { o as detectDuplicate, R as Reference, t as tokenize, s as search$1, m as sortResults, l as loadConfig, L as Library } from "./chunks/detector-BF8Mcc72.js";
4
- import { promises, readFileSync } from "node:fs";
3
+ import { l as loadConfig, L as Library } from "./chunks/loader-mQ25o6cV.js";
4
+ import { promises, existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
5
5
  import * as os from "node:os";
6
+ import { tmpdir } from "node:os";
6
7
  import * as path from "node:path";
7
- import { stdin, stdout } from "node:process";
8
+ import { join, extname } from "node:path";
9
+ import { a as addReferences, c as citeReferences, u as updateReference, l as listReferences, r as removeReference, s as searchReferences } from "./chunks/search-Be9vzUIH.js";
10
+ import { mkdir, unlink, rename, copyFile, rm, readFile } from "node:fs/promises";
8
11
  import { spawn } from "node:child_process";
12
+ import { stdin, stdout } from "node:process";
13
+ const version = "0.3.0";
14
+ const description = "A local reference management tool using CSL-JSON as the single source of truth";
15
+ const packageJson = {
16
+ version,
17
+ description
18
+ };
9
19
  function getPortfilePath() {
10
20
  const tmpDir = os.tmpdir();
11
21
  return path.join(tmpDir, "reference-manager", "server.port");
@@ -67,192 +77,620 @@ function isProcessRunning(pid) {
67
77
  return false;
68
78
  }
69
79
  }
70
- function generateSuffix(index) {
71
- const alphabet = "abcdefghijklmnopqrstuvwxyz";
72
- let suffix = "";
73
- let n = index;
74
- do {
75
- suffix = alphabet[n % 26] + suffix;
76
- n = Math.floor(n / 26) - 1;
77
- } while (n >= 0);
78
- return suffix;
79
- }
80
- function resolveIdCollision(baseId, existing) {
81
- const existingIds = new Set(existing.map((item) => item.id));
82
- if (!existingIds.has(baseId)) {
83
- return { id: baseId, changed: false };
84
- }
85
- let index = 0;
86
- let newId;
87
- do {
88
- const suffix = generateSuffix(index);
89
- newId = `${baseId}${suffix}`;
90
- index++;
91
- } while (existingIds.has(newId));
92
- return { id: newId, changed: true };
93
- }
94
- async function add(existing, newItem, options) {
95
- if (!options.force) {
96
- const duplicateResult = detectDuplicate(newItem, existing);
97
- if (duplicateResult.matches.length > 0) {
98
- return {
99
- added: false,
100
- item: newItem,
101
- idChanged: false,
102
- duplicate: duplicateResult.matches[0]
103
- };
80
+ const MAX_ERROR_LENGTH = 80;
81
+ async function executeAdd(options, context) {
82
+ const { inputs, force, format, pubmedConfig, stdinContent } = options;
83
+ if (context.type === "server") {
84
+ const serverOptions = { force };
85
+ if (format !== void 0) {
86
+ serverOptions.format = format;
87
+ }
88
+ if (stdinContent !== void 0) {
89
+ serverOptions.stdinContent = stdinContent;
104
90
  }
91
+ return context.client.addFromInputs(inputs, serverOptions);
105
92
  }
106
- const originalId = newItem.id;
107
- const { id, changed } = resolveIdCollision(originalId, existing);
108
- const finalItem = {
109
- ...newItem,
110
- id
111
- };
93
+ const addOptions = { force };
94
+ if (format !== void 0) {
95
+ addOptions.format = format;
96
+ }
97
+ if (pubmedConfig !== void 0) {
98
+ addOptions.pubmedConfig = pubmedConfig;
99
+ }
100
+ if (stdinContent !== void 0) {
101
+ addOptions.stdinContent = stdinContent;
102
+ }
103
+ return addReferences(inputs, context.library, addOptions);
104
+ }
105
+ function formatAddedItem(item) {
106
+ const idPart = item.idChanged ? `${item.id} (was: ${item.originalId})` : item.id;
107
+ const title = item.title ?? "(no title)";
108
+ return ` - ${idPart}: "${title}"`;
109
+ }
110
+ function formatFailedItem(item, verbose) {
111
+ let error = item.error;
112
+ if (!verbose && error.length > MAX_ERROR_LENGTH) {
113
+ error = `${error.substring(0, MAX_ERROR_LENGTH - 3)}...`;
114
+ }
115
+ if (!verbose && error.includes("\n")) {
116
+ error = error.split("\n")[0] ?? error;
117
+ }
118
+ return ` - ${item.source}: ${error}`;
119
+ }
120
+ function formatSkippedItem(item) {
121
+ return ` - ${item.source}: matches existing '${item.existingId}'`;
122
+ }
123
+ function formatAddOutput(result, verbose) {
124
+ const lines = [];
125
+ if (result.added.length > 0) {
126
+ lines.push(`Added ${result.added.length} reference(s):`);
127
+ for (const item of result.added) {
128
+ lines.push(formatAddedItem(item));
129
+ }
130
+ } else if (result.failed.length === 0 && result.skipped.length === 0) {
131
+ lines.push("Added 0 reference(s).");
132
+ }
133
+ if (result.failed.length > 0) {
134
+ if (lines.length > 0) lines.push("");
135
+ lines.push(`Failed to add ${result.failed.length} item(s):`);
136
+ for (const item of result.failed) {
137
+ lines.push(formatFailedItem(item, verbose));
138
+ }
139
+ }
140
+ if (result.skipped.length > 0) {
141
+ if (lines.length > 0) lines.push("");
142
+ lines.push(`Skipped ${result.skipped.length} duplicate(s):`);
143
+ for (const item of result.skipped) {
144
+ lines.push(formatSkippedItem(item));
145
+ }
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+ function getExitCode(result) {
150
+ if (result.added.length > 0) {
151
+ return 0;
152
+ }
153
+ if (result.failed.length > 0) {
154
+ return 1;
155
+ }
156
+ return 0;
157
+ }
158
+ async function validateOptions$2(options) {
159
+ if (options.format && !["text", "html", "rtf"].includes(options.format)) {
160
+ throw new Error(`Invalid format '${options.format}'. Must be one of: text, html, rtf`);
161
+ }
162
+ if (options.cslFile) {
163
+ const fs = await import("node:fs");
164
+ if (!fs.existsSync(options.cslFile)) {
165
+ throw new Error(`CSL file '${options.cslFile}' not found`);
166
+ }
167
+ }
168
+ }
169
+ function buildServerCiteOptions(options) {
112
170
  return {
113
- added: true,
114
- item: finalItem,
115
- idChanged: changed,
116
- originalId: changed ? originalId : void 0
171
+ identifiers: options.identifiers,
172
+ ...options.uuid !== void 0 && { byUuid: options.uuid },
173
+ ...options.inText !== void 0 && { inText: options.inText },
174
+ ...options.style !== void 0 && { style: options.style },
175
+ ...options.cslFile !== void 0 && { cslFile: options.cslFile },
176
+ ...options.locale !== void 0 && { locale: options.locale },
177
+ ...options.format !== void 0 && {
178
+ format: options.format === "rtf" ? "text" : options.format
179
+ }
117
180
  };
118
181
  }
119
- function mapEntryType(cslType) {
120
- const typeMap = {
121
- article: "article",
122
- "article-journal": "article",
123
- "article-magazine": "article",
124
- "article-newspaper": "article",
125
- book: "book",
126
- chapter: "inbook",
127
- "paper-conference": "inproceedings",
128
- thesis: "phdthesis",
129
- report: "techreport",
130
- webpage: "misc"
182
+ function buildOperationCiteOptions(options) {
183
+ return {
184
+ identifiers: options.identifiers,
185
+ ...options.uuid !== void 0 && { byUuid: options.uuid },
186
+ ...options.style !== void 0 && { style: options.style },
187
+ ...options.cslFile !== void 0 && { cslFile: options.cslFile },
188
+ ...options.locale !== void 0 && { locale: options.locale },
189
+ ...options.format !== void 0 && { format: options.format },
190
+ ...options.inText !== void 0 && { inText: options.inText }
131
191
  };
132
- return typeMap[cslType] || "misc";
133
192
  }
134
- function formatBibtexAuthor(author) {
135
- if (author.literal) {
136
- return author.literal;
193
+ async function executeCite(options, context) {
194
+ await validateOptions$2(options);
195
+ if (context.type === "server") {
196
+ return context.client.cite(buildServerCiteOptions(options));
137
197
  }
138
- const family = author.family || "";
139
- const given = author.given || "";
140
- return given ? `${family}, ${given}` : family;
198
+ return citeReferences(context.library, buildOperationCiteOptions(options));
141
199
  }
142
- function formatBibtexAuthors(authors) {
143
- return authors.map(formatBibtexAuthor).join(" and ");
200
+ function formatCiteOutput(result) {
201
+ const lines = [];
202
+ for (const r of result.results) {
203
+ if (r.success) {
204
+ lines.push(r.citation);
205
+ }
206
+ }
207
+ return lines.join("\n");
208
+ }
209
+ function formatCiteErrors(result) {
210
+ const lines = [];
211
+ for (const r of result.results) {
212
+ if (!r.success) {
213
+ lines.push(`Error for '${r.identifier}': ${r.error}`);
214
+ }
215
+ }
216
+ return lines.join("\n");
144
217
  }
145
- function formatField(name, value) {
146
- return ` ${name} = {${value}},`;
218
+ function getCiteExitCode(result) {
219
+ const hasSuccess = result.results.some((r) => r.success);
220
+ const hasError = result.results.some((r) => !r.success);
221
+ if (hasSuccess) {
222
+ return 0;
223
+ }
224
+ if (hasError) {
225
+ return 1;
226
+ }
227
+ return 0;
147
228
  }
148
- function addBasicFields(lines, item) {
149
- if (item.title) {
150
- lines.push(formatField("title", item.title));
229
+ const FULLTEXT_EXTENSIONS = {
230
+ pdf: ".pdf",
231
+ markdown: ".md"
232
+ };
233
+ function generateFulltextFilename(item, type) {
234
+ const uuid = item.custom?.uuid;
235
+ if (!uuid) {
236
+ throw new Error("Missing uuid in custom field");
151
237
  }
152
- if (item.author && item.author.length > 0) {
153
- lines.push(formatField("author", formatBibtexAuthors(item.author)));
238
+ const parts = [item.id];
239
+ if (item.PMID && item.PMID.length > 0) {
240
+ parts.push(`PMID${item.PMID}`);
154
241
  }
155
- const year = item.issued?.["date-parts"]?.[0]?.[0];
156
- if (year) {
157
- lines.push(formatField("year", String(year)));
242
+ parts.push(uuid);
243
+ return parts.join("-") + FULLTEXT_EXTENSIONS[type];
244
+ }
245
+ class FulltextIOError extends Error {
246
+ constructor(message, cause) {
247
+ super(message);
248
+ this.cause = cause;
249
+ this.name = "FulltextIOError";
250
+ }
251
+ }
252
+ class FulltextNotAttachedError extends Error {
253
+ constructor(itemId, type) {
254
+ super(`No ${type} attached to reference ${itemId}`);
255
+ this.itemId = itemId;
256
+ this.type = type;
257
+ this.name = "FulltextNotAttachedError";
158
258
  }
159
259
  }
160
- function addPublicationDetails(lines, item, entryType) {
161
- if (item["container-title"]) {
162
- if (entryType === "article") {
163
- lines.push(formatField("journal", item["container-title"]));
164
- } else if (entryType === "inbook" || entryType === "inproceedings") {
165
- lines.push(formatField("booktitle", item["container-title"]));
260
+ class FulltextManager {
261
+ constructor(fulltextDirectory) {
262
+ this.fulltextDirectory = fulltextDirectory;
263
+ }
264
+ /**
265
+ * Ensure the fulltext directory exists
266
+ */
267
+ async ensureDirectory() {
268
+ await mkdir(this.fulltextDirectory, { recursive: true });
269
+ }
270
+ /**
271
+ * Attach a file to a reference
272
+ */
273
+ async attachFile(item, sourcePath, type, options) {
274
+ const { move = false, force = false } = options ?? {};
275
+ const newFilename = generateFulltextFilename(item, type);
276
+ this.validateSourceFile(sourcePath);
277
+ const existingFilename = this.getExistingFilename(item, type);
278
+ if (existingFilename && !force) {
279
+ return {
280
+ filename: newFilename,
281
+ existingFile: existingFilename,
282
+ overwritten: false
283
+ };
284
+ }
285
+ await this.ensureDirectory();
286
+ const deletedOldFile = await this.deleteOldFileIfNeeded(existingFilename, newFilename, force);
287
+ const destPath = join(this.fulltextDirectory, newFilename);
288
+ await this.copyOrMoveFile(sourcePath, destPath, move);
289
+ const result = {
290
+ filename: newFilename,
291
+ overwritten: existingFilename !== void 0
292
+ };
293
+ if (deletedOldFile) {
294
+ result.deletedOldFile = deletedOldFile;
295
+ }
296
+ return result;
297
+ }
298
+ /**
299
+ * Validate that source file exists
300
+ */
301
+ validateSourceFile(sourcePath) {
302
+ if (!existsSync(sourcePath)) {
303
+ throw new FulltextIOError(`Source file not found: ${sourcePath}`);
304
+ }
305
+ }
306
+ /**
307
+ * Delete old file if force mode and filename changed
308
+ * @returns Deleted filename or undefined
309
+ */
310
+ async deleteOldFileIfNeeded(existingFilename, newFilename, force) {
311
+ if (!force || !existingFilename || existingFilename === newFilename) {
312
+ return void 0;
313
+ }
314
+ const oldPath = join(this.fulltextDirectory, existingFilename);
315
+ try {
316
+ await unlink(oldPath);
317
+ } catch {
318
+ }
319
+ return existingFilename;
320
+ }
321
+ /**
322
+ * Copy or move file to destination
323
+ */
324
+ async copyOrMoveFile(sourcePath, destPath, move) {
325
+ try {
326
+ if (move) {
327
+ await rename(sourcePath, destPath);
328
+ } else {
329
+ await copyFile(sourcePath, destPath);
330
+ }
331
+ } catch (error) {
332
+ const operation = move ? "move" : "copy";
333
+ throw new FulltextIOError(
334
+ `Failed to ${operation} file to ${destPath}`,
335
+ error instanceof Error ? error : void 0
336
+ );
337
+ }
338
+ }
339
+ /**
340
+ * Get the full path for an attached file
341
+ * @returns Full path or null if not attached
342
+ */
343
+ getFilePath(item, type) {
344
+ const filename = this.getExistingFilename(item, type);
345
+ if (!filename) {
346
+ return null;
347
+ }
348
+ return join(this.fulltextDirectory, filename);
349
+ }
350
+ /**
351
+ * Detach a file from a reference
352
+ */
353
+ async detachFile(item, type, options) {
354
+ const { delete: deleteFile = false } = options ?? {};
355
+ const filename = this.getExistingFilename(item, type);
356
+ if (!filename) {
357
+ throw new FulltextNotAttachedError(item.id, type);
358
+ }
359
+ if (deleteFile) {
360
+ const filePath = join(this.fulltextDirectory, filename);
361
+ try {
362
+ await unlink(filePath);
363
+ } catch {
364
+ }
365
+ }
366
+ return {
367
+ filename,
368
+ deleted: deleteFile
369
+ };
370
+ }
371
+ /**
372
+ * Get list of attached fulltext types
373
+ */
374
+ getAttachedTypes(item) {
375
+ const types = [];
376
+ const fulltext = item.custom?.fulltext;
377
+ if (fulltext?.pdf) {
378
+ types.push("pdf");
379
+ }
380
+ if (fulltext?.markdown) {
381
+ types.push("markdown");
382
+ }
383
+ return types;
384
+ }
385
+ /**
386
+ * Check if item has attachment
387
+ * @param type Optional type to check; if omitted, checks for any attachment
388
+ */
389
+ hasAttachment(item, type) {
390
+ if (type) {
391
+ return this.getExistingFilename(item, type) !== void 0;
166
392
  }
393
+ return this.getAttachedTypes(item).length > 0;
167
394
  }
168
- if (item.volume) {
169
- lines.push(formatField("volume", item.volume));
395
+ /**
396
+ * Get existing filename from item metadata
397
+ */
398
+ getExistingFilename(item, type) {
399
+ const fulltext = item.custom?.fulltext;
400
+ if (!fulltext) {
401
+ return void 0;
402
+ }
403
+ return fulltext[type];
170
404
  }
171
- if (item.issue) {
172
- lines.push(formatField("number", item.issue));
405
+ }
406
+ function detectType(filePath) {
407
+ const ext = extname(filePath).toLowerCase();
408
+ if (ext === ".pdf") return "pdf";
409
+ if (ext === ".md" || ext === ".markdown") return "markdown";
410
+ return void 0;
411
+ }
412
+ async function findReference(identifier, context, byUuid) {
413
+ if (context.type === "server") {
414
+ return context.client.find(identifier, { byUuid });
415
+ }
416
+ const ref = byUuid ? context.library.findByUuid(identifier) : context.library.findById(identifier);
417
+ return ref?.getItem() ?? null;
418
+ }
419
+ async function updateFulltextMetadata(identifier, fulltext, context, byUuid) {
420
+ const updates = {
421
+ custom: { fulltext }
422
+ };
423
+ if (context.type === "server") {
424
+ await context.client.update(identifier, updates, { byUuid });
425
+ } else {
426
+ await updateReference(context.library, {
427
+ identifier,
428
+ updates,
429
+ byUuid
430
+ });
173
431
  }
174
- if (item.page) {
175
- lines.push(formatField("pages", item.page));
432
+ }
433
+ async function cleanupTempDir(tempDir) {
434
+ if (tempDir) {
435
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {
436
+ });
176
437
  }
177
- if (item.publisher) {
178
- lines.push(formatField("publisher", item.publisher));
438
+ }
439
+ function prepareStdinSource(stdinContent, fileType) {
440
+ try {
441
+ const tempDir = mkdtempSync(join(tmpdir(), "refmgr-"));
442
+ const ext = fileType === "pdf" ? ".pdf" : ".md";
443
+ const sourcePath = join(tempDir, `stdin${ext}`);
444
+ writeFileSync(sourcePath, stdinContent);
445
+ return { sourcePath, tempDir };
446
+ } catch (error) {
447
+ return {
448
+ error: `Failed to write stdin content: ${error instanceof Error ? error.message : String(error)}`
449
+ };
179
450
  }
180
451
  }
181
- function addIdentifierFields(lines, item) {
182
- if (item.DOI) {
183
- lines.push(formatField("doi", item.DOI));
452
+ function buildNewFulltext(currentFulltext, fileType, filename) {
453
+ const newFulltext = {};
454
+ if (currentFulltext.pdf) newFulltext.pdf = currentFulltext.pdf;
455
+ if (currentFulltext.markdown) newFulltext.markdown = currentFulltext.markdown;
456
+ newFulltext[fileType] = filename;
457
+ return newFulltext;
458
+ }
459
+ function resolveFileType(explicitType, filePath, stdinContent) {
460
+ let fileType = explicitType;
461
+ if (!fileType && filePath) {
462
+ fileType = detectType(filePath);
184
463
  }
185
- if (item.URL) {
186
- lines.push(formatField("url", item.URL));
464
+ if (stdinContent && !fileType) {
465
+ return {
466
+ error: "File type must be specified with --pdf or --markdown when reading from stdin."
467
+ };
187
468
  }
188
- if (item.PMID) {
189
- lines.push(formatField("note", `PMID: ${item.PMID}`));
190
- } else if (item.PMCID) {
191
- lines.push(formatField("note", `PMCID: ${item.PMCID}`));
469
+ if (!fileType) {
470
+ return { error: "Cannot detect file type. Use --pdf or --markdown to specify the type." };
192
471
  }
472
+ return fileType;
193
473
  }
194
- function formatSingleBibtexEntry(ref) {
195
- const item = ref.getItem();
196
- const entryType = mapEntryType(item.type);
197
- const lines = [];
198
- lines.push(`@${entryType}{${item.id},`);
199
- addBasicFields(lines, item);
200
- addPublicationDetails(lines, item, entryType);
201
- addIdentifierFields(lines, item);
202
- lines.push("}");
203
- return lines.join("\n");
474
+ async function executeFulltextAttach(options, context) {
475
+ const {
476
+ identifier,
477
+ filePath,
478
+ type: explicitType,
479
+ move,
480
+ force,
481
+ byUuid = false,
482
+ fulltextDirectory,
483
+ stdinContent
484
+ } = options;
485
+ const item = await findReference(identifier, context, byUuid);
486
+ if (!item) {
487
+ return { success: false, error: `Reference '${identifier}' not found` };
488
+ }
489
+ const fileTypeResult = resolveFileType(explicitType, filePath, stdinContent);
490
+ if (typeof fileTypeResult === "object" && "error" in fileTypeResult) {
491
+ return { success: false, error: fileTypeResult.error };
492
+ }
493
+ const fileType = fileTypeResult;
494
+ let sourcePath = filePath;
495
+ let tempDir;
496
+ if (stdinContent) {
497
+ const stdinResult = prepareStdinSource(stdinContent, fileType);
498
+ if ("error" in stdinResult) {
499
+ return { success: false, error: stdinResult.error };
500
+ }
501
+ sourcePath = stdinResult.sourcePath;
502
+ tempDir = stdinResult.tempDir;
503
+ }
504
+ if (!sourcePath) {
505
+ return { success: false, error: "No file path or stdin content provided." };
506
+ }
507
+ const manager = new FulltextManager(fulltextDirectory);
508
+ try {
509
+ const attachOptions = {
510
+ ...move !== void 0 && { move },
511
+ ...force !== void 0 && { force }
512
+ };
513
+ const result = await manager.attachFile(item, sourcePath, fileType, attachOptions);
514
+ if (result.existingFile && !result.overwritten) {
515
+ await cleanupTempDir(tempDir);
516
+ return { success: false, existingFile: result.existingFile, requiresConfirmation: true };
517
+ }
518
+ const newFulltext = buildNewFulltext(item.custom?.fulltext ?? {}, fileType, result.filename);
519
+ await updateFulltextMetadata(identifier, newFulltext, context, byUuid);
520
+ await cleanupTempDir(tempDir);
521
+ return {
522
+ success: true,
523
+ filename: result.filename,
524
+ type: fileType,
525
+ overwritten: result.overwritten
526
+ };
527
+ } catch (error) {
528
+ await cleanupTempDir(tempDir);
529
+ if (error instanceof FulltextIOError) {
530
+ return { success: false, error: error.message };
531
+ }
532
+ throw error;
533
+ }
204
534
  }
205
- function formatBibtex(references) {
206
- if (references.length === 0) {
207
- return "";
535
+ async function getFileContent(manager, item, type, identifier) {
536
+ const filePath = manager.getFilePath(item, type);
537
+ if (!filePath) {
538
+ return { success: false, error: `No ${type} fulltext attached to '${identifier}'` };
539
+ }
540
+ try {
541
+ const content = await readFile(filePath);
542
+ return { success: true, content };
543
+ } catch (error) {
544
+ return {
545
+ success: false,
546
+ error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`
547
+ };
208
548
  }
209
- return references.map(formatSingleBibtexEntry).join("\n\n");
210
549
  }
211
- function formatJson(references) {
212
- const items = references.map((ref) => ref.getItem());
213
- return JSON.stringify(items);
550
+ function getFilePaths(manager, item, types, identifier) {
551
+ const paths = {};
552
+ for (const t of types) {
553
+ const filePath = manager.getFilePath(item, t);
554
+ if (filePath) {
555
+ paths[t] = filePath;
556
+ }
557
+ }
558
+ if (Object.keys(paths).length === 0) {
559
+ return { success: false, error: `No fulltext attached to '${identifier}'` };
560
+ }
561
+ return { success: true, paths };
214
562
  }
215
- function formatAuthor(author) {
216
- const family = author.family || "";
217
- const givenInitial = author.given ? `${author.given.charAt(0)}.` : "";
218
- return givenInitial ? `${family}, ${givenInitial}` : family;
563
+ async function executeFulltextGet(options, context) {
564
+ const { identifier, type, stdout: stdout2, byUuid = false, fulltextDirectory } = options;
565
+ const item = await findReference(identifier, context, byUuid);
566
+ if (!item) {
567
+ return { success: false, error: `Reference '${identifier}' not found` };
568
+ }
569
+ const manager = new FulltextManager(fulltextDirectory);
570
+ if (stdout2 && type) {
571
+ return getFileContent(manager, item, type, identifier);
572
+ }
573
+ const attachedTypes = type ? [type] : manager.getAttachedTypes(item);
574
+ if (attachedTypes.length === 0) {
575
+ return { success: false, error: `No fulltext attached to '${identifier}'` };
576
+ }
577
+ return getFilePaths(manager, item, attachedTypes, identifier);
219
578
  }
220
- function formatAuthors(authors) {
221
- return authors.map(formatAuthor).join("; ");
579
+ async function performDetachOperations(manager, item, typesToDetach, deleteFile) {
580
+ const detached = [];
581
+ const deleted = [];
582
+ for (const t of typesToDetach) {
583
+ const detachOptions = deleteFile ? { delete: deleteFile } : {};
584
+ const result = await manager.detachFile(item, t, detachOptions);
585
+ detached.push(t);
586
+ if (result.deleted) {
587
+ deleted.push(t);
588
+ }
589
+ }
590
+ return { detached, deleted };
222
591
  }
223
- function formatSingleReference(ref) {
224
- const item = ref.getItem();
225
- const lines = [];
226
- const header = item.title ? `[${item.id}] ${item.title}` : `[${item.id}]`;
227
- lines.push(header);
228
- if (item.author && item.author.length > 0) {
229
- lines.push(` Authors: ${formatAuthors(item.author)}`);
592
+ function buildRemainingFulltext(currentFulltext, detached) {
593
+ const newFulltext = {};
594
+ if (currentFulltext.pdf && !detached.includes("pdf")) {
595
+ newFulltext.pdf = currentFulltext.pdf;
230
596
  }
231
- const year = item.issued?.["date-parts"]?.[0]?.[0];
232
- lines.push(` Year: ${year || "(no year)"}`);
233
- lines.push(` Type: ${item.type}`);
234
- if (item.DOI) {
235
- lines.push(` DOI: ${item.DOI}`);
597
+ if (currentFulltext.markdown && !detached.includes("markdown")) {
598
+ newFulltext.markdown = currentFulltext.markdown;
236
599
  }
237
- if (item.PMID) {
238
- lines.push(` PMID: ${item.PMID}`);
600
+ return Object.keys(newFulltext).length > 0 ? newFulltext : void 0;
601
+ }
602
+ function handleDetachError(error) {
603
+ if (error instanceof FulltextNotAttachedError || error instanceof FulltextIOError) {
604
+ return { success: false, error: error.message };
605
+ }
606
+ throw error;
607
+ }
608
+ async function executeFulltextDetach(options, context) {
609
+ const { identifier, type, delete: deleteFile, byUuid = false, fulltextDirectory } = options;
610
+ const item = await findReference(identifier, context, byUuid);
611
+ if (!item) {
612
+ return { success: false, error: `Reference '${identifier}' not found` };
613
+ }
614
+ const manager = new FulltextManager(fulltextDirectory);
615
+ const typesToDetach = type ? [type] : manager.getAttachedTypes(item);
616
+ if (typesToDetach.length === 0) {
617
+ return { success: false, error: `No fulltext attached to '${identifier}'` };
618
+ }
619
+ try {
620
+ const { detached, deleted } = await performDetachOperations(
621
+ manager,
622
+ item,
623
+ typesToDetach,
624
+ deleteFile
625
+ );
626
+ const updatedFulltext = buildRemainingFulltext(item.custom?.fulltext ?? {}, detached);
627
+ await updateFulltextMetadata(identifier, updatedFulltext, context, byUuid);
628
+ const resultData = { success: true, detached };
629
+ if (deleted.length > 0) {
630
+ resultData.deleted = deleted;
631
+ }
632
+ return resultData;
633
+ } catch (error) {
634
+ return handleDetachError(error);
635
+ }
636
+ }
637
+ function formatFulltextAttachOutput(result) {
638
+ if (result.requiresConfirmation) {
639
+ return `File already attached: ${result.existingFile}
640
+ Use --force to overwrite.`;
641
+ }
642
+ if (!result.success) {
643
+ return `Error: ${result.error}`;
644
+ }
645
+ const parts = [];
646
+ if (result.overwritten) {
647
+ parts.push(`Attached ${result.type} (overwritten): ${result.filename}`);
648
+ } else {
649
+ parts.push(`Attached ${result.type}: ${result.filename}`);
650
+ }
651
+ return parts.join("\n");
652
+ }
653
+ function formatFulltextGetOutput(result) {
654
+ if (!result.success) {
655
+ return `Error: ${result.error}`;
239
656
  }
240
- if (item.PMCID) {
241
- lines.push(` PMCID: ${item.PMCID}`);
657
+ if (result.content) {
658
+ return result.content.toString();
242
659
  }
243
- if (item.URL) {
244
- lines.push(` URL: ${item.URL}`);
660
+ const lines = [];
661
+ if (result.paths?.pdf) {
662
+ lines.push(`pdf: ${result.paths.pdf}`);
663
+ }
664
+ if (result.paths?.markdown) {
665
+ lines.push(`markdown: ${result.paths.markdown}`);
245
666
  }
246
- lines.push(` UUID: ${ref.getUuid()}`);
247
667
  return lines.join("\n");
248
668
  }
249
- function formatPretty(references) {
250
- if (references.length === 0) {
251
- return "";
669
+ function formatFulltextDetachOutput(result) {
670
+ if (!result.success) {
671
+ return `Error: ${result.error}`;
252
672
  }
253
- return references.map(formatSingleReference).join("\n\n");
673
+ const lines = [];
674
+ for (const type of result.detached ?? []) {
675
+ if (result.deleted?.includes(type)) {
676
+ lines.push(`Detached and deleted ${type}`);
677
+ } else {
678
+ lines.push(`Detached ${type}`);
679
+ }
680
+ }
681
+ return lines.join("\n");
682
+ }
683
+ function getFulltextExitCode(result) {
684
+ return result.success ? 0 : 1;
685
+ }
686
+ function getListFormat(options) {
687
+ if (options.json) return "json";
688
+ if (options.idsOnly) return "ids-only";
689
+ if (options.uuid) return "uuid";
690
+ if (options.bibtex) return "bibtex";
691
+ return "pretty";
254
692
  }
255
- async function list(items, options) {
693
+ function validateOptions$1(options) {
256
694
  const outputOptions = [options.json, options.idsOnly, options.uuid, options.bibtex].filter(
257
695
  Boolean
258
696
  );
@@ -261,28 +699,81 @@ async function list(items, options) {
261
699
  "Multiple output formats specified. Only one of --json, --ids-only, --uuid, --bibtex can be used."
262
700
  );
263
701
  }
264
- const references = items.map((item) => new Reference(item));
265
- if (options.json) {
266
- process.stdout.write(formatJson(references));
267
- } else if (options.idsOnly) {
268
- for (const item of items) {
269
- process.stdout.write(`${item.id}
270
- `);
271
- }
272
- } else if (options.uuid) {
273
- for (const item of items) {
274
- if (item.custom) {
275
- process.stdout.write(`${item.custom.uuid}
276
- `);
277
- }
702
+ }
703
+ async function executeList(options, context) {
704
+ validateOptions$1(options);
705
+ const format = getListFormat(options);
706
+ if (context.type === "server") {
707
+ return context.client.list({ format });
708
+ }
709
+ return listReferences(context.library, { format });
710
+ }
711
+ function formatListOutput(result) {
712
+ if (result.items.length === 0) {
713
+ return "";
714
+ }
715
+ return result.items.join("\n");
716
+ }
717
+ async function executeRemove(options, context) {
718
+ const { identifier, byUuid = false } = options;
719
+ if (context.type === "server") {
720
+ return context.client.remove(identifier, { byUuid });
721
+ }
722
+ return removeReference(context.library, { identifier, byUuid });
723
+ }
724
+ function formatRemoveOutput(result, identifier) {
725
+ if (!result.removed) {
726
+ return `Reference not found: ${identifier}`;
727
+ }
728
+ const item = result.item;
729
+ if (item) {
730
+ return `Removed: [${item.id}] ${item.title || "(no title)"}`;
731
+ }
732
+ return `Removed reference: ${identifier}`;
733
+ }
734
+ function getFulltextAttachmentTypes(item) {
735
+ const types = [];
736
+ const fulltext = item.custom?.fulltext;
737
+ if (fulltext?.pdf) {
738
+ types.push("pdf");
739
+ }
740
+ if (fulltext?.markdown) {
741
+ types.push("markdown");
742
+ }
743
+ return types;
744
+ }
745
+ function formatFulltextWarning(types) {
746
+ const typeLabels = types.map((t) => t === "pdf" ? "PDF" : "Markdown");
747
+ const fileTypes = typeLabels.join(" and ");
748
+ return `Warning: This reference has fulltext files attached (${fileTypes}). Use --force to also delete the fulltext files.`;
749
+ }
750
+ async function deleteFulltextFiles(item, fulltextDirectory) {
751
+ const fulltext = item.custom?.fulltext;
752
+ if (!fulltext) {
753
+ return;
754
+ }
755
+ const filesToDelete = [];
756
+ if (fulltext.pdf) {
757
+ filesToDelete.push(join(fulltextDirectory, fulltext.pdf));
758
+ }
759
+ if (fulltext.markdown) {
760
+ filesToDelete.push(join(fulltextDirectory, fulltext.markdown));
761
+ }
762
+ for (const filePath of filesToDelete) {
763
+ try {
764
+ await unlink(filePath);
765
+ } catch {
278
766
  }
279
- } else if (options.bibtex) {
280
- process.stdout.write(formatBibtex(references));
281
- } else {
282
- process.stdout.write(formatPretty(references));
283
767
  }
284
768
  }
285
- async function search(items, query, options) {
769
+ function getSearchFormat(options) {
770
+ if (options.json) return "json";
771
+ if (options.idsOnly) return "ids-only";
772
+ if (options.uuid) return "uuid";
773
+ if (options.bibtex) return "bibtex";
774
+ return "pretty";
775
+ }
776
+ function validateOptions(options) {
286
777
  const outputOptions = [options.json, options.idsOnly, options.uuid, options.bibtex].filter(
287
778
  Boolean
288
779
  );
@@ -291,30 +782,20 @@ async function search(items, query, options) {
291
782
  "Multiple output formats specified. Only one of --json, --ids-only, --uuid, --bibtex can be used."
292
783
  );
293
784
  }
294
- const searchQuery = tokenize(query);
295
- const results = search$1(items, searchQuery.tokens);
296
- const sorted = sortResults(results);
297
- const matchedItems = sorted.map((result) => result.reference);
298
- const references = matchedItems.map((item) => new Reference(item));
299
- if (options.json) {
300
- process.stdout.write(formatJson(references));
301
- } else if (options.idsOnly) {
302
- for (const item of matchedItems) {
303
- process.stdout.write(`${item.id}
304
- `);
305
- }
306
- } else if (options.uuid) {
307
- for (const item of matchedItems) {
308
- if (item.custom) {
309
- process.stdout.write(`${item.custom.uuid}
310
- `);
311
- }
312
- }
313
- } else if (options.bibtex) {
314
- process.stdout.write(formatBibtex(references));
315
- } else {
316
- process.stdout.write(formatPretty(references));
785
+ }
786
+ async function executeSearch(options, context) {
787
+ validateOptions(options);
788
+ const format = getSearchFormat(options);
789
+ if (context.type === "server") {
790
+ return context.client.search({ query: options.query, format });
791
+ }
792
+ return searchReferences(context.library, { query: options.query, format });
793
+ }
794
+ function formatSearchOutput(result) {
795
+ if (result.items.length === 0) {
796
+ return "";
317
797
  }
798
+ return result.items.join("\n");
318
799
  }
319
800
  async function serverStart(options) {
320
801
  const existingStatus = await serverStatus(options.portfilePath);
@@ -353,117 +834,31 @@ async function serverStatus(portfilePath) {
353
834
  }
354
835
  return result;
355
836
  }
356
- async function update(items, identifier, updates, options) {
357
- let foundIndex = -1;
358
- if (options.byUuid) {
359
- foundIndex = items.findIndex((item) => item.custom?.uuid === identifier);
360
- } else {
361
- foundIndex = items.findIndex((item) => item.id === identifier);
362
- }
363
- if (foundIndex === -1) {
364
- return {
365
- updated: false,
366
- items
367
- };
837
+ async function executeUpdate(options, context) {
838
+ const { identifier, updates, byUuid = false } = options;
839
+ if (context.type === "server") {
840
+ return context.client.update(identifier, updates, { byUuid });
368
841
  }
369
- const existingItem = items[foundIndex];
370
- if (!existingItem) {
371
- return {
372
- updated: false,
373
- items
374
- };
375
- }
376
- const updatedItem = {
377
- ...existingItem,
378
- ...updates,
379
- // Ensure required fields
380
- id: updates.id ?? existingItem.id,
381
- type: updates.type ?? existingItem.type,
382
- // Preserve UUID and created_at
383
- custom: {
384
- ...existingItem.custom || {},
385
- ...updates.custom || {},
386
- uuid: existingItem.custom?.uuid || "",
387
- created_at: existingItem.custom?.created_at || (/* @__PURE__ */ new Date()).toISOString(),
388
- // Update timestamp
389
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
390
- }
391
- };
392
- const updatedItems = [...items.slice(0, foundIndex), updatedItem, ...items.slice(foundIndex + 1)];
393
- return {
394
- updated: true,
395
- item: updatedItem,
396
- items: updatedItems
397
- };
842
+ return updateReference(context.library, { identifier, updates, byUuid });
398
843
  }
399
- async function readJsonInput(file) {
400
- if (file) {
401
- try {
402
- return readFileSync(file, "utf-8");
403
- } catch (error) {
404
- throw new Error(
405
- `I/O error: Cannot read file ${file}: ${error instanceof Error ? error.message : String(error)}`
406
- );
844
+ function formatUpdateOutput(result, identifier) {
845
+ if (!result.updated) {
846
+ if (result.idCollision) {
847
+ return `Update failed: ID collision for ${identifier}`;
407
848
  }
849
+ return `Reference not found: ${identifier}`;
408
850
  }
409
- const chunks = [];
410
- for await (const chunk of stdin) {
411
- chunks.push(chunk);
412
- }
413
- return Buffer.concat(chunks).toString("utf-8");
414
- }
415
- function parseJsonInput(input) {
416
- if (!input || input.trim() === "") {
417
- throw new Error("Parse error: Empty input");
418
- }
419
- try {
420
- return JSON.parse(input);
421
- } catch (error) {
422
- throw new Error(
423
- `Parse error: Invalid JSON: ${error instanceof Error ? error.message : String(error)}`
424
- );
425
- }
426
- }
427
- async function loadConfigWithOverrides(options) {
428
- const config = await loadConfig();
429
- const overrides = {};
430
- if (options.library) {
431
- overrides.library = options.library;
432
- }
433
- if (options.quiet) {
434
- overrides.logLevel = "silent";
435
- } else if (options.verbose) {
436
- overrides.logLevel = "debug";
437
- } else if (options.logLevel) {
438
- overrides.logLevel = options.logLevel;
851
+ const item = result.item;
852
+ const parts = [];
853
+ if (item) {
854
+ parts.push(`Updated: [${item.id}] ${item.title || "(no title)"}`);
855
+ } else {
856
+ parts.push(`Updated reference: ${identifier}`);
439
857
  }
440
- if (options.backup !== void 0 || options.backupDir) {
441
- overrides.backup = {
442
- ...config.backup,
443
- ...options.backup !== void 0 && { enabled: options.backup },
444
- ...options.backupDir && { directory: options.backupDir }
445
- };
858
+ if (result.idChanged && result.newId) {
859
+ parts.push(`ID changed to: ${result.newId}`);
446
860
  }
447
- return { ...config, ...overrides };
448
- }
449
- function isTTY() {
450
- return Boolean(stdin.isTTY && stdout.isTTY);
451
- }
452
- async function readConfirmation(prompt) {
453
- if (!isTTY()) {
454
- return true;
455
- }
456
- stdout.write(`${prompt} (y/N): `);
457
- const chunks = [];
458
- for await (const chunk of stdin) {
459
- chunks.push(chunk);
460
- const input2 = Buffer.concat(chunks).toString("utf-8");
461
- if (input2.includes("\n")) {
462
- break;
463
- }
464
- }
465
- const input = Buffer.concat(chunks).toString("utf-8").trim().toLowerCase();
466
- return input === "y" || input === "yes";
861
+ return parts.join("\n");
467
862
  }
468
863
  class ServerClient {
469
864
  constructor(baseUrl) {
@@ -482,12 +877,15 @@ class ServerClient {
482
877
  return await response.json();
483
878
  }
484
879
  /**
485
- * Find reference by UUID.
486
- * @param uuid - Reference UUID
880
+ * Find reference by identifier.
881
+ * @param identifier - Citation ID or UUID
882
+ * @param options - Options object
883
+ * @param options.byUuid - If true, treat identifier as UUID; otherwise as citation ID
487
884
  * @returns CSL item or null if not found
488
885
  */
489
- async findByUuid(uuid) {
490
- const url = `${this.baseUrl}/api/references/${uuid}`;
886
+ async find(identifier, options) {
887
+ const path2 = options?.byUuid ? `/api/references/uuid/${identifier}` : `/api/references/id/${identifier}`;
888
+ const url = `${this.baseUrl}${path2}`;
491
889
  const response = await fetch(url);
492
890
  if (response.status === 404) {
493
891
  return null;
@@ -516,34 +914,111 @@ class ServerClient {
516
914
  }
517
915
  /**
518
916
  * Update existing reference.
519
- * @param uuid - Reference UUID
520
- * @param item - Updated CSL item
521
- * @returns Updated CSL item
917
+ * @param identifier - Citation ID or UUID
918
+ * @param updates - Partial CSL item with fields to update
919
+ * @param options - Options object
920
+ * @param options.byUuid - If true, treat identifier as UUID; otherwise as citation ID
921
+ * @returns Update operation result
522
922
  */
523
- async update(uuid, item) {
524
- const url = `${this.baseUrl}/api/references/${uuid}`;
923
+ async update(identifier, updates, options) {
924
+ const path2 = options?.byUuid ? `/api/references/uuid/${identifier}` : `/api/references/id/${identifier}`;
925
+ const url = `${this.baseUrl}${path2}`;
525
926
  const response = await fetch(url, {
526
927
  method: "PUT",
527
928
  headers: { "Content-Type": "application/json" },
528
- body: JSON.stringify(item)
929
+ body: JSON.stringify(updates)
529
930
  });
530
- if (!response.ok) {
931
+ if (!response.ok && response.status !== 404 && response.status !== 409) {
531
932
  throw new Error(await response.text());
532
933
  }
533
934
  return await response.json();
534
935
  }
535
936
  /**
536
- * Remove reference by UUID.
537
- * @param uuid - Reference UUID
937
+ * Remove reference by identifier.
938
+ * @param identifier - Citation ID or UUID
939
+ * @param options - Options object
940
+ * @param options.byUuid - If true, treat identifier as UUID; otherwise as citation ID
941
+ * @returns Remove operation result
538
942
  */
539
- async remove(uuid) {
540
- const url = `${this.baseUrl}/api/references/${uuid}`;
943
+ async remove(identifier, options) {
944
+ const path2 = options?.byUuid ? `/api/references/uuid/${identifier}` : `/api/references/id/${identifier}`;
945
+ const url = `${this.baseUrl}${path2}`;
541
946
  const response = await fetch(url, {
542
947
  method: "DELETE"
543
948
  });
949
+ if (!response.ok && response.status !== 404) {
950
+ throw new Error(await response.text());
951
+ }
952
+ return await response.json();
953
+ }
954
+ /**
955
+ * Add references from various input formats.
956
+ * @param inputs - Array of inputs (file paths, PMIDs, DOIs)
957
+ * @param options - Options for add operation
958
+ * @returns Result containing added, failed, and skipped items
959
+ */
960
+ async addFromInputs(inputs, options) {
961
+ const url = `${this.baseUrl}/api/add`;
962
+ const response = await fetch(url, {
963
+ method: "POST",
964
+ headers: { "Content-Type": "application/json" },
965
+ body: JSON.stringify({ inputs, options })
966
+ });
967
+ if (!response.ok) {
968
+ throw new Error(await response.text());
969
+ }
970
+ return await response.json();
971
+ }
972
+ /**
973
+ * Generate citations for references.
974
+ * @param options - Cite options including identifiers and formatting
975
+ * @returns Cite result with per-identifier results
976
+ */
977
+ async cite(options) {
978
+ const url = `${this.baseUrl}/api/cite`;
979
+ const response = await fetch(url, {
980
+ method: "POST",
981
+ headers: { "Content-Type": "application/json" },
982
+ body: JSON.stringify(options)
983
+ });
984
+ if (!response.ok) {
985
+ throw new Error(await response.text());
986
+ }
987
+ return await response.json();
988
+ }
989
+ /**
990
+ * List all references with optional formatting.
991
+ * @param options - List options including format
992
+ * @returns List result with formatted items
993
+ */
994
+ async list(options) {
995
+ const url = `${this.baseUrl}/api/list`;
996
+ const response = await fetch(url, {
997
+ method: "POST",
998
+ headers: { "Content-Type": "application/json" },
999
+ body: JSON.stringify(options ?? {})
1000
+ });
544
1001
  if (!response.ok) {
545
1002
  throw new Error(await response.text());
546
1003
  }
1004
+ return await response.json();
1005
+ }
1006
+ /**
1007
+ * Search references with query.
1008
+ * @param options - Search options including query and format
1009
+ * @returns Search result with formatted items
1010
+ */
1011
+ async search(options) {
1012
+ const url = `${this.baseUrl}/api/search`;
1013
+ const response = await fetch(url, {
1014
+ method: "POST",
1015
+ headers: { "Content-Type": "application/json" },
1016
+ body: JSON.stringify(options)
1017
+ });
1018
+ if (!response.ok) {
1019
+ throw new Error(await response.text());
1020
+ }
1021
+ return await response.json();
547
1022
  }
548
1023
  }
549
1024
  async function getServerConnection(libraryPath, config) {
@@ -557,48 +1032,139 @@ async function getServerConnection(libraryPath, config) {
557
1032
  }
558
1033
  return null;
559
1034
  }
560
- if (!isProcessRunning(portfileData.pid)) {
561
- await removePortfile(portfilePath);
562
- return null;
1035
+ if (!isProcessRunning(portfileData.pid)) {
1036
+ await removePortfile(portfilePath);
1037
+ return null;
1038
+ }
1039
+ if (!portfileData.library || portfileData.library !== libraryPath) {
1040
+ return null;
1041
+ }
1042
+ return {
1043
+ baseUrl: `http://localhost:${portfileData.port}`,
1044
+ pid: portfileData.pid
1045
+ };
1046
+ }
1047
+ async function startServerDaemon(libraryPath, _config) {
1048
+ const binaryPath = process.argv[1] || process.execPath;
1049
+ const child = spawn(
1050
+ process.execPath,
1051
+ [binaryPath, "server", "start", "--daemon", "--library", libraryPath],
1052
+ {
1053
+ detached: true,
1054
+ stdio: "ignore"
1055
+ }
1056
+ );
1057
+ child.unref();
1058
+ }
1059
+ async function waitForPortfile(timeoutMs) {
1060
+ const portfilePath = getPortfilePath();
1061
+ const startTime = Date.now();
1062
+ const checkInterval = 50;
1063
+ while (Date.now() - startTime < timeoutMs) {
1064
+ if (await portfileExists(portfilePath)) {
1065
+ return;
1066
+ }
1067
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
1068
+ }
1069
+ throw new Error(`Server failed to start: portfile not created within ${timeoutMs}ms`);
1070
+ }
1071
+ async function createExecutionContext(config, loadLibrary) {
1072
+ const server = await getServerConnection(config.library, config);
1073
+ if (server) {
1074
+ return {
1075
+ type: "server",
1076
+ client: new ServerClient(server.baseUrl)
1077
+ };
1078
+ }
1079
+ const library = await loadLibrary(config.library);
1080
+ return {
1081
+ type: "local",
1082
+ library
1083
+ };
1084
+ }
1085
+ async function readJsonInput(file) {
1086
+ if (file) {
1087
+ try {
1088
+ return readFileSync(file, "utf-8");
1089
+ } catch (error) {
1090
+ throw new Error(
1091
+ `I/O error: Cannot read file ${file}: ${error instanceof Error ? error.message : String(error)}`
1092
+ );
1093
+ }
1094
+ }
1095
+ const chunks = [];
1096
+ for await (const chunk of stdin) {
1097
+ chunks.push(chunk);
1098
+ }
1099
+ return Buffer.concat(chunks).toString("utf-8");
1100
+ }
1101
+ function parseJsonInput(input) {
1102
+ if (!input || input.trim() === "") {
1103
+ throw new Error("Parse error: Empty input");
1104
+ }
1105
+ try {
1106
+ return JSON.parse(input);
1107
+ } catch (error) {
1108
+ throw new Error(
1109
+ `Parse error: Invalid JSON: ${error instanceof Error ? error.message : String(error)}`
1110
+ );
1111
+ }
1112
+ }
1113
+ async function loadConfigWithOverrides(options) {
1114
+ const config = await loadConfig();
1115
+ const overrides = {};
1116
+ if (options.library) {
1117
+ overrides.library = options.library;
1118
+ }
1119
+ if (options.quiet) {
1120
+ overrides.logLevel = "silent";
1121
+ } else if (options.verbose) {
1122
+ overrides.logLevel = "debug";
1123
+ } else if (options.logLevel) {
1124
+ overrides.logLevel = options.logLevel;
563
1125
  }
564
- if (!portfileData.library || portfileData.library !== libraryPath) {
565
- return null;
1126
+ if (options.backup !== void 0 || options.backupDir) {
1127
+ overrides.backup = {
1128
+ ...config.backup,
1129
+ ...options.backup !== void 0 && { enabled: options.backup },
1130
+ ...options.backupDir && { directory: options.backupDir }
1131
+ };
566
1132
  }
567
- return {
568
- baseUrl: `http://localhost:${portfileData.port}`,
569
- pid: portfileData.pid
570
- };
1133
+ return { ...config, ...overrides };
571
1134
  }
572
- async function startServerDaemon(libraryPath, _config) {
573
- const binaryPath = process.argv[1] || process.execPath;
574
- const child = spawn(
575
- process.execPath,
576
- [binaryPath, "server", "start", "--daemon", "--library", libraryPath],
577
- {
578
- detached: true,
579
- stdio: "ignore"
580
- }
581
- );
582
- child.unref();
1135
+ function isTTY() {
1136
+ return Boolean(stdin.isTTY && stdout.isTTY);
583
1137
  }
584
- async function waitForPortfile(timeoutMs) {
585
- const portfilePath = getPortfilePath();
586
- const startTime = Date.now();
587
- const checkInterval = 50;
588
- while (Date.now() - startTime < timeoutMs) {
589
- if (await portfileExists(portfilePath)) {
590
- return;
1138
+ async function readConfirmation(prompt) {
1139
+ if (!isTTY()) {
1140
+ return true;
1141
+ }
1142
+ stdout.write(`${prompt} (y/N): `);
1143
+ const chunks = [];
1144
+ for await (const chunk of stdin) {
1145
+ chunks.push(chunk);
1146
+ const input2 = Buffer.concat(chunks).toString("utf-8");
1147
+ if (input2.includes("\n")) {
1148
+ break;
591
1149
  }
592
- await new Promise((resolve) => setTimeout(resolve, checkInterval));
593
1150
  }
594
- throw new Error(`Server failed to start: portfile not created within ${timeoutMs}ms`);
1151
+ const input = Buffer.concat(chunks).toString("utf-8").trim().toLowerCase();
1152
+ return input === "y" || input === "yes";
1153
+ }
1154
+ async function readStdinContent() {
1155
+ const chunks = [];
1156
+ for await (const chunk of stdin) {
1157
+ chunks.push(chunk);
1158
+ }
1159
+ return Buffer.concat(chunks).toString("utf-8").trim();
1160
+ }
1161
+ async function readStdinBuffer() {
1162
+ const chunks = [];
1163
+ for await (const chunk of stdin) {
1164
+ chunks.push(chunk);
1165
+ }
1166
+ return Buffer.concat(chunks);
595
1167
  }
596
- const version = "0.1.0";
597
- const description = "A local reference management tool using CSL-JSON as the single source of truth";
598
- const packageJson = {
599
- version,
600
- description
601
- };
602
1168
  function createProgram() {
603
1169
  const program = new Command();
604
1170
  program.name("reference-manager").version(packageJson.version).description(packageJson.description);
@@ -608,175 +1174,129 @@ function createProgram() {
608
1174
  registerAddCommand(program);
609
1175
  registerRemoveCommand(program);
610
1176
  registerUpdateCommand(program);
1177
+ registerCiteCommand(program);
611
1178
  registerServerCommand(program);
1179
+ registerFulltextCommand(program);
612
1180
  return program;
613
1181
  }
614
- function registerListCommand(program) {
615
- program.command("list").description("List all references in the library").option("--json", "Output in JSON format").option("--ids-only", "Output only citation keys").option("--uuid", "Output only UUIDs").option("--bibtex", "Output in BibTeX format").action(async (options) => {
616
- try {
617
- const globalOpts = program.opts();
618
- const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
619
- const server = await getServerConnection(config.library, config);
620
- let items;
621
- if (server) {
622
- const client = new ServerClient(server.baseUrl);
623
- items = await client.getAll();
624
- } else {
625
- const library = await Library.load(config.library);
626
- items = library.getAll().map((ref) => ref.getItem());
627
- }
628
- await list(items, {
629
- json: options.json,
630
- idsOnly: options.idsOnly,
631
- uuid: options.uuid,
632
- bibtex: options.bibtex
633
- });
634
- process.exit(0);
635
- } catch (error) {
636
- process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1182
+ async function handleListAction(options, program) {
1183
+ try {
1184
+ const globalOpts = program.opts();
1185
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1186
+ const context = await createExecutionContext(config, Library.load);
1187
+ const result = await executeList(options, context);
1188
+ const output = formatListOutput(result);
1189
+ if (output) {
1190
+ process.stdout.write(`${output}
637
1191
  `);
638
- process.exit(4);
639
1192
  }
640
- });
641
- }
642
- function registerSearchCommand(program) {
643
- program.command("search").description("Search references").argument("<query>", "Search query").option("--json", "Output in JSON format").option("--ids-only", "Output only citation keys").option("--uuid", "Output only UUIDs").option("--bibtex", "Output in BibTeX format").action(async (query, options) => {
644
- try {
645
- const globalOpts = program.opts();
646
- const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
647
- const server = await getServerConnection(config.library, config);
648
- let items;
649
- if (server) {
650
- const client = new ServerClient(server.baseUrl);
651
- items = await client.getAll();
652
- } else {
653
- const library = await Library.load(config.library);
654
- items = library.getAll().map((ref) => ref.getItem());
655
- }
656
- await search(items, query, {
657
- json: options.json,
658
- idsOnly: options.idsOnly,
659
- uuid: options.uuid,
660
- bibtex: options.bibtex
661
- });
662
- process.exit(0);
663
- } catch (error) {
664
- process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1193
+ process.exit(0);
1194
+ } catch (error) {
1195
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
665
1196
  `);
666
- process.exit(4);
667
- }
1197
+ process.exit(4);
1198
+ }
1199
+ }
1200
+ function registerListCommand(program) {
1201
+ program.command("list").description("List all references in the library").option("--json", "Output in JSON format").option("--ids-only", "Output only citation keys").option("--uuid", "Output only UUIDs").option("--bibtex", "Output in BibTeX format").action(async (options) => {
1202
+ await handleListAction(options, program);
668
1203
  });
669
1204
  }
670
- async function addViaServer(items, server, force) {
671
- const client = new ServerClient(server.baseUrl);
672
- for (const item of items) {
673
- try {
674
- await client.add(item);
675
- process.stderr.write(`Added reference: [${item.id}]
676
- `);
677
- } catch (error) {
678
- if (!force && error instanceof Error && error.message.includes("Duplicate")) {
679
- process.stderr.write(`Error: ${error.message}
1205
+ async function handleSearchAction(query, options, program) {
1206
+ try {
1207
+ const globalOpts = program.opts();
1208
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1209
+ const context = await createExecutionContext(config, Library.load);
1210
+ const result = await executeSearch({ ...options, query }, context);
1211
+ const output = formatSearchOutput(result);
1212
+ if (output) {
1213
+ process.stdout.write(`${output}
680
1214
  `);
681
- process.exit(1);
682
- }
683
- throw error;
684
1215
  }
685
- }
686
- }
687
- async function addViaLibrary(items, libraryPath, force) {
688
- const library = await Library.load(libraryPath);
689
- const existingItems = library.getAll().map((ref) => ref.getItem());
690
- for (const item of items) {
691
- const result = await add(existingItems, item, { force });
692
- if (result.added) {
693
- library.add(result.item);
694
- process.stderr.write(`Added reference: [${result.item.id}]
1216
+ process.exit(0);
1217
+ } catch (error) {
1218
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
695
1219
  `);
696
- existingItems.push(result.item);
697
- } else if (result.duplicate) {
698
- throw new Error(
699
- `Duplicate detected: ${result.duplicate.type} match with existing reference [${result.duplicate.existing.id}]`
700
- );
701
- }
1220
+ process.exit(4);
702
1221
  }
703
- await library.save();
704
1222
  }
705
- function handleAddError(error) {
706
- const message = error instanceof Error ? error.message : String(error);
707
- if (message.includes("Parse error")) {
708
- process.stderr.write(`Error: ${message}
709
- `);
710
- process.exit(3);
711
- }
712
- if (message.includes("Duplicate")) {
713
- process.stderr.write(`Error: ${message}
714
- `);
715
- process.exit(1);
716
- }
717
- process.stderr.write(`Error: ${message}
718
- `);
719
- process.exit(4);
1223
+ function registerSearchCommand(program) {
1224
+ program.command("search").description("Search references").argument("<query>", "Search query").option("--json", "Output in JSON format").option("--ids-only", "Output only citation keys").option("--uuid", "Output only UUIDs").option("--bibtex", "Output in BibTeX format").action(async (query, options) => {
1225
+ await handleSearchAction(query, options, program);
1226
+ });
720
1227
  }
721
- async function handleAddAction(file, options, program) {
1228
+ async function handleAddAction(inputs, options, program) {
722
1229
  try {
723
1230
  const globalOpts = program.opts();
724
1231
  const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
725
- const inputStr = await readJsonInput(file);
726
- const input = parseJsonInput(inputStr);
727
- const items = Array.isArray(input) ? input : [input];
728
- const server = await getServerConnection(config.library, config);
729
- if (server) {
730
- await addViaServer(items, server, options.force ?? false);
731
- } else {
732
- await addViaLibrary(items, config.library, options.force ?? false);
1232
+ let stdinContent;
1233
+ if (inputs.length === 0) {
1234
+ stdinContent = await readStdinContent();
733
1235
  }
734
- process.exit(0);
1236
+ const context = await createExecutionContext(config, Library.load);
1237
+ const addOptions = {
1238
+ inputs,
1239
+ force: options.force ?? false
1240
+ };
1241
+ if (options.format !== void 0) {
1242
+ addOptions.format = options.format;
1243
+ }
1244
+ if (options.verbose !== void 0) {
1245
+ addOptions.verbose = options.verbose;
1246
+ }
1247
+ if (stdinContent?.trim()) {
1248
+ addOptions.stdinContent = stdinContent;
1249
+ }
1250
+ const pubmedConfig = {};
1251
+ if (config.pubmed.email !== void 0) {
1252
+ pubmedConfig.email = config.pubmed.email;
1253
+ }
1254
+ if (config.pubmed.apiKey !== void 0) {
1255
+ pubmedConfig.apiKey = config.pubmed.apiKey;
1256
+ }
1257
+ if (Object.keys(pubmedConfig).length > 0) {
1258
+ addOptions.pubmedConfig = pubmedConfig;
1259
+ }
1260
+ const result = await executeAdd(addOptions, context);
1261
+ const output = formatAddOutput(result, options.verbose ?? false);
1262
+ process.stderr.write(`${output}
1263
+ `);
1264
+ process.exit(getExitCode(result));
735
1265
  } catch (error) {
736
- handleAddError(error);
1266
+ const message = error instanceof Error ? error.message : String(error);
1267
+ process.stderr.write(`Error: ${message}
1268
+ `);
1269
+ process.exit(1);
737
1270
  }
738
1271
  }
739
1272
  function registerAddCommand(program) {
740
- program.command("add").description("Add new reference(s) to the library").argument("[file]", "JSON file to add (or use stdin)").option("-f, --force", "Skip duplicate detection").action(async (file, options) => {
741
- await handleAddAction(file, options, program);
1273
+ program.command("add").description("Add new reference(s) to the library").argument("[input...]", "File paths or identifiers (PMID/DOI), or use stdin").option("-f, --force", "Skip duplicate detection").option("--format <format>", "Explicit input format: json|bibtex|ris|pmid|doi|auto", "auto").option("--verbose", "Show detailed error information").action(async (inputs, options) => {
1274
+ await handleAddAction(inputs, options, program);
742
1275
  });
743
1276
  }
744
- async function findReferenceToRemove(identifier, byUuid, server, libraryPath) {
745
- if (server) {
746
- const client = new ServerClient(server.baseUrl);
747
- const items = await client.getAll();
748
- return byUuid ? items.find((item) => item.custom?.uuid === identifier) : items.find((item) => item.id === identifier);
1277
+ async function findReferenceToRemove(identifier, byUuid, context) {
1278
+ if (context.type === "server") {
1279
+ const item = await context.client.find(identifier, { byUuid });
1280
+ return item ?? void 0;
749
1281
  }
750
- const library = await Library.load(libraryPath);
751
- const ref = byUuid ? library.findByUuid(identifier) : library.findById(identifier);
1282
+ const ref = byUuid ? context.library.findByUuid(identifier) : context.library.findById(identifier);
752
1283
  return ref?.getItem();
753
1284
  }
754
- async function confirmRemoval(refToRemove, force) {
1285
+ async function confirmRemoval(refToRemove, force, fulltextWarning) {
755
1286
  if (force || !isTTY()) {
756
1287
  return true;
757
1288
  }
758
1289
  const authors = Array.isArray(refToRemove.author) ? refToRemove.author.map((a) => `${a.family || ""}, ${a.given?.[0] || ""}.`).join("; ") : "(no authors)";
759
- const confirmMsg = `Remove reference [${refToRemove.id}]?
1290
+ let confirmMsg = `Remove reference [${refToRemove.id}]?
760
1291
  Title: ${refToRemove.title || "(no title)"}
761
- Authors: ${authors}
762
- Continue?`;
763
- return await readConfirmation(confirmMsg);
764
- }
765
- async function removeReference(identifier, refToRemove, byUuid, server, libraryPath) {
766
- if (server) {
767
- const client = new ServerClient(server.baseUrl);
768
- if (!refToRemove.custom?.uuid) {
769
- throw new Error("Reference missing UUID");
770
- }
771
- await client.remove(refToRemove.custom.uuid);
772
- return;
773
- }
774
- const library = await Library.load(libraryPath);
775
- const removed = byUuid ? library.removeByUuid(identifier) : library.removeById(identifier);
776
- if (!removed) {
777
- throw new Error("Reference not found");
1292
+ Authors: ${authors}`;
1293
+ if (fulltextWarning) {
1294
+ confirmMsg += `
1295
+
1296
+ ${fulltextWarning}`;
778
1297
  }
779
- await library.save();
1298
+ confirmMsg += "\nContinue?";
1299
+ return await readConfirmation(confirmMsg);
780
1300
  }
781
1301
  function handleRemoveError(error) {
782
1302
  const message = error instanceof Error ? error.message : String(error);
@@ -789,30 +1309,61 @@ function handleRemoveError(error) {
789
1309
  `);
790
1310
  process.exit(4);
791
1311
  }
1312
+ function formatTypeLabels(types) {
1313
+ return types.map((t) => t === "pdf" ? "PDF" : "Markdown").join(" and ");
1314
+ }
1315
+ async function handleFulltextDeletion(item, fulltextDirectory, types, output) {
1316
+ await deleteFulltextFiles(item, fulltextDirectory);
1317
+ const typeLabels = formatTypeLabels(types);
1318
+ process.stderr.write(`${output}
1319
+ `);
1320
+ process.stderr.write(`Deleted fulltext files: ${typeLabels}
1321
+ `);
1322
+ }
792
1323
  async function handleRemoveAction(identifier, options, program) {
793
1324
  try {
794
1325
  const globalOpts = program.opts();
795
1326
  const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
796
- const server = await getServerConnection(config.library, config);
797
- const refToRemove = await findReferenceToRemove(
798
- identifier,
799
- options.uuid ?? false,
800
- server,
801
- config.library
802
- );
1327
+ const context = await createExecutionContext(config, Library.load);
1328
+ const refToRemove = await findReferenceToRemove(identifier, options.uuid ?? false, context);
803
1329
  if (!refToRemove) {
804
1330
  process.stderr.write(`Error: Reference not found: ${identifier}
805
1331
  `);
806
1332
  process.exit(1);
807
1333
  }
808
- const confirmed = await confirmRemoval(refToRemove, options.force ?? false);
1334
+ const fulltextTypes = getFulltextAttachmentTypes(refToRemove);
1335
+ const hasFulltext = fulltextTypes.length > 0;
1336
+ if (hasFulltext && !isTTY() && !options.force) {
1337
+ const warning = formatFulltextWarning(fulltextTypes);
1338
+ process.stderr.write(`Error: ${warning}
1339
+ `);
1340
+ process.exit(1);
1341
+ }
1342
+ const fulltextWarning = hasFulltext && !options.force ? formatFulltextWarning(fulltextTypes) : void 0;
1343
+ const confirmed = await confirmRemoval(refToRemove, options.force ?? false, fulltextWarning);
809
1344
  if (!confirmed) {
810
1345
  process.stderr.write("Cancelled.\n");
811
1346
  process.exit(2);
812
1347
  }
813
- await removeReference(identifier, refToRemove, options.uuid ?? false, server, config.library);
814
- process.stderr.write(`Removed reference: [${refToRemove.id}]
1348
+ const removeOptions = {
1349
+ identifier
1350
+ };
1351
+ if (options.uuid !== void 0) {
1352
+ removeOptions.byUuid = options.uuid;
1353
+ }
1354
+ const result = await executeRemove(removeOptions, context);
1355
+ const output = formatRemoveOutput(result, identifier);
1356
+ if (!result.removed) {
1357
+ process.stderr.write(`${output}
815
1358
  `);
1359
+ process.exit(1);
1360
+ }
1361
+ if (hasFulltext && options.force) {
1362
+ await handleFulltextDeletion(refToRemove, config.fulltext.directory, fulltextTypes, output);
1363
+ } else {
1364
+ process.stderr.write(`${output}
1365
+ `);
1366
+ }
816
1367
  process.exit(0);
817
1368
  } catch (error) {
818
1369
  handleRemoveError(error);
@@ -823,31 +1374,6 @@ function registerRemoveCommand(program) {
823
1374
  await handleRemoveAction(identifier, options, program);
824
1375
  });
825
1376
  }
826
- async function updateViaServer(identifier, updates, byUuid, server) {
827
- const client = new ServerClient(server.baseUrl);
828
- const items = await client.getAll();
829
- const refToUpdate = byUuid ? items.find((item) => item.custom?.uuid === identifier) : items.find((item) => item.id === identifier);
830
- if (!refToUpdate || !refToUpdate.custom?.uuid) {
831
- process.stderr.write(`Error: Reference not found: ${identifier}
832
- `);
833
- process.exit(1);
834
- }
835
- const updatedItem = { ...refToUpdate, ...updates };
836
- await client.update(refToUpdate.custom.uuid, updatedItem);
837
- }
838
- async function updateViaLibrary(identifier, updates, byUuid, libraryPath) {
839
- const library = await Library.load(libraryPath);
840
- const items = library.getAll().map((ref) => ref.getItem());
841
- const result = await update(items, identifier, updates, { byUuid });
842
- if (!result.updated || !result.item) {
843
- throw new Error("Reference not found");
844
- }
845
- if (result.item.custom?.uuid) {
846
- library.removeByUuid(result.item.custom.uuid);
847
- library.add(result.item);
848
- }
849
- await library.save();
850
- }
851
1377
  function handleUpdateError(error) {
852
1378
  const message = error instanceof Error ? error.message : String(error);
853
1379
  if (message.includes("Parse error")) {
@@ -872,20 +1398,25 @@ async function handleUpdateAction(identifier, file, options, program) {
872
1398
  const updates = parseJsonInput(inputStr);
873
1399
  const updatesSchema = z.record(z.string(), z.unknown());
874
1400
  const validatedUpdates = updatesSchema.parse(updates);
875
- const server = await getServerConnection(config.library, config);
876
- if (server) {
877
- await updateViaServer(identifier, validatedUpdates, options.uuid ?? false, server);
878
- } else {
879
- await updateViaLibrary(
880
- identifier,
881
- validatedUpdates,
882
- options.uuid ?? false,
883
- config.library
884
- );
1401
+ const context = await createExecutionContext(config, Library.load);
1402
+ const updateOptions = {
1403
+ identifier,
1404
+ updates: validatedUpdates
1405
+ };
1406
+ if (options.uuid !== void 0) {
1407
+ updateOptions.byUuid = options.uuid;
885
1408
  }
886
- process.stderr.write(`Updated reference: [${identifier}]
1409
+ const result = await executeUpdate(updateOptions, context);
1410
+ const output = formatUpdateOutput(result, identifier);
1411
+ if (result.updated) {
1412
+ process.stderr.write(`${output}
887
1413
  `);
888
- process.exit(0);
1414
+ process.exit(0);
1415
+ } else {
1416
+ process.stderr.write(`${output}
1417
+ `);
1418
+ process.exit(1);
1419
+ }
889
1420
  } catch (error) {
890
1421
  handleUpdateError(error);
891
1422
  }
@@ -895,6 +1426,34 @@ function registerUpdateCommand(program) {
895
1426
  await handleUpdateAction(identifier, file, options, program);
896
1427
  });
897
1428
  }
1429
+ async function handleCiteAction(identifiers, options, program) {
1430
+ try {
1431
+ const globalOpts = program.opts();
1432
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1433
+ const context = await createExecutionContext(config, Library.load);
1434
+ const result = await executeCite({ ...options, identifiers }, context);
1435
+ const output = formatCiteOutput(result);
1436
+ if (output) {
1437
+ process.stdout.write(`${output}
1438
+ `);
1439
+ }
1440
+ const errors = formatCiteErrors(result);
1441
+ if (errors) {
1442
+ process.stderr.write(`${errors}
1443
+ `);
1444
+ }
1445
+ process.exit(getCiteExitCode(result));
1446
+ } catch (error) {
1447
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1448
+ `);
1449
+ process.exit(4);
1450
+ }
1451
+ }
1452
+ function registerCiteCommand(program) {
1453
+ program.command("cite").description("Generate formatted citations for references").argument("<id-or-uuid...>", "Citation keys or UUIDs to cite").option("--uuid", "Treat arguments as UUIDs instead of IDs").option("--style <style>", "CSL style name").option("--csl-file <path>", "Path to custom CSL file").option("--locale <locale>", "Locale code (e.g., en-US, ja-JP)").option("--format <format>", "Output format: text|html|rtf").option("--in-text", "Generate in-text citations instead of bibliography entries").action(async (identifiers, options) => {
1454
+ await handleCiteAction(identifiers, options, program);
1455
+ });
1456
+ }
898
1457
  function registerServerCommand(program) {
899
1458
  const serverCmd = program.command("server").description("Manage HTTP server for library access");
900
1459
  serverCmd.command("start").description("Start HTTP server").option("--port <port>", "Specify port number").option("-d, --daemon", "Run in background").action(async (options) => {
@@ -964,6 +1523,119 @@ Library: ${status.library}
964
1523
  }
965
1524
  });
966
1525
  }
1526
+ function isValidFilePath(value) {
1527
+ return typeof value === "string" && value !== "" && value !== "true";
1528
+ }
1529
+ function parseFulltextAttachTypeAndPath(filePathArg, options) {
1530
+ if (options.pdf) {
1531
+ return { type: "pdf", filePath: isValidFilePath(options.pdf) ? options.pdf : filePathArg };
1532
+ }
1533
+ if (options.markdown) {
1534
+ return {
1535
+ type: "markdown",
1536
+ filePath: isValidFilePath(options.markdown) ? options.markdown : filePathArg
1537
+ };
1538
+ }
1539
+ return { type: void 0, filePath: filePathArg };
1540
+ }
1541
+ async function handleFulltextAttachAction(identifier, filePathArg, options, program) {
1542
+ try {
1543
+ const globalOpts = program.opts();
1544
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1545
+ const context = await createExecutionContext(config, Library.load);
1546
+ const { type, filePath } = parseFulltextAttachTypeAndPath(filePathArg, options);
1547
+ const stdinContent = !filePath && type ? await readStdinBuffer() : void 0;
1548
+ const attachOptions = {
1549
+ identifier,
1550
+ fulltextDirectory: config.fulltext.directory,
1551
+ ...filePath && { filePath },
1552
+ ...type && { type },
1553
+ ...options.move && { move: options.move },
1554
+ ...options.force && { force: options.force },
1555
+ ...options.uuid && { byUuid: options.uuid },
1556
+ ...stdinContent && { stdinContent }
1557
+ };
1558
+ const result = await executeFulltextAttach(attachOptions, context);
1559
+ const output = formatFulltextAttachOutput(result);
1560
+ process.stderr.write(`${output}
1561
+ `);
1562
+ process.exit(getFulltextExitCode(result));
1563
+ } catch (error) {
1564
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1565
+ `);
1566
+ process.exit(4);
1567
+ }
1568
+ }
1569
+ async function handleFulltextGetAction(identifier, options, program) {
1570
+ try {
1571
+ const globalOpts = program.opts();
1572
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1573
+ const context = await createExecutionContext(config, Library.load);
1574
+ const getOptions = {
1575
+ identifier,
1576
+ fulltextDirectory: config.fulltext.directory,
1577
+ ...options.pdf && { type: "pdf" },
1578
+ ...options.markdown && { type: "markdown" },
1579
+ ...options.stdout && { stdout: options.stdout },
1580
+ ...options.uuid && { byUuid: options.uuid }
1581
+ };
1582
+ const result = await executeFulltextGet(getOptions, context);
1583
+ if (result.success && result.content && options.stdout) {
1584
+ process.stdout.write(result.content);
1585
+ } else {
1586
+ const output = formatFulltextGetOutput(result);
1587
+ if (result.success) {
1588
+ process.stdout.write(`${output}
1589
+ `);
1590
+ } else {
1591
+ process.stderr.write(`${output}
1592
+ `);
1593
+ }
1594
+ }
1595
+ process.exit(getFulltextExitCode(result));
1596
+ } catch (error) {
1597
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1598
+ `);
1599
+ process.exit(4);
1600
+ }
1601
+ }
1602
+ async function handleFulltextDetachAction(identifier, options, program) {
1603
+ try {
1604
+ const globalOpts = program.opts();
1605
+ const config = await loadConfigWithOverrides({ ...globalOpts, ...options });
1606
+ const context = await createExecutionContext(config, Library.load);
1607
+ const detachOptions = {
1608
+ identifier,
1609
+ fulltextDirectory: config.fulltext.directory,
1610
+ ...options.pdf && { type: "pdf" },
1611
+ ...options.markdown && { type: "markdown" },
1612
+ ...options.delete && { delete: options.delete },
1613
+ ...options.force && { force: options.force },
1614
+ ...options.uuid && { byUuid: options.uuid }
1615
+ };
1616
+ const result = await executeFulltextDetach(detachOptions, context);
1617
+ const output = formatFulltextDetachOutput(result);
1618
+ process.stderr.write(`${output}
1619
+ `);
1620
+ process.exit(getFulltextExitCode(result));
1621
+ } catch (error) {
1622
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
1623
+ `);
1624
+ process.exit(4);
1625
+ }
1626
+ }
1627
+ function registerFulltextCommand(program) {
1628
+ const fulltextCmd = program.command("fulltext").description("Manage full-text files attached to references");
1629
+ fulltextCmd.command("attach").description("Attach a full-text file to a reference").argument("<identifier>", "Citation key or UUID").argument("[file-path]", "Path to the file to attach").option("--pdf [path]", "Attach as PDF (path optional if provided as argument)").option("--markdown [path]", "Attach as Markdown (path optional if provided as argument)").option("--move", "Move file instead of copy").option("-f, --force", "Overwrite existing attachment").option("--uuid", "Interpret identifier as UUID").action(async (identifier, filePath, options) => {
1630
+ await handleFulltextAttachAction(identifier, filePath, options, program);
1631
+ });
1632
+ fulltextCmd.command("get").description("Get full-text file path or content").argument("<identifier>", "Citation key or UUID").option("--pdf", "Get PDF file only").option("--markdown", "Get Markdown file only").option("--stdout", "Output file content to stdout").option("--uuid", "Interpret identifier as UUID").action(async (identifier, options) => {
1633
+ await handleFulltextGetAction(identifier, options, program);
1634
+ });
1635
+ fulltextCmd.command("detach").description("Detach full-text file from a reference").argument("<identifier>", "Citation key or UUID").option("--pdf", "Detach PDF only").option("--markdown", "Detach Markdown only").option("--delete", "Also delete the file from disk").option("-f, --force", "Skip confirmation for delete").option("--uuid", "Interpret identifier as UUID").action(async (identifier, options) => {
1636
+ await handleFulltextDetachAction(identifier, options, program);
1637
+ });
1638
+ }
967
1639
  async function main(argv) {
968
1640
  const program = createProgram();
969
1641
  process.on("SIGINT", () => {