joplin 1.2.0 → 1.3.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.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/Makefile +1 -0
  4. data/bin/jp +917 -73
  5. data/lib/joplin/version.rb +1 -1
  6. data/readme.md +66 -0
  7. metadata +1 -1
data/bin/jp CHANGED
@@ -10,9 +10,8 @@ require 'uri'
10
10
  require 'io/console'
11
11
  require 'stringio'
12
12
  require 'shellwords'
13
+ require 'open3'
13
14
  require 'json'
14
- require 'bundler/setup'
15
-
16
15
 
17
16
  # Polyfill for CGI.parse which was removed in Ruby 4.x
18
17
  unless CGI.respond_to?(:parse)
@@ -69,14 +68,18 @@ class TestRunner
69
68
  db = SQLite3::Database.new(@db_path)
70
69
  db.execute_batch <<-SQL
71
70
  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);
71
+ CREATE TABLE notes (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL DEFAULT "", title TEXT, body TEXT, created_time INT, updated_time INT, user_created_time INT, user_updated_time INT, latitude REAL, longitude REAL, altitude REAL, author TEXT, source_url TEXT, is_todo INT NOT NULL DEFAULT 0, todo_due INT NOT NULL DEFAULT 0, todo_completed INT NOT NULL DEFAULT 0, deleted_time INT NOT NULL DEFAULT 0);
73
72
  CREATE TABLE tags (id TEXT PRIMARY KEY, title TEXT);
74
73
  CREATE TABLE note_tags (id TEXT PRIMARY KEY, note_id TEXT, tag_id TEXT);
74
+ CREATE TABLE resources (id TEXT PRIMARY KEY, title TEXT, mime TEXT, filename TEXT, file_extension TEXT);
75
+ CREATE TABLE alarms (id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL, trigger_time INT NOT NULL);
75
76
  SQL
76
77
  db.close
77
78
 
78
79
  @old_joplin_data_dir = ENV['JOPLIN_DATA_DIR']
79
80
  ENV['JOPLIN_DATA_DIR'] = @dir
81
+ @old_core_location_cli = ENV['CORE_LOCATION_CLI']
82
+ ENV['CORE_LOCATION_CLI'] = ""
80
83
 
81
84
  @old_stdout = $stdout
82
85
  @old_stderr = $stderr
@@ -153,6 +156,7 @@ class TestRunner
153
156
  $stdout = @old_stdout
154
157
  $stderr = @old_stderr
155
158
  ENV['JOPLIN_DATA_DIR'] = @old_joplin_data_dir
159
+ ENV['CORE_LOCATION_CLI'] = @old_core_location_cli
156
160
  db.close rescue nil
157
161
  FileUtils.remove_entry(@dir) rescue nil
158
162
  end
@@ -474,6 +478,106 @@ module TestSuite
474
478
  assert_includes out, Time.at(backup_epoch).strftime('%Y-%m-%d %H:%M:%S')
475
479
  assert_includes out, "(1 hours ago)"
476
480
  end
481
+
482
+ it "shows when CoreLocationCLI is not installed on macOS" do
483
+ runner.cli.send(:show_core_location_cli_info, macos: true)
484
+ runner.flush_output
485
+
486
+ out = TestSuite.strip_ansi(runner.last_stdout)
487
+ assert_includes out, "WARNING: CoreLocationCLI is not installed. New notes will not include GPS coordinates."
488
+ end
489
+
490
+ it "does not show CoreLocationCLI when it is installed" do
491
+ cli = File.join(runner.dir, "CoreLocationCLI")
492
+ File.write(cli, "#!/bin/sh\nexit 0\n")
493
+ FileUtils.chmod(0o755, cli)
494
+ ENV['CORE_LOCATION_CLI'] = cli
495
+
496
+ runner.cli.send(:show_core_location_cli_info, macos: true)
497
+ runner.flush_output
498
+
499
+ assert_empty runner.last_stdout
500
+ end
501
+ end
502
+
503
+ describe "active alarm warnings" do
504
+ it "shows a triggered reminder before command output" do
505
+ trigger_time = (Time.now.to_i - 60) * 1000
506
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f1', 'Work')")
507
+ runner.db.execute("INSERT INTO notes (id, title, is_todo) VALUES ('n111111', 'Submit report', 1)")
508
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('n111111', ?)", [trigger_time])
509
+
510
+ runner.run_cmd("ls")
511
+ out = TestSuite.strip_ansi(runner.last_stdout)
512
+ expected = "WARNING: 1 active reminder\n #{Time.at(trigger_time / 1000).strftime('%Y-%m-%d %H:%M')} [n1111] Submit report\n\n"
513
+
514
+ assert out.start_with?(expected)
515
+ assert_operator out.index("Submit report"), :<, out.index("Work")
516
+ end
517
+
518
+ it "lists multiple reminders in trigger-time order" do
519
+ later = (Time.now.to_i - 60) * 1000
520
+ earlier = later - 60_000
521
+ runner.db.execute("INSERT INTO notes (id, title, is_todo) VALUES ('n111111', 'Later', 1)")
522
+ runner.db.execute("INSERT INTO notes (id, title, is_todo) VALUES ('n222222', 'Earlier', 1)")
523
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('n111111', ?)", [later])
524
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('n222222', ?)", [earlier])
525
+
526
+ runner.run_cmd("version")
527
+ out = TestSuite.strip_ansi(runner.last_stdout)
528
+
529
+ assert out.start_with?("WARNING: 2 active reminders\n")
530
+ assert_operator out.index("Earlier"), :<, out.index("Later")
531
+ assert out.end_with?("#{CLI::VERSION}\n")
532
+ end
533
+
534
+ it "excludes inactive and invalid alarms" do
535
+ now = Time.now.to_i * 1000
536
+ notes = [
537
+ ['active1', 'Active', 1, 0, 0],
538
+ ['future1', 'Future', 1, 0, 0],
539
+ ['done111', 'Completed', 1, now - 10_000, 0],
540
+ ['deleted', 'Deleted', 1, 0, now - 10_000],
541
+ ['regular', 'Regular note', 0, 0, 0],
542
+ ['zero111', 'Zero trigger', 1, 0, 0]
543
+ ]
544
+ notes.each do |id, title, is_todo, completed, deleted|
545
+ runner.db.execute(
546
+ "INSERT INTO notes (id, title, is_todo, todo_completed, deleted_time) VALUES (?, ?, ?, ?, ?)",
547
+ [id, title, is_todo, completed, deleted]
548
+ )
549
+ end
550
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('active1', ?)", [now - 60_000])
551
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('future1', ?)", [now + 60_000])
552
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('done111', ?)", [now - 60_000])
553
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('deleted', ?)", [now - 60_000])
554
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('regular', ?)", [now - 60_000])
555
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('zero111', 0)")
556
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('missing', ?)", [now - 60_000])
557
+
558
+ runner.run_cmd("version")
559
+ out = TestSuite.strip_ansi(runner.last_stdout)
560
+
561
+ assert_includes out, "Active"
562
+ %w[Future Completed Deleted Regular Zero].each { |title| refute_includes out, title }
563
+ end
564
+
565
+ it "suppresses reminders for raw and completion output" do
566
+ trigger_time = (Time.now.to_i - 60) * 1000
567
+ runner.db.execute("INSERT INTO notes (id, title, body, is_todo) VALUES ('a111111', 'Raw note', '# Raw', 1)")
568
+ runner.db.execute("INSERT INTO alarms (note_id, trigger_time) VALUES ('a111111', ?)", [trigger_time])
569
+
570
+ runner.run_cmd("cat a1111 --raw")
571
+ assert_equal "# Raw\n", TestSuite.strip_ansi(runner.last_stdout)
572
+
573
+ runner.run_cmd("cat a1111 -r")
574
+ assert_equal "# Raw\n", TestSuite.strip_ansi(runner.last_stdout)
575
+
576
+ runner.run_cmd("__complete notes")
577
+ out = TestSuite.strip_ansi(runner.last_stdout)
578
+ refute_includes out, "WARNING:"
579
+ assert_includes out, "Raw note"
580
+ end
477
581
  end
478
582
 
479
583
  describe "bkup" do
@@ -539,6 +643,18 @@ module TestSuite
539
643
  end
540
644
 
541
645
  describe "ls target" do
646
+ it "displays a note when no notebook or tag matches its path" do
647
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'projects', '')")
648
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body) VALUES ('n1', 'f1', 'reef', '# Reef body')")
649
+
650
+ runner.run_cmd("ls projects/reef")
651
+ out = TestSuite.strip_ansi(runner.last_stdout)
652
+
653
+ assert_includes out, "reef (n1)"
654
+ assert_includes out, "Reef body"
655
+ refute_includes out, "No notebook or tag found"
656
+ end
657
+
542
658
  it "filters notebook notes by date" do
543
659
  runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
544
660
  runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Today Note', 1718294400000)")
