joplin 1.1.0 → 1.2.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.
data/bin/jp ADDED
@@ -0,0 +1,4158 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'sqlite3'
4
+ require 'date'
5
+ require 'set'
6
+ require 'thor'
7
+ require 'colorize'
8
+ require 'cgi'
9
+ require 'uri'
10
+ require 'io/console'
11
+ require 'stringio'
12
+ require 'shellwords'
13
+ require 'json'
14
+
15
+ # Polyfill for CGI.parse which was removed in Ruby 4.x
16
+ unless CGI.respond_to?(:parse)
17
+ def CGI.parse(query)
18
+ return {} if query.nil? || query.empty?
19
+ URI.decode_www_form(query).each_with_object({}) do |(k, v), h|
20
+ (h[k] ||= []) << v
21
+ end
22
+ end
23
+ end
24
+ require 'fileutils'
25
+ require 'tempfile'
26
+ require 'tmpdir'
27
+ require 'tty-markdown'
28
+ require 'kramdown/converter/toc'
29
+ require 'yaml'
30
+ require 'securerandom'
31
+ require 'zlib'
32
+
33
+ # Disable colorization if not outputting to a TTY (for piping)
34
+ String.disable_colorization = true unless STDOUT.tty?
35
+ PRG = File.basename $PROGRAM_NAME
36
+
37
+ # Simple Tee class to duplicate output
38
+ class Tee
39
+ def initialize(captured, original)
40
+ @captured = captured
41
+ @original = original
42
+ end
43
+ def write(data); @captured.write(data); @original.write(data); end
44
+ def puts(*args); @captured.puts(*args); @original.puts(*args); end
45
+ def print(*args); @captured.print(*args); @original.print(*args); end
46
+ def flush; @captured.flush; @original.flush; end
47
+ def tty?; @original.tty?; end
48
+ end
49
+
50
+ class TestInput < StringIO
51
+ def initialize(content = "", tty: false)
52
+ super(content)
53
+ @tty = tty
54
+ end
55
+
56
+ def tty?; @tty; end
57
+ end
58
+
59
+ # In-process test runner
60
+ class TestRunner
61
+ attr_reader :cli, :stdout_io, :stderr_io, :dir
62
+
63
+ def initialize(debug: false)
64
+ @dir = Dir.mktmpdir("jp_test")
65
+ @db_path = File.join(@dir, "database.sqlite")
66
+
67
+ db = SQLite3::Database.new(@db_path)
68
+ db.execute_batch <<-SQL
69
+ CREATE TABLE folders (id TEXT PRIMARY KEY, title TEXT, parent_id TEXT NOT NULL DEFAULT "", deleted_time INT NOT NULL DEFAULT 0);
70
+ CREATE TABLE notes (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL DEFAULT "", title TEXT, body TEXT, created_time INT, updated_time INT, latitude REAL, longitude REAL, source_url TEXT, deleted_time INT NOT NULL DEFAULT 0);
71
+ CREATE TABLE tags (id TEXT PRIMARY KEY, title TEXT);
72
+ CREATE TABLE note_tags (id TEXT PRIMARY KEY, note_id TEXT, tag_id TEXT);
73
+ SQL
74
+ db.close
75
+
76
+ @old_joplin_data_dir = ENV['JOPLIN_DATA_DIR']
77
+ ENV['JOPLIN_DATA_DIR'] = @dir
78
+
79
+ @old_stdout = $stdout
80
+ @old_stderr = $stderr
81
+ @stdout_io = StringIO.new
82
+ @stderr_io = StringIO.new
83
+
84
+ if debug
85
+ $stdout = Tee.new(@stdout_io, @old_stdout)
86
+ $stderr = Tee.new(@stderr_io, @old_stderr)
87
+ else
88
+ $stdout = @stdout_io
89
+ $stderr = @stderr_io
90
+ end
91
+
92
+ @cli = CLI.new
93
+ String.disable_colorization = false
94
+ end
95
+
96
+ def db
97
+ @cli.instance_variable_get(:@db)
98
+ end
99
+
100
+ def prepare_yaml(target)
101
+ @cli.send(:prepare_yaml_edit_data_for_target, target)
102
+ end
103
+
104
+ def apply_yaml(init, newd)
105
+ @cli.send(:apply_yaml_changes, init, newd)
106
+ end
107
+
108
+ def expand_toc(body)
109
+ @cli.send(:expand_toc_markers, body)
110
+ end
111
+
112
+ def should_page(output, raw: false, no_pager: false, tty: true, rows: 24, columns: 80)
113
+ @cli.send(:should_page_output?, output, raw: raw, no_pager: no_pager, tty: tty, rows: rows, columns: columns)
114
+ end
115
+
116
+ def settings_path
117
+ File.join(@dir, "settings.json")
118
+ end
119
+
120
+ def format_settings(data, color: true)
121
+ @cli.send(:format_settings_list, data, color: color)
122
+ end
123
+
124
+ def run_cmd(argv, stdin: nil, stdin_tty: false)
125
+ args = argv.is_a?(Array) ? argv : argv.to_s.split(' ')
126
+ old_stdin = $stdin
127
+ $stdin = TestInput.new(stdin || "", tty: stdin_tty) unless stdin.nil?
128
+ begin
129
+ CLI.start(args)
130
+ ensure
131
+ Thread.current[:jp_args] = nil
132
+ Thread.current[:jp_setting_overrides] = nil
133
+ $stdin = old_stdin
134
+ flush_output
135
+ end
136
+ end
137
+
138
+ def flush_output
139
+ @stdout_io.rewind
140
+ @stderr_io.rewind
141
+ @last_out = @stdout_io.read
142
+ @last_err = @stderr_io.read
143
+ @stdout_io.truncate(0); @stdout_io.rewind
144
+ @stderr_io.truncate(0); @stderr_io.rewind
145
+ end
146
+
147
+ def last_stdout; @last_out || ""; end
148
+ def last_stderr; @last_err || ""; end
149
+
150
+ def close
151
+ $stdout = @old_stdout
152
+ $stderr = @old_stderr
153
+ ENV['JOPLIN_DATA_DIR'] = @old_joplin_data_dir
154
+ db.close rescue nil
155
+ FileUtils.remove_entry(@dir) rescue nil
156
+ end
157
+ end
158
+
159
+ module TestSuite
160
+ require 'minitest'
161
+ require 'minitest/spec'
162
+
163
+ class << self
164
+ attr_accessor :debug
165
+ def strip_ansi(str); str.gsub(/\e\[(\d+;?)*m/, ""); end
166
+ end
167
+
168
+ describe "JP CLI" do
169
+ before { runner }
170
+ let(:runner) { TestRunner.new(debug: TestSuite.debug) }
171
+ after do
172
+ out = runner.last_stdout
173
+ err = runner.last_stderr
174
+ runner.close
175
+ if !passed?
176
+ $stdout.puts "\n#{"="*30} CAPTURED OUTPUT #{"="*30}".red
177
+ $stdout.puts "STDOUT:".yellow
178
+ $stdout.puts out
179
+ unless err.empty?
180
+ $stdout.puts "\nSTDERR:".red
181
+ $stdout.puts err
182
+ end
183
+ $stdout.puts "#{"="*77}".red
184
+ end
185
+ end
186
+
187
+ describe "ls overview" do
188
+ it "lists only top-level notebooks and all tags" do
189
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
190
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
191
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'urgent')")
192
+ runner.run_cmd("ls")
193
+ out = TestSuite.strip_ansi(runner.last_stdout)
194
+ assert_includes out, "Work"
195
+ refute_includes out, "Projects"
196
+ assert_includes out, "urgent"
197
+ end
198
+
199
+ it "shows the complete notebook hierarchy with -T" do
200
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
201
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
202
+
203
+ runner.run_cmd("ls -T")
204
+ out = TestSuite.strip_ansi(runner.last_stdout)
205
+ assert_includes out, "Work"
206
+ assert_includes out, "Projects"
207
+ end
208
+
209
+ it "lists notebook contents recursively with -R" do
210
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
211
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
212
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Work Note', 1718294400000)")
213
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n2', 'f2', 'Project Note', 1718294400000)")
214
+
215
+ runner.run_cmd("ls -R")
216
+ out = TestSuite.strip_ansi(runner.last_stdout)
217
+ assert_includes out, ".:"
218
+ assert_includes out, "Work:"
219
+ assert_includes out, "Work/Projects:"
220
+ assert_includes out, "Work Note"
221
+ assert_includes out, "Project Note"
222
+ refute_match /[├└]──/, out
223
+ end
224
+
225
+ it "shows all notebooks as an alphabetically sorted flat list with -a" do
226
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'work')")
227
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f2', 'Archive')")
228
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f3', 'zeta', 'f1')")
229
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f4', 'Alpha', 'f1')")
230
+
231
+ runner.run_cmd("ls -a -n")
232
+ out = TestSuite.strip_ansi(runner.last_stdout)
233
+ expected = ["Alpha", "Archive", "work", "zeta"]
234
+ expected.each { |title| assert_includes out, title }
235
+ refute_includes out, "work/Alpha"
236
+ positions = expected.map { |title| out.index(title) }
237
+ assert_equal positions.sort, positions
238
+ end
239
+
240
+ it "sorts top-level notebooks case-insensitively" do
241
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'zebra')")
242
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f2', 'Alpha')")
243
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f3', 'beta')")
244
+
245
+ runner.run_cmd("ls -n -l")
246
+ out = TestSuite.strip_ansi(runner.last_stdout)
247
+ positions = ["Alpha", "beta", "zebra"].map { |title| out.index(title) }
248
+ assert_equal positions.sort, positions
249
+ end
250
+
251
+ it "shows note counts instead of notebook and tag IDs with -l" do
252
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('folder123', 'Work')")
253
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n1', 'folder123', 'One')")
254
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n2', 'folder123', 'Two')")
255
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('tag123', 'urgent')")
256
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 'tag123')")
257
+
258
+ runner.run_cmd("ls -l")
259
+ out = TestSuite.strip_ansi(runner.last_stdout)
260
+ assert_includes out, "Work [2]"
261
+ assert_includes out, "urgent [1]"
262
+ refute_includes out, "folde"
263
+ refute_includes out, "tag12"
264
+ end
265
+
266
+ it "respects -t and -n" do
267
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
268
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'urgent')")
269
+
270
+ runner.run_cmd("ls -t")
271
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "urgent"
272
+ refute_includes TestSuite.strip_ansi(runner.last_stdout), "Work"
273
+
274
+ runner.run_cmd("ls -n")
275
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Work"
276
+ refute_includes TestSuite.strip_ansi(runner.last_stdout), "urgent"
277
+ end
278
+ end
279
+
280
+ describe "settings" do
281
+ it "is registered as a Thor subcommand" do
282
+ assert_includes CLI.subcommands, "settings"
283
+ %w[get list set unset].each { |command| assert_includes Settings.commands, command }
284
+ end
285
+
286
+ it "populates jp.pager during ordinary commands when it is missing" do
287
+ FileUtils.rm_f(runner.settings_path)
288
+
289
+ runner.run_cmd("ls")
290
+
291
+ settings = JSON.parse(File.read(runner.settings_path))
292
+ assert_equal "less -R", settings["jp.pager"]
293
+ assert_equal false, settings["jp.readOnly"]
294
+ assert_equal 10, settings["jp.backupKeep"]
295
+ assert_equal true, settings["jp.backupAuto"]
296
+ assert_equal 24, settings["jp.backupAutoInterval"]
297
+ end
298
+
299
+ it "adds the default pager to the existing Joplin settings file" do
300
+ File.write(runner.settings_path, JSON.pretty_generate(
301
+ "sync.target" => 2,
302
+ "spellChecker.languages" => ["en-US", "vi-VN"]
303
+ ))
304
+
305
+ runner.run_cmd("settings list")
306
+
307
+ settings = JSON.parse(File.read(runner.settings_path))
308
+ assert_equal 2, settings["sync.target"]
309
+ assert_equal "less -R", settings["jp.pager"]
310
+ assert_equal false, settings["jp.readOnly"]
311
+ assert_equal 10, settings["jp.backupKeep"]
312
+ assert_equal true, settings["jp.backupAuto"]
313
+ assert_equal 24, settings["jp.backupAutoInterval"]
314
+ assert_includes runner.last_stdout, "jp.pager: less -R"
315
+ assert_includes runner.last_stdout, "jp.readOnly: false"
316
+ assert_includes runner.last_stdout, "jp.backupKeep: 10"
317
+ assert_includes runner.last_stdout, "jp.backupAuto: true"
318
+ assert_includes runner.last_stdout, "jp.backupAutoInterval: 24"
319
+ assert_includes runner.last_stdout, "sync.target: 2"
320
+ assert_includes runner.last_stdout, "spellChecker.languages:\n- en-US\n- vi-VN"
321
+ refute_includes runner.last_stdout, "---"
322
+ refute_includes runner.last_stdout, "{"
323
+ end
324
+
325
+ it "colorizes YAML settings for terminal output" do
326
+ output = runner.format_settings({ "jp.pager" => "less -R", "sync.target" => 2 })
327
+
328
+ assert_includes output, "jp.pager".cyan
329
+ assert_includes output, ":".colorize(mode: :dim)
330
+ assert_includes output, "less -R".green
331
+ assert_includes output, "2".green
332
+ assert_equal "jp.pager: less -R\nsync.target: 2\n", TestSuite.strip_ansi(output)
333
+ end
334
+
335
+ it "gets, sets, and unsets settings while preserving value types" do
336
+ runner.run_cmd(["settings", "set", "jp.pager", "false"])
337
+ assert_equal false, JSON.parse(File.read(runner.settings_path))["jp.pager"]
338
+
339
+ runner.run_cmd("settings get jp.pager")
340
+ assert_equal "false\n", runner.last_stdout
341
+
342
+ runner.run_cmd(["settings", "set", "example.name", "some value"])
343
+ runner.run_cmd("settings get example.name")
344
+ assert_equal "some value\n", runner.last_stdout
345
+
346
+ runner.run_cmd(["settings", "set", "jp.readOnly", "true"])
347
+ assert_equal true, JSON.parse(File.read(runner.settings_path))["jp.readOnly"]
348
+
349
+ runner.run_cmd(["settings", "set", "jp.backupKeep", "3"])
350
+ assert_equal 3, JSON.parse(File.read(runner.settings_path))["jp.backupKeep"]
351
+
352
+ runner.run_cmd(["settings", "set", "jp.backupAuto", "false"])
353
+ assert_equal false, JSON.parse(File.read(runner.settings_path))["jp.backupAuto"]
354
+
355
+ runner.run_cmd(["settings", "set", "jp.backupAutoInterval", "0"])
356
+ assert_equal 0, JSON.parse(File.read(runner.settings_path))["jp.backupAutoInterval"]
357
+
358
+ runner.run_cmd("settings unset example.name")
359
+ refute JSON.parse(File.read(runner.settings_path)).key?("example.name")
360
+ end
361
+
362
+ it "allows disabling read-only mode through settings" do
363
+ runner.run_cmd(["settings", "set", "jp.readOnly", "true"])
364
+ runner.run_cmd(["settings", "set", "jp.readOnly", "false"])
365
+
366
+ assert_equal false, JSON.parse(File.read(runner.settings_path))["jp.readOnly"]
367
+ end
368
+
369
+ it "blocks mutating commands when jp.readOnly is true" do
370
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f111111', 'Inbox')")
371
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body, deleted_time) VALUES ('n111111', 'f111111', 'Plan', 'Body', 0)")
372
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('n222222', 'Deleted', 1781524800000)")
373
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t111111', 'todo')")
374
+ runner.run_cmd(["settings", "set", "jp.readOnly", "true"])
375
+
376
+ runner.run_cmd(["add", "Inbox", "--title", "New", "Body"], stdin: "", stdin_tty: true)
377
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
378
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
379
+
380
+ runner.run_cmd(["mv", "Inbox", "Archive"])
381
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
382
+ assert_equal "Inbox", runner.db.get_first_value("SELECT title FROM folders WHERE id = 'f111111'")
383
+
384
+ runner.run_cmd(["edit", "n1111"])
385
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
386
+ assert_equal "Body", runner.db.get_first_value("SELECT body FROM notes WHERE id = 'n111111'")
387
+
388
+ runner.run_cmd(["rm", "n1111"])
389
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
390
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n111111'")
391
+
392
+ runner.run_cmd(["journal", "2026-06-17", "blocked"])
393
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
394
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
395
+
396
+ runner.run_cmd(["trash", "restore", "n2222"])
397
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
398
+ assert_operator runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n222222'"), :>, 0
399
+
400
+ runner.run_cmd(["trash", "empty"], stdin: "yes\n", stdin_tty: true)
401
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
402
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
403
+ end
404
+
405
+ it "allows a one-shot read-only override without changing settings" do
406
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f111111', 'Inbox')")
407
+ runner.run_cmd(["settings", "set", "jp.readOnly", "true"])
408
+
409
+ runner.run_cmd(["-o", "readonly=false", "add", "Inbox", "--title", "New", "Body"], stdin: "", stdin_tty: true)
410
+
411
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Created note"
412
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
413
+ assert_equal true, JSON.parse(File.read(runner.settings_path))["jp.readOnly"]
414
+ end
415
+
416
+ it "does not let rm -f bypass read-only mode" do
417
+ runner.db.execute("INSERT INTO notes (id, title, body, deleted_time) VALUES ('n111111', 'Plan', 'Body', 0)")
418
+ runner.run_cmd(["settings", "set", "jp.readOnly", "true"])
419
+
420
+ runner.run_cmd(["rm", "-f", "n1111"])
421
+
422
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Read-only mode is enabled"
423
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n111111'")
424
+ end
425
+
426
+ it "accepts a one-shot pager override without changing settings" do
427
+ runner.run_cmd(["-o", "pager=false", "cat", "missing"])
428
+
429
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "No note found matching 'missing'"
430
+ assert_equal "less -R", JSON.parse(File.read(runner.settings_path))["jp.pager"]
431
+ end
432
+
433
+ it "rejects malformed and unsupported one-shot overrides" do
434
+ assert_raises(Thor::Error) { runner.run_cmd(["-o", "readonly", "ls"]) }
435
+ assert_raises(Thor::Error) { runner.run_cmd(["-o", "backupAuto=false", "ls"]) }
436
+ assert_raises(Thor::Error) { runner.run_cmd(["-o", "readonly=maybe", "ls"]) }
437
+ end
438
+
439
+ it "disables paging when jp.pager is false" do
440
+ runner.run_cmd(["settings", "set", "jp.pager", "false"])
441
+ long_output = (1..30).map { |n| "line #{n}" }.join("\n")
442
+
443
+ refute runner.should_page(long_output, rows: 24)
444
+ end
445
+ end
446
+
447
+ describe "version" do
448
+ it "prints only the CLI version" do
449
+ runner.run_cmd("version")
450
+
451
+ assert_equal "#{CLI::VERSION}\n", runner.last_stdout
452
+ end
453
+ end
454
+
455
+ describe "info" do
456
+ it "shows paths, version, and active item counts" do
457
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Active')")
458
+ runner.db.execute("INSERT INTO folders (id, title, deleted_time) VALUES ('f2', 'Deleted', 1)")
459
+ runner.db.execute("INSERT INTO notes (id, title) VALUES ('n1', 'Active')")
460
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('n2', 'Deleted', 1)")
461
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'tag')")
462
+ backup_epoch = Time.now.to_i - 3600
463
+ File.write(File.join(runner.dir, "joplin_#{backup_epoch}.sqlite3.gz"), "backup")
464
+
465
+ runner.run_cmd("info")
466
+ out = TestSuite.strip_ansi(runner.last_stdout)
467
+ assert_includes out, "Joplin Utility Version: #{CLI::VERSION}"
468
+ assert_includes out, "Database: #{File.join(runner.dir, 'database.sqlite')}"
469
+ assert_includes out, "Settings File: #{runner.settings_path}"
470
+ assert_includes out, "Statistics: 1 notebook, 1 note, 1 tag"
471
+ assert_includes out, "Backups: 1 files"
472
+ assert_includes out, Time.at(backup_epoch).strftime('%Y-%m-%d %H:%M:%S')
473
+ assert_includes out, "(1 hours ago)"
474
+ end
475
+ end
476
+
477
+ describe "bkup" do
478
+ it "creates a compressed database backup" do
479
+ runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('n1', 'Backup Note', 'Body')")
480
+
481
+ runner.run_cmd("bkup")
482
+
483
+ backups = Dir.glob(File.join(runner.dir, "joplin_*.sqlite3.gz"))
484
+ assert_equal 1, backups.length
485
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Backup created:"
486
+ Tempfile.create(["jp_backup_check", ".sqlite3"]) do |file|
487
+ Zlib::GzipReader.open(backups.first) { |gz| file.write(gz.read) }
488
+ file.flush
489
+ copy = SQLite3::Database.new(file.path)
490
+ assert_equal "Backup Note", copy.get_first_value("SELECT title FROM notes WHERE id = 'n1'")
491
+ copy.close
492
+ end
493
+ end
494
+
495
+ it "rotates old backups using jp.backupKeep" do
496
+ runner.run_cmd(["settings", "set", "jp.backupKeep", "2"])
497
+ old1 = File.join(runner.dir, "joplin_100.sqlite3.gz")
498
+ old2 = File.join(runner.dir, "joplin_200.sqlite3.gz")
499
+ File.write(old1, "old")
500
+ File.write(old2, "old")
501
+
502
+ runner.run_cmd("bkup")
503
+
504
+ backups = Dir.glob(File.join(runner.dir, "joplin_*.sqlite3.gz")).map { |path| File.basename(path) }
505
+ assert_equal 2, backups.length
506
+ refute_includes backups, "joplin_100.sqlite3.gz"
507
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Removed 1 old backup"
508
+ end
509
+
510
+ it "auto-backs up after successful writes when the interval has elapsed" do
511
+ runner.run_cmd(["settings", "set", "jp.backupAutoInterval", "0"])
512
+
513
+ runner.run_cmd(["add", "Inbox", "--title", "Auto", "Body"], stdin: "", stdin_tty: true)
514
+
515
+ backups = Dir.glob(File.join(runner.dir, "joplin_*.sqlite3.gz"))
516
+ assert_equal 1, backups.length
517
+ end
518
+
519
+ it "does not auto-back up when jp.backupAuto is false" do
520
+ runner.run_cmd(["settings", "set", "jp.backupAuto", "false"])
521
+
522
+ runner.run_cmd(["add", "Inbox", "--title", "No Auto", "Body"], stdin: "", stdin_tty: true)
523
+
524
+ backups = Dir.glob(File.join(runner.dir, "joplin_*.sqlite3.gz"))
525
+ assert_empty backups
526
+ end
527
+
528
+ it "skips auto-backup while the interval has not elapsed" do
529
+ existing = File.join(runner.dir, "joplin_#{Time.now.to_i}.sqlite3.gz")
530
+ File.write(existing, "recent")
531
+
532
+ runner.run_cmd(["add", "Inbox", "--title", "Recent", "Body"], stdin: "", stdin_tty: true)
533
+
534
+ backups = Dir.glob(File.join(runner.dir, "joplin_*.sqlite3.gz"))
535
+ assert_equal [existing], backups
536
+ end
537
+ end
538
+
539
+ describe "ls target" do
540
+ it "filters notebook notes by date" do
541
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
542
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Today Note', 1718294400000)")
543
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n2', 'f1', 'Old Note', 1718208000000)")
544
+
545
+ runner.run_cmd(["ls", "Work", "-d", "2024-06-13"])
546
+ out = TestSuite.strip_ansi(runner.last_stdout)
547
+
548
+ assert_includes out, "Today Note"
549
+ refute_includes out, "Old Note"
550
+ end
551
+
552
+ it "filters tagged notes by date" do
553
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('tag1', 'todo')")
554
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n1', 'Today Note', 1718294400000)")
555
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n2', 'Old Note', 1718208000000)")
556
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 'tag1')")
557
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'n2', 'tag1')")
558
+
559
+ runner.run_cmd(["ls", "todo", "-d", "2024-06-13"])
560
+ out = TestSuite.strip_ansi(runner.last_stdout)
561
+
562
+ assert_includes out, "Today Note"
563
+ refute_includes out, "Old Note"
564
+ end
565
+
566
+ it "filters long note counts by date" do
567
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
568
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('tag1', 'todo')")
569
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Today Note', 1718294400000)")
570
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n2', 'f1', 'Old Note', 1718208000000)")
571
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 'tag1')")
572
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'n2', 'tag1')")
573
+
574
+ runner.run_cmd(["ls", "-l", "-d", "2024-06-13"])
575
+ out = TestSuite.strip_ansi(runner.last_stdout)
576
+
577
+ assert_includes out, "Work [1]"
578
+ assert_includes out, "todo [1]"
579
+ end
580
+
581
+ it "recursively lists only the targeted notebook subtree with -R" do
582
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
583
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
584
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f3', 'Personal', '')")
585
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f2', 'Plan', 1718294400000)")
586
+
587
+ runner.run_cmd("ls Work -R")
588
+ out = TestSuite.strip_ansi(runner.last_stdout)
589
+ assert_includes out, "Work:"
590
+ assert_includes out, "Work/Projects:"
591
+ assert_includes out, "Plan"
592
+ refute_includes out, "Personal"
593
+ end
594
+
595
+ it "lists notes for a notebook with ID first" do
596
+ nb_id = 'nb123456789012345678901234567890'
597
+ n_id = 'n123456789012345678901234567890'
598
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('#{nb_id}', 'Journal')")
599
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('#{n_id}', '#{nb_id}', 'E1', 1718294400000)")
600
+ runner.run_cmd("ls Journal")
601
+ out = TestSuite.strip_ansi(runner.last_stdout)
602
+ assert_includes out, "Notes:"
603
+ assert_match /^n1234 /, out.split("\n").last.strip
604
+ assert_includes out, "E1"
605
+ end
606
+
607
+ it "resolves notebook paths like Journal/2026" do
608
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('p1', 'Journal', '')")
609
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('c1', '2026', 'p1')")
610
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'c1', 'Deep Note', 1718294400000)")
611
+ runner.run_cmd("ls Journal/2026")
612
+ out = TestSuite.strip_ansi(runner.last_stdout)
613
+ assert_includes out, "Notebook: Journal/2026:"
614
+ assert_includes out, "Notes:"
615
+ assert_includes out, "Deep Note"
616
+ end
617
+
618
+ it "handles multiple notebooks with the same name" do
619
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('p1', 'Work', '')")
620
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('p2', 'Home', '')")
621
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('c1', '2025', 'p1')")
622
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('c2', '2025', 'p2')")
623
+ runner.run_cmd("ls 2025")
624
+ out = TestSuite.strip_ansi(runner.last_stdout)
625
+ assert_includes out, "Notebook: Work/2025:"
626
+ assert_includes out, "Notebook: Home/2025:"
627
+ end
628
+
629
+ it "supports long format -l to show notebook note counts" do
630
+ id = 'f1234567890123456789012345678901'
631
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('#{id}', 'Work')")
632
+ runner.run_cmd("ls -l")
633
+ out = TestSuite.strip_ansi(runner.last_stdout)
634
+ assert_includes out, "Work [0]"
635
+ refute_includes out, "f1234"
636
+ end
637
+
638
+ it "targets notebooks and tags by short ID without prefixes" do
639
+ id = 'abcde123456789012345678901234567'
640
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('#{id}', 'Target')")
641
+ runner.run_cmd("ls abcde")
642
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Notebook: Target:"
643
+ end
644
+
645
+ it "lists notes for a tag" do
646
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('tag1', 'todo')")
647
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n1', 'T1', 1718294400000)")
648
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 'tag1')")
649
+ runner.run_cmd("ls todo")
650
+ out = TestSuite.strip_ansi(runner.last_stdout)
651
+ assert_includes out, "Notes tagged with 'todo'"
652
+ assert_includes out, "T1"
653
+ end
654
+
655
+ it "handles collisions and prefixes" do
656
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Health')")
657
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Note in folder', 1718294400000)")
658
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'Health')")
659
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n2', 'Note with tag', 1718294400000)")
660
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n2', 't1')")
661
+
662
+ runner.run_cmd("ls Health")
663
+ out = TestSuite.strip_ansi(runner.last_stdout)
664
+ assert_includes out, "Notebook: Health:"
665
+ assert_includes out, "Notes:"
666
+ assert_includes out, "Note in folder"
667
+ assert_includes out, "Notes tagged with 'Health'"
668
+ assert_includes out, "Note with tag"
669
+
670
+ runner.run_cmd("ls n:Health")
671
+ out = TestSuite.strip_ansi(runner.last_stdout)
672
+ assert_includes out, "Notebook: Health:"
673
+ assert_includes out, "Notes:"
674
+ refute_includes out, "Notes tagged with 'Health'"
675
+
676
+ runner.run_cmd("ls t:Health")
677
+ out = TestSuite.strip_ansi(runner.last_stdout)
678
+ assert_includes out, "Notes tagged with 'Health'"
679
+ refute_includes out, "Notebook: Health:"
680
+ end
681
+
682
+ it "shows notebook and tag IDs in targeted long output" do
683
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f123456', 'Health')")
684
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t123456', 'Health')")
685
+
686
+ runner.run_cmd("ls Health -l")
687
+ out = TestSuite.strip_ansi(runner.last_stdout)
688
+ assert_includes out, "Notebook: Health (f1234):"
689
+ assert_includes out, "Notes tagged with 'Health' (t1234):"
690
+ end
691
+
692
+ it "shows notebook structure/path when targeting a note id" do
693
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
694
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
695
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('e1234', 'f2', 'My Project Note', 1718294400000)")
696
+
697
+ runner.run_cmd("ls e1234")
698
+ out = TestSuite.strip_ansi(runner.last_stdout)
699
+ assert_includes out, "Note: Work/Projects/My Project Note"
700
+ end
701
+ end
702
+
703
+ describe "mv" do
704
+ it "renames a uniquely named notebook or tag" do
705
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a111111', 'Work')")
706
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('b111111', 'urgent')")
707
+
708
+ runner.run_cmd(["mv", "Work", "Office"])
709
+ runner.run_cmd(["mv", "urgent", "important"])
710
+
711
+ assert_equal "Office", runner.db.get_first_value("SELECT title FROM folders WHERE id = 'a111111'")
712
+ assert_equal "important", runner.db.get_first_value("SELECT title FROM tags WHERE id = 'b111111'")
713
+ end
714
+
715
+ it "requires a UUID when notebook names collide at different levels" do
716
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('a111111', 'Work', '')")
717
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('b111111', 'Home', '')")
718
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('c111111', 'Archive', 'a111111')")
719
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('d111111', 'Archive', 'b111111')")
720
+
721
+ runner.run_cmd(["mv", "Archive", "Old"])
722
+ out = TestSuite.strip_ansi(runner.last_stdout)
723
+ assert_includes out, "Ambiguous source 'Archive'"
724
+ assert_includes out, "c1111"
725
+ assert_includes out, "d1111"
726
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM folders WHERE title = 'Archive'")
727
+
728
+ runner.run_cmd(["mv", "c1111", "Old"])
729
+ assert_equal "Old", runner.db.get_first_value("SELECT title FROM folders WHERE id = 'c111111'")
730
+ end
731
+
732
+ it "requires a UUID when a notebook and tag share a name" do
733
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a222222', 'Health')")
734
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('b222222', 'Health')")
735
+
736
+ runner.run_cmd(["mv", "Health", "Wellness"])
737
+ out = TestSuite.strip_ansi(runner.last_stdout)
738
+ assert_includes out, "Ambiguous source 'Health'"
739
+ assert_includes out, "a2222 Notebook Health"
740
+ assert_includes out, "b2222 Tag Health"
741
+
742
+ runner.run_cmd(["mv", "b2222", "Wellness"])
743
+ assert_equal "Health", runner.db.get_first_value("SELECT title FROM folders WHERE id = 'a222222'")
744
+ assert_equal "Wellness", runner.db.get_first_value("SELECT title FROM tags WHERE id = 'b222222'")
745
+ end
746
+ end
747
+
748
+ describe "rm" do
749
+ it "requires -r for notebooks and tags, even when empty" do
750
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a111111', 'EmptyNotebook')")
751
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('b111111', 'empty-tag')")
752
+
753
+ runner.run_cmd(["rm", "EmptyNotebook"], stdin: "", stdin_tty: true)
754
+ runner.run_cmd(["rm", "empty-tag"], stdin: "", stdin_tty: true)
755
+
756
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "use -r"
757
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
758
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
759
+
760
+ runner.run_cmd(["rm", "EmptyNotebook", "-r"])
761
+ runner.run_cmd(["rm", "empty-tag", "-r"])
762
+
763
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
764
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
765
+ end
766
+
767
+ it "moves a specific note to Trash without -r" do
768
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
769
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n111111', 'f1', 'Plan')")
770
+
771
+ runner.run_cmd(["rm", "Work/Plan"])
772
+
773
+ assert_operator runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n111111'"), :>, 0
774
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
775
+ end
776
+
777
+ it "prompts before trashing notebook notes and honors rejection" do
778
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a222222', 'Work')")
779
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n1', 'a222222', 'Plan')")
780
+
781
+ runner.run_cmd(["rm", "Work", "-r"], stdin: "n\n", stdin_tty: true)
782
+
783
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Move 1 note to Trash"
784
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
785
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n1'")
786
+ end
787
+
788
+ it "trashes notes in a notebook subtree after confirmation" do
789
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('a333333', 'Work', '')")
790
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('b333333', 'Projects', 'a333333')")
791
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n1', 'a333333', 'Root Note')")
792
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n2', 'b333333', 'Child Note')")
793
+
794
+ runner.run_cmd(["rm", "Work", "-r"], stdin: "yes\n", stdin_tty: true)
795
+
796
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
797
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
798
+ end
799
+
800
+ it "force-removes a tag and trashes its active notes" do
801
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('a444444', 'obsolete')")
802
+ runner.db.execute("INSERT INTO notes (id, title) VALUES ('n1', 'Tagged')")
803
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 'a444444')")
804
+
805
+ runner.run_cmd(["rm", "obsolete", "-rf"])
806
+
807
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
808
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM note_tags")
809
+ assert_operator runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n1'"), :>, 0
810
+ end
811
+
812
+ it "requires a UUID for ambiguous notebook and tag names" do
813
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a555555', 'Archive')")
814
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('b555555', 'Archive')")
815
+
816
+ runner.run_cmd(["rm", "Archive", "-rf"])
817
+ out = TestSuite.strip_ansi(runner.last_stdout)
818
+ assert_includes out, "Ambiguous target 'Archive'"
819
+ assert_includes out, "a5555 Notebook Archive"
820
+ assert_includes out, "b5555 Tag Archive"
821
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
822
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
823
+ end
824
+
825
+ it "requires a UUID when a note collides with a notebook or tag name" do
826
+ runner.db.execute("INSERT INTO notes (id, title) VALUES ('e666666', 'Health')")
827
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f666666', 'Health')")
828
+
829
+ runner.run_cmd(["rm", "Health", "-rf"])
830
+ out = TestSuite.strip_ansi(runner.last_stdout)
831
+ assert_includes out, "e6666 Note Health"
832
+ assert_includes out, "f6666 Notebook Health"
833
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e666666'")
834
+
835
+ runner.run_cmd(["rm", "e6666"])
836
+ assert_operator runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e666666'"), :>, 0
837
+ end
838
+ end
839
+
840
+ describe "trash" do
841
+ it "is registered as a Thor subcommand" do
842
+ assert_includes CLI.subcommands, "trash"
843
+ %w[empty ls restore].each { |command| assert_includes Trash.commands, command }
844
+ end
845
+
846
+ it "lists trashed notes" do
847
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
848
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, deleted_time) VALUES ('e111111', 'f1', 'Deleted Plan', 1781524800000)")
849
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, deleted_time) VALUES ('e222222', 'f1', 'Active Plan', 0)")
850
+
851
+ runner.run_cmd("trash ls")
852
+
853
+ out = TestSuite.strip_ansi(runner.last_stdout)
854
+ assert_includes out, "e1111"
855
+ assert_includes out, "Work/Deleted Plan"
856
+ refute_includes out, "Active Plan"
857
+ end
858
+
859
+ it "restores a trashed note by UUID" do
860
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e333333', 'Restore Me', 1781524800000)")
861
+
862
+ runner.run_cmd(["trash", "restore", "e3333"])
863
+
864
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e333333'")
865
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Restored note 'Restore Me'"
866
+ end
867
+
868
+ it "requires a unique UUID when restoring" do
869
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444441', 'One', 1781524800000)")
870
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444442', 'Two', 1781524800000)")
871
+
872
+ runner.run_cmd(["trash", "restore", "e4444"])
873
+
874
+ out = TestSuite.strip_ansi(runner.last_stdout)
875
+ assert_includes out, "Ambiguous trashed note"
876
+ assert_includes out, "e4444 One"
877
+ assert_includes out, "e4444 Two"
878
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
879
+ end
880
+
881
+ it "empties Trash only after confirmation" do
882
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e555555', 'Delete Me', 1781524800000)")
883
+ runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e666666', 'Keep Me', 0)")
884
+
885
+ runner.run_cmd(["trash", "empty"], stdin: "n\n", stdin_tty: true)
886
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
887
+
888
+ runner.run_cmd(["trash", "empty"], stdin: "yes\n", stdin_tty: true)
889
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
890
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE id = 'e666666'")
891
+ end
892
+ end
893
+
894
+ describe "add" do
895
+ it "creates nested notebooks, a note, and merged tags" do
896
+ runner.run_cmd(["add", "Work/Projects", "--title", "Plan", "-T", "work,urgent", "-T", "urgent", "Initial", "body"], stdin: "", stdin_tty: true)
897
+
898
+ out = TestSuite.strip_ansi(runner.last_stdout)
899
+ assert_includes out, "Created note Work/Projects/Plan"
900
+ folders = runner.db.execute("SELECT id, title, parent_id FROM folders ORDER BY rowid")
901
+ assert_equal ["Work", "Projects"], folders.map { |folder| folder['title'] }
902
+ note = runner.db.get_first_row("SELECT * FROM notes WHERE title = 'Plan'")
903
+ assert_equal "Initial body", note['body']
904
+ assert_equal folders.first['id'], folders.last['parent_id']
905
+ tags = runner.db.execute("SELECT title FROM tags ORDER BY title").map { |tag| tag['title'] }
906
+ assert_equal ["urgent", "work"], tags
907
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM note_tags WHERE note_id = ?", [note['id']])
908
+ end
909
+
910
+ it "reads stdin and derives the title from the first non-empty heading" do
911
+ body = "\n# Derived Title\n\nContent\n"
912
+ runner.run_cmd(["add", "Inbox"], stdin: body)
913
+
914
+ note = runner.db.get_first_row("SELECT * FROM notes")
915
+ assert_equal "Derived Title", note['title']
916
+ assert_equal body, note['body']
917
+ end
918
+
919
+ it "rejects positional body combined with stdin" do
920
+ runner.run_cmd(["add", "Inbox", "body"], stdin: "piped body")
921
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "body cannot be supplied both"
922
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
923
+ end
924
+
925
+ it "allows duplicate note titles" do
926
+ 2.times do
927
+ runner.run_cmd(["add", "Inbox", "--title", "Same", "Body"], stdin: "", stdin_tty: true)
928
+ end
929
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE title = 'Same'")
930
+ end
931
+
932
+ it "opens a Joplin-style text editor when title and body are absent" do
933
+ old_editor = ENV['EDITOR']
934
+ ENV['EDITOR'] = "ruby -e 'File.write(ARGV[0], \"Editor Note\\nignored separator\\nEditor body\\nsecond line\")'"
935
+ begin
936
+ runner.run_cmd(["add", "Journal/2026", "-T", "daily"], stdin: "", stdin_tty: true)
937
+ ensure
938
+ ENV['EDITOR'] = old_editor
939
+ end
940
+
941
+ note = runner.db.get_first_row("SELECT * FROM notes")
942
+ assert_equal "Editor Note", note['title']
943
+ assert_equal "Editor body\nsecond line", note['body']
944
+ assert_equal "Journal/2026/Editor Note", runner.cli.send(:get_full_path_for_note, note)
945
+ assert_equal ["daily"], runner.db.execute("SELECT title FROM tags").map { |tag| tag['title'] }
946
+ end
947
+
948
+ it "starts the add editor with title and body positions" do
949
+ captured = File.join(runner.dir, 'add_editor_input')
950
+ old_editor = ENV['EDITOR']
951
+ ENV['EDITOR'] = "ruby -e 'File.write(\"#{captured}\", File.read(ARGV[0])); File.write(ARGV[0], \"Title\\n\\nBody\")'"
952
+ begin
953
+ runner.run_cmd(["add", "Inbox"], stdin: "", stdin_tty: true)
954
+ ensure
955
+ ENV['EDITOR'] = old_editor
956
+ end
957
+
958
+ assert_equal "\n\n", File.read(captured)
959
+ end
960
+
961
+ it "rejects empty piped input instead of opening an editor" do
962
+ runner.run_cmd(["add", "Inbox"], stdin: "")
963
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Empty stdin"
964
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
965
+ end
966
+
967
+ it "cancels cleanly when the editor fails" do
968
+ old_editor = ENV['EDITOR']
969
+ ENV['EDITOR'] = 'false'
970
+ begin
971
+ runner.run_cmd(["add", "Inbox"], stdin: "", stdin_tty: true)
972
+ ensure
973
+ ENV['EDITOR'] = old_editor
974
+ end
975
+
976
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Add cancelled."
977
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
978
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
979
+ end
980
+
981
+ it "rolls back notebook creation if note insertion fails" do
982
+ runner.db.execute(<<~SQL)
983
+ CREATE TRIGGER reject_note BEFORE INSERT ON notes
984
+ BEGIN
985
+ SELECT RAISE(ABORT, 'forced note failure');
986
+ END;
987
+ SQL
988
+
989
+ runner.run_cmd(["add", "New/Nested", "--title", "Plan", "Body"], stdin: "", stdin_tty: true)
990
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Could not create note"
991
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
992
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
993
+ end
994
+ end
995
+
996
+ describe "journal" do
997
+ it "creates the journal hierarchy and note for a date" do
998
+ runner.run_cmd(["journal", "2026-06-17", "first", "entry"])
999
+
1000
+ note = runner.db.get_first_row("SELECT * FROM notes")
1001
+ assert_equal "2026-06-17", note["title"]
1002
+ assert_equal "first entry", note["body"]
1003
+ assert_equal "Journal/2026/06-Jun/2026-06-17", runner.cli.send(:get_full_path_for_note, note)
1004
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Updated journal Journal/2026/06-Jun/2026-06-17"
1005
+ end
1006
+
1007
+ it "aliases j to journal" do
1008
+ runner.run_cmd(["j", "2026-06-17", "short", "entry"])
1009
+
1010
+ note = runner.db.get_first_row("SELECT * FROM notes")
1011
+ assert_equal "2026-06-17", note["title"]
1012
+ assert_equal "short entry", note["body"]
1013
+ end
1014
+
1015
+ it "appends text to an existing journal note" do
1016
+ runner.run_cmd(["journal", "2026-06-17", "first"])
1017
+ runner.run_cmd(["journal", "2026-06-17", "second"])
1018
+
1019
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
1020
+ assert_equal "first\n\nsecond", runner.db.get_first_value("SELECT body FROM notes")
1021
+ end
1022
+
1023
+ it "defaults to today when no date token is provided" do
1024
+ runner.run_cmd(["journal", "plain", "text"])
1025
+
1026
+ today = Date.today
1027
+ note = runner.db.get_first_row("SELECT * FROM notes")
1028
+ assert_equal today.strftime("%Y-%m-%d"), note["title"]
1029
+ assert_equal "plain text", note["body"]
1030
+ assert_equal "Journal/#{today.strftime('%Y')}/#{today.strftime('%m-%b')}/#{today.strftime('%Y-%m-%d')}", runner.cli.send(:get_full_path_for_note, note)
1031
+ end
1032
+
1033
+ it "opens an editor when text is empty" do
1034
+ old_editor = ENV['EDITOR']
1035
+ ENV['EDITOR'] = "ruby -e 'File.write(ARGV[0], \"editor body\")'"
1036
+ begin
1037
+ runner.run_cmd(["journal", "2026-06-17"], stdin: "", stdin_tty: true)
1038
+ ensure
1039
+ ENV['EDITOR'] = old_editor
1040
+ end
1041
+
1042
+ assert_equal "editor body", runner.db.get_first_value("SELECT body FROM notes")
1043
+ end
1044
+
1045
+ it "opens the existing journal body when editing without text" do
1046
+ runner.run_cmd(["journal", "2026-06-17", "existing body"])
1047
+ captured = File.join(runner.dir, "journal_editor_input")
1048
+ old_editor = ENV['EDITOR']
1049
+ ENV['EDITOR'] = "ruby -e 'File.write(\"#{captured}\", File.read(ARGV[0])); File.write(ARGV[0], \"existing body\\nnew line\")'"
1050
+ begin
1051
+ runner.run_cmd(["journal", "2026-06-17"], stdin: "", stdin_tty: true)
1052
+ ensure
1053
+ ENV['EDITOR'] = old_editor
1054
+ end
1055
+
1056
+ assert_equal "existing body", File.read(captured)
1057
+ assert_equal "existing body\nnew line", runner.db.get_first_value("SELECT body FROM notes")
1058
+ end
1059
+ end
1060
+
1061
+ describe "cat" do
1062
+ it "displays note body" do
1063
+ id = 'e1234567890123456789012345678901'
1064
+ body = "# Header\nContent"
1065
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('#{id}', 'Note', ?, 1718294400000)", [body])
1066
+
1067
+ runner.run_cmd("cat e1234")
1068
+ out = TestSuite.strip_ansi(runner.last_stdout)
1069
+ assert_includes out, "Note"
1070
+ assert_includes out, "Header"
1071
+ assert_includes out, "Content"
1072
+ end
1073
+
1074
+ it "displays multiple note targets in order" do
1075
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1111', 'First', 'first body', 1718294400000)")
1076
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e2222', 'Second', 'second body', 1718294400000)")
1077
+
1078
+ runner.run_cmd(["cat", "e1111", "e2222"])
1079
+ out = TestSuite.strip_ansi(runner.last_stdout)
1080
+
1081
+ assert_operator out.index("First"), :<, out.index("Second")
1082
+ assert_includes out, "first body"
1083
+ assert_includes out, "second body"
1084
+ assert_match(/first body\s*\n\nSecond/, out)
1085
+ end
1086
+
1087
+ it "reads note targets from stdin when none are provided" do
1088
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1111', 'First', 'first body', 1718294400000)")
1089
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e2222', 'Second', 'second body', 1718294400000)")
1090
+
1091
+ runner.run_cmd(["cat"], stdin: "e1111\ne2222\n")
1092
+ out = TestSuite.strip_ansi(runner.last_stdout)
1093
+
1094
+ assert_operator out.index("First"), :<, out.index("Second")
1095
+ assert_includes out, "first body"
1096
+ assert_includes out, "second body"
1097
+ end
1098
+
1099
+ it "resolves notes by path" do
1100
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1101
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body, updated_time) VALUES ('n1', 'f1', 'Task', 'Done', 1718294400000)")
1102
+ runner.run_cmd("cat Work/Task")
1103
+ assert_includes runner.last_stdout, "Done"
1104
+ end
1105
+
1106
+ it "handles ambiguous note titles" do
1107
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1108
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Home', '')")
1109
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body) VALUES ('n1', 'f1', 'Todo', 'Work task')")
1110
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body) VALUES ('n2', 'f2', 'Todo', 'Home task')")
1111
+
1112
+ runner.run_cmd("cat Todo")
1113
+ out = TestSuite.strip_ansi(runner.last_stdout)
1114
+ assert_includes out, "Ambiguous target: 'Todo' matches multiple notes"
1115
+ assert_includes out, "Work/Todo"
1116
+ assert_includes out, "Home/Todo"
1117
+ end
1118
+
1119
+ it "renders around malformed markdown link definitions" do
1120
+ body = "# Heading\n\n1. text ^[trust]\n\n[trust]: referring to sweden when talking about it recently @2024.06.17"
1121
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'Issues', ?, 1718294400000)", [body])
1122
+
1123
+ runner.run_cmd("cat e1")
1124
+ out = TestSuite.strip_ansi(runner.last_stdout)
1125
+ assert_includes out, "Heading"
1126
+ refute_includes out, "# Heading"
1127
+ assert_includes out, "1. text ^[trust]"
1128
+ assert_includes out, "[trust]: referring to sweden when talking about it recently @2024.06.17"
1129
+ end
1130
+
1131
+ it "supports raw output" do
1132
+ body = "# Heading\n\n**bold**"
1133
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'Raw Note', ?, 1718294400000)", [body])
1134
+
1135
+ runner.run_cmd("cat e1 -r")
1136
+ out = TestSuite.strip_ansi(runner.last_stdout)
1137
+ assert_equal "#{body}\n", out
1138
+ refute_includes out, "Raw Note"
1139
+ refute_includes out, "e1"
1140
+ end
1141
+
1142
+ it "prints multiple raw note bodies separated by a blank line" do
1143
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1111', 'First', 'one', 1718294400000)")
1144
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e2222', 'Second', 'two', 1718294400000)")
1145
+
1146
+ runner.run_cmd(["cat", "e1111", "e2222", "-r"])
1147
+
1148
+ assert_equal "one\n\ntwo\n", TestSuite.strip_ansi(runner.last_stdout)
1149
+ end
1150
+
1151
+ it "splits single-line stdin targets on whitespace" do
1152
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1111', 'First', 'one', 1718294400000)")
1153
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e2222', 'Second', 'two', 1718294400000)")
1154
+
1155
+ runner.run_cmd(["cat", "-r"], stdin: "e1111 e2222\n")
1156
+
1157
+ assert_equal "one\n\ntwo\n", TestSuite.strip_ansi(runner.last_stdout)
1158
+ end
1159
+
1160
+ it "pages only long interactive rendered output" do
1161
+ long_output = (1..30).map { |n| "line #{n}" }.join("\n")
1162
+ assert runner.should_page(long_output, rows: 24)
1163
+ refute runner.should_page("short", rows: 24)
1164
+ refute runner.should_page(long_output, raw: true, rows: 24)
1165
+ refute runner.should_page(long_output, no_pager: true, rows: 24)
1166
+ refute runner.should_page(long_output, tty: false, rows: 24)
1167
+ end
1168
+
1169
+ it "accounts for wrapped terminal lines when deciding to page" do
1170
+ assert runner.should_page("a" * 81, rows: 1, columns: 80)
1171
+ end
1172
+
1173
+ it "renders Joplin resource links" do
1174
+ body = "# Attachment\n\n[document](:/64de182c41ee454ebcd9d5733346d5db)"
1175
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'Resource Note', ?, 1718294400000)", [body])
1176
+
1177
+ runner.run_cmd("cat e1")
1178
+ out = TestSuite.strip_ansi(runner.last_stdout)
1179
+ assert_includes out, "Attachment"
1180
+ assert_includes out, "document"
1181
+ end
1182
+
1183
+ it "shows existing metadata in verbose mode" do
1184
+ created_time = Time.local(2024, 6, 12, 19, 0).to_i * 1000
1185
+ updated_time = Time.local(2024, 6, 13, 19, 0).to_i * 1000
1186
+ runner.db.execute(<<~SQL, [created_time, updated_time])
1187
+ INSERT INTO notes (id, title, body, created_time, updated_time, latitude, longitude, source_url)
1188
+ VALUES ('e1', 'Metadata Note', 'Body', ?, ?, 10.7769, 106.7009, 'https://example.com/source')
1189
+ SQL
1190
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'work')")
1191
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t2', 'Urgent')")
1192
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'e1', 't1')")
1193
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'e1', 't2')")
1194
+
1195
+ runner.run_cmd("cat e1 -v")
1196
+ out = TestSuite.strip_ansi(runner.last_stdout)
1197
+ assert_includes out, "Created: 2024-06-12 19:00"
1198
+ assert_includes out, "Updated: 2024-06-13 19:00"
1199
+ assert_includes out, "Location: 10.7769, 106.7009"
1200
+ assert_includes out, "https://www.google.com/maps?q=10.7769,106.7009"
1201
+ assert_includes out, "URL: https://example.com/source"
1202
+ assert_includes out, "Tags: Urgent, work"
1203
+ end
1204
+
1205
+ it "omits missing metadata in verbose mode" do
1206
+ runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('e1', 'Plain Note', 'Body')")
1207
+
1208
+ runner.run_cmd("cat e1 -v")
1209
+ out = TestSuite.strip_ansi(runner.last_stdout)
1210
+ refute_includes out, "Created:"
1211
+ refute_includes out, "Updated:"
1212
+ refute_includes out, "Location:"
1213
+ refute_includes out, "URL:"
1214
+ refute_includes out, "Tags:"
1215
+ end
1216
+
1217
+ it "expands Joplin table of contents markers" do
1218
+ body = <<~'MARKDOWN'
1219
+ # Overview
1220
+
1221
+ [toc]
1222
+
1223
+ ## First Section
1224
+
1225
+ ### Details
1226
+
1227
+ ## Second Section
1228
+ MARKDOWN
1229
+
1230
+ expanded = runner.expand_toc(body)
1231
+ assert_includes expanded, "- Overview"
1232
+ assert_includes expanded, " - First Section"
1233
+ assert_includes expanded, " - Details"
1234
+ assert_includes expanded, " - Second Section"
1235
+ refute_includes expanded, "[toc]"
1236
+ end
1237
+
1238
+ it "supports every Joplin table of contents marker" do
1239
+ ['${toc}', '[[toc]]', '[toc]', '[[_toc_]]'].each do |marker|
1240
+ expanded = runner.expand_toc("#{marker}\n\n# Heading\n")
1241
+ assert_includes expanded, "- Heading"
1242
+ refute_includes expanded, marker
1243
+ end
1244
+ end
1245
+
1246
+ it "ignores headings inside fenced code when building a table of contents" do
1247
+ body = "[toc]\n\n# Real\n\n```markdown\n# Not A Heading\n```\n"
1248
+ expanded = runner.expand_toc(body)
1249
+ assert_includes expanded, "- Real"
1250
+ refute_includes expanded, "- Not A Heading"
1251
+ end
1252
+
1253
+ it "uses Kramdown heading parsing for Setext and inline formatting" do
1254
+ body = "[toc]\n\nMain *Title*\n============\n\n## Child `Code`\n"
1255
+ expanded = runner.expand_toc(body)
1256
+ assert_includes expanded, "- Main Title"
1257
+ assert_includes expanded, " - Child Code"
1258
+ end
1259
+ end
1260
+
1261
+ describe "random" do
1262
+ it "displays a random non-deleted note" do
1263
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'Random Note', '# Random Body', 1718294400000)")
1264
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time, deleted_time) VALUES ('e2', 'Deleted Note', 'Hidden', 1718294400000, 1)")
1265
+
1266
+ runner.run_cmd("random")
1267
+ out = TestSuite.strip_ansi(runner.last_stdout)
1268
+ assert_includes out, "Random Note"
1269
+ assert_includes out, "Random Body"
1270
+ refute_includes out, "Deleted Note"
1271
+ end
1272
+
1273
+ it "reports when there are no notes" do
1274
+ runner.run_cmd("random")
1275
+ assert_includes runner.last_stdout, "No notes found."
1276
+ end
1277
+ end
1278
+
1279
+ describe "search" do
1280
+ it "searches notes for keywords" do
1281
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n1', 'Meeting', 'Plan for project', 1718294400000)")
1282
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n2', 'Shopping', 'Buy milk', 1718294400000)")
1283
+
1284
+ runner.run_cmd("search Plan")
1285
+ out = TestSuite.strip_ansi(runner.last_stdout)
1286
+ assert_includes out, "Meeting"
1287
+ refute_includes out, "Shopping"
1288
+ end
1289
+
1290
+ it "shows snippets in verbose mode" do
1291
+ long_body = "A" * 50 + "keyword" + "B" * 50
1292
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n1', 'Note', ?, 1718294400000)", [long_body])
1293
+ runner.run_cmd("search keyword -v")
1294
+ out = TestSuite.strip_ansi(runner.last_stdout)
1295
+ assert_includes out, "Note"
1296
+ assert_includes out, "..."
1297
+ assert_includes out, "keyword"
1298
+ end
1299
+ end
1300
+
1301
+ describe "init" do
1302
+ it "is registered as a Thor subcommand" do
1303
+ assert_includes CLI.subcommands, "init"
1304
+ assert_includes Init.commands, "fish"
1305
+ refute_includes Init.commands, "bash"
1306
+ end
1307
+
1308
+ it "prints fish completion setup" do
1309
+ runner.run_cmd("init fish")
1310
+ out = runner.last_stdout
1311
+ assert_includes out, "complete -c jp"
1312
+ assert_includes out, "Override readonly or pager for this command"
1313
+ assert_includes out, "__jp_complete targets"
1314
+ assert_includes out, "jp init fish"
1315
+ end
1316
+
1317
+ it "provides machine-readable dynamic completions" do
1318
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1319
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
1320
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('n12345', 'f2', 'Plan')")
1321
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'urgent')")
1322
+
1323
+ runner.run_cmd("__complete targets")
1324
+ out = runner.last_stdout
1325
+ assert_includes out, "Work/Projects\tNotebook"
1326
+ assert_includes out, "urgent\tTag"
1327
+ assert_includes out, "n1234\tNote Work/Projects/Plan"
1328
+
1329
+ runner.run_cmd("__complete notes")
1330
+ assert_includes runner.last_stdout, "Work/Projects/Plan\tNote n1234"
1331
+
1332
+ runner.run_cmd("__complete tags")
1333
+ assert_equal "urgent\tTag\n", runner.last_stdout
1334
+ end
1335
+
1336
+ it "uses UUIDs for ambiguous mv completion candidates" do
1337
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('a111111', 'Health')")
1338
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('b111111', 'Health')")
1339
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('c111111', 'unique')")
1340
+
1341
+ runner.run_cmd("__complete movable")
1342
+ out = runner.last_stdout
1343
+ assert_includes out, "a1111\tNotebook Health"
1344
+ assert_includes out, "b1111\tTag Health"
1345
+ assert_includes out, "unique\tTag"
1346
+ refute_match /^Health\t/, out
1347
+ end
1348
+
1349
+ it "includes notes and uses UUIDs for ambiguous rm completion candidates" do
1350
+ runner.db.execute("INSERT INTO notes (id, title) VALUES ('e222222', 'Health')")
1351
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f222222', 'Health')")
1352
+ runner.db.execute("INSERT INTO notes (id, title) VALUES ('e333333', 'Unique Note')")
1353
+
1354
+ runner.run_cmd("__complete removable")
1355
+ out = runner.last_stdout
1356
+ assert_includes out, "e2222\tNote Health"
1357
+ assert_includes out, "f2222\tNotebook Health"
1358
+ assert_includes out, "Unique Note\tNote"
1359
+ end
1360
+ end
1361
+
1362
+ describe "help" do
1363
+ it "colorizes the command listing" do
1364
+ runner.run_cmd("help")
1365
+ out = runner.last_stdout
1366
+ plain = TestSuite.strip_ansi(out)
1367
+
1368
+ assert_includes plain, "Joplin Utility (v#{CLI::VERSION})"
1369
+ assert_includes plain, "Usage:"
1370
+ assert_includes plain, "jp ls [TARGET]"
1371
+ assert_includes plain, "jp journal [today|yesterday|YYYY-MM-DD] [TEXT...]"
1372
+ assert_includes out, "ls".green.bold
1373
+ refute_includes plain, "__complete"
1374
+ end
1375
+
1376
+ it "marks the completion command as hidden from Thor suggestions" do
1377
+ assert CLI.all_commands.fetch("__complete").hidden?
1378
+ end
1379
+
1380
+ it "shows nested subcommand children in the command tree" do
1381
+ runner.run_cmd("tree")
1382
+ out = TestSuite.strip_ansi(runner.last_stdout)
1383
+ assert_match /settings.*list/m, out
1384
+ assert_match /settings.*get/m, out
1385
+ assert_match /settings.*set/m, out
1386
+ assert_match /settings.*unset/m, out
1387
+ assert_match /init.*fish/m, out
1388
+ assert_match /trash.*ls/m, out
1389
+ assert_match /trash.*restore/m, out
1390
+ assert_match /trash.*empty/m, out
1391
+ end
1392
+ end
1393
+
1394
+ describe "configuration" do
1395
+ it "builds the default data dir from HOME" do
1396
+ assert_equal File.join(ENV.fetch('HOME'), '.config', 'joplin'), CLI::DEFAULT_JOPLIN_DATA_DIR
1397
+ end
1398
+
1399
+ it "uses JOPLIN_DATA_DIR for the database and settings file" do
1400
+ assert_equal File.join(runner.dir, 'database.sqlite'), runner.cli.instance_variable_get(:@db_path)
1401
+ assert_equal File.join(runner.dir, 'settings.json'), runner.settings_path
1402
+ end
1403
+ end
1404
+
1405
+ describe "cal" do
1406
+ it "shows calendar and highlights note updated_time date" do
1407
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n1', 'N1', 'B1', 1781524800000)")
1408
+
1409
+ runner.run_cmd("cal 2026-06")
1410
+ out = TestSuite.strip_ansi(runner.last_stdout)
1411
+ assert_includes out, "June 2026"
1412
+ assert_match /\b15\b/, out
1413
+ end
1414
+
1415
+ it "filters calendar highlights by tag" do
1416
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n1', 'N1', 'B1', 1781524800000)")
1417
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('n2', 'N2', 'B2', 1781611200000)")
1418
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'tagA')")
1419
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t2', 'tagB')")
1420
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 't1')")
1421
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'n2', 't2')")
1422
+
1423
+ runner.run_cmd("cal 2026-06 -i tagA")
1424
+ out = runner.last_stdout
1425
+ assert_includes out, "15".bold.yellow
1426
+ refute_includes out, "16".bold.yellow
1427
+
1428
+ runner.run_cmd("cal 2026-06 -x tagA")
1429
+ out = runner.last_stdout
1430
+ refute_includes out, "15".bold.yellow
1431
+ assert_includes out, "16".bold.yellow
1432
+ end
1433
+ end
1434
+
1435
+ describe "edit" do
1436
+ it "edits a note title and body using Joplin's text layout" do
1437
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'original title', 'original content', 1781524800000)")
1438
+
1439
+ old_editor = ENV['EDITOR']
1440
+ ENV['EDITOR'] = "ruby -e 'File.write(ARGV[0], \"updated title\\nignored separator\\nupdated content\\nsecond body line\")'"
1441
+ begin
1442
+ runner.run_cmd("edit e1")
1443
+ ensure
1444
+ ENV['EDITOR'] = old_editor
1445
+ end
1446
+
1447
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Updated note 'updated title'"
1448
+ note = runner.db.get_first_row("SELECT title, body FROM notes WHERE id = 'e1'")
1449
+ assert_equal "updated title", note['title']
1450
+ assert_equal "updated content\nsecond body line", note['body']
1451
+ end
1452
+
1453
+ it "writes title, blank separator, and body to the editor" do
1454
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'Title', 'Body', 1781524800000)")
1455
+ captured = File.join(runner.dir, 'editor_input')
1456
+ old_editor = ENV['EDITOR']
1457
+ ENV['EDITOR'] = "ruby -e 'File.write(\"#{captured}\", File.read(ARGV[0]))'"
1458
+ begin
1459
+ runner.run_cmd("edit e1")
1460
+ ensure
1461
+ ENV['EDITOR'] = old_editor
1462
+ end
1463
+
1464
+ assert_equal "Title\n\nBody", File.read(captured)
1465
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "No changes made."
1466
+ end
1467
+
1468
+ it "updates content and tags via YAML edit" do
1469
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'original title', 'original content', 1781524800000)")
1470
+
1471
+ res = runner.prepare_yaml('e1')
1472
+ assert res
1473
+ init = res[0]
1474
+ newd = Marshal.load(Marshal.dump(init))
1475
+ newd[0]['body'] = 'content v2'
1476
+ newd[0]['tags'] = ['mod_tag']
1477
+
1478
+ runner.apply_yaml(init, newd)
1479
+
1480
+ runner.run_cmd("cat e1")
1481
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "content v2"
1482
+
1483
+ rows = runner.db.execute("SELECT t.title FROM tags t JOIN note_tags nt ON t.id = nt.tag_id WHERE nt.note_id = 'e1'")
1484
+ assert_equal ['mod_tag'], rows.map { |r| r['title'] }
1485
+ end
1486
+
1487
+ it "creates new tags through the edit -y command" do
1488
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'new note', 'body', 1781524800000)")
1489
+
1490
+ old_editor = ENV['EDITOR']
1491
+ ENV['EDITOR'] = "ruby -e 'require \"yaml\"; p=ARGV[0]; d=YAML.load_file(p); d[0][\"tags\"]=[\"new_tag\", \" second_tag \"]; File.write(p,d.to_yaml)'"
1492
+ begin
1493
+ runner.run_cmd("edit e1 -y")
1494
+ ensure
1495
+ ENV['EDITOR'] = old_editor
1496
+ end
1497
+
1498
+ tags = runner.db.execute(<<~SQL).map { |row| row['title'] }
1499
+ SELECT t.title FROM tags t
1500
+ JOIN note_tags nt ON t.id = nt.tag_id
1501
+ WHERE nt.note_id = 'e1'
1502
+ ORDER BY t.title
1503
+ SQL
1504
+ assert_equal ['new_tag', 'second_tag'], tags
1505
+ end
1506
+
1507
+ it "edits a tag stack via 'edit -y'" do
1508
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e1', 'entry 1', 'body 1', 1781524800000)")
1509
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('e2', 'entry 2', 'body 2', 1781611200000)")
1510
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'stack_test')")
1511
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'e1', 't1')")
1512
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'e2', 't1')")
1513
+
1514
+ old_editor = ENV['EDITOR']
1515
+ ENV['EDITOR'] = "ruby -e 'require \"yaml\"; data = YAML.load_file(ARGV[0]); data[0][\"body\"] = \"modified 1\"; data[1][\"body\"] = \"modified 2\"; File.write(ARGV[0], data.to_yaml)'"
1516
+ begin
1517
+ runner.run_cmd("edit stack_test -y")
1518
+ ensure
1519
+ ENV['EDITOR'] = old_editor
1520
+ end
1521
+
1522
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Updated stack for tag 'stack_test'"
1523
+ runner.run_cmd("cat e1")
1524
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "modified 1"
1525
+ runner.run_cmd("cat e2")
1526
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "modified 2"
1527
+ end
1528
+ end
1529
+
1530
+ describe "log" do
1531
+ it "lists entries chronologically using 'log'" do
1532
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n123456', 'log entry 1', 1781524800000)")
1533
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n234567', 'log entry 2', 1781611200000)")
1534
+
1535
+ runner.run_cmd("log 2026-06")
1536
+ out = TestSuite.strip_ansi(runner.last_stdout)
1537
+ assert_includes out, "n1234"
1538
+ assert_includes out, "n2345"
1539
+ assert_includes out, "log entry 1"
1540
+ assert_includes out, "log entry 2"
1541
+ end
1542
+
1543
+ it "shows blue notebook names and bracketed green tags before titles with -l" do
1544
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1545
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
1546
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n123456', 'f2', 'Plan', 1781524800000)")
1547
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'urgent')")
1548
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n123456', 't1')")
1549
+
1550
+ runner.run_cmd("log 2026-06 -l")
1551
+ out = runner.last_stdout
1552
+ assert_includes out, "Projects".blue
1553
+ assert_includes out, "[#{'urgent'.green}]"
1554
+ plain = TestSuite.strip_ansi(out)
1555
+ refute_includes plain, "Work/Projects"
1556
+ assert_match /n1234 .* Projects \[urgent\] Plan/, plain
1557
+ end
1558
+
1559
+ it "supports index and range selection in log" do
1560
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n1', 'entry 1', 1781524800000)")
1561
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n2', 'entry 2', 1781611200000)")
1562
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n3', 'entry 3', 1781697600000)")
1563
+
1564
+ # Test single index
1565
+ runner.run_cmd("log [-1]")
1566
+ out = TestSuite.strip_ansi(runner.last_stdout)
1567
+ assert_includes out, "entry 3"
1568
+ refute_includes out, "entry 2"
1569
+
1570
+ # Test range
1571
+ runner.run_cmd("log [-2..-1]")
1572
+ out = TestSuite.strip_ansi(runner.last_stdout)
1573
+ assert_includes out, "entry 2"
1574
+ assert_includes out, "entry 3"
1575
+ refute_includes out, "entry 1"
1576
+ end
1577
+
1578
+ it "excludes entries with specific tags in log" do
1579
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n1', 'content to keep', 1781524800000)")
1580
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('n2', 'content to hide', 1781611200000)")
1581
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'keep')")
1582
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t2', 'hide')")
1583
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 't1')")
1584
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'n2', 't2')")
1585
+
1586
+ runner.run_cmd("log")
1587
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "content to keep"
1588
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "content to hide"
1589
+
1590
+ runner.run_cmd("log -x hide")
1591
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "content to keep"
1592
+ out = TestSuite.strip_ansi(runner.last_stdout)
1593
+ refute_includes out, "content to hide"
1594
+ end
1595
+
1596
+
1597
+ it "treats last1week the same as last7days" do
1598
+ inside = (Date.today - 6).to_time.to_i * 1000
1599
+ outside = (Date.today - 7).to_time.to_i * 1000
1600
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a1', 'inside range', ?)", [inside])
1601
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a2', 'outside range', ?)", [outside])
1602
+
1603
+ runner.run_cmd("log last7days")
1604
+ days_out = TestSuite.strip_ansi(runner.last_stdout)
1605
+ runner.run_cmd("log last1week")
1606
+ week_out = TestSuite.strip_ansi(runner.last_stdout)
1607
+
1608
+ assert_equal days_out, week_out
1609
+ assert_includes week_out, "inside range"
1610
+ refute_includes week_out, "outside range"
1611
+ end
1612
+
1613
+ it "supports rolling multi-week ranges" do
1614
+ inside = (Date.today - 13).to_time.to_i * 1000
1615
+ outside = (Date.today - 14).to_time.to_i * 1000
1616
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a1', 'inside two weeks', ?)", [inside])
1617
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a2', 'outside two weeks', ?)", [outside])
1618
+
1619
+ runner.run_cmd("log last2weeks")
1620
+ out = TestSuite.strip_ansi(runner.last_stdout)
1621
+ assert_includes out, "inside two weeks"
1622
+ refute_includes out, "outside two weeks"
1623
+ end
1624
+
1625
+ it "supports rolling month ranges" do
1626
+ anchor = Date.today << 1
1627
+ inside = anchor.to_time.to_i * 1000
1628
+ outside = (anchor - 1).to_time.to_i * 1000
1629
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a1', 'inside month', ?)", [inside])
1630
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a2', 'outside month', ?)", [outside])
1631
+
1632
+ runner.run_cmd("log last1month")
1633
+ out = TestSuite.strip_ansi(runner.last_stdout)
1634
+ assert_includes out, "inside month"
1635
+ refute_includes out, "outside month"
1636
+ end
1637
+
1638
+ it "supports rolling year ranges" do
1639
+ anchor = Date.today << 12
1640
+ inside = anchor.to_time.to_i * 1000
1641
+ outside = (anchor - 1).to_time.to_i * 1000
1642
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a1', 'inside year', ?)", [inside])
1643
+ runner.db.execute("INSERT INTO notes (id, title, updated_time) VALUES ('a2', 'outside year', ?)", [outside])
1644
+
1645
+ runner.run_cmd("log last1year")
1646
+ out = TestSuite.strip_ansi(runner.last_stdout)
1647
+ assert_includes out, "inside year"
1648
+ refute_includes out, "outside year"
1649
+ end
1650
+ end
1651
+
1652
+ describe "flashback" do
1653
+ it "shows notes from the same day in previous years" do
1654
+ target = Date.new(2026, 6, 15)
1655
+ one_year_ago = Time.local(2025, 6, 15, 10, 0).to_i * 1000
1656
+ two_years_ago = Time.local(2024, 6, 15, 11, 0).to_i * 1000
1657
+ other_day = Time.local(2025, 6, 14, 10, 0).to_i * 1000
1658
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('a1', 'One Year', 'memory one', ?)", [one_year_ago])
1659
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('a2', 'Two Years', 'memory two', ?)", [two_years_ago])
1660
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('a3', 'Other Day', 'not a flashback', ?)", [other_day])
1661
+
1662
+ runner.run_cmd("flashback #{target}")
1663
+ out = TestSuite.strip_ansi(runner.last_stdout)
1664
+ assert_includes out, "Flashbacks for June 15:"
1665
+ assert_includes out, "1 Year Ago"
1666
+ assert_includes out, "One Year"
1667
+ assert_includes out, "2 Years Ago"
1668
+ assert_includes out, "Two Years"
1669
+ refute_includes out, "Other Day"
1670
+ end
1671
+
1672
+ it "supports verbose output and tag exclusion" do
1673
+ timestamp = Time.local(2025, 6, 15, 10, 0).to_i * 1000
1674
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('a1', 'Visible', 'visible body', ?)", [timestamp])
1675
+ runner.db.execute("INSERT INTO notes (id, title, body, updated_time) VALUES ('a2', 'Hidden', 'hidden body', ?)", [timestamp])
1676
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'skip')")
1677
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'a2', 't1')")
1678
+
1679
+ runner.run_cmd("flashback 2026-06-15 -vv -x skip")
1680
+ out = TestSuite.strip_ansi(runner.last_stdout)
1681
+ assert_includes out, "Visible"
1682
+ assert_includes out, "visible body"
1683
+ refute_includes out, "Hidden"
1684
+ end
1685
+
1686
+ it "reports when no flashbacks exist" do
1687
+ runner.run_cmd("flashback 2026-06-15")
1688
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "No flashbacks found for June 15."
1689
+ end
1690
+ end
1691
+ end
1692
+ end
1693
+
1694
+ class GroupedReporter < Minitest::AbstractReporter
1695
+ attr_accessor :io, :failures, :assertions, :count, :start_time
1696
+ def initialize(io = $stdout)
1697
+ @io, @failures, @assertions, @count = io, [], 0, 0
1698
+ end
1699
+ def start; @start_time = Minitest.clock_time; io.puts "\n#{PRG.bold} Test Suite".yellow; end
1700
+ def record(result)
1701
+ @count += 1; @assertions += result.assertions
1702
+ io.puts "\n#{result.klass.to_s.blue.bold}" if @current_suite != result.klass
1703
+ @current_suite = result.klass
1704
+ name = result.name.gsub(/^test_\d+_/, '')
1705
+ if result.passed?; io.puts " #{name.ljust(60)} ✅".green
1706
+ elsif result.skipped?; io.puts " #{name.ljust(60)} ⚠️".yellow
1707
+ else; io.puts " #{name.ljust(60)} ❌".red; @failures << result; end
1708
+ end
1709
+ def report
1710
+ total_time = Minitest.clock_time - @start_time
1711
+ io.puts "\n--------------------"
1712
+ if failures.any?
1713
+ io.puts "#{@count} tests, #{@assertions} assertions, #{@failures.size} failures".red
1714
+ @failures.each { |f| io.puts "\n#{f.klass} > #{f.name}:".red; io.puts " #{f.failures.first.message}" }
1715
+ else; io.puts "#{@count} tests, #{@assertions} assertions passed!".green; end
1716
+ io.puts "Finished in #{"%.6f" % total_time}s".colorize(mode: :dim)
1717
+ end
1718
+ def passed?; @failures.empty?; end
1719
+ end
1720
+
1721
+ class Settings < Thor
1722
+ def self.exit_on_failure? = true
1723
+
1724
+ desc "list", "List settings"
1725
+ def list
1726
+ cli = CLI.new
1727
+ print cli.send(:format_settings_list, cli.send(:load_settings), color: $stdout.tty?)
1728
+ end
1729
+
1730
+ desc "get NAME", "Get a setting"
1731
+ def get(name)
1732
+ cli = CLI.new
1733
+ data = cli.send(:load_settings)
1734
+ return say "Setting not found: #{name}" unless data.key?(name)
1735
+
1736
+ puts cli.send(:format_setting_value, data[name])
1737
+ end
1738
+
1739
+ desc "set NAME VALUE", "Set a setting"
1740
+ def set(name, value)
1741
+ cli = CLI.new
1742
+ data = cli.send(:load_settings)
1743
+ parsed_value = cli.send(:parse_setting_value, value)
1744
+ cli.send(:validate_setting_value!, name, parsed_value)
1745
+ data[name] = parsed_value
1746
+ cli.send(:write_settings, data)
1747
+ end
1748
+
1749
+ desc "unset NAME", "Unset a setting"
1750
+ def unset(name)
1751
+ cli = CLI.new
1752
+ data = cli.send(:load_settings)
1753
+ data.delete(name)
1754
+ cli.send(:write_settings, data)
1755
+ end
1756
+ end
1757
+
1758
+ class Init < Thor
1759
+ def self.exit_on_failure? = true
1760
+
1761
+ desc "fish", "Print Fish shell integration code"
1762
+ def fish
1763
+ puts CLI.new.send(:fish_completion_script)
1764
+ end
1765
+ end
1766
+
1767
+ class Trash < Thor
1768
+ def self.exit_on_failure? = true
1769
+
1770
+ desc "ls", "List trashed notes"
1771
+ def ls
1772
+ CLI.new.send(:list_trash)
1773
+ end
1774
+
1775
+ desc "restore UUID", "Restore a trashed note"
1776
+ def restore(uuid)
1777
+ CLI.new.send(:restore_trash_note, uuid)
1778
+ end
1779
+
1780
+ desc "empty", "Permanently delete all trashed notes"
1781
+ def empty
1782
+ CLI.new.send(:empty_trash)
1783
+ end
1784
+ end
1785
+
1786
+ class CLI < Thor
1787
+ AddError = Class.new(StandardError)
1788
+
1789
+ def self.exit_on_failure?; true; end
1790
+
1791
+ def self.start(given_args = ARGV, config = {})
1792
+ sanitized_args, overrides = extract_runtime_overrides(given_args)
1793
+ Thread.current[:jp_args] = sanitized_args
1794
+ Thread.current[:jp_setting_overrides] = overrides
1795
+ super(sanitized_args, config)
1796
+ ensure
1797
+ Thread.current[:jp_setting_overrides] = nil
1798
+ end
1799
+
1800
+ def self.extract_runtime_overrides(given_args)
1801
+ args = Array(given_args).map(&:to_s)
1802
+ sanitized = []
1803
+ overrides = {}
1804
+ index = 0
1805
+
1806
+ while index < args.length
1807
+ argument = args[index]
1808
+ if argument == "-o" || argument == "--option"
1809
+ value = args[index + 1]
1810
+ raise Thor::Error, "#{argument} requires KEY=VALUE." if value.nil?
1811
+
1812
+ overrides.merge!(parse_runtime_override(value))
1813
+ index += 2
1814
+ elsif argument.start_with?("--option=")
1815
+ overrides.merge!(parse_runtime_override(argument.delete_prefix("--option=")))
1816
+ index += 1
1817
+ else
1818
+ sanitized << argument
1819
+ index += 1
1820
+ end
1821
+ end
1822
+
1823
+ [sanitized, overrides]
1824
+ end
1825
+
1826
+ def self.parse_runtime_override(value)
1827
+ key, raw_value = value.to_s.split("=", 2)
1828
+ raise Thor::Error, "Override must use KEY=VALUE." if key.to_s.empty? || raw_value.nil?
1829
+
1830
+ setting = normalize_runtime_override_key(key)
1831
+ parsed_value = parse_runtime_override_value(raw_value)
1832
+ validate_runtime_override_value!(setting, parsed_value)
1833
+ { setting => parsed_value }
1834
+ end
1835
+
1836
+ def self.normalize_runtime_override_key(key)
1837
+ case key
1838
+ when "readonly", "jp.readOnly"
1839
+ "jp.readOnly"
1840
+ when "pager", "jp.pager"
1841
+ "jp.pager"
1842
+ else
1843
+ raise Thor::Error, "Unsupported override: #{key}. Supported overrides: readonly, pager."
1844
+ end
1845
+ end
1846
+
1847
+ def self.parse_runtime_override_value(value)
1848
+ JSON.parse(value)
1849
+ rescue JSON::ParserError
1850
+ value
1851
+ end
1852
+
1853
+ def self.validate_runtime_override_value!(setting, value)
1854
+ case setting
1855
+ when "jp.readOnly"
1856
+ return if value == true || value == false
1857
+
1858
+ raise Thor::Error, "jp.readOnly must be true or false."
1859
+ when "jp.pager"
1860
+ return if value == false || value.is_a?(String)
1861
+
1862
+ raise Thor::Error, "jp.pager must be false or a command string."
1863
+ end
1864
+ end
1865
+
1866
+ def self.printable_commands(all = true, subcommand = false)
1867
+ super.reject { |command| command.first.include?("__complete") }
1868
+ end
1869
+
1870
+ VERSION = "0.36.1"
1871
+ DEFAULT_JOPLIN_DATA_DIR = File.join(ENV.fetch('HOME'), '.config', 'joplin')
1872
+
1873
+ def self.joplin_data_dir
1874
+ ENV.fetch('JOPLIN_DATA_DIR', DEFAULT_JOPLIN_DATA_DIR)
1875
+ end
1876
+
1877
+ desc "settings SUBCOMMAND", "Manage settings"
1878
+ subcommand "settings", Settings
1879
+
1880
+ desc "init SUBCOMMAND", "Print shell integration code"
1881
+ subcommand "init", Init
1882
+
1883
+ desc "trash SUBCOMMAND", "Manage trashed notes"
1884
+ subcommand "trash", Trash
1885
+
1886
+ def self.help(shell, subcommand = false)
1887
+ shell.say "Joplin Utility (v#{VERSION})".bold.yellow.underline
1888
+ shell.say ""
1889
+ shell.say "Usage:".yellow
1890
+ shell.say " #{PRG.cyan} " + "COMMAND ".green.bold + "[ARGS]"
1891
+ shell.say ""
1892
+ shell.say "Commands:".yellow
1893
+
1894
+ list = printable_commands(true, subcommand)
1895
+ Thor::Util.thor_classes_in(self).each do |klass|
1896
+ list += klass.printable_commands(false)
1897
+ end
1898
+ list.sort!
1899
+
1900
+ max_len = list.map { |command| command[0].length }.max || 0
1901
+ list.each do |command_full, description|
1902
+ parts = command_full.split
1903
+ program = parts.shift
1904
+ command = parts.shift
1905
+ args = parts.join(' ')
1906
+
1907
+ colored_args = args.gsub(/(\[|\]|\.\.\.)/) { |part| part.colorize(mode: :dim) }
1908
+ colored_args.gsub!(/([A-Z_]{2,})/, "\\1".cyan.colorize(mode: :dim))
1909
+
1910
+ plain_command = [program, command, args].reject(&:empty?).join(' ')
1911
+ colored_command = "#{program.cyan} #{command.green.bold}"
1912
+ colored_command += " #{colored_args}" unless args.empty?
1913
+ padding = " " * (max_len - plain_command.length)
1914
+ detail = "# #{description.gsub(/^#\s*/, '')}".colorize(mode: :dim)
1915
+
1916
+ shell.say " #{colored_command}#{padding} #{detail}"
1917
+ end
1918
+ shell.say ""
1919
+ end
1920
+
1921
+ def initialize(*args)
1922
+ super
1923
+ @joplin_data_dir = self.class.joplin_data_dir
1924
+ @db_path = File.join(@joplin_data_dir, 'database.sqlite')
1925
+
1926
+ unless File.exist?(@db_path)
1927
+ raise Thor::Error, "Joplin database not found at #{@db_path}. Please check your JOPLIN_DATA_DIR.".red
1928
+ end
1929
+
1930
+ @db = SQLite3::Database.new(@db_path)
1931
+ @db.results_as_hash = true
1932
+ load_settings unless Array(Thread.current[:jp_args]).first == "test"
1933
+ end
1934
+
1935
+ desc "version", "Show version"
1936
+ def version
1937
+ puts VERSION
1938
+ end
1939
+
1940
+ desc "info", "Show JP and Joplin profile information"
1941
+ def info
1942
+ notebooks = @db.get_first_value("SELECT COUNT(*) FROM folders WHERE deleted_time = 0")
1943
+ notes = @db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time = 0")
1944
+ tags = @db.get_first_value("SELECT COUNT(*) FROM tags")
1945
+ count_label = ->(count, noun) { "#{count} #{noun}#{count == 1 ? '' : 's'}" }
1946
+
1947
+ say "Joplin Utility Version: ".cyan + " #{VERSION}".green
1948
+ say "Database: ".cyan + " #{@db_path}".green
1949
+ say "Settings File: ".cyan + " #{settings_path}".green
1950
+ say "Statistics: ".cyan + " #{count_label.call(notebooks, 'notebook')}".white +
1951
+ ", ".green + "#{count_label.call(notes, 'note')}".white +
1952
+ ", ".green + "#{count_label.call(tags, 'tag')}".white
1953
+
1954
+ backups = backup_files
1955
+ if backups.any?
1956
+ say "Backups: ".cyan + " #{backups.size} files".green
1957
+ backups.each do |path|
1958
+ epoch = File.basename(path)[/\Ajoplin_(\d+)\.sqlite3\.gz\z/, 1].to_i
1959
+ timestamp = Time.at(epoch)
1960
+ say " #{timestamp.strftime('%Y-%m-%d %H:%M:%S')}".cyan + " " + "(#{time_ago(timestamp)})".yellow
1961
+ end
1962
+ else
1963
+ say "Backups: ".cyan + " none".yellow
1964
+ end
1965
+ end
1966
+
1967
+ desc "bkup", "Backup the database"
1968
+ def bkup
1969
+ begin
1970
+ compressed = create_backup
1971
+ say "Backup created: #{compressed.green}"
1972
+ rescue => e
1973
+ say "Backup failed: #{e.message}".red
1974
+ end
1975
+ end
1976
+
1977
+ desc "ls [TARGET]", "List notebooks, notes, or tags"
1978
+ method_option :long, aliases: "-l", type: :boolean, desc: "Show note counts"
1979
+ method_option :tree, aliases: "-T", type: :boolean
1980
+ method_option :recursive, aliases: "-R", type: :boolean, desc: "List notebook contents recursively"
1981
+ method_option :type, aliases: "-t", type: :boolean
1982
+ method_option :nb, aliases: "-n", type: :boolean
1983
+ method_option :all, aliases: "-a", type: :boolean, desc: "Show all notebooks"
1984
+ method_option :date, aliases: "-d", type: :string, desc: "Filter notes by date/shortcut"
1985
+ def ls(target = nil)
1986
+ if target.nil?
1987
+ list_overview
1988
+ else
1989
+ list_target(target)
1990
+ end
1991
+ end
1992
+
1993
+ desc "add NOTEBOOK [BODY...]", "Add a note"
1994
+ method_option :title, aliases: "-t", type: :string, desc: "Note title"
1995
+ method_option :tag, aliases: "-T", type: :string, desc: "Tag (repeatable or comma-separated)"
1996
+ def add(notebook_path, *body_parts)
1997
+ return unless ensure_writable!
1998
+
1999
+ stdin_body = nil
2000
+ unless $stdin.tty?
2001
+ stdin_body = $stdin.read
2002
+ if body_parts.any? && !stdin_body.empty?
2003
+ return say "Error: body cannot be supplied both as arguments and stdin.".red
2004
+ end
2005
+ end
2006
+
2007
+ tags = normalize_add_tags(extract_add_tag_options)
2008
+ title = options[:title].to_s.strip
2009
+ body = body_parts.any? ? body_parts.join(' ') : stdin_body
2010
+
2011
+ if title.empty? && body.nil?
2012
+ edited = edit_add_text_buffer
2013
+ return unless edited
2014
+ title, body = edited
2015
+ elsif body_parts.empty? && stdin_body&.empty?
2016
+ return say "Error: Empty stdin, nothing added.".red
2017
+ end
2018
+
2019
+ body ||= ""
2020
+ title = derive_note_title(body) if title.empty?
2021
+ return say "Error: Note title is required.".red if title.nil? || title.empty?
2022
+
2023
+ source_url ||= ""
2024
+ latitude ||= nil
2025
+ longitude ||= nil
2026
+ note = create_note_with_hierarchy(
2027
+ notebook_path: notebook_path,
2028
+ title: title,
2029
+ body: body,
2030
+ tags: tags,
2031
+ source_url: source_url,
2032
+ latitude: latitude,
2033
+ longitude: longitude
2034
+ )
2035
+
2036
+ say "Created note #{note[:path].green}"
2037
+ say " ID: #{note[:id][0..4].colorize(mode: :dim)}"
2038
+ auto_bkup_if_needed
2039
+ rescue AddError => e
2040
+ say "Error: #{e.message}".red
2041
+ end
2042
+
2043
+ desc "journal [today|yesterday|YYYY-MM-DD] [TEXT...]", "Append to a daily journal note; omit DATE for today"
2044
+ def journal(date_or_text = nil, *text_parts)
2045
+ return unless ensure_writable!
2046
+
2047
+ date, body = parse_journal_args(date_or_text, text_parts)
2048
+ replace_body = body.nil? || body.empty?
2049
+ body = edit_journal_text_buffer(date) if replace_body
2050
+ return unless body && !body.empty?
2051
+
2052
+ note = append_or_create_journal_note(date, body, append: !replace_body)
2053
+ say "Updated journal #{note[:path].green}"
2054
+ say " ID: #{note[:id][0..4].colorize(mode: :dim)}"
2055
+ auto_bkup_if_needed
2056
+ rescue AddError => e
2057
+ say "Error: #{e.message}".red
2058
+ end
2059
+ map "j" => :journal
2060
+
2061
+ desc "__complete TYPE", "Return shell completion candidates", hide: true
2062
+ def __complete(type)
2063
+ case type
2064
+ when "targets"
2065
+ completion_notebooks.each { |path| puts "#{path}\tNotebook" }
2066
+ @db.execute("SELECT title FROM tags ORDER BY title").each { |tag| puts "#{tag['title']}\tTag" }
2067
+ completion_notes.each { |note| puts "#{note['id'][0..4]}\tNote #{get_full_path_for_note(note)}" }
2068
+ when "notes"
2069
+ completion_notes.each do |note|
2070
+ puts "#{get_full_path_for_note(note)}\tNote #{note['id'][0..4]}"
2071
+ end
2072
+ when "tags"
2073
+ @db.execute("SELECT title FROM tags ORDER BY title").each { |tag| puts "#{tag['title']}\tTag" }
2074
+ when "notebooks"
2075
+ completion_notebooks.each { |path| puts "#{path}\tNotebook" }
2076
+ when "movable"
2077
+ completion_movable_items.each { |candidate, description| puts "#{candidate}\t#{description}" }
2078
+ when "removable"
2079
+ completion_removable_items.each { |candidate, description| puts "#{candidate}\t#{description}" }
2080
+ when "trashed"
2081
+ trashed_notes.each { |note| puts "#{note['id'][0..4]}\tNote #{get_full_path_for_note(note)}" }
2082
+ end
2083
+ end
2084
+
2085
+ desc "cat TARGET...", "Display note content"
2086
+ method_option :raw, aliases: "-r", type: :boolean, desc: "Display raw Markdown source"
2087
+ method_option :verbose, aliases: "-v", type: :boolean, desc: "Show note metadata"
2088
+ method_option :pager, type: :boolean, default: true, desc: "Use a pager for long output"
2089
+ def cat(*targets)
2090
+ targets = cat_targets_from_stdin if targets.empty? && !$stdin.tty?
2091
+ return say "Error: TARGET is required.".red if targets.empty?
2092
+
2093
+ displayed = 0
2094
+ targets.each do |target|
2095
+ note = resolve_cat_target(target)
2096
+ next unless note
2097
+
2098
+ puts if displayed.positive?
2099
+ display_note(note, raw: options[:raw], verbose: options[:verbose], no_pager: !options[:pager])
2100
+ displayed += 1
2101
+ end
2102
+ end
2103
+
2104
+ no_commands do
2105
+ def cat_targets_from_stdin
2106
+ input = $stdin.read
2107
+ return [] if input.nil? || input.strip.empty?
2108
+
2109
+ lines = input.lines.map(&:strip).reject(&:empty?)
2110
+ return lines unless lines.length == 1
2111
+
2112
+ lines.first.split(/\s+/)
2113
+ end
2114
+
2115
+ def resolve_cat_target(target)
2116
+ notes = find_notes(target)
2117
+ if notes.empty?
2118
+ say "No note found matching '#{target}'."
2119
+ nil
2120
+ elsif notes.size > 1
2121
+ say "Ambiguous target: '#{target}' matches multiple notes:".yellow
2122
+ notes.each do |n|
2123
+ say " #{n['id'][0..4].colorize(:mode => :dim)} #{get_full_path_for_note(n)}"
2124
+ end
2125
+ say "Use the short ID to specify."
2126
+ nil
2127
+ else
2128
+ notes.first
2129
+ end
2130
+ end
2131
+ end
2132
+
2133
+ desc "random", "Display a random note"
2134
+ def random
2135
+ note = @db.get_first_row("SELECT * FROM notes WHERE deleted_time = 0 ORDER BY RANDOM() LIMIT 1")
2136
+ return say "No notes found." unless note
2137
+
2138
+ display_note(note)
2139
+ end
2140
+
2141
+ desc "search QUERY", "Search notes for text"
2142
+ method_option :verbose, aliases: "-v", type: :boolean, desc: "Show content snippets"
2143
+ def search(*query_parts)
2144
+ query = query_parts.join(' ')
2145
+ return say "Error:".yellow + " Search query is required." if query.empty?
2146
+
2147
+ results = fetch_notes(query: query)
2148
+ if results.empty?
2149
+ say "No results found for '#{query.cyan}'."
2150
+ else
2151
+ say "Search results for '#{query}':".bold
2152
+ results.each do |note|
2153
+ ts = Time.at(note['updated_time'] / 1000).strftime('%Y-%m-%d %H:%M').colorize(mode: :dim)
2154
+ id_str = note['id'][0..4].colorize(:mode => :dim)
2155
+ # Highlight in title
2156
+ title = note['title'].gsub(/(#{Regexp.escape(query)})/i) { |m| m.red.on_yellow.bold }
2157
+ puts "#{id_str} #{ts} #{title}"
2158
+
2159
+ if options[:verbose]
2160
+ snippet = get_snippet(note['body'] || "", query)
2161
+ puts snippet if snippet
2162
+ end
2163
+ end
2164
+ end
2165
+ end
2166
+ map "grep" => :search
2167
+
2168
+ desc "cal [DATE]", "Show calendar"
2169
+ method_option :year, aliases: "-y", type: :boolean, desc: "Show full year calendar"
2170
+ method_option :include, aliases: "-i", type: :string, desc: "Filter by tags (comma-separated)"
2171
+ method_option :exclude, aliases: "-x", type: :string, desc: "Exclude entries with these tags"
2172
+ def cal(date_str = nil)
2173
+ include_tags = options[:include] ? options[:include].split(',').map(&:strip) : []
2174
+ exclude_tags = options[:exclude] ? options[:exclude].split(',').map(&:strip) : []
2175
+
2176
+ sql = "SELECT n.updated_time, (SELECT GROUP_CONCAT(t.title) FROM note_tags nt JOIN tags t ON nt.tag_id = t.id WHERE nt.note_id = n.id) as tags FROM notes n WHERE n.deleted_time = 0"
2177
+ rows = @db.execute(sql)
2178
+
2179
+ dates = rows.select do |row|
2180
+ row_tags = row['tags'].to_s.split(',')
2181
+ if include_tags.any? && !tag_matches_any?(row_tags, include_tags)
2182
+ next false
2183
+ end
2184
+ if exclude_tags.any? && tag_matches_any?(row_tags, exclude_tags)
2185
+ next false
2186
+ end
2187
+ true
2188
+ end.map do |row|
2189
+ Time.at(row['updated_time'] / 1000).strftime('%Y-%m-%d')
2190
+ end.to_set
2191
+
2192
+ # Use unified parser
2193
+ _, _, date_format, anchor_date = parse_date_query(date_str)
2194
+
2195
+ # Grid generation
2196
+ if date_format == '%Y' || options[:year]
2197
+ say "#{anchor_date.year}".center(66).bold
2198
+ months = (1..12).map { |m| get_month_data(Date.new(anchor_date.year, m, 1), dates) }
2199
+
2200
+ # Print 3 columns
2201
+ months.each_slice(3) do |slice|
2202
+ # Print titles
2203
+ slice.each { |m| print m[:title].center(22) }
2204
+ say ""
2205
+ slice.each { |_| print "Su Mo Tu We Th Fr Sa ".colorize(mode: :dim) }
2206
+ say ""
2207
+
2208
+ # Print grid rows (up to 6 rows per month)
2209
+ (0..5).each do |r|
2210
+ slice.each do |m|
2211
+ row = m[:rows][r]
2212
+ if row
2213
+ print row.join(" ") + " "
2214
+ else
2215
+ print " " * 22
2216
+ end
2217
+ end
2218
+ say ""
2219
+ end
2220
+ say ""
2221
+ end
2222
+ else
2223
+ # Single month
2224
+ m = get_month_data(anchor_date, dates)
2225
+ print_month(m, anchor_date.year)
2226
+ end
2227
+ end
2228
+
2229
+ desc "log [DATE] [SLICE]", "Show log (shortcuts: today, yesterday, week, month, year, last-week, last-month, 7days, 3hours, 1yearago)\nfor 7days or 3hours, the number can be replaced by any integer"
2230
+ method_option :verbose, aliases: "-v", type: :boolean, desc: "Show more info (-vv for IDs/updates)"
2231
+ method_option :long, aliases: "-l", type: :boolean, desc: "Alias for -v"
2232
+ method_option :all, aliases: "-a", type: :boolean, desc: "Include hidden/excluded entries"
2233
+ method_option :exclude, aliases: "-x", type: :string, desc: "Exclude entries with these tags"
2234
+ def log(arg1 = nil, arg2 = nil)
2235
+ verbosity = determine_verbosity
2236
+ date_arg = nil
2237
+ slice_arg = nil
2238
+
2239
+ # Identify which argument is the date/shortcut
2240
+ [arg1, arg2].each do |arg|
2241
+ next if arg.nil?
2242
+ if is_date_query?(arg)
2243
+ date_arg = arg
2244
+ else
2245
+ slice_arg = arg
2246
+ end
2247
+ end
2248
+
2249
+ range, date_filter, format, _ = parse_date_query(date_arg)
2250
+
2251
+ # Strip brackets if passed
2252
+ slice_arg = $1 if slice_arg =~ /^\[(.+)\]$/
2253
+
2254
+ exclude_list = options[:exclude] ? options[:exclude].split(',').map(&:strip) : []
2255
+ list_chronological(verbosity, slice_arg, date_filter, format, exclude_list, range: range, include_hidden: options[:all])
2256
+ end
2257
+
2258
+ desc "flashback [DATE]", "Show notes from this day in previous years"
2259
+ method_option :verbose, aliases: "-v", type: :boolean, desc: "Show more info (-vv for note bodies)"
2260
+ method_option :long, aliases: "-l", type: :boolean, desc: "Alias for -v"
2261
+ method_option :exclude, aliases: "-x", type: :string, desc: "Exclude notes with these tags"
2262
+ method_option :all, aliases: "-a", type: :boolean, desc: "Include excluded notes"
2263
+ def flashback(date_str = nil)
2264
+ verbosity = determine_verbosity
2265
+ exclude_list = options[:exclude] ? options[:exclude].split(',').map(&:strip) : []
2266
+ _, _, _, target_date = parse_date_query(date_str)
2267
+
2268
+ say "Flashbacks for #{target_date.strftime('%B %d')}:".bold.yellow
2269
+ say ""
2270
+
2271
+ found_any = false
2272
+ (1..20).each do |years_ago|
2273
+ date = target_date << (years_ago * 12)
2274
+ start_time = date.to_time.to_i
2275
+ notes = fetch_notes_log(range: [start_time, start_time + 86_399])
2276
+ notes = notes.reject { |note| tag_matches_any?(note['tags'], exclude_list) } unless options[:all]
2277
+ next if notes.empty?
2278
+
2279
+ found_any = true
2280
+ label = years_ago == 1 ? "1 Year Ago" : "#{years_ago} Years Ago"
2281
+ say "--- #{label.bold} (#{date.strftime('%Y-%m-%d').cyan}) ---"
2282
+ display_log_notes(notes, verbosity)
2283
+ say ""
2284
+ end
2285
+
2286
+ say "No flashbacks found for #{target_date.strftime('%B %d')}." unless found_any
2287
+ end
2288
+
2289
+ desc "edit TARGET", "Edit a note or tag stack"
2290
+ method_option :yaml, aliases: "-y", type: :boolean, desc: "Edit note and tags using YAML"
2291
+ def edit(target)
2292
+ return unless ensure_writable!
2293
+
2294
+ if options[:yaml]
2295
+ result = prepare_yaml_edit_data_for_target(target)
2296
+ if result
2297
+ data, msg = result
2298
+ perform_yaml_edit(data, msg)
2299
+ return
2300
+ else
2301
+ say "Note or tag '#{target.cyan}' not found."
2302
+ return
2303
+ end
2304
+ end
2305
+
2306
+ notes = find_notes(target)
2307
+ if notes.empty?
2308
+ # Check if it's a tag
2309
+ tag_row = @db.get_first_row("SELECT * FROM tags WHERE title = ? OR id LIKE ?", [target, "#{target.downcase}%"])
2310
+ if tag_row
2311
+ edit_tag_stack(tag_row['title'])
2312
+ else
2313
+ say "Note or tag '#{target.cyan}' not found."
2314
+ end
2315
+ elsif notes.size > 1
2316
+ say "Ambiguous target: '#{target}' matches multiple notes:".yellow
2317
+ notes.each do |n|
2318
+ say " #{n['id'][0..4].colorize(:mode => :dim)} #{get_full_path_for_note(n)}"
2319
+ end
2320
+ say "Use the short ID to specify."
2321
+ else
2322
+ edit_single_note(notes.first)
2323
+ end
2324
+ end
2325
+
2326
+ desc "mv SOURCE DESTINATION", "Rename a notebook or tag"
2327
+ def mv(source, destination)
2328
+ return unless ensure_writable!
2329
+
2330
+ destination = destination.to_s.strip
2331
+ raise Thor::Error, "Destination name cannot be empty." if destination.empty?
2332
+
2333
+ matches = find_movable_items(source)
2334
+ if matches.empty?
2335
+ return say "No notebook or tag found matching '#{source}'."
2336
+ end
2337
+ if matches.length > 1
2338
+ say "Ambiguous source '#{source}'. Use the UUID:".yellow
2339
+ matches.each do |item|
2340
+ label = item[:type] == :notebook ? "Notebook #{get_full_path(item[:row])}" : "Tag #{item[:row]['title']}"
2341
+ say " #{item[:row]['id'][0..4].colorize(mode: :dim)} #{label}"
2342
+ end
2343
+ return
2344
+ end
2345
+
2346
+ item = matches.first
2347
+ old_name = item[:row]['title']
2348
+ rename_movable_item(item, destination)
2349
+ type = item[:type] == :notebook ? "Notebook" : "Tag"
2350
+ say "Renamed #{type.downcase} '#{old_name}' to '#{destination}'.".green
2351
+ auto_bkup_if_needed
2352
+ end
2353
+
2354
+ desc "rm TARGET", "Move a note to Trash or recursively remove a notebook or tag"
2355
+ method_option :recursive, aliases: "-r", type: :boolean, desc: "Remove a notebook or tag recursively"
2356
+ method_option :force, aliases: "-f", type: :boolean, desc: "Do not prompt before trashing notes"
2357
+ def rm(target)
2358
+ return unless ensure_writable!
2359
+
2360
+ matches = find_removable_items(target)
2361
+ if matches.empty?
2362
+ return say "No note, notebook, or tag found matching '#{target}'."
2363
+ end
2364
+ if matches.length > 1
2365
+ say "Ambiguous target '#{target}'. Use the UUID:".yellow
2366
+ matches.each do |item|
2367
+ label = removable_item_label(item)
2368
+ say " #{item[:row]['id'][0..4].colorize(mode: :dim)} #{label}"
2369
+ end
2370
+ return
2371
+ end
2372
+
2373
+ item = matches.first
2374
+ if item[:type] == :note
2375
+ trash_notes([item[:row]['id']])
2376
+ say "Moved note '#{item[:row]['title']}' to Trash.".green
2377
+ auto_bkup_if_needed
2378
+ return
2379
+ end
2380
+ unless options[:recursive]
2381
+ return say "Cannot remove #{item[:type]} '#{item[:row]['title']}': use -r."
2382
+ end
2383
+
2384
+ note_ids = removable_note_ids(item)
2385
+ unless note_ids.empty? || options[:force]
2386
+ noun = note_ids.length == 1 ? "note" : "notes"
2387
+ print "Move #{note_ids.length} #{noun} to Trash and remove #{item[:type]} '#{item[:row]['title']}'? [y/N] "
2388
+ answer = $stdin.gets.to_s.strip.downcase
2389
+ unless %w[y yes].include?(answer)
2390
+ say "Cancelled."
2391
+ return
2392
+ end
2393
+ end
2394
+
2395
+ remove_item(item, note_ids)
2396
+ noun = item[:type] == :notebook ? "notebook" : "tag"
2397
+ summary = note_ids.empty? ? "" : " and moved #{note_ids.length} #{note_ids.length == 1 ? 'note' : 'notes'} to Trash"
2398
+ say "Removed #{noun} '#{item[:row]['title']}'#{summary}.".green
2399
+ auto_bkup_if_needed
2400
+ end
2401
+
2402
+ desc "test", "Run tests"
2403
+ method_option :verbose, aliases: "-v", type: :boolean
2404
+ def test
2405
+ TestSuite.debug = options[:verbose]
2406
+ Minitest.seed = srand % 0xFFFF
2407
+ reporter = GroupedReporter.new
2408
+ reporter.start
2409
+ Minitest::Runnable.runnables.reject { |r| [Minitest::Test, Minitest::Spec].include?(r) }.sort_by(&:to_s).each do |suite|
2410
+ suite.runnable_methods.each { |m| reporter.record(suite.new(m).run) }
2411
+ end
2412
+ reporter.report
2413
+ exit 1 unless reporter.passed?
2414
+ end
2415
+
2416
+ private
2417
+
2418
+ def find_movable_items(source)
2419
+ if source.match?(/\A[a-f0-9]{1,32}\z/i)
2420
+ prefix = "#{source.downcase}%"
2421
+ matches = @db.execute(
2422
+ "SELECT id, title, parent_id FROM folders WHERE LOWER(id) LIKE ? AND deleted_time = 0",
2423
+ [prefix]
2424
+ ).map { |row| { type: :notebook, row: row } }
2425
+ matches.concat(
2426
+ @db.execute("SELECT id, title FROM tags WHERE LOWER(id) LIKE ?", [prefix]).map do |row|
2427
+ { type: :tag, row: row }
2428
+ end
2429
+ )
2430
+ return matches
2431
+ end
2432
+
2433
+ matches = @db.execute(
2434
+ "SELECT id, title, parent_id FROM folders WHERE title = ? AND deleted_time = 0",
2435
+ [source]
2436
+ ).map { |row| { type: :notebook, row: row } }
2437
+ matches.concat(
2438
+ @db.execute("SELECT id, title FROM tags WHERE title = ?", [source]).map do |row|
2439
+ { type: :tag, row: row }
2440
+ end
2441
+ )
2442
+ matches
2443
+ end
2444
+
2445
+ def find_removable_items(source)
2446
+ if source.match?(/\A[a-f0-9]{1,32}\z/i)
2447
+ prefix = "#{source.downcase}%"
2448
+ matches = find_movable_items(source)
2449
+ matches.concat(
2450
+ @db.execute(
2451
+ "SELECT id, title, parent_id FROM notes WHERE LOWER(id) LIKE ? AND deleted_time = 0",
2452
+ [prefix]
2453
+ ).map { |row| { type: :note, row: row } }
2454
+ )
2455
+ return matches
2456
+ end
2457
+
2458
+ matches = find_movable_items(source)
2459
+ find_notes(source).each { |row| matches << { type: :note, row: row } }
2460
+ matches
2461
+ end
2462
+
2463
+ def removable_item_label(item)
2464
+ case item[:type]
2465
+ when :notebook then "Notebook #{get_full_path(item[:row])}"
2466
+ when :tag then "Tag #{item[:row]['title']}"
2467
+ else "Note #{get_full_path_for_note(item[:row])}"
2468
+ end
2469
+ end
2470
+
2471
+ def rename_movable_item(item, destination)
2472
+ table = item[:type] == :notebook ? "folders" : "tags"
2473
+ columns = @db.execute("PRAGMA table_info(#{table})").map { |column| column['name'] }.to_set
2474
+ assignments = ["title = ?"]
2475
+ values = [destination]
2476
+ now = Time.now.to_i * 1000
2477
+ %w(updated_time user_updated_time).each do |column|
2478
+ next unless columns.include?(column)
2479
+
2480
+ assignments << "#{column} = ?"
2481
+ values << now
2482
+ end
2483
+ values << item[:row]['id']
2484
+ @db.execute("UPDATE #{table} SET #{assignments.join(', ')} WHERE id = ?", values)
2485
+ end
2486
+
2487
+ def removable_note_ids(item)
2488
+ if item[:type] == :tag
2489
+ @db.execute(<<~SQL, [item[:row]['id']]).map { |row| row['id'] }
2490
+ SELECT DISTINCT n.id
2491
+ FROM notes n
2492
+ JOIN note_tags nt ON nt.note_id = n.id
2493
+ WHERE nt.tag_id = ? AND n.deleted_time = 0
2494
+ SQL
2495
+ else
2496
+ folder_ids = notebook_subtree_ids(item[:row]['id'])
2497
+ placeholders = Array.new(folder_ids.length, "?").join(", ")
2498
+ @db.execute(
2499
+ "SELECT id FROM notes WHERE parent_id IN (#{placeholders}) AND deleted_time = 0",
2500
+ folder_ids
2501
+ ).map { |row| row['id'] }
2502
+ end
2503
+ end
2504
+
2505
+ def notebook_subtree_ids(root_id)
2506
+ ids = []
2507
+ pending = [root_id]
2508
+ until pending.empty?
2509
+ parent_id = pending.shift
2510
+ ids << parent_id
2511
+ pending.concat(
2512
+ @db.execute("SELECT id FROM folders WHERE parent_id = ? AND deleted_time = 0", [parent_id]).map { |row| row['id'] }
2513
+ )
2514
+ end
2515
+ ids
2516
+ end
2517
+
2518
+ def remove_item(item, note_ids)
2519
+ @db.transaction do
2520
+ trash_notes(note_ids)
2521
+ if item[:type] == :tag
2522
+ @db.execute("DELETE FROM note_tags WHERE tag_id = ?", [item[:row]['id']])
2523
+ @db.execute("DELETE FROM tags WHERE id = ?", [item[:row]['id']])
2524
+ else
2525
+ folder_ids = notebook_subtree_ids(item[:row]['id'])
2526
+ placeholders = Array.new(folder_ids.length, "?").join(", ")
2527
+ @db.execute("DELETE FROM folders WHERE id IN (#{placeholders})", folder_ids)
2528
+ end
2529
+ end
2530
+ end
2531
+
2532
+ def trash_notes(note_ids)
2533
+ return if note_ids.empty?
2534
+
2535
+ columns = @db.execute("PRAGMA table_info(notes)").map { |column| column['name'] }.to_set
2536
+ now = Time.now.to_i * 1000
2537
+ assignments = ["deleted_time = ?"]
2538
+ values = [now]
2539
+ if columns.include?("updated_time")
2540
+ assignments << "updated_time = ?"
2541
+ values << now
2542
+ end
2543
+ placeholders = Array.new(note_ids.length, "?").join(", ")
2544
+ @db.execute(
2545
+ "UPDATE notes SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
2546
+ [*values, *note_ids]
2547
+ )
2548
+ end
2549
+
2550
+ def trashed_notes
2551
+ @db.execute("SELECT id, title, parent_id, deleted_time FROM notes WHERE deleted_time > 0 ORDER BY deleted_time DESC")
2552
+ end
2553
+
2554
+ def list_trash
2555
+ notes = trashed_notes
2556
+ return say "Trash is empty." if notes.empty?
2557
+
2558
+ notes.each do |note|
2559
+ id = note['id'][0..4].colorize(mode: :dim)
2560
+ timestamp = format_note_time(note['deleted_time']).colorize(mode: :dim)
2561
+ say "#{id} #{timestamp} #{get_full_path_for_note(note)}"
2562
+ end
2563
+ end
2564
+
2565
+ def restore_trash_note(uuid)
2566
+ return unless ensure_writable!
2567
+
2568
+ matches = @db.execute(
2569
+ "SELECT id, title, parent_id FROM notes WHERE LOWER(id) LIKE ? AND deleted_time > 0 ORDER BY title COLLATE NOCASE",
2570
+ ["#{uuid.downcase}%"]
2571
+ )
2572
+ return say "No trashed note found matching '#{uuid}'." if matches.empty?
2573
+
2574
+ if matches.length > 1
2575
+ say "Ambiguous trashed note '#{uuid}'. Use a longer UUID:".yellow
2576
+ matches.each do |note|
2577
+ say " #{note['id'][0..4].colorize(mode: :dim)} #{get_full_path_for_note(note)}"
2578
+ end
2579
+ return
2580
+ end
2581
+
2582
+ note = matches.first
2583
+ @db.execute("UPDATE notes SET deleted_time = 0 WHERE id = ?", [note['id']])
2584
+ say "Restored note '#{note['title']}'.".green
2585
+ auto_bkup_if_needed
2586
+ end
2587
+
2588
+ def empty_trash
2589
+ return unless ensure_writable!
2590
+
2591
+ count = @db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0").to_i
2592
+ return say "Trash is empty." if count.zero?
2593
+
2594
+ noun = count == 1 ? "note" : "notes"
2595
+ print "Permanently delete #{count} trashed #{noun}? [y/N] "
2596
+ answer = $stdin.gets.to_s.strip.downcase
2597
+ unless %w[y yes].include?(answer)
2598
+ say "Cancelled."
2599
+ return
2600
+ end
2601
+
2602
+ trashed_ids = @db.execute("SELECT id FROM notes WHERE deleted_time > 0").map { |row| row['id'] }
2603
+ placeholders = Array.new(trashed_ids.length, "?").join(", ")
2604
+ @db.transaction do
2605
+ @db.execute("DELETE FROM note_tags WHERE note_id IN (#{placeholders})", trashed_ids)
2606
+ @db.execute("DELETE FROM notes WHERE id IN (#{placeholders})", trashed_ids)
2607
+ end
2608
+ say "Emptied Trash.".green
2609
+ auto_bkup_if_needed
2610
+ end
2611
+
2612
+ def extract_add_tag_options
2613
+ args = Thread.current[:jp_args] || []
2614
+ tags = []
2615
+ index = 0
2616
+ while index < args.length
2617
+ argument = args[index]
2618
+ case argument
2619
+ when '-T', '--tag'
2620
+ tags << args[index + 1] if args[index + 1]
2621
+ index += 2
2622
+ when /\A--tag=(.*)\z/
2623
+ tags << Regexp.last_match(1)
2624
+ index += 1
2625
+ else
2626
+ index += 1
2627
+ end
2628
+ end
2629
+ tags << options[:tag] if tags.empty? && options[:tag]
2630
+ tags
2631
+ end
2632
+
2633
+ def normalize_add_tags(values)
2634
+ Array(values).flat_map do |value|
2635
+ value.is_a?(Array) ? value : value.to_s.split(',')
2636
+ end.map { |tag| tag.to_s.strip }.reject(&:empty?).uniq
2637
+ end
2638
+
2639
+ def parse_journal_args(date_or_text, text_parts)
2640
+ if journal_date_token?(date_or_text)
2641
+ [journal_date_from_token(date_or_text), text_parts.join(' ')]
2642
+ else
2643
+ [Date.today, [date_or_text, *text_parts].compact.join(' ')]
2644
+ end
2645
+ end
2646
+
2647
+ def journal_date_token?(value)
2648
+ value.nil? || value == "today" || value == "yesterday" || value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
2649
+ end
2650
+
2651
+ def journal_date_from_token(value)
2652
+ case value
2653
+ when nil, "today" then Date.today
2654
+ when "yesterday" then Date.today - 1
2655
+ else
2656
+ Date.strptime(value, "%Y-%m-%d")
2657
+ end
2658
+ rescue ArgumentError
2659
+ raise AddError, "Invalid journal date: #{value}"
2660
+ end
2661
+
2662
+ def journal_notebook_path(date)
2663
+ "Journal/#{date.strftime('%Y')}/#{date.strftime('%m-%b')}"
2664
+ end
2665
+
2666
+ def journal_note_title(date)
2667
+ date.strftime("%Y-%m-%d")
2668
+ end
2669
+
2670
+ def edit_journal_text_buffer(date)
2671
+ temp = Tempfile.new(['jp_journal', '.md'])
2672
+ begin
2673
+ notes = journal_notes_for_date(date)
2674
+ raise AddError, "Journal note is ambiguous for #{journal_note_title(date)}." if notes.length > 1
2675
+
2676
+ temp.write(notes.first ? notes.first['body'].to_s : "")
2677
+ temp.close
2678
+ editor = ENV['EDITOR'] || 'vi'
2679
+ success = system("#{editor} #{Shellwords.escape(temp.path)}")
2680
+ unless success
2681
+ say "Journal cancelled."
2682
+ return nil
2683
+ end
2684
+
2685
+ body = File.read(temp.path).delete_suffix("\n")
2686
+ if body.empty?
2687
+ say "Journal cancelled."
2688
+ return nil
2689
+ end
2690
+ body
2691
+ ensure
2692
+ temp.unlink
2693
+ end
2694
+ end
2695
+
2696
+ def journal_notes_for_date(date)
2697
+ notebook_path = journal_notebook_path(date)
2698
+ title = journal_note_title(date)
2699
+ find_notes("#{notebook_path}/#{title}")
2700
+ end
2701
+
2702
+ def append_or_create_journal_note(date, body, append: true)
2703
+ notebook_path = journal_notebook_path(date)
2704
+ title = journal_note_title(date)
2705
+ notes = journal_notes_for_date(date)
2706
+
2707
+ if notes.any?
2708
+ raise AddError, "Journal note is ambiguous for #{title}." if notes.length > 1
2709
+
2710
+ note = notes.first
2711
+ existing = note['body'].to_s
2712
+ separator = existing.empty? ? "" : "\n\n"
2713
+ updated_body = append ? "#{existing}#{separator}#{body}" : body
2714
+ now = Time.now.to_i * 1000
2715
+ columns = @db.execute("PRAGMA table_info(notes)").map { |column| column['name'] }.to_set
2716
+ updates = ["body = ?"]
2717
+ params = [updated_body]
2718
+ %w(updated_time user_updated_time).each do |column|
2719
+ next unless columns.include?(column)
2720
+
2721
+ updates << "#{column} = ?"
2722
+ params << now
2723
+ end
2724
+ params << note['id']
2725
+ @db.execute("UPDATE notes SET #{updates.join(', ')} WHERE id = ?", params)
2726
+ note['body'] = updated_body
2727
+ note['path'] = "#{notebook_path}/#{title}"
2728
+ return { id: note['id'], path: note['path'] }
2729
+ end
2730
+
2731
+ create_note_with_hierarchy(
2732
+ notebook_path: notebook_path,
2733
+ title: title,
2734
+ body: body,
2735
+ tags: [],
2736
+ source_url: "",
2737
+ latitude: nil,
2738
+ longitude: nil
2739
+ )
2740
+ end
2741
+
2742
+ def derive_note_title(body)
2743
+ line = body.each_line.find { |candidate| !candidate.strip.empty? }
2744
+ return nil unless line
2745
+
2746
+ line.strip.sub(/\A\#{1,6}\s+/, '').sub(/\s+\#+\z/, '').strip
2747
+ end
2748
+
2749
+ def edit_add_text_buffer
2750
+ temp = Tempfile.new(['jp_add', '.md'])
2751
+ begin
2752
+ temp.write("\n\n")
2753
+ temp.close
2754
+ editor = ENV['EDITOR'] || 'vi'
2755
+ success = system("#{editor} #{Shellwords.escape(temp.path)}")
2756
+ unless success
2757
+ say "Add cancelled."
2758
+ return nil
2759
+ end
2760
+
2761
+ lines = File.read(temp.path).lines
2762
+ title = lines.shift.to_s.chomp
2763
+ lines.shift
2764
+ body = lines.join.delete_suffix("\n")
2765
+ if title.empty? && body.empty?
2766
+ say "Add cancelled."
2767
+ return nil
2768
+ end
2769
+ [title, body]
2770
+ ensure
2771
+ temp.unlink
2772
+ end
2773
+ end
2774
+
2775
+ def parse_coordinate(value, name, minimum, maximum)
2776
+ return nil if value.nil? || value.to_s.strip.empty?
2777
+
2778
+ coordinate = Float(value)
2779
+ raise AddError, "#{name} must be between #{minimum} and #{maximum}." unless coordinate.between?(minimum, maximum)
2780
+ coordinate
2781
+ rescue ArgumentError, TypeError
2782
+ raise AddError, "#{name} must be a number."
2783
+ end
2784
+
2785
+ def create_note_with_hierarchy(notebook_path:, title:, body:, tags:, source_url:, latitude:, longitude:)
2786
+ components = notebook_path.to_s.split('/', -1).map(&:strip)
2787
+ raise AddError, "Notebook path is required." if components.empty? || components.any?(&:empty?)
2788
+
2789
+ now = Time.now.to_i * 1000
2790
+ note_id = SecureRandom.hex(16)
2791
+ parent_id = ""
2792
+
2793
+ @db.transaction do
2794
+ components.each do |component|
2795
+ folders = @db.execute(
2796
+ "SELECT id FROM folders WHERE title = ? AND parent_id = ? AND deleted_time = 0",
2797
+ [component, parent_id]
2798
+ )
2799
+ raise AddError, "Notebook path is ambiguous at '#{component}'." if folders.size > 1
2800
+
2801
+ if folders.empty?
2802
+ folder_id = SecureRandom.hex(16)
2803
+ insert_available_columns('folders', {
2804
+ 'id' => folder_id,
2805
+ 'title' => component,
2806
+ 'parent_id' => parent_id,
2807
+ 'created_time' => now,
2808
+ 'updated_time' => now,
2809
+ 'user_created_time' => now,
2810
+ 'user_updated_time' => now,
2811
+ 'deleted_time' => 0
2812
+ })
2813
+ parent_id = folder_id
2814
+ else
2815
+ parent_id = folders.first['id']
2816
+ end
2817
+ end
2818
+
2819
+ insert_available_columns('notes', {
2820
+ 'id' => note_id,
2821
+ 'parent_id' => parent_id,
2822
+ 'title' => title,
2823
+ 'body' => body,
2824
+ 'created_time' => now,
2825
+ 'updated_time' => now,
2826
+ 'user_created_time' => now,
2827
+ 'user_updated_time' => now,
2828
+ 'latitude' => latitude,
2829
+ 'longitude' => longitude,
2830
+ 'source_url' => source_url,
2831
+ 'deleted_time' => 0
2832
+ })
2833
+
2834
+ tags.each do |tag_title|
2835
+ tag = @db.get_first_row("SELECT id FROM tags WHERE title = ?", [tag_title])
2836
+ tag_id = tag && tag['id']
2837
+ unless tag_id
2838
+ tag_id = SecureRandom.hex(16)
2839
+ insert_available_columns('tags', {
2840
+ 'id' => tag_id,
2841
+ 'title' => tag_title,
2842
+ 'created_time' => now,
2843
+ 'updated_time' => now,
2844
+ 'user_created_time' => now,
2845
+ 'user_updated_time' => now
2846
+ })
2847
+ end
2848
+ insert_available_columns('note_tags', {
2849
+ 'id' => SecureRandom.hex(16),
2850
+ 'note_id' => note_id,
2851
+ 'tag_id' => tag_id,
2852
+ 'created_time' => now,
2853
+ 'updated_time' => now,
2854
+ 'user_created_time' => now,
2855
+ 'user_updated_time' => now
2856
+ })
2857
+ end
2858
+ end
2859
+
2860
+ { id: note_id, path: "#{components.join('/')}/#{title}" }
2861
+ rescue SQLite3::Exception => e
2862
+ raise AddError, "Could not create note: #{e.message}"
2863
+ end
2864
+
2865
+ def insert_available_columns(table, attributes)
2866
+ available = @db.execute("PRAGMA table_info(#{table})").map { |column| column['name'] }.to_set
2867
+ values = attributes.select { |column, value| available.include?(column) && !value.nil? }
2868
+ columns = values.keys
2869
+ placeholders = Array.new(columns.length, '?').join(', ')
2870
+ @db.execute(
2871
+ "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})",
2872
+ values.values
2873
+ )
2874
+ end
2875
+
2876
+ def display_note(note, raw: false, verbose: false, no_pager: false)
2877
+ return puts(note['body'] || "") if raw
2878
+
2879
+ width = (IO.console.winsize[1] rescue 80)
2880
+ output = StringIO.new
2881
+ output.puts "#{note['title'].bold.yellow} (#{note['id'][0..4].colorize(mode: :dim)})"
2882
+ display_note_metadata(note).each { |line| output.puts line.colorize(mode: :dim) } if verbose
2883
+ output.puts "-" * 40
2884
+ output.puts render_markdown(note['body'] || "", width: width)
2885
+ write_with_pager(output.string, raw: raw, no_pager: no_pager)
2886
+ end
2887
+
2888
+ def display_note_metadata(note)
2889
+ metadata = []
2890
+ metadata << "Created: #{format_note_time(note['created_time'])}" if note['created_time'].to_i.positive?
2891
+ metadata << "Updated: #{format_note_time(note['updated_time'])}" if note['updated_time'].to_i.positive?
2892
+
2893
+ latitude = note['latitude'].to_f
2894
+ longitude = note['longitude'].to_f
2895
+ if latitude.nonzero? || longitude.nonzero?
2896
+ coordinates = "#{latitude}, #{longitude}"
2897
+ metadata << "Location: #{coordinates} (https://www.google.com/maps?q=#{latitude},#{longitude})"
2898
+ end
2899
+
2900
+ source_url = note['source_url'].to_s.strip
2901
+ metadata << "URL: #{source_url}" unless source_url.empty?
2902
+
2903
+ tags = @db.execute(
2904
+ "SELECT t.title FROM tags t JOIN note_tags nt ON t.id = nt.tag_id WHERE nt.note_id = ? ORDER BY t.title COLLATE NOCASE",
2905
+ [note['id']]
2906
+ ).map { |tag| tag['title'] }
2907
+ metadata << "Tags: #{tags.join(', ')}" if tags.any?
2908
+ metadata
2909
+ end
2910
+
2911
+ def write_with_pager(output, raw:, no_pager:)
2912
+ rows, columns = terminal_dimensions
2913
+ unless should_page_output?(output, raw: raw, no_pager: no_pager, tty: $stdout.tty?, rows: rows, columns: columns)
2914
+ return print output
2915
+ end
2916
+
2917
+ command = pager_command
2918
+ return print output unless command
2919
+
2920
+ IO.popen(command, 'w') { |pager| pager.write(output) }
2921
+ rescue Errno::ENOENT, Errno::EPIPE
2922
+ print output
2923
+ end
2924
+
2925
+ def should_page_output?(output, raw:, no_pager:, tty:, rows:, columns:)
2926
+ return false if raw || no_pager || !tty || rows.to_i <= 0 || columns.to_i <= 0 || pager_command.nil?
2927
+
2928
+ visible_output = output.gsub(/\e\[(\d+;?)*m/, '')
2929
+ visible_lines = visible_output.lines.sum do |line|
2930
+ [1, (line.chomp.length.to_f / columns).ceil].max
2931
+ end
2932
+ visible_lines > rows
2933
+ end
2934
+
2935
+ def settings_path
2936
+ File.join(@joplin_data_dir, "settings.json")
2937
+ end
2938
+
2939
+ def load_settings
2940
+ data = if File.exist?(settings_path)
2941
+ JSON.parse(File.read(settings_path))
2942
+ else
2943
+ {}
2944
+ end
2945
+ raise Thor::Error, "Settings file must contain a JSON object: #{settings_path}" unless data.is_a?(Hash)
2946
+
2947
+ changed = false
2948
+ unless data.key?("jp.pager")
2949
+ data["jp.pager"] = "less -R"
2950
+ changed = true
2951
+ end
2952
+ unless data.key?("jp.readOnly")
2953
+ data["jp.readOnly"] = false
2954
+ changed = true
2955
+ end
2956
+ unless data.key?("jp.backupKeep")
2957
+ data["jp.backupKeep"] = 10
2958
+ changed = true
2959
+ end
2960
+ unless data.key?("jp.backupAuto")
2961
+ data["jp.backupAuto"] = true
2962
+ changed = true
2963
+ end
2964
+ unless data.key?("jp.backupAutoInterval")
2965
+ data["jp.backupAutoInterval"] = 24
2966
+ changed = true
2967
+ end
2968
+ write_settings(data) if changed
2969
+ data
2970
+ rescue JSON::ParserError => e
2971
+ raise Thor::Error, "Invalid JSON in #{settings_path}: #{e.message}"
2972
+ end
2973
+
2974
+ def write_settings(data)
2975
+ mode = File.exist?(settings_path) ? File.stat(settings_path).mode & 0o777 : 0o600
2976
+ Tempfile.create(["settings", ".json"], File.dirname(settings_path)) do |file|
2977
+ file.write(JSON.pretty_generate(data))
2978
+ file.write("\n")
2979
+ file.flush
2980
+ file.fsync
2981
+ File.chmod(mode, file.path)
2982
+ File.rename(file.path, settings_path)
2983
+ end
2984
+ end
2985
+
2986
+ def parse_setting_value(value)
2987
+ JSON.parse(value)
2988
+ rescue JSON::ParserError
2989
+ value
2990
+ end
2991
+
2992
+ def format_setting_value(value)
2993
+ value.is_a?(String) ? value : JSON.generate(value)
2994
+ end
2995
+
2996
+ def format_settings_list(data, color:)
2997
+ yaml = YAML.dump(data.sort.to_h).delete_prefix("---\n")
2998
+ return yaml unless color
2999
+
3000
+ yaml.lines.map { |line| colorize_setting_line(line) }.join
3001
+ end
3002
+
3003
+ def colorize_setting_line(line)
3004
+ newline = line.end_with?("\n") ? "\n" : ""
3005
+ content = line.chomp
3006
+
3007
+ if content =~ /\A(\s*)(-)(\s+)(.*)\z/
3008
+ "#{$1}#{$2.colorize(mode: :dim)}#{$3}#{$4.green}#{newline}"
3009
+ elsif content =~ /\A(\s*)(.+?)(:)(?:\s(.*))?\z/
3010
+ value = $4
3011
+ output = "#{$1}#{$2.cyan}#{$3.colorize(mode: :dim)}"
3012
+ output += " #{value.green}" if value
3013
+ "#{output}#{newline}"
3014
+ else
3015
+ line
3016
+ end
3017
+ end
3018
+
3019
+ def validate_setting_value!(name, value)
3020
+ case name
3021
+ when "jp.pager"
3022
+ return if value == false || value.is_a?(String)
3023
+
3024
+ raise Thor::Error, "jp.pager must be false or a command string."
3025
+ when "jp.readOnly"
3026
+ return if value == true || value == false
3027
+
3028
+ raise Thor::Error, "jp.readOnly must be true or false."
3029
+ when "jp.backupKeep"
3030
+ return if value.is_a?(Integer) && value.positive?
3031
+
3032
+ raise Thor::Error, "jp.backupKeep must be a positive integer."
3033
+ when "jp.backupAuto"
3034
+ return if value == true || value == false
3035
+
3036
+ raise Thor::Error, "jp.backupAuto must be true or false."
3037
+ when "jp.backupAutoInterval"
3038
+ return if value.is_a?(Numeric) && value >= 0
3039
+
3040
+ raise Thor::Error, "jp.backupAutoInterval must be a non-negative number of hours."
3041
+ end
3042
+ end
3043
+
3044
+ def backup_keep
3045
+ value = load_settings["jp.backupKeep"]
3046
+ raise Thor::Error, "jp.backupKeep must be a positive integer." unless value.is_a?(Integer) && value.positive?
3047
+
3048
+ value
3049
+ end
3050
+
3051
+ def backup_files
3052
+ Dir.glob(File.join(@joplin_data_dir, 'joplin_*.sqlite3.gz')).select do |path|
3053
+ File.basename(path).match?(/\Ajoplin_\d+\.sqlite3\.gz\z/)
3054
+ end.sort_by do |path|
3055
+ -File.basename(path)[/\Ajoplin_(\d+)\.sqlite3\.gz\z/, 1].to_i
3056
+ end
3057
+ end
3058
+
3059
+ def rotate_backups
3060
+ files = backup_files.reverse
3061
+ return unless files.size > backup_keep
3062
+
3063
+ to_delete = files[0...-backup_keep]
3064
+ to_delete.each { |path| File.delete(path) }
3065
+ say "Removed #{to_delete.size} old backup(s).".yellow
3066
+ end
3067
+
3068
+ def create_backup
3069
+ timestamp = Time.now.to_i
3070
+ compressed = File.join(@joplin_data_dir, "joplin_#{timestamp}.sqlite3.gz")
3071
+ temp_db_path = File.join(Dir.tmpdir, "jp_#{timestamp}.db")
3072
+
3073
+ source_db = @db
3074
+ dest_db = SQLite3::Database.new(temp_db_path)
3075
+
3076
+ backup = SQLite3::Backup.new(dest_db, "main", source_db, "main")
3077
+ backup.step(-1)
3078
+ backup.finish
3079
+ dest_db.close
3080
+
3081
+ File.open(temp_db_path, 'rb') do |src|
3082
+ Zlib::GzipWriter.open(compressed) do |gz|
3083
+ IO.copy_stream(src, gz)
3084
+ end
3085
+ end
3086
+
3087
+ rotate_backups
3088
+ compressed
3089
+ ensure
3090
+ dest_db&.close rescue nil
3091
+ File.delete(temp_db_path) if temp_db_path && File.exist?(temp_db_path)
3092
+ end
3093
+
3094
+ def auto_bkup_if_needed
3095
+ settings = load_settings
3096
+ return unless settings["jp.backupAuto"] == true
3097
+
3098
+ interval = settings["jp.backupAutoInterval"]
3099
+ raise Thor::Error, "jp.backupAutoInterval must be a non-negative number of hours." unless interval.is_a?(Numeric) && interval >= 0
3100
+
3101
+ latest_epoch = backup_files.map { |path| File.basename(path)[/\Ajoplin_(\d+)\.sqlite3\.gz\z/, 1].to_i }.max
3102
+ return if latest_epoch && (Time.now.to_i - latest_epoch) < (interval * 3600)
3103
+
3104
+ create_backup
3105
+ rescue => e
3106
+ say "Auto-backup failed: #{e.message}".red
3107
+ end
3108
+
3109
+ def read_only?
3110
+ effective_settings["jp.readOnly"] == true
3111
+ end
3112
+
3113
+ def ensure_writable!
3114
+ return true unless read_only?
3115
+
3116
+ say "Read-only mode is enabled (jp.readOnly: true).".red
3117
+ false
3118
+ end
3119
+
3120
+ def pager_command
3121
+ value = effective_settings["jp.pager"]
3122
+ return nil if value == false
3123
+ raise Thor::Error, "jp.pager must be false or a command string." unless value.is_a?(String)
3124
+
3125
+ command = Shellwords.split(value)
3126
+ command.empty? ? nil : command
3127
+ end
3128
+
3129
+ def effective_settings
3130
+ load_settings.merge(Thread.current[:jp_setting_overrides] || {})
3131
+ end
3132
+
3133
+ def terminal_dimensions
3134
+ IO.console.winsize
3135
+ rescue StandardError
3136
+ [24, 80]
3137
+ end
3138
+
3139
+ def format_note_time(milliseconds)
3140
+ Time.at(milliseconds / 1000.0).strftime('%Y-%m-%d %H:%M')
3141
+ end
3142
+
3143
+ def time_ago(time)
3144
+ diff = Time.now - time
3145
+ if diff < 60
3146
+ "just now"
3147
+ elsif diff < 3600
3148
+ "#{(diff / 60).to_i} minutes ago"
3149
+ elsif diff < 86400
3150
+ "#{(diff / 3600).to_i} hours ago"
3151
+ else
3152
+ "#{(diff / 86400).to_i} days ago"
3153
+ end
3154
+ end
3155
+
3156
+ def render_markdown(body, width:)
3157
+ renderable = expand_toc_markers(body).gsub(/\((:\/[a-f0-9]{32})\)/i, '(joplin-resource\1)')
3158
+ TTY::Markdown.parse(renderable, width: width)
3159
+ rescue URI::InvalidURIError
3160
+ repaired = expand_toc_markers(body).gsub(/^(\s*)\[([^\]]+)\]:(\s+.+)$/) { "#{$1}\\[#{$2}\]:#{$3}" }
3161
+ repaired = repaired.gsub(/\((:\/[a-f0-9]{32})\)/i, '(joplin-resource\1)')
3162
+ begin
3163
+ TTY::Markdown.parse(repaired, width: width)
3164
+ rescue URI::InvalidURIError
3165
+ body
3166
+ end
3167
+ end
3168
+
3169
+ def expand_toc_markers(body)
3170
+ marker = /^\s*(?:\$\{toc\}|\[\[toc\]\]|\[toc\]|\[\[_toc_\]\])\s*$/i
3171
+ return body unless body.match?(marker)
3172
+
3173
+ toc_tree = Kramdown::Document.new(body).to_toc
3174
+ toc = toc_tree.children.flat_map { |node| toc_markdown_lines(node) }.join("\n")
3175
+
3176
+ body.gsub(marker, toc)
3177
+ end
3178
+
3179
+ def toc_markdown_lines(node, depth = 0)
3180
+ title = kramdown_element_text(node.value).strip
3181
+ ["#{' ' * depth}- #{title}"] + node.children.flat_map do |child|
3182
+ toc_markdown_lines(child, depth + 1)
3183
+ end
3184
+ end
3185
+
3186
+ def kramdown_element_text(element)
3187
+ return element.value.to_s if [:text, :codespan].include?(element.type)
3188
+ return element.attr['alt'].to_s if element.type == :img
3189
+
3190
+ element.children.map { |child| kramdown_element_text(child) }.join
3191
+ end
3192
+
3193
+ def fish_completion_script
3194
+ <<~'FISH'
3195
+ # Fish completions for jp. Load with: jp init fish | source
3196
+ function __jp_needs_command
3197
+ set -l words (commandline -opc)
3198
+ test (count $words) -eq 1
3199
+ end
3200
+
3201
+ function __jp_using_command
3202
+ set -l words (commandline -opc)
3203
+ test (count $words) -gt 1; and test "$words[2]" = "$argv[1]"
3204
+ end
3205
+
3206
+ function __jp_complete
3207
+ command jp __complete $argv[1] 2>/dev/null
3208
+ end
3209
+
3210
+ complete -c jp -f
3211
+ complete -c jp -s o -l option -r -d 'Override readonly or pager for this command'
3212
+ complete -c jp -n __jp_needs_command -a add -d 'Add a note'
3213
+ complete -c jp -n __jp_needs_command -a journal -d 'Append to a daily journal note'
3214
+ complete -c jp -n __jp_needs_command -a j -d 'Append to a daily journal note'
3215
+ complete -c jp -n __jp_needs_command -a ls -d 'List notebooks, notes, or tags'
3216
+ complete -c jp -n __jp_needs_command -a cat -d 'Display note content'
3217
+ complete -c jp -n __jp_needs_command -a random -d 'Display a random note'
3218
+ complete -c jp -n __jp_needs_command -a search -d 'Search notes for text'
3219
+ complete -c jp -n __jp_needs_command -a cal -d 'Show calendar'
3220
+ complete -c jp -n __jp_needs_command -a log -d 'Show log'
3221
+ complete -c jp -n __jp_needs_command -a flashback -d 'Show notes from this day in previous years'
3222
+ complete -c jp -n __jp_needs_command -a edit -d 'Edit a note or tag stack'
3223
+ complete -c jp -n __jp_needs_command -a mv -d 'Rename a notebook or tag'
3224
+ complete -c jp -n __jp_needs_command -a rm -d 'Remove a notebook or tag'
3225
+ complete -c jp -n __jp_needs_command -a trash -d 'Manage trashed notes'
3226
+ complete -c jp -n __jp_needs_command -a settings -d 'Manage settings'
3227
+ complete -c jp -n __jp_needs_command -a info -d 'Show JP and Joplin profile information'
3228
+ complete -c jp -n __jp_needs_command -a bkup -d 'Backup the database'
3229
+ complete -c jp -n __jp_needs_command -a version -d 'Show version'
3230
+ complete -c jp -n __jp_needs_command -a init -d 'Print shell integration code'
3231
+ complete -c jp -n '__jp_using_command init' -a fish -d 'Fish shell'
3232
+
3233
+ complete -c jp -n '__jp_using_command add' -a '(__jp_complete notebooks)'
3234
+ complete -c jp -n '__jp_using_command add' -s t -l title -r -d 'Note title'
3235
+ complete -c jp -n '__jp_using_command add' -s T -l tag -r -a '(__jp_complete tags)' -d 'Tag'
3236
+ complete -c jp -n '__jp_using_command journal' -a 'today yesterday' -d 'Journal date'
3237
+ complete -c jp -n '__jp_using_command j' -a 'today yesterday' -d 'Journal date'
3238
+
3239
+ complete -c jp -n '__jp_using_command ls' -s l -l long -d 'Show note counts'
3240
+ complete -c jp -n '__jp_using_command ls' -s T -l tree -d 'Show notebook tree'
3241
+ complete -c jp -n '__jp_using_command ls' -s R -l recursive -d 'List notebook contents recursively'
3242
+ complete -c jp -n '__jp_using_command ls' -s t -l type -d 'Show tags only'
3243
+ complete -c jp -n '__jp_using_command ls' -s n -l nb -d 'Show notebooks only'
3244
+ complete -c jp -n '__jp_using_command ls' -s a -l all -d 'Show all notebooks'
3245
+ complete -c jp -n '__jp_using_command ls' -a '(__jp_complete targets)'
3246
+
3247
+ complete -c jp -n '__jp_using_command cat' -a '(__jp_complete notes)'
3248
+ complete -c jp -n '__jp_using_command cat' -s r -l raw -d 'Display raw Markdown source'
3249
+ complete -c jp -n '__jp_using_command cat' -s v -l verbose -d 'Show note metadata'
3250
+ complete -c jp -n '__jp_using_command cat' -l no-pager -d 'Disable paging'
3251
+ complete -c jp -n '__jp_using_command settings' -a 'list get set unset'
3252
+ complete -c jp -n '__jp_using_command edit' -s y -l yaml -d 'Edit note and tags as YAML'
3253
+ complete -c jp -n '__jp_using_command edit' -a '(__jp_complete notes; __jp_complete tags)'
3254
+ complete -c jp -n '__jp_using_command mv' -a '(__jp_complete movable)'
3255
+ complete -c jp -n '__jp_using_command rm' -a '(__jp_complete removable)'
3256
+ complete -c jp -n '__jp_using_command rm' -s r -l recursive -d 'Remove a notebook or tag recursively'
3257
+ complete -c jp -n '__jp_using_command rm' -s f -l force -d 'Do not prompt before trashing notes'
3258
+ complete -c jp -n '__jp_using_command trash' -a 'ls restore empty'
3259
+ complete -c jp -n '__jp_using_command trash; and contains -- restore (commandline -opc)' -a '(__jp_complete trashed)'
3260
+ complete -c jp -n '__jp_using_command search' -s v -l verbose -d 'Show content snippets'
3261
+ complete -c jp -n '__jp_using_command cal' -s y -l year -d 'Show full year'
3262
+ complete -c jp -n '__jp_using_command cal' -s i -l include -r -d 'Include tags'
3263
+ complete -c jp -n '__jp_using_command cal' -s x -l exclude -r -d 'Exclude tags'
3264
+ complete -c jp -n '__jp_using_command log' -s v -l verbose -d 'Show more information'
3265
+ complete -c jp -n '__jp_using_command log' -s l -l long -d 'Show more information'
3266
+ complete -c jp -n '__jp_using_command log' -s a -l all -d 'Include hidden entries'
3267
+ complete -c jp -n '__jp_using_command log' -s x -l exclude -r -d 'Exclude tags'
3268
+ complete -c jp -n '__jp_using_command log' -a 'today yesterday week month year last-week last-month last7days last1week last30days last1month last3months last1year last3hours' -d 'Date shortcut'
3269
+ complete -c jp -n '__jp_using_command flashback' -s v -l verbose -d 'Show more information'
3270
+ complete -c jp -n '__jp_using_command flashback' -s l -l long -d 'Show more information'
3271
+ complete -c jp -n '__jp_using_command flashback' -s a -l all -d 'Include excluded notes'
3272
+ complete -c jp -n '__jp_using_command flashback' -s x -l exclude -r -d 'Exclude tags'
3273
+ FISH
3274
+ end
3275
+
3276
+ def completion_notebooks
3277
+ @db.execute("SELECT id, title, parent_id FROM folders WHERE deleted_time = 0 ORDER BY title").map do |notebook|
3278
+ get_full_path(notebook)
3279
+ end.sort
3280
+ end
3281
+
3282
+ def completion_notes
3283
+ @db.execute("SELECT id, title, parent_id FROM notes WHERE deleted_time = 0 ORDER BY title")
3284
+ end
3285
+
3286
+ def completion_movable_items
3287
+ items = []
3288
+ items.concat(@db.execute("SELECT id, title, 'Notebook' AS type FROM folders WHERE deleted_time = 0"))
3289
+ items.concat(@db.execute("SELECT id, title, 'Tag' AS type FROM tags"))
3290
+ counts = items.each_with_object(Hash.new(0)) { |item, totals| totals[item['title']] += 1 }
3291
+ items.sort_by { |item| [item['title'].downcase, item['type'], item['id']] }.map do |item|
3292
+ if counts[item['title']] == 1
3293
+ [item['title'], item['type']]
3294
+ else
3295
+ [item['id'][0..4], "#{item['type']} #{item['title']}"]
3296
+ end
3297
+ end
3298
+ end
3299
+
3300
+ def completion_removable_items
3301
+ items = []
3302
+ items.concat(@db.execute("SELECT id, title, 'Notebook' AS type FROM folders WHERE deleted_time = 0"))
3303
+ items.concat(@db.execute("SELECT id, title, 'Tag' AS type FROM tags"))
3304
+ items.concat(@db.execute("SELECT id, title, 'Note' AS type FROM notes WHERE deleted_time = 0"))
3305
+ counts = items.each_with_object(Hash.new(0)) { |item, totals| totals[item['title']] += 1 }
3306
+ items.sort_by { |item| [item['title'].downcase, item['type'], item['id']] }.map do |item|
3307
+ if counts[item['title']] == 1
3308
+ [item['title'], item['type']]
3309
+ else
3310
+ [item['id'][0..4], "#{item['type']} #{item['title']}"]
3311
+ end
3312
+ end
3313
+ end
3314
+
3315
+ def print_counted_names(items, color:, indent: "")
3316
+ entries = items.map do |item|
3317
+ "#{item['title'].colorize(color)} #{"[#{item['note_count']}]".colorize(mode: :dim)}"
3318
+ end
3319
+ print_in_columns(entries, indent: indent)
3320
+ end
3321
+
3322
+ def ls_note_date_scope
3323
+ @ls_note_date_scope ||= if options[:date]
3324
+ parse_date_query(options[:date])[0, 3]
3325
+ else
3326
+ [nil, nil, '%Y-%m-%d']
3327
+ end
3328
+ end
3329
+
3330
+ def ls_note_date_clause(table_alias)
3331
+ range, date_filter, date_format = ls_note_date_scope
3332
+ if range
3333
+ ["#{table_alias}.updated_time >= ? AND #{table_alias}.updated_time <= ?", [range[0] * 1000, range[1] * 1000]]
3334
+ elsif date_filter
3335
+ ["strftime('#{date_format}', #{table_alias}.updated_time / 1000, 'unixepoch', 'localtime') = ?", [date_filter]]
3336
+ else
3337
+ [nil, []]
3338
+ end
3339
+ end
3340
+
3341
+ def ls_notes_where(table_alias, *clauses)
3342
+ date_clause, date_params = ls_note_date_clause(table_alias)
3343
+ where = [*clauses, "#{table_alias}.deleted_time = 0", date_clause].compact.join(" AND ")
3344
+ [where, date_params]
3345
+ end
3346
+
3347
+ def list_overview
3348
+ sn, st = options[:nb] || (!options[:type]), options[:type] || (!options[:nb])
3349
+ long = options[:long]
3350
+ note_date_clause, note_date_params = ls_note_date_clause("n")
3351
+ note_join_clause = ["n.parent_id = f.id", "n.deleted_time = 0", note_date_clause].compact.join(" AND ")
3352
+ if sn
3353
+ puts "Notebooks:".bold.yellow
3354
+ if options[:recursive]; list_notebooks_recursive
3355
+ elsif options[:tree]; list_notebook_tree
3356
+ else
3357
+ notebooks = if options[:all]
3358
+ @db.execute(<<~SQL, note_date_params)
3359
+ SELECT f.id, f.title, COUNT(n.id) AS note_count
3360
+ FROM folders f
3361
+ LEFT JOIN notes n ON #{note_join_clause}
3362
+ WHERE f.deleted_time = 0
3363
+ GROUP BY f.id, f.title
3364
+ ORDER BY f.title COLLATE NOCASE
3365
+ SQL
3366
+ else
3367
+ @db.execute(<<~SQL, note_date_params)
3368
+ SELECT f.id, f.title, COUNT(n.id) AS note_count
3369
+ FROM folders f
3370
+ LEFT JOIN notes n ON #{note_join_clause}
3371
+ WHERE f.parent_id = '' AND f.deleted_time = 0
3372
+ GROUP BY f.id, f.title
3373
+ ORDER BY f.title COLLATE NOCASE
3374
+ SQL
3375
+ end
3376
+ if long
3377
+ print_counted_names(notebooks, color: :blue)
3378
+ else
3379
+ print_in_columns(notebooks.map { |notebook| notebook['title'] }, color: :blue)
3380
+ end
3381
+ end
3382
+ puts ""
3383
+ end
3384
+ if st
3385
+ puts "Tags:".bold.yellow
3386
+ tag_note_date_clause, tag_note_date_params = ls_note_date_clause("n")
3387
+ tag_note_join_clause = ["n.id = nt.note_id", "n.deleted_time = 0", tag_note_date_clause].compact.join(" AND ")
3388
+ tags = @db.execute(<<~SQL, tag_note_date_params)
3389
+ SELECT t.id, t.title, COUNT(DISTINCT n.id) AS note_count
3390
+ FROM tags t
3391
+ LEFT JOIN note_tags nt ON nt.tag_id = t.id
3392
+ LEFT JOIN notes n ON #{tag_note_join_clause}
3393
+ GROUP BY t.id, t.title
3394
+ ORDER BY t.title COLLATE NOCASE
3395
+ SQL
3396
+ if long
3397
+ print_counted_names(tags, color: :green)
3398
+ else
3399
+ print_in_columns(tags.map { |t| t['title'] }, color: :green)
3400
+ end
3401
+ puts ""
3402
+ end
3403
+ end
3404
+
3405
+ def list_notebook_tree
3406
+ note_date_clause, note_date_params = ls_note_date_clause("n")
3407
+ note_join_clause = ["n.parent_id = f.id", "n.deleted_time = 0", note_date_clause].compact.join(" AND ")
3408
+ folders = @db.execute(<<~SQL, note_date_params)
3409
+ SELECT f.id, f.title, f.parent_id, COUNT(n.id) AS note_count
3410
+ FROM folders f
3411
+ LEFT JOIN notes n ON #{note_join_clause}
3412
+ WHERE f.deleted_time = 0
3413
+ GROUP BY f.id, f.title, f.parent_id
3414
+ ORDER BY f.title COLLATE NOCASE
3415
+ SQL
3416
+ tree = Hash.new { |h, k| h[k] = [] }
3417
+ folders.each { |f| tree[f['parent_id']] << f }
3418
+ print_tree_node(tree, "", "")
3419
+ end
3420
+
3421
+ def print_tree_node(tree, parent_id, indent = "")
3422
+ tree[parent_id].each_with_index do |node, index|
3423
+ last = (index == tree[parent_id].size - 1)
3424
+ count = options[:long] ? " #{"[#{node['note_count']}]".colorize(mode: :dim)}" : ""
3425
+ puts "#{indent}#{last ? "└── " : "├── "}#{node['title'].blue}#{count}"
3426
+ print_tree_node(tree, node['id'], indent + (last ? " " : "│ "))
3427
+ end
3428
+ end
3429
+
3430
+ def list_notebooks_recursive(notebook = nil)
3431
+ parent_id = notebook ? notebook['id'] : ""
3432
+ path = notebook ? get_full_path(notebook) : "."
3433
+ print_recursive_notebook_contents(parent_id, path)
3434
+ end
3435
+
3436
+ def print_recursive_notebook_contents(parent_id, path)
3437
+ puts "#{path}:".bold.yellow
3438
+ note_date_clause, note_date_params = ls_note_date_clause("n")
3439
+ note_join_clause = ["n.parent_id = f.id", "n.deleted_time = 0", note_date_clause].compact.join(" AND ")
3440
+ subfolders = @db.execute(
3441
+ <<~SQL,
3442
+ SELECT f.id, f.title, f.parent_id, COUNT(n.id) AS note_count
3443
+ FROM folders f
3444
+ LEFT JOIN notes n ON #{note_join_clause}
3445
+ WHERE f.parent_id = ? AND f.deleted_time = 0
3446
+ GROUP BY f.id, f.title, f.parent_id
3447
+ ORDER BY f.title COLLATE NOCASE
3448
+ SQL
3449
+ [*note_date_params, parent_id]
3450
+ )
3451
+ notes_where, notes_params = ls_notes_where("n", "n.parent_id = ?")
3452
+ notes = @db.execute(
3453
+ "SELECT id, title, updated_time FROM notes n WHERE #{notes_where} ORDER BY title COLLATE NOCASE",
3454
+ [parent_id, *notes_params]
3455
+ )
3456
+
3457
+ if options[:long]
3458
+ print_counted_names(subfolders, color: :blue)
3459
+ else
3460
+ print_in_columns(subfolders.map { |folder| folder['title'] }, color: :blue) if subfolders.any?
3461
+ end
3462
+ notes.each do |note|
3463
+ timestamp = Time.at(note['updated_time'] / 1000).strftime('%Y-%m-%d %H:%M').colorize(mode: :dim)
3464
+ id = note['id'][0..4].colorize(mode: :dim)
3465
+ puts "#{id} #{timestamp} #{note['title']}"
3466
+ end
3467
+
3468
+ subfolders.each do |folder|
3469
+ puts ""
3470
+ print_recursive_notebook_contents(folder['id'], path == "." ? folder['title'] : "#{path}/#{folder['title']}")
3471
+ end
3472
+ end
3473
+
3474
+ def list_target(target)
3475
+ # Prioritize ID resolution since IDs are unique across Joplin
3476
+ if target =~ /^[a-f0-9]{1,32}$/i
3477
+ notebooks = @db.execute("SELECT id, title, parent_id FROM folders WHERE id LIKE ? AND deleted_time = 0", ["#{target.downcase}%"])
3478
+ tags = @db.execute("SELECT id, title FROM tags WHERE id LIKE ?", ["#{target.downcase}%"])
3479
+ notes_where, notes_params = ls_notes_where("n", "n.id LIKE ?")
3480
+ notes = @db.execute(
3481
+ "SELECT id, title, parent_id FROM notes n WHERE #{notes_where}",
3482
+ ["#{target.downcase}%", *notes_params]
3483
+ )
3484
+
3485
+ if notebooks.any? || tags.any? || notes.any?
3486
+ notebooks.each { |n| list_notebook_contents(n) }
3487
+ tags.each { |t| list_tag_contents(t) }
3488
+ notes.each do |n|
3489
+ id_hdr = options[:long] ? " (#{n['id'][0..4].colorize(:mode => :dim)})" : ""
3490
+ puts "Note: #{get_full_path_for_note(n)}#{id_hdr}".bold.yellow
3491
+ end
3492
+ return
3493
+ end
3494
+ end
3495
+
3496
+ sn, st = options[:nb] || (!options[:type]), options[:type] || (!options[:nb])
3497
+ if target =~ /^n:(.+)$/; sn, st, target = true, false, $1
3498
+ elsif target =~ /^t:(.+)$/; sn, st, target = false, true, $1; end
3499
+
3500
+ found = false
3501
+ if sn
3502
+ notebooks = find_notebooks_by_path(target)
3503
+ notebooks.each do |notebook|
3504
+ list_notebook_contents(notebook)
3505
+ found = true
3506
+ end
3507
+ end
3508
+ if st
3509
+ tags = find_tags_by_id_or_title(target)
3510
+ tags.each do |tag|
3511
+ puts "" if found
3512
+ list_tag_contents(tag)
3513
+ found = true
3514
+ end
3515
+ end
3516
+ say "No notebook or tag found matching '#{target}'." unless found
3517
+ end
3518
+
3519
+ def list_notebook_contents(notebook)
3520
+ long = options[:long]
3521
+ id_header = long ? " (#{notebook['id'][0..4].colorize(mode: :dim)})" : ""
3522
+ puts "Notebook: #{get_full_path(notebook)}#{id_header}:".bold.yellow
3523
+ if options[:recursive]
3524
+ list_notebooks_recursive(notebook)
3525
+ return
3526
+ elsif options[:tree]
3527
+ puts "Notebooks:".bold.yellow
3528
+ folders = @db.execute(<<~SQL)
3529
+ SELECT f.id, f.title, f.parent_id, COUNT(n.id) AS note_count
3530
+ FROM folders f
3531
+ LEFT JOIN notes n ON n.parent_id = f.id AND n.deleted_time = 0
3532
+ WHERE f.deleted_time = 0
3533
+ GROUP BY f.id, f.title, f.parent_id
3534
+ ORDER BY f.title COLLATE NOCASE
3535
+ SQL
3536
+ tree = Hash.new { |h, k| h[k] = [] }
3537
+ folders.each { |f| tree[f['parent_id']] << f }
3538
+ print_tree_node(tree, notebook['id'], "")
3539
+ else
3540
+ note_date_clause, note_date_params = ls_note_date_clause("n")
3541
+ note_join_clause = ["n.parent_id = f.id", "n.deleted_time = 0", note_date_clause].compact.join(" AND ")
3542
+ subfolders = @db.execute(<<~SQL, [*note_date_params, notebook['id']])
3543
+ SELECT f.id, f.title, COUNT(n.id) AS note_count
3544
+ FROM folders f
3545
+ LEFT JOIN notes n ON #{note_join_clause}
3546
+ WHERE f.parent_id = ? AND f.deleted_time = 0
3547
+ GROUP BY f.id, f.title
3548
+ ORDER BY f.title COLLATE NOCASE
3549
+ SQL
3550
+ if subfolders.any?
3551
+ puts "Notebooks:".bold.yellow
3552
+ if long
3553
+ print_counted_names(subfolders, color: :blue, indent: " ")
3554
+ else
3555
+ print_in_columns(subfolders.map { |f| f['title'] }, color: :blue)
3556
+ end
3557
+ puts ""
3558
+ end
3559
+ end
3560
+
3561
+ notes_where, notes_params = ls_notes_where("n", "n.parent_id = ?")
3562
+ notes = @db.execute(
3563
+ "SELECT id, title, updated_time FROM notes n WHERE #{notes_where} ORDER BY updated_time DESC",
3564
+ [notebook['id'], *notes_params]
3565
+ )
3566
+ if notes.any?
3567
+ puts "Notes:".bold.yellow
3568
+ notes.each do |n|
3569
+ ts = Time.at(n['updated_time'] / 1000).strftime('%Y-%m-%d %H:%M').colorize(mode: :dim)
3570
+ id_str = n['id'][0..4].colorize(:mode => :dim)
3571
+ puts "#{id_str} #{ts} #{n['title']}"
3572
+ end
3573
+ end
3574
+ puts ""
3575
+ end
3576
+
3577
+ def list_tag_contents(tag)
3578
+ notes_where, notes_params = ls_notes_where("n", "nt.tag_id = ?")
3579
+ notes = @db.execute(
3580
+ "SELECT n.id, n.title, n.updated_time FROM notes n JOIN note_tags nt ON n.id = nt.note_id WHERE #{notes_where} ORDER BY n.updated_time DESC",
3581
+ [tag['id'], *notes_params]
3582
+ )
3583
+ id_header = options[:long] ? " (#{tag['id'][0..4].colorize(mode: :dim)})" : ""
3584
+ puts "Notes tagged with '#{tag['title']}'#{id_header}:".bold.yellow
3585
+ notes.each do |n|
3586
+ ts = Time.at(n['updated_time'] / 1000).strftime('%Y-%m-%d %H:%M').colorize(mode: :dim)
3587
+ id_str = n['id'][0..4].colorize(:mode => :dim)
3588
+ puts "#{id_str} #{ts} #{n['title']}"
3589
+ end
3590
+ end
3591
+
3592
+ def find_notes(target)
3593
+ # 1. ID Match (Short or Full)
3594
+ if target =~ /^[a-f0-9]{1,32}$/i
3595
+ rows = @db.execute("SELECT n.* FROM notes n WHERE n.id LIKE ? AND n.deleted_time = 0", ["#{target.downcase}%"])
3596
+ return rows if rows.any?
3597
+ end
3598
+
3599
+ # 2. Path Match (Notebook/Note)
3600
+ if target.include?('/')
3601
+ parts = target.split('/')
3602
+ note_title = parts.pop
3603
+ folder_path = parts.join('/')
3604
+ folders = find_notebooks_by_path(folder_path)
3605
+ if folders.any?
3606
+ placeholders = folders.map { "?" }.join(',')
3607
+ return @db.execute("SELECT n.* FROM notes n WHERE n.title = ? AND n.parent_id IN (#{placeholders}) AND n.deleted_time = 0", [note_title, *folders.map { |f| f['id'] }])
3608
+ end
3609
+ end
3610
+
3611
+ # 3. Exact Title Match
3612
+ @db.execute("SELECT n.* FROM notes n WHERE n.title = ? AND n.deleted_time = 0", [target])
3613
+ end
3614
+
3615
+ def fetch_notes(query: nil)
3616
+ params = []
3617
+ sql = "SELECT n.* FROM notes n WHERE n.deleted_time = 0"
3618
+ if query
3619
+ sql += " AND (n.title LIKE ? OR n.body LIKE ?)"
3620
+ params << "%#{query}%"
3621
+ params << "%#{query}%"
3622
+ end
3623
+ sql += " ORDER BY n.updated_time DESC"
3624
+ @db.execute(sql, params)
3625
+ end
3626
+
3627
+ def get_snippet(text, query, context = 40)
3628
+ idx = text.downcase.index(query.downcase)
3629
+ return nil unless idx
3630
+
3631
+ start = [0, idx - context].max
3632
+ finish = [text.length, idx + query.length + context].min
3633
+
3634
+ snippet = text[start...finish].gsub("\n", " ")
3635
+ snippet = "..." + snippet if start > 0
3636
+ snippet = snippet + "..." if finish < text.length
3637
+
3638
+ # Highlight in snippet
3639
+ snippet.gsub(/(#{Regexp.escape(query)})/i) { |m| m.red.on_yellow.bold }.colorize(:mode => :dim)
3640
+ end
3641
+
3642
+ def get_full_path_for_note(note)
3643
+ folder = @db.get_first_row("SELECT id, title, parent_id FROM folders WHERE id = ?", [note['parent_id']])
3644
+ if folder
3645
+ "#{get_full_path(folder)}/#{note['title']}"
3646
+ else
3647
+ note['title']
3648
+ end
3649
+ end
3650
+
3651
+ def find_notebooks_by_path(path)
3652
+ # Short ID or Full ID match
3653
+ if path =~ /^[a-f0-9]{1,32}$/i
3654
+ rows = @db.execute("SELECT id, title, parent_id FROM folders WHERE id LIKE ? AND deleted_time = 0", ["#{path.downcase}%"])
3655
+ return rows if rows.any?
3656
+ end
3657
+
3658
+ # Exact title match (might contain slashes)
3659
+ exact = @db.execute("SELECT id, title, parent_id FROM folders WHERE title = ? AND deleted_time = 0", [path])
3660
+ return exact if exact.any?
3661
+
3662
+ if path.include?('/')
3663
+ parts = path.split('/')
3664
+ candidates = [""] # parent_ids
3665
+ notebooks = []
3666
+ parts.each_with_index do |part, index|
3667
+ new_candidates = []
3668
+ candidates.each do |pid|
3669
+ rows = @db.execute("SELECT id, title, parent_id FROM folders WHERE title = ? AND parent_id = ? AND deleted_time = 0", [part, pid])
3670
+ rows.each do |row|
3671
+ if index == parts.size - 1
3672
+ notebooks << row
3673
+ else
3674
+ new_candidates << row['id']
3675
+ end
3676
+ end
3677
+ end
3678
+ candidates = new_candidates
3679
+ break if candidates.empty? && notebooks.empty?
3680
+ end
3681
+ notebooks
3682
+ else
3683
+ @db.execute("SELECT id, title, parent_id FROM folders WHERE title = ? AND deleted_time = 0", [path])
3684
+ end
3685
+ end
3686
+
3687
+ def find_tags_by_id_or_title(target)
3688
+ if target =~ /^[a-f0-9]{1,32}$/i
3689
+ rows = @db.execute("SELECT id, title FROM tags WHERE id LIKE ?", ["#{target.downcase}%"])
3690
+ return rows if rows.any?
3691
+ end
3692
+ @db.execute("SELECT id, title FROM tags WHERE title = ?", [target])
3693
+ end
3694
+
3695
+ def get_full_path(notebook)
3696
+ path = [notebook['title']]
3697
+ current = notebook
3698
+ while current['parent_id'] != ""
3699
+ current = @db.get_first_row("SELECT id, title, parent_id FROM folders WHERE id = ?", [current['parent_id']])
3700
+ break unless current
3701
+ path.unshift(current['title'])
3702
+ end
3703
+ path.join('/')
3704
+ end
3705
+
3706
+ def print_in_columns(items, color: nil, indent: "")
3707
+ return if items.empty?
3708
+ w = (IO.console.winsize[1] rescue 80)
3709
+ visible_length = ->(item) { item.gsub(/\e\[(\d+;?)*m/, '').length }
3710
+ ml = (items.map { |item| visible_length.call(item) }.max || 0) + 2
3711
+ cols = [1, ((w - indent.length) / ml).to_i].max
3712
+ rows = (items.size.to_f / cols).ceil
3713
+ rows.times do |r|
3714
+ print indent
3715
+ cols.times do |c|
3716
+ idx = r + c * rows
3717
+ if idx < items.size
3718
+ item = items[idx]
3719
+ padding = " " * (ml - visible_length.call(item))
3720
+ print color ? "#{item.colorize(color)}#{padding}" : "#{item}#{padding}"
3721
+ end
3722
+ end
3723
+ puts ""
3724
+ end
3725
+ end
3726
+
3727
+ DATE_SHORTCUTS = %w(today yesterday week month year last-week last-month)
3728
+ DATE_REGEXES = {
3729
+ iso: /^\d{4}(-\d{2})?(-\d{2})?$/,
3730
+ days: /^(last)?(\d+)days?$/,
3731
+ weeks: /^(last)?(\d+)weeks?$/,
3732
+ months: /^(last)?(\d+)months?$/,
3733
+ years: /^(last)?(\d+)years?$/,
3734
+ hours: /^(last)?(\d+)hours?$/,
3735
+ ago: /^(\d+)(day|week|month|year)s?ago$/
3736
+ }
3737
+
3738
+ def parse_date_query(arg)
3739
+ return [nil, nil, '%Y-%m-%d', Date.today] if arg.nil?
3740
+
3741
+ range = nil
3742
+ date_filter = arg
3743
+ format = '%Y-%m-%d'
3744
+ anchor_date = Date.today
3745
+
3746
+ case arg
3747
+ when 'today'
3748
+ anchor_date = Date.today
3749
+ s = anchor_date.to_time.to_i
3750
+ range = [s, s + 86400 - 1]
3751
+ date_filter = nil
3752
+ when 'yesterday'
3753
+ anchor_date = Date.today - 1
3754
+ s = anchor_date.to_time.to_i
3755
+ range = [s, s + 86400 - 1]
3756
+ date_filter = nil
3757
+ when 'week', 'last-week'
3758
+ anchor_date = Date.today - 6
3759
+ range = [anchor_date.to_time.to_i, Time.now.to_i]
3760
+ date_filter = nil
3761
+ when 'month'
3762
+ anchor_date = Date.new(Date.today.year, Date.today.month, 1)
3763
+ s = anchor_date.to_time.to_i
3764
+ nxt = Date.today.next_month
3765
+ e = Date.new(nxt.year, nxt.month, 1).to_time.to_i - 1
3766
+ range = [s, e]
3767
+ date_filter = nil
3768
+ when 'last-month'
3769
+ anchor_date = Date.today.prev_month
3770
+ s = Date.new(anchor_date.year, anchor_date.month, 1).to_time.to_i
3771
+ e = Date.new(Date.today.year, Date.today.month, 1).to_time.to_i - 1
3772
+ range = [s, e]
3773
+ date_filter = nil
3774
+ when 'year'
3775
+ anchor_date = Date.new(Date.today.year, 1, 1)
3776
+ s = anchor_date.to_time.to_i
3777
+ e = Date.new(Date.today.year + 1, 1, 1).to_time.to_i - 1
3778
+ range = [s, e]
3779
+ date_filter = nil
3780
+ when DATE_REGEXES[:days]
3781
+ days = $2.to_i
3782
+ anchor_date = Date.today - (days - 1)
3783
+ range = [anchor_date.to_time.to_i, Time.now.to_i]
3784
+ date_filter = nil
3785
+ when DATE_REGEXES[:weeks]
3786
+ days = $2.to_i * 7
3787
+ anchor_date = Date.today - (days - 1)
3788
+ range = [anchor_date.to_time.to_i, Time.now.to_i]
3789
+ date_filter = nil
3790
+ when DATE_REGEXES[:months]
3791
+ anchor_date = Date.today << $2.to_i
3792
+ range = [anchor_date.to_time.to_i, Time.now.to_i]
3793
+ date_filter = nil
3794
+ when DATE_REGEXES[:years]
3795
+ anchor_date = Date.today << ($2.to_i * 12)
3796
+ range = [anchor_date.to_time.to_i, Time.now.to_i]
3797
+ date_filter = nil
3798
+ when DATE_REGEXES[:hours]
3799
+ hours = $2.to_i
3800
+ range = [Time.now.to_i - (hours * 3600), Time.now.to_i]
3801
+ date_filter = nil
3802
+ when DATE_REGEXES[:ago]
3803
+ n = $1.to_i
3804
+ unit = $2
3805
+ anchor_date = case unit
3806
+ when 'day' then Date.today - n
3807
+ when 'week' then Date.today - (n * 7)
3808
+ when 'month' then Date.today << n
3809
+ when 'year' then Date.today << (n * 12)
3810
+ end
3811
+ s = anchor_date.to_time.to_i
3812
+ range = [s, s + 86400 - 1]
3813
+ date_filter = nil
3814
+ when /^\d{4}-\d{2}$/
3815
+ format = '%Y-%m'
3816
+ begin
3817
+ anchor_date = Date.strptime(arg, format)
3818
+ rescue ArgumentError
3819
+ anchor_date = Date.today
3820
+ end
3821
+ when /^\d{4}$/
3822
+ format = '%Y'
3823
+ anchor_date = Date.new(arg.to_i, 1, 1)
3824
+ when /^\d{4}-\d{2}-\d{2}$/
3825
+ begin
3826
+ anchor_date = Date.parse(arg)
3827
+ rescue ArgumentError
3828
+ anchor_date = Date.today
3829
+ end
3830
+ end
3831
+
3832
+ [range, date_filter, format, anchor_date]
3833
+ end
3834
+
3835
+ def tag_matches_any?(tag_list, patterns)
3836
+ return false if patterns.empty?
3837
+ tags = tag_list.is_a?(Array) ? tag_list : tag_list.to_s.split(',').map(&:strip)
3838
+ tags.any? do |tag|
3839
+ patterns.any? { |p| File.fnmatch?(p, tag, File::FNM_CASEFOLD) }
3840
+ end
3841
+ end
3842
+
3843
+ def get_month_data(date, active_dates)
3844
+ start_day = Date.new(date.year, date.month, 1).wday
3845
+ days_in_month = Date.new(date.year, date.month, -1).day
3846
+
3847
+ rows = []
3848
+ current_row = [" "] * start_day
3849
+
3850
+ (1..days_in_month).each do |day|
3851
+ curr = Date.new(date.year, date.month, day)
3852
+ day_str = day.to_s.rjust(2)
3853
+
3854
+ formatted_day = if active_dates.include?(curr.strftime('%Y-%m-%d'))
3855
+ day_str.bold.yellow
3856
+ else
3857
+ day_str.colorize(mode: :dim)
3858
+ end
3859
+ current_row << formatted_day
3860
+
3861
+ if current_row.size == 7
3862
+ rows << current_row
3863
+ current_row = []
3864
+ end
3865
+ end
3866
+ current_row += [" "] * (7 - current_row.size) unless current_row.empty?
3867
+ rows << current_row unless current_row.empty?
3868
+
3869
+ { title: date.strftime('%B'), rows: rows }
3870
+ end
3871
+
3872
+ def print_month(m, year = nil)
3873
+ say "\n #{m[:title]} #{year}".bold.white
3874
+ say "Su Mo Tu We Th Fr Sa".colorize(mode: :dim)
3875
+ m[:rows].each { |row| say row.join(" ") }
3876
+ end
3877
+
3878
+ def is_date_query?(arg)
3879
+ return false if arg.nil?
3880
+ return false if arg.start_with?('[') && arg.end_with?(']')
3881
+ return true if DATE_SHORTCUTS.include?(arg)
3882
+ DATE_REGEXES.any? { |_, re| arg =~ re }
3883
+ end
3884
+
3885
+ def fetch_notes_log(range: nil, date_filter: nil, date_format: '%Y-%m-%d')
3886
+ base_query = "SELECT n.*, (SELECT GROUP_CONCAT(t.title) FROM note_tags nt JOIN tags t ON nt.tag_id = t.id WHERE nt.note_id = n.id) as tags FROM notes n WHERE n.deleted_time = 0"
3887
+ params = []
3888
+ where_clauses = []
3889
+
3890
+ if range
3891
+ where_clauses << "n.updated_time >= ? AND n.updated_time <= ?"
3892
+ params << range[0] * 1000
3893
+ params << range[1] * 1000
3894
+ elsif date_filter
3895
+ where_clauses << "strftime('#{date_format}', n.updated_time / 1000, 'unixepoch', 'localtime') = ?"
3896
+ params << date_filter
3897
+ end
3898
+
3899
+ sql = base_query
3900
+ sql += " AND #{where_clauses.join(' AND ')}" unless where_clauses.empty?
3901
+ sql += " ORDER BY n.updated_time ASC"
3902
+ @db.execute(sql, params).each do |note|
3903
+ folder = @db.get_first_row("SELECT id, title, parent_id FROM folders WHERE id = ? AND deleted_time = 0", [note['parent_id']])
3904
+ note['notebook'] = folder ? folder['title'] : ""
3905
+ end
3906
+ end
3907
+
3908
+ def list_chronological(verbosity, slice_arg = nil, date_filter = nil, date_format = '%Y-%m-%d', exclude = [], range: nil, include_hidden: false)
3909
+ notes = fetch_notes_log(range: range, date_filter: date_filter, date_format: date_format)
3910
+
3911
+ unless include_hidden
3912
+ if exclude.any?
3913
+ notes = notes.reject { |n| tag_matches_any?(n['tags'], exclude) }
3914
+ end
3915
+ end
3916
+
3917
+ if slice_arg
3918
+ notes = apply_slice(notes, slice_arg)
3919
+ end
3920
+
3921
+ if notes.empty?
3922
+ say "No notes found."
3923
+ else
3924
+ display_log_notes(notes, verbosity)
3925
+ end
3926
+ end
3927
+
3928
+ def display_log_notes(notes, verbosity)
3929
+ notes.each do |note|
3930
+ timestamp = Time.at(note['updated_time'] / 1000).strftime('%Y-%m-%d %H:%M').colorize(mode: :dim)
3931
+ id = note['id'][0..4].colorize(mode: :dim)
3932
+ tags = note['tags'] || ''
3933
+ notebook = note['notebook'] || ''
3934
+
3935
+ case verbosity
3936
+ when 0
3937
+ say "#{id} #{timestamp} #{note['title']}"
3938
+ when 1
3939
+ notebook_part = notebook.empty? ? "" : "#{notebook.blue} "
3940
+ tags_part = tags.empty? ? "" : "[#{tags.green}] "
3941
+ say "#{id} #{timestamp} #{notebook_part}#{tags_part}#{note['title']}"
3942
+ else
3943
+ notebook_part = notebook.empty? ? "" : "#{notebook.blue} "
3944
+ tags_part = tags.empty? ? "" : "[#{tags.green}] "
3945
+ say "#{id} #{timestamp} #{notebook_part}#{tags_part}#{note['title']}"
3946
+ if note['body'] && !note['body'].strip.empty?
3947
+ width = (IO.console.winsize[1] rescue 80)
3948
+ say render_markdown(note['body'], width: width)
3949
+ end
3950
+ end
3951
+ end
3952
+ end
3953
+
3954
+ def apply_slice(entries, index_part)
3955
+ if index_part.include?('..')
3956
+ range = eval(index_part)
3957
+ entries[range] || []
3958
+ else
3959
+ index = index_part.to_i
3960
+ [entries[index]].compact
3961
+ end
3962
+ rescue => e
3963
+ say "Error parsing index [#{index_part}]:".yellow + " #{e.message}"
3964
+ []
3965
+ end
3966
+
3967
+ def determine_verbosity
3968
+ args = Thread.current[:jp_args] || ARGV
3969
+ count = args.grep(/^-/).join.count('v')
3970
+ count = 1 if count == 0 && (options[:verbose] || options[:long])
3971
+ count
3972
+ end
3973
+
3974
+ def prepare_yaml_edit_data_for_target(target)
3975
+ # Check if target matches note(s) first
3976
+ notes = find_notes(target)
3977
+ if notes.size == 1
3978
+ note = notes.first
3979
+ tags = @db.execute("SELECT t.title FROM tags t JOIN note_tags nt ON t.id = nt.tag_id WHERE nt.note_id = ?", [note['id']]).map { |r| r['title'] }
3980
+ h = {
3981
+ 'id' => note['id'],
3982
+ 'title' => note['title'],
3983
+ 'tags' => tags,
3984
+ 'body' => note['body'] || ""
3985
+ }
3986
+ return [[h], "Updated note '#{note['title'].green}'."]
3987
+ end
3988
+
3989
+ # If not a note, check if it's a tag
3990
+ tag_row = @db.get_first_row("SELECT * FROM tags WHERE title = ? OR id LIKE ?", [target, "#{target.downcase}%"])
3991
+ if tag_row
3992
+ notes = @db.execute("SELECT n.* FROM notes n JOIN note_tags nt ON n.id = nt.note_id WHERE nt.tag_id = ? AND n.deleted_time = 0 ORDER BY n.updated_time ASC", [tag_row['id']])
3993
+ stack_data = notes.map do |n|
3994
+ tags = @db.execute("SELECT t.title FROM tags t JOIN note_tags nt ON t.id = nt.tag_id WHERE nt.note_id = ?", [n['id']]).map { |r| r['title'] }
3995
+ {
3996
+ 'id' => n['id'],
3997
+ 'title' => n['title'],
3998
+ 'tags' => tags,
3999
+ 'body' => n['body'] || ""
4000
+ }
4001
+ end
4002
+ return [stack_data, "Updated stack for tag '#{tag_row['title'].blue}'."]
4003
+ end
4004
+
4005
+ nil
4006
+ end
4007
+
4008
+ def apply_yaml_changes(initial_data, new_data)
4009
+ @db.transaction do
4010
+ new_data.each do |new_n|
4011
+ old_n = initial_data.find { |o| o['id'] == new_n['id'] }
4012
+ if old_n
4013
+ updates = []
4014
+ params = []
4015
+
4016
+ if new_n['title'] != old_n['title']
4017
+ updates << "title = ?"
4018
+ params << new_n['title']
4019
+ end
4020
+
4021
+ if new_n['body'] != old_n['body']
4022
+ updates << "body = ?"
4023
+ params << new_n['body']
4024
+ end
4025
+
4026
+ if updates.any?
4027
+ updates << "updated_time = ?"
4028
+ params << Time.now.to_i * 1000
4029
+ query = "UPDATE notes SET #{updates.join(', ')} WHERE id = ?"
4030
+ params << new_n['id']
4031
+ @db.execute(query, params)
4032
+ end
4033
+
4034
+ # Sync tags
4035
+ new_tags = normalize_add_tags(new_n['tags'])
4036
+ old_tags = normalize_add_tags(old_n['tags'])
4037
+ now = Time.now.to_i * 1000
4038
+
4039
+ # Added tags
4040
+ (new_tags - old_tags).each do |tag_title|
4041
+ tag = @db.get_first_row("SELECT id FROM tags WHERE title = ?", [tag_title])
4042
+ unless tag
4043
+ tag_id = SecureRandom.hex(16)
4044
+ insert_available_columns('tags', {
4045
+ 'id' => tag_id,
4046
+ 'title' => tag_title,
4047
+ 'created_time' => now,
4048
+ 'updated_time' => now,
4049
+ 'user_created_time' => now,
4050
+ 'user_updated_time' => now
4051
+ })
4052
+ tag = { 'id' => tag_id }
4053
+ end
4054
+
4055
+ existing = @db.get_first_row(
4056
+ "SELECT id FROM note_tags WHERE note_id = ? AND tag_id = ?",
4057
+ [new_n['id'], tag['id']]
4058
+ )
4059
+ next if existing
4060
+
4061
+ insert_available_columns('note_tags', {
4062
+ 'id' => SecureRandom.hex(16),
4063
+ 'note_id' => new_n['id'],
4064
+ 'tag_id' => tag['id'],
4065
+ 'created_time' => now,
4066
+ 'updated_time' => now,
4067
+ 'user_created_time' => now,
4068
+ 'user_updated_time' => now
4069
+ })
4070
+ end
4071
+
4072
+ # Removed tags
4073
+ (old_tags - new_tags).each do |tag_title|
4074
+ tag = @db.get_first_row("SELECT * FROM tags WHERE title = ?", [tag_title])
4075
+ if tag
4076
+ @db.execute("DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?", [new_n['id'], tag['id']])
4077
+ end
4078
+ end
4079
+ end
4080
+ end
4081
+
4082
+ new_ids = new_data.map { |n| n['id'] }
4083
+ initial_data.each do |old_n|
4084
+ unless new_ids.include?(old_n['id'])
4085
+ @db.execute("UPDATE notes SET deleted_time = ? WHERE id = ?", [Time.now.to_i * 1000, old_n['id']])
4086
+ end
4087
+ end
4088
+ end
4089
+ end
4090
+
4091
+ def perform_yaml_edit(initial_data, success_msg)
4092
+ temp = Tempfile.new(['jp_edit', '.yml'])
4093
+ begin
4094
+ temp.write(initial_data.to_yaml)
4095
+ temp.close
4096
+ editor = ENV['EDITOR'] || 'vi'
4097
+ system("#{editor} #{temp.path}")
4098
+
4099
+ begin
4100
+ new_data = YAML.load_file(temp.path)
4101
+ new_data = [new_data] unless new_data.is_a?(Array)
4102
+ rescue Psych::SyntaxError => e
4103
+ say "Error parsing YAML:".yellow + " #{e.message}", :red
4104
+ return
4105
+ end
4106
+
4107
+ return say "No changes made." if new_data == initial_data
4108
+
4109
+ apply_yaml_changes(initial_data, new_data)
4110
+ say success_msg
4111
+ auto_bkup_if_needed
4112
+ ensure
4113
+ temp.unlink
4114
+ end
4115
+ end
4116
+
4117
+ def edit_single_note(note)
4118
+ temp = Tempfile.new(['jp_edit', '.md'])
4119
+ begin
4120
+ temp.write("#{note['title']}\n\n#{note['body'] || ''}")
4121
+ temp.close
4122
+ editor = ENV['EDITOR'] || 'vi'
4123
+ success = system("#{editor} #{Shellwords.escape(temp.path)}")
4124
+ return say "Edit cancelled." unless success
4125
+
4126
+ lines = File.read(temp.path).lines
4127
+ new_title = lines.shift.to_s.chomp
4128
+ lines.shift
4129
+ new_body = lines.join.delete_suffix("\n")
4130
+
4131
+ if new_title != note['title'].to_s || new_body != (note['body'] || "")
4132
+ now_ms = Time.now.to_i * 1000
4133
+ @db.execute(
4134
+ "UPDATE notes SET title = ?, body = ?, updated_time = ? WHERE id = ?",
4135
+ [new_title, new_body, now_ms, note['id']]
4136
+ )
4137
+ say "Updated note '#{new_title.green}'."
4138
+ auto_bkup_if_needed
4139
+ else
4140
+ say "No changes made."
4141
+ end
4142
+ ensure
4143
+ temp.unlink
4144
+ end
4145
+ end
4146
+
4147
+ def edit_tag_stack(tag)
4148
+ result = prepare_yaml_edit_data_for_target(tag)
4149
+ if result
4150
+ data, msg = result
4151
+ perform_yaml_edit(data, msg)
4152
+ else
4153
+ say "Tag '#{tag.cyan}' not found."
4154
+ end
4155
+ end
4156
+ end
4157
+
4158
+ CLI.start(ARGV)