freshjots 1.1.0 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a9a2097c87005bc6e9b43c35881949e54bce2871962789f463ed347b4e2e415
4
- data.tar.gz: 18f981d7b062f4e683b0c4bb4331f6be9442e94cad3f4067dea8847ed9d3893e
3
+ metadata.gz: '0881261ca2139927877d9e54e9960a92c4318af920804a0324a7e9a728d9a0cd'
4
+ data.tar.gz: 320b4c81fa62361731c3a02e50570841519d8bcf9458d97bf8ce4478195e270b
5
5
  SHA512:
6
- metadata.gz: 00b9d55134d2826c0aa1515277907e31bcd1cb9b0df62918e194ca4b7f7e2542008fb3236b5503cb815ebde47702e4bac1e094a6ba3d2a5ac025ebb5ebf89171
7
- data.tar.gz: f5f7dbcc724409cdb3b969972e0c5ff455abc715f3fc0da6650eaf1b6c654693ebd3a69c93c8f2f67aff021b529c41b37f90b506f3ead3d1e059737bfc4c4b85
6
+ metadata.gz: 48f118b9637d9e5beb2c418cfb8c7e91b42f2ee684e8061eadea70c4e11b5ba54f7a1179a80c3ac60fa60f11bb19d416e8edb7d5e58ea118df8c4b8c6ddcb0de
7
+ data.tar.gz: 3ca213be4f8684c5f1f8ef8a92b84e2b550dd9ff5228888fe7e72c326429ec024b64906285e14e8666a3854dbd0531e15e53d45c7339e65ac13d3315c2dbf5c1
data/README.md CHANGED
@@ -44,13 +44,25 @@ end
44
44
  created = client.create(title: "Research 2026 Q2", body: "Initial outline.")
45
45
  puts created[:filename] # server-derived stream name
46
46
 
47
- # Organize: move into a folder (by id or name), delete (by id or filename), list folders.
47
+ # Update a note's fields (only the keys you pass change). By id or by filename:
48
+ client.update(42, title: "Q2 research", body: "Revised outline.")
49
+ client.set("cron-jobs-prod", folder: "Ops", deadline: 26) # metadata only, no body needed
50
+
51
+ # Create many notes in one atomic batch (up to 50 — all land or none do).
52
+ client.bulk([{ title: "a", plain_body: "1" }, { title: "b", plain_body: "2" }])
53
+
54
+ # Organize: move into a folder (by id or name), delete (by id or filename).
48
55
  client.move("cron-jobs-prod", folder: "Ops")
49
56
  client.delete("old-note")
57
+
58
+ # Folders: list, read one, create, rename, delete (its notes survive, un-foldered).
50
59
  client.folders.each { |f| puts "#{f[:id]}\t#{f[:name]}" }