@@ -762,7 +878,8 @@ module TestSuite
762
878
  runner.run_cmd(["rm", "EmptyNotebook", "-r"])
763
879
  runner.run_cmd(["rm", "empty-tag", "-r"])
764
880
 
765
- assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
881
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
882
+ assert_operator runner.db.get_first_value("SELECT deleted_time FROM folders WHERE id = 'a111111'"), :>, 0
766
883
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
767
884
  end
768
885
 
@@ -782,7 +899,7 @@ module TestSuite
782
899
 
783
900
  runner.run_cmd(["rm", "Work", "-r"], stdin: "n\n", stdin_tty: true)
784
901
 
785
- assert_includes TestSuite.strip_ansi(runner.last_stdout), "Move 1 note to Trash"
902
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Move notebook 'Work' and 1 note to Trash"
786
903
  assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
787
904
  assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n1'")
788
905
  end
@@ -795,7 +912,8 @@ module TestSuite
795
912
 
796
913
  runner.run_cmd(["rm", "Work", "-r"], stdin: "yes\n", stdin_tty: true)
797
914
 
798
- assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
915
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
916
+ assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM folders WHERE deleted_time > 0")
799
917
  assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
800
918
  end
801
919
 
@@ -855,9 +973,34 @@ module TestSuite
855
973
  out = TestSuite.strip_ansi(runner.last_stdout)
856
974
  assert_includes out, "e1111"
857
975
  assert_includes out, "Work/Deleted Plan"
976
+ refute_includes out, "Note Work/Deleted Plan"
858
977
  refute_includes out, "Active Plan"
859
978
  end
860
979
 
980
+ it "lists notebook contents as note paths without a redundant notebook entry" do
981
+ runner.db.execute("INSERT INTO folders (id, title, deleted_time) VALUES ('f111111', 'Work', 1781524800000)")
982
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, deleted_time) VALUES ('e111111', 'f111111', 'Plan', 1781524800000)")
983
+
984
+ runner.run_cmd("trash ls")
985
+
986
+ out = TestSuite.strip_ansi(runner.last_stdout)
987
+ assert_includes out, "e1111"
988
+ assert_includes out, "Work/Plan"
989
+ refute_includes out, "f1111"
990
+ refute_includes out, "Note "
991
+ refute_includes out, "Notebook "
992
+ end
993
+
994
+ it "lists an empty trashed notebook with a trailing slash" do
995
+ runner.db.execute("INSERT INTO folders (id, title, deleted_time) VALUES ('f111111', 'Empty', 1781524800000)")
996
+
997
+ runner.run_cmd("trash ls")
998
+
999
+ out = TestSuite.strip_ansi(runner.last_stdout)
1000
+ assert_match(/f1111 .* Empty\/$/, out)
1001
+ refute_includes out, "Notebook "
1002
+ end
1003
+
861
1004
  it "restores a trashed note by UUID" do
862
1005
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e333333', 'Restore Me', 1781524800000)")
863
1006
 
@@ -867,6 +1010,31 @@ module TestSuite
867
1010
  assert_includes TestSuite.strip_ansi(runner.last_stdout), "Restored note 'Restore Me'"
868
1011
  end
869
1012
 
1013
+ it "restores a notebook subtree and notes deleted with it" do
1014
+ deleted_at = 1781524800000
1015
+ runner.db.execute("INSERT INTO folders (id, title, parent_id, deleted_time) VALUES ('f111111', 'Work', '', ?)", [deleted_at])
1016
+ runner.db.execute("INSERT INTO folders (id, title, parent_id, deleted_time) VALUES ('f222222', 'Projects', 'f111111', ?)", [deleted_at])
1017
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, deleted_time) VALUES ('e111111', 'f222222', 'Plan', ?)", [deleted_at])
1018
+
1019
+ runner.run_cmd(["trash", "restore", "f1111"])
1020
+
1021
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders WHERE deleted_time > 0")
1022
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e111111'")
1023
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Restored notebook 'Work'"
1024
+ end
1025
+
1026
+ it "restores all deleted contents in a notebook" do
1027
+ runner.db.execute("INSERT INTO folders (id, title) VALUES ('f111111', 'Work')")
1028
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, deleted_time) VALUES ('e111111', 'f111111', 'Old Trash', 1000)")
1029
+ runner.db.execute("INSERT INTO notes (id, parent_id, title) VALUES ('e222222', 'f111111', 'Active')")
1030
+
1031
+ runner.run_cmd(["rm", "Work", "-rf"])
1032
+ runner.run_cmd(["trash", "restore", "f1111"])
1033
+
1034
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e111111'")
1035
+ assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'e222222'")
1036
+ end
1037
+
870
1038
  it "requires a unique UUID when restoring" do
871
1039
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444441', 'One', 1781524800000)")
872
1040
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444442', 'Two', 1781524800000)")
@@ -874,7 +1042,7 @@ module TestSuite
874
1042
  runner.run_cmd(["trash", "restore", "e4444"])
875
1043
 
876
1044
  out = TestSuite.strip_ansi(runner.last_stdout)
877
- assert_includes out, "Ambiguous trashed note"
1045
+ assert_includes out, "Ambiguous trashed item"
878
1046
  assert_includes out, "e4444 One"
879
1047
  assert_includes out, "e4444 Two"
880
1048
  assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
@@ -883,13 +1051,16 @@ module TestSuite
883
1051
  it "empties Trash only after confirmation" do
884
1052
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e555555', 'Delete Me', 1781524800000)")
885
1053
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e666666', 'Keep Me', 0)")
1054
+ runner.db.execute("INSERT INTO folders (id, title, deleted_time) VALUES ('f555555', 'Delete Book', 1781524800000)")
886
1055
 
887
1056
  runner.run_cmd(["trash", "empty"], stdin: "n\n", stdin_tty: true)
888
1057
  assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
1058
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders WHERE deleted_time > 0")
889
1059
 
890
1060
  runner.run_cmd(["trash", "empty"], stdin: "yes\n", stdin_tty: true)
891
1061
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
892
1062
  assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE id = 'e666666'")
1063
+ assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
893
1064
  end
894
1065
  end
895
1066
 
@@ -993,6 +1164,46 @@ module TestSuite
993
1164
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
994
1165
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
995
1166
  end
1167
+
1168
+ it "adds CoreLocationCLI coordinates to a new note when available" do
1169
+ cli = File.join(runner.dir, "CoreLocationCLI")
1170
+ File.write(cli, "#!/bin/sh\nprintf '10.7769 106.7009 12.5\\n'\n")
1171
+ FileUtils.chmod(0o755, cli)
1172
+ ENV['CORE_LOCATION_CLI'] = cli
1173
+
1174
+ runner.run_cmd(["add", "Places", "--title", "Cafe", "Visited"], stdin: "", stdin_tty: true)
1175
+
1176
+ note = runner.db.get_first_row("SELECT latitude, longitude, altitude FROM notes")
1177
+ assert_equal 10.7769, note['latitude']
1178
+ assert_equal 106.7009, note['longitude']
1179
+ assert_equal 12.5, note['altitude']
1180
+ end
1181
+
1182
+ it "shows the CoreLocationCLI permission error and still creates the note" do
1183
+ cli = File.join(runner.dir, "CoreLocationCLI")
1184
+ message = "CoreLocationCLI: ❌ Location services are disabled or location access denied. Please visit System Settings > Privacy & Security > Location Services"
1185
+ File.write(cli, "#!/bin/sh\nprintf '%s\\n' '#{message}' >&2\nexit 1\n")
1186
+ FileUtils.chmod(0o755, cli)
1187
+ ENV['CORE_LOCATION_CLI'] = cli
1188
+
1189
+ runner.run_cmd(["add", "Inbox", "--title", "Note", "Body"], stdin: "", stdin_tty: true)
1190
+
1191
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), message
1192
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
1193
+ end
1194
+
1195
+ it "shows any CoreLocationCLI error and still creates the note" do
1196
+ cli = File.join(runner.dir, "CoreLocationCLI")
1197
+ message = "CoreLocationCLI: unable to determine location"
1198
+ File.write(cli, "#!/bin/sh\nprintf '%s\\n' '#{message}' >&2\nexit 2\n")
1199
+ FileUtils.chmod(0o755, cli)
1200
+ ENV['CORE_LOCATION_CLI'] = cli
1201
+
1202
+ runner.run_cmd(["add", "Inbox", "--title", "Note", "Body"], stdin: "", stdin_tty: true)
1203
+
1204
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), message
1205
+ assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
1206
+ end
996
1207
  end
997
1208
 
998
1209
  describe "journal" do
@@ -1006,6 +1217,20 @@ module TestSuite
1006
1217
  assert_includes TestSuite.strip_ansi(runner.last_stdout), "Updated journal Journal/2026/06-Jun/2026-06-17"
1007
1218
  end
1008
1219
 
