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