60
+ ops = client.create_folder("Ops")
61
+ client.rename_folder(ops[:id], "Operations")
62
+ client.delete_folder(ops[:id])
51
63
  ```
52
64
 
53
- Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:, client_encrypted:)`, `append(filename, text, client_encrypted:)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, and `folders`. Client-side crypto: `Freshjots.encrypt(text, passphrase)` / `Freshjots.decrypt(token, passphrase)` (see [Encryption](#encryption)). `note`/`note_by_id`/`create` return the note hash directly (no `{ note: … }` wrapper); `notes` and `folders` return arrays. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
65
+ Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:, client_encrypted:)`, `append(filename, text, client_encrypted:)`, `update(id, **fields)`, `set(filename, **fields)`, `bulk(notes)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, `folders`, `folder(id)`, `create_folder(name)`, `rename_folder(id, name)`, and `delete_folder(id)`. Client-side crypto: `Freshjots.encrypt(text, passphrase)` / `Freshjots.decrypt(token, passphrase)` (see [Encryption](#encryption)). `note`/`note_by_id`/`create`/`update`/`set` and the single-folder methods return the hash directly (no `{ note: … }` / `{ folder: … }` wrapper); `notes` and `folders` return arrays, and `bulk` returns `{ created: [...] }`. For `update`/`set`, the fields are `title:`, `body:`, `folder:` (id or name — `root: true` un-folders), `deadline:`, `alert_email:`, `webhook_url:`, `webhook_secret:`; changing `title:` rewrites the body as a unit, so pass `body:` too. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
54
66
 
55
67
  ## Encryption
56
68
 
data/exe/freshjots ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "freshjots/cli"
5
+
6
+ exit Freshjots::CLI.run(ARGV)
@@ -0,0 +1,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../freshjots"
5
+
6
+ # Command-line front-end for the Fresh Jots API — a thin wrapper over
7
+ # Freshjots::Client, mirroring the npm and bash CLIs command-for-command
8
+ # (ls, get, cat, create, append, update, set, rm, mv, bulk, folders, folder
9
+ # subcommands, encrypt, decrypt) and their output. run() takes its I/O and a
10
+ # client factory as keyword args so it is fully testable without a network or a
11
+ # real token; exe/freshjots is a one-line wrapper around it.
12
+ module Freshjots
13
+ module CLI
14
+ USAGE = <<~USAGE
15
+ freshjots — Fresh Jots CLI
16
+
17
+ Usage:
18
+ freshjots ls [flags] List notes as id<TAB>filename<TAB>title.
19
+ -n N | --limit N
20
+ --sort created|updated|appended
21
+ --folder <id|name> | --root
22
+ --all fetch every page (past the 200 cap)
23
+ -l|--long id, updated_at, lock, folder, name, title
24
+ freshjots get <id> Print a note as JSON (full metadata).
25
+ freshjots cat <id|filename> [--decrypt] Print a note's body.
26
+ freshjots create <title> [<body>] [--body <text>] [--encrypt]
27
+ freshjots append <filename> [<text>] [--encrypt]
28
+ freshjots update <id> [flags] Update a note by id (see Update flags).
29
+ freshjots set <filename> [flags] Update a note by filename (see Update flags).
30
+ freshjots rm <id|filename> Delete a note.
31
+ freshjots mv <id|filename> <folder-id|name|--root>
32
+ freshjots bulk [file.json] Bulk-create notes (JSON array/{notes:[…]}
33
+ on stdin or from a file; max 50).
34
+ freshjots folders List folders as id<TAB>name.
35
+ freshjots folder <subcommand> Manage folders:
36
+ folder [ls] list (same as 'folders')
37
+ folder create <name> create a folder
38
+ folder rename <id> <name> rename a folder
39
+ folder rm <id> delete a folder (notes survive)
40
+ folder <id> show one folder as JSON
41
+ freshjots encrypt Encrypt stdin, print an fj1: token.
42
+ freshjots decrypt Decrypt fj1: lines from stdin to plaintext.
43
+ freshjots --help | --version
44
+
45
+ Global flags:
46
+ -q, --quiet Suppress the success confirmation on write commands.
47
+
48
+ Update flags (update / set) — only the fields you pass are changed:
49
+ --title <s> new title (a title change rewrites the body,
50
+ so pass --body too)
51
+ --body <text> | - new body (- reads the body from stdin)
52
+ --folder <id> | --root move into a folder (numeric id) or un-folder
53
+ --deadline <hours> dead-man's-switch deadline
54
+ --alert-email <s> dead-man alert address
55
+ --webhook-url <s> outbound webhook URL
56
+ --webhook-secret <s> outbound webhook signing secret
57
+
58
+ Notes:
59
+ - <text> for append and the body for create may also be piped on stdin.
60
+ - --encrypt / --decrypt and the encrypt/decrypt commands encrypt client-side
61
+ with the passphrase in FRESHJOTS_PASSPHRASE; the server only ever stores the
62
+ ciphertext and cannot read it. Lose the passphrase and the note is lost.
63
+ - Auth: set FRESHJOTS_TOKEN. Mint one at
64
+ https://freshjots.com/settings/api_tokens.
65
+ USAGE
66
+
67
+ module_function
68
+
69
+ # Entry point. Returns a process exit code. I/O and the client are injected
70
+ # for testability; production callers (exe/freshjots) use the defaults.
71
+ def run(argv, out: $stdout, err: $stderr, stdin: $stdin, env: ENV,
72
+ client_factory: ->(token) { Freshjots::Client.new(token: token) })
73
+ # Global -q/--quiet, stripped before parsing: suppresses the success
74
+ # confirmation on write commands. Data commands print regardless.
75
+ quiet = argv.include?("-q") || argv.include?("--quiet")
76
+ args = argv.reject { |a| a == "-q" || a == "--quiet" }
77
+ say = quiet ? ->(_s) {} : ->(s) { out.print(s) }
78
+
79
+ parsed = parse_args(args)
80
+
81
+ case parsed[:command]
82
+ when :help
83
+ (parsed[:exit_code].to_i.positive? ? err : out).print(USAGE)
84
+ return parsed[:exit_code] || 0
85
+ when :version
86
+ out.print("freshjots #{Freshjots::VERSION}\n")
87
+ return 0
88
+ when :error
89
+ err.print("Error: #{parsed[:message]}\n\n#{USAGE}")
90
+ return 2
91
+ when :encrypt, :decrypt
92
+ return run_crypto(parsed[:command], stdin, out, err, env)
93
+ end
94
+
95
+ token = env["FRESHJOTS_TOKEN"]
96
+ if token.nil? || token.empty?
97
+ err.print("Error: FRESHJOTS_TOKEN is not set. Mint one at https://freshjots.com/settings/api_tokens\n")
98
+ return 1
99
+ end
100
+
101
+ begin
102
+ client = client_factory.call(token)
103
+ rescue StandardError => e
104
+ err.print("Error: #{e.message}\n")
105
+ return 1
106
+ end
107
+
108
+ begin
109
+ dispatch(parsed, client, out, err, stdin, env, say)
110
+ rescue Freshjots::ApiError => e
111
+ err.print("Error: HTTP #{e.status} #{e.code}: #{e.message}\n")
112
+ 1
113
+ rescue StandardError => e
114
+ err.print("Error: #{e.message}\n")
115
+ 1
116
+ end
117
+ end
118
+
119
+ # ---- command dispatch (assumes token + client are ready) ----
120
+
121
+ def dispatch(parsed, client, out, err, stdin, env, say)
122
+ case parsed[:command]
123
+ when :list
124
+ folder_id = list_folder_filter(parsed[:folder], client)
125
+ notes =
126
+ if parsed[:all]
127
+ fetch_all_notes(client, parsed[:sort], folder_id)
128
+ else
129
+ client.notes(sort: parsed[:sort], folder_id: folder_id, limit: parsed[:limit])
130
+ end
131
+ print_notes(notes, parsed[:long], out)
132
+ 0
133
+ when :get
134
+ out.print("#{JSON.pretty_generate(client.note_by_id(parsed[:id]))}\n")
135
+ 0
136
+ when :show
137
+ note = numeric?(parsed[:target]) ? client.note_by_id(parsed[:target]) : client.note(parsed[:target])
138
+ body = note[:plain_body] || ""
139
+ body = decrypt_body(body, passphrase!(env)) if parsed[:decrypt]
140
+ out.print(body)
141
+ 0
142
+ when :create
143
+ body = parsed[:body]
144
+ body = read_stdin(stdin) if body.nil?
145
+ body ||= ""
146
+ encrypted = parsed[:encrypt]
147
+ body = Freshjots.encrypt(body, passphrase!(env)) if encrypted
148
+ created = client.create(title: parsed[:title], body: body, client_encrypted: encrypted)
149
+ say.call("created ##{created[:id]} #{created[:filename]}\n")
150
+ 0
151
+ when :append
152
+ text = parsed[:text]
153
+ text = read_stdin(stdin) if text.nil?
154
+ if text.nil? || text.empty?
155
+ err.print("Error: append requires text (as an argument or on stdin)\n")
156
+ return 2
157
+ end
158
+ text = Freshjots.encrypt(text, passphrase!(env)) if parsed[:encrypt]
159
+ res = client.append(parsed[:filename], text, client_encrypted: parsed[:encrypt])
160
+ say.call("#{res[:created] ? 'created' : 'appended to'} ##{res[:id]} #{res[:filename]} — #{group_thousands(res[:bytes_remaining])} bytes remaining\n")
161
+ 0
162
+ when :rm
163
+ res = client.delete(parsed[:target])
164
+ say.call("deleted ##{res[:id]}\n")
165
+ 0
166
+ when :mv
167
+ dest = parsed[:dest]
168
+ folder = %w[--root root none null].include?(dest) ? nil : dest
169
+ res = client.move(parsed[:target], folder: folder)
170
+ say.call("moved ##{res[:id]} -> folder #{res[:folder_id] || 'root'}\n")
171
+ 0
172
+ when :folders
173
+ client.folders.each { |f| out.print("#{f[:id]}\t#{f[:name]}\n") }
174
+ 0
175
+ when :folder_create
176
+ f = client.create_folder(parsed[:name])
177
+ say.call("created folder ##{f[:id]} #{f[:name]}\n")
178
+ 0
179
+ when :folder_rename
180
+ f = client.rename_folder(parsed[:id], parsed[:name])
181
+ say.call("renamed folder ##{f[:id]} -> #{f[:name]}\n")
182
+ 0
183
+ when :folder_rm
184
+ res = client.delete_folder(parsed[:id])
185
+ say.call("deleted folder ##{res[:id]}\n")
186
+ 0
187
+ when :folder_show
188
+ out.print("#{JSON.pretty_generate(client.folder(parsed[:id]))}\n")
189
+ 0
190
+ when :update, :set
191
+ attrs = parsed[:attrs].dup
192
+ attrs[:body] = read_stdin(stdin) if parsed[:body_from_stdin]
193
+ note = parsed[:command] == :update ? client.update(parsed[:id], **attrs) : client.set(parsed[:filename], **attrs)
194
+ say.call(parsed[:command] == :update ? "updated ##{note[:id]} #{note[:filename]}\n" : "updated #{note[:filename]}\n")
195
+ 0
196
+ when :bulk
197
+ run_bulk(parsed, client, err, stdin, say)
198
+ else
199
+ 0
200
+ end
201
+ end
202
+
203
+ def run_bulk(parsed, client, err, stdin, say)
204
+ raw =
205
+ if parsed[:file]
206
+ File.read(parsed[:file])
207
+ else
208
+ piped = read_stdin(stdin)
209
+ if piped.nil? || piped.empty?
210
+ err.print("Error: bulk reads a JSON array of notes from a file arg or stdin\n")
211
+ return 2
212
+ end
213
+ piped
214
+ end
215
+ begin
216
+ json = JSON.parse(raw, symbolize_names: true)
217
+ rescue JSON::ParserError => e
218
+ err.print("Error: bulk input is not valid JSON: #{e.message}\n")
219
+ return 2
220
+ end
221
+ notes =
222
+ if json.is_a?(Array)
223
+ json
224
+ elsif json.is_a?(Hash) && json[:notes].is_a?(Array)
225
+ json[:notes]
226
+ end
227
+ if notes.nil?
228
+ err.print("Error: bulk expects a JSON array of notes, or {\"notes\":[...]}\n")
229
+ return 2
230
+ end
231
+ if notes.size > 50
232
+ err.print("Error: max 50 notes per batch (got #{notes.size}). Split the input.\n")
233
+ return 2
234
+ end
235
+ res = client.bulk(notes)
236
+ say.call("created #{res[:created].size} notes\n")
237
+ 0
238
+ end
239
+
240
+ # ---- offline crypto (no API call, no token) ----
241
+
242
+ def run_crypto(command, stdin, out, err, env)
243
+ begin
244
+ pass = passphrase!(env)
245
+ rescue StandardError => e
246
+ err.print("Error: #{e.message}\n")
247
+ return 1
248
+ end
249
+ input = read_stdin(stdin)
250
+ if input.nil? || input.empty?
251
+ err.print("Error: #{command} reads #{command == :encrypt ? 'plaintext' : 'ciphertext'} on stdin\n")
252
+ return 2
253
+ end
254
+ begin
255
+ out.print(command == :encrypt ? Freshjots.encrypt(input, pass) : decrypt_body(input, pass))
256
+ 0
257
+ rescue StandardError => e
258
+ err.print("Error: #{e.message}\n")
259
+ 1
260
+ end
261
+ end
262
+
263
+ # ---- argument parsing (pure) ----
264
+
265
+ def parse_args(argv)
266
+ return { command: :help, exit_code: 2 } if argv.empty?
267
+
268
+ first, *rest = argv
269
+ return { command: :help, exit_code: 0 } if %w[-h --help help].include?(first)
270
+ return { command: :version } if %w[-v --version version].include?(first)
271
+
272
+ case first
273
+ when "list", "ls" then parse_list(rest)
274
+ when "get"
275
+ return err_result("get requires exactly one <id>") if rest.length != 1
276
+ { command: :get, id: rest[0] }
277
+ when "show", "cat"
278
+ decrypt = false
279
+ positional = []
280
+ rest.each { |a| a == "--decrypt" ? (decrypt = true) : (positional << a) }
281
+ return err_result("#{first} requires exactly one <id|filename>") if positional.length != 1
282
+ { command: :show, target: positional[0], decrypt: decrypt }
283
+ when "create" then parse_create(rest)
284
+ when "append" then parse_append(rest)
285
+ when "rm", "delete"
286
+ return err_result("#{first} requires exactly one <id|filename>") if rest.length != 1
287
+ { command: :rm, target: rest[0] }
288
+ when "mv", "move"
289
+ return err_result("#{first} requires <id|filename> <folder-id|name|--root>") if rest.length != 2
290
+ { command: :mv, target: rest[0], dest: rest[1] }
291
+ when "folders"
292
+ return err_result("folders takes no arguments") unless rest.empty?
293
+ { command: :folders }
294
+ when "folder" then parse_folder(rest)
295
+ when "update"
296
+ return err_result("update requires <note-id> and at least one field flag") if rest.empty?
297
+ attrs = parse_note_attrs(rest[1..])
298
+ return err_result(attrs[:error]) if attrs[:error]
299
+ { command: :update, id: rest[0], attrs: attrs[:attrs], body_from_stdin: attrs[:body_from_stdin] }
300
+ when "set"
301
+ return err_result("set requires <filename> and at least one field flag") if rest.empty?
302
+ attrs = parse_note_attrs(rest[1..])
303
+ return err_result(attrs[:error]) if attrs[:error]
304
+ { command: :set, filename: rest[0], attrs: attrs[:attrs], body_from_stdin: attrs[:body_from_stdin] }
305
+ when "bulk"
306
+ files = rest.reject { |a| a == "-" }
307
+ return err_result("usage: freshjots bulk [file.json] (or pipe JSON on stdin)") if files.length > 1
308
+ { command: :bulk, file: files[0] }
309
+ when "encrypt", "decrypt"
310
+ return err_result("#{first} takes no arguments (reads stdin)") unless rest.empty?
311
+ { command: first.to_sym }
312
+ else
313
+ err_result("unknown command: #{first}")
314
+ end
315
+ end
316
+
317
+ def parse_list(rest)
318
+ opts = { command: :list, limit: nil, sort: nil, folder: nil, all: false, long: false }
319
+ i = 0
320
+ while i < rest.length
321
+ a = rest[i]
322
+ case a
323
+ when "-n", "--limit"
324
+ return err_result("--limit requires a value") if i + 1 >= rest.length
325
+ opts[:limit] = rest[i += 1]
326
+ when "--sort"
327
+ return err_result("--sort requires a value") if i + 1 >= rest.length
328
+ opts[:sort] = rest[i += 1]
329
+ when "--folder"
330
+ return err_result("--folder requires a value") if i + 1 >= rest.length
331
+ opts[:folder] = rest[i += 1]
332
+ when "--root" then opts[:folder] = "none"
333
+ when "--all" then opts[:all] = true
334
+ when "-l", "--long" then opts[:long] = true
335
+ else return err_result("unknown flag for list: #{a}")
336
+ end
337
+ i += 1
338
+ end
339
+ opts
340
+ end
341
+
342
+ def parse_create(rest)
343
+ body = nil
344
+ encrypt = false
345
+ positional = []
346
+ i = 0
347
+ while i < rest.length
348
+ a = rest[i]
349
+ if a == "--body" || a == "-b"
350
+ return err_result("--body requires a value") if i + 1 >= rest.length
351
+ body = rest[i += 1]
352
+ elsif a.start_with?("--body=")
353
+ body = a.delete_prefix("--body=")
354
+ elsif a == "--encrypt"
355
+ encrypt = true
356
+ else
357
+ positional << a
358
+ end
359
+ i += 1
360
+ end
361
+ return err_result("create requires <title> and an optional <body>") if positional.empty? || positional.length > 2
362
+ if positional.length == 2
363
+ return err_result("create: pass the body positionally or with --body, not both") unless body.nil?
364
+ body = positional[1]
365
+ end
366
+ { command: :create, title: positional[0], body: body, encrypt: encrypt }
367
+ end
368
+
369
+ def parse_append(rest)
370
+ encrypt = false
371
+ positional = []
372
+ rest.each { |a| a == "--encrypt" ? (encrypt = true) : (positional << a) }
373
+ return err_result("append requires <filename> and optional <text>") if positional.empty? || positional.length > 2
374
+ { command: :append, filename: positional[0], text: positional[1], encrypt: encrypt }
375
+ end
376
+
377
+ def parse_folder(rest)
378
+ sub = rest[0]
379
+ if sub.nil? || sub == "ls" || sub == "list"
380
+ return err_result("usage: freshjots folder ls") if rest.length > 1
381
+ return { command: :folders }
382
+ end
383
+ if sub == "create" || sub == "new"
384
+ return err_result("usage: freshjots folder create <name>") if rest.length != 2 || rest[1].to_s.empty?
385
+ return { command: :folder_create, name: rest[1] }
386
+ end
387
+ if sub == "rename"
388
+ return err_result("usage: freshjots folder rename <id> <new-name>") if rest.length != 3 || rest[1].to_s.empty? || rest[2].to_s.empty?
389
+ return { command: :folder_rename, id: rest[1], name: rest[2] }
390
+ end
391
+ if sub == "rm" || sub == "delete"
392
+ return err_result("usage: freshjots folder rm <id>") if rest.length != 2 || rest[1].to_s.empty?
393
+ return { command: :folder_rm, id: rest[1] }
394
+ end
395
+ return err_result("usage: freshjots folder <id>") if rest.length != 1
396
+ { command: :folder_show, id: sub }
397
+ end
398
+
399
+ # Parse the shared update/set flags into friendly kwargs for Client#update /
400
+ # #set (only the keys passed are sent). `-` marks the body as coming from
401
+ # stdin (resolved by dispatch). Returns { attrs:, body_from_stdin: } or
402
+ # { error: }.
403
+ def parse_note_attrs(args)
404
+ attrs = {}
405
+ body_from_stdin = false
406
+ has_title = false
407
+ has_body = false
408
+ i = 0
409
+ while i < args.length
410
+ a = args[i]
411
+ case a
412
+ when "--title"
413
+ v = args[i + 1]
414
+ return { error: "--title requires a value" } if v.nil?
415
+ attrs[:title] = v; has_title = true; i += 1
416
+ when "--body", "-b"
417
+ v = args[i + 1]
418
+ return { error: "--body requires a value" } if v.nil?
419
+ attrs[:body] = v; has_body = true; i += 1
420
+ when "-"
421
+ body_from_stdin = true; has_body = true
422
+ when "--folder"
423
+ v = args[i + 1]
424
+ return { error: "--folder requires a value" } if v.nil?
425
+ return { error: "--folder requires a numeric folder id (or use --root)" } unless v.match?(/\A\d+\z/)
426
+ attrs[:folder] = v; i += 1
427
+ when "--root"
428
+ attrs[:root] = true
429
+ when "--deadline"
430
+ v = args[i + 1]
431
+ return { error: "--deadline requires a value" } if v.nil?
432
+ return { error: "--deadline requires a number of hours" } unless v.match?(/\A\d+\z/)
433
+ attrs[:deadline] = v.to_i; i += 1
434
+ when "--alert-email"
435
+ v = args[i + 1]
436
+ return { error: "--alert-email requires a value" } if v.nil?
437
+ attrs[:alert_email] = v; i += 1
438
+ when "--webhook-url"
439
+ v = args[i + 1]
440
+ return { error: "--webhook-url requires a value" } if v.nil?
441
+ attrs[:webhook_url] = v; i += 1
442
+ when "--webhook-secret"
443
+ v = args[i + 1]
444
+ return { error: "--webhook-secret requires a value" } if v.nil?
445
+ attrs[:webhook_secret] = v; i += 1
446
+ else
447
+ return { error: "unknown flag: #{a} (append_only/format are not API-updatable)" }
448
+ end
449
+ i += 1
450
+ end
451
+ return { error: "no fields to update. See 'freshjots --help'." } if attrs.empty? && !body_from_stdin
452
+ if has_title && !has_body
453
+ return { error: "can't change the title alone: a content update rewrites the body too, so pass " \
454
+ "--body (or '-'). For metadata-only changes use --folder/--root/--deadline/" \
455
+ "--alert-email/--webhook-url/--webhook-secret (no body needed)." }
456
+ end
457
+ { attrs: attrs, body_from_stdin: body_from_stdin }
458
+ end
459
+
460
+ # ---- helpers ----
461
+
462
+ def err_result(message)
463
+ { command: :error, message: message }
464
+ end
465
+
466
+ def numeric?(value)
467
+ value.to_s.match?(/\A\d+\z/)
468
+ end
469
+
470
+ def read_stdin(stdin)
471
+ return "" if stdin.nil?
472
+ return "" if stdin.respond_to?(:tty?) && stdin.tty?
473
+
474
+ stdin.read || ""
475
+ end
476
+
477
+ def passphrase!(env)
478
+ pass = env["FRESHJOTS_PASSPHRASE"]
479
+ raise "FRESHJOTS_PASSPHRASE is not set — required for --encrypt/--decrypt" if pass.nil? || pass.empty?
480
+
481
+ pass
482
+ end
483
+
484
+ # Decrypt a note body line by line: fj1: lines are decrypted, any other line
485
+ # passes through unchanged (handles both a whole-body ciphertext and an
486
+ # append stream of one ciphertext line per entry).
487
+ def decrypt_body(body, passphrase)
488
+ body.split("\n", -1).map { |line| Freshjots.encrypted?(line) ? Freshjots.decrypt(line, passphrase) : line }.join("\n")
489
+ end
490
+
491
+ def resolve_folder_name(client, name)
492
+ matches = client.folders.select { |f| f[:name] == name }
493
+ raise "no folder named '#{name}' (see: freshjots folders)" if matches.empty?
494
+ raise "ambiguous folder name '#{name}' — use its numeric id" if matches.size > 1
495
+
496
+ matches.first[:id]
497
+ end
498
+
499
+ def list_folder_filter(folder, client)
500
+ return nil if folder.nil?
501
+ return "none" if folder == "none"
502
+ return folder if numeric?(folder)
503
+
504
+ resolve_folder_name(client, folder)
505
+ end
506
+
507
+ def fetch_all_notes(client, sort, folder_id)
508
+ notes = []
509
+ offset = 0
510
+ loop do
511
+ page = client.notes(sort: sort, folder_id: folder_id, limit: 200, offset: offset)
512
+ notes.concat(page)
513
+ break if page.size < 200
514
+
515
+ offset += 200
516
+ break if offset >= 100_000
517
+ end
518
+ notes
519
+ end
520
+
521
+ # Group an integer with thousands separators ("3145719" -> "3,145,719"),
522
+ # matching the byte-count formatting the other clients produce.
523
+ def group_thousands(number)
524
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
525
+ end
526
+
527
+ def print_notes(notes, long, out)
528
+ notes.each do |n|
529
+ title = n[:title] || "(untitled)"
530
+ if long
531
+ lock = n[:append_only] ? "L" : "-"
532
+ out.print("#{n[:id]}\t#{n[:updated_at]}\t#{lock}\t#{n[:folder_id] || '-'}\t#{n[:filename]}\t#{title}\n")
533
+ else
534
+ out.print("#{n[:id]}\t#{n[:filename]}\t#{title}\n")
535
+ end
536
+ end
537
+ end
538
+ end
539
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Freshjots
4
- VERSION = "1.1.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/freshjots.rb CHANGED
@@ -34,6 +34,11 @@ module Freshjots
34
34
 
35
35
  class Client
36
36
  DEFAULT_BASE_URL = "https://freshjots.com/api/v1"
37
+ BULK_MAX = 50
38
+ # Friendly field names accepted by #update / #set, mapped to the API's
39
+ # note keys in #note_fields. append_only / format are intentionally
40
+ # absent — the API does not update them.
41
+ UPDATABLE_FIELDS = %i[title body folder root deadline alert_email webhook_url webhook_secret].freeze
37
42
 
38
43
  def initialize(token: ENV["FRESHJOTS_TOKEN"], base_url: DEFAULT_BASE_URL)
39
44
  raise ArgumentError, "FRESHJOTS_TOKEN missing — pass token: or set the env var" if token.nil? || token.empty?
@@ -89,19 +94,51 @@ module Freshjots
89
94
  # On first-touch creation, pass client_encrypted: true to open the stream
90
95
  # as a client-encrypted note (send one ciphertext line per append).
91
96
  # Ignored once the note exists.
97
+ # Returns the append envelope (top level): { created:, id:, filename:,
98
+ # bytes_remaining:, ... } — `created` is true on first-touch creation and
99
+ # false on subsequent appends.
92
100
  def append(filename, text, client_encrypted: false)
93
101
  body = { text: text }
94
102
  body[:client_encrypted] = true if client_encrypted
95
103
  request(:post, "/notes/by-filename/#{escape(filename)}/append", body)
96
- true
104
+ end
105
+
106
+ # Update a note by id. Pass any of: title:, body:, folder:, root: true,
107
+ # deadline:, alert_email:, webhook_url:, webhook_secret: — only the keys
108
+ # you pass are changed, so an unmentioned field is never clobbered. A
109
+ # content change (title/body) rewrites the body as a unit, so a title
110
+ # change needs body: too (the API requires plain_body). append_only and
111
+ # format are not updatable. Returns the updated note hash (top level).
112
+ def update(id, **fields)
113
+ request(:patch, "/notes/#{escape(id)}", { note: note_fields(fields) })
114
+ end
115
+
116
+ # Update a note addressed by its exact filename / stream name. Same
117
+ # fields as #update.
118
+ def set(filename, **fields)
119
+ request(:patch, "/notes/by-filename/#{escape(filename)}", { note: note_fields(fields) })
120
+ end
121
+
122
+ # Create up to 50 notes in one atomic batch (all land or none do).
123
+ # `notes` is an array of note hashes ({ title:, plain_body:,
124
+ # format: "plain" }). Returns the response ({ created: [...] }).
125
+ def bulk(notes)
126
+ items = Array(notes)
127
+ raise ArgumentError, "bulk requires at least one note" if items.empty?
128
+ raise ArgumentError, "bulk accepts at most #{BULK_MAX} notes (got #{items.size})" if items.size > BULK_MAX
129
+
130
+ request(:post, "/notes/bulk", { notes: items })
97
131
  end
98
132
 
99
133
  # Delete a note. Accepts a numeric id or a filename (resolved to its
100
- # id via the by-filename lookup). Locked (append-only) notes are
101
- # refused by the API with note_locked. Returns true on success.
134
+ # id via the by-filename lookup). Works on any note, including locked
135
+ # (append-only) ones the lock freezes content, not deletability.
136
+ # Returns { deleted: true, id: <numeric id> } (the API's 204 carries no
137
+ # body, so this is synthesized from the resolved id).
102
138
  def delete(id_or_filename)
103
- request(:delete, "/notes/#{resolve_note_id(id_or_filename)}")
104
- true
139
+ id = resolve_note_id(id_or_filename)
140
+ request(:delete, "/notes/#{id}")
141
+ { deleted: true, id: id.to_i }
105
142
  end
106
143
 
107
144
  # Move a note into a folder. `folder` may be a folder id, a folder
@@ -117,6 +154,28 @@ module Freshjots
117
154
  request(:get, "/folders")[:folders]
118
155
  end
119
156
 
157
+ # Fetch one folder by id (GET /folders/:id) — top-level serializer.
158
+ def folder(id)
159
+ request(:get, "/folders/#{escape(id)}")
160
+ end
161
+
162
+ # Create a folder. Returns the created folder hash (top level).
163
+ def create_folder(name)
164
+ request(:post, "/folders", { folder: { name: name } })
165
+ end
166
+
167
+ # Rename a folder. Returns the updated folder hash.
168
+ def rename_folder(id, name)
169
+ request(:patch, "/folders/#{escape(id)}", { folder: { name: name } })
170
+ end
171
+
172
+ # Delete a folder by id. Its notes survive — they just become
173
+ # un-foldered. Returns { deleted: true, id: <numeric id> }.
174
+ def delete_folder(id)
175
+ request(:delete, "/folders/#{escape(id)}")
176
+ { deleted: true, id: id.to_i }
177
+ end
178
+
120
179
  private
121
180
 
122
181
  # A note reference is either a numeric id (used as-is) or a
@@ -140,6 +199,42 @@ module Freshjots
140
199
  matches.first[:id]
141
200
  end
142
201
 
202
+ # Map the friendly #update / #set keyword fields to the API's note keys,
203
+ # sending only what the caller passed. A title change rewrites the body
204
+ # as a unit (the API requires plain_body), so a title-only change is
205
+ # refused here — mirrors the CLI. Unknown fields and an empty change are
206
+ # errors. `folder:` accepts an id or a name (resolved via /folders);
207
+ # `root: true` un-folders the note.
208
+ def note_fields(fields)
209
+ unknown = fields.keys - UPDATABLE_FIELDS
210
+ unless unknown.empty?
211
+ raise ArgumentError,
212
+ "unknown update field(s): #{unknown.join(', ')}. " \
213
+ "allowed: #{UPDATABLE_FIELDS.join(', ')} (append_only/format are not updatable)"
214
+ end
215
+ if fields.key?(:title) && !fields.key?(:body)
216
+ raise ArgumentError,
217
+ "changing the title also rewrites the body — pass body: too. For metadata " \
218
+ "only, use folder/root/deadline/alert_email/webhook_* without title."
219
+ end
220
+
221
+ note = {}
222
+ note[:title] = fields[:title] if fields.key?(:title)
223
+ note[:plain_body] = fields[:body] if fields.key?(:body)
224
+ if fields[:root]
225
+ note[:folder_id] = nil
226
+ elsif fields.key?(:folder)
227
+ note[:folder_id] = resolve_folder_id(fields[:folder])
228
+ end
229
+ note[:append_deadline_hours] = fields[:deadline] if fields.key?(:deadline)
230
+ note[:alert_email] = fields[:alert_email] if fields.key?(:alert_email)
231
+ note[:webhook_url] = fields[:webhook_url] if fields.key?(:webhook_url)
232
+ note[:webhook_secret] = fields[:webhook_secret] if fields.key?(:webhook_secret)
233
+ raise ArgumentError, "no fields to update" if note.empty?
234
+
235
+ note
236
+ end
237
+
143
238
  def request(method, path, body = nil)
144
239
  uri = URI("#{@base_url}#{path}")
145
240
  req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshjots
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Goran Arsov
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
@@ -13,13 +13,16 @@ description: Append-only notebooks for cron jobs, deploy scripts, and bots. Wrap
13
13
  the plain-text REST API at freshjots.com/api/v1.
14
14
  email:
15
15
  - arsphy@yahoo.com
16
- executables: []
16
+ executables:
17
+ - freshjots
17
18
  extensions: []
18
19
  extra_rdoc_files: []
19
20
  files:
20
21
  - LICENSE
21
22
  - README.md
23
+ - exe/freshjots
22
24
  - lib/freshjots.rb
25
+ - lib/freshjots/cli.rb
23
26
  - lib/freshjots/crypto.rb
24
27
  - lib/freshjots/version.rb
25
28
  homepage: https://github.com/Goran-Arsov/freshjots-ruby