1220
+ it "adds CoreLocationCLI coordinates to a new journal note" do
1221
+ cli = File.join(runner.dir, "CoreLocationCLI")
1222
+ File.write(cli, "#!/bin/sh\nprintf '10.7769 106.7009 12.5\\n'\n")
1223
+ FileUtils.chmod(0o755, cli)
1224
+ ENV['CORE_LOCATION_CLI'] = cli
1225
+
1226
+ runner.run_cmd(["journal", "2026-06-17", "visited"])
1227
+
1228
+ note = runner.db.get_first_row("SELECT latitude, longitude, altitude FROM notes")
1229
+ assert_equal 10.7769, note['latitude']
1230
+ assert_equal 106.7009, note['longitude']
1231
+ assert_equal 12.5, note['altitude']
1232
+ end
1233
+
1009
1234
  it "aliases j to journal" do
1010
1235
  runner.run_cmd(["j", "2026-06-17", "short", "entry"])
1011
1236
 
@@ -1058,6 +1283,27 @@ module TestSuite
1058
1283
  assert_equal "existing body", File.read(captured)
1059
1284
  assert_equal "existing body\nnew line", runner.db.get_first_value("SELECT body FROM notes")
1060
1285
  end
1286
+
1287
+ it "does not update an existing journal note when editor content is unchanged" do
1288
+ runner.run_cmd(["journal", "2026-06-17", "existing body"])
1289
+ original_updated_time = 1718294400000
1290
+ runner.db.execute("UPDATE notes SET updated_time = ? WHERE title = ?", [original_updated_time, "2026-06-17"])
1291
+
1292
+ old_editor = ENV['EDITOR']
1293
+ ENV['EDITOR'] = "ruby -e 'exit 0'"
1294
+ begin
1295
+ runner.run_cmd(["journal", "2026-06-17"], stdin: "", stdin_tty: true)
1296
+ ensure
1297
+ ENV['EDITOR'] = old_editor
1298
+ end
1299
+
1300
+ note = runner.db.get_first_row("SELECT body, updated_time FROM notes WHERE title = ?", ["2026-06-17"])
1301
+ out = TestSuite.strip_ansi(runner.last_stdout)
1302
+ assert_equal "existing body", note["body"]
1303
+ assert_equal original_updated_time, note["updated_time"]
1304
+ assert_includes out, "No changes made."
1305
+ refute_includes out, "Updated journal"
1306
+ end
1061
1307
  end
1062
1308
 
1063
1309
  describe "cat" do
@@ -1204,6 +1450,18 @@ module TestSuite
1204
1450
  assert_includes out, "Tags: Urgent, work"
1205
1451
  end
1206
1452
 
1453
+ it "shows the notebook path in verbose mode" do
1454
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'people', '')")
1455
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'lee', 'f1')")
1456
+ runner.db.execute("INSERT INTO notes (id, parent_id, title, body) VALUES ('e9ced1', 'f2', 'Saturday', 'Body')")
1457
+
1458
+ runner.run_cmd("cat e9ced -v")
1459
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Notebook: people/lee"
1460
+
1461
+ runner.run_cmd("cat e9ced -vv")
1462
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Notebook: people/lee"
1463
+ end
1464
+
1207
1465
  it "omits missing metadata in verbose mode" do
1208
1466
  runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('e1', 'Plain Note', 'Body')")
1209
1467
 
@@ -1313,6 +1571,7 @@ module TestSuite
1313
1571
  assert_includes out, "complete -c jp"
1314
1572
  assert_includes out, "Override readonly or pager for this command"
1315
1573
  assert_includes out, "__jp_complete targets"
1574
+ assert_includes out, "-a export"
1316
1575
  assert_includes out, "jp init fish"
1317
1576
  end
1318
1577
 
@@ -1361,6 +1620,121 @@ module TestSuite
1361
1620
  end
1362
1621
  end
1363
1622
 
1623
+ describe "export" do
1624
+ it "exports notebook directories and Joplin-compatible front matter" do
1625
+ export_dir = File.join(runner.dir, "markdown-export")
1626
+ created = Time.utc(2024, 6, 12, 19, 0).to_i * 1000
1627
+ updated = Time.utc(2024, 6, 13, 20, 30).to_i * 1000
1628
+ due = Time.utc(2024, 6, 20, 9, 15).to_i * 1000
1629
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1630
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
1631
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f3', 'Empty', '')")
1632
+ runner.db.execute(<<~SQL, [created, updated, due])
1633
+ INSERT INTO notes (
1634
+ id, parent_id, title, body, user_created_time, user_updated_time,
1635
+ latitude, longitude, altitude, author, source_url, is_todo, todo_due, todo_completed
1636
+ ) VALUES (
1637
+ 'n1', 'f2', 'Plan / Q3', '# Body', ?, ?,
1638
+ 10.7769, 106.7009, 12.5, 'Ada', 'https://example.com/source', 1, ?, 1
1639
+ )
1640
+ SQL
1641
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t1', 'work')")
1642
+ runner.db.execute("INSERT INTO tags (id, title) VALUES ('t2', 'Urgent')")
1643
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt1', 'n1', 't1')")
1644
+ runner.db.execute("INSERT INTO note_tags (id, note_id, tag_id) VALUES ('nt2', 'n1', 't2')")
1645
+
1646
+ runner.run_cmd(["export", export_dir])
1647
+
1648
+ note_path = File.join(export_dir, "Work", "Projects", "Plan - Q3.md")
1649
+ assert File.directory?(File.join(export_dir, "Empty"))
1650
+ assert File.file?(note_path)
1651
+ front_matter, body = File.read(note_path).sub(/\A---\n/, '').split("---\n\n", 2)
1652
+ metadata = YAML.safe_load(front_matter)
1653
+ assert_equal "Plan / Q3", metadata['title']
1654
+ assert_equal "2024-06-12 19:00:00Z", metadata['created']
1655
+ assert_equal "2024-06-13 20:30:00Z", metadata['updated']
1656
+ assert_equal "2024-06-20 09:15:00Z", metadata['due']
1657
+ assert_equal 10.7769, metadata['latitude']
1658
+ assert_equal 106.7009, metadata['longitude']
1659
+ assert_equal 12.5, metadata['altitude']
1660
+ assert_equal "Ada", metadata['author']
1661
+ assert_equal "https://example.com/source", metadata['source']
1662
+ assert_equal true, metadata['completed?']
1663
+ assert_equal %w[Urgent work], metadata['tags']
1664
+ assert_equal "# Body", body
1665
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Exported 1 note and 3 notebooks"
1666
+ end
1667
+
1668
+ it "keeps duplicate and unsafe note titles in distinct files" do
1669
+ export_dir = File.join(runner.dir, "markdown-export")
1670
+ runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('abcdef111', '../Same', 'First')")
1671
+ runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('abcdef222', '../Same', 'Second')")
1672
+
1673
+ runner.run_cmd(["export", export_dir])
1674
+
1675
+ files = Dir.glob(File.join(export_dir, "*.md")).map { |path| File.basename(path) }.sort
1676
+ assert_equal ["_-Same (abcde).md", "_-Same.md"], files
1677
+ assert_equal 2, files.map { |name| File.read(File.join(export_dir, name)) }.uniq.size
1678
+ end
1679
+
1680
+ it "copies referenced resources and rewrites nested note links" do
1681
+ export_dir = File.join(runner.dir, "markdown-export")
1682
+ resource_id = "a" * 32
1683
+ orphan_id = "b" * 32
1684
+ resource_dir = File.join(runner.dir, "resources")
1685
+ FileUtils.mkdir_p(resource_dir)
1686
+ File.write(File.join(resource_dir, "#{resource_id}.pdf"), "PDF data")
1687
+ File.write(File.join(resource_dir, "#{orphan_id}.png"), "orphan")
1688
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
1689
+ runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f2', 'Projects', 'f1')")
1690
+ runner.db.execute(
1691
+ "INSERT INTO notes (id, parent_id, title, body) VALUES ('n1', 'f2', 'Attachment', ?)",
1692
+ ["[document](:/#{resource_id})"]
1693
+ )
1694
+ runner.db.execute(
1695
+ "INSERT INTO resources (id, title, mime, filename, file_extension) VALUES (?, 'Document', 'application/pdf', 'report.pdf', 'pdf')",
1696
+ [resource_id]
1697
+ )
1698
+ runner.db.execute(
1699
+ "INSERT INTO resources (id, title, mime, filename, file_extension) VALUES (?, 'Orphan', 'image/png', 'orphan.png', 'png')",
1700
+ [orphan_id]
1701
+ )
1702
+
1703
+ runner.run_cmd(["export", export_dir])
1704
+
1705
+ exported_resource = File.join(export_dir, "_resources", "#{resource_id}.pdf")
1706
+ note = File.read(File.join(export_dir, "Work", "Projects", "Attachment.md"))
1707
+ assert_equal "PDF data", File.read(exported_resource)
1708
+ assert_includes note, "[document](../../_resources/#{resource_id}.pdf)"
1709
+ refute File.exist?(File.join(export_dir, "_resources", "#{orphan_id}.png"))
1710
+ assert_includes TestSuite.strip_ansi(runner.last_stdout), "Copied 1 resource."
1711
+ end
1712
+
1713
+ it "skips resources with -N and --no-resources" do
1714
+ resource_id = "a" * 32
1715
+ resource_dir = File.join(runner.dir, "resources")
1716
+ FileUtils.mkdir_p(resource_dir)
1717
+ File.write(File.join(resource_dir, "#{resource_id}.pdf"), "PDF data")
1718
+ runner.db.execute(
1719
+ "INSERT INTO notes (id, title, body) VALUES ('n1', 'Attachment', ?)",
1720
+ ["[document](:/#{resource_id})"]
1721
+ )
1722
+ runner.db.execute(
1723
+ "INSERT INTO resources (id, title, mime, filename, file_extension) VALUES (?, 'Document', 'application/pdf', 'report.pdf', 'pdf')",
1724
+ [resource_id]
1725
+ )
1726
+
1727
+ ["-N", "--no-resources"].each_with_index do |option, index|
1728
+ export_dir = File.join(runner.dir, "markdown-export-#{index}")
1729
+ runner.run_cmd(["export", option, export_dir])
1730
+
1731
+ note = File.read(File.join(export_dir, "Attachment.md"))
1732
+ assert_includes note, "[document](:/#{resource_id})"
1733
+ refute File.exist?(File.join(export_dir, "_resources"))
1734
+ end
1735
+ end
1736
+ end
1737
+
1364
1738
  describe "help" do
