doing 2.0.23 → 2.1.1pre
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/.yardoc/checksums +19 -15
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/CHANGELOG.md +40 -1
- data/Gemfile.lock +8 -1
- data/README.md +7 -1
- data/Rakefile +23 -4
- data/bin/doing +431 -256
- data/doc/Array.html +354 -1
- data/doc/Doing/Color.html +104 -92
- data/doc/Doing/Completion.html +216 -0
- data/doc/Doing/Configuration.html +340 -5
- data/doc/Doing/Content.html +229 -0
- data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
- data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
- data/doc/Doing/Errors/DoingStandardError.html +1 -1
- data/doc/Doing/Errors/EmptyInput.html +1 -1
- data/doc/Doing/Errors/NoResults.html +1 -1
- data/doc/Doing/Errors/PluginException.html +1 -1
- data/doc/Doing/Errors/UserCancelled.html +1 -1
- data/doc/Doing/Errors/WrongCommand.html +1 -1
- data/doc/Doing/Errors.html +1 -1
- data/doc/Doing/Hooks.html +1 -1
- data/doc/Doing/Item.html +337 -49
- data/doc/Doing/Items.html +444 -35
- data/doc/Doing/LogAdapter.html +139 -51
- data/doc/Doing/Note.html +253 -22
- data/doc/Doing/Pager.html +74 -36
- data/doc/Doing/Plugins.html +1 -1
- data/doc/Doing/Prompt.html +674 -0
- data/doc/Doing/Section.html +354 -0
- data/doc/Doing/Util.html +57 -1
- data/doc/Doing/WWID.html +517 -890
- data/doc/Doing/WWIDFile.html +398 -0
- data/doc/Doing.html +5 -5
- data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
- data/doc/GLI/Commands.html +1 -1
- data/doc/GLI.html +1 -1
- data/doc/Hash.html +97 -1
- data/doc/Status.html +37 -3
- data/doc/String.html +833 -53
- data/doc/Symbol.html +3 -3
- data/doc/Time.html +1 -1
- data/doc/_index.html +22 -1
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +8 -2
- data/doc/index.html +8 -2
- data/doc/method_list.html +460 -180
- data/doc/top-level-namespace.html +1 -1
- data/doing.gemspec +3 -0
- data/doing.rdoc +163 -44
- data/example_plugin.rb +5 -5
- data/lib/completion/_doing.zsh +42 -42
- data/lib/completion/doing.bash +21 -21
- data/lib/completion/doing.fish +1 -280
- data/lib/doing/array.rb +36 -0
- data/lib/doing/colors.rb +70 -66
- data/lib/doing/completion/bash_completion.rb +1 -2
- data/lib/doing/completion/fish_completion.rb +1 -1
- data/lib/doing/completion/zsh_completion.rb +1 -1
- data/lib/doing/completion.rb +6 -0
- data/lib/doing/configuration.rb +134 -23
- data/lib/doing/hash.rb +37 -0
- data/lib/doing/item.rb +77 -12
- data/lib/doing/items.rb +125 -0
- data/lib/doing/log_adapter.rb +58 -4
- data/lib/doing/note.rb +53 -1
- data/lib/doing/pager.rb +49 -38
- data/lib/doing/plugins/export/markdown_export.rb +4 -4
- data/lib/doing/plugins/export/template_export.rb +2 -2
- data/lib/doing/plugins/import/calendar_import.rb +4 -4
- data/lib/doing/plugins/import/doing_import.rb +5 -7
- data/lib/doing/plugins/import/timing_import.rb +3 -3
- data/lib/doing/prompt.rb +206 -0
- data/lib/doing/section.rb +30 -0
- data/lib/doing/string.rb +123 -35
- data/lib/doing/string_chronify.rb +81 -0
- data/lib/doing/util.rb +14 -6
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +387 -685
- data/lib/doing.rb +7 -2
- data/lib/examples/plugins/capture_thing_import.rb +162 -0
- data/rdoc_to_mmd.rb +14 -8
- data/scripts/generate_bash_completions.rb +1 -1
- data/scripts/generate_fish_completions.rb +1 -1
- data/scripts/generate_zsh_completions.rb +1 -1
- metadata +74 -5
- data/lib/doing/wwidfile.rb +0 -117
data/lib/doing/wwid.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Doing
|
|
|
16
16
|
|
|
17
17
|
attr_accessor :config, :config_file, :auto_tag, :default_option
|
|
18
18
|
|
|
19
|
+
include Color
|
|
19
20
|
# include Util
|
|
20
21
|
|
|
21
22
|
##
|
|
@@ -24,11 +25,8 @@ module Doing
|
|
|
24
25
|
def initialize
|
|
25
26
|
@timers = {}
|
|
26
27
|
@recorded_items = []
|
|
27
|
-
@content =
|
|
28
|
-
@doingrc_needs_update = false
|
|
29
|
-
@default_config_file = '.doingrc'
|
|
28
|
+
@content = Items.new
|
|
30
29
|
@auto_tag = true
|
|
31
|
-
@user_home = Util.user_home
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
##
|
|
@@ -56,52 +54,56 @@ module Doing
|
|
|
56
54
|
create(@doing_file) unless File.exist?(@doing_file)
|
|
57
55
|
input = IO.read(@doing_file)
|
|
58
56
|
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
|
57
|
+
logger.debug('Read:', "read file #{@doing_file}")
|
|
59
58
|
elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
|
|
60
59
|
@doing_file = File.expand_path(path)
|
|
61
60
|
input = IO.read(File.expand_path(path))
|
|
62
61
|
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
|
62
|
+
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
|
63
63
|
elsif path.length < 256
|
|
64
64
|
@doing_file = File.expand_path(path)
|
|
65
65
|
create(path)
|
|
66
66
|
input = IO.read(File.expand_path(path))
|
|
67
67
|
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
|
68
|
+
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
@other_content_top = []
|
|
71
72
|
@other_content_bottom = []
|
|
72
73
|
|
|
73
|
-
section =
|
|
74
|
+
section = nil
|
|
74
75
|
lines = input.split(/[\n\r]/)
|
|
75
|
-
current = 0
|
|
76
76
|
|
|
77
77
|
lines.each do |line|
|
|
78
78
|
next if line =~ /^\s*$/
|
|
79
79
|
|
|
80
80
|
if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
|
|
81
81
|
section = Regexp.last_match(1)
|
|
82
|
-
@content
|
|
83
|
-
@content[section][:original] = line
|
|
84
|
-
@content[section][:items] = []
|
|
85
|
-
current = 0
|
|
82
|
+
@content.add_section(Section.new(section, original: line), log: false)
|
|
86
83
|
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
|
|
84
|
+
if section.nil?
|
|
85
|
+
section = 'Uncategorized'
|
|
86
|
+
@content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
|
|
87
|
+
end
|
|
88
|
+
|
|
87
89
|
date = Regexp.last_match(1).strip
|
|
88
90
|
title = Regexp.last_match(2).strip
|
|
89
91
|
item = Item.new(date, title, section)
|
|
90
|
-
@content
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# if content[section][:items].length - 1 == current
|
|
92
|
+
@content.push(item)
|
|
93
|
+
elsif @content.count.zero?
|
|
94
|
+
# if content[section].items.length - 1 == current
|
|
94
95
|
@other_content_top.push(line)
|
|
95
96
|
elsif line =~ /^\S/
|
|
96
97
|
@other_content_bottom.push(line)
|
|
97
98
|
else
|
|
98
|
-
prev_item = @content
|
|
99
|
+
prev_item = @content.last
|
|
99
100
|
prev_item.note = Note.new unless prev_item.note
|
|
100
101
|
|
|
101
102
|
prev_item.note.add(line)
|
|
102
103
|
# end
|
|
103
104
|
end
|
|
104
105
|
end
|
|
106
|
+
|
|
105
107
|
Hooks.trigger :post_read, self
|
|
106
108
|
end
|
|
107
109
|
|
|
@@ -122,7 +124,7 @@ module Doing
|
|
|
122
124
|
##
|
|
123
125
|
## @param input [String] Text input for editor
|
|
124
126
|
##
|
|
125
|
-
def fork_editor(input = '')
|
|
127
|
+
def fork_editor(input = '', message: :default)
|
|
126
128
|
# raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
|
|
127
129
|
|
|
128
130
|
raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
|
@@ -131,7 +133,9 @@ module Doing
|
|
|
131
133
|
|
|
132
134
|
File.open(tmpfile.path, 'w+') do |f|
|
|
133
135
|
f.puts input
|
|
134
|
-
|
|
136
|
+
unless message.nil?
|
|
137
|
+
f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
|
|
138
|
+
end
|
|
135
139
|
end
|
|
136
140
|
|
|
137
141
|
pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
|
|
@@ -177,13 +181,37 @@ module Doing
|
|
|
177
181
|
title = input_lines[0]&.strip
|
|
178
182
|
raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
|
|
179
183
|
|
|
184
|
+
date = nil
|
|
185
|
+
iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
|
|
186
|
+
done_rx = /(?<=^| )@(?<tag>done|finished|completed?)\((?<date>.*?)\)/i
|
|
187
|
+
date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
|
|
188
|
+
|
|
189
|
+
title.gsub!(done_rx) do
|
|
190
|
+
m = Regexp.last_match
|
|
191
|
+
t = m['tag']
|
|
192
|
+
d = m['date']
|
|
193
|
+
parsed_date = d =~ date_rx ? Time.parse(d) : d.chronify(guess: :begin)
|
|
194
|
+
parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if title =~ date_rx
|
|
198
|
+
m = title.match(date_rx)
|
|
199
|
+
d = m['date']
|
|
200
|
+
date = if d =~ iso_rx
|
|
201
|
+
Time.parse(d)
|
|
202
|
+
else
|
|
203
|
+
d.chronify(guess: :begin)
|
|
204
|
+
end
|
|
205
|
+
title.sub!(date_rx, '').strip!
|
|
206
|
+
end
|
|
207
|
+
|
|
180
208
|
note = Note.new
|
|
181
209
|
note.add(input_lines[1..-1]) if input_lines.length > 1
|
|
182
210
|
# If title line ends in a parenthetical, use that as the note
|
|
183
211
|
if note.empty? && title =~ /\s+\(.*?\)$/
|
|
184
|
-
title.sub!(/\s+\((
|
|
212
|
+
title.sub!(/\s+\((?<note>.*?)\)$/) do
|
|
185
213
|
m = Regexp.last_match
|
|
186
|
-
note.add(m[
|
|
214
|
+
note.add(m['note'])
|
|
187
215
|
''
|
|
188
216
|
end
|
|
189
217
|
end
|
|
@@ -191,72 +219,7 @@ module Doing
|
|
|
191
219
|
note.strip_lines!
|
|
192
220
|
note.compress
|
|
193
221
|
|
|
194
|
-
[title, note]
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
##
|
|
198
|
-
## Converts input string into a Time object when input takes on the
|
|
199
|
-
## following formats:
|
|
200
|
-
## - interval format e.g. '1d2h30m', '45m' etc.
|
|
201
|
-
## - a semantic phrase e.g. 'yesterday 5:30pm'
|
|
202
|
-
## - a strftime e.g. '2016-03-15 15:32:04 PDT'
|
|
203
|
-
##
|
|
204
|
-
## @param input [String] String to chronify
|
|
205
|
-
##
|
|
206
|
-
## @return [DateTime] result
|
|
207
|
-
##
|
|
208
|
-
def chronify(input, future: false, guess: :begin)
|
|
209
|
-
now = Time.now
|
|
210
|
-
raise InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
|
|
211
|
-
|
|
212
|
-
secs_ago = if input.match(/^(\d+)$/)
|
|
213
|
-
# plain number, assume minutes
|
|
214
|
-
Regexp.last_match(1).to_i * 60
|
|
215
|
-
elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
|
|
216
|
-
# day/hour/minute format e.g. 1d2h30m
|
|
217
|
-
[[m['day'], 24 * 3600],
|
|
218
|
-
[m['hour'], 3600],
|
|
219
|
-
[m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
if secs_ago
|
|
223
|
-
now - secs_ago
|
|
224
|
-
else
|
|
225
|
-
Chronic.parse(input, { guess: guess, context: future ? :future : :past, ambiguous_time_range: 8 })
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
##
|
|
230
|
-
## Converts simple strings into seconds that can be added to a Time
|
|
231
|
-
## object
|
|
232
|
-
##
|
|
233
|
-
## @param qty [String] HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
|
|
234
|
-
## 1.5d, 1h20m, etc.)
|
|
235
|
-
##
|
|
236
|
-
## @return [Integer] seconds
|
|
237
|
-
##
|
|
238
|
-
def chronify_qty(qty)
|
|
239
|
-
minutes = 0
|
|
240
|
-
case qty.strip
|
|
241
|
-
when /^(\d+):(\d\d)$/
|
|
242
|
-
minutes += Regexp.last_match(1).to_i * 60
|
|
243
|
-
minutes += Regexp.last_match(2).to_i
|
|
244
|
-
when /^(\d+(?:\.\d+)?)([hmd])?$/
|
|
245
|
-
amt = Regexp.last_match(1)
|
|
246
|
-
type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
|
|
247
|
-
|
|
248
|
-
minutes = case type.downcase
|
|
249
|
-
when 'm'
|
|
250
|
-
amt.to_i
|
|
251
|
-
when 'h'
|
|
252
|
-
(amt.to_f * 60).round
|
|
253
|
-
when 'd'
|
|
254
|
-
(amt.to_f * 60 * 24).round
|
|
255
|
-
else
|
|
256
|
-
minutes
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
minutes * 60
|
|
222
|
+
[date, title, note]
|
|
260
223
|
end
|
|
261
224
|
|
|
262
225
|
##
|
|
@@ -265,21 +228,7 @@ module Doing
|
|
|
265
228
|
## @return [Array] section titles
|
|
266
229
|
##
|
|
267
230
|
def sections
|
|
268
|
-
@content.
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
##
|
|
272
|
-
## Adds a section.
|
|
273
|
-
##
|
|
274
|
-
## @param title [String] The new section title
|
|
275
|
-
##
|
|
276
|
-
def add_section(title)
|
|
277
|
-
if @content.key?(title.cap_first)
|
|
278
|
-
raise InvalidSection, %(section "#{title.cap_first}" already exists)
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
@content[title.cap_first] = { :original => "#{title}:", :items => [] }
|
|
282
|
-
logger.info('New section:', %("#{title.cap_first}"))
|
|
231
|
+
@content.section_titles
|
|
283
232
|
end
|
|
284
233
|
|
|
285
234
|
##
|
|
@@ -292,8 +241,9 @@ module Doing
|
|
|
292
241
|
return 'All' if frag =~ /^all$/i
|
|
293
242
|
frag ||= @config['current_section']
|
|
294
243
|
|
|
295
|
-
|
|
296
|
-
|
|
244
|
+
return frag.cap_first if @content.section?(frag)
|
|
245
|
+
|
|
246
|
+
section = nil
|
|
297
247
|
re = frag.split('').join('.*?')
|
|
298
248
|
sections.each do |sect|
|
|
299
249
|
next unless sect =~ /#{re}/i
|
|
@@ -308,79 +258,25 @@ module Doing
|
|
|
308
258
|
unless section || guessed
|
|
309
259
|
alt = guess_view(frag, guessed: true, suggest: true)
|
|
310
260
|
if alt
|
|
311
|
-
meant_view = yn("#{
|
|
261
|
+
meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
|
|
312
262
|
|
|
313
263
|
raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
|
|
314
264
|
|
|
315
265
|
end
|
|
316
266
|
|
|
317
|
-
res = yn("#{
|
|
267
|
+
res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
|
|
318
268
|
|
|
319
269
|
if res
|
|
320
|
-
add_section(frag.cap_first)
|
|
270
|
+
@content.add_section(frag.cap_first, log: true)
|
|
321
271
|
write(@doing_file)
|
|
322
272
|
return frag.cap_first
|
|
323
273
|
end
|
|
324
274
|
|
|
325
|
-
raise InvalidSection.new("unknown section #{frag.
|
|
275
|
+
raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
|
|
326
276
|
end
|
|
327
277
|
section ? section.cap_first : guessed
|
|
328
278
|
end
|
|
329
279
|
|
|
330
|
-
##
|
|
331
|
-
## Ask a yes or no question in the terminal
|
|
332
|
-
##
|
|
333
|
-
## @param question [String] The question
|
|
334
|
-
## to ask
|
|
335
|
-
## @param default_response (Bool) default
|
|
336
|
-
## response if no input
|
|
337
|
-
##
|
|
338
|
-
## @return (Bool) yes or no
|
|
339
|
-
##
|
|
340
|
-
def yn(question, default_response: false)
|
|
341
|
-
if default_response.is_a?(String)
|
|
342
|
-
default = default_response =~ /y/i ? true : false
|
|
343
|
-
else
|
|
344
|
-
default = default_response
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
# if global --default is set, answer default
|
|
348
|
-
return default if @default_option
|
|
349
|
-
|
|
350
|
-
# if this isn't an interactive shell, answer default
|
|
351
|
-
return default unless $stdout.isatty
|
|
352
|
-
|
|
353
|
-
# clear the buffer
|
|
354
|
-
if ARGV&.length
|
|
355
|
-
ARGV.length.times do
|
|
356
|
-
ARGV.shift
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
system 'stty cbreak'
|
|
360
|
-
|
|
361
|
-
cw = Color.white
|
|
362
|
-
cbw = Color.boldwhite
|
|
363
|
-
cbg = Color.boldgreen
|
|
364
|
-
cd = Color.default
|
|
365
|
-
|
|
366
|
-
options = unless default.nil?
|
|
367
|
-
"#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
|
|
368
|
-
else
|
|
369
|
-
"#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
|
|
370
|
-
end
|
|
371
|
-
$stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
|
|
372
|
-
res = $stdin.sysread 1
|
|
373
|
-
puts
|
|
374
|
-
system 'stty cooked'
|
|
375
|
-
|
|
376
|
-
res.chomp!
|
|
377
|
-
res.downcase!
|
|
378
|
-
|
|
379
|
-
return default if res.empty?
|
|
380
|
-
|
|
381
|
-
res =~ /y/i ? true : false
|
|
382
|
-
end
|
|
383
|
-
|
|
384
280
|
##
|
|
385
281
|
## Attempt to match a string with an existing view
|
|
386
282
|
##
|
|
@@ -400,11 +296,14 @@ module Doing
|
|
|
400
296
|
end
|
|
401
297
|
unless view || guessed
|
|
402
298
|
alt = guess_section(frag, guessed: true, suggest: true)
|
|
403
|
-
|
|
299
|
+
|
|
300
|
+
raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
|
|
301
|
+
|
|
302
|
+
meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
|
|
404
303
|
|
|
405
304
|
raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
|
|
406
305
|
|
|
407
|
-
raise InvalidView.new(%(
|
|
306
|
+
raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
|
|
408
307
|
end
|
|
409
308
|
view
|
|
410
309
|
end
|
|
@@ -414,17 +313,22 @@ module Doing
|
|
|
414
313
|
##
|
|
415
314
|
## @param title [String] The entry title
|
|
416
315
|
## @param section [String] The section to add to
|
|
417
|
-
## @param opt [Hash] Additional Options
|
|
316
|
+
## @param opt [Hash] Additional Options
|
|
317
|
+
##
|
|
318
|
+
## @option opt :date [Date] item start date
|
|
319
|
+
## @option opt :note [Array] item note (will be converted if value is String)
|
|
320
|
+
## @option opt :back [Date] backdate
|
|
321
|
+
## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
|
|
418
322
|
##
|
|
419
323
|
def add_item(title, section = nil, opt = {})
|
|
420
324
|
section ||= @config['current_section']
|
|
421
|
-
add_section(section
|
|
325
|
+
@content.add_section(section, log: false)
|
|
326
|
+
opt[:back] ||= opt[:date] ? opt[:date] : Time.now
|
|
422
327
|
opt[:date] ||= Time.now
|
|
423
|
-
|
|
424
|
-
opt[:back] ||= Time.now
|
|
328
|
+
note = Note.new
|
|
425
329
|
opt[:timed] ||= false
|
|
426
330
|
|
|
427
|
-
|
|
331
|
+
note.add(opt[:note]) if opt[:note]
|
|
428
332
|
|
|
429
333
|
title = [title.strip.cap_first]
|
|
430
334
|
title = title.join(' ')
|
|
@@ -434,10 +338,11 @@ module Doing
|
|
|
434
338
|
title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
|
|
435
339
|
end
|
|
436
340
|
|
|
437
|
-
title.
|
|
341
|
+
title.compress!
|
|
438
342
|
entry = Item.new(opt[:back], title.strip, section)
|
|
439
|
-
entry.note =
|
|
440
|
-
|
|
343
|
+
entry.note = note
|
|
344
|
+
|
|
345
|
+
items = @content.dup
|
|
441
346
|
if opt[:timed]
|
|
442
347
|
items.reverse!
|
|
443
348
|
items.each_with_index do |i, x|
|
|
@@ -446,10 +351,9 @@ module Doing
|
|
|
446
351
|
items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
|
|
447
352
|
break
|
|
448
353
|
end
|
|
449
|
-
items.reverse!
|
|
450
354
|
end
|
|
451
355
|
|
|
452
|
-
|
|
356
|
+
@content.push(entry)
|
|
453
357
|
# logger.count(:added, level: :debug)
|
|
454
358
|
logger.info('New entry:', %(added "#{entry.title}" to #{section}))
|
|
455
359
|
end
|
|
@@ -460,16 +364,10 @@ module Doing
|
|
|
460
364
|
## @param items [Array] The items to deduplicate
|
|
461
365
|
## @param no_overlap [Boolean] Remove items with overlapping time spans
|
|
462
366
|
##
|
|
463
|
-
def dedup(items, no_overlap
|
|
464
|
-
|
|
465
|
-
combined = []
|
|
466
|
-
@content.each do |_k, v|
|
|
467
|
-
combined += v[:items]
|
|
468
|
-
end
|
|
469
|
-
|
|
367
|
+
def dedup(items, no_overlap: false)
|
|
470
368
|
items.delete_if do |item|
|
|
471
369
|
duped = false
|
|
472
|
-
|
|
370
|
+
@content.each do |comp|
|
|
473
371
|
duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
|
|
474
372
|
break if duped
|
|
475
373
|
end
|
|
@@ -519,17 +417,32 @@ module Doing
|
|
|
519
417
|
"#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
|
|
520
418
|
end
|
|
521
419
|
|
|
420
|
+
# Reset start date to current time, optionally remove
|
|
421
|
+
# done tag (resume)
|
|
422
|
+
#
|
|
423
|
+
# @param item [Item] the item to reset/resume
|
|
424
|
+
# @param resume [Boolean] removing @done tag if true
|
|
425
|
+
#
|
|
522
426
|
def reset_item(item, resume: false)
|
|
523
427
|
item.date = Time.now
|
|
524
|
-
if resume
|
|
525
|
-
item.tag('done', remove: true)
|
|
526
|
-
end
|
|
428
|
+
item.tag('done', remove: true) if resume
|
|
527
429
|
logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
|
|
528
430
|
item
|
|
529
431
|
end
|
|
530
432
|
|
|
433
|
+
# Duplicate an item and add it as a new item
|
|
434
|
+
#
|
|
435
|
+
# @param item [Item] the item to duplicate
|
|
436
|
+
# @param opt [Hash] additional options
|
|
437
|
+
#
|
|
438
|
+
# @option opt :editor [Boolean] open new item in editor
|
|
439
|
+
# @option opt :date [String] set start date
|
|
440
|
+
# @option opt :in [String] add new item to section :in
|
|
441
|
+
# @option opt :note [Note] add note to new item
|
|
442
|
+
#
|
|
443
|
+
# @return nothing
|
|
444
|
+
#
|
|
531
445
|
def repeat_item(item, opt = {})
|
|
532
|
-
original = item.dup
|
|
533
446
|
if item.should_finish?
|
|
534
447
|
if item.should_time?
|
|
535
448
|
item.title.tag!('done', value: Time.now.strftime('%F %R'))
|
|
@@ -546,10 +459,13 @@ module Doing
|
|
|
546
459
|
note = opt[:note] || Note.new
|
|
547
460
|
|
|
548
461
|
if opt[:editor]
|
|
549
|
-
|
|
550
|
-
to_edit
|
|
462
|
+
start = opt[:date] ? opt[:date] : Time.now
|
|
463
|
+
to_edit = "#{start.strftime('%F %R')} | #{title}"
|
|
464
|
+
to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
|
|
551
465
|
new_item = fork_editor(to_edit)
|
|
552
|
-
title, note = format_input(new_item)
|
|
466
|
+
date, title, note = format_input(new_item)
|
|
467
|
+
|
|
468
|
+
opt[:date] = date unless date.nil?
|
|
553
469
|
|
|
554
470
|
if title.nil? || title.empty?
|
|
555
471
|
logger.warn('Skipped:', 'No content provided')
|
|
@@ -557,9 +473,8 @@ module Doing
|
|
|
557
473
|
end
|
|
558
474
|
end
|
|
559
475
|
|
|
560
|
-
update_item(original, item)
|
|
476
|
+
# @content.update_item(original, item)
|
|
561
477
|
add_item(title, section, { note: note, back: opt[:date], timed: true })
|
|
562
|
-
write(@doing_file)
|
|
563
478
|
end
|
|
564
479
|
|
|
565
480
|
##
|
|
@@ -569,6 +484,7 @@ module Doing
|
|
|
569
484
|
##
|
|
570
485
|
def repeat_last(opt = {})
|
|
571
486
|
opt[:section] ||= 'all'
|
|
487
|
+
opt[:section] = guess_section(opt[:section])
|
|
572
488
|
opt[:note] ||= []
|
|
573
489
|
opt[:tag] ||= []
|
|
574
490
|
opt[:tag_bool] ||= :and
|
|
@@ -580,6 +496,7 @@ module Doing
|
|
|
580
496
|
end
|
|
581
497
|
|
|
582
498
|
repeat_item(last, opt)
|
|
499
|
+
write(@doing_file)
|
|
583
500
|
end
|
|
584
501
|
|
|
585
502
|
##
|
|
@@ -591,19 +508,19 @@ module Doing
|
|
|
591
508
|
opt[:tag_bool] ||= :and
|
|
592
509
|
opt[:section] ||= @config['current_section']
|
|
593
510
|
|
|
594
|
-
items = filter_items(
|
|
511
|
+
items = filter_items(Items.new, opt: opt)
|
|
595
512
|
|
|
596
513
|
logger.debug('Filtered:', "Parameters matched #{items.count} entries")
|
|
597
514
|
|
|
598
515
|
if opt[:interactive]
|
|
599
|
-
last_entry = choose_from_items(items,
|
|
516
|
+
last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
|
|
600
517
|
menu: true,
|
|
601
518
|
header: '',
|
|
602
519
|
prompt: 'Select an entry > ',
|
|
603
520
|
multiple: false,
|
|
604
521
|
sort: false,
|
|
605
522
|
show_if_single: true
|
|
606
|
-
|
|
523
|
+
)
|
|
607
524
|
else
|
|
608
525
|
last_entry = items.max_by { |item| item.date }
|
|
609
526
|
end
|
|
@@ -611,45 +528,6 @@ module Doing
|
|
|
611
528
|
last_entry
|
|
612
529
|
end
|
|
613
530
|
|
|
614
|
-
def fzf
|
|
615
|
-
fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
|
|
616
|
-
FileUtils.mkdir_p(fzf_dir) unless File.directory?(fzf_dir)
|
|
617
|
-
fzf_bin = File.join(fzf_dir, 'bin/fzf')
|
|
618
|
-
return fzf_bin if File.exist?(fzf_bin)
|
|
619
|
-
|
|
620
|
-
Doing.logger.log_now(:warn, 'Compiling and installing FZF -- this will only happen once')
|
|
621
|
-
Doing.logger.log_now(:warn, 'fzf is copyright Junegunn Choi <https://github.com/junegunn/fzf/blob/master/LICENSE>')
|
|
622
|
-
|
|
623
|
-
res = system("#{fzf_dir}/install --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish")
|
|
624
|
-
unless res
|
|
625
|
-
Doing.logger.log_now(:warn, 'Error installing, trying again as root')
|
|
626
|
-
system("sudo #{fzf_dir}/install --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish")
|
|
627
|
-
end
|
|
628
|
-
raise RuntimeError.new('Error installing fzf, please report at https://github.com/ttscoff/doing/issues') unless File.exist?(fzf_bin)
|
|
629
|
-
|
|
630
|
-
fzf_bin
|
|
631
|
-
end
|
|
632
|
-
|
|
633
|
-
##
|
|
634
|
-
## Generate a menu of options and allow user selection
|
|
635
|
-
##
|
|
636
|
-
## @return [String] The selected option
|
|
637
|
-
##
|
|
638
|
-
def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
|
|
639
|
-
return nil unless $stdout.isatty
|
|
640
|
-
|
|
641
|
-
# fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
|
|
642
|
-
fzf_args << %(--prompt "#{prompt}")
|
|
643
|
-
fzf_args << '--multi' if multiple
|
|
644
|
-
header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
|
|
645
|
-
fzf_args << %(--header "#{header}")
|
|
646
|
-
options.sort! if sorted
|
|
647
|
-
res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
|
|
648
|
-
return false if res.strip.size.zero?
|
|
649
|
-
|
|
650
|
-
res
|
|
651
|
-
end
|
|
652
|
-
|
|
653
531
|
def all_tags(items, opt: {})
|
|
654
532
|
all_tags = []
|
|
655
533
|
items.each { |item| all_tags.concat(item.tags).uniq! }
|
|
@@ -688,8 +566,8 @@ module Doing
|
|
|
688
566
|
end
|
|
689
567
|
# fzf_args << '-e' if opt[:exact]
|
|
690
568
|
# puts fzf_args.join(' ')
|
|
691
|
-
res = `echo #{Shellwords.escape(scannable)}|#{fzf} #{fzf_args.join(' ')}`
|
|
692
|
-
selected =
|
|
569
|
+
res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
|
|
570
|
+
selected = Items.new
|
|
693
571
|
res.split(/\n/).each do |item|
|
|
694
572
|
idx = item.match(/\|(\d+)$/)[1].to_i
|
|
695
573
|
selected.push(items[idx])
|
|
@@ -717,15 +595,54 @@ module Doing
|
|
|
717
595
|
## @option opt [Number] :count (Number to return)
|
|
718
596
|
## @option opt [String] :age ('old' or 'new')
|
|
719
597
|
##
|
|
720
|
-
def filter_items(items =
|
|
598
|
+
def filter_items(items = Items.new, opt: {})
|
|
599
|
+
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
|
600
|
+
|
|
721
601
|
if items.nil? || items.empty?
|
|
722
602
|
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
|
603
|
+
items = section =~ /^all$/i ? @content.dup : @content.in_section(section)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
opt[:time_filter] = [nil, nil]
|
|
607
|
+
if opt[:from] && !opt[:date_filter]
|
|
608
|
+
date_string = opt[:from]
|
|
609
|
+
case date_string
|
|
610
|
+
when / (to|through|thru|(un)?til|-+) /
|
|
611
|
+
dates = date_string.split(/ (?:to|through|thru|(?:un)?til|-+) /)
|
|
612
|
+
if dates[0].strip =~ time_rx && dates[-1].strip =~ time_rx
|
|
613
|
+
time_start = dates[0].strip
|
|
614
|
+
time_end = dates[-1].strip
|
|
615
|
+
else
|
|
616
|
+
start = dates[0].chronify(guess: :begin)
|
|
617
|
+
finish = dates[-1].chronify(guess: :end)
|
|
618
|
+
end
|
|
619
|
+
when time_rx
|
|
620
|
+
time_start = date_string
|
|
621
|
+
time_end = nil
|
|
622
|
+
else
|
|
623
|
+
start = date_string.chronify(guess: :begin)
|
|
624
|
+
finish = false
|
|
625
|
+
end
|
|
723
626
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
627
|
+
if time_start
|
|
628
|
+
opt[:time_filter] = [time_start, time_end]
|
|
629
|
+
Doing.logger.debug('Parser:', "--from string interpreted as time span, from #{time_start ? time_start : '12am'} to #{time_end ? time_end : '11:59pm'}")
|
|
630
|
+
else
|
|
631
|
+
raise InvalidTimeExpression, 'Unrecognized date string' unless start
|
|
632
|
+
|
|
633
|
+
opt[:date_filter] = [start, finish]
|
|
634
|
+
Doing.logger.debug('Parser:', "--from string interpreted as #{start.strftime('%F %R')} -- #{finish ? finish.strftime('%F %R') : 'now'}")
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
if opt[:before] =~ time_rx
|
|
639
|
+
opt[:time_filter][1] = opt[:before]
|
|
640
|
+
opt[:before] = nil
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
if opt[:after] =~ time_rx
|
|
644
|
+
opt[:time_filter][0] = opt[:after]
|
|
645
|
+
opt[:after] = nil
|
|
729
646
|
end
|
|
730
647
|
|
|
731
648
|
items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
|
|
@@ -769,6 +686,26 @@ module Doing
|
|
|
769
686
|
keep = opt[:not] ? !keep : keep
|
|
770
687
|
end
|
|
771
688
|
|
|
689
|
+
if keep && opt[:time_filter][0] || opt[:time_filter][1]
|
|
690
|
+
start_string = if opt[:time_filter][0].nil?
|
|
691
|
+
"#{item.date.strftime('%Y-%m-%d')} 12am"
|
|
692
|
+
else
|
|
693
|
+
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
|
|
694
|
+
end
|
|
695
|
+
start_time = start_string.chronify(guess: :begin)
|
|
696
|
+
|
|
697
|
+
end_string = if opt[:time_filter][1].nil?
|
|
698
|
+
"#{item.date.next_day.strftime('%Y-%m-%d')} 12am"
|
|
699
|
+
else
|
|
700
|
+
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
|
|
701
|
+
end
|
|
702
|
+
end_time = end_string.chronify(guess: :end)
|
|
703
|
+
|
|
704
|
+
in_time_range = item.date >= start_time && item.date <= end_time
|
|
705
|
+
keep = false unless in_time_range
|
|
706
|
+
keep = opt[:not] ? !keep : keep
|
|
707
|
+
end
|
|
708
|
+
|
|
772
709
|
keep = false if keep && opt[:only_timed] && !item.interval
|
|
773
710
|
|
|
774
711
|
if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
|
|
@@ -778,14 +715,22 @@ module Doing
|
|
|
778
715
|
|
|
779
716
|
if keep && opt[:before]
|
|
780
717
|
time_string = opt[:before]
|
|
781
|
-
|
|
718
|
+
if time_string =~ time_rx
|
|
719
|
+
cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :begin)
|
|
720
|
+
else
|
|
721
|
+
cutoff = time_string.chronify(guess: :begin)
|
|
722
|
+
end
|
|
782
723
|
keep = cutoff && item.date <= cutoff
|
|
783
724
|
keep = opt[:not] ? !keep : keep
|
|
784
725
|
end
|
|
785
726
|
|
|
786
727
|
if keep && opt[:after]
|
|
787
728
|
time_string = opt[:after]
|
|
788
|
-
|
|
729
|
+
if time_string =~ time_rx
|
|
730
|
+
cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :end)
|
|
731
|
+
else
|
|
732
|
+
cutoff = time_string.chronify(guess: :end)
|
|
733
|
+
end
|
|
789
734
|
keep = cutoff && item.date >= cutoff
|
|
790
735
|
keep = opt[:not] ? !keep : keep
|
|
791
736
|
end
|
|
@@ -800,14 +745,17 @@ module Doing
|
|
|
800
745
|
|
|
801
746
|
keep
|
|
802
747
|
end
|
|
803
|
-
count = opt[:count]
|
|
748
|
+
count = opt[:count]&.positive? ? opt[:count] : filtered_items.length
|
|
749
|
+
|
|
750
|
+
output = Items.new
|
|
804
751
|
|
|
805
752
|
if opt[:age] =~ /^o/i
|
|
806
|
-
filtered_items.slice(0, count).reverse
|
|
753
|
+
output.concat(filtered_items.slice(0, count).reverse)
|
|
807
754
|
else
|
|
808
|
-
filtered_items.reverse.slice(0, count)
|
|
755
|
+
output.concat(filtered_items.reverse.slice(0, count))
|
|
809
756
|
end
|
|
810
757
|
|
|
758
|
+
output
|
|
811
759
|
end
|
|
812
760
|
|
|
813
761
|
##
|
|
@@ -818,7 +766,7 @@ module Doing
|
|
|
818
766
|
## Options hash is shared with #filter_items and #act_on
|
|
819
767
|
##
|
|
820
768
|
def interactive(opt = {})
|
|
821
|
-
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
|
769
|
+
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
|
822
770
|
|
|
823
771
|
search = nil
|
|
824
772
|
|
|
@@ -832,94 +780,18 @@ module Doing
|
|
|
832
780
|
opt[:query] = "!#{opt[:query]}" if opt[:not]
|
|
833
781
|
opt[:multiple] = true
|
|
834
782
|
opt[:show_if_single] = true
|
|
835
|
-
|
|
783
|
+
filter_options = %i[after before case date_filter from fuzzy not search section].each_with_object({}) {
|
|
784
|
+
|k, hsh| hsh[k] = opt[k]
|
|
785
|
+
}
|
|
786
|
+
items = filter_items(Items.new, opt: filter_options)
|
|
836
787
|
|
|
837
|
-
selection = choose_from_items(items,
|
|
788
|
+
selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt)
|
|
838
789
|
|
|
839
790
|
raise NoResults, 'no items selected' if selection.nil? || selection.empty?
|
|
840
791
|
|
|
841
792
|
act_on(selection, opt)
|
|
842
793
|
end
|
|
843
794
|
|
|
844
|
-
##
|
|
845
|
-
## Create an interactive menu to select from a set of Items
|
|
846
|
-
##
|
|
847
|
-
## @param items [Array] list of items
|
|
848
|
-
## @param opt [Hash] options
|
|
849
|
-
## @param include_section [Boolean] include section
|
|
850
|
-
##
|
|
851
|
-
## @option opt [String] :header
|
|
852
|
-
## @option opt [String] :prompt
|
|
853
|
-
## @option opt [String] :query
|
|
854
|
-
## @option opt [Boolean] :show_if_single
|
|
855
|
-
## @option opt [Boolean] :menu
|
|
856
|
-
## @option opt [Boolean] :sort
|
|
857
|
-
## @option opt [Boolean] :multiple
|
|
858
|
-
## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
|
|
859
|
-
##
|
|
860
|
-
def choose_from_items(items, opt = {}, include_section: false)
|
|
861
|
-
return items unless $stdout.isatty
|
|
862
|
-
|
|
863
|
-
return nil unless items.count.positive?
|
|
864
|
-
|
|
865
|
-
opt[:case] ||= :smart
|
|
866
|
-
opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
|
|
867
|
-
opt[:prompt] ||= "Select entries to act on > "
|
|
868
|
-
|
|
869
|
-
pad = items.length.to_s.length
|
|
870
|
-
options = items.map.with_index do |item, i|
|
|
871
|
-
out = [
|
|
872
|
-
format("%#{pad}d", i),
|
|
873
|
-
') ',
|
|
874
|
-
format('%13s', item.date.relative_date),
|
|
875
|
-
' | ',
|
|
876
|
-
item.title
|
|
877
|
-
]
|
|
878
|
-
if include_section
|
|
879
|
-
out.concat([
|
|
880
|
-
' (',
|
|
881
|
-
item.section,
|
|
882
|
-
') '
|
|
883
|
-
])
|
|
884
|
-
end
|
|
885
|
-
out.join('')
|
|
886
|
-
end
|
|
887
|
-
|
|
888
|
-
fzf_args = [
|
|
889
|
-
%(--header="#{opt[:header]}"),
|
|
890
|
-
%(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
|
|
891
|
-
opt[:multiple] ? '--multi' : '--no-multi',
|
|
892
|
-
'-0',
|
|
893
|
-
'--bind ctrl-a:select-all',
|
|
894
|
-
%(-q "#{opt[:query]}"),
|
|
895
|
-
'--info=inline'
|
|
896
|
-
]
|
|
897
|
-
fzf_args.push('-1') unless opt[:show_if_single]
|
|
898
|
-
fzf_args << case opt[:case].normalize_case
|
|
899
|
-
when :sensitive
|
|
900
|
-
'+i'
|
|
901
|
-
when :ignore
|
|
902
|
-
'-i'
|
|
903
|
-
end
|
|
904
|
-
fzf_args << '-e' if opt[:exact]
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
unless opt[:menu]
|
|
908
|
-
raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty?
|
|
909
|
-
|
|
910
|
-
fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
|
|
911
|
-
end
|
|
912
|
-
|
|
913
|
-
res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
|
|
914
|
-
selected = []
|
|
915
|
-
res.split(/\n/).each do |item|
|
|
916
|
-
idx = item.match(/^ *(\d+)\)/)[1].to_i
|
|
917
|
-
selected.push(items[idx])
|
|
918
|
-
end
|
|
919
|
-
|
|
920
|
-
opt[:multiple] ? selected : selected[0]
|
|
921
|
-
end
|
|
922
|
-
|
|
923
795
|
##
|
|
924
796
|
## Perform actions on a set of entries. If
|
|
925
797
|
## no valid action is included in the opt
|
|
@@ -969,11 +841,11 @@ module Doing
|
|
|
969
841
|
|
|
970
842
|
actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
|
|
971
843
|
|
|
972
|
-
choice = choose_from(actions,
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
844
|
+
choice = Prompt.choose_from(actions,
|
|
845
|
+
prompt: 'What do you want to do with the selected items? > ',
|
|
846
|
+
multiple: true,
|
|
847
|
+
sorted: false,
|
|
848
|
+
fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
|
|
977
849
|
return unless choice
|
|
978
850
|
|
|
979
851
|
to_do = choice.strip.split(/\n/)
|
|
@@ -987,7 +859,7 @@ module Doing
|
|
|
987
859
|
type = action =~ /^add/ ? 'add' : 'remove'
|
|
988
860
|
raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
|
|
989
861
|
|
|
990
|
-
print "#{
|
|
862
|
+
print "#{yellow("Tag to #{type}: ")}#{reset}"
|
|
991
863
|
tag = $stdin.gets
|
|
992
864
|
next if tag =~ /^ *$/
|
|
993
865
|
|
|
@@ -995,17 +867,22 @@ module Doing
|
|
|
995
867
|
opt[:remove] = true if type == 'remove'
|
|
996
868
|
when /output formatted/
|
|
997
869
|
plugins = Plugins.available_plugins(type: :export).sort
|
|
998
|
-
output_format = choose_from(plugins,
|
|
999
|
-
|
|
1000
|
-
|
|
870
|
+
output_format = Prompt.choose_from(plugins,
|
|
871
|
+
prompt: 'Which output format? > ',
|
|
872
|
+
fzf_args: [
|
|
873
|
+
"--height=#{plugins.count + 3}",
|
|
874
|
+
'--tac',
|
|
875
|
+
'--no-sort',
|
|
876
|
+
'--info=hidden'
|
|
877
|
+
])
|
|
1001
878
|
next if tag =~ /^ *$/
|
|
1002
879
|
|
|
1003
880
|
raise UserCancelled unless output_format
|
|
1004
881
|
|
|
1005
882
|
opt[:output] = output_format.strip
|
|
1006
|
-
res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
|
|
883
|
+
res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
|
|
1007
884
|
if res
|
|
1008
|
-
print "#{
|
|
885
|
+
print "#{yellow('File path/name: ')}#{reset}"
|
|
1009
886
|
filename = $stdin.gets.strip
|
|
1010
887
|
next if filename.empty?
|
|
1011
888
|
|
|
@@ -1031,29 +908,28 @@ module Doing
|
|
|
1031
908
|
end
|
|
1032
909
|
|
|
1033
910
|
if opt[:resume] || opt[:reset]
|
|
1034
|
-
if items.count > 1
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
update_item(item, reset_item(item, resume: res))
|
|
1047
|
-
end
|
|
1048
|
-
write(@doing_file)
|
|
911
|
+
raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
|
|
912
|
+
|
|
913
|
+
item = items[0]
|
|
914
|
+
if opt[:resume] && !opt[:reset]
|
|
915
|
+
repeat_item(item, { editor: opt[:editor] })
|
|
916
|
+
elsif opt[:reset]
|
|
917
|
+
res = if item.tags?('done', :and) && !opt[:resume]
|
|
918
|
+
opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
|
|
919
|
+
else
|
|
920
|
+
opt[:resume]
|
|
921
|
+
end
|
|
922
|
+
@content.update_item(item, reset_item(item, resume: res))
|
|
1049
923
|
end
|
|
924
|
+
write(@doing_file)
|
|
925
|
+
|
|
1050
926
|
return
|
|
1051
927
|
end
|
|
1052
928
|
|
|
1053
929
|
if opt[:delete]
|
|
1054
|
-
res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
|
|
930
|
+
res = opt[:force] ? true : Prompt.yn("Delete #{items.size} items?", default_response: 'y')
|
|
1055
931
|
if res
|
|
1056
|
-
items.each { |
|
|
932
|
+
items.each { |i| @content.delete_item(i, single: items.count == 1) }
|
|
1057
933
|
write(@doing_file)
|
|
1058
934
|
end
|
|
1059
935
|
return
|
|
@@ -1061,31 +937,31 @@ module Doing
|
|
|
1061
937
|
|
|
1062
938
|
if opt[:flag]
|
|
1063
939
|
tag = @config['marker_tag'] || 'flagged'
|
|
1064
|
-
items.map! do |
|
|
1065
|
-
|
|
940
|
+
items.map! do |i|
|
|
941
|
+
i.tag(tag, date: false, remove: opt[:remove], single: single)
|
|
1066
942
|
end
|
|
1067
943
|
end
|
|
1068
944
|
|
|
1069
945
|
if opt[:finish] || opt[:cancel]
|
|
1070
946
|
tag = 'done'
|
|
1071
|
-
items.map! do |
|
|
1072
|
-
if
|
|
1073
|
-
should_date = !opt[:cancel] &&
|
|
1074
|
-
|
|
947
|
+
items.map! do |i|
|
|
948
|
+
if i.should_finish?
|
|
949
|
+
should_date = !opt[:cancel] && i.should_time?
|
|
950
|
+
i.tag(tag, date: should_date, remove: opt[:remove], single: single)
|
|
1075
951
|
end
|
|
1076
952
|
end
|
|
1077
953
|
end
|
|
1078
954
|
|
|
1079
955
|
if opt[:tag]
|
|
1080
956
|
tag = opt[:tag]
|
|
1081
|
-
items.map! do |
|
|
1082
|
-
|
|
957
|
+
items.map! do |i|
|
|
958
|
+
i.tag(tag, date: false, remove: opt[:remove], single: single)
|
|
1083
959
|
end
|
|
1084
960
|
end
|
|
1085
961
|
|
|
1086
962
|
if opt[:archive] || opt[:move]
|
|
1087
963
|
section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
|
|
1088
|
-
items.map! {|
|
|
964
|
+
items.map! { |i| i.move_to(section, label: true) }
|
|
1089
965
|
end
|
|
1090
966
|
|
|
1091
967
|
write(@doing_file)
|
|
@@ -1094,111 +970,88 @@ module Doing
|
|
|
1094
970
|
|
|
1095
971
|
editable_items = []
|
|
1096
972
|
|
|
1097
|
-
items.each do |
|
|
1098
|
-
editable = "#{
|
|
1099
|
-
old_note =
|
|
973
|
+
items.each do |i|
|
|
974
|
+
editable = "#{i.date.strftime('%F %R')} | #{i.title}"
|
|
975
|
+
old_note = i.note ? i.note.strip_lines.join("\n") : nil
|
|
1100
976
|
editable += "\n#{old_note}" unless old_note.nil?
|
|
1101
977
|
editable_items << editable
|
|
1102
978
|
end
|
|
1103
979
|
divider = "\n-----------\n"
|
|
1104
|
-
|
|
980
|
+
notice =<<~EONOTICE
|
|
981
|
+
# - You may delete entries, but leave all divider lines (---) in place.
|
|
982
|
+
# - Start and @done dates replaced with a time string (yesterday 3pm) will
|
|
983
|
+
# be parsed automatically. Do not delete the pipe (|) between start date
|
|
984
|
+
# and entry title.
|
|
985
|
+
EONOTICE
|
|
986
|
+
input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}"
|
|
1105
987
|
|
|
1106
988
|
new_items = fork_editor(input).split(/#{divider}/)
|
|
1107
989
|
|
|
1108
990
|
new_items.each_with_index do |new_item, i|
|
|
1109
|
-
|
|
1110
991
|
input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
|
|
1111
|
-
|
|
992
|
+
first_line = input_lines[0]&.strip
|
|
1112
993
|
|
|
1113
|
-
if
|
|
1114
|
-
delete_item(items[i], single: new_items.count == 1)
|
|
994
|
+
if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
|
|
995
|
+
@content.delete_item(items[i], single: new_items.count == 1)
|
|
996
|
+
Doing.logger.count(:deleted)
|
|
1115
997
|
else
|
|
1116
|
-
note =
|
|
998
|
+
date, title, note = format_input(new_item)
|
|
1117
999
|
|
|
1118
1000
|
note.map!(&:strip)
|
|
1119
1001
|
note.delete_if(&:ignore?)
|
|
1120
|
-
|
|
1121
|
-
date = title.match(/^([\d\-: ]+) \| /)[1]
|
|
1122
|
-
title.sub!(/^([\d\-: ]+) \| /, '')
|
|
1123
|
-
|
|
1124
1002
|
item = items[i]
|
|
1003
|
+
old_item = item.dup
|
|
1004
|
+
item.date = date || items[i].date
|
|
1125
1005
|
item.title = title
|
|
1126
1006
|
item.note = note
|
|
1127
|
-
item.
|
|
1007
|
+
if (item.equal?(old_item))
|
|
1008
|
+
Doing.logger.count(:skipped, level: :debug)
|
|
1009
|
+
else
|
|
1010
|
+
Doing.logger.count(:updated)
|
|
1011
|
+
end
|
|
1128
1012
|
end
|
|
1129
1013
|
end
|
|
1130
1014
|
|
|
1131
1015
|
write(@doing_file)
|
|
1132
1016
|
end
|
|
1133
1017
|
|
|
1134
|
-
|
|
1135
|
-
items.map! do |item|
|
|
1136
|
-
item.title = "#{item.title} @project(#{item.section})"
|
|
1137
|
-
item
|
|
1138
|
-
end
|
|
1139
|
-
|
|
1140
|
-
@content = { 'Export' => { :original => 'Export:', :items => items } }
|
|
1141
|
-
options = { section: 'Export' }
|
|
1018
|
+
return unless opt[:output]
|
|
1142
1019
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
options[:template] = '- %date | %title%note'
|
|
1147
|
-
else
|
|
1148
|
-
options[:output] = opt[:output]
|
|
1149
|
-
options[:template] = opt[:template] || nil
|
|
1150
|
-
end
|
|
1151
|
-
|
|
1152
|
-
output = list_section(options)
|
|
1153
|
-
|
|
1154
|
-
if opt[:save_to]
|
|
1155
|
-
file = File.expand_path(opt[:save_to])
|
|
1156
|
-
if File.exist?(file)
|
|
1157
|
-
# Create a backup copy for the undo command
|
|
1158
|
-
FileUtils.cp(file, "#{file}~")
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
|
-
File.open(file, 'w+') do |f|
|
|
1162
|
-
f.puts output
|
|
1163
|
-
end
|
|
1164
|
-
|
|
1165
|
-
logger.warn('File written:', file)
|
|
1166
|
-
else
|
|
1167
|
-
Doing::Pager.page output
|
|
1168
|
-
end
|
|
1020
|
+
items.map! do |i|
|
|
1021
|
+
i.title = "#{i.title} @project(#{i.section})"
|
|
1022
|
+
i
|
|
1169
1023
|
end
|
|
1170
|
-
end
|
|
1171
1024
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
## @param tags [String] The tag to apply
|
|
1177
|
-
## @param remove [Boolean] remove tags?
|
|
1178
|
-
## @param date [Boolean] Include timestamp?
|
|
1179
|
-
## @param single [Boolean] Log as a single change?
|
|
1180
|
-
##
|
|
1181
|
-
## @return [Item] updated item
|
|
1182
|
-
##
|
|
1183
|
-
def tag_item(item, tags, remove: false, date: false, single: false)
|
|
1184
|
-
added = []
|
|
1185
|
-
removed = []
|
|
1025
|
+
@content = Items.new
|
|
1026
|
+
@content.concat(items)
|
|
1027
|
+
@content.add_section(Section.new('Export'), log: false)
|
|
1028
|
+
options = { section: 'Export' }
|
|
1186
1029
|
|
|
1187
|
-
|
|
1030
|
+
if opt[:output] =~ /doing/
|
|
1031
|
+
options[:output] = 'template'
|
|
1032
|
+
options[:template] = '- %date | %title%note'
|
|
1033
|
+
else
|
|
1034
|
+
options[:output] = opt[:output]
|
|
1035
|
+
options[:template] = opt[:template] || nil
|
|
1036
|
+
end
|
|
1188
1037
|
|
|
1189
|
-
|
|
1038
|
+
output = list_section(options)
|
|
1190
1039
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
if
|
|
1194
|
-
|
|
1195
|
-
|
|
1040
|
+
if opt[:save_to]
|
|
1041
|
+
file = File.expand_path(opt[:save_to])
|
|
1042
|
+
if File.exist?(file)
|
|
1043
|
+
# Create a backup copy for the undo command
|
|
1044
|
+
FileUtils.cp(file, "#{file}~")
|
|
1196
1045
|
end
|
|
1197
|
-
end
|
|
1198
1046
|
|
|
1199
|
-
|
|
1047
|
+
File.open(file, 'w+') do |f|
|
|
1048
|
+
f.puts output
|
|
1049
|
+
end
|
|
1200
1050
|
|
|
1201
|
-
|
|
1051
|
+
logger.warn('File written:', file)
|
|
1052
|
+
else
|
|
1053
|
+
Doing::Pager.page output
|
|
1054
|
+
end
|
|
1202
1055
|
end
|
|
1203
1056
|
|
|
1204
1057
|
##
|
|
@@ -1222,17 +1075,15 @@ module Doing
|
|
|
1222
1075
|
opt[:unfinished] ||= false
|
|
1223
1076
|
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
|
1224
1077
|
|
|
1225
|
-
items = filter_items(
|
|
1078
|
+
items = filter_items(Items.new, opt: opt)
|
|
1226
1079
|
|
|
1227
1080
|
if opt[:interactive]
|
|
1228
|
-
items = choose_from_items(items,
|
|
1229
|
-
menu: true,
|
|
1081
|
+
items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
|
|
1230
1082
|
header: '',
|
|
1231
1083
|
prompt: 'Select entries to tag > ',
|
|
1232
1084
|
multiple: true,
|
|
1233
1085
|
sort: true,
|
|
1234
|
-
show_if_single: true
|
|
1235
|
-
}, include_section: opt[:section] =~ /^all$/i)
|
|
1086
|
+
show_if_single: true)
|
|
1236
1087
|
|
|
1237
1088
|
raise NoResults, 'no items selected' if items.empty?
|
|
1238
1089
|
|
|
@@ -1313,12 +1164,12 @@ module Doing
|
|
|
1313
1164
|
end
|
|
1314
1165
|
end
|
|
1315
1166
|
|
|
1316
|
-
log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
|
|
1167
|
+
logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
|
|
1317
1168
|
|
|
1318
1169
|
item.note.add(opt[:note]) if opt[:note]
|
|
1319
1170
|
|
|
1320
1171
|
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
|
1321
|
-
|
|
1172
|
+
item.move_to('Archive', label: true)
|
|
1322
1173
|
elsif opt[:archive] && opt[:count].zero?
|
|
1323
1174
|
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
|
1324
1175
|
end
|
|
@@ -1327,29 +1178,6 @@ module Doing
|
|
|
1327
1178
|
write(@doing_file)
|
|
1328
1179
|
end
|
|
1329
1180
|
|
|
1330
|
-
##
|
|
1331
|
-
## Move item from current section to
|
|
1332
|
-
## destination section
|
|
1333
|
-
##
|
|
1334
|
-
## @param item [Item] The item to move
|
|
1335
|
-
## @param section [String] The destination section
|
|
1336
|
-
##
|
|
1337
|
-
## @return [Item] Updated item
|
|
1338
|
-
##
|
|
1339
|
-
def move_item(item, section, label: true)
|
|
1340
|
-
from = item.section
|
|
1341
|
-
new_item = @content[item.section][:items].delete(item)
|
|
1342
|
-
new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
|
|
1343
|
-
new_item.section = section
|
|
1344
|
-
|
|
1345
|
-
@content[section][:items].concat([new_item])
|
|
1346
|
-
|
|
1347
|
-
logger.count(section == 'Archive' ? :archived : :moved)
|
|
1348
|
-
logger.debug("#{section == 'Archive' ? 'Archived' : 'Moved'}:",
|
|
1349
|
-
"#{new_item.title.truncate(60)} from #{from} to #{section}")
|
|
1350
|
-
new_item
|
|
1351
|
-
end
|
|
1352
|
-
|
|
1353
1181
|
##
|
|
1354
1182
|
## Get next item in the index
|
|
1355
1183
|
##
|
|
@@ -1360,49 +1188,13 @@ module Doing
|
|
|
1360
1188
|
## @return [Item] the next chronological item in the index
|
|
1361
1189
|
##
|
|
1362
1190
|
def next_item(item, options = {})
|
|
1363
|
-
items = filter_items(
|
|
1191
|
+
items = filter_items(Items.new, opt: options)
|
|
1364
1192
|
|
|
1365
1193
|
idx = items.index(item)
|
|
1366
1194
|
|
|
1367
1195
|
idx.positive? ? items[idx - 1] : nil
|
|
1368
1196
|
end
|
|
1369
1197
|
|
|
1370
|
-
##
|
|
1371
|
-
## Delete an item from the index
|
|
1372
|
-
##
|
|
1373
|
-
## @param item The item
|
|
1374
|
-
##
|
|
1375
|
-
def delete_item(item, single: false)
|
|
1376
|
-
section = item.section
|
|
1377
|
-
|
|
1378
|
-
section_items = @content[section][:items]
|
|
1379
|
-
deleted = section_items.delete(item)
|
|
1380
|
-
logger.count(:deleted)
|
|
1381
|
-
logger.info('Entry deleted:', deleted.title) if single
|
|
1382
|
-
end
|
|
1383
|
-
|
|
1384
|
-
##
|
|
1385
|
-
## Update an item in the index with a modified item
|
|
1386
|
-
##
|
|
1387
|
-
## @param old_item The old item
|
|
1388
|
-
## @param new_item The new item
|
|
1389
|
-
##
|
|
1390
|
-
def update_item(old_item, new_item)
|
|
1391
|
-
section = old_item.section
|
|
1392
|
-
|
|
1393
|
-
section_items = @content[section][:items]
|
|
1394
|
-
s_idx = section_items.index { |item| item.equal?(old_item) }
|
|
1395
|
-
|
|
1396
|
-
raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx
|
|
1397
|
-
|
|
1398
|
-
return if section_items[s_idx].equal?(new_item)
|
|
1399
|
-
|
|
1400
|
-
section_items[s_idx] = new_item
|
|
1401
|
-
logger.count(:updated)
|
|
1402
|
-
logger.info('Entry updated:', section_items[s_idx].title.truncate(60))
|
|
1403
|
-
new_item
|
|
1404
|
-
end
|
|
1405
|
-
|
|
1406
1198
|
##
|
|
1407
1199
|
## Edit the last entry
|
|
1408
1200
|
##
|
|
@@ -1418,16 +1210,18 @@ module Doing
|
|
|
1418
1210
|
return
|
|
1419
1211
|
end
|
|
1420
1212
|
|
|
1421
|
-
content = [item.title.dup]
|
|
1422
|
-
content << item.note.
|
|
1213
|
+
content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
|
|
1214
|
+
content << item.note.strip_lines.join("\n") unless item.note.empty?
|
|
1423
1215
|
new_item = fork_editor(content.join("\n"))
|
|
1424
|
-
title, note = format_input(new_item)
|
|
1216
|
+
date, title, note = format_input(new_item)
|
|
1217
|
+
date ||= item.date
|
|
1425
1218
|
|
|
1426
1219
|
if title.nil? || title.empty?
|
|
1427
1220
|
logger.debug('Skipped:', 'No content provided')
|
|
1428
|
-
elsif title == item.title && note.equal?(item.note)
|
|
1221
|
+
elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
|
|
1429
1222
|
logger.debug('Skipped:', 'No change in content')
|
|
1430
1223
|
else
|
|
1224
|
+
item.date = date unless date.nil?
|
|
1431
1225
|
item.title = title
|
|
1432
1226
|
item.note.add(note, replace: true)
|
|
1433
1227
|
logger.info('Edited:', item.title)
|
|
@@ -1446,6 +1240,11 @@ module Doing
|
|
|
1446
1240
|
## @param target_tag [String] Tag to replace
|
|
1447
1241
|
## @param opt [Hash] Additional Options
|
|
1448
1242
|
##
|
|
1243
|
+
## @option opt :section [String] target section
|
|
1244
|
+
## @option opt :archive [Boolean] archive old item
|
|
1245
|
+
## @option opt :back [Date] backdate new item
|
|
1246
|
+
## @option opt :new_item [String] content to use for new item
|
|
1247
|
+
## @option opt :note [Array] note content for new item
|
|
1449
1248
|
def stop_start(target_tag, opt = {})
|
|
1450
1249
|
tag = target_tag.dup
|
|
1451
1250
|
opt[:section] ||= @config['current_section']
|
|
@@ -1460,7 +1259,9 @@ module Doing
|
|
|
1460
1259
|
|
|
1461
1260
|
found_items = 0
|
|
1462
1261
|
|
|
1463
|
-
@content
|
|
1262
|
+
@content.each_with_index do |item, i|
|
|
1263
|
+
next unless item.section == opt[:section] || opt[:section] =~ /all/i
|
|
1264
|
+
|
|
1464
1265
|
next unless item.title =~ /@#{tag}/
|
|
1465
1266
|
|
|
1466
1267
|
item.title.add_tags!([tag, 'done'], remove: true)
|
|
@@ -1470,7 +1271,7 @@ module Doing
|
|
|
1470
1271
|
|
|
1471
1272
|
if opt[:archive] && opt[:section] != 'Archive'
|
|
1472
1273
|
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
|
1473
|
-
|
|
1274
|
+
item.move_to('Archive', label: false, log: false)
|
|
1474
1275
|
logger.count(:completed_archived)
|
|
1475
1276
|
logger.info('Completed/archived:', item.title)
|
|
1476
1277
|
else
|
|
@@ -1482,7 +1283,8 @@ module Doing
|
|
|
1482
1283
|
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
|
1483
1284
|
|
|
1484
1285
|
if opt[:new_item]
|
|
1485
|
-
title, note = format_input(opt[:new_item])
|
|
1286
|
+
date, title, note = format_input(opt[:new_item])
|
|
1287
|
+
opt[:back] = date unless date.nil?
|
|
1486
1288
|
note.add(opt[:note]) if opt[:note]
|
|
1487
1289
|
title.tag!(tag)
|
|
1488
1290
|
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
|
@@ -1499,7 +1301,6 @@ module Doing
|
|
|
1499
1301
|
def write(file = nil, backup: true)
|
|
1500
1302
|
Hooks.trigger :pre_write, self, file
|
|
1501
1303
|
output = combined_content
|
|
1502
|
-
|
|
1503
1304
|
if file.nil?
|
|
1504
1305
|
$stdout.puts output
|
|
1505
1306
|
else
|
|
@@ -1516,7 +1317,7 @@ module Doing
|
|
|
1516
1317
|
def restore_backup(file)
|
|
1517
1318
|
if File.exist?("#{file}~")
|
|
1518
1319
|
FileUtils.cp("#{file}~", file)
|
|
1519
|
-
logger.warn('File update:', "Restored #{file.sub(/^#{
|
|
1320
|
+
logger.warn('File update:', "Restored #{file.sub(/^#{Util.user_home}/, '~')}")
|
|
1520
1321
|
else
|
|
1521
1322
|
logger.error('Restore error:', 'No backup file found')
|
|
1522
1323
|
end
|
|
@@ -1532,70 +1333,46 @@ module Doing
|
|
|
1532
1333
|
bool = opt[:bool] || :and
|
|
1533
1334
|
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
|
1534
1335
|
|
|
1535
|
-
|
|
1536
|
-
all_sections = sections.dup
|
|
1537
|
-
else
|
|
1538
|
-
all_sections = [sect]
|
|
1539
|
-
end
|
|
1336
|
+
section = guess_section(sect)
|
|
1540
1337
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1338
|
+
section_items = @content.in_section(section)
|
|
1339
|
+
max = section_items.count - keep.to_i
|
|
1543
1340
|
|
|
1341
|
+
counter = 0
|
|
1342
|
+
new_content = Items.new
|
|
1544
1343
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
moved_items = []
|
|
1552
|
-
if !tags.empty? || opt[:search] || opt[:before]
|
|
1553
|
-
if opt[:before]
|
|
1554
|
-
time_string = opt[:before]
|
|
1555
|
-
cutoff = chronify(time_string, guess: :begin)
|
|
1556
|
-
end
|
|
1557
|
-
|
|
1558
|
-
items.delete_if do |item|
|
|
1559
|
-
if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
|
|
1560
|
-
moved_items.push(item)
|
|
1561
|
-
counter += 1
|
|
1562
|
-
true
|
|
1563
|
-
else
|
|
1564
|
-
false
|
|
1565
|
-
end
|
|
1566
|
-
end
|
|
1567
|
-
@content[section][:items] = items
|
|
1568
|
-
new_content[section][:items] = moved_items
|
|
1569
|
-
logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
|
|
1570
|
-
else
|
|
1571
|
-
new_content[section][:items] = []
|
|
1572
|
-
moved_items = []
|
|
1573
|
-
|
|
1574
|
-
count = items.length < keep ? items.length : keep
|
|
1575
|
-
|
|
1576
|
-
if items.count > count
|
|
1577
|
-
moved_items.concat(items[count..-1])
|
|
1578
|
-
else
|
|
1579
|
-
moved_items.concat(items)
|
|
1580
|
-
end
|
|
1344
|
+
@content.each do |item|
|
|
1345
|
+
break if counter >= max
|
|
1346
|
+
if opt[:before]
|
|
1347
|
+
time_string = opt[:before]
|
|
1348
|
+
cutoff = time_string.chronify(guess: :begin)
|
|
1349
|
+
end
|
|
1581
1350
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
items[0..count - 1]
|
|
1586
|
-
end
|
|
1587
|
-
new_content[section][:items] = moved_items
|
|
1351
|
+
unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
|
|
1352
|
+
new_item = @content.delete(item)
|
|
1353
|
+
raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
|
|
1588
1354
|
|
|
1589
|
-
|
|
1355
|
+
new_content.add_section(new_item.section, log: false)
|
|
1356
|
+
new_content.push(new_item)
|
|
1357
|
+
counter += 1
|
|
1590
1358
|
end
|
|
1591
1359
|
end
|
|
1592
1360
|
|
|
1361
|
+
if counter.positive?
|
|
1362
|
+
logger.count(:rotated,
|
|
1363
|
+
level: :info,
|
|
1364
|
+
count: counter,
|
|
1365
|
+
message: "Rotated %count %items")
|
|
1366
|
+
else
|
|
1367
|
+
logger.info('Skipped:', 'No items were rotated')
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1593
1370
|
write(@doing_file)
|
|
1594
1371
|
|
|
1595
1372
|
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
|
1596
1373
|
if File.exist?(file)
|
|
1597
1374
|
init_doing_file(file)
|
|
1598
|
-
@content.
|
|
1375
|
+
@content.concat(new_content).uniq!
|
|
1599
1376
|
logger.warn('File update:', "added entries to existing file: #{file}")
|
|
1600
1377
|
else
|
|
1601
1378
|
@content = new_content
|
|
@@ -1611,7 +1388,7 @@ module Doing
|
|
|
1611
1388
|
## @return [String] The selected section name
|
|
1612
1389
|
##
|
|
1613
1390
|
def choose_section
|
|
1614
|
-
choice = choose_from(
|
|
1391
|
+
choice = Prompt.choose_from(@content.section_titles.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
|
1615
1392
|
choice ? choice.strip : choice
|
|
1616
1393
|
end
|
|
1617
1394
|
|
|
@@ -1630,7 +1407,7 @@ module Doing
|
|
|
1630
1407
|
## @return [String] The selected view name
|
|
1631
1408
|
##
|
|
1632
1409
|
def choose_view
|
|
1633
|
-
choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
|
1410
|
+
choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
|
1634
1411
|
choice ? choice.strip : choice
|
|
1635
1412
|
end
|
|
1636
1413
|
|
|
@@ -1688,7 +1465,7 @@ module Doing
|
|
|
1688
1465
|
end
|
|
1689
1466
|
end
|
|
1690
1467
|
|
|
1691
|
-
items = filter_items(
|
|
1468
|
+
items = filter_items(Items.new, opt: opt).reverse
|
|
1692
1469
|
|
|
1693
1470
|
items.reverse! if opt[:order] =~ /^d/i
|
|
1694
1471
|
|
|
@@ -1696,7 +1473,7 @@ module Doing
|
|
|
1696
1473
|
opt[:menu] = !opt[:force]
|
|
1697
1474
|
opt[:query] = '' # opt[:search]
|
|
1698
1475
|
opt[:multiple] = true
|
|
1699
|
-
selected = choose_from_items(items,
|
|
1476
|
+
selected = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt)
|
|
1700
1477
|
|
|
1701
1478
|
raise NoResults, 'no items selected' if selected.empty?
|
|
1702
1479
|
|
|
@@ -1704,11 +1481,8 @@ module Doing
|
|
|
1704
1481
|
return
|
|
1705
1482
|
end
|
|
1706
1483
|
|
|
1707
|
-
|
|
1708
1484
|
opt[:output] ||= 'template'
|
|
1709
|
-
|
|
1710
1485
|
opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0
|
|
1711
|
-
|
|
1712
1486
|
output(items, title, is_single, opt)
|
|
1713
1487
|
end
|
|
1714
1488
|
|
|
@@ -1729,11 +1503,12 @@ module Doing
|
|
|
1729
1503
|
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
|
1730
1504
|
section = guess_section(section) unless archive_all
|
|
1731
1505
|
|
|
1732
|
-
add_section(
|
|
1506
|
+
@content.add_section(destination, log: true)
|
|
1507
|
+
# add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
|
|
1733
1508
|
|
|
1734
1509
|
destination = guess_section(destination)
|
|
1735
1510
|
|
|
1736
|
-
if
|
|
1511
|
+
if @content.section?(destination) && (@content.section?(section) || archive_all)
|
|
1737
1512
|
do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
|
|
1738
1513
|
write(doing_file)
|
|
1739
1514
|
else
|
|
@@ -1758,10 +1533,12 @@ module Doing
|
|
|
1758
1533
|
'order' => @config['order'] || 'asc',
|
|
1759
1534
|
'tags_color' => @config['tags_color']
|
|
1760
1535
|
})
|
|
1536
|
+
|
|
1761
1537
|
options = {
|
|
1762
1538
|
after: opt[:after],
|
|
1763
1539
|
before: opt[:before],
|
|
1764
1540
|
count: 0,
|
|
1541
|
+
from: opt[:from],
|
|
1765
1542
|
format: cfg['date_format'],
|
|
1766
1543
|
order: cfg['order'] || 'asc',
|
|
1767
1544
|
output: output,
|
|
@@ -1818,6 +1595,7 @@ module Doing
|
|
|
1818
1595
|
after: opt[:after],
|
|
1819
1596
|
before: opt[:before],
|
|
1820
1597
|
count: 0,
|
|
1598
|
+
from: opt[:from],
|
|
1821
1599
|
order: opt[:order],
|
|
1822
1600
|
output: output,
|
|
1823
1601
|
section: section,
|
|
@@ -1973,7 +1751,6 @@ module Doing
|
|
|
1973
1751
|
end
|
|
1974
1752
|
end
|
|
1975
1753
|
|
|
1976
|
-
|
|
1977
1754
|
logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
|
|
1978
1755
|
logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
|
|
1979
1756
|
logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
|
|
@@ -1986,10 +1763,10 @@ module Doing
|
|
|
1986
1763
|
text.add_tags!(tail_tags) unless tail_tags.empty?
|
|
1987
1764
|
|
|
1988
1765
|
if text == original
|
|
1989
|
-
logger.debug('Autotag:', "no change to \"#{text}\"")
|
|
1766
|
+
logger.debug('Autotag:', "no change to \"#{text.strip}\"")
|
|
1990
1767
|
else
|
|
1991
1768
|
new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
|
|
1992
|
-
logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text}\"")
|
|
1769
|
+
logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
|
|
1993
1770
|
logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
|
|
1994
1771
|
end
|
|
1995
1772
|
|
|
@@ -2166,7 +1943,7 @@ EOS
|
|
|
2166
1943
|
def format_time(seconds, human: false)
|
|
2167
1944
|
return [0, 0, 0] if seconds.nil?
|
|
2168
1945
|
|
|
2169
|
-
if seconds.
|
|
1946
|
+
if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
|
|
2170
1947
|
h = Regexp.last_match(1)
|
|
2171
1948
|
m = Regexp.last_match(2)
|
|
2172
1949
|
s = Regexp.last_match(3)
|
|
@@ -2195,13 +1972,13 @@ EOS
|
|
|
2195
1972
|
##
|
|
2196
1973
|
def combined_content
|
|
2197
1974
|
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0, tags_color: false })
|
|
2202
|
-
end
|
|
2203
|
-
|
|
1975
|
+
was_color = Color.coloring?
|
|
1976
|
+
Color.coloring = false
|
|
1977
|
+
output += @content.to_s
|
|
2204
1978
|
output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
|
1979
|
+
# Just strip all ANSI colors from the content before writing to doing file
|
|
1980
|
+
Color.coloring = was_color
|
|
1981
|
+
|
|
2205
1982
|
output.uncolor
|
|
2206
1983
|
end
|
|
2207
1984
|
|
|
@@ -2256,99 +2033,50 @@ EOS
|
|
|
2256
2033
|
##
|
|
2257
2034
|
## Helper function, performs the actual archiving
|
|
2258
2035
|
##
|
|
2259
|
-
## @param
|
|
2036
|
+
## @param section [String] The source section
|
|
2260
2037
|
## @param destination [String] The destination
|
|
2261
2038
|
## section
|
|
2262
2039
|
## @param opt [Hash] Additional Options
|
|
2263
2040
|
##
|
|
2264
|
-
def do_archive(
|
|
2041
|
+
def do_archive(section, destination, opt = {})
|
|
2265
2042
|
count = opt[:count] || 0
|
|
2266
2043
|
tags = opt[:tags] || []
|
|
2267
2044
|
bool = opt[:bool] || :and
|
|
2268
2045
|
label = opt[:label] || true
|
|
2269
2046
|
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
all_sections.delete(destination)
|
|
2273
|
-
else
|
|
2274
|
-
all_sections = [sect]
|
|
2275
|
-
end
|
|
2276
|
-
|
|
2277
|
-
counter = 0
|
|
2047
|
+
section = guess_section(section)
|
|
2048
|
+
destination = guess_section(destination)
|
|
2278
2049
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2050
|
+
section_items = @content.in_section(section)
|
|
2051
|
+
max = section_items.count - count.to_i
|
|
2281
2052
|
|
|
2282
|
-
|
|
2283
|
-
if !tags.empty? || opt[:search] || opt[:before]
|
|
2284
|
-
if opt[:before]
|
|
2285
|
-
time_string = opt[:before]
|
|
2286
|
-
cutoff = chronify(time_string, guess: :begin)
|
|
2287
|
-
end
|
|
2053
|
+
counter = 0
|
|
2288
2054
|
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
false
|
|
2296
|
-
end
|
|
2297
|
-
end
|
|
2298
|
-
moved_items.each do |item|
|
|
2299
|
-
if label
|
|
2300
|
-
item.title = if section == @config['current_section']
|
|
2301
|
-
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
|
2302
|
-
else
|
|
2303
|
-
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
|
2304
|
-
end
|
|
2305
|
-
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
|
2306
|
-
end
|
|
2307
|
-
end
|
|
2055
|
+
@content.map! do |item|
|
|
2056
|
+
break if counter >= max
|
|
2057
|
+
if opt[:before]
|
|
2058
|
+
time_string = opt[:before]
|
|
2059
|
+
cutoff = time_string.chronify(guess: :begin)
|
|
2060
|
+
end
|
|
2308
2061
|
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
level: :info,
|
|
2314
|
-
count: moved_items.length,
|
|
2315
|
-
message: "%count %items from #{section} to #{destination}")
|
|
2316
|
-
else
|
|
2317
|
-
logger.info('Skipped:', 'No items were moved')
|
|
2318
|
-
end
|
|
2062
|
+
if (item.section.downcase != section.downcase && section != /^all$/i) || item.section.downcase == destination.downcase
|
|
2063
|
+
item
|
|
2064
|
+
elsif ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
|
|
2065
|
+
item
|
|
2319
2066
|
else
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
items.map! do |item|
|
|
2323
|
-
if label
|
|
2324
|
-
item.title = if section == @config['current_section']
|
|
2325
|
-
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
|
2326
|
-
else
|
|
2327
|
-
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
|
2328
|
-
end
|
|
2329
|
-
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
|
2330
|
-
end
|
|
2331
|
-
item
|
|
2332
|
-
end
|
|
2333
|
-
|
|
2334
|
-
if items.count > count
|
|
2335
|
-
@content[destination][:items].concat(items[count..-1])
|
|
2336
|
-
else
|
|
2337
|
-
@content[destination][:items].concat(items)
|
|
2338
|
-
end
|
|
2339
|
-
|
|
2340
|
-
@content[section][:items] = if count.zero?
|
|
2341
|
-
[]
|
|
2342
|
-
else
|
|
2343
|
-
items[0..count - 1]
|
|
2344
|
-
end
|
|
2345
|
-
|
|
2346
|
-
logger.count(destination == 'Archive' ? :archived : :moved,
|
|
2347
|
-
level: :info,
|
|
2348
|
-
count: items.length - count,
|
|
2349
|
-
message: "%count %items from #{section} to #{destination}")
|
|
2067
|
+
counter += 1
|
|
2068
|
+
item.move_to(destination, label: label, log: false)
|
|
2350
2069
|
end
|
|
2351
2070
|
end
|
|
2071
|
+
|
|
2072
|
+
if counter.positive?
|
|
2073
|
+
logger.count(destination == 'Archive' ? :archived : :moved,
|
|
2074
|
+
level: :info,
|
|
2075
|
+
count: counter,
|
|
2076
|
+
message: "%count %items from #{section} to #{destination}")
|
|
2077
|
+
else
|
|
2078
|
+
logger.info('Skipped:', 'No items were moved')
|
|
2079
|
+
end
|
|
2352
2080
|
end
|
|
2353
2081
|
|
|
2354
2082
|
def run_after
|
|
@@ -2360,31 +2088,5 @@ EOS
|
|
|
2360
2088
|
logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
|
|
2361
2089
|
logger.log_now(:error, 'STDERR output:', stderr)
|
|
2362
2090
|
end
|
|
2363
|
-
|
|
2364
|
-
def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
|
|
2365
|
-
if tags_added.empty? && tags_removed.empty?
|
|
2366
|
-
logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
|
|
2367
|
-
else
|
|
2368
|
-
if tags_added.empty?
|
|
2369
|
-
logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
|
|
2370
|
-
else
|
|
2371
|
-
if single && item
|
|
2372
|
-
logger.info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} to #{item.title}))
|
|
2373
|
-
else
|
|
2374
|
-
logger.count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
|
|
2375
|
-
end
|
|
2376
|
-
end
|
|
2377
|
-
|
|
2378
|
-
if tags_removed.empty?
|
|
2379
|
-
logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
|
|
2380
|
-
else
|
|
2381
|
-
if single && item
|
|
2382
|
-
logger.info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} from #{item.title}))
|
|
2383
|
-
else
|
|
2384
|
-
logger.count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items')
|
|
2385
|
-
end
|
|
2386
|
-
end
|
|
2387
|
-
end
|
|
2388
|
-
end
|
|
2389
2091
|
end
|
|
2390
2092
|
end
|