joplin 1.2.1 → 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 +912 -71
  5. data/lib/joplin/version.rb +1 -1
  6. data/readme.md +32 -2
  7. metadata +1 -1
data/bin/jp CHANGED
@@ -10,6 +10,7 @@ require 'uri'
10
10
  require 'io/console'
11
11
  require 'stringio'
12
12
  require 'shellwords'
13
+ require 'open3'
13
14
  require 'json'
14
15
 
15
16
  # Polyfill for CGI.parse which was removed in Ruby 4.x
@@ -67,14 +68,18 @@ class TestRunner
67
68
  db = SQLite3::Database.new(@db_path)
68
69
  db.execute_batch <<-SQL
69
70
  CREATE TABLE folders (id TEXT PRIMARY KEY, title TEXT, parent_id TEXT NOT NULL DEFAULT "", deleted_time INT NOT NULL DEFAULT 0);
70
- CREATE TABLE notes (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL DEFAULT "", title TEXT, body TEXT, created_time INT, updated_time INT, latitude REAL, longitude REAL, source_url TEXT, deleted_time INT NOT NULL DEFAULT 0);
71
+ CREATE TABLE 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);
71
72
  CREATE TABLE tags (id TEXT PRIMARY KEY, title TEXT);
72
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);
73
76
  SQL
74
77
  db.close
75
78
 
76
79
  @old_joplin_data_dir = ENV['JOPLIN_DATA_DIR']
77
80
  ENV['JOPLIN_DATA_DIR'] = @dir
81
+ @old_core_location_cli = ENV['CORE_LOCATION_CLI']
82
+ ENV['CORE_LOCATION_CLI'] = ""
78
83
 
79
84
  @old_stdout = $stdout
80
85
  @old_stderr = $stderr
@@ -151,6 +156,7 @@ class TestRunner
151
156
  $stdout = @old_stdout
152
157
  $stderr = @old_stderr
153
158
  ENV['JOPLIN_DATA_DIR'] = @old_joplin_data_dir
159
+ ENV['CORE_LOCATION_CLI'] = @old_core_location_cli
154
160
  db.close rescue nil
155
161
  FileUtils.remove_entry(@dir) rescue nil
156
162
  end
@@ -472,6 +478,106 @@ module TestSuite
472
478
  assert_includes out, Time.at(backup_epoch).strftime('%Y-%m-%d %H:%M:%S')
473
479
  assert_includes out, "(1 hours ago)"
474
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
475
581
  end
476
582
 
477
583
  describe "bkup" do
@@ -537,6 +643,18 @@ module TestSuite
537
643
  end
538
644
 
539
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
+
540
658
  it "filters notebook notes by date" do
541
659
  runner.db.execute("INSERT INTO folders (id, title, parent_id) VALUES ('f1', 'Work', '')")
542
660
  runner.db.execute("INSERT INTO notes (id, parent_id, title, updated_time) VALUES ('n1', 'f1', 'Today Note', 1718294400000)")
@@ -760,7 +878,8 @@ module TestSuite
760
878
  runner.run_cmd(["rm", "EmptyNotebook", "-r"])
761
879
  runner.run_cmd(["rm", "empty-tag", "-r"])
762
880
 
763
- 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
764
883
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM tags")
765
884
  end
766
885
 
@@ -780,7 +899,7 @@ module TestSuite
780
899
 
781
900
  runner.run_cmd(["rm", "Work", "-r"], stdin: "n\n", stdin_tty: true)
782
901
 
783
- 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"
784
903
  assert_equal 1, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
785
904
  assert_equal 0, runner.db.get_first_value("SELECT deleted_time FROM notes WHERE id = 'n1'")
786
905
  end
@@ -793,7 +912,8 @@ module TestSuite
793
912
 
794
913
  runner.run_cmd(["rm", "Work", "-r"], stdin: "yes\n", stdin_tty: true)
795
914
 
796
- 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")
797
917
  assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
798
918
  end
799
919
 
@@ -853,9 +973,34 @@ module TestSuite
853
973
  out = TestSuite.strip_ansi(runner.last_stdout)
854
974
  assert_includes out, "e1111"
855
975
  assert_includes out, "Work/Deleted Plan"
976
+ refute_includes out, "Note Work/Deleted Plan"
856
977
  refute_includes out, "Active Plan"
857
978
  end
858
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
+
859
1004
  it "restores a trashed note by UUID" do