1365
1739
  it "colorizes the command listing" do
1366
1740
  runner.run_cmd("help")
@@ -1371,6 +1745,7 @@ module TestSuite
1371
1745
  assert_includes plain, "Usage:"
1372
1746
  assert_includes plain, "jp ls [TARGET]"
1373
1747
  assert_includes plain, "jp journal [today|yesterday|YYYY-MM-DD] [TEXT...]"
1748
+ assert_includes plain, "jp export PATH"
1374
1749
  assert_includes out, "ls".green.bold
1375
1750
  refute_includes plain, "__complete"
1376
1751
  end
@@ -1398,6 +1773,18 @@ module TestSuite
1398
1773
  assert_equal File.join(ENV.fetch('HOME'), '.config', 'joplin'), CLI::DEFAULT_JOPLIN_DATA_DIR
1399
1774
  end
1400
1775
 
1776
+ it "falls back to joplin-desktop when the joplin config directory is absent" do
1777
+ config_dir = Dir.mktmpdir("jp_config")
1778
+ desktop_dir = File.join(config_dir, "joplin-desktop")
1779
+ FileUtils.mkdir_p(desktop_dir)
1780
+ old_joplin_data_dir = ENV.delete('JOPLIN_DATA_DIR')
1781
+
1782
+ assert_equal desktop_dir, CLI.joplin_data_dir(candidates: [File.join(config_dir, "joplin"), desktop_dir])
1783
+ ensure
1784
+ ENV['JOPLIN_DATA_DIR'] = old_joplin_data_dir if old_joplin_data_dir
1785
+ FileUtils.remove_entry(config_dir) if config_dir && File.exist?(config_dir)
1786
+ end
1787
+
1401
1788
  it "uses JOPLIN_DATA_DIR for the database and settings file" do
1402
1789
  assert_equal File.join(runner.dir, 'database.sqlite'), runner.cli.instance_variable_get(:@db_path)
1403
1790
  assert_equal File.join(runner.dir, 'settings.json'), runner.settings_path
@@ -1769,17 +2156,17 @@ end
1769
2156
  class Trash < Thor
1770
2157
  def self.exit_on_failure? = true
1771
2158
 
1772
- desc "ls", "List trashed notes"
2159
+ desc "ls", "List trashed notes and notebooks"
1773
2160
  def ls
1774
2161
  CLI.new.send(:list_trash)
1775
2162
  end
1776
2163
 
1777
- desc "restore UUID", "Restore a trashed note"
2164
+ desc "restore UUID", "Restore a trashed note or notebook"
1778
2165
  def restore(uuid)
1779
- CLI.new.send(:restore_trash_note, uuid)
2166
+ CLI.new.send(:restore_trash_item, uuid)
1780
2167
  end
1781
2168
 
1782
- desc "empty", "Permanently delete all trashed notes"
2169
+ desc "empty", "Permanently delete all trashed items"
1783
2170
  def empty
1784
2171
  CLI.new.send(:empty_trash)
1785
2172
  end
@@ -1869,11 +2256,18 @@ class CLI < Thor
1869
2256
  super.reject { |command| command.first.include?("__complete") }
1870
2257
  end
1871
2258
 
1872
- VERSION = "0.36.0"
2259
+ VERSION = "0.38.8"
1873
2260
  DEFAULT_JOPLIN_DATA_DIR = File.join(ENV.fetch('HOME'), '.config', 'joplin')
2261
+ FALLBACK_JOPLIN_DATA_DIR = File.join(ENV.fetch('HOME'), '.config', 'joplin-desktop')
2262
+
2263
+ def self.joplin_data_dir(candidates: [DEFAULT_JOPLIN_DATA_DIR, FALLBACK_JOPLIN_DATA_DIR])
2264
+ return ENV['JOPLIN_DATA_DIR'] if ENV.key?('JOPLIN_DATA_DIR')
1874
2265
 
1875
- def self.joplin_data_dir
1876
- ENV.fetch('JOPLIN_DATA_DIR', DEFAULT_JOPLIN_DATA_DIR)
2266
+ candidates.find { |path| File.directory?(path) } || candidates.first
2267
+ end
2268
+
2269
+ def self.macos?
2270
+ RUBY_PLATFORM.include?('darwin')
1877
2271
  end
1878
2272
 
1879
2273
  desc "settings SUBCOMMAND", "Manage settings"
@@ -1882,7 +2276,7 @@ class CLI < Thor
1882
2276
  desc "init SUBCOMMAND", "Print shell integration code"
1883
2277
  subcommand "init", Init
1884
2278
 
1885
- desc "trash SUBCOMMAND", "Manage trashed notes"
2279
+ desc "trash SUBCOMMAND", "Manage trashed notes and notebooks"
1886
2280
  subcommand "trash", Trash
1887
2281
 
1888
2282
  def self.help(shell, subcommand = false)
@@ -1924,11 +2318,23 @@ class CLI < Thor
1924
2318
  super
1925
2319
  @joplin_data_dir = self.class.joplin_data_dir
1926
2320
  @db_path = File.join(@joplin_data_dir, 'database.sqlite')
2321
+
2322
+ unless File.exist?(@db_path)
2323
+ raise Thor::Error, "Joplin database not found at #{@db_path}. Please check your JOPLIN_DATA_DIR.".red
2324
+ end
2325
+
1927
2326
  @db = SQLite3::Database.new(@db_path)
1928
2327
  @db.results_as_hash = true
1929
2328
  load_settings unless Array(Thread.current[:jp_args]).first == "test"
1930
2329
  end
1931
2330
 
2331
+ no_commands do
2332
+ def invoke_command(command, *args)
2333
+ show_active_alarm_warning unless alarm_warning_suppressed?(command)
2334
+ super
2335
+ end
2336
+ end
2337
+
1932
2338
  desc "version", "Show version"
1933
2339
  def version
1934
2340
  puts VERSION
@@ -1959,6 +2365,8 @@ class CLI < Thor
1959
2365
  else
1960
2366
  say "Backups: ".cyan + " none".yellow
1961
2367
  end
2368
+
2369
+ show_core_location_cli_info
1962
2370
  end
1963
2371
 
1964
2372
  desc "bkup", "Backup the database"
@@ -2017,17 +2425,12 @@ class CLI < Thor
2017
2425
  title = derive_note_title(body) if title.empty?
2018
2426
  return say "Error: Note title is required.".red if title.nil? || title.empty?
2019
2427
 
2020
- source_url ||= ""
2021
- latitude ||= nil
2022
- longitude ||= nil
2023
2428
  note = create_note_with_hierarchy(
2024
2429
  notebook_path: notebook_path,
2025
2430
  title: title,
2026
2431
  body: body,
2027
2432
  tags: tags,
2028
- source_url: source_url,
2029
- latitude: latitude,
2030
- longitude: longitude
2433
+ source_url: ""
2031
2434
  )
2032
2435
 
2033
2436
  say "Created note #{note[:path].green}"
@@ -2075,7 +2478,8 @@ class CLI < Thor
2075
2478
  when "removable"
2076
2479
  completion_removable_items.each { |candidate, description| puts "#{candidate}\t#{description}" }
2077
2480
  when "trashed"
