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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/Makefile +1 -0
- data/bin/jp +912 -71
- data/lib/joplin/version.rb +1 -1
- data/readme.md +32 -2
- 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
|
|
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
|
|
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
|
|
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(:
|
|
2166
|
+
CLI.new.send(:restore_trash_item, uuid)
|
|
1778
2167
|
end
|
|
1779
2168
|
|
|
1780
|
-
desc "empty", "Permanently delete all trashed
|
|
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.
|
|
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
|
-
|
|
1874
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2398
|
-
|
|
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
|
-
|
|
2527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2536
|
-
|
|
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 = [
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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(
|
|
3179
|
+
placeholders = Array.new(ids.length, "?").join(", ")
|
|
2544
3180
|
@db.execute(
|
|
2545
|
-
"UPDATE
|
|
2546
|
-
[*values, *
|
|
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(
|
|
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
|
-
|
|
3215
|
+
folders = visible_trashed_folders
|
|
3216
|
+
return say "Trash is empty." if notes.empty? && folders.empty?
|
|
2557
3217
|
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
|
3229
|
+
def restore_trash_item(uuid)
|
|
2566
3230
|
return unless ensure_writable!
|
|
2567
3231
|
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
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
|
|
2576
|
-
matches.each do |
|
|
2577
|
-
|
|
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
|
-
|
|
2583
|
-
|
|
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
|
-
|
|
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 ? "
|
|
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
|
-
|
|
2603
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|