860
1005
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e333333', 'Restore Me', 1781524800000)")
861
1006
 
@@ -865,6 +1010,31 @@ module TestSuite
865
1010
  assert_includes TestSuite.strip_ansi(runner.last_stdout), "Restored note 'Restore Me'"
866
1011
  end
867
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
+
868
1038
  it "requires a unique UUID when restoring" do
869
1039
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444441', 'One', 1781524800000)")
870
1040
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e444442', 'Two', 1781524800000)")
@@ -872,7 +1042,7 @@ module TestSuite
872
1042
  runner.run_cmd(["trash", "restore", "e4444"])
873
1043
 
874
1044
  out = TestSuite.strip_ansi(runner.last_stdout)
875
- assert_includes out, "Ambiguous trashed note"
1045
+ assert_includes out, "Ambiguous trashed item"
876
1046
  assert_includes out, "e4444 One"
877
1047
  assert_includes out, "e4444 Two"
878
1048
  assert_equal 2, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
@@ -881,13 +1051,16 @@ module TestSuite
881
1051
  it "empties Trash only after confirmation" do
882
1052
  runner.db.execute("INSERT INTO notes (id, title, deleted_time) VALUES ('e555555', 'Delete Me', 1781524800000)")
883
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)")
884
1055
 
885
1056
  runner.run_cmd(["trash", "empty"], stdin: "n\n", stdin_tty: true)
886
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")
887
1059
 
888
1060
  runner.run_cmd(["trash", "empty"], stdin: "yes\n", stdin_tty: true)
889
1061
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes WHERE deleted_time > 0")
890
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")
891
1064
  end
892
1065
  end
893
1066
 
@@ -991,6 +1164,46 @@ module TestSuite
991
1164
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM notes")
992
1165
  assert_equal 0, runner.db.get_first_value("SELECT COUNT(*) FROM folders")
993
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
994
1207
  end
995
1208
 
996
1209
  describe "journal" do
@@ -1004,6 +1217,20 @@ module TestSuite
1004
1217
  assert_includes TestSuite.strip_ansi(runner.last_stdout), "Updated journal Journal/2026/06-Jun/2026-06-17"
1005
1218
  end
1006
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
+
1007
1234
  it "aliases j to journal" do
1008
1235
  runner.run_cmd(["j", "2026-06-17", "short", "entry"])
1009
1236
 
@@ -1056,6 +1283,27 @@ module TestSuite
1056
1283
  assert_equal "existing body", File.read(captured)
1057
1284
  assert_equal "existing body\nnew line", runner.db.get_first_value("SELECT body FROM notes")
1058
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
1059
1307
  end
1060
1308
 
1061
1309
  describe "cat" do
@@ -1202,6 +1450,18 @@ module TestSuite
1202
1450
  assert_includes out, "Tags: Urgent, work"
1203
1451
  end
1204
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
+
1205
1465
  it "omits missing metadata in verbose mode" do
1206
1466
  runner.db.execute("INSERT INTO notes (id, title, body) VALUES ('e1', 'Plain Note', 'Body')")
1207
1467
 
@@ -1311,6 +1571,7 @@ module TestSuite
1311
1571
  assert_includes out, "complete -c jp"
1312
1572
  assert_includes out, "Override readonly or pager for this command"
1313
1573
  assert_includes out, "__jp_complete targets"
1574
+ assert_includes out, "-a export"
1314
1575
  assert_includes out, "jp init fish"
1315
1576
  end
1316
1577
 
@@ -1359,6 +1620,121 @@ module TestSuite
1359
1620
  end
1360
1621
  end
1361
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
+
1362
1738
  describe "help" do
1363
1739
  it "colorizes the command listing" do
1364
1740
  runner.run_cmd("help")
@@ -1369,6 +1745,7 @@ module TestSuite
1369
1745
  assert_includes plain, "Usage:"
1370
1746
  assert_includes plain, "jp ls [TARGET]"
1371
1747
  assert_includes plain, "jp journal [today|yesterday|YYYY-MM-DD] [TEXT...]"
1748
+ assert_includes plain, "jp export PATH"
1372
1749
  assert_includes out, "ls".green.bold
1373
1750
  refute_includes plain, "__complete"
1374
1751
  end
@@ -1396,6 +1773,18 @@ module TestSuite
1396
1773
  assert_equal File.join(ENV.fetch('HOME'), '.config', 'joplin'), CLI::DEFAULT_JOPLIN_DATA_DIR