2078
- trashed_notes.each { |note| puts "#{note['id'][0..4]}\tNote #{get_full_path_for_note(note)}" }
2481
+ visible_trashed_folders.each { |folder| puts "#{folder['id'][0..4]}\t#{get_full_path(folder)}/" }
2482
+ trashed_notes.each { |note| puts "#{note['id'][0..4]}\t#{get_full_path_for_note(note)}" }
2079
2483
  end
2080
2484
  end
2081
2485
 
@@ -2109,8 +2513,8 @@ class CLI < Thor
2109
2513
  lines.first.split(/\s+/)
2110
2514
  end
2111
2515
 
2112
- def resolve_cat_target(target)
2113
- notes = find_notes(target)
2516
+ def resolve_cat_target(target, notes: nil)
2517
+ notes ||= find_notes(target)
2114
2518
  if notes.empty?
2115
2519
  say "No note found matching '#{target}'."
2116
2520
  nil
@@ -2348,7 +2752,7 @@ class CLI < Thor
2348
2752
  auto_bkup_if_needed
2349
2753
  end
2350
2754
 
2351
- desc "rm TARGET", "Move a note to Trash or recursively remove a notebook or tag"
2755
+ desc "rm TARGET", "Move a note or notebook to Trash, or recursively remove a tag"
2352
2756
  method_option :recursive, aliases: "-r", type: :boolean, desc: "Remove a notebook or tag recursively"
2353
2757
  method_option :force, aliases: "-f", type: :boolean, desc: "Do not prompt before trashing notes"
2354
2758
  def rm(target)
@@ -2381,7 +2785,11 @@ class CLI < Thor
2381
2785
  note_ids = removable_note_ids(item)
2382
2786
  unless note_ids.empty? || options[:force]
2383
2787
  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] "
2788
+ if item[:type] == :notebook
2789
+ print "Move notebook '#{item[:row]['title']}' and #{note_ids.length} #{noun} to Trash? [y/N] "
2790
+ else
2791
+ print "Move #{note_ids.length} #{noun} to Trash and remove tag '#{item[:row]['title']}'? [y/N] "
2792
+ end
2385
2793
  answer = $stdin.gets.to_s.strip.downcase
2386
2794
  unless %w[y yes].include?(answer)
2387
2795
  say "Cancelled."
@@ -2391,11 +2799,71 @@ class CLI < Thor
2391
2799
 
2392
2800
  remove_item(item, note_ids)
2393
2801
  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
2802
+ if item[:type] == :notebook
2803
+ say "Moved notebook '#{item[:row]['title']}' and its contents to Trash.".green
2804
+ else
2805
+ summary = note_ids.empty? ? "" : " and moved #{note_ids.length} #{note_ids.length == 1 ? 'note' : 'notes'} to Trash"
2806
+ say "Removed #{noun} '#{item[:row]['title']}'#{summary}.".green
2807
+ end
2396
2808
  auto_bkup_if_needed
2397
2809
  end
2398
2810
 
2811
+ desc "export PATH", "Export notes as Markdown with front matter"
2812
+ method_option :no_resources, aliases: "-N", type: :boolean, desc: "Do not copy or rewrite resources"
2813
+ def export(path)
2814
+ target_dir = File.expand_path(path)
2815
+ if File.exist?(target_dir) && !File.directory?(target_dir)
2816
+ raise Thor::Error, "Export path is not a directory: #{target_dir}"
2817
+ end
2818
+ FileUtils.mkdir_p(target_dir)
2819
+
2820
+ folders = @db.execute(
2821
+ "SELECT id, title, parent_id FROM folders WHERE deleted_time = 0 ORDER BY title COLLATE NOCASE, id"
2822
+ )
2823
+ folder_components = export_folder_components(folders)
2824
+ folder_components.each_value { |components| FileUtils.mkdir_p(File.join(target_dir, *components)) }
2825
+
2826
+ tags_by_note = Hash.new { |hash, note_id| hash[note_id] = [] }
2827
+ @db.execute(<<~SQL).each do |row|
2828
+ SELECT nt.note_id, t.title
2829
+ FROM note_tags nt
2830
+ JOIN tags t ON t.id = nt.tag_id
2831
+ JOIN notes n ON n.id = nt.note_id
2832
+ WHERE n.deleted_time = 0
2833
+ ORDER BY t.title COLLATE NOCASE, t.id
2834
+ SQL
2835
+ tags_by_note[row['note_id']] << row['title']
2836
+ end
2837
+
2838
+ used_names = Hash.new { |hash, directory| hash[directory] = Set.new }
2839
+ notes = @db.execute(
2840
+ "SELECT * FROM notes WHERE deleted_time = 0 ORDER BY title COLLATE NOCASE, id"
2841
+ )
2842
+ resource_paths = options[:no_resources] ? {} : export_resources(notes, target_dir)
2843
+ notes.each do |note|
2844
+ components = folder_components.fetch(note['parent_id'], [])
2845
+ directory = File.join(target_dir, *components)
2846
+ basename = unique_export_component(
2847
+ sanitize_export_component(note['title']),
2848
+ note['id'],
2849
+ used_names[directory]
2850
+ )
2851
+ metadata = export_front_matter(note, tags_by_note[note['id']])
2852
+ body = export_note_body(note['body'], resource_paths, components.length)
2853
+ File.write(File.join(directory, "#{basename}.md"), metadata.to_yaml + "---\n\n" + body)
2854
+ end
2855
+
2856
+ note_label = notes.length == 1 ? "note" : "notes"
2857
+ folder_label = folders.length == 1 ? "notebook" : "notebooks"
2858
+ say "Exported #{notes.length} #{note_label} and #{folders.length} #{folder_label} to #{target_dir}.".green
2859
+ unless options[:no_resources] || resource_paths.empty?
2860
+ resource_label = resource_paths.length == 1 ? "resource" : "resources"
2861
+ say "Copied #{resource_paths.length} #{resource_label}.".green
2862
+ end
2863
+ rescue SystemCallError, SQLite3::Exception => e
2864
+ raise Thor::Error, "Export failed: #{e.message}"
2865
+ end
2866
+
2399
2867
  desc "test", "Run tests"
2400
2868
  method_option :verbose, aliases: "-v", type: :boolean
2401
2869
  def test
@@ -2412,6 +2880,156 @@ class CLI < Thor
2412
2880
 
2413
2881
  private
2414
2882
 
