freshjots 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3916dec8cefe696476c18a0a28aba36a1657721082098eef1929129c05355b84
4
- data.tar.gz: 94b852d213f92017d897fe309efd21f9ecf60c6d398e0cbfe6c37af0f11219b0
3
+ metadata.gz: 1658bd31f5a326954ef023600e532e08c55802549ebe611ed39bf23d5ed5e57d
4
+ data.tar.gz: 51b0fb70da39e223a96ccdf5c642385f0a6ab9ed902a6250b5004a831aebb393
5
5
  SHA512:
6
- metadata.gz: 8c650cb3dfdb4ff2c3d4680652e9fa8ec82cfd2fa5f6605fe9e33a3f67b3b6149d46ac5b3f6aada3cb2b3a7c4c8e77d2e4ebf98c122a5957439ce2205001aa9b
7
- data.tar.gz: 1ee7ec3cace2bf20ce328f27d9ffb1ed91348eabb5fc3bf1354a9c21f09cd06707d22ca8f9beeb8dfc42b23a39bf8c873d19c0d7bc48af8089f149d659ede7e9
6
+ metadata.gz: bcd07151b0b9265b202bbc1dbcfd2a3c23b57c0b696e1ef5399f5facfb2aaca9ff66e8f77119a5fc021c9781d24498efb8dc32cfdeae50c65b618938c6e62f3c
7
+ data.tar.gz: 760cfd4a168fa97fc57c418df1ece55643a79f2d3522be0eb931baec8e27c19eaf44c1aec26cee97ade9cf46dd64efb3623da095e410d208686aefad19ddec10
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.2.0"
4
+ VERSION = "2.0.1"
5
5
  end
data/lib/freshjots.rb CHANGED
@@ -94,11 +94,13 @@ module Freshjots
94
94
  # On first-touch creation, pass client_encrypted: true to open the stream
95
95
  # as a client-encrypted note (send one ciphertext line per append).
96
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.
97
100
  def append(filename, text, client_encrypted: false)
98
101
  body = { text: text }
99
102
  body[:client_encrypted] = true if client_encrypted
100
103
  request(:post, "/notes/by-filename/#{escape(filename)}/append", body)
101
- true
102
104
  end
103
105
 
104
106
  # Update a note by id. Pass any of: title:, body:, folder:, root: true,
@@ -131,10 +133,12 @@ module Freshjots
131
133
  # Delete a note. Accepts a numeric id or a filename (resolved to its
132
134
  # id via the by-filename lookup). Works on any note, including locked
133
135
  # (append-only) ones — the lock freezes content, not deletability.
134
- # Returns true on success.
136
+ # Returns { deleted: true, id: <numeric id> } (the API's 204 carries no
137
+ # body, so this is synthesized from the resolved id).
135
138
  def delete(id_or_filename)
136
- request(:delete, "/notes/#{resolve_note_id(id_or_filename)}")
137
- true
139
+ id = resolve_note_id(id_or_filename)
140
+ request(:delete, "/notes/#{id}")
141
+ { deleted: true, id: id.to_i }
138
142
  end
139
143
 
140
144
  # Move a note into a folder. `folder` may be a folder id, a folder
@@ -166,10 +170,10 @@ module Freshjots
166
170
  end
167
171
 
168
172
  # Delete a folder by id. Its notes survive — they just become
169
- # un-foldered. Returns true.
173
+ # un-foldered. Returns { deleted: true, id: <numeric id> }.
170
174
  def delete_folder(id)
171
175
  request(:delete, "/folders/#{escape(id)}")
172
- true
176
+ { deleted: true, id: id.to_i }
173
177
  end
174
178
 
175
179
  private
metadata CHANGED
@@ -1,25 +1,42 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshjots
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.1
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
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  description: Append-only notebooks for cron jobs, deploy scripts, and bots. Wraps
13
27
  the plain-text REST API at freshjots.com/api/v1.
14
28
  email:
15
29
  - arsphy@yahoo.com
16
- executables: []
30
+ executables:
31
+ - freshjots
17
32
  extensions: []
18
33
  extra_rdoc_files: []
19
34
  files:
20
35
  - LICENSE
21
36
  - README.md
37
+ - exe/freshjots
22
38
  - lib/freshjots.rb
39
+ - lib/freshjots/cli.rb
23
40
  - lib/freshjots/crypto.rb
24
41
  - lib/freshjots/version.rb
25
42
  homepage: https://github.com/Goran-Arsov/freshjots-ruby