1397
1774
  end
1398
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
+
1399
1788
  it "uses JOPLIN_DATA_DIR for the database and settings file" do
1400
1789
  assert_equal File.join(runner.dir, 'database.sqlite'), runner.cli.instance_variable_get(:@db_path)
1401
1790
  assert_equal File.join(runner.dir, 'settings.json'), runner.settings_path
@@ -1767,17 +2156,17 @@ end
1767
2156
  class Trash < Thor
1768
2157
  def self.exit_on_failure? = true
1769
2158
 
1770
- desc "ls", "List trashed notes"
2159
+ desc "ls", "List trashed notes and notebooks"
1771
2160
  def ls
1772
2161
  CLI.new.send(:list_trash)
1773
2162
  end
1774
2163
 
1775
- desc "restore UUID", "Restore a trashed note"
2164
+ desc "restore UUID", "Restore a trashed note or notebook"
1776
2165
  def restore(uuid)
1777
- CLI.new.send(:restore_trash_note, uuid)
2166
+ CLI.new.send(:restore_trash_item, uuid)
1778
2167
  end
1779
2168
 
1780
- desc "empty", "Permanently delete all trashed notes"
2169
+ desc "empty", "Permanently delete all trashed items"
1781
2170
  def empty
1782
2171
  CLI.new.send(:empty_trash)
1783
2172
  end
@@ -1867,11 +2256,18 @@ class CLI < Thor
1867
2256
  super.reject { |command| command.first.include?("__complete") }
1868
2257
  end
1869
2258
 
1870
- VERSION = "0.36.1"
2259
+ VERSION = "0.38.8"
1871
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')
1872
2265
 
1873
- def self.joplin_data_dir
1874
- 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')
1875
2271
  end
1876
2272
 
1877
2273
  desc "settings SUBCOMMAND", "Manage settings"
@@ -1880,7 +2276,7 @@ class CLI < Thor
1880
2276
  desc "init SUBCOMMAND", "Print shell integration code"
1881
2277
  subcommand "init", Init
1882
2278
 
1883
- desc "trash SUBCOMMAND", "Manage trashed notes"
2279
+ desc "trash SUBCOMMAND", "Manage trashed notes and notebooks"
1884
2280
  subcommand "trash", Trash
1885
2281
 
1886
2282
  def self.help(shell, subcommand = false)
@@ -1932,6 +2328,13 @@ class CLI < Thor
1932
2328
  load_settings unless Array(Thread.current[:jp_args]).first == "test"
1933
2329
  end
1934
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
+
1935
2338
  desc "version", "Show version"
1936
2339
  def version
1937
2340
  puts VERSION
@@ -1962,6 +2365,8 @@ class CLI < Thor
1962
2365
  else
1963
2366
  say "Backups: ".cyan + " none".yellow
1964
2367
  end
2368
+
2369
+ show_core_location_cli_info
1965
2370
  end
1966
2371
 
1967
2372
  desc "bkup", "Backup the database"
@@ -2020,17 +2425,12 @@ class CLI < Thor
2020
2425
  title = derive_note_title(body) if title.empty?
2021
2426
  return say "Error: Note title is required.".red if title.nil? || title.empty?
2022
2427
 
2023
- source_url ||= ""
2024
- latitude ||= nil
2025
- longitude ||= nil
2026
2428
  note = create_note_with_hierarchy(
2027
2429
  notebook_path: notebook_path,
2028
2430
  title: title,
2029
2431
  body: body,
2030
2432
  tags: tags,
2031
- source_url: source_url,
2032
- latitude: latitude,
2033
- longitude: longitude
2433
+ source_url: ""
2034
2434
  )
2035
2435
 
2036
2436
  say "Created note #{note[:path].green}"
@@ -2078,7 +2478,8 @@ class CLI < Thor
2078
2478
  when "removable"
2079
2479
  completion_removable_items.each { |candidate, description| puts "#{candidate}\t#{description}" }
2080
2480
  when "trashed"
2081
- 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)}" }
2082
2483
  end
2083
2484
  end
2084
2485
 
@@ -2112,8 +2513,8 @@ class CLI < Thor
2112
2513
  lines.first.split(/\s+/)
2113
2514
  end
2114
2515
 