2883
+ def export_resources(notes, target_dir)
2884
+ referenced_ids = notes.each_with_object(Set.new) do |note, ids|
2885
+ note['body'].to_s.scan(/:\/([a-f0-9]{32})(?![a-f0-9])/i) { |match| ids << match.first.downcase }
2886
+ end
2887
+ return {} if referenced_ids.empty?
2888
+
2889
+ resources = []
2890
+ referenced_ids.to_a.each_slice(500) do |ids|
2891
+ placeholders = Array.new(ids.length, '?').join(', ')
2892
+ resources.concat(@db.execute(
2893
+ "SELECT id, file_extension FROM resources WHERE LOWER(id) IN (#{placeholders})",
2894
+ ids
2895
+ ))
2896
+ end
2897
+
2898
+ exported = {}
2899
+ resources.each do |resource|
2900
+ resource_id = resource['id'].downcase
2901
+ source = find_resource_file(resource_id, resource['file_extension'])
2902
+ unless source
2903
+ say "Warning: resource file not found for #{resource_id}.".yellow
2904
+ next
2905
+ end
2906
+
2907
+ resource_dir = File.join(target_dir, '_resources')
2908
+ FileUtils.mkdir_p(resource_dir)
2909
+ filename = File.basename(source)
2910
+ FileUtils.cp(source, File.join(resource_dir, filename))
2911
+ exported[resource_id] = filename
2912
+ end
2913
+ exported
2914
+ end
2915
+
2916
+ def find_resource_file(resource_id, extension)
2917
+ resource_dir = File.join(@joplin_data_dir, 'resources')
2918
+ expected_name = extension.to_s.empty? ? resource_id : "#{resource_id}.#{extension}"
2919
+ expected = File.join(resource_dir, expected_name)
2920
+ return expected if File.file?(expected)
2921
+
2922
+ exact = File.join(resource_dir, resource_id)
2923
+ return exact if File.file?(exact)
2924
+
2925
+ Dir.glob(File.join(resource_dir, "#{resource_id}.*")).sort.find { |path| File.file?(path) }
2926
+ end
2927
+
2928
+ def export_note_body(body, resource_paths, notebook_depth)
2929
+ prefix = "../" * notebook_depth
2930
+ body.to_s.gsub(/:\/([a-f0-9]{32})(?![a-f0-9])/i) do |link|
2931
+ filename = resource_paths[Regexp.last_match(1).downcase]
2932
+ filename ? "#{prefix}_resources/#{filename}" : link
2933
+ end
2934
+ end
2935
+
2936
+ def export_folder_components(folders)
2937
+ folders_by_id = folders.to_h { |folder| [folder['id'], folder] }
2938
+ component_by_id = {}
2939
+
2940
+ folders.group_by { |folder| folders_by_id.key?(folder['parent_id']) ? folder['parent_id'] : "" }.each_value do |siblings|
2941
+ used = Set.new
2942
+ siblings.sort_by { |folder| [folder['title'].to_s.downcase, folder['id'].to_s] }.each do |folder|
2943
+ component_by_id[folder['id']] = unique_export_component(
2944
+ sanitize_export_component(folder['title']),
2945
+ folder['id'],
2946
+ used
2947
+ )
2948
+ end
2949
+ end
2950
+
2951
+ resolved = {}
2952
+ resolve = lambda do |folder_id, visiting = Set.new|
2953
+ return resolved[folder_id] if resolved.key?(folder_id)
2954
+ return [] if visiting.include?(folder_id)
2955
+
2956
+ folder = folders_by_id[folder_id]
2957
+ return [] unless folder
2958
+
2959
+ parent_components = resolve.call(folder['parent_id'], visiting | [folder_id])
2960
+ resolved[folder_id] = parent_components + [component_by_id.fetch(folder_id)]
2961
+ end
2962
+ folders.each { |folder| resolve.call(folder['id']) }
2963
+ resolved
2964
+ end
2965
+
2966
+ def sanitize_export_component(value)
2967
+ component = value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
2968
+ component = component.gsub(/[<>:"\\\/|?*\x00-\x1f]/, '-').strip
2969
+ component = component.sub(/\A\.+/, '_')
2970
+ component = component.sub(/[. ]+\z/, '')
2971
+ component = "Untitled" if component.empty? || component == "." || component == ".."
2972
+ component += "_" if component.match?(/\A(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?\z/i)
2973
+ component = component[0...-1] while component.bytesize > 180
2974
+ component
2975
+ end
2976
+
2977
+ def unique_export_component(component, id, used)
2978
+ candidate = component
2979
+ suffix = " (#{id.to_s[0, 5]})"
2980
+ counter = 2
2981
+ while used.include?(candidate.downcase)
2982
+ candidate = "#{component}#{suffix}"
2983
+ if used.include?(candidate.downcase)
2984
+ candidate = "#{component}#{suffix} #{counter}"
2985
+ counter += 1
2986
+ end
2987
+ end
2988
+ used << candidate.downcase
2989
+ candidate
2990
+ end
2991
+
2992
+ def export_front_matter(note, tags)
2993
+ metadata = { 'title' => note['title'].to_s }
2994
+ updated = export_note_time(note, 'user_updated_time', 'updated_time')
2995
+ created = export_note_time(note, 'user_created_time', 'created_time')
2996
+ metadata['updated'] = updated if updated
2997
+ metadata['created'] = created if created
2998
+
2999
+ source = note['source_url'].to_s.strip
3000
+ author = note['author'].to_s.strip
3001
+ metadata['source'] = source unless source.empty?
3002
+ metadata['author'] = author unless author.empty?
3003
+
3004
+ latitude = note['latitude'].to_f
3005
+ longitude = note['longitude'].to_f
3006
+ if latitude.nonzero? || longitude.nonzero?
3007
+ metadata['latitude'] = latitude
3008
+ metadata['longitude'] = longitude
3009
+ end
3010
+ altitude = note['altitude'].to_f
3011
+ metadata['altitude'] = altitude if altitude.nonzero?
3012
+
3013
+ if note['is_todo'].to_i != 0
3014
+ metadata['completed?'] = note['todo_completed'].to_i.positive?
3015
+ due = export_timestamp(note['todo_due'])
3016
+ metadata['due'] = due if due
3017
+ end
3018
+ metadata['tags'] = tags if tags.any?
3019
+ metadata
3020
+ end
3021
+
3022
+ def export_note_time(note, preferred_key, fallback_key)
3023
+ export_timestamp(note[preferred_key]) || export_timestamp(note[fallback_key])
3024
+ end
3025
+
3026
+ def export_timestamp(milliseconds)
3027
+ value = milliseconds.to_i
3028
+ return nil unless value.positive?
3029
+
3030
+ Time.at(value / 1000.0).utc.strftime('%Y-%m-%d %H:%M:%SZ')
3031
+ end
3032
+
2415
3033
  def find_movable_items(source)
2416
3034
  if source.match?(/\A[a-f0-9]{1,32}\z/i)
2417
3035
  prefix = "#{source.downcase}%"
@@ -2512,83 +3130,188 @@ class CLI < Thor
2512
3130
  ids
2513
3131
  end
2514
3132
 
3133
+ def all_notebook_subtree_ids(root_id)
3134
+ ids = []
3135
+ pending = [root_id]
3136
+ until pending.empty?
3137
+ parent_id = pending.shift
3138
+ next if ids.include?(parent_id)
3139
+
3140
+ ids << parent_id
3141
+ pending.concat(
3142
+ @db.execute("SELECT id FROM folders WHERE parent_id = ?", [parent_id]).map { |row| row['id'] }
3143
+ )
3144
+ end
3145
+ ids
3146
+ end
3147
+
2515
3148
  def remove_item(item, note_ids)
3149
+ deleted_at = Time.now.to_i * 1000
2516
3150
  @db.transaction do
2517
- trash_notes(note_ids)
2518
3151
  if item[:type] == :tag
3152
+ trash_notes(note_ids, deleted_at: deleted_at)
2519
3153
  @db.execute("DELETE FROM note_tags WHERE tag_id = ?", [item[:row]['id']])
2520
3154
  @db.execute("DELETE FROM tags WHERE id = ?", [item[:row]['id']])
2521
3155
  else
2522
3156
  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)
3157
+ trash_notes(note_ids, deleted_at: deleted_at)
3158
+ set_deleted_time('folders', folder_ids, deleted_at)
2525
3159
  end
2526
3160
  end
2527
3161
  end
2528
3162
 
2529
- def trash_notes(note_ids)
2530
- return if note_ids.empty?
3163
+ def trash_notes(note_ids, deleted_at: Time.now.to_i * 1000)
3164
+ set_deleted_time('notes', note_ids, deleted_at)
3165
+ end
2531
3166
 
2532
- columns = @db.execute("PRAGMA table_info(notes)").map { |column| column['name'] }.to_set
2533
- now = Time.now.to_i * 1000
3167
+ def set_deleted_time(table, ids, deleted_at)
3168
+ return if ids.empty?
3169
+
3170
+ columns = @db.execute("PRAGMA table_info(#{table})").map { |column| column['name'] }.to_set
2534
3171
  assignments = ["deleted_time = ?"]
2535
- values = [now]
2536
- if columns.include?("updated_time")
2537
- assignments << "updated_time = ?"
2538
- values << now
3172
+ values = [deleted_at]
3173
+ %w[updated_time user_updated_time].each do |column|
3174
+ next unless columns.include?(column)
3175
+
3176
+ assignments << "#{column} = ?"
3177
+ values << deleted_at
2539
3178
  end
2540
- placeholders = Array.new(note_ids.length, "?").join(", ")
3179
+ placeholders = Array.new(ids.length, "?").join(", ")
2541
3180
  @db.execute(
2542
- "UPDATE notes SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
2543
- [*values, *note_ids]
3181
+ "UPDATE #{table} SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
3182
+ [*values, *ids]
2544
3183
  )
2545
3184
  end
2546
3185
 
2547
3186
  def trashed_notes
2548
- @db.execute("SELECT id, title, parent_id, deleted_time FROM notes WHERE deleted_time > 0 ORDER BY deleted_time DESC")
3187
+ @db.execute(
3188
+ "SELECT id, title, parent_id, deleted_time FROM notes WHERE deleted_time > 0 ORDER BY deleted_time DESC, title COLLATE NOCASE"
3189
+ )
3190
+ end
3191
+
3192
+ def trashed_folders
3193
+ @db.execute(<<~SQL)
3194
+ SELECT f.id, f.title, f.parent_id, f.deleted_time
3195
+ FROM folders f
3196
+ LEFT JOIN folders parent ON parent.id = f.parent_id
3197
+ WHERE f.deleted_time > 0 AND (parent.id IS NULL OR parent.deleted_time = 0)
3198
+ ORDER BY f.deleted_time DESC, f.title COLLATE NOCASE
3199
+ SQL
3200
+ end
3201
+
3202
+ def visible_trashed_folders
3203
+ trashed_folders.reject do |folder|
3204
+ folder_ids = all_notebook_subtree_ids(folder['id'])
3205
+ placeholders = Array.new(folder_ids.length, '?').join(', ')
3206
+ @db.get_first_value(
3207
+ "SELECT COUNT(*) FROM notes WHERE parent_id IN (#{placeholders}) AND deleted_time > 0",
3208
+ folder_ids
3209
+ ).to_i.positive?
3210
+ end
2549
3211
  end
2550
3212
 
2551
3213
  def list_trash
2552
3214
  notes = trashed_notes
2553
- return say "Trash is empty." if notes.empty?
3215
+ folders = visible_trashed_folders
3216
+ return say "Trash is empty." if notes.empty? && folders.empty?
2554
3217
 
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)}"
3218
+ items = folders.map { |folder| { type: :notebook, row: folder } } +
3219
+ notes.map { |note| { type: :note, row: note } }
3220
+ items.sort_by { |item| [-item[:row]['deleted_time'].to_i, item[:row]['title'].to_s.downcase] }.each do |item|
3221
+ row = item[:row]
3222
+ id = row['id'][0..4].colorize(mode: :dim)
3223
+ timestamp = format_note_time(row['deleted_time']).colorize(mode: :dim)
3224
+ path = item[:type] == :notebook ? "#{get_full_path(row)}/" : get_full_path_for_note(row)
3225
+ say "#{id} #{timestamp} #{path}"
2559
3226
  end
