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