2115
- def resolve_cat_target(target)
2116
- notes = find_notes(target)
2516
+ def resolve_cat_target(target, notes: nil)
2517
+ notes ||= find_notes(target)
2117
2518
  if notes.empty?
2118
2519
  say "No note found matching '#{target}'."
2119
2520
  nil
@@ -2351,7 +2752,7 @@ class CLI < Thor
2351
2752
  auto_bkup_if_needed
2352
2753
  end
2353
2754
 
2354
- 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"
2355
2756
  method_option :recursive, aliases: "-r", type: :boolean, desc: "Remove a notebook or tag recursively"
2356
2757
  method_option :force, aliases: "-f", type: :boolean, desc: "Do not prompt before trashing notes"
2357
2758
  def rm(target)
@@ -2384,7 +2785,11 @@ class CLI < Thor
2384
2785
  note_ids = removable_note_ids(item)
2385
2786
  unless note_ids.empty? || options[:force]
2386
2787
  noun = note_ids.length == 1 ? "note" : "notes"
2387
- print "Move #{note_ids.length} #{noun} to Trash and remove #{item[:type]} '#{item[:row]['title']}'? [y/N] "
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
2388
2793
  answer = $stdin.gets.to_s.strip.downcase
2389
2794
  unless %w[y yes].include?(answer)
2390
2795
  say "Cancelled."
@@ -2394,11 +2799,71 @@ class CLI < Thor
2394
2799
 
2395
2800
  remove_item(item, note_ids)
2396
2801
  noun = item[:type] == :notebook ? "notebook" : "tag"
2397
- summary = note_ids.empty? ? "" : " and moved #{note_ids.length} #{note_ids.length == 1 ? 'note' : 'notes'} to Trash"
2398
- say "Removed #{noun} '#{item[:row]['title']}'#{summary}.".green
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
2399
2808
  auto_bkup_if_needed
2400
2809
  end
2401
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
+
2402
2867
  desc "test", "Run tests"
2403
2868
  method_option :verbose, aliases: "-v", type: :boolean
2404
2869
  def test
@@ -2415,6 +2880,156 @@ class CLI < Thor
2415
2880
 
2416
2881
  private
2417
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
+
2418
3033
  def find_movable_items(source)
2419
3034
  if source.match?(/\A[a-f0-9]{1,32}\z/i)
2420
3035
  prefix = "#{source.downcase}%"
@@ -2515,83 +3130,188 @@ class CLI < Thor
2515
3130
  ids
2516
3131
  end
2517
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
+
2518
3148
  def remove_item(item, note_ids)
3149
+ deleted_at = Time.now.to_i * 1000
2519
3150
  @db.transaction do
2520
- trash_notes(note_ids)
2521
3151
  if item[:type] == :tag
3152
+ trash_notes(note_ids, deleted_at: deleted_at)
2522
3153
  @db.execute("DELETE FROM note_tags WHERE tag_id = ?", [item[:row]['id']])
2523
3154
  @db.execute("DELETE FROM tags WHERE id = ?", [item[:row]['id']])
2524
3155
  else
2525
3156
  folder_ids = notebook_subtree_ids(item[:row]['id'])
2526
- placeholders = Array.new(folder_ids.length, "?").join(", ")
2527
- @db.execute("DELETE FROM folders WHERE id IN (#{placeholders})", folder_ids)
3157
+ trash_notes(note_ids, deleted_at: deleted_at)
3158
+ set_deleted_time('folders', folder_ids, deleted_at)
2528
3159
  end
2529
3160
  end
2530
3161
  end
2531
3162
 
2532
- def trash_notes(note_ids)
2533
- 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
2534
3166
 
2535
- columns = @db.execute("PRAGMA table_info(notes)").map { |column| column['name'] }.to_set
2536
- 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
2537
3171
  assignments = ["deleted_time = ?"]
2538
- values = [now]
2539
- if columns.include?("updated_time")
2540
- assignments << "updated_time = ?"
2541
- 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
2542
3178
  end
2543
- placeholders = Array.new(note_ids.length, "?").join(", ")
3179
+ placeholders = Array.new(ids.length, "?").join(", ")
2544
3180
  @db.execute(
2545
- "UPDATE notes SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
2546
- [*values, *note_ids]
3181
+ "UPDATE #{table} SET #{assignments.join(', ')} WHERE id IN (#{placeholders})",
3182
+ [*values, *ids]
2547
3183
  )