2560
3227
  end
2561
3228
 
2562
- def restore_trash_note(uuid)
3229
+ def restore_trash_item(uuid)
2563
3230
  return unless ensure_writable!
2564
3231
 
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?
3232
+ prefix = "#{uuid.downcase}%"
3233
+ matches = trashed_folders.select { |folder| folder['id'].downcase.start_with?(uuid.downcase) }.map do |folder|
3234
+ { type: :notebook, row: folder }
3235
+ end
3236
+ matches.concat(@db.execute(
3237
+ "SELECT id, title, parent_id, deleted_time FROM notes WHERE LOWER(id) LIKE ? AND deleted_time > 0 ORDER BY title COLLATE NOCASE",
3238
+ [prefix]
3239
+ ).map { |note| { type: :note, row: note } })
3240
+ return say "No trashed note or notebook found matching '#{uuid}'." if matches.empty?
2570
3241
 
2571
3242
  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)}"
3243
+ say "Ambiguous trashed item '#{uuid}'. Use a longer UUID:".yellow
3244
+ matches.each do |item|
3245
+ row = item[:row]
3246
+ path = item[:type] == :notebook ? "#{get_full_path(row)}/" : get_full_path_for_note(row)
3247
+ say " #{row['id'][0..4].colorize(mode: :dim)} #{path}"
2575
3248
  end
2576
3249
  return
2577
3250
  end
2578
3251
 
2579
- note = matches.first
2580
- @db.execute("UPDATE notes SET deleted_time = 0 WHERE id = ?", [note['id']])
2581
- say "Restored note '#{note['title']}'.".green
3252
+ item = matches.first
3253
+ item[:type] == :notebook ? restore_trashed_notebook(item[:row]) : restore_trashed_note(item[:row])
2582
3254
  auto_bkup_if_needed
2583
3255
  end
2584
3256
 
3257
+ def restore_trashed_note(note)
3258
+ parent = @db.get_first_row("SELECT deleted_time FROM folders WHERE id = ?", [note['parent_id']])
3259
+ parent_id = parent && parent['deleted_time'].to_i.zero? ? note['parent_id'] : ""
3260
+ @db.transaction do
3261
+ @db.execute("UPDATE notes SET parent_id = ? WHERE id = ?", [parent_id, note['id']])
3262
+ restore_deleted_items('notes', [note['id']])
3263
+ end
3264
+ say "Restored note '#{note['title']}'.".green
3265
+ end
3266
+
3267
+ def restore_trashed_notebook(folder)
3268
+ subtree_ids = all_notebook_subtree_ids(folder['id'])
3269
+ placeholders = Array.new(subtree_ids.length, '?').join(', ')
3270
+ parent = @db.get_first_row("SELECT deleted_time FROM folders WHERE id = ?", [folder['parent_id']])
3271
+ parent_id = parent && parent['deleted_time'].to_i.zero? ? folder['parent_id'] : ""
3272
+
3273
+ @db.transaction do
3274
+ @db.execute("UPDATE folders SET parent_id = ? WHERE id = ?", [parent_id, folder['id']])
3275
+ deleted_folder_ids = @db.execute(
3276
+ "SELECT id FROM folders WHERE id IN (#{placeholders}) AND deleted_time > 0",
3277
+ subtree_ids
3278
+ ).map { |row| row['id'] }
3279
+ restore_deleted_items('folders', deleted_folder_ids)
3280
+ note_ids = @db.execute(
3281
+ "SELECT id FROM notes WHERE parent_id IN (#{placeholders}) AND deleted_time > 0",
3282
+ subtree_ids
3283
+ ).map { |row| row['id'] }
3284
+ restore_deleted_items('notes', note_ids)
3285
+ end
3286
+ say "Restored notebook '#{folder['title']}'.".green
3287
+ end
3288
+
3289
+ def restore_deleted_items(table, ids)
3290
+ return if ids.empty?
3291
+
3292
+ columns = @db.execute("PRAGMA table_info(#{table})").map { |column| column['name'] }.to_set
3293
+ assignments = ["deleted_time = 0"]
3294
+ values = []
3295
+ if columns.include?('updated_time')
3296
+ assignments << "updated_time = ?"
3297
+ values << Time.now.to_i * 1000
3298
+ end
3299
+ placeholders = Array.new(ids.length, '?').join(', ')
3300
+ @db.execute(
3301
+ "UPDATE #{table} SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
3302
+ [*values, *ids]
3303
+ )
3304
+ end
3305
+
2585
3306
  def empty_trash
2586
3307
  return unless ensure_writable!
2587
3308
 
2588
- count = @db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0").to_i
3309
+ folders = trashed_folders
3310
+ notes = trashed_notes
3311
+ count = visible_trashed_folders.length + notes.length
2589
3312
  return say "Trash is empty." if count.zero?
2590
3313
 
2591
- noun = count == 1 ? "note" : "notes"
3314
+ noun = count == 1 ? "item" : "items"
2592
3315
  print "Permanently delete #{count} trashed #{noun}? [y/N] "
2593
3316
  answer = $stdin.gets.to_s.strip.downcase
2594
3317
  unless %w[y yes].include?(answer)
@@ -2596,11 +3319,23 @@ class CLI < Thor
2596
3319
  return
2597
3320
  end
2598
3321
 
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(", ")
3322
+ folder_ids = folders.flat_map { |folder| all_notebook_subtree_ids(folder['id']) }.uniq
3323
+ note_ids = @db.execute("SELECT id FROM notes WHERE deleted_time > 0").map { |row| row['id'] }
3324
+ unless folder_ids.empty?
3325
+ placeholders = Array.new(folder_ids.length, '?').join(', ')
3326
+ note_ids.concat(@db.execute("SELECT id FROM notes WHERE parent_id IN (#{placeholders})", folder_ids).map { |row| row['id'] })
3327
+ end
3328
+ note_ids.uniq!
2601
3329
  @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)
3330
+ unless note_ids.empty?
3331
+ placeholders = Array.new(note_ids.length, '?').join(', ')
3332
+ @db.execute("DELETE FROM note_tags WHERE note_id IN (#{placeholders})", note_ids)
3333
+ @db.execute("DELETE FROM notes WHERE id IN (#{placeholders})", note_ids)
3334
+ end
3335
+ unless folder_ids.empty?
3336
+ placeholders = Array.new(folder_ids.length, '?').join(', ')
3337
+ @db.execute("DELETE FROM folders WHERE id IN (#{placeholders})", folder_ids)
3338
+ end
2604
3339
  end
2605
3340
  say "Emptied Trash.".green
2606
3341
  auto_bkup_if_needed
@@ -2679,7 +3414,13 @@ class CLI < Thor
2679
3414
  return nil
2680
3415
  end
2681
3416
 
2682
- body = File.read(temp.path).delete_suffix("\n")
3417
+ raw_body = File.read(temp.path)
3418
+ if notes.first && raw_body == notes.first['body'].to_s
3419
+ say "No changes made."
3420
+ return nil
3421
+ end
3422
+
3423
+ body = raw_body.delete_suffix("\n")
2683
3424
  if body.empty?
2684
3425
  say "Journal cancelled."
2685
3426
  return nil
@@ -2730,9 +3471,7 @@ class CLI < Thor
2730
3471
  title: title,
2731
3472
  body: body,
2732
3473
  tags: [],
2733
- source_url: "",
2734
- latitude: nil,
2735
- longitude: nil
3474
+ source_url: ""
2736
3475
  )
2737
3476
  end
2738
3477
 
@@ -2779,10 +3518,67 @@ class CLI < Thor
2779
3518
  raise AddError, "#{name} must be a number."
2780
3519
  end
2781
3520
 
