freshjots 1.2.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 +4 -4
- data/exe/freshjots +6 -0
- data/lib/freshjots/cli.rb +539 -0
- data/lib/freshjots/version.rb +1 -1
- data/lib/freshjots.rb +10 -6
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0881261ca2139927877d9e54e9960a92c4318af920804a0324a7e9a728d9a0cd'
|
|
4
|
+
data.tar.gz: 320b4c81fa62361731c3a02e50570841519d8bcf9458d97bf8ce4478195e270b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48f118b9637d9e5beb2c418cfb8c7e91b42f2ee684e8061eadea70c4e11b5ba54f7a1179a80c3ac60fa60f11bb19d416e8edb7d5e58ea118df8c4b8c6ddcb0de
|
|
7
|
+
data.tar.gz: 3ca213be4f8684c5f1f8ef8a92b84e2b550dd9ff5228888fe7e72c326429ec024b64906285e14e8666a3854dbd0531e15e53d45c7339e65ac13d3315c2dbf5c1
|
data/exe/freshjots
ADDED
|
@@ -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
|
data/lib/freshjots/version.rb
CHANGED
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
|
|
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
|
-
|
|
137
|
-
|
|
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,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: freshjots
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Goran Arsov
|
|
8
|
-
bindir:
|
|
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
|