2548
3184
  end
2549
3185
 
2550
3186
  def trashed_notes
2551
- @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
2552
3211
  end
2553
3212
 
2554
3213
  def list_trash
2555
3214
  notes = trashed_notes
2556
- return say "Trash is empty." if notes.empty?
3215
+ folders = visible_trashed_folders
3216
+ return say "Trash is empty." if notes.empty? && folders.empty?
2557
3217
 
2558
- notes.each do |note|
2559
- id = note['id'][0..4].colorize(mode: :dim)
2560
- timestamp = format_note_time(note['deleted_time']).colorize(mode: :dim)
2561
- say "#{id} #{timestamp} #{get_full_path_for_note(note)}"
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}"
2562
3226
  end
2563
3227
  end
2564
3228
 
2565
- def restore_trash_note(uuid)
3229
+ def restore_trash_item(uuid)
2566
3230
  return unless ensure_writable!
2567
3231
 
2568
- matches = @db.execute(
2569
- "SELECT id, title, parent_id FROM notes WHERE LOWER(id) LIKE ? AND deleted_time > 0 ORDER BY title COLLATE NOCASE",
2570
- ["#{uuid.downcase}%"]
2571
- )
2572
- return say "No trashed note found matching '#{uuid}'." if matches.empty?
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?
2573
3241
 
2574
3242
  if matches.length > 1
2575
- say "Ambiguous trashed note '#{uuid}'. Use a longer UUID:".yellow
2576
- matches.each do |note|
2577
- say " #{note['id'][0..4].colorize(mode: :dim)} #{get_full_path_for_note(note)}"
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}"
2578
3248
  end
2579
3249
  return
2580
3250
  end
2581
3251
 
2582
- note = matches.first
2583
- @db.execute("UPDATE notes SET deleted_time = 0 WHERE id = ?", [note['id']])
2584
- say "Restored note '#{note['title']}'.".green
3252
+ item = matches.first
3253
+ item[:type] == :notebook ? restore_trashed_notebook(item[:row]) : restore_trashed_note(item[:row])
2585
3254
  auto_bkup_if_needed
2586
3255
  end
2587
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
+
2588
3306
  def empty_trash
2589
3307
  return unless ensure_writable!
2590
3308
 
2591
- 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
2592
3312
  return say "Trash is empty." if count.zero?
2593
3313
 
2594
- noun = count == 1 ? "note" : "notes"
3314
+ noun = count == 1 ? "item" : "items"
2595
3315
  print "Permanently delete #{count} trashed #{noun}? [y/N] "
2596
3316
  answer = $stdin.gets.to_s.strip.downcase
2597
3317
  unless %w[y yes].include?(answer)
@@ -2599,11 +3319,23 @@ class CLI < Thor
2599
3319
  return
2600
3320
  end
2601
3321
 
2602
- trashed_ids = @db.execute("SELECT id FROM notes WHERE deleted_time > 0").map { |row| row['id'] }
2603
- placeholders = Array.new(trashed_ids.length, "?").join(", ")
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!
2604
3329
  @db.transaction do
2605
- @db.execute("DELETE FROM note_tags WHERE note_id IN (#{placeholders})", trashed_ids)
2606
- @db.execute("DELETE FROM notes WHERE id IN (#{placeholders})", trashed_ids)
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
2607
3339
  end
2608
3340
  say "Emptied Trash.".green
2609
3341
  auto_bkup_if_needed
@@ -2682,7 +3414,13 @@ class CLI < Thor
2682
3414
  return nil
2683
3415
  end
2684
3416
 
2685
- 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")
2686
3424
  if body.empty?
2687
3425
  say "Journal cancelled."
2688
3426
  return nil
@@ -2733,9 +3471,7 @@ class CLI < Thor
2733
3471
  title: title,
2734
3472
  body: body,
2735
3473
  tags: [],
2736
- source_url: "",
2737
- latitude: nil,
2738
- longitude: nil
3474
+ source_url: ""
2739
3475
  )
2740
3476
  end
2741
3477
 
@@ -2782,10 +3518,67 @@ class CLI < Thor
2782
3518
  raise AddError, "#{name} must be a number."
2783
3519
  end
2784
3520
 
2785
- 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:)
2786
3578
  components = notebook_path.to_s.split('/', -1).map(&:strip)
2787
3579
  raise AddError, "Notebook path is required." if components.empty? || components.any?(&:empty?)