2782
- def create_note_with_hierarchy(notebook_path:, title:, body:, tags:, source_url:, latitude:, longitude:)
3521
+ def core_location
3522
+ executable = core_location_cli
3523
+ return nil unless executable
3524
+
3525
+ stdout, stderr, status = Open3.capture3(
3526
+ executable,
3527
+ '--format',
3528
+ "%latitude %longitude %altitude"
3529
+ )
3530
+ output = [stdout, stderr].reject(&:empty?).join("\n")
3531
+ unless status.success?
3532
+ message = output.strip
3533
+ message = "CoreLocationCLI exited with status #{status.exitstatus}." if message.empty?
3534
+ say message.red
3535
+ return nil
3536
+ end
3537
+
3538
+ values = stdout.strip.split(/\s+/)
3539
+ return nil if values.length < 2
3540
+
3541
+ latitude = Float(values[0])
3542
+ longitude = Float(values[1])
3543
+ return nil unless latitude.finite? && latitude.between?(-90, 90)
3544
+ return nil unless longitude.finite? && longitude.between?(-180, 180)
3545
+
3546
+ altitude = Float(values[2]) if values[2]
3547
+ altitude = nil unless altitude&.finite?
3548
+ [latitude, longitude, altitude]
3549
+ rescue SystemCallError => e
3550
+ say "CoreLocationCLI: #{e.message}".red
3551
+ nil
3552
+ rescue ArgumentError, TypeError
3553
+ nil
3554
+ end
3555
+
3556
+ def show_core_location_cli_info(macos: self.class.macos?)
3557
+ return unless macos
3558
+
3559
+ executable = core_location_cli
3560
+ return if executable
3561
+
3562
+ say "\nWARNING: CoreLocationCLI is not installed. New notes will not include GPS coordinates.".yellow.bold
3563
+ end
3564
+
3565
+ def core_location_cli
3566
+ configured = ENV.fetch('CORE_LOCATION_CLI', 'CoreLocationCLI')
3567
+ return nil if configured.empty?
3568
+ return configured if configured.include?(File::SEPARATOR) && File.file?(configured) && File.executable?(configured)
3569
+
3570
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |directory|
3571
+ candidate = File.join(directory, configured)
3572
+ return candidate if File.file?(candidate) && File.executable?(candidate)
3573
+ end
3574
+ nil
3575
+ end
3576
+
3577
+ def create_note_with_hierarchy(notebook_path:, title:, body:, tags:, source_url:)
2783
3578
  components = notebook_path.to_s.split('/', -1).map(&:strip)
2784
3579
  raise AddError, "Notebook path is required." if components.empty? || components.any?(&:empty?)
2785
3580
 
3581
+ latitude, longitude, altitude = core_location || [nil, nil, nil]
2786
3582
  now = Time.now.to_i * 1000
2787
3583
  note_id = SecureRandom.hex(16)
2788
3584
  parent_id = ""
@@ -2824,6 +3620,7 @@ class CLI < Thor
2824
3620
  'user_updated_time' => now,
2825
3621
  'latitude' => latitude,
2826
3622
  'longitude' => longitude,
3623
+ 'altitude' => altitude,
2827
3624
  'source_url' => source_url,
2828
3625
  'deleted_time' => 0
2829
3626
  })
@@ -2884,6 +3681,7 @@ class CLI < Thor
2884
3681
 
2885
3682
  def display_note_metadata(note)
2886
3683
  metadata = []
3684
+ metadata << "Notebook: #{notebook_path_for_note(note)}"
2887
3685
  metadata << "Created: #{format_note_time(note['created_time'])}" if note['created_time'].to_i.positive?
2888
3686
  metadata << "Updated: #{format_note_time(note['updated_time'])}" if note['updated_time'].to_i.positive?
2889
3687
 
@@ -2905,6 +3703,11 @@ class CLI < Thor
2905
3703
  metadata
2906
3704
  end
2907
3705
 
3706
+ def notebook_path_for_note(note)
3707
+ folder = @db.get_first_row("SELECT id, title, parent_id FROM folders WHERE id = ?", [note['parent_id']])
3708
+ folder ? get_full_path(folder) : "/"
3709
+ end
3710
+
2908
3711
  def write_with_pager(output, raw:, no_pager:)
2909
3712
  rows, columns = terminal_dimensions
2910
3713
  unless should_page_output?(output, raw: raw, no_pager: no_pager, tty: $stdout.tty?, rows: rows, columns: columns)
@@ -3137,6 +3940,35 @@ class CLI < Thor
3137
3940
  Time.at(milliseconds / 1000.0).strftime('%Y-%m-%d %H:%M')
3138
3941
  end
3139
3942
 
3943
+ def alarm_warning_suppressed?(command)
3944
+ command_name = command.name.to_s
3945
+ %w[__complete test].include?(command_name) || (command_name == "cat" && options[:raw])
3946
+ end
3947
+
3948
+ def show_active_alarm_warning(now: Time.now)
3949
+ alarms = @db.execute(<<~SQL, [now.to_i * 1000])
3950
+ SELECT a.id, a.trigger_time, n.id AS note_id, n.title
3951
+ FROM alarms a
3952
+ JOIN notes n ON n.id = a.note_id
3953
+ WHERE a.trigger_time > 0
3954
+ AND a.trigger_time <= ?
3955
+ AND n.is_todo != 0
3956
+ AND n.todo_completed = 0
3957
+ AND n.deleted_time = 0
3958
+ ORDER BY a.trigger_time, n.title COLLATE NOCASE, a.id
3959
+ SQL
3960
+ return if alarms.empty?
3961
+
3962
+ noun = alarms.length == 1 ? "reminder" : "reminders"
3963
+ say "WARNING: #{alarms.length} active #{noun}".yellow.bold
3964
+ alarms.each do |alarm|
3965
+ title = alarm['title'].to_s.gsub(/\s+/, ' ').strip
3966
+ title = "(untitled)" if title.empty?
3967
+ say " #{format_note_time(alarm['trigger_time'])} [#{alarm['note_id'][0, 5]}] #{title}".yellow
3968
+ end
3969
+ say ""
3970
+ end
3971
+
3140
3972
  def time_ago(time)
3141
3973
  diff = Time.now - time
3142
3974
  if diff < 60
@@ -3219,10 +4051,11 @@ class CLI < Thor
3219
4051
  complete -c jp -n __jp_needs_command -a edit -d 'Edit a note or tag stack'
3220
4052
  complete -c jp -n __jp_needs_command -a mv -d 'Rename a notebook or tag'
3221
4053
  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'
4054
+ complete -c jp -n __jp_needs_command -a trash -d 'Manage trashed notes and notebooks'
3223
4055
  complete -c jp -n __jp_needs_command -a settings -d 'Manage settings'
3224
4056
  complete -c jp -n __jp_needs_command -a info -d 'Show JP and Joplin profile information'
3225
4057
  complete -c jp -n __jp_needs_command -a bkup -d 'Backup the database'
4058
+ complete -c jp -n __jp_needs_command -a export -d 'Export notes as Markdown with front matter'
3226
4059
  complete -c jp -n __jp_needs_command -a version -d 'Show version'
3227
4060
  complete -c jp -n __jp_needs_command -a init -d 'Print shell integration code'
3228
4061
  complete -c jp -n '__jp_using_command init' -a fish -d 'Fish shell'
@@ -3230,6 +4063,7 @@ class CLI < Thor
3230
4063
  complete -c jp -n '__jp_using_command add' -a '(__jp_complete notebooks)'
3231
4064
  complete -c jp -n '__jp_using_command add' -s t -l title -r -d 'Note title'
3232
4065
  complete -c jp -n '__jp_using_command add' -s T -l tag -r -a '(__jp_complete tags)' -d 'Tag'
4066
+ complete -c jp -n '__jp_using_command export' -s N -l no-resources -d 'Do not copy or rewrite resources'
3233
4067
  complete -c jp -n '__jp_using_command journal' -a 'today yesterday' -d 'Journal date'
3234
4068
  complete -c jp -n '__jp_using_command j' -a 'today yesterday' -d 'Journal date'
3235
4069
 
@@ -3490,6 +4324,7 @@ class CLI < Thor
3490
4324
  end
3491
4325
  end
3492
4326
 
4327
+ explicit_type = options[:nb] || options[:type] || target.match?(/\A[nt]:/)
3493
4328
  sn, st = options[:nb] || (!options[:type]), options[:type] || (!options[:nb])
3494
4329
  if target =~ /^n:(.+)$/; sn, st, target = true, false, $1
3495
4330
  elsif target =~ /^t:(.+)$/; sn, st, target = false, true, $1; end
@@ -3510,7 +4345,16 @@ class CLI < Thor
3510
4345
  found = true
3511
4346
  end
3512
4347
  end
3513
- say "No notebook or tag found matching '#{target}'." unless found
4348
+ return if found
4349
+
4350
+ notes = explicit_type ? [] : find_notes(target)
4351
+ if notes.empty?
4352
+ say "No notebook or tag found matching '#{target}'."
4353
+ return
4354
+ end
4355
+
4356
+ note = resolve_cat_target(target, notes: notes)
4357
+ display_note(note, raw: false, verbose: false, no_pager: false) if note
3514
4358
  end
3515
4359
 
3516
4360
  def list_notebook_contents(notebook)