@ncukondo/reference-manager 0.7.1 → 0.9.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 (54) hide show
  1. package/README.md +71 -4
  2. package/dist/chunks/{action-menu-CTtINmWd.js → action-menu-BN2HHFPR.js} +2 -2
  3. package/dist/chunks/{action-menu-CTtINmWd.js.map → action-menu-BN2HHFPR.js.map} +1 -1
  4. package/dist/chunks/{file-watcher-D7oyc-9z.js → file-watcher-DdhXSm1l.js} +3 -3
  5. package/dist/chunks/file-watcher-DdhXSm1l.js.map +1 -0
  6. package/dist/chunks/{index-_7NEUoS7.js → index-DmMZCOno.js} +316 -41
  7. package/dist/chunks/index-DmMZCOno.js.map +1 -0
  8. package/dist/chunks/{loader-BItrdVWG.js → loader-C-bdImuO.js} +44 -7
  9. package/dist/chunks/loader-C-bdImuO.js.map +1 -0
  10. package/dist/cli/commands/add.d.ts +4 -0
  11. package/dist/cli/commands/add.d.ts.map +1 -1
  12. package/dist/cli/commands/fulltext.d.ts +20 -3
  13. package/dist/cli/commands/fulltext.d.ts.map +1 -1
  14. package/dist/cli/commands/remove.d.ts +7 -16
  15. package/dist/cli/commands/remove.d.ts.map +1 -1
  16. package/dist/cli/commands/update.d.ts +40 -0
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli.js +582 -152
  20. package/dist/cli.js.map +1 -1
  21. package/dist/core/library.d.ts.map +1 -1
  22. package/dist/features/duplicate/types.d.ts +1 -1
  23. package/dist/features/duplicate/types.d.ts.map +1 -1
  24. package/dist/features/import/detector.d.ts +1 -1
  25. package/dist/features/import/detector.d.ts.map +1 -1
  26. package/dist/features/import/fetcher.d.ts +6 -0
  27. package/dist/features/import/fetcher.d.ts.map +1 -1
  28. package/dist/features/import/importer.d.ts +3 -1
  29. package/dist/features/import/importer.d.ts.map +1 -1
  30. package/dist/features/import/parser.d.ts +16 -0
  31. package/dist/features/import/parser.d.ts.map +1 -1
  32. package/dist/features/operations/add.d.ts +4 -0
  33. package/dist/features/operations/add.d.ts.map +1 -1
  34. package/dist/features/operations/fulltext/index.d.ts +1 -0
  35. package/dist/features/operations/fulltext/index.d.ts.map +1 -1
  36. package/dist/features/operations/fulltext/open.d.ts +36 -0
  37. package/dist/features/operations/fulltext/open.d.ts.map +1 -0
  38. package/dist/features/operations/index.d.ts +2 -0
  39. package/dist/features/operations/index.d.ts.map +1 -1
  40. package/dist/features/operations/json-output.d.ts +93 -0
  41. package/dist/features/operations/json-output.d.ts.map +1 -0
  42. package/dist/features/operations/remove.d.ts +11 -0
  43. package/dist/features/operations/remove.d.ts.map +1 -1
  44. package/dist/index.js +5 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/server.js +2 -2
  47. package/dist/utils/index.d.ts +1 -0
  48. package/dist/utils/index.d.ts.map +1 -1
  49. package/dist/utils/opener.d.ts +13 -0
  50. package/dist/utils/opener.d.ts.map +1 -0
  51. package/package.json +1 -1
  52. package/dist/chunks/file-watcher-D7oyc-9z.js.map +0 -1
  53. package/dist/chunks/index-_7NEUoS7.js.map +0 -1
  54. package/dist/chunks/loader-BItrdVWG.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,25 +1,127 @@
1
1
  import { Command } from "commander";
2
2
  import { ZodOptional as ZodOptional$2, z } from "zod";