2788
3580
 
3581
+ latitude, longitude, altitude = core_location || [nil, nil, nil]
2789
3582
  now = Time.now.to_i * 1000
2790
3583
  note_id = SecureRandom.hex(16)
2791
3584
  parent_id = ""
@@ -2827,6 +3620,7 @@ class CLI < Thor
2827
3620
  'user_updated_time' => now,
2828
3621
  'latitude' => latitude,
2829
3622
  'longitude' => longitude,
3623
+ 'altitude' => altitude,
2830
3624
  'source_url' => source_url,
2831
3625
  'deleted_time' => 0
2832
3626
  })
@@ -2887,6 +3681,7 @@ class CLI < Thor
2887
3681
 
2888
3682
  def display_note_metadata(note)
2889
3683
  metadata = []
3684
+ metadata << "Notebook: #{notebook_path_for_note(note)}"
2890
3685
  metadata << "Created: #{format_note_time(note['created_time'])}" if note['created_time'].to_i.positive?
2891
3686
  metadata << "Updated: #{format_note_time(note['updated_time'])}" if note['updated_time'].to_i.positive?
2892
3687
 
@@ -2908,6 +3703,11 @@ class CLI < Thor
2908
3703
  metadata
2909
3704
  end
2910
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
+
2911
3711
  def write_with_pager(output, raw:, no_pager:)
2912
3712
  rows, columns = terminal_dimensions
2913
3713
  unless should_page_output?(output, raw: raw, no_pager: no_pager, tty: $stdout.tty?, rows: rows, columns: columns)
@@ -3140,6 +3940,35 @@ class CLI < Thor
3140
3940
  Time.at(milliseconds / 1000.0).strftime('%Y-%m-%d %H:%M')
3141
3941
  end
3142
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
+
3143
3972
  def time_ago(time)
3144
3973
  diff = Time.now - time
3145
3974
  if diff < 60
@@ -3222,10 +4051,11 @@ class CLI < Thor
3222
4051
  complete -c jp -n __jp_needs_command -a edit -d 'Edit a note or tag stack'
3223
4052
  complete -c jp -n __jp_needs_command -a mv -d 'Rename a notebook or tag'
3224
4053
  complete -c jp -n __jp_needs_command -a rm -d 'Remove a notebook or tag'
3225
- complete -c jp -n __jp_needs_command -a trash -d 'Manage trashed notes'
4054
+ complete -c jp -n __jp_needs_command -a trash -d 'Manage trashed notes and notebooks'
3226
4055
  complete -c jp -n __jp_needs_command -a settings -d 'Manage settings'
3227
4056
  complete -c jp -n __jp_needs_command -a info -d 'Show JP and Joplin profile information'
3228
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'
3229
4059
  complete -c jp -n __jp_needs_command -a version -d 'Show version'
3230
4060
  complete -c jp -n __jp_needs_command -a init -d 'Print shell integration code'
3231
4061
  complete -c jp -n '__jp_using_command init' -a fish -d 'Fish shell'
@@ -3233,6 +4063,7 @@ class CLI < Thor
3233
4063
  complete -c jp -n '__jp_using_command add' -a '(__jp_complete notebooks)'
3234
4064
  complete -c jp -n '__jp_using_command add' -s t -l title -r -d 'Note title'
3235
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'
3236
4067
  complete -c jp -n '__jp_using_command journal' -a 'today yesterday' -d 'Journal date'
3237
4068
  complete -c jp -n '__jp_using_command j' -a 'today yesterday' -d 'Journal date'
3238
4069
 
@@ -3493,6 +4324,7 @@ class CLI < Thor
3493
4324
  end
3494
4325
  end
3495
4326
 
4327
+ explicit_type = options[:nb] || options[:type] || target.match?(/\A[nt]:/)
3496
4328
  sn, st = options[:nb] || (!options[:type]), options[:type] || (!options[:nb])
3497
4329
  if target =~ /^n:(.+)$/; sn, st, target = true, false, $1
3498
4330
  elsif target =~ /^t:(.+)$/; sn, st, target = false, true, $1; end
@@ -3513,7 +4345,16 @@ class CLI < Thor
3513
4345
  found = true
3514
4346
  end
3515
4347
  end
3516
- 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
3517
4358
  end
3518
4359
 
3519
4360
  def list_notebook_contents(notebook)