joplin 1.0.2 → 1.2.0

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