3
- import { p as pickDefined, q as sortOrderSchema, r as paginationOptionsSchema, L as Library, F as FileWatcher, u as sortFieldSchema, v as searchSortFieldSchema } from "./chunks/file-watcher-D7oyc-9z.js";
3
+ import { p as pickDefined, q as sortOrderSchema, r as paginationOptionsSchema, L as Library, F as FileWatcher, u as sortFieldSchema, v as searchSortFieldSchema } from "./chunks/file-watcher-DdhXSm1l.js";
4
4
  import { promises, existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import { tmpdir } from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { join, extname } from "node:path";
9
9
  import { mkdir, unlink, rename, copyFile, rm, readFile } from "node:fs/promises";
10
- import { u as updateReference, B as BUILTIN_STYLES, s as startServerWithFileWatcher } from "./chunks/index-_7NEUoS7.js";
10
+ import { u as updateReference, B as BUILTIN_STYLES, s as startServerWithFileWatcher, g as getFulltextAttachmentTypes } from "./chunks/index-DmMZCOno.js";
11
+ import { o as openWithSystemApp, l as loadConfig } from "./chunks/loader-C-bdImuO.js";
11
12
  import process$1, { stdin, stdout } from "node:process";
12
- import { l as loadConfig } from "./chunks/loader-BItrdVWG.js";
13
13
  import { spawn } from "node:child_process";
14
14
  import { serve } from "@hono/node-server";
15
15
  const name = "@ncukondo/reference-manager";
16
- const version$1 = "0.7.1";
16
+ const version$1 = "0.9.0";
17
17
  const description$1 = "A local reference management tool using CSL-JSON as the single source of truth";
18
18
  const packageJson = {
19
19
  name,
20
20
  version: version$1,
21
21
  description: description$1
22
22
  };
23
+ function formatAddJsonOutput(result, options) {
24
+ const { full = false, sources = /* @__PURE__ */ new Map(), items: items2 = /* @__PURE__ */ new Map() } = options;
25
+ const added = result.added.map((item) => {
26
+ const output = {
27
+ source: sources.get(item.id) ?? "",
28
+ id: item.id,
29
+ uuid: item.uuid,
30
+ title: item.title
31
+ };
32
+ if (item.idChanged && item.originalId) {
33
+ output.idChanged = true;
34
+ output.originalId = item.originalId;
35
+ }
36
+ if (full) {
37
+ const cslItem = items2.get(item.id);
38
+ if (cslItem) {
39
+ output.item = cslItem;
40
+ }
41
+ }
42
+ return output;
43
+ });
44
+ const skipped = result.skipped.map((item) => ({
45
+ source: item.source,
46
+ reason: "duplicate",
47
+ existingId: item.existingId,
48
+ duplicateType: item.duplicateType
49
+ }));
50
+ const failed = result.failed.map((item) => ({
51
+ source: item.source,
52
+ reason: item.reason,
53
+ error: item.error
54
+ }));
55
+ return {
56
+ summary: {
57
+ total: added.length + skipped.length + failed.length,
58
+ added: added.length,
59
+ skipped: skipped.length,
60
+ failed: failed.length
61
+ },
62
+ added,
63
+ skipped,
64
+ failed
65
+ };
66
+ }
67
+ function formatRemoveJsonOutput(result, id2, options) {
68
+ const { full = false } = options;
69
+ if (!result.removed || !result.removedItem) {
70
+ return {
71
+ success: false,
72
+ id: id2,
73
+ error: `Reference not found: ${id2}`
74
+ };
75
+ }
76
+ const uuid2 = result.removedItem.custom?.uuid;
77
+ const output = {
78
+ success: true,
79
+ id: id2,
80
+ ...uuid2 && { uuid: uuid2 },
81
+ title: typeof result.removedItem.title === "string" ? result.removedItem.title : ""
82
+ };
83
+ if (full) {
84
+ output.item = result.removedItem;
85
+ }
86
+ return output;
87
+ }
88
+ function formatUpdateJsonOutput(result, originalId, options) {
89
+ const { full = false, before } = options;
90
+ if (!result.updated || !result.item) {
91
+ if (result.idCollision) {
92
+ return {
93
+ success: false,
94
+ id: originalId,
95
+ error: "ID collision: target ID already exists"
96
+ };
97
+ }
98
+ return {
99
+ success: false,
100
+ id: originalId,
101
+ error: `Reference not found: ${originalId}`
102
+ };
103
+ }
104
+ const item = result.item;
105
+ const uuid2 = item.custom?.uuid;
106
+ const finalId = result.idChanged && result.newId ? result.newId : item.id ?? originalId;
107
+ const output = {
108
+ success: true,
109
+ id: finalId,
110
+ ...uuid2 && { uuid: uuid2 },
111
+ title: typeof item.title === "string" ? item.title : ""
112
+ };
113
+ if (result.idChanged && result.newId) {
114
+ output.idChanged = true;
115
+ output.previousId = originalId;
116
+ }
117
+ if (full) {
118
+ if (before) {
119
+ output.before = before;
120
+ }
121
+ output.after = item;
122
+ }
123
+ return output;
124
+ }
23
125
  function getPortfilePath() {
24
126
  const tmpDir = os.tmpdir();
25
127
  return path.join(tmpDir, "reference-manager", "server.port");
@@ -607,6 +709,54 @@ async function fulltextDetach(library, options) {
607
709
  return handleDetachError(error);
608
710
  }
609
711
  }
712
+ function getFulltextPath(item, type2, fulltextDirectory) {
713
+ const fulltext = item.custom?.fulltext;
714
+ if (!fulltext) return void 0;
715
+ const filename = type2 === "pdf" ? fulltext.pdf : fulltext.markdown;
716
+ if (!filename) return void 0;
717
+ return join(fulltextDirectory, filename);
718
+ }
719
+ function determineTypeToOpen(item) {
720
+ const fulltext = item.custom?.fulltext;
721
+ if (!fulltext) return void 0;
722
+ if (fulltext.pdf) return "pdf";
723
+ if (fulltext.markdown) return "markdown";
724
+ return void 0;
725
+ }
726
+ async function fulltextOpen(library, options) {
727
+ const { identifier, type: type2, idType = "id", fulltextDirectory } = options;
728
+ const item = await library.find(identifier, { idType });
729
+ if (!item) {
730
+ return { success: false, error: `Reference not found: ${identifier}` };
731
+ }
732
+ const typeToOpen = type2 ?? determineTypeToOpen(item);
733
+ if (!typeToOpen) {
734
+ return { success: false, error: `No fulltext attached to reference: ${identifier}` };
735
+ }
736
+ const filePath = getFulltextPath(item, typeToOpen, fulltextDirectory);
737
+ if (!filePath) {
738
+ return { success: false, error: `No ${typeToOpen} attached to reference: ${identifier}` };
739
+ }
740
+ if (!existsSync(filePath)) {
741
+ return {
742
+ success: false,
743
+ error: `Fulltext file not found: ${filePath} (metadata exists but file is missing)`
744
+ };
745
+ }
746
+ try {
747
+ await openWithSystemApp(filePath);
748
+ return {
749
+ success: true,
750
+ openedType: typeToOpen,
751
+ openedPath: filePath
752
+ };
753
+ } catch (_error) {
754
+ return {
755
+ success: false,
756
+ error: `Failed to open file: ${filePath}`
757
+ };
758
+ }
759
+ }
610
760
  async function executeFulltextAttach(options, context) {
611
761
  const operationOptions = {
612
762
  identifier: options.identifier,
@@ -640,6 +790,15 @@ async function executeFulltextDetach(options, context) {
640
790
  };
641
791
  return fulltextDetach(context.library, operationOptions);
642
792
  }
793
+ async function executeFulltextOpen(options, context) {
794
+ const operationOptions = {
795
+ identifier: options.identifier,
796
+ type: options.type,
797
+ idType: options.idType,
798
+ fulltextDirectory: options.fulltextDirectory
799
+ };
800
+ return fulltextOpen(context.library, operationOptions);
801
+ }
643
802
  function formatFulltextAttachOutput(result) {
644
803
  if (result.requiresConfirmation) {
645
804
  return `File already attached: ${result.existingFile}
@@ -686,6 +845,12 @@ function formatFulltextDetachOutput(result) {
686
845
  }
687
846
  return lines.join("\n");
688
847
  }
848
+ function formatFulltextOpenOutput(result) {
849
+ if (!result.success) {
850
+ return `Error: ${result.error}`;
851
+ }
852
+ return `Opened ${result.openedType}: ${result.openedPath}`;
853
+ }
689
854
  function getFulltextExitCode(result) {
690
855
  return result.success ? 0 : 1;
691
856
  }
@@ -20959,19 +21124,19 @@ class OperationsLibrary {
20959
21124
  }
20960
21125
  // High-level operations
20961
21126
  async search(options) {
20962
- const { searchReferences } = await import("./chunks/index-_7NEUoS7.js").then((n) => n.e);
21127
+ const { searchReferences } = await import("./chunks/index-DmMZCOno.js").then((n) => n.e);
20963
21128
  return searchReferences(this.library, options);
20964
21129
  }
20965
21130
  async list(options) {
20966
- const { listReferences } = await import("./chunks/index-_7NEUoS7.js").then((n) => n.l);
21131
+ const { listReferences } = await import("./chunks/index-DmMZCOno.js").then((n) => n.l);
20967
21132
  return listReferences(this.library, options ?? {});
20968
21133
  }
20969
21134
  async cite(options) {
20970
- const { citeReferences } = await import("./chunks/index-_7NEUoS7.js").then((n) => n.d);
21135
+ const { citeReferences } = await import("./chunks/index-DmMZCOno.js").then((n) => n.d);
20971
21136
  return citeReferences(this.library, options);
20972
21137
  }
20973
21138
  async import(inputs, options) {
20974
- const { addReferences } = await import("./chunks/index-_7NEUoS7.js").then((n) => n.b);
21139
+ const { addReferences } = await import("./chunks/index-DmMZCOno.js").then((n) => n.b);
20975
21140
  return addReferences(inputs, this.library, options ?? {});
20976
21141
  }
20977
21142
  }
@@ -21484,7 +21649,16 @@ async function mcpStart(options) {
21484
21649
  return result;
21485
21650
  }
21486
21651
  async function executeRemove(options, context) {
21487
- const { identifier, idType = "id" } = options;
21652
+ const { identifier, idType = "id", fulltextDirectory, deleteFulltext = false } = options;
21653
+ if (context.mode === "local" && deleteFulltext && fulltextDirectory) {
21654
+ const { removeReference } = await import("./chunks/index-DmMZCOno.js").then((n) => n.r);
21655
+ return removeReference(context.library, {
21656
+ identifier,
21657
+ idType,
21658
+ fulltextDirectory,
21659
+ deleteFulltext
21660
+ });
21661
+ }
21488
21662
  return context.library.remove(identifier, { idType });
21489
21663
  }
21490
21664
  function formatRemoveOutput(result, identifier) {
@@ -21492,46 +21666,24 @@ function formatRemoveOutput(result, identifier) {
21492
21666
  return `Reference not found: ${identifier}`;
21493
21667
  }
21494
21668
  const item = result.removedItem;
21669
+ let output = "";
21495
21670
  if (item) {
21496
- return `Removed: [${item.id}] ${item.title || "(no title)"}`;
21497
- }
21498
- return `Removed reference: ${identifier}`;
21499
- }
21500
- function getFulltextAttachmentTypes(item) {
21501
- const types2 = [];
21502
- const fulltext = item.custom?.fulltext;
21503
- if (fulltext?.pdf) {
21504
- types2.push("pdf");
21671
+ output = `Removed: [${item.id}] ${item.title || "(no title)"}`;
21672
+ } else {
21673
+ output = `Removed reference: ${identifier}`;
21505
21674
  }
21506
- if (fulltext?.markdown) {
21507
- types2.push("markdown");
21675
+ if (result.deletedFulltextTypes && result.deletedFulltextTypes.length > 0) {
21676
+ const typeLabels = result.deletedFulltextTypes.map((t) => t === "pdf" ? "PDF" : "Markdown");
21677
+ output += `
21678
+ Deleted fulltext files: ${typeLabels.join(" and ")}`;
21508
21679
  }
21509
- return types2;
21680
+ return output;
21510
21681
  }
21511
21682
  function formatFulltextWarning(types2) {
21512
21683
  const typeLabels = types2.map((t) => t === "pdf" ? "PDF" : "Markdown");
21513
21684
  const fileTypes = typeLabels.join(" and ");
21514
21685
  return `Warning: This reference has fulltext files attached (${fileTypes}). Use --force to also delete the fulltext files.`;
21515
21686
  }
21516
- async function deleteFulltextFiles(item, fulltextDirectory) {
21517
- const fulltext = item.custom?.fulltext;
21518
- if (!fulltext) {
21519
- return;
21520
- }
21521
- const filesToDelete = [];
21522
- if (fulltext.pdf) {
21523
- filesToDelete.push(join(fulltextDirectory, fulltext.pdf));
21524
- }
21525
- if (fulltext.markdown) {
21526
- filesToDelete.push(join(fulltextDirectory, fulltext.markdown));
21527
- }
21528
- for (const filePath of filesToDelete) {
21529
- try {
21530
- await unlink(filePath);
21531
- } catch {
21532
- }
21533
- }
21534
- }
21535
21687
  const VALID_SEARCH_SORT_FIELDS = /* @__PURE__ */ new Set([
21536
21688
  "created",
21537
21689
  "updated",
@@ -21628,9 +21780,9 @@ async function executeInteractiveSearch(options, context, config2) {
21628
21780
  validateInteractiveOptions(options);
21629
21781
  const { checkTTY } = await import("./chunks/tty-CDBIQraQ.js");
21630
21782
  const { runSearchPrompt } = await import("./chunks/search-prompt-RtHDJFgL.js");
21631
- const { runActionMenu } = await import("./chunks/action-menu-CTtINmWd.js");
21632
- const { search } = await import("./chunks/file-watcher-D7oyc-9z.js").then((n) => n.y);
21633
- const { tokenize } = await import("./chunks/file-watcher-D7oyc-9z.js").then((n) => n.x);
21783
+ const { runActionMenu } = await import("./chunks/action-menu-BN2HHFPR.js");
21784
+ const { search } = await import("./chunks/file-watcher-DdhXSm1l.js").then((n) => n.y);
21785
+ const { tokenize } = await import("./chunks/file-watcher-DdhXSm1l.js").then((n) => n.x);
21634
21786
  checkTTY();
21635
21787
  const allReferences = await context.library.getAll();
21636
21788
  const searchFn = (query) => {
@@ -21740,9 +21892,181 @@ async function serverStatus(portfilePath) {
21740
21892
  }
21741
21893
  return result;
21742
21894
  }
21895
+ function parseSetOption(input) {
21896
+ const match = input.match(/^([a-zA-Z][a-zA-Z0-9_.]*)([\+\-]?=)(.*)$/);
21897
+ if (!match) {
21898
+ throw new Error(
21899
+ `Invalid --set syntax: "${input}". Use field=value, field+=value, or field-=value`
21900
+ );
21901
+ }
21902
+ return {
21903
+ field: match[1],
21904
+ operator: match[2],
21905
+ value: match[3]
21906
+ };
21907
+ }
21908
+ const PROTECTED_FIELDS = /* @__PURE__ */ new Set([
21909
+ "custom.uuid",
21910
+ "custom.created_at",
21911
+ "custom.timestamp",
21912
+ "custom.fulltext"
21913
+ ]);
21914
+ const STRING_FIELDS = /* @__PURE__ */ new Set([
21915
+ "title",
21916
+ "abstract",
21917
+ "type",
21918
+ "DOI",
21919
+ "PMID",
21920
+ "PMCID",
21921
+ "ISBN",
21922
+ "ISSN",
21923
+ "URL",
21924
+ "publisher",
21925
+ "publisher-place",
21926
+ "page",
21927
+ "volume",
21928
+ "issue",
21929
+ "container-title",
21930
+ "note",
21931
+ "id"
21932
+ ]);
21933
+ const ARRAY_FIELDS = /* @__PURE__ */ new Set(["custom.tags", "custom.additional_urls", "keyword"]);
21934
+ const NAME_FIELDS = /* @__PURE__ */ new Set(["author", "editor"]);
21935
+ const DATE_RAW_FIELDS = /* @__PURE__ */ new Set(["issued.raw", "accessed.raw"]);
21936
+ function parseAuthorValue(value) {
21937
+ return value.split(";").map((author) => {
21938
+ const trimmed = author.trim();
21939
+ const parts = trimmed.split(",").map((p) => p.trim());
21940
+ if (parts.length >= 2 && parts[0] && parts[1]) {
21941
+ return { family: parts[0], given: parts[1] };
21942
+ }
21943
+ return { family: trimmed };
21944
+ });
21945
+ }
21946
+ function applyArrayOperator(operator, value) {
21947
+ if (operator === "+=") return { $add: value };
21948
+ if (operator === "-=") return { $remove: value };
21949
+ return value.split(",").map((v) => v.trim());
21950
+ }
21951
+ function handleCustomArrayField(result, field, operator, value) {
21952
+ const child = field.split(".")[1];
21953
+ if (!child) return;
21954
+ if (!result.custom) result.custom = {};
21955
+ result.custom[child] = applyArrayOperator(operator, value);
21956
+ }
21957
+ function applySingleOperation(result, op) {
21958
+ const { field, operator, value } = op;
21959
+ if (PROTECTED_FIELDS.has(field)) {
21960
+ throw new Error(`Cannot set protected field: ${field}`);
21961
+ }
21962
+ if (STRING_FIELDS.has(field)) {
21963
+ result[field] = value === "" ? void 0 : value;
21964
+ return;
21965
+ }
21966
+ if (ARRAY_FIELDS.has(field)) {
21967
+ if (field.startsWith("custom.")) {
21968
+ handleCustomArrayField(result, field, operator, value);
21969
+ } else {
21970
+ result[field] = applyArrayOperator(operator, value);
21971
+ }
21972
+ return;
21973
+ }
21974
+ if (NAME_FIELDS.has(field)) {
21975
+ result[field] = parseAuthorValue(value);
21976
+ return;
21977
+ }
21978
+ if (DATE_RAW_FIELDS.has(field)) {
21979
+ const dateField = field.split(".")[0];
21980
+ if (dateField) result[dateField] = { raw: value };
21981
+ return;
21982
+ }
21983
+ throw new Error(`Unsupported field: ${field}`);
21984
+ }
21985
+ function applySetOperations(operations) {
21986
+ const result = {};
21987
+ for (const op of operations) {
21988
+ applySingleOperation(result, op);
21989
+ }
21990
+ return result;
21991
+ }
21992
+ function hasArrayOperations(updates) {
21993
+ for (const value of Object.values(updates)) {
21994
+ if (typeof value !== "object" || value === null) continue;
21995
+ const obj = value;
21996
+ if ("$add" in obj || "$remove" in obj) return true;
21997
+ if (hasArrayOperations(obj)) return true;
21998
+ }
21999
+ return false;
22000
+ }
22001
+ function resolveArrayOperations(updates, existingItem) {
22002
+ const result = {};
22003
+ for (const [key, value] of Object.entries(updates)) {
22004
+ if (key === "custom" && typeof value === "object" && value !== null) {
22005
+ result.custom = resolveCustomArrayOperations(
22006
+ value,
22007
+ existingItem.custom
22008
+ );
22009
+ } else if (isArrayOperation(value)) {
22010
+ result[key] = resolveTopLevelArrayOperation(
22011
+ value,
22012
+ existingItem[key]
22013
+ );
22014
+ } else {
22015
+ result[key] = value;
22016
+ }
22017
+ }
22018
+ return result;
22019
+ }
22020
+ function isArrayOperation(value) {
22021
+ if (typeof value !== "object" || value === null) return false;
22022
+ const obj = value;
22023
+ return "$add" in obj || "$remove" in obj;
22024
+ }
22025
+ function resolveCustomArrayOperations(customUpdates, existingCustom) {
22026
+ const result = {};
22027
+ for (const [customKey, customValue] of Object.entries(customUpdates)) {
22028
+ if (isArrayOperation(customValue)) {
22029
+ const op = customValue;
22030
+ const existingArray = existingCustom?.[customKey] ?? [];
22031
+ result[customKey] = applyArrayOperation(existingArray, op);
22032
+ } else {
22033
+ result[customKey] = customValue;
22034
+ }
22035
+ }
22036
+ return result;
22037
+ }
22038
+ function resolveTopLevelArrayOperation(op, existingArray) {
22039
+ return applyArrayOperation(existingArray ?? [], op);
22040
+ }
22041
+ function applyArrayOperation(existingArray, op) {
22042
+ const newArray = [...existingArray];
22043
+ if ("$add" in op && op.$add && !newArray.includes(op.$add)) {
22044
+ newArray.push(op.$add);
22045
+ }
22046
+ if ("$remove" in op && op.$remove) {
22047
+ const idx = newArray.indexOf(op.$remove);
22048
+ if (idx >= 0) newArray.splice(idx, 1);
22049
+ }
22050
+ return newArray;
22051
+ }
21743
22052
  async function executeUpdate(options, context) {
21744
- const { identifier, updates, idType = "id" } = options;
21745
- return context.library.update(identifier, updates, { idType });
22053
+ const { identifier, idType = "id" } = options;
22054
+ let { updates } = options;
22055
+ if (hasArrayOperations(updates)) {
22056
+ const existingItem = await context.library.find(identifier, { idType });
22057
+ if (!existingItem) {
22058
+ return { updated: false };
22059
+ }
22060
+ updates = resolveArrayOperations(
22061
+ updates,
22062
+ existingItem
22063
+ );
22064
+ }
22065
+ const result = await context.library.update(identifier, updates, { idType });
22066
+ if (result.updated) {
22067
+ await context.library.save();
22068
+ }
22069
+ return result;
21746
22070
  }
21747
22071
  function formatUpdateOutput(result, identifier) {
21748
22072
  if (!result.updated) {
@@ -22005,7 +22329,7 @@ const OPTION_VALUES = {
22005
22329
  "--log-level": LOG_LEVELS
22006
22330
  };
22007
22331
  const ID_COMPLETION_COMMANDS = /* @__PURE__ */ new Set(["cite", "remove", "update"]);
22008
- const ID_COMPLETION_FULLTEXT_SUBCOMMANDS = /* @__PURE__ */ new Set(["attach", "get", "detach"]);
22332
+ const ID_COMPLETION_FULLTEXT_SUBCOMMANDS = /* @__PURE__ */ new Set(["attach", "get", "detach", "open"]);
22009
22333
  function toCompletionItems(values) {
22010
22334
  return values.map((name2) => ({ name: name2 }));
22011
22335
  }
@@ -22357,7 +22681,53 @@ function registerSearchCommand(program) {
22357
22681
  await handleSearchAction(query ?? "", options, program);
22358
22682
  });
22359
22683
  }
22684
+ function buildAddOptions(inputs, options, config2, stdinContent) {
22685
+ const addOptions = {
22686
+ inputs,
22687
+ force: options.force ?? false
22688
+ };
22689
+ if (options.format !== void 0) {
22690
+ addOptions.format = options.format;
22691
+ }
22692
+ if (options.verbose !== void 0) {
22693
+ addOptions.verbose = options.verbose;
22694
+ }
22695
+ if (stdinContent?.trim()) {
22696
+ addOptions.stdinContent = stdinContent;
22697
+ }
22698
+ const pubmedConfig = {};
22699
+ if (config2.pubmed.email !== void 0) {
22700
+ pubmedConfig.email = config2.pubmed.email;
22701
+ }
22702
+ if (config2.pubmed.apiKey !== void 0) {
22703
+ pubmedConfig.apiKey = config2.pubmed.apiKey;
22704
+ }
22705
+ if (Object.keys(pubmedConfig).length > 0) {
22706
+ addOptions.pubmedConfig = pubmedConfig;
22707
+ }
22708
+ return addOptions;
22709
+ }
22710
+ async function outputAddResultJson(result, context, full) {
22711
+ const sources = /* @__PURE__ */ new Map();
22712
+ for (const item of result.added) {
22713
+ sources.set(item.id, "");
22714
+ }
22715
+ const items2 = /* @__PURE__ */ new Map();
22716
+ if (full) {
22717
+ for (const added of result.added) {
22718
+ const item = await context.library.find(added.id, { idType: "id" });
22719
+ if (item) {
22720
+ items2.set(added.id, item);
22721
+ }
22722
+ }
22723
+ }
22724
+ const options = { full, sources, items: items2 };
22725
+ const jsonOutput = formatAddJsonOutput(result, options);
22726
+ process.stdout.write(`${JSON.stringify(jsonOutput)}
22727
+ `);
22728
+ }
22360
22729
  async function handleAddAction(inputs, options, program) {
22730
+ const outputFormat = options.output ?? "text";
22361
22731
  try {
22362
22732
  const globalOpts = program.opts();
22363
22733
  const config2 = await loadConfigWithOverrides({ ...globalOpts, ...options });
@@ -22366,38 +22736,25 @@ async function handleAddAction(inputs, options, program) {
22366
22736
  stdinContent = await readStdinContent();
22367
22737
  }
22368
22738
  const context = await createExecutionContext(config2, Library.load);
22369
- const addOptions = {
22370
- inputs,
22371
- force: options.force ?? false
22372
- };
22373
- if (options.format !== void 0) {
22374
- addOptions.format = options.format;
22375
- }
22376
- if (options.verbose !== void 0) {
22377
- addOptions.verbose = options.verbose;
22378
- }
22379
- if (stdinContent?.trim()) {
22380
- addOptions.stdinContent = stdinContent;
22381
- }
22382
- const pubmedConfig = {};
22383
- if (config2.pubmed.email !== void 0) {
22384
- pubmedConfig.email = config2.pubmed.email;
22385
- }
22386
- if (config2.pubmed.apiKey !== void 0) {
22387
- pubmedConfig.apiKey = config2.pubmed.apiKey;
22388
- }
22389
- if (Object.keys(pubmedConfig).length > 0) {
22390
- addOptions.pubmedConfig = pubmedConfig;
22391
- }
22739
+ const addOptions = buildAddOptions(inputs, options, config2, stdinContent);
22392
22740
  const result = await executeAdd(addOptions, context);
22393
- const output = formatAddOutput(result, options.verbose ?? false);
22394
- process.stderr.write(`${output}
22741
+ if (outputFormat === "json") {
22742
+ await outputAddResultJson(result, context, options.full ?? false);
22743
+ } else {
22744
+ const output = formatAddOutput(result, options.verbose ?? false);
22745
+ process.stderr.write(`${output}
22395
22746
  `);
22747
+ }
22396
22748
  process.exit(getExitCode(result));
22397
22749
  } catch (error) {
22398
22750
  const message = error instanceof Error ? error.message : String(error);
22399
- process.stderr.write(`Error: ${message}
22751
+ if (outputFormat === "json") {
22752
+ process.stdout.write(`${JSON.stringify({ success: false, error: message })}
22400
22753
  `);
22754
+ } else {
22755
+ process.stderr.write(`Error: ${message}
22756
+ `);
22757
+ }
22401
22758
  process.exit(1);
22402
22759
  }
22403
22760
  }
@@ -22406,102 +22763,99 @@ function registerAddCommand(program) {
22406
22763
  "--format <format>",
22407
22764
  "Explicit input format: json|bibtex|ris|pmid|doi|isbn|auto",
22408
22765
  "auto"
22409
- ).option("--verbose", "Show detailed error information").action(async (inputs, options) => {
22766
+ ).option("--verbose", "Show detailed error information").option("-o, --output <format>", "Output format: json|text", "text").option("--full", "Include full CSL-JSON data in JSON output").action(async (inputs, options) => {
22410
22767
  await handleAddAction(inputs, options, program);
22411
22768
  });
22412
22769
  }
22413
- async function findReferenceToRemove(identifier, useUuid, context) {
22414
- return context.library.find(identifier, { idType: useUuid ? "uuid" : "id" });
22770
+ function outputRemoveNotFoundAndExit(identifier, outputFormat) {
22771
+ if (outputFormat === "json") {
22772
+ const jsonOutput = formatRemoveJsonOutput({ removed: false }, identifier, {});
22773
+ process.stdout.write(`${JSON.stringify(jsonOutput)}
22774
+ `);
22775
+ } else {
22776
+ process.stderr.write(`Error: Reference not found: ${identifier}
22777
+ `);
22778
+ }
22779
+ process.exit(1);
22415
22780
  }
22416
- async function confirmRemoval(refToRemove, force, fulltextWarning) {
22781
+ function outputRemoveResult(result, identifier, outputFormat, full) {
22782
+ if (outputFormat === "json") {
22783
+ const jsonOutput = formatRemoveJsonOutput(result, identifier, { full });
22784
+ process.stdout.write(`${JSON.stringify(jsonOutput)}
22785
+ `);
22786
+ } else {
22787
+ const output = formatRemoveOutput(result, identifier);
22788
+ process.stderr.write(`${output}
22789
+ `);
22790
+ }
22791
+ }
22792
+ async function confirmRemoveIfNeeded(item, hasFulltext, force) {
22417
22793
  if (force || !isTTY()) {
22418
22794
  return true;
22419
22795
  }
22420
- const authors = Array.isArray(refToRemove.author) ? refToRemove.author.map((a) => `${a.family || ""}, ${a.given?.[0] || ""}.`).join("; ") : "(no authors)";
22421
- let confirmMsg = `Remove reference [${refToRemove.id}]?
22422
- Title: ${refToRemove.title || "(no title)"}
22423
- Authors: ${authors}`;
22424
- if (fulltextWarning) {
22425
- confirmMsg += `
22796
+ const authors = Array.isArray(item.author) ? item.author.map((a) => `${a.family || ""}, ${a.given?.[0] || ""}.`).join("; ") : "(no authors)";
22797
+ const fulltextTypes = hasFulltext ? getFulltextAttachmentTypes(item) : [];
22798
+ const warning = hasFulltext ? formatFulltextWarning(fulltextTypes) : "";
22799
+ const warningPart = warning ? `
22426
22800
 
22427
- ${fulltextWarning}`;
22428
- }
22429
- confirmMsg += "\nContinue?";
22430
- return await readConfirmation(confirmMsg);
22431
- }
22432
- function handleRemoveError(error) {
22801
+ ${warning}` : "";
22802
+ const confirmMsg = `Remove reference [${item.id}]?
22803
+ Title: ${item.title || "(no title)"}
22804
+ Authors: ${authors}${warningPart}
22805
+ Continue?`;
22806
+ return readConfirmation(confirmMsg);
22807
+ }
22808
+ function handleRemoveError(error, identifier, outputFormat) {
22433
22809
  const message = error instanceof Error ? error.message : String(error);
22434
- if (message.includes("not found")) {
22810
+ if (outputFormat === "json") {
22811
+ process.stdout.write(`${JSON.stringify({ success: false, id: identifier, error: message })}
22812
+ `);
22813
+ } else {
22435
22814
  process.stderr.write(`Error: ${message}
22436
22815
  `);
22437
- process.exit(1);
22438
22816
  }
22439
- process.stderr.write(`Error: ${message}
22440
- `);
22441
- process.exit(4);
22442
- }
22443
- function formatTypeLabels(types2) {
22444
- return types2.map((t) => t === "pdf" ? "PDF" : "Markdown").join(" and ");
22445
- }
22446
- async function handleFulltextDeletion(item, fulltextDirectory, types2, output) {
22447
- await deleteFulltextFiles(item, fulltextDirectory);
22448
- const typeLabels = formatTypeLabels(types2);
22449
- process.stderr.write(`${output}
22450
- `);
22451
- process.stderr.write(`Deleted fulltext files: ${typeLabels}
22452
- `);
22817
+ process.exit(message.includes("not found") ? 1 : 4);
22453
22818
  }
22454
22819
  async function handleRemoveAction(identifier, options, program) {
22820
+ const outputFormat = options.output ?? "text";
22821
+ const useUuid = options.uuid ?? false;
22822
+ const force = options.force ?? false;
22455
22823
  try {
22456
22824
  const globalOpts = program.opts();
22457
22825
  const config2 = await loadConfigWithOverrides({ ...globalOpts, ...options });
22458
22826
  const context = await createExecutionContext(config2, Library.load);
22459
- const refToRemove = await findReferenceToRemove(identifier, options.uuid ?? false, context);
22827
+ const refToRemove = await context.library.find(identifier, { idType: useUuid ? "uuid" : "id" });
22460
22828
  if (!refToRemove) {
22461
- process.stderr.write(`Error: Reference not found: ${identifier}
22462
- `);
22463
- process.exit(1);
22829
+ outputRemoveNotFoundAndExit(identifier, outputFormat);
22464
22830
  }
22465
22831
  const fulltextTypes = getFulltextAttachmentTypes(refToRemove);
22466
22832
  const hasFulltext = fulltextTypes.length > 0;
22467
- if (hasFulltext && !isTTY() && !options.force) {
22468
- const warning = formatFulltextWarning(fulltextTypes);
22469
- process.stderr.write(`Error: ${warning}
22833
+ const requiresForce = hasFulltext && !isTTY() && !force;
22834
+ if (requiresForce) {
22835
+ process.stderr.write(`Error: ${formatFulltextWarning(fulltextTypes)}
22470
22836
  `);
22471
22837
  process.exit(1);
22472
22838
  }
22473
- const fulltextWarning = hasFulltext && !options.force ? formatFulltextWarning(fulltextTypes) : void 0;
22474
- const confirmed = await confirmRemoval(refToRemove, options.force ?? false, fulltextWarning);
22839
+ const confirmed = await confirmRemoveIfNeeded(refToRemove, hasFulltext, force);
22475
22840
  if (!confirmed) {
22476
22841
  process.stderr.write("Cancelled.\n");
22477
22842
  process.exit(2);
22478
22843
  }
22479
22844
  const removeOptions = {
22480
- identifier
22845
+ identifier,
22846
+ idType: useUuid ? "uuid" : "id",
22847
+ fulltextDirectory: config2.fulltext.directory,
22848
+ deleteFulltext: force && hasFulltext
22481
22849
  };
22482
- if (options.uuid) {
22483
- removeOptions.idType = "uuid";
22484
- }
22485
22850
  const result = await executeRemove(removeOptions, context);
22486
- const output = formatRemoveOutput(result, identifier);
22487
- if (!result.removed) {
22488
- process.stderr.write(`${output}
22489
- `);
22490
- process.exit(1);
22491
- }
22492
- if (hasFulltext && options.force) {
22493
- await handleFulltextDeletion(refToRemove, config2.fulltext.directory, fulltextTypes, output);
22494
- } else {
22495
- process.stderr.write(`${output}
22496
- `);
22497
- }
22498
- process.exit(0);
22851
+ outputRemoveResult(result, identifier, outputFormat, options.full ?? false);
22852
+ process.exit(result.removed ? 0 : 1);
22499
22853
  } catch (error) {
22500
- handleRemoveError(error);
22854
+ handleRemoveError(error, identifier, outputFormat);
22501
22855
  }
22502
22856
  }
22503
22857
  function registerRemoveCommand(program) {
22504
- program.command("remove").description("Remove a reference from the library").argument("<identifier>", "Citation key or UUID").option("--uuid", "Interpret identifier as UUID").option("-f, --force", "Skip confirmation prompt").action(async (identifier, options) => {
22858
+ program.command("remove").description("Remove a reference from the library").argument("<identifier>", "Citation key or UUID").option("--uuid", "Interpret identifier as UUID").option("-f, --force", "Skip confirmation prompt").option("-o, --output <format>", "Output format: json|text", "text").option("--full", "Include full CSL-JSON data in JSON output").action(async (identifier, options) => {
22505
22859
  await handleRemoveAction(identifier, options, program);
22506
22860
  });
22507
22861
  }
@@ -22521,39 +22875,70 @@ function handleUpdateError(error) {
22521
22875
  `);
22522
22876
  process.exit(4);
22523
22877
  }
22878
+ function parseUpdateInput(setOptions, file) {
22879
+ if (setOptions && setOptions.length > 0 && file) {
22880
+ throw new Error("Cannot use --set with a file argument. Use one or the other.");
22881
+ }
22882
+ if (setOptions && setOptions.length > 0) {
22883
+ const operations = setOptions.map((s) => parseSetOption(s));
22884
+ return applySetOperations(operations);
22885
+ }
22886
+ return readJsonInput(file).then((inputStr) => {
22887
+ const updates = parseJsonInput(inputStr);
22888
+ const updatesSchema = z.record(z.string(), z.unknown());
22889
+ return updatesSchema.parse(updates);
22890
+ });
22891
+ }
22892
+ function outputUpdateResult(result, identifier, outputFormat, full, beforeItem) {
22893
+ if (outputFormat === "json") {
22894
+ const jsonOptions = {
22895
+ full,
22896
+ ...beforeItem && { before: beforeItem }
22897
+ };
22898
+ const jsonOutput = formatUpdateJsonOutput(result, identifier, jsonOptions);
22899
+ process.stdout.write(`${JSON.stringify(jsonOutput)}
22900
+ `);
22901
+ } else {
22902
+ const output = formatUpdateOutput(result, identifier);
22903
+ process.stderr.write(`${output}
22904
+ `);
22905
+ }
22906
+ }
22907
+ function handleUpdateErrorWithFormat(error, identifier, outputFormat) {
22908
+ const message = error instanceof Error ? error.message : String(error);
22909
+ if (outputFormat === "json") {
22910
+ process.stdout.write(`${JSON.stringify({ success: false, id: identifier, error: message })}
22911
+ `);
22912
+ process.exit(message.includes("not found") || message.includes("validation") ? 1 : 4);
22913
+ }
22914
+ handleUpdateError(error);
22915
+ }
22524
22916
  async function handleUpdateAction(identifier, file, options, program) {
22917
+ const outputFormat = options.output ?? "text";
22525
22918
  try {
22526
22919
  const globalOpts = program.opts();
22527
22920
  const config2 = await loadConfigWithOverrides({ ...globalOpts, ...options });
22528
- const inputStr = await readJsonInput(file);
22529
- const updates = parseJsonInput(inputStr);
22530
- const updatesSchema = z.record(z.string(), z.unknown());
22531
- const validatedUpdates = updatesSchema.parse(updates);
22921
+ const validatedUpdates = await parseUpdateInput(options.set, file);
22532
22922
  const context = await createExecutionContext(config2, Library.load);
22923
+ const idType = options.uuid ? "uuid" : "id";
22924
+ const beforeItem = options.full ? await context.library.find(identifier, { idType }) : void 0;
22533
22925
  const updateOptions = {
22534
22926
  identifier,
22535
- updates: validatedUpdates
22927
+ updates: validatedUpdates,
22928
+ ...options.uuid && { idType: "uuid" }
22536
22929
  };
22537
- if (options.uuid) {
22538
- updateOptions.idType = "uuid";
22539
- }
22540
22930
  const result = await executeUpdate(updateOptions, context);
22541
- const output = formatUpdateOutput(result, identifier);
22542
- if (result.updated) {
22543
- process.stderr.write(`${output}
22544
- `);
22545
- process.exit(0);
22546
- } else {
22547
- process.stderr.write(`${output}
22548
- `);
22549
- process.exit(1);
22550
- }
22931
+ outputUpdateResult(result, identifier, outputFormat, options.full ?? false, beforeItem);
22932
+ process.exit(result.updated ? 0 : 1);
22551
22933
  } catch (error) {
22552
- handleUpdateError(error);
22934
+ handleUpdateErrorWithFormat(error, identifier, outputFormat);
22553
22935
  }
22554
22936
  }
22937
+ function collectSetOption(value, previous) {
22938
+ return previous.concat([value]);
22939
+ }
22555
22940
  function registerUpdateCommand(program) {
22556
- program.command("update").description("Update fields of an existing reference").argument("<identifier>", "Citation key or UUID").argument("[file]", "JSON file with updates (or use stdin)").option("--uuid", "Interpret identifier as UUID").action(async (identifier, file, options) => {
22941
+ program.command("update").description("Update fields of an existing reference").argument("<identifier>", "Citation key or UUID").argument("[file]", "JSON file with updates (or use stdin)").option("--uuid", "Interpret identifier as UUID").option("--set <field=value>", "Set field value (repeatable)", collectSetOption, []).option("-o, --output <format>", "Output format: json|text", "text").option("--full", "Include full CSL-JSON data in JSON output").action(async (identifier, file, options) => {
22557
22942
  await handleUpdateAction(identifier, file, options, program);
22558
22943
  });
22559
22944
  }
@@ -22787,6 +23172,48 @@ async function handleFulltextDetachAction(identifier, options, program) {
22787
23172
  process.exit(4);
22788
23173
  }
22789
23174
  }
23175
+ async function handleFulltextOpenAction(identifierArg, options, program) {
23176
+ try {
23177
+ const config2 = await loadConfigWithOverrides(program.opts());
23178
+ let identifier;
23179
+ if (identifierArg) {
23180
+ identifier = identifierArg;
23181
+ } else {
23182
+ if (isTTY()) {
23183
+ process.stderr.write("Error: Identifier is required when running interactively.\n");
23184
+ process.exit(1);
23185
+ }
23186
+ const stdinId = (await readStdinContent()).split("\n")[0]?.trim() ?? "";
23187
+ if (!stdinId) {
23188
+ process.stderr.write("Error: No identifier provided from stdin.\n");
23189
+ process.exit(1);
23190
+ }
23191
+ identifier = stdinId;
23192
+ }
23193
+ const context = await createExecutionContext(config2, Library.load);
23194
+ const openOptions = {
23195
+ identifier,
23196
+ fulltextDirectory: config2.fulltext.directory,
23197
+ ...options.pdf && { type: "pdf" },
23198
+ ...options.markdown && { type: "markdown" },
23199
+ ...options.uuid && { idType: "uuid" }
23200
+ };
23201
+ const result = await executeFulltextOpen(openOptions, context);
23202
+ const output = formatFulltextOpenOutput(result);
23203
+ if (result.success) {
23204
+ process.stderr.write(`${output}
23205
+ `);
23206
+ } else {
23207
+ process.stderr.write(`${output}
23208
+ `);
23209
+ }
23210
+ process.exit(getFulltextExitCode(result));
23211
+ } catch (error) {
23212
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
23213
+ `);
23214
+ process.exit(4);
23215
+ }
23216
+ }
22790
23217
  function registerFulltextCommand(program) {
22791
23218
  const fulltextCmd = program.command("fulltext").description("Manage full-text files attached to references");
22792
23219
  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) => {
@@ -22798,6 +23225,9 @@ function registerFulltextCommand(program) {
22798
23225
  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) => {
22799
23226
  await handleFulltextDetachAction(identifier, options, program);
22800
23227
  });
23228
+ fulltextCmd.command("open").description("Open full-text file with system default application").argument("[identifier]", "Citation key or UUID (reads from stdin if not provided)").option("--pdf", "Open PDF file").option("--markdown", "Open Markdown file").option("--uuid", "Interpret identifier as UUID").action(async (identifier, options) => {
23229
+ await handleFulltextOpenAction(identifier, options, program);
23230
+ });
22801
23231
  }
22802
23232
  async function main(argv) {
22803
23233
  const program = createProgram();