doing 2.1.37 → 2.1.40
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/Rakefile +7 -1
- data/bin/commands/config.rb +43 -34
- data/bin/commands/done.rb +1 -18
- data/bin/commands/finish.rb +30 -25
- data/bin/commands/grep.rb +3 -14
- data/bin/commands/last.rb +2 -8
- data/bin/commands/meanwhile.rb +13 -6
- data/bin/commands/now.rb +2 -4
- data/bin/commands/on.rb +4 -15
- data/bin/commands/recent.rb +2 -8
- data/bin/commands/reset.rb +24 -1
- data/bin/commands/select.rb +1 -1
- data/bin/commands/show.rb +8 -16
- data/bin/commands/since.rb +1 -12
- data/bin/commands/today.rb +2 -13
- data/bin/commands/view.rb +1 -1
- data/bin/commands/yesterday.rb +2 -13
- data/bin/doing +41 -36
- data/docs/doc/Array.html +1 -1
- data/docs/doc/BooleanTermParser/Clause.html +1 -1
- data/docs/doc/BooleanTermParser/Operator.html +1 -1
- data/docs/doc/BooleanTermParser/Query.html +1 -1
- data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
- data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
- data/docs/doc/BooleanTermParser.html +1 -1
- data/docs/doc/Doing/Color.html +166 -20
- data/docs/doc/Doing/Completion.html +1 -1
- data/docs/doc/Doing/Configuration.html +1 -1
- data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
- data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
- data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
- data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
- data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
- data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
- data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
- data/docs/doc/Doing/Errors/NoResults.html +10 -2
- data/docs/doc/Doing/Errors/PluginException.html +1 -1
- data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
- data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
- data/docs/doc/Doing/Errors.html +9 -9
- data/docs/doc/Doing/Hooks.html +1 -1
- data/docs/doc/Doing/Item.html +114 -1576
- data/docs/doc/Doing/Items.html +121 -5
- data/docs/doc/Doing/Logger.html +1 -1
- data/docs/doc/Doing/Note.html +1 -1
- data/docs/doc/Doing/Pager.html +1 -1
- data/docs/doc/Doing/Plugins.html +1 -1
- data/docs/doc/Doing/Prompt.html +2 -2
- data/docs/doc/Doing/Section.html +1 -1
- data/docs/doc/Doing/TemplateString.html +2 -2
- data/docs/doc/Doing/Types.html +1 -1
- data/docs/doc/Doing/Util/Backup.html +5 -5
- data/docs/doc/Doing/Util.html +1 -1
- data/docs/doc/Doing/WWID.html +197 -4033
- data/docs/doc/Doing.html +2 -2
- data/docs/doc/FalseClass.html +1 -1
- data/docs/doc/GLI/Commands/Help.html +1 -1
- data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
- data/docs/doc/GLI/Commands.html +1 -1
- data/docs/doc/GLI.html +1 -1
- data/docs/doc/Hash.html +1 -1
- data/docs/doc/Object.html +1 -1
- data/docs/doc/PhraseParser/Operator.html +1 -1
- data/docs/doc/PhraseParser/PhraseClause.html +1 -1
- data/docs/doc/PhraseParser/Query.html +1 -1
- data/docs/doc/PhraseParser/QueryParser.html +1 -1
- data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
- data/docs/doc/PhraseParser/TermClause.html +1 -1
- data/docs/doc/PhraseParser.html +1 -1
- data/docs/doc/Status.html +1 -1
- data/docs/doc/String.html +1 -1
- data/docs/doc/Symbol.html +1 -1
- data/docs/doc/Time.html +1 -1
- data/docs/doc/TrueClass.html +1 -1
- data/docs/doc/_index.html +26 -5
- data/docs/doc/class_list.html +1 -1
- data/docs/doc/file.README.html +2 -2
- data/docs/doc/index.html +2 -2
- data/docs/doc/method_list.html +237 -709
- data/docs/doc/top-level-namespace.html +3 -3
- data/docs/index.md +1 -1
- data/doing.rdoc +54 -7
- data/lib/completion/_doing.zsh +6 -6
- data/lib/completion/doing.bash +10 -10
- data/lib/completion/doing.fish +8 -2
- data/lib/doing/add_options.rb +31 -1
- data/lib/doing/chronify/array.rb +68 -18
- data/lib/doing/chronify/string.rb +3 -1
- data/lib/doing/colors.rb +77 -30
- data/lib/doing/completion.rb +4 -5
- data/lib/doing/errors.rb +51 -35
- data/lib/doing/hooks.rb +3 -3
- data/lib/doing/item/dates.rb +112 -0
- data/lib/doing/item/query.rb +433 -0
- data/lib/doing/item/state.rb +59 -0
- data/lib/doing/item/tags.rb +87 -0
- data/lib/doing/item.rb +6 -537
- data/lib/doing/items.rb +39 -14
- data/lib/doing/plugin_manager.rb +3 -3
- data/lib/doing/plugins/export/template_export.rb +4 -4
- data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
- data/lib/doing/prompt.rb +6 -8
- data/lib/doing/string/tags.rb +8 -2
- data/lib/doing/util_backup.rb +6 -8
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid/display.rb +399 -0
- data/lib/doing/wwid/editor.rb +214 -0
- data/lib/doing/wwid/filetools.rb +186 -0
- data/lib/doing/wwid/filter.rb +218 -0
- data/lib/doing/wwid/guess.rb +87 -0
- data/lib/doing/wwid/interactive.rb +385 -0
- data/lib/doing/wwid/modify.rb +618 -0
- data/lib/doing/wwid/tags.rb +54 -0
- data/lib/doing/wwid/timers.rb +345 -0
- data/lib/doing/wwid/wwidutil.rb +104 -0
- data/lib/doing/wwid.rb +31 -2308
- metadata +19 -2
data/lib/doing/wwid.rb
CHANGED
@@ -7,16 +7,37 @@ require 'pp'
|
|
7
7
|
require 'shellwords'
|
8
8
|
require 'erb'
|
9
9
|
|
10
|
+
require_relative 'wwid/display'
|
11
|
+
require_relative 'wwid/editor'
|
12
|
+
require_relative 'wwid/filetools'
|
13
|
+
require_relative 'wwid/filter'
|
14
|
+
require_relative 'wwid/guess'
|
15
|
+
require_relative 'wwid/interactive'
|
16
|
+
require_relative 'wwid/modify'
|
17
|
+
require_relative 'wwid/tags'
|
18
|
+
require_relative 'wwid/timers'
|
19
|
+
require_relative 'wwid/wwidutil'
|
20
|
+
|
10
21
|
module Doing
|
11
22
|
##
|
12
23
|
## Main "What Was I Doing" methods
|
13
24
|
##
|
14
25
|
class WWID
|
15
|
-
attr_reader :additional_configs, :current_section, :doing_file, :content
|
26
|
+
attr_reader :additional_configs, :current_section, :doing_file, :content, :initial_content
|
16
27
|
|
17
28
|
attr_accessor :config, :config_file, :default_option
|
18
29
|
|
19
30
|
include Color
|
31
|
+
include Display
|
32
|
+
include Editor
|
33
|
+
include FileTools
|
34
|
+
include Filter
|
35
|
+
include Guess
|
36
|
+
include Interactive
|
37
|
+
include Modify
|
38
|
+
include Tags
|
39
|
+
include Timers
|
40
|
+
include WWIDUtil
|
20
41
|
# include Util
|
21
42
|
|
22
43
|
##
|
@@ -47,183 +68,6 @@ module Doing
|
|
47
68
|
@logger ||= Doing.logger
|
48
69
|
end
|
49
70
|
|
50
|
-
##
|
51
|
-
## Initializes the doing file.
|
52
|
-
##
|
53
|
-
## @param path [String] Override path to a doing file, optional
|
54
|
-
##
|
55
|
-
def init_doing_file(path = nil)
|
56
|
-
@doing_file = File.expand_path(Doing.setting('doing_file'))
|
57
|
-
|
58
|
-
if path.nil?
|
59
|
-
create(@doing_file) unless File.exist?(@doing_file)
|
60
|
-
input = IO.read(@doing_file)
|
61
|
-
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
62
|
-
logger.debug('Read:', "read file #{@doing_file}")
|
63
|
-
elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
|
64
|
-
@doing_file = File.expand_path(path)
|
65
|
-
input = IO.read(File.expand_path(path))
|
66
|
-
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
67
|
-
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
68
|
-
elsif path.length < 256
|
69
|
-
@doing_file = File.expand_path(path)
|
70
|
-
create(path)
|
71
|
-
input = IO.read(File.expand_path(path))
|
72
|
-
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
73
|
-
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
74
|
-
end
|
75
|
-
|
76
|
-
@other_content_top = []
|
77
|
-
@other_content_bottom = []
|
78
|
-
|
79
|
-
section = nil
|
80
|
-
lines = input.split(/[\n\r]/)
|
81
|
-
|
82
|
-
lines.each do |line|
|
83
|
-
next if line =~ /^\s*$/
|
84
|
-
|
85
|
-
if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
|
86
|
-
section = Regexp.last_match(1)
|
87
|
-
@content.add_section(Section.new(section, original: line), log: false)
|
88
|
-
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
|
89
|
-
if section.nil?
|
90
|
-
section = 'Uncategorized'
|
91
|
-
@content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
|
92
|
-
end
|
93
|
-
|
94
|
-
date = Regexp.last_match(1).strip
|
95
|
-
title = Regexp.last_match(2).strip
|
96
|
-
item = Item.new(date, title, section)
|
97
|
-
@content.push(item)
|
98
|
-
elsif @content.count.zero?
|
99
|
-
# if content[section].items.length - 1 == current
|
100
|
-
@other_content_top.push(line)
|
101
|
-
elsif line =~ /^\S/
|
102
|
-
@other_content_bottom.push(line)
|
103
|
-
else
|
104
|
-
prev_item = @content.last
|
105
|
-
prev_item.note = Note.new unless prev_item.note
|
106
|
-
|
107
|
-
prev_item.note.add(line)
|
108
|
-
# end
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
|
-
Hooks.trigger :post_read, self
|
113
|
-
end
|
114
|
-
|
115
|
-
##
|
116
|
-
## Create a new doing file
|
117
|
-
##
|
118
|
-
def create(filename = nil)
|
119
|
-
filename = @doing_file if filename.nil?
|
120
|
-
return if File.exist?(filename) && File.stat(filename).size.positive?
|
121
|
-
|
122
|
-
FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
|
123
|
-
|
124
|
-
File.open(filename, 'w+') do |f|
|
125
|
-
f.puts "#{Doing.setting('current_section')}:"
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
129
|
-
##
|
130
|
-
## Create a process for an editor and wait for the file handle to return
|
131
|
-
##
|
132
|
-
## @param input [String] Text input for editor
|
133
|
-
##
|
134
|
-
def fork_editor(input = '', message: :default)
|
135
|
-
# raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
|
136
|
-
|
137
|
-
raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
138
|
-
|
139
|
-
tmpfile = Tempfile.new(['doing', '.md'])
|
140
|
-
|
141
|
-
File.open(tmpfile.path, 'w+') do |f|
|
142
|
-
f.puts input
|
143
|
-
unless message.nil?
|
144
|
-
f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
|
149
|
-
|
150
|
-
trap('INT') do
|
151
|
-
begin
|
152
|
-
Process.kill(9, pid)
|
153
|
-
rescue StandardError
|
154
|
-
Errno::ESRCH
|
155
|
-
end
|
156
|
-
tmpfile.unlink
|
157
|
-
tmpfile.close!
|
158
|
-
exit 0
|
159
|
-
end
|
160
|
-
|
161
|
-
Process.wait(pid)
|
162
|
-
|
163
|
-
begin
|
164
|
-
if $?.exitstatus == 0
|
165
|
-
input = IO.read(tmpfile.path)
|
166
|
-
else
|
167
|
-
exit_now! 'Cancelled'
|
168
|
-
end
|
169
|
-
ensure
|
170
|
-
tmpfile.close
|
171
|
-
tmpfile.unlink
|
172
|
-
end
|
173
|
-
|
174
|
-
input.split(/\n/).delete_if(&:ignore?).join("\n")
|
175
|
-
end
|
176
|
-
|
177
|
-
##
|
178
|
-
## Takes a multi-line string and formats it as an entry
|
179
|
-
##
|
180
|
-
## @param input [String] The string to parse
|
181
|
-
##
|
182
|
-
## @return [Array] [[String]title, [Note]note]
|
183
|
-
##
|
184
|
-
def format_input(input)
|
185
|
-
raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
|
186
|
-
|
187
|
-
input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
|
188
|
-
title = input_lines[0]&.strip
|
189
|
-
raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
|
190
|
-
|
191
|
-
date = nil
|
192
|
-
iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
|
193
|
-
date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
|
194
|
-
|
195
|
-
raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
|
196
|
-
|
197
|
-
title.expand_date_tags(Doing.setting('date_tags'))
|
198
|
-
|
199
|
-
if title =~ date_rx
|
200
|
-
m = title.match(date_rx)
|
201
|
-
d = m['date']
|
202
|
-
date = if d =~ iso_rx
|
203
|
-
Time.parse(d)
|
204
|
-
else
|
205
|
-
d.chronify(guess: :begin)
|
206
|
-
end
|
207
|
-
title.sub!(date_rx, '').strip!
|
208
|
-
end
|
209
|
-
|
210
|
-
note = Note.new
|
211
|
-
note.add(input_lines[1..-1]) if input_lines.length > 1
|
212
|
-
# If title line ends in a parenthetical, use that as the note
|
213
|
-
if note.empty? && title =~ /\s+\(.*?\)$/
|
214
|
-
title.sub!(/\s+\((?<note>.*?)\)$/) do
|
215
|
-
m = Regexp.last_match
|
216
|
-
note.add(m['note'])
|
217
|
-
''
|
218
|
-
end
|
219
|
-
end
|
220
|
-
|
221
|
-
note.strip_lines!
|
222
|
-
note.compress
|
223
|
-
|
224
|
-
[date, title, note]
|
225
|
-
end
|
226
|
-
|
227
71
|
##
|
228
72
|
## List sections
|
229
73
|
##
|
@@ -234,2148 +78,27 @@ module Doing
|
|
234
78
|
end
|
235
79
|
|
236
80
|
##
|
237
|
-
##
|
238
|
-
##
|
239
|
-
## @param frag [String] The user-provided string
|
240
|
-
## @param guessed [Boolean] already guessed and failed
|
241
|
-
##
|
242
|
-
def guess_section(frag, guessed: false, suggest: false)
|
243
|
-
return 'All' if frag =~ /^all$/i
|
244
|
-
frag ||= Doing.setting('current_section')
|
245
|
-
|
246
|
-
return frag.cap_first if @content.section?(frag)
|
247
|
-
|
248
|
-
section = nil
|
249
|
-
re = frag.to_rx(distance: 2, case_type: :ignore)
|
250
|
-
sections.each do |sect|
|
251
|
-
next unless sect =~ /#{re}/i
|
252
|
-
|
253
|
-
logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
|
254
|
-
section = sect
|
255
|
-
break
|
256
|
-
end
|
257
|
-
|
258
|
-
return section if suggest
|
259
|
-
|
260
|
-
unless section || guessed
|
261
|
-
alt = guess_view(frag, guessed: true, suggest: true)
|
262
|
-
if alt
|
263
|
-
meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
|
264
|
-
|
265
|
-
raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
|
266
|
-
|
267
|
-
end
|
268
|
-
|
269
|
-
res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
|
270
|
-
|
271
|
-
if res
|
272
|
-
@content.add_section(frag.cap_first, log: true)
|
273
|
-
write(@doing_file)
|
274
|
-
return frag.cap_first
|
275
|
-
end
|
276
|
-
|
277
|
-
raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
|
278
|
-
end
|
279
|
-
section ? section.cap_first : guessed
|
280
|
-
end
|
281
|
-
|
282
|
-
##
|
283
|
-
## Attempt to match a string with an existing view
|
284
|
-
##
|
285
|
-
## @param frag [String] The user-provided string
|
286
|
-
## @param guessed [Boolean] already guessed
|
287
|
-
##
|
288
|
-
def guess_view(frag, guessed: false, suggest: false)
|
289
|
-
views.each { |view| return view if frag.downcase == view.downcase }
|
290
|
-
view = false
|
291
|
-
re = frag.to_rx(distance: 2, case_type: :ignore)
|
292
|
-
views.each do |v|
|
293
|
-
next unless v =~ /#{re}/i
|
294
|
-
|
295
|
-
logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
|
296
|
-
view = v
|
297
|
-
break
|
298
|
-
end
|
299
|
-
unless view || guessed
|
300
|
-
alt = guess_section(frag, guessed: true, suggest: true)
|
301
|
-
|
302
|
-
raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
|
303
|
-
|
304
|
-
meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
|
305
|
-
|
306
|
-
raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
|
307
|
-
|
308
|
-
raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
|
309
|
-
end
|
310
|
-
view
|
311
|
-
end
|
312
|
-
|
313
|
-
def add_with_editor(**options)
|
314
|
-
raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
315
|
-
|
316
|
-
input = options[:date].strftime('%F %R | ')
|
317
|
-
input += options[:title]
|
318
|
-
input += "\n#{options[:note]}" if options[:note]
|
319
|
-
input = fork_editor(input).strip
|
320
|
-
|
321
|
-
d, title, note = format_input(input)
|
322
|
-
raise EmptyInput, 'No content' if title.empty?
|
323
|
-
|
324
|
-
if options[:ask]
|
325
|
-
ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
|
326
|
-
note.add(ask_note) unless ask_note.empty?
|
327
|
-
end
|
328
|
-
|
329
|
-
date = d.nil? ? options[:date] : d
|
330
|
-
finish = options[:finish_last] || false
|
331
|
-
add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
|
332
|
-
write(@doing_file)
|
333
|
-
end
|
334
|
-
|
335
|
-
##
|
336
|
-
## Adds an entry
|
337
|
-
##
|
338
|
-
## @param title [String] The entry title
|
339
|
-
## @param section [String] The section to add to
|
340
|
-
## @param opt [Hash] Additional Options
|
341
|
-
##
|
342
|
-
## @option opt :date [Date] item start date
|
343
|
-
## @option opt :note [Array] item note (will be converted if value is String)
|
344
|
-
## @option opt :back [Date] backdate
|
345
|
-
## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
|
346
|
-
## @option opt :done [Date] If set, adds a @done tag to new entry
|
347
|
-
##
|
348
|
-
def add_item(title, section = nil, opt)
|
349
|
-
opt ||= {}
|
350
|
-
section ||= Doing.setting('current_section')
|
351
|
-
@content.add_section(section, log: false)
|
352
|
-
opt[:back] ||= opt[:date] ? opt[:date] : Time.now
|
353
|
-
opt[:date] ||= Time.now
|
354
|
-
note = Note.new
|
355
|
-
opt[:timed] ||= false
|
356
|
-
|
357
|
-
note.add(opt[:note]) if opt[:note]
|
358
|
-
|
359
|
-
title = [title.strip.cap_first]
|
360
|
-
title = title.join(' ')
|
361
|
-
|
362
|
-
if Doing.auto_tag
|
363
|
-
title = autotag(title)
|
364
|
-
title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
|
365
|
-
end
|
366
|
-
|
367
|
-
title.compress!
|
368
|
-
entry = Item.new(opt[:back], title.strip, section)
|
369
|
-
|
370
|
-
if opt[:done] && entry.should_finish?
|
371
|
-
if entry.should_time?
|
372
|
-
entry.tag('done', value: opt[:done])
|
373
|
-
else
|
374
|
-
entry.tag('done')
|
375
|
-
end
|
376
|
-
end
|
377
|
-
|
378
|
-
entry.note = note
|
379
|
-
|
380
|
-
items = @content.clone
|
381
|
-
if opt[:timed]
|
382
|
-
items.reverse!
|
383
|
-
items.each_with_index do |i, x|
|
384
|
-
next if i.title =~ / @done/
|
385
|
-
|
386
|
-
finish_date = verify_duration(i.date, opt[:back], title: i.title)
|
387
|
-
items[x].tag('done', value: finish_date.strftime('%F %R'))
|
388
|
-
break
|
389
|
-
end
|
390
|
-
end
|
391
|
-
|
392
|
-
Hooks.trigger :pre_entry_add, self, entry
|
393
|
-
|
394
|
-
@content.push(entry)
|
395
|
-
# logger.count(:added, level: :debug)
|
396
|
-
logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
|
397
|
-
|
398
|
-
Hooks.trigger :post_entry_added, self, entry
|
399
|
-
entry
|
400
|
-
end
|
401
|
-
|
402
|
-
##
|
403
|
-
## Remove items from an array that already exist in
|
404
|
-
## @content based on start and end times
|
405
|
-
##
|
406
|
-
## @param items [Array] The items to
|
407
|
-
## deduplicate
|
408
|
-
## @param no_overlap [Boolean] Remove items with
|
409
|
-
## overlapping time spans
|
410
|
-
##
|
411
|
-
def dedup(items, no_overlap: false)
|
412
|
-
items.delete_if do |item|
|
413
|
-
duped = false
|
414
|
-
@content.each do |comp|
|
415
|
-
duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
|
416
|
-
break if duped
|
417
|
-
end
|
418
|
-
logger.count(:skipped, level: :debug, message: '%count overlapping %items') if duped
|
419
|
-
# logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
|
420
|
-
duped
|
421
|
-
end
|
422
|
-
end
|
423
|
-
|
424
|
-
##
|
425
|
-
## Imports external entries
|
426
|
-
##
|
427
|
-
## @param paths [String] Path to JSON report file
|
428
|
-
## @param opt [Hash] Additional Options
|
429
|
-
##
|
430
|
-
def import(paths, opt)
|
431
|
-
opt ||= {}
|
432
|
-
Plugins.plugins[:import].each do |_, options|
|
433
|
-
next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
434
|
-
|
435
|
-
if paths.count.positive?
|
436
|
-
paths.each do |path|
|
437
|
-
options[:class].import(self, path, options: opt)
|
438
|
-
end
|
439
|
-
else
|
440
|
-
options[:class].import(self, nil, options: opt)
|
441
|
-
end
|
442
|
-
break
|
443
|
-
end
|
444
|
-
end
|
445
|
-
|
446
|
-
##
|
447
|
-
## Return the content of the last note for a given section
|
448
|
-
##
|
449
|
-
## @param section [String] The section to retrieve from, default
|
450
|
-
## All
|
451
|
-
##
|
452
|
-
def last_note(section = 'All')
|
453
|
-
section = guess_section(section)
|
454
|
-
|
455
|
-
last_item = last_entry({ section: section })
|
456
|
-
|
457
|
-
raise NoEntryError, 'No entry found' unless last_item
|
458
|
-
|
459
|
-
logger.log_now(:info, 'Edit note:', last_item.title)
|
460
|
-
|
461
|
-
note = last_item.note&.to_s || ''
|
462
|
-
"#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
|
463
|
-
end
|
464
|
-
|
465
|
-
# Reset start date to current time, optionally remove
|
466
|
-
# done tag (resume)
|
467
|
-
#
|
468
|
-
# @param item [Item] the item to reset/resume
|
469
|
-
# @param resume [Boolean] removing @done tag if true
|
470
|
-
#
|
471
|
-
def reset_item(item, date: nil, resume: false)
|
472
|
-
date ||= Time.now
|
473
|
-
item.date = date
|
474
|
-
item.tag('done', remove: true) if resume
|
475
|
-
logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
|
476
|
-
item
|
477
|
-
end
|
478
|
-
|
479
|
-
# Duplicate an item and add it as a new item
|
480
|
-
#
|
481
|
-
# @param item [Item] the item to duplicate
|
482
|
-
# @param opt [Hash] additional options
|
483
|
-
#
|
484
|
-
# @option opt :editor [Boolean] open new item in editor
|
485
|
-
# @option opt :date [String] set start date
|
486
|
-
# @option opt :in [String] add new item to section :in
|
487
|
-
# @option opt :note [Note] add note to new item
|
488
|
-
#
|
489
|
-
# @return nothing
|
490
|
-
#
|
491
|
-
def repeat_item(item, opt)
|
492
|
-
opt ||= {}
|
493
|
-
old_item = item.clone
|
494
|
-
if item.should_finish?
|
495
|
-
if item.should_time?
|
496
|
-
finish_date = verify_duration(item.date, Time.now, title: item.title)
|
497
|
-
item.title.tag!('done', value: finish_date.strftime('%F %R'))
|
498
|
-
else
|
499
|
-
item.title.tag!('done')
|
500
|
-
end
|
501
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
502
|
-
end
|
503
|
-
|
504
|
-
# Remove @done tag
|
505
|
-
title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
|
506
|
-
section = opt[:in].nil? ? item.section : guess_section(opt[:in])
|
507
|
-
Doing.auto_tag = false
|
508
|
-
|
509
|
-
note = opt[:note] || Note.new
|
510
|
-
|
511
|
-
if opt[:editor]
|
512
|
-
start = opt[:date] ? opt[:date] : Time.now
|
513
|
-
to_edit = "#{start.strftime('%F %R')} | #{title}"
|
514
|
-
to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
|
515
|
-
new_item = fork_editor(to_edit)
|
516
|
-
date, title, note = format_input(new_item)
|
517
|
-
|
518
|
-
opt[:date] = date unless date.nil?
|
519
|
-
|
520
|
-
if title.nil? || title.empty?
|
521
|
-
logger.warn('Skipped:', 'No content provided')
|
522
|
-
return
|
523
|
-
end
|
524
|
-
end
|
525
|
-
|
526
|
-
# @content.update_item(original, item)
|
527
|
-
add_item(title, section, { note: note, back: opt[:date], timed: false })
|
528
|
-
end
|
529
|
-
|
530
|
-
##
|
531
|
-
## Restart the last entry
|
532
|
-
##
|
533
|
-
## @param opt [Hash] Additional Options
|
534
|
-
##
|
535
|
-
def repeat_last(opt)
|
536
|
-
opt ||= {}
|
537
|
-
opt[:section] ||= 'all'
|
538
|
-
opt[:section] = guess_section(opt[:section])
|
539
|
-
opt[:note] ||= []
|
540
|
-
opt[:tag] ||= []
|
541
|
-
opt[:tag_bool] ||= :and
|
542
|
-
|
543
|
-
last = last_entry(opt)
|
544
|
-
if last.nil?
|
545
|
-
logger.warn('Skipped:', 'No previous entry found')
|
546
|
-
return
|
547
|
-
end
|
548
|
-
|
549
|
-
repeat_item(last, opt)
|
550
|
-
write(@doing_file)
|
551
|
-
end
|
552
|
-
|
553
|
-
##
|
554
|
-
## Get the last entry
|
555
|
-
##
|
556
|
-
## @param opt [Hash] Additional Options
|
557
|
-
##
|
558
|
-
def last_entry(opt)
|
559
|
-
opt ||= {}
|
560
|
-
opt[:tag_bool] ||= :and
|
561
|
-
opt[:section] ||= Doing.setting('current_section')
|
562
|
-
|
563
|
-
items = filter_items(Items.new, opt: opt)
|
564
|
-
|
565
|
-
logger.debug('Filtered:', "Parameters matched #{items.count} entries")
|
566
|
-
|
567
|
-
if opt[:interactive]
|
568
|
-
last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
|
569
|
-
menu: true,
|
570
|
-
header: '',
|
571
|
-
prompt: 'Select an entry > ',
|
572
|
-
multiple: false,
|
573
|
-
sort: false,
|
574
|
-
show_if_single: true
|
575
|
-
)
|
576
|
-
else
|
577
|
-
last_entry = items.max_by { |item| item.date }
|
578
|
-
end
|
579
|
-
|
580
|
-
last_entry
|
581
|
-
end
|
582
|
-
|
583
|
-
def all_tags(items, opt: {}, counts: false)
|
584
|
-
if counts
|
585
|
-
all_tags = {}
|
586
|
-
items.each do |item|
|
587
|
-
item.tags.each do |tag|
|
588
|
-
if all_tags.key?(tag.downcase)
|
589
|
-
all_tags[tag.downcase] += 1
|
590
|
-
else
|
591
|
-
all_tags[tag.downcase] = 1
|
592
|
-
end
|
593
|
-
end
|
594
|
-
end
|
595
|
-
|
596
|
-
all_tags.sort_by { |tag, count| count }
|
597
|
-
else
|
598
|
-
all_tags = []
|
599
|
-
items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
|
600
|
-
all_tags.sort
|
601
|
-
end
|
602
|
-
end
|
603
|
-
|
604
|
-
def tag_groups(items, opt: {})
|
605
|
-
all_items = filter_items(items, opt: opt)
|
606
|
-
tags = all_tags(all_items, opt: {})
|
607
|
-
tag_groups = {}
|
608
|
-
tags.each do |tag|
|
609
|
-
tag_groups[tag] ||= []
|
610
|
-
tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
|
611
|
-
end
|
612
|
-
|
613
|
-
tag_groups
|
614
|
-
end
|
615
|
-
|
616
|
-
def fuzzy_filter_items(items, opt: {})
|
617
|
-
scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
|
618
|
-
|
619
|
-
fzf_args = [
|
620
|
-
'--multi',
|
621
|
-
%(--filter="#{opt[:search].sub(/^'?/, "'")}"),
|
622
|
-
'--no-sort',
|
623
|
-
'-d "\|"',
|
624
|
-
'--nth=1'
|
625
|
-
]
|
626
|
-
if opt[:case]
|
627
|
-
fzf_args << case opt[:case].normalize_case
|
628
|
-
when :sensitive
|
629
|
-
'+i'
|
630
|
-
when :ignore
|
631
|
-
'-i'
|
632
|
-
end
|
633
|
-
end
|
634
|
-
# fzf_args << '-e' if opt[:exact]
|
635
|
-
# puts fzf_args.join(' ')
|
636
|
-
res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
|
637
|
-
selected = Items.new
|
638
|
-
res.split(/\n/).each do |item|
|
639
|
-
idx = item.match(/\|(\d+)$/)[1].to_i
|
640
|
-
selected.push(items[idx])
|
641
|
-
end
|
642
|
-
selected
|
643
|
-
end
|
644
|
-
|
645
|
-
##
|
646
|
-
## Filter items based on search criteria
|
647
|
-
##
|
648
|
-
## @param items [Array] The items to filter (if empty, filters all items)
|
649
|
-
## @param opt [Hash] The filter parameters
|
650
|
-
##
|
651
|
-
## @option opt [String] :section ('all')
|
652
|
-
## @option opt [Boolean] :unfinished (false)
|
653
|
-
## @option opt [Array or String] :tag ([]) Array or comma-separated string
|
654
|
-
## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
|
655
|
-
## @option opt [String] :search ('') string, optional regex with `/string/`
|
656
|
-
## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
|
657
|
-
## @option opt [Boolean] :only_timed (false)
|
658
|
-
## @option opt [String] :before (nil) Date/Time string, unparsed
|
659
|
-
## @option opt [String] :after (nil) Date/Time string, unparsed
|
660
|
-
## @option opt [Boolean] :today (false) limit to entries from today
|
661
|
-
## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
|
662
|
-
## @option opt [Number] :count (0) max entries to return
|
663
|
-
## @option opt [String] :age (new) 'old' or 'new'
|
664
|
-
## @option opt [Array] :val (nil) Array of tag value queries
|
665
|
-
##
|
666
|
-
def filter_items(items = Items.new, opt: {})
|
667
|
-
logger.benchmark(:filter_items, :start)
|
668
|
-
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
669
|
-
|
670
|
-
if items.nil? || items.empty?
|
671
|
-
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
672
|
-
items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
|
673
|
-
end
|
674
|
-
|
675
|
-
if !opt[:time_filter]
|
676
|
-
opt[:time_filter] = [nil, nil]
|
677
|
-
if opt[:from] && !opt[:date_filter]
|
678
|
-
if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
|
679
|
-
opt[:time_filter] = opt[:from]
|
680
|
-
elsif opt[:from][0].is_a?(Time)
|
681
|
-
opt[:date_filter] = opt[:from]
|
682
|
-
end
|
683
|
-
end
|
684
|
-
end
|
685
|
-
|
686
|
-
if opt[:before].is_a?(String) && opt[:before] =~ time_rx
|
687
|
-
opt[:time_filter][1] = opt[:before]
|
688
|
-
opt[:before] = nil
|
689
|
-
end
|
690
|
-
|
691
|
-
if opt[:after].is_a?(String) && opt[:after] =~ time_rx
|
692
|
-
opt[:time_filter][0] = opt[:after]
|
693
|
-
opt[:after] = nil
|
694
|
-
end
|
695
|
-
|
696
|
-
items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
|
697
|
-
|
698
|
-
filtered_items = items.select do |item|
|
699
|
-
keep = true
|
700
|
-
if opt[:unfinished]
|
701
|
-
finished = item.tags?('done', :and)
|
702
|
-
finished = opt[:not] ? !finished : finished
|
703
|
-
keep = false if finished
|
704
|
-
end
|
705
|
-
|
706
|
-
if keep && opt[:val]&.count&.positive?
|
707
|
-
bool = opt[:bool].normalize_bool if opt[:bool]
|
708
|
-
bool ||= :and
|
709
|
-
bool = :and if bool == :pattern
|
710
|
-
|
711
|
-
val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
|
712
|
-
keep = false unless val_match
|
713
|
-
keep = opt[:not] ? !keep : keep
|
714
|
-
end
|
715
|
-
|
716
|
-
if keep && opt[:tag]
|
717
|
-
opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
|
718
|
-
opt[:tag_bool] ||= :and
|
719
|
-
tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
|
720
|
-
keep = false unless tag_match
|
721
|
-
keep = opt[:not] ? !keep : keep
|
722
|
-
end
|
723
|
-
|
724
|
-
if keep && opt[:search]
|
725
|
-
search_match = if opt[:search].nil? || opt[:search].empty?
|
726
|
-
true
|
727
|
-
else
|
728
|
-
item.search(opt[:search], case_type: opt[:case].normalize_case)
|
729
|
-
end
|
730
|
-
|
731
|
-
keep = false unless search_match
|
732
|
-
keep = opt[:not] ? !keep : keep
|
733
|
-
end
|
734
|
-
|
735
|
-
if keep && opt[:date_filter]&.length == 2
|
736
|
-
start_date = opt[:date_filter][0]
|
737
|
-
end_date = opt[:date_filter][1]
|
738
|
-
|
739
|
-
in_date_range = if end_date
|
740
|
-
item.date >= start_date && item.date <= end_date
|
741
|
-
else
|
742
|
-
item.date.strftime('%F') == start_date.strftime('%F')
|
743
|
-
end
|
744
|
-
keep = false unless in_date_range
|
745
|
-
keep = opt[:not] ? !keep : keep
|
746
|
-
end
|
747
|
-
|
748
|
-
if keep && opt[:time_filter][0] || opt[:time_filter][1]
|
749
|
-
start_string = if opt[:time_filter][0].nil?
|
750
|
-
"#{item.date.strftime('%Y-%m-%d')} 12am"
|
751
|
-
else
|
752
|
-
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
|
753
|
-
end
|
754
|
-
start_time = start_string.chronify(guess: :begin)
|
755
|
-
|
756
|
-
end_string = if opt[:time_filter][1].nil?
|
757
|
-
"#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
|
758
|
-
else
|
759
|
-
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
|
760
|
-
end
|
761
|
-
end_time = end_string.chronify(guess: :end)
|
762
|
-
|
763
|
-
in_time_range = item.date >= start_time && item.date <= end_time
|
764
|
-
keep = false unless in_time_range
|
765
|
-
keep = opt[:not] ? !keep : keep
|
766
|
-
end
|
767
|
-
|
768
|
-
keep = false if keep && opt[:only_timed] && !item.interval
|
769
|
-
|
770
|
-
if keep && opt[:tag_filter]
|
771
|
-
keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
|
772
|
-
keep = opt[:not] ? !keep : keep
|
773
|
-
end
|
774
|
-
|
775
|
-
if keep && opt[:before]
|
776
|
-
before = opt[:before]
|
777
|
-
if before =~ time_rx
|
778
|
-
cutoff = "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
|
779
|
-
elsif before.is_a?(String)
|
780
|
-
cutoff = before.chronify(guess: :begin)
|
781
|
-
else
|
782
|
-
cutoff = before
|
783
|
-
end
|
784
|
-
keep = cutoff && item.date <= cutoff
|
785
|
-
keep = opt[:not] ? !keep : keep
|
786
|
-
end
|
787
|
-
|
788
|
-
if keep && opt[:after]
|
789
|
-
after = opt[:after]
|
790
|
-
if after =~ time_rx
|
791
|
-
cutoff = "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
|
792
|
-
elsif after.is_a?(String)
|
793
|
-
cutoff = after.chronify(guess: :end)
|
794
|
-
else
|
795
|
-
cutoff = after
|
796
|
-
end
|
797
|
-
keep = cutoff && item.date >= cutoff
|
798
|
-
keep = opt[:not] ? !keep : keep
|
799
|
-
end
|
800
|
-
|
801
|
-
if keep && opt[:today]
|
802
|
-
keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
|
803
|
-
keep = opt[:not] ? !keep : keep
|
804
|
-
elsif keep && opt[:yesterday]
|
805
|
-
keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
|
806
|
-
keep = opt[:not] ? !keep : keep
|
807
|
-
end
|
808
|
-
|
809
|
-
keep
|
810
|
-
end
|
811
|
-
count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
|
812
|
-
|
813
|
-
output = Items.new
|
814
|
-
|
815
|
-
if opt[:age] && opt[:age].normalize_age == :oldest
|
816
|
-
output.concat(filtered_items.slice(0, count).reverse)
|
817
|
-
else
|
818
|
-
output.concat(filtered_items.reverse.slice(0, count))
|
819
|
-
end
|
820
|
-
|
821
|
-
logger.benchmark(:filter_items, :finish)
|
822
|
-
|
823
|
-
output
|
824
|
-
end
|
825
|
-
|
826
|
-
def delete_items(items, force: false)
|
827
|
-
items.slice(0, 5).each { |i| puts i.to_pretty } unless force
|
828
|
-
puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
|
829
|
-
|
830
|
-
res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
|
831
|
-
return unless res
|
832
|
-
|
833
|
-
items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
|
834
|
-
write(@doing_file)
|
835
|
-
end
|
836
|
-
|
837
|
-
def edit_items(items)
|
838
|
-
items.sort_by! { |i| i.date }
|
839
|
-
editable_items = []
|
840
|
-
|
841
|
-
items.each do |i|
|
842
|
-
editable = "#{i.date.strftime('%F %R')} | #{i.title}"
|
843
|
-
old_note = i.note ? i.note.strip_lines.join("\n") : nil
|
844
|
-
editable += "\n#{old_note}" unless old_note.nil?
|
845
|
-
editable_items << editable
|
846
|
-
end
|
847
|
-
divider = "-----------"
|
848
|
-
notice =<<~EONOTICE
|
849
|
-
# - You may delete entries, but leave all divider lines (---) in place.
|
850
|
-
# - Start and @done dates replaced with a time string (yesterday 3pm) will
|
851
|
-
# be parsed automatically. Do not delete the pipe (|) between start date
|
852
|
-
# and entry title.
|
853
|
-
EONOTICE
|
854
|
-
input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
|
855
|
-
|
856
|
-
new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
|
857
|
-
|
858
|
-
new_items.each_with_index do |new_item, i|
|
859
|
-
input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
|
860
|
-
first_line = input_lines[0]&.strip
|
861
|
-
|
862
|
-
if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
|
863
|
-
deleted = @content.delete_item(items[i], single: new_items.count == 1)
|
864
|
-
Hooks.trigger :post_entry_removed, self, deleted
|
865
|
-
Doing.logger.info('Deleted:', deleted.title)
|
866
|
-
else
|
867
|
-
date, title, note = format_input(new_item)
|
868
|
-
|
869
|
-
note.map!(&:strip)
|
870
|
-
note.delete_if(&:ignore?)
|
871
|
-
item = items[i]
|
872
|
-
old_item = item.clone
|
873
|
-
item.date = date || items[i].date
|
874
|
-
item.title = title
|
875
|
-
item.note = note
|
876
|
-
if (item.equal?(old_item))
|
877
|
-
Doing.logger.count(:skipped, level: :debug)
|
878
|
-
else
|
879
|
-
Doing.logger.count(:updated)
|
880
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
881
|
-
end
|
882
|
-
end
|
883
|
-
end
|
884
|
-
end
|
885
|
-
|
886
|
-
##
|
887
|
-
## Display an interactive menu of entries
|
888
|
-
##
|
889
|
-
## @param opt [Hash] Additional options
|
81
|
+
## List available views
|
890
82
|
##
|
891
|
-
##
|
83
|
+
## @return [Array] View names
|
892
84
|
##
|
893
|
-
def
|
894
|
-
|
895
|
-
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
896
|
-
|
897
|
-
search = nil
|
898
|
-
|
899
|
-
if opt[:search]
|
900
|
-
search = opt[:search]
|
901
|
-
search.sub!(/^'?/, "'") if opt[:exact]
|
902
|
-
opt[:search] = search
|
903
|
-
end
|
904
|
-
|
905
|
-
# opt[:query] = opt[:search] if opt[:search] && !opt[:query]
|
906
|
-
opt[:query] = "!#{opt[:query]}" if opt[:query] && opt[:not]
|
907
|
-
opt[:multiple] = true
|
908
|
-
opt[:show_if_single] = true
|
909
|
-
filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) {
|
910
|
-
|k, hsh| hsh[k] = opt[k]
|
911
|
-
}
|
912
|
-
items = filter_items(Items.new, opt: filter_options)
|
913
|
-
|
914
|
-
menu_options = %i[search query exact multiple show_if_single menu sort case].each_with_object({}) {
|
915
|
-
|k, hsh| hsh[k] = opt[k]
|
916
|
-
}
|
917
|
-
|
918
|
-
selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **menu_options)
|
919
|
-
|
920
|
-
raise NoResults, 'no items selected' if selection.nil? || selection.empty?
|
921
|
-
|
922
|
-
act_on(selection, opt)
|
85
|
+
def views
|
86
|
+
Doing.setting('views') ? Doing.setting('views').keys : []
|
923
87
|
end
|
924
88
|
|
925
89
|
##
|
926
|
-
##
|
927
|
-
## no valid action is included in the opt
|
928
|
-
## hash and the terminal is a TTY, a menu
|
929
|
-
## will be presented
|
930
|
-
##
|
931
|
-
## @param items [Array] Array of Items to affect
|
932
|
-
## @param opt [Hash] Options and actions to perform
|
90
|
+
## Gets a view from configuration
|
933
91
|
##
|
934
|
-
## @
|
935
|
-
## @option opt [Boolean] :delete
|
936
|
-
## @option opt [String] :tag
|
937
|
-
## @option opt [Boolean] :flag
|
938
|
-
## @option opt [Boolean] :finish
|
939
|
-
## @option opt [Boolean] :cancel
|
940
|
-
## @option opt [Boolean] :archive
|
941
|
-
## @option opt [String] :output
|
942
|
-
## @option opt [String] :save_to
|
943
|
-
## @option opt [Boolean] :again
|
944
|
-
## @option opt [Boolean] :resume
|
92
|
+
## @param title [String] The title of the view to retrieve
|
945
93
|
##
|
946
|
-
def
|
947
|
-
|
948
|
-
actions = %i[editor delete tag flag finish cancel archive output save_to again resume]
|
949
|
-
has_action = false
|
950
|
-
single = items.count == 1
|
951
|
-
|
952
|
-
actions.each do |a|
|
953
|
-
if opt[a]
|
954
|
-
has_action = true
|
955
|
-
break
|
956
|
-
end
|
957
|
-
end
|
958
|
-
|
959
|
-
unless has_action
|
960
|
-
actions = [
|
961
|
-
'add tag',
|
962
|
-
'remove tag',
|
963
|
-
'autotag',
|
964
|
-
'cancel',
|
965
|
-
'delete',
|
966
|
-
'finish',
|
967
|
-
'flag',
|
968
|
-
'archive',
|
969
|
-
'move',
|
970
|
-
'edit',
|
971
|
-
'output formatted'
|
972
|
-
]
|
973
|
-
|
974
|
-
actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
|
975
|
-
|
976
|
-
choice = Prompt.choose_from(actions,
|
977
|
-
prompt: 'What do you want to do with the selected items? > ',
|
978
|
-
multiple: true,
|
979
|
-
sorted: false,
|
980
|
-
fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
|
981
|
-
return unless choice
|
982
|
-
|
983
|
-
to_do = choice.strip.split(/\n/)
|
984
|
-
to_do.each do |action|
|
985
|
-
case action
|
986
|
-
when /resume/
|
987
|
-
opt[:resume] = true
|
988
|
-
when /reset/
|
989
|
-
opt[:reset] = true
|
990
|
-
when /autotag/
|
991
|
-
opt[:autotag] = true
|
992
|
-
when /(add|remove) tag/
|
993
|
-
type = action =~ /^add/ ? 'add' : 'remove'
|
994
|
-
raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
|
995
|
-
|
996
|
-
tags = type == 'add' ? all_tags(@content) : all_tags(items)
|
997
|
-
|
998
|
-
puts "#{yellow}Separate multiple tags with spaces, hit tab to complete known tags#{type == 'add' ? ', include values with tag(value)' : ''}"
|
999
|
-
puts "#{boldgreen}Available tags: #{boldwhite}#{tags.sort.map(&:add_at).join(', ')}" if type == 'remove'
|
1000
|
-
tag = Prompt.read_line(prompt: "Tags to #{type}", completions: tags)
|
1001
|
-
|
1002
|
-
# print "#{yellow("Tag to #{type}: ")}#{reset}"
|
1003
|
-
# tag = $stdin.gets
|
1004
|
-
next if tag =~ /^ *$/
|
1005
|
-
|
1006
|
-
opt[:tag] = tag.strip.sub(/^@/, '')
|
1007
|
-
opt[:remove] = true if type == 'remove'
|
1008
|
-
when /output formatted/
|
1009
|
-
plugins = Plugins.available_plugins(type: :export).sort
|
1010
|
-
output_format = Prompt.choose_from(plugins,
|
1011
|
-
prompt: 'Which output format? > ',
|
1012
|
-
fzf_args: [
|
1013
|
-
"--height=#{plugins.count + 3}",
|
1014
|
-
'--tac',
|
1015
|
-
'--no-sort',
|
1016
|
-
'--info=hidden'
|
1017
|
-
])
|
1018
|
-
next if output_format =~ /^ *$/
|
1019
|
-
|
1020
|
-
raise UserCancelled unless output_format
|
1021
|
-
|
1022
|
-
opt[:output] = output_format.strip
|
1023
|
-
res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
|
1024
|
-
if res
|
1025
|
-
# print "#{yellow('File path/name: ')}#{reset}"
|
1026
|
-
# filename = $stdin.gets.strip
|
1027
|
-
filename = Prompt.read_line(prompt: 'File path/name')
|
1028
|
-
next if filename.empty?
|
1029
|
-
|
1030
|
-
opt[:save_to] = filename
|
1031
|
-
end
|
1032
|
-
when /archive/
|
1033
|
-
opt[:archive] = true
|
1034
|
-
when /delete/
|
1035
|
-
opt[:delete] = true
|
1036
|
-
when /edit/
|
1037
|
-
opt[:editor] = true
|
1038
|
-
when /finish/
|
1039
|
-
opt[:finish] = true
|
1040
|
-
when /cancel/
|
1041
|
-
opt[:cancel] = true
|
1042
|
-
when /move/
|
1043
|
-
section = choose_section.strip
|
1044
|
-
opt[:move] = section.strip unless section =~ /^ *$/
|
1045
|
-
when /flag/
|
1046
|
-
opt[:flag] = true
|
1047
|
-
end
|
1048
|
-
end
|
1049
|
-
end
|
1050
|
-
|
1051
|
-
if opt[:resume] || opt[:reset]
|
1052
|
-
raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
|
1053
|
-
|
1054
|
-
item = items[0]
|
1055
|
-
if opt[:resume] && !opt[:reset]
|
1056
|
-
repeat_item(item, { editor: opt[:editor] }) # hooked
|
1057
|
-
elsif opt[:reset]
|
1058
|
-
res = Prompt.enter_text('Start date (blank for current time)', default_response: '')
|
1059
|
-
if res =~ /^ *$/
|
1060
|
-
date = Time.now
|
1061
|
-
else
|
1062
|
-
date = res.chronify(guess: :begin)
|
1063
|
-
end
|
1064
|
-
|
1065
|
-
res = if item.tags?('done', :and) && !opt[:resume]
|
1066
|
-
opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
|
1067
|
-
else
|
1068
|
-
opt[:resume]
|
1069
|
-
end
|
1070
|
-
old_item = item.clone
|
1071
|
-
new_entry = reset_item(item, date: date, resume: res)
|
1072
|
-
@content.update_item(item, new_entry)
|
1073
|
-
Hooks.trigger :post_entry_updated, self, new_entry, old_item
|
1074
|
-
end
|
1075
|
-
write(@doing_file)
|
1076
|
-
|
1077
|
-
return
|
1078
|
-
end
|
1079
|
-
|
1080
|
-
if opt[:delete]
|
1081
|
-
delete_items(items, force: opt[:force]) # hooked
|
1082
|
-
return
|
1083
|
-
end
|
1084
|
-
|
1085
|
-
if opt[:flag]
|
1086
|
-
tag = Doing.setting('marker_tag', 'flagged')
|
1087
|
-
items.map! do |i|
|
1088
|
-
old_item = i.clone
|
1089
|
-
i.tag(tag, date: false, remove: opt[:remove], single: single)
|
1090
|
-
Hooks.trigger :post_entry_updated, self, i, old_item
|
1091
|
-
end
|
1092
|
-
end
|
1093
|
-
|
1094
|
-
if opt[:finish] || opt[:cancel]
|
1095
|
-
tag = 'done'
|
1096
|
-
items.map! do |i|
|
1097
|
-
if i.should_finish?
|
1098
|
-
old_item = i.clone
|
1099
|
-
should_date = !opt[:cancel] && i.should_time?
|
1100
|
-
i.tag(tag, date: should_date, remove: opt[:remove], single: single)
|
1101
|
-
Hooks.trigger :post_entry_updated, self, i, old_item
|
1102
|
-
end
|
1103
|
-
end
|
1104
|
-
end
|
1105
|
-
|
1106
|
-
if opt[:autotag]
|
1107
|
-
items.map! do |i|
|
1108
|
-
new_title = autotag(i.title)
|
1109
|
-
if new_title == i.title
|
1110
|
-
logger.count(:skipped, level: :debug, message: '%count unchaged %items')
|
1111
|
-
# logger.debug('Autotag:', 'No changes')
|
1112
|
-
else
|
1113
|
-
logger.count(:added_tags)
|
1114
|
-
logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
|
1115
|
-
old_item = i.clone
|
1116
|
-
i.title = new_title
|
1117
|
-
Hooks.trigger :post_entry_updated, self, i, old_item
|
1118
|
-
end
|
1119
|
-
end
|
1120
|
-
end
|
1121
|
-
|
1122
|
-
if opt[:tag]
|
1123
|
-
tag = opt[:tag]
|
1124
|
-
items.map! do |i|
|
1125
|
-
old_item = i.clone
|
1126
|
-
i.tag(tag, date: false, remove: opt[:remove], single: single)
|
1127
|
-
i.expand_date_tags(Doing.setting('date_tags'))
|
1128
|
-
Hooks.trigger :post_entry_updated, self, i, old_item
|
1129
|
-
end
|
1130
|
-
end
|
1131
|
-
|
1132
|
-
if opt[:archive] || opt[:move]
|
1133
|
-
section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
|
1134
|
-
items.map! do |i|
|
1135
|
-
old_item = i.clone
|
1136
|
-
i.move_to(section, label: true)
|
1137
|
-
Hooks.trigger :post_entry_updated, self, i, old_item
|
1138
|
-
end
|
1139
|
-
end
|
1140
|
-
|
1141
|
-
write(@doing_file)
|
1142
|
-
|
1143
|
-
if opt[:editor]
|
1144
|
-
edit_items(items) # hooked
|
1145
|
-
|
1146
|
-
write(@doing_file)
|
1147
|
-
end
|
1148
|
-
|
1149
|
-
return unless opt[:output]
|
1150
|
-
|
1151
|
-
items.each { |i| i.title = "#{i.title} @section(#{i.section})" }
|
1152
|
-
|
1153
|
-
export_items = Items.new
|
1154
|
-
export_items.concat(items)
|
1155
|
-
export_items.add_section(Section.new('Export'), log: false)
|
1156
|
-
options = { section: 'All' }
|
1157
|
-
|
1158
|
-
if opt[:output] =~ /doing/
|
1159
|
-
options[:output] = 'template'
|
1160
|
-
options[:template] = '- %date | %title%note'
|
1161
|
-
else
|
1162
|
-
options[:output] = opt[:output]
|
1163
|
-
options[:template] = opt[:template] || nil
|
1164
|
-
end
|
1165
|
-
|
1166
|
-
output = list_section(options, items: export_items) # hooked
|
1167
|
-
|
1168
|
-
if opt[:save_to]
|
1169
|
-
file = File.expand_path(opt[:save_to])
|
1170
|
-
if File.exist?(file)
|
1171
|
-
# Create a backup copy for the undo command
|
1172
|
-
FileUtils.cp(file, "#{file}~")
|
1173
|
-
end
|
1174
|
-
|
1175
|
-
File.open(file, 'w+') do |f|
|
1176
|
-
f.puts output
|
1177
|
-
end
|
1178
|
-
|
1179
|
-
logger.warn('File written:', file)
|
1180
|
-
else
|
1181
|
-
Doing::Pager.page output
|
1182
|
-
end
|
1183
|
-
end
|
1184
|
-
|
1185
|
-
def verify_duration(date, finish_date, title: nil)
|
1186
|
-
max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
|
1187
|
-
max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
|
1188
|
-
date = date.chronify(guess: :end, context: :today) if finish_date.is_a?(String)
|
1189
|
-
|
1190
|
-
elapsed = finish_date - date
|
1191
|
-
|
1192
|
-
if max_elapsed.positive? && (elapsed > max_elapsed)
|
1193
|
-
puts boldwhite(title) if title
|
1194
|
-
human = elapsed.time_string(format: :natural)
|
1195
|
-
res = Prompt.yn(yellow("Did this entry actually take #{human}"), default_response: true)
|
1196
|
-
unless res
|
1197
|
-
new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
|
1198
|
-
raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed.positive?
|
1199
|
-
|
1200
|
-
finish_date = date + new_elapsed if new_elapsed
|
1201
|
-
end
|
1202
|
-
end
|
94
|
+
def get_view(title)
|
95
|
+
return Doing.setting(['views', title], nil)
|
1203
96
|
|
1204
|
-
|
1205
|
-
end
|
1206
|
-
|
1207
|
-
##
|
1208
|
-
## Tag the last entry or X entries
|
1209
|
-
##
|
1210
|
-
## @param opt [Hash] Additional Options (see
|
1211
|
-
## #filter_items for filtering
|
1212
|
-
## options)
|
1213
|
-
##
|
1214
|
-
## @see #filter_items
|
1215
|
-
##
|
1216
|
-
def tag_last(opt) # hooked
|
1217
|
-
opt ||= {}
|
1218
|
-
opt[:count] ||= 1
|
1219
|
-
opt[:archive] ||= false
|
1220
|
-
opt[:tags] ||= ['done']
|
1221
|
-
opt[:sequential] ||= false
|
1222
|
-
opt[:date] ||= false
|
1223
|
-
opt[:remove] ||= false
|
1224
|
-
opt[:update] ||= false
|
1225
|
-
opt[:autotag] ||= false
|
1226
|
-
opt[:back] ||= false
|
1227
|
-
opt[:unfinished] ||= false
|
1228
|
-
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
1229
|
-
|
1230
|
-
items = filter_items(Items.new, opt: opt)
|
1231
|
-
|
1232
|
-
if opt[:interactive]
|
1233
|
-
items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
|
1234
|
-
header: '',
|
1235
|
-
prompt: 'Select entries to tag > ',
|
1236
|
-
multiple: true,
|
1237
|
-
sort: true,
|
1238
|
-
show_if_single: true)
|
1239
|
-
|
1240
|
-
raise NoResults, 'no items selected' if items.empty?
|
1241
|
-
|
1242
|
-
end
|
1243
|
-
|
1244
|
-
raise NoResults, 'no items matched your search' if items.empty?
|
1245
|
-
|
1246
|
-
if opt[:tags].empty? && !opt[:autotag]
|
1247
|
-
completions = opt[:remove] ? all_tags(items) : all_tags(@content)
|
1248
|
-
if opt[:remove]
|
1249
|
-
puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
|
1250
|
-
else
|
1251
|
-
puts "#{yellow}Use tab to complete known tags"
|
1252
|
-
end
|
1253
|
-
opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
|
1254
|
-
completions: completions,
|
1255
|
-
default_response: '').to_tags
|
1256
|
-
raise UserCancelled, 'No tags provided' if opt[:tags].empty?
|
1257
|
-
end
|
1258
|
-
|
1259
|
-
items.each do |item|
|
1260
|
-
old_item = item.clone
|
1261
|
-
added = []
|
1262
|
-
removed = []
|
1263
|
-
|
1264
|
-
if opt[:autotag]
|
1265
|
-
new_title = autotag(item.title) if Doing.auto_tag
|
1266
|
-
if new_title == item.title
|
1267
|
-
logger.count(:skipped, level: :debug, message: '%count unchaged %items')
|
1268
|
-
# logger.debug('Autotag:', 'No changes')
|
1269
|
-
else
|
1270
|
-
logger.count(:added_tags)
|
1271
|
-
logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
|
1272
|
-
item.title = new_title
|
1273
|
-
end
|
1274
|
-
else
|
1275
|
-
if opt[:sequential]
|
1276
|
-
next_entry = next_item(item)
|
1277
|
-
|
1278
|
-
done_date = if next_entry.nil?
|
1279
|
-
Time.now
|
1280
|
-
else
|
1281
|
-
next_entry.date - 60
|
1282
|
-
end
|
1283
|
-
else
|
1284
|
-
done_date = item.calculate_end_date(opt)
|
1285
|
-
end
|
1286
|
-
|
1287
|
-
opt[:tags].each do |tag|
|
1288
|
-
if tag == 'done' && !item.should_finish?
|
1289
|
-
|
1290
|
-
Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
|
1291
|
-
logger.count(:skipped, level: :debug)
|
1292
|
-
next
|
1293
|
-
end
|
1294
|
-
|
1295
|
-
|
1296
|
-
tag = tag.strip
|
1297
|
-
|
1298
|
-
if tag =~ /^done$/ && opt[:date] && item.should_time?
|
1299
|
-
max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
|
1300
|
-
max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
|
1301
|
-
elapsed = done_date - item.date
|
1302
|
-
|
1303
|
-
if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
|
1304
|
-
puts boldwhite(item.title)
|
1305
|
-
human = elapsed.time_string(format: :natural)
|
1306
|
-
res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
|
1307
|
-
unless res
|
1308
|
-
new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
|
1309
|
-
raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
|
1310
|
-
|
1311
|
-
opt[:took] = new_elapsed
|
1312
|
-
done_date = item.calculate_end_date(opt) if opt[:took]
|
1313
|
-
end
|
1314
|
-
end
|
1315
|
-
end
|
1316
|
-
|
1317
|
-
if opt[:remove] || opt[:rename] || opt[:value]
|
1318
|
-
rename_to = nil
|
1319
|
-
if opt[:value]
|
1320
|
-
rename_to = tag
|
1321
|
-
elsif opt[:rename]
|
1322
|
-
rename_to = tag
|
1323
|
-
tag = opt[:rename]
|
1324
|
-
end
|
1325
|
-
old_title = item.title.dup
|
1326
|
-
force = opt[:value].nil? ? false : true
|
1327
|
-
item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
|
1328
|
-
if old_title != item.title
|
1329
|
-
removed << tag
|
1330
|
-
added << rename_to if rename_to
|
1331
|
-
else
|
1332
|
-
logger.count(:skipped, level: :debug)
|
1333
|
-
end
|
1334
|
-
else
|
1335
|
-
old_title = item.title.dup
|
1336
|
-
should_date = opt[:date] && item.should_time?
|
1337
|
-
item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
|
1338
|
-
item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
|
1339
|
-
added << tag if old_title != item.title
|
1340
|
-
end
|
1341
|
-
end
|
1342
|
-
end
|
1343
|
-
|
1344
|
-
logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
|
1345
|
-
|
1346
|
-
item.note.add(opt[:note]) if opt[:note]
|
1347
|
-
|
1348
|
-
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
1349
|
-
item.move_to('Archive', label: true)
|
1350
|
-
elsif opt[:archive] && opt[:count].zero?
|
1351
|
-
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
1352
|
-
end
|
1353
|
-
|
1354
|
-
item.expand_date_tags(Doing.setting('date_tags'))
|
1355
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
1356
|
-
end
|
1357
|
-
|
1358
|
-
write(@doing_file)
|
1359
|
-
end
|
1360
|
-
|
1361
|
-
##
|
1362
|
-
## Get next item in the index
|
1363
|
-
##
|
1364
|
-
## @param item [Item] target item
|
1365
|
-
## @param options [Hash] additional options
|
1366
|
-
## @see #filter_items
|
1367
|
-
##
|
1368
|
-
## @return [Item] the next chronological item in the index
|
1369
|
-
##
|
1370
|
-
def next_item(item, options = {})
|
1371
|
-
options ||= {}
|
1372
|
-
items = filter_items(Items.new, opt: options)
|
1373
|
-
|
1374
|
-
idx = items.index(item)
|
1375
|
-
|
1376
|
-
idx.positive? ? items[idx - 1] : nil
|
1377
|
-
end
|
1378
|
-
|
1379
|
-
##
|
1380
|
-
## Edit the last entry
|
1381
|
-
##
|
1382
|
-
## @param section [String] The section, default "All"
|
1383
|
-
##
|
1384
|
-
def edit_last(section: 'All', options: {})
|
1385
|
-
options[:section] = guess_section(section)
|
1386
|
-
|
1387
|
-
item = last_entry(options)
|
1388
|
-
|
1389
|
-
if item.nil?
|
1390
|
-
logger.debug('Skipped:', 'No entries found')
|
1391
|
-
return
|
1392
|
-
end
|
1393
|
-
|
1394
|
-
old_item = item.clone
|
1395
|
-
content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
|
1396
|
-
content << item.note.strip_lines.join("\n") unless item.note.empty?
|
1397
|
-
new_item = fork_editor(content.join("\n"))
|
1398
|
-
date, title, note = format_input(new_item)
|
1399
|
-
date ||= item.date
|
1400
|
-
|
1401
|
-
if title.nil? || title.empty?
|
1402
|
-
logger.debug('Skipped:', 'No content provided')
|
1403
|
-
elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
|
1404
|
-
logger.debug('Skipped:', 'No change in content')
|
1405
|
-
else
|
1406
|
-
item.date = date unless date.nil?
|
1407
|
-
item.title = title
|
1408
|
-
item.note.add(note, replace: true)
|
1409
|
-
logger.info('Edited:', item.title)
|
1410
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
1411
|
-
|
1412
|
-
write(@doing_file)
|
1413
|
-
end
|
1414
|
-
end
|
1415
|
-
|
1416
|
-
##
|
1417
|
-
## Accepts one tag and the raw text of a new item if the
|
1418
|
-
## passed tag is on any item, it's replaced with @done.
|
1419
|
-
## if new_item is not nil, it's tagged with the passed
|
1420
|
-
## tag and inserted. This is for use where only one
|
1421
|
-
## instance of a given tag should exist (@meanwhile)
|
1422
|
-
##
|
1423
|
-
## @param target_tag [String] Tag to replace
|
1424
|
-
## @param opt [Hash] Additional Options
|
1425
|
-
##
|
1426
|
-
## @option opt :section [String] target section
|
1427
|
-
## @option opt :archive [Boolean] archive old item
|
1428
|
-
## @option opt :back [Date] backdate new item
|
1429
|
-
## @option opt :new_item [String] content to use for new item
|
1430
|
-
## @option opt :note [Array] note content for new item
|
1431
|
-
def stop_start(target_tag, opt)
|
1432
|
-
opt ||= {}
|
1433
|
-
tag = target_tag.dup
|
1434
|
-
opt[:section] ||= Doing.setting('current_section')
|
1435
|
-
opt[:archive] ||= false
|
1436
|
-
opt[:back] ||= Time.now
|
1437
|
-
opt[:new_item] ||= false
|
1438
|
-
opt[:note] ||= false
|
1439
|
-
|
1440
|
-
opt[:section] = guess_section(opt[:section])
|
1441
|
-
|
1442
|
-
tag.sub!(/^@/, '')
|
1443
|
-
|
1444
|
-
found_items = 0
|
1445
|
-
|
1446
|
-
@content.each_with_index do |item, i|
|
1447
|
-
old_item = i.clone
|
1448
|
-
next unless item.section == opt[:section] || opt[:section] =~ /all/i
|
1449
|
-
|
1450
|
-
next unless item.title =~ /@#{tag}/
|
1451
|
-
|
1452
|
-
item.title.add_tags!([tag, 'done'], remove: true)
|
1453
|
-
item.tag('done', value: opt[:back].strftime('%F %R'))
|
1454
|
-
|
1455
|
-
found_items += 1
|
1456
|
-
|
1457
|
-
if opt[:archive] && opt[:section] != 'Archive'
|
1458
|
-
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
1459
|
-
item.move_to('Archive', label: false, log: false)
|
1460
|
-
logger.count(:completed_archived)
|
1461
|
-
logger.info('Completed/archived:', item.title)
|
1462
|
-
else
|
1463
|
-
logger.count(:completed)
|
1464
|
-
logger.info('Completed:', item.title)
|
1465
|
-
end
|
1466
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
1467
|
-
end
|
1468
|
-
|
1469
|
-
|
1470
|
-
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
1471
|
-
|
1472
|
-
if opt[:new_item]
|
1473
|
-
date, title, note = format_input(opt[:new_item])
|
1474
|
-
opt[:back] = date unless date.nil?
|
1475
|
-
note.add(opt[:note]) if opt[:note]
|
1476
|
-
title.tag!(tag)
|
1477
|
-
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
1478
|
-
end
|
1479
|
-
|
1480
|
-
write(@doing_file)
|
1481
|
-
end
|
1482
|
-
|
1483
|
-
##
|
1484
|
-
## Write content to file or STDOUT
|
1485
|
-
##
|
1486
|
-
## @param file [String] The filepath to write to
|
1487
|
-
##
|
1488
|
-
def write(file = nil, backup: true)
|
1489
|
-
Hooks.trigger :pre_write, self, file
|
1490
|
-
output = combined_content
|
1491
|
-
if file.nil?
|
1492
|
-
$stdout.puts output
|
1493
|
-
else
|
1494
|
-
Util.write_to_file(file, output, backup: backup)
|
1495
|
-
run_after if Doing.setting('run_after')
|
1496
|
-
end
|
1497
|
-
end
|
1498
|
-
|
1499
|
-
##
|
1500
|
-
## Rename doing file with date and start fresh one
|
1501
|
-
##
|
1502
|
-
def rotate(opt)
|
1503
|
-
opt ||= {}
|
1504
|
-
keep = opt[:keep] || 0
|
1505
|
-
tags = []
|
1506
|
-
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
1507
|
-
bool = opt[:bool] || :and
|
1508
|
-
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
1509
|
-
|
1510
|
-
section = guess_section(sect)
|
1511
|
-
|
1512
|
-
section_items = @content.in_section(section)
|
1513
|
-
max = section_items.count - keep.to_i
|
1514
|
-
|
1515
|
-
counter = 0
|
1516
|
-
new_content = Items.new
|
1517
|
-
|
1518
|
-
section_items.each do |item|
|
1519
|
-
break if counter >= max
|
1520
|
-
if opt[:before]
|
1521
|
-
time_string = opt[:before]
|
1522
|
-
cutoff = time_string.chronify(guess: :begin)
|
1523
|
-
end
|
1524
|
-
|
1525
|
-
unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
|
1526
|
-
new_item = @content.delete(item)
|
1527
|
-
Hooks.trigger :post_entry_removed, self, item.clone
|
1528
|
-
raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
|
1529
|
-
|
1530
|
-
new_content.add_section(new_item.section, log: false)
|
1531
|
-
new_content.push(new_item)
|
1532
|
-
counter += 1
|
1533
|
-
end
|
1534
|
-
end
|
1535
|
-
|
1536
|
-
if counter.positive?
|
1537
|
-
logger.count(:rotated,
|
1538
|
-
level: :info,
|
1539
|
-
count: counter,
|
1540
|
-
message: "Rotated %count %items")
|
1541
|
-
else
|
1542
|
-
logger.info('Skipped:', 'No items were rotated')
|
1543
|
-
end
|
1544
|
-
|
1545
|
-
write(@doing_file)
|
1546
|
-
|
1547
|
-
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
1548
|
-
if File.exist?(file)
|
1549
|
-
init_doing_file(file)
|
1550
|
-
@content.concat(new_content).uniq!
|
1551
|
-
logger.warn('File update:', "added entries to existing file: #{file}")
|
1552
|
-
else
|
1553
|
-
@content = new_content
|
1554
|
-
logger.warn('File update:', "created new file: #{file}")
|
1555
|
-
end
|
1556
|
-
|
1557
|
-
write(file, backup: false)
|
1558
|
-
end
|
1559
|
-
|
1560
|
-
##
|
1561
|
-
## Generate a menu of sections and allow user selection
|
1562
|
-
##
|
1563
|
-
## @return [String] The selected section name
|
1564
|
-
##
|
1565
|
-
def choose_section(include_all: false)
|
1566
|
-
options = @content.section_titles.sort
|
1567
|
-
options.unshift('All') if include_all
|
1568
|
-
choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
1569
|
-
choice ? choice.strip : choice
|
1570
|
-
end
|
1571
|
-
|
1572
|
-
##
|
1573
|
-
## Generate a menu of tags and allow user selection
|
1574
|
-
##
|
1575
|
-
## @return [String] The selected tag name
|
1576
|
-
##
|
1577
|
-
def choose_tag(section = 'All', items: nil, include_all: false)
|
1578
|
-
items ||= @content.in_section(section)
|
1579
|
-
tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" }
|
1580
|
-
tags.unshift('No tag filter') if include_all
|
1581
|
-
choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%'])
|
1582
|
-
choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice
|
1583
|
-
end
|
1584
|
-
|
1585
|
-
##
|
1586
|
-
## Generate a menu of sections and tags and allow user selection
|
1587
|
-
##
|
1588
|
-
## @return [String] The selected section or tag name
|
1589
|
-
##
|
1590
|
-
def choose_section_tag
|
1591
|
-
options = @content.section_titles.sort
|
1592
|
-
options.concat(@content.all_tags.sort.map { |t| "@#{t}" })
|
1593
|
-
choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%'])
|
1594
|
-
choice ? choice.strip : choice
|
1595
|
-
end
|
1596
|
-
|
1597
|
-
##
|
1598
|
-
## List available views
|
1599
|
-
##
|
1600
|
-
## @return [Array] View names
|
1601
|
-
##
|
1602
|
-
def views
|
1603
|
-
Doing.setting('views') ? Doing.setting('views').keys : []
|
1604
|
-
end
|
1605
|
-
|
1606
|
-
##
|
1607
|
-
## Generate a menu of views and allow user selection
|
1608
|
-
##
|
1609
|
-
## @return [String] The selected view name
|
1610
|
-
##
|
1611
|
-
def choose_view
|
1612
|
-
choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
1613
|
-
choice ? choice.strip : choice
|
1614
|
-
end
|
1615
|
-
|
1616
|
-
##
|
1617
|
-
## Gets a view from configuration
|
1618
|
-
##
|
1619
|
-
## @param title [String] The title of the view to retrieve
|
1620
|
-
##
|
1621
|
-
def get_view(title)
|
1622
|
-
return Doing.setting(['views', title], nil)
|
1623
|
-
|
1624
|
-
false
|
1625
|
-
end
|
1626
|
-
|
1627
|
-
##
|
1628
|
-
## Display contents of a section based on options
|
1629
|
-
##
|
1630
|
-
## @param opt [Hash] Additional Options
|
1631
|
-
##
|
1632
|
-
def list_section(opt, items: Items.new)
|
1633
|
-
logger.benchmark(:list_section, :start)
|
1634
|
-
opt[:config_template] ||= 'default'
|
1635
|
-
|
1636
|
-
tpl_cfg = Doing.setting(['templates', opt[:config_template]])
|
1637
|
-
|
1638
|
-
cfg = if opt[:view_template]
|
1639
|
-
Doing.setting(['views', opt[:view_template]]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true })
|
1640
|
-
else
|
1641
|
-
tpl_cfg
|
1642
|
-
end
|
1643
|
-
|
1644
|
-
cfg.deep_merge({
|
1645
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
1646
|
-
'date_format' => Doing.setting('default_date_format'),
|
1647
|
-
'order' => Doing.setting('order') || :asc,
|
1648
|
-
'tags_color' => Doing.setting('tags_color'),
|
1649
|
-
'duration' => Doing.setting('duration'),
|
1650
|
-
'interval_format' => Doing.setting('interval_format')
|
1651
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
1652
|
-
|
1653
|
-
opt[:duration] ||= cfg['duration'] || false
|
1654
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
1655
|
-
opt[:count] ||= 0
|
1656
|
-
opt[:age] ||= :newest
|
1657
|
-
opt[:age] = opt[:age].normalize_age
|
1658
|
-
opt[:format] ||= cfg['date_format']
|
1659
|
-
opt[:order] ||= cfg['order'] || :asc
|
1660
|
-
opt[:tag_order] ||= :asc
|
1661
|
-
opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil?
|
1662
|
-
opt[:template] ||= cfg['template']
|
1663
|
-
opt[:sort_tags] ||= opt[:tag_sort]
|
1664
|
-
|
1665
|
-
# opt[:highlight] ||= true
|
1666
|
-
title = ''
|
1667
|
-
is_single = true
|
1668
|
-
if opt[:section].nil?
|
1669
|
-
opt[:section] = choose_section
|
1670
|
-
title = opt[:section]
|
1671
|
-
elsif opt[:section].instance_of?(String)
|
1672
|
-
title = if opt[:section] =~ /^all$/i
|
1673
|
-
if opt[:page_title]
|
1674
|
-
opt[:page_title]
|
1675
|
-
elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
|
1676
|
-
opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
|
1677
|
-
else
|
1678
|
-
'doing'
|
1679
|
-
end
|
1680
|
-
else
|
1681
|
-
guess_section(opt[:section])
|
1682
|
-
end
|
1683
|
-
end
|
1684
|
-
|
1685
|
-
items = filter_items(items, opt: opt)
|
1686
|
-
|
1687
|
-
items.reverse! unless opt[:order].normalize_order == :desc
|
1688
|
-
|
1689
|
-
if opt[:delete]
|
1690
|
-
delete_items(items, force: opt[:force])
|
1691
|
-
return
|
1692
|
-
elsif opt[:editor]
|
1693
|
-
edit_items(items)
|
1694
|
-
return
|
1695
|
-
elsif opt[:interactive]
|
1696
|
-
opt[:menu] = !opt[:force]
|
1697
|
-
opt[:query] = '' # opt[:search]
|
1698
|
-
opt[:multiple] = true
|
1699
|
-
selected = Prompt.choose_from_items(items.reverse, include_section: opt[:section] =~ /^all$/i, **opt)
|
1700
|
-
|
1701
|
-
raise NoResults, 'no items selected' if selected.nil? || selected.empty?
|
1702
|
-
|
1703
|
-
act_on(selected, opt)
|
1704
|
-
return
|
1705
|
-
end
|
1706
|
-
|
1707
|
-
opt[:output] ||= 'template'
|
1708
|
-
opt[:wrap_width] ||= Doing.setting('templates.default.wrap_width', 0)
|
1709
|
-
|
1710
|
-
logger.benchmark(:list_section, :finish)
|
1711
|
-
output(items, title, is_single, opt)
|
1712
|
-
end
|
1713
|
-
|
1714
|
-
##
|
1715
|
-
## Move entries from a section to Archive or other specified
|
1716
|
-
## section
|
1717
|
-
##
|
1718
|
-
## @param section [String] The source section
|
1719
|
-
## @param options [Hash] Options
|
1720
|
-
##
|
1721
|
-
def archive(section = Doing.setting('current_section'), options)
|
1722
|
-
options ||= {}
|
1723
|
-
count = options[:keep] || 0
|
1724
|
-
destination = options[:destination] || 'Archive'
|
1725
|
-
tags = options[:tags] || []
|
1726
|
-
bool = options[:bool] || :and
|
1727
|
-
|
1728
|
-
section = choose_section if section.nil? || section =~ /choose/i
|
1729
|
-
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
1730
|
-
section = guess_section(section) unless archive_all
|
1731
|
-
|
1732
|
-
@content.add_section(destination, log: true)
|
1733
|
-
# add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
|
1734
|
-
|
1735
|
-
destination = guess_section(destination)
|
1736
|
-
|
1737
|
-
if @content.section?(destination) && (@content.section?(section) || archive_all)
|
1738
|
-
do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before], after: options[:after], from: options[:from] })
|
1739
|
-
write(doing_file)
|
1740
|
-
else
|
1741
|
-
raise InvalidArgument, 'Either source or destination does not exist'
|
1742
|
-
end
|
1743
|
-
end
|
1744
|
-
|
1745
|
-
##
|
1746
|
-
## Show all entries from the current day
|
1747
|
-
##
|
1748
|
-
## @param times [Boolean] show times
|
1749
|
-
## @param output [String] output format
|
1750
|
-
## @param opt [Hash] Options
|
1751
|
-
##
|
1752
|
-
def today(times = true, output = nil, opt)
|
1753
|
-
opt ||= {}
|
1754
|
-
opt[:totals] ||= false
|
1755
|
-
opt[:sort_tags] ||= false
|
1756
|
-
|
1757
|
-
cfg = Doing.setting('templates').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
1758
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
1759
|
-
'date_format' => Doing.setting('default_date_format'),
|
1760
|
-
'order' => Doing.setting('order') || :asc,
|
1761
|
-
'tags_color' => Doing.setting('tags_color'),
|
1762
|
-
'duration' => Doing.setting('duration'),
|
1763
|
-
'interval_format' => Doing.setting('interval_format')
|
1764
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
1765
|
-
|
1766
|
-
template = opt[:template] || cfg['template']
|
1767
|
-
|
1768
|
-
opt[:duration] ||= cfg['duration'] || false
|
1769
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
1770
|
-
|
1771
|
-
options = {
|
1772
|
-
after: opt[:after],
|
1773
|
-
before: opt[:before],
|
1774
|
-
count: 0,
|
1775
|
-
duration: opt[:duration],
|
1776
|
-
from: opt[:from],
|
1777
|
-
format: cfg['date_format'],
|
1778
|
-
interval_format: opt[:interval_format],
|
1779
|
-
only_timed: opt[:only_timed],
|
1780
|
-
order: cfg['order'] || :asc,
|
1781
|
-
output: output,
|
1782
|
-
section: opt[:section],
|
1783
|
-
sort_tags: opt[:sort_tags],
|
1784
|
-
template: template,
|
1785
|
-
times: times,
|
1786
|
-
today: true,
|
1787
|
-
totals: opt[:totals],
|
1788
|
-
wrap_width: cfg['wrap_width'],
|
1789
|
-
tags_color: cfg['tags_color'],
|
1790
|
-
config_template: opt[:config_template]
|
1791
|
-
}
|
1792
|
-
list_section(options)
|
1793
|
-
end
|
1794
|
-
|
1795
|
-
##
|
1796
|
-
## Display entries within a date range
|
1797
|
-
##
|
1798
|
-
## @param dates [Array] [start, end]
|
1799
|
-
## @param section [String] The section
|
1800
|
-
## @param times (Bool) Show times
|
1801
|
-
## @param output [String] Output format
|
1802
|
-
## @param opt [Hash] Additional Options
|
1803
|
-
##
|
1804
|
-
def list_date(dates, section, times = nil, output = nil, opt)
|
1805
|
-
opt ||= {}
|
1806
|
-
opt[:totals] ||= false
|
1807
|
-
opt[:sort_tags] ||= false
|
1808
|
-
section = guess_section(section)
|
1809
|
-
# :date_filter expects an array with start and end date
|
1810
|
-
dates = dates.split_date_range if dates.instance_of?(String)
|
1811
|
-
|
1812
|
-
opt[:section] = section
|
1813
|
-
opt[:count] = 0
|
1814
|
-
opt[:order] = :asc
|
1815
|
-
opt[:date_filter] = dates
|
1816
|
-
opt[:times] = times
|
1817
|
-
opt[:output] = output
|
1818
|
-
|
1819
|
-
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
1820
|
-
if opt[:from] && opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
|
1821
|
-
opt[:time_filter] = opt[:from]
|
1822
|
-
end
|
1823
|
-
|
1824
|
-
list_section(opt)
|
1825
|
-
end
|
1826
|
-
|
1827
|
-
##
|
1828
|
-
## Show entries from the previous day
|
1829
|
-
##
|
1830
|
-
## @param section [String] The section
|
1831
|
-
## @param times (Bool) Show times
|
1832
|
-
## @param output [String] Output format
|
1833
|
-
## @param opt [Hash] Additional Options
|
1834
|
-
##
|
1835
|
-
def yesterday(section, times = nil, output = nil, opt)
|
1836
|
-
opt ||= {}
|
1837
|
-
opt[:totals] ||= false
|
1838
|
-
opt[:sort_tags] ||= false
|
1839
|
-
opt[:config_template] ||= 'today'
|
1840
|
-
opt[:yesterday] = true
|
1841
|
-
|
1842
|
-
section = guess_section(section)
|
1843
|
-
y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
|
1844
|
-
opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
|
1845
|
-
opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
|
1846
|
-
|
1847
|
-
opt[:output] = output
|
1848
|
-
opt[:section] = section
|
1849
|
-
opt[:times] = times
|
1850
|
-
opt[:count] = 0
|
1851
|
-
|
1852
|
-
list_section(opt)
|
1853
|
-
end
|
1854
|
-
|
1855
|
-
##
|
1856
|
-
## Show recent entries
|
1857
|
-
##
|
1858
|
-
## @param count [Integer] The number to show
|
1859
|
-
## @param section [String] The section to show from, default Currently
|
1860
|
-
## @param opt [Hash] Additional Options
|
1861
|
-
##
|
1862
|
-
def recent(count = 10, section = nil, opt)
|
1863
|
-
opt ||= {}
|
1864
|
-
times = opt[:t] || true
|
1865
|
-
opt[:totals] ||= false
|
1866
|
-
opt[:sort_tags] ||= false
|
1867
|
-
|
1868
|
-
cfg = Doing.setting('templates.recent').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
1869
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
1870
|
-
'date_format' => Doing.setting('default_date_format'),
|
1871
|
-
'order' => Doing.setting('order') || :asc,
|
1872
|
-
'tags_color' => Doing.setting('tags_color'),
|
1873
|
-
'duration' => Doing.setting('duration'),
|
1874
|
-
'interval_format' => Doing.setting('interval_format')
|
1875
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
1876
|
-
opt[:duration] ||= cfg['duration'] || false
|
1877
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
1878
|
-
|
1879
|
-
section ||= Doing.setting('current_section')
|
1880
|
-
section = guess_section(section)
|
1881
|
-
|
1882
|
-
opt[:section] = section
|
1883
|
-
opt[:wrap_width] = cfg['wrap_width']
|
1884
|
-
opt[:count] = count
|
1885
|
-
opt[:format] = cfg['date_format']
|
1886
|
-
opt[:template] = opt[:template] || cfg['template']
|
1887
|
-
opt[:order] = :asc
|
1888
|
-
opt[:times] = times
|
1889
|
-
|
1890
|
-
list_section(opt)
|
1891
|
-
end
|
1892
|
-
|
1893
|
-
##
|
1894
|
-
## Show the last entry
|
1895
|
-
##
|
1896
|
-
## @param times (Bool) Show times
|
1897
|
-
## @param section [String] Section to pull from, default Currently
|
1898
|
-
##
|
1899
|
-
def last(times: true, section: nil, options: {})
|
1900
|
-
section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
|
1901
|
-
cfg = Doing.setting(['templates', options[:config_template]]).deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
1902
|
-
'wrap_width' => Doing.setting('wrap_width', 0),
|
1903
|
-
'date_format' => Doing.setting('default_date_format'),
|
1904
|
-
'order' => Doing.setting('order', :asc),
|
1905
|
-
'tags_color' => Doing.setting('tags_color'),
|
1906
|
-
'duration' => Doing.setting('duration'),
|
1907
|
-
'interval_format' => Doing.setting('interval_format')
|
1908
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
1909
|
-
options[:duration] ||= cfg['duration'] || false
|
1910
|
-
options[:interval_format] ||= cfg['interval_format'] || 'text'
|
1911
|
-
|
1912
|
-
opts = {
|
1913
|
-
case: options[:case],
|
1914
|
-
config_template: 'last',
|
1915
|
-
count: 1,
|
1916
|
-
delete: options[:delete],
|
1917
|
-
duration: options[:duration],
|
1918
|
-
format: cfg['date_format'],
|
1919
|
-
interval_format: options[:interval_format],
|
1920
|
-
not: options[:negate],
|
1921
|
-
section: section,
|
1922
|
-
template: options[:template] || cfg['template'],
|
1923
|
-
times: times,
|
1924
|
-
val: options[:val],
|
1925
|
-
wrap_width: cfg['wrap_width']
|
1926
|
-
}
|
1927
|
-
|
1928
|
-
if options[:tag]
|
1929
|
-
opts[:tag_filter] = {
|
1930
|
-
'tags' => options[:tag],
|
1931
|
-
'bool' => options[:tag_bool]
|
1932
|
-
}
|
1933
|
-
end
|
1934
|
-
|
1935
|
-
opts[:search] = options[:search] if options[:search]
|
1936
|
-
|
1937
|
-
list_section(opts)
|
1938
|
-
end
|
1939
|
-
|
1940
|
-
##
|
1941
|
-
## Uses 'autotag' configuration to turn keywords into tags for time tracking.
|
1942
|
-
## Does not repeat tags in a title, and only converts the first instance of an
|
1943
|
-
## untagged keyword
|
1944
|
-
##
|
1945
|
-
## @param string [String] The text to tag
|
1946
|
-
##
|
1947
|
-
def autotag(string)
|
1948
|
-
return unless string
|
1949
|
-
return string unless Doing.auto_tag
|
1950
|
-
|
1951
|
-
original = string.dup
|
1952
|
-
text = string.dup
|
1953
|
-
|
1954
|
-
current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
|
1955
|
-
tagged = {
|
1956
|
-
whitelisted: [],
|
1957
|
-
synonyms: [],
|
1958
|
-
transformed: [],
|
1959
|
-
replaced: []
|
1960
|
-
}
|
1961
|
-
|
1962
|
-
Doing.setting('autotag.whitelist').each do |tag|
|
1963
|
-
next if text =~ /@#{tag}\b/i
|
1964
|
-
|
1965
|
-
text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
|
1966
|
-
m.downcase! unless tag =~ /[A-Z]/
|
1967
|
-
tagged[:whitelisted].push(m)
|
1968
|
-
"@#{m}"
|
1969
|
-
end
|
1970
|
-
end
|
1971
|
-
|
1972
|
-
Doing.setting('autotag.synonyms').each do |tag, v|
|
1973
|
-
v.each do |word|
|
1974
|
-
word = word.wildcard_to_rx
|
1975
|
-
next unless text =~ /\b#{word}\b/i
|
1976
|
-
|
1977
|
-
unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
|
1978
|
-
tagged[:synonyms].push(tag)
|
1979
|
-
tagged[:synonyms] = tagged[:synonyms].uniq
|
1980
|
-
end
|
1981
|
-
end
|
1982
|
-
end
|
1983
|
-
|
1984
|
-
if Doing.setting('autotag.transform')
|
1985
|
-
Doing.setting('autotag.transform').each do |tag|
|
1986
|
-
next unless tag =~ /\S+:\S+/
|
1987
|
-
|
1988
|
-
if tag =~ /::/
|
1989
|
-
rx, r = tag.split(/::/)
|
1990
|
-
else
|
1991
|
-
rx, r = tag.split(/:/)
|
1992
|
-
end
|
1993
|
-
|
1994
|
-
flag_rx = %r{/([r]+)$}
|
1995
|
-
if r =~ flag_rx
|
1996
|
-
flags = r.match(flag_rx)[1].split(//)
|
1997
|
-
r.sub!(flag_rx, '')
|
1998
|
-
end
|
1999
|
-
r.gsub!(/\$/, '\\')
|
2000
|
-
rx.sub!(/^@?/, '@')
|
2001
|
-
regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
|
2002
|
-
|
2003
|
-
text.sub!(regex) do
|
2004
|
-
m = Regexp.last_match
|
2005
|
-
new_tag = r
|
2006
|
-
|
2007
|
-
m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
|
2008
|
-
new_tag.gsub!("\\#{idx + 1}", v)
|
2009
|
-
end
|
2010
|
-
# Replace original tag if /r
|
2011
|
-
if flags&.include?('r')
|
2012
|
-
tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
2013
|
-
new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
|
2014
|
-
else
|
2015
|
-
tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
2016
|
-
tagged[:transformed] = tagged[:transformed].uniq
|
2017
|
-
m[0]
|
2018
|
-
end
|
2019
|
-
end
|
2020
|
-
end
|
2021
|
-
end
|
2022
|
-
|
2023
|
-
logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
|
2024
|
-
logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
|
2025
|
-
logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
|
2026
|
-
logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
|
2027
|
-
|
2028
|
-
tail_tags = tagged[:synonyms].concat(tagged[:transformed])
|
2029
|
-
tail_tags.sort!
|
2030
|
-
tail_tags.uniq!
|
2031
|
-
|
2032
|
-
text.add_tags!(tail_tags) unless tail_tags.empty?
|
2033
|
-
|
2034
|
-
if text == original
|
2035
|
-
logger.debug('Autotag:', "no change to \"#{text.strip}\"")
|
2036
|
-
else
|
2037
|
-
new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
|
2038
|
-
logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
|
2039
|
-
logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
|
2040
|
-
end
|
2041
|
-
|
2042
|
-
text.dedup_tags
|
2043
|
-
end
|
2044
|
-
|
2045
|
-
##
|
2046
|
-
## Get total elapsed time for all tags in
|
2047
|
-
## selection
|
2048
|
-
##
|
2049
|
-
## @param format [String] return format (html,
|
2050
|
-
## json, or text)
|
2051
|
-
## @param sort_by [Symbol] Sort by :name or :time
|
2052
|
-
## @param sort_order [Symbol] The sort order (:asc or :desc)
|
2053
|
-
##
|
2054
|
-
def tag_times(format: :text, sort_by: :time, sort_order: :asc)
|
2055
|
-
return '' if @timers.empty?
|
2056
|
-
|
2057
|
-
max = @timers.keys.sort_by(&:length).reverse[0].length + 1
|
2058
|
-
|
2059
|
-
total = @timers.delete('All')
|
2060
|
-
|
2061
|
-
tags_data = @timers.delete_if { |_k, v| v.zero? }
|
2062
|
-
sorted_tags_data = if sort_by.normalize_tag_sort == :name
|
2063
|
-
tags_data.sort_by { |k, _v| k }
|
2064
|
-
else
|
2065
|
-
tags_data.sort_by { |_k, v| v }
|
2066
|
-
end
|
2067
|
-
|
2068
|
-
sorted_tags_data.reverse! if sort_order.normalize_order == :asc
|
2069
|
-
case format
|
2070
|
-
when :html
|
2071
|
-
|
2072
|
-
output = <<EOHEAD
|
2073
|
-
<table>
|
2074
|
-
<caption id="tagtotals">Tag Totals</caption>
|
2075
|
-
<colgroup>
|
2076
|
-
<col style="text-align:left;"/>
|
2077
|
-
<col style="text-align:left;"/>
|
2078
|
-
</colgroup>
|
2079
|
-
<thead>
|
2080
|
-
<tr>
|
2081
|
-
<th style="text-align:left;">project</th>
|
2082
|
-
<th style="text-align:left;">time</th>
|
2083
|
-
</tr>
|
2084
|
-
</thead>
|
2085
|
-
<tbody>
|
2086
|
-
EOHEAD
|
2087
|
-
sorted_tags_data.reverse.each do |k, v|
|
2088
|
-
if v.positive?
|
2089
|
-
output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
|
2090
|
-
end
|
2091
|
-
end
|
2092
|
-
tail = <<EOTAIL
|
2093
|
-
<tr>
|
2094
|
-
<td style="text-align:left;" colspan="2"></td>
|
2095
|
-
</tr>
|
2096
|
-
</tbody>
|
2097
|
-
<tfoot>
|
2098
|
-
<tr>
|
2099
|
-
<td style="text-align:left;"><strong>Total</strong></td>
|
2100
|
-
<td style="text-align:left;">#{total.time_string(format: :clock)}</td>
|
2101
|
-
</tr>
|
2102
|
-
</tfoot>
|
2103
|
-
</table>
|
2104
|
-
EOTAIL
|
2105
|
-
output + tail
|
2106
|
-
when :markdown
|
2107
|
-
pad = sorted_tags_data.map { |k, _| k }.group_by(&:size).max.last[0].length
|
2108
|
-
pad = 7 if pad < 7
|
2109
|
-
output = <<~EOHEADER
|
2110
|
-
| #{' ' * (pad - 7)}project | time |
|
2111
|
-
| #{'-' * (pad - 1)}: | :------- |
|
2112
|
-
EOHEADER
|
2113
|
-
sorted_tags_data.reverse.each do |k, v|
|
2114
|
-
if v.positive?
|
2115
|
-
output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
|
2116
|
-
end
|
2117
|
-
end
|
2118
|
-
tail = '[Tag Totals]'
|
2119
|
-
output + tail
|
2120
|
-
when :json
|
2121
|
-
output = []
|
2122
|
-
sorted_tags_data.reverse.each do |k, v|
|
2123
|
-
output << {
|
2124
|
-
'tag' => k,
|
2125
|
-
'seconds' => v,
|
2126
|
-
'formatted' => v.time_string(format: :clock)
|
2127
|
-
}
|
2128
|
-
end
|
2129
|
-
output
|
2130
|
-
when :human
|
2131
|
-
output = []
|
2132
|
-
sorted_tags_data.reverse.each do |k, v|
|
2133
|
-
spacer = ''
|
2134
|
-
(max - k.length).times do
|
2135
|
-
spacer += ' '
|
2136
|
-
end
|
2137
|
-
output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
|
2138
|
-
end
|
2139
|
-
|
2140
|
-
header = '┏━━ Tag Totals '
|
2141
|
-
(max - 2).times { header += '━' }
|
2142
|
-
header += '┓'
|
2143
|
-
footer = '┗'
|
2144
|
-
(max + 12).times { footer += '━' }
|
2145
|
-
footer += '┛'
|
2146
|
-
divider = '┣'
|
2147
|
-
(max + 12).times { divider += '━' }
|
2148
|
-
divider += '┫'
|
2149
|
-
output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
|
2150
|
-
output += "\n#{divider}"
|
2151
|
-
spacer = ''
|
2152
|
-
(max - 6).times do
|
2153
|
-
spacer += ' '
|
2154
|
-
end
|
2155
|
-
total_time = total.time_string(format: :hm)
|
2156
|
-
total = "┃ #{spacer}total: "
|
2157
|
-
total += total_time
|
2158
|
-
total += ' ┃'
|
2159
|
-
output += "\n#{total}"
|
2160
|
-
output += "\n#{footer}"
|
2161
|
-
output
|
2162
|
-
else
|
2163
|
-
output = []
|
2164
|
-
sorted_tags_data.reverse.each do |k, v|
|
2165
|
-
spacer = ''
|
2166
|
-
(max - k.length).times do
|
2167
|
-
spacer += ' '
|
2168
|
-
end
|
2169
|
-
output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
|
2170
|
-
end
|
2171
|
-
|
2172
|
-
output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
|
2173
|
-
output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
|
2174
|
-
output
|
2175
|
-
end
|
2176
|
-
end
|
2177
|
-
|
2178
|
-
##
|
2179
|
-
## Gets the interval between entry's start
|
2180
|
-
## date and @done date
|
2181
|
-
##
|
2182
|
-
## @param item [Item] The entry
|
2183
|
-
## @param formatted [Boolean] Return human readable
|
2184
|
-
## time (default seconds)
|
2185
|
-
## @param record [Boolean] Add the interval to the
|
2186
|
-
## total for each tag
|
2187
|
-
##
|
2188
|
-
## @return Interval in seconds, or [d, h, m] array if
|
2189
|
-
## formatted is true. False if no end date or
|
2190
|
-
## interval is 0
|
2191
|
-
##
|
2192
|
-
def get_interval(item, formatted: true, record: true)
|
2193
|
-
if item.interval
|
2194
|
-
seconds = item.interval
|
2195
|
-
record_tag_times(item, seconds) if record
|
2196
|
-
return seconds.positive? ? seconds : false unless formatted
|
2197
|
-
|
2198
|
-
return seconds.positive? ? seconds.time_string(format: :clock) : false
|
2199
|
-
end
|
2200
|
-
|
2201
|
-
false
|
2202
|
-
end
|
2203
|
-
|
2204
|
-
##
|
2205
|
-
## Load configuration files and updated the @settings
|
2206
|
-
## attribute with a Doing::Configuration object
|
2207
|
-
##
|
2208
|
-
## @param filename [String] (optional) path to
|
2209
|
-
## alternative config file
|
2210
|
-
##
|
2211
|
-
def configure(filename = nil)
|
2212
|
-
logger.benchmark(:configure, :start)
|
2213
|
-
|
2214
|
-
if filename
|
2215
|
-
Doing.config_with(filename, { ignore_local: true })
|
2216
|
-
elsif ENV['DOING_CONFIG']
|
2217
|
-
Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true })
|
2218
|
-
end
|
2219
|
-
|
2220
|
-
logger.benchmark(:configure, :finish)
|
2221
|
-
|
2222
|
-
Doing.set('backup_dir', ENV['DOING_BACKUP_DIR']) if ENV['DOING_BACKUP_DIR']
|
2223
|
-
end
|
2224
|
-
|
2225
|
-
def get_diff(filename = nil)
|
2226
|
-
configure if Doing.settings.nil?
|
2227
|
-
|
2228
|
-
filename ||= Doing.setting('doing_file')
|
2229
|
-
init_doing_file(filename)
|
2230
|
-
current_content = @content.clone
|
2231
|
-
backup_file = Util::Backup.last_backup(filename, count: 1)
|
2232
|
-
raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil?
|
2233
|
-
|
2234
|
-
backup = WWID.new
|
2235
|
-
backup.config = Doing.settings
|
2236
|
-
backup.init_doing_file(backup_file)
|
2237
|
-
current_content.diff(backup.content)
|
97
|
+
false
|
2238
98
|
end
|
2239
99
|
|
2240
100
|
private
|
2241
101
|
|
2242
|
-
##
|
2243
|
-
## Wraps doing file content with additional
|
2244
|
-
## header/footer content
|
2245
|
-
##
|
2246
|
-
## @return [String] concatenated content
|
2247
|
-
##
|
2248
|
-
def combined_content
|
2249
|
-
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
2250
|
-
was_color = Color.coloring?
|
2251
|
-
Color.coloring = false
|
2252
|
-
@content.dedup!(match_section: true)
|
2253
|
-
output += @content.to_s
|
2254
|
-
output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
2255
|
-
# Just strip all ANSI colors from the content before writing to doing file
|
2256
|
-
Color.coloring = was_color
|
2257
|
-
|
2258
|
-
output.uncolor
|
2259
|
-
end
|
2260
|
-
|
2261
|
-
##
|
2262
|
-
## Generate output using available export plugins
|
2263
|
-
##
|
2264
|
-
## @param items [Array] The items
|
2265
|
-
## @param title [String] Page title
|
2266
|
-
## @param is_single [Boolean] Indicates if single
|
2267
|
-
## section
|
2268
|
-
## @param opt [Hash] Additional options
|
2269
|
-
##
|
2270
|
-
## @return [String] formatted output based on opt[:output]
|
2271
|
-
## template trigger
|
2272
|
-
##
|
2273
|
-
def output(items, title, is_single, opt)
|
2274
|
-
logger.benchmark(:output, :start)
|
2275
|
-
opt ||= {}
|
2276
|
-
out = nil
|
2277
|
-
|
2278
|
-
raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
|
2279
|
-
|
2280
|
-
export_options = { page_title: title, is_single: is_single, options: opt }
|
2281
|
-
|
2282
|
-
Hooks.trigger :pre_export, self, opt[:output], items
|
2283
|
-
|
2284
|
-
Plugins.plugins[:export].each do |_, options|
|
2285
|
-
next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
2286
|
-
|
2287
|
-
out = options[:class].render(self, items, variables: export_options)
|
2288
|
-
break
|
2289
|
-
end
|
2290
|
-
|
2291
|
-
logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
|
2292
|
-
logger.benchmark(:output, :finish)
|
2293
|
-
out
|
2294
|
-
end
|
2295
|
-
|
2296
|
-
##
|
2297
|
-
## Record times for item tags
|
2298
|
-
##
|
2299
|
-
## @param item [Item] The item to record
|
2300
|
-
##
|
2301
|
-
def record_tag_times(item, seconds)
|
2302
|
-
item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
|
2303
|
-
return if @recorded_items.include?(item_hash)
|
2304
|
-
item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
|
2305
|
-
k = m[0] == 'done' ? 'All' : m[0].downcase
|
2306
|
-
if @timers.key?(k)
|
2307
|
-
@timers[k] += seconds
|
2308
|
-
else
|
2309
|
-
@timers[k] = seconds
|
2310
|
-
end
|
2311
|
-
@recorded_items.push(item_hash)
|
2312
|
-
end
|
2313
|
-
end
|
2314
|
-
|
2315
|
-
##
|
2316
|
-
## Helper function, performs the actual archiving
|
2317
|
-
##
|
2318
|
-
## @param section [String] The source section
|
2319
|
-
## @param destination [String] The destination
|
2320
|
-
## section
|
2321
|
-
## @param opt [Hash] Additional Options
|
2322
|
-
##
|
2323
|
-
def do_archive(section, destination, opt)
|
2324
|
-
opt ||= {}
|
2325
|
-
count = opt[:count] || 0
|
2326
|
-
tags = opt[:tags] || []
|
2327
|
-
bool = opt[:bool] || :and
|
2328
|
-
label = opt[:label] || true
|
2329
|
-
|
2330
|
-
section = guess_section(section)
|
2331
|
-
destination = guess_section(destination)
|
2332
|
-
|
2333
|
-
section_items = @content.in_section(section)
|
2334
|
-
max = section_items.count - count.to_i
|
2335
|
-
|
2336
|
-
opt[:after] = opt[:from][0] if opt[:from]
|
2337
|
-
opt[:before] = opt[:from][1] if opt[:from]
|
2338
|
-
|
2339
|
-
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
2340
|
-
|
2341
|
-
if opt[:before].is_a?(String) && opt[:before] =~ time_rx
|
2342
|
-
opt[:before] = opt[:before].chronify(guess: :end, future: false)
|
2343
|
-
end
|
2344
|
-
|
2345
|
-
if opt[:after].is_a?(String) && opt[:after] =~ time_rx
|
2346
|
-
opt[:after] = opt[:after].chronify(guess: :begin, future: false)
|
2347
|
-
end
|
2348
|
-
|
2349
|
-
counter = 0
|
2350
|
-
|
2351
|
-
@content.map do |item|
|
2352
|
-
break if counter >= max
|
2353
|
-
|
2354
|
-
next if item.section.downcase == destination.downcase
|
2355
|
-
|
2356
|
-
next if item.section.downcase != section.downcase && section != /^all$/i
|
2357
|
-
|
2358
|
-
next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
|
2359
|
-
|
2360
|
-
next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
|
2361
|
-
|
2362
|
-
counter += 1
|
2363
|
-
old_item = item.clone
|
2364
|
-
item.move_to(destination, label: label, log: false)
|
2365
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
2366
|
-
item
|
2367
|
-
end
|
2368
|
-
|
2369
|
-
if counter.positive?
|
2370
|
-
logger.count(destination == 'Archive' ? :archived : :moved,
|
2371
|
-
level: :info,
|
2372
|
-
count: counter,
|
2373
|
-
message: "%count %items from #{section} to #{destination}")
|
2374
|
-
else
|
2375
|
-
logger.info('Skipped:', 'No items were moved')
|
2376
|
-
end
|
2377
|
-
end
|
2378
|
-
|
2379
102
|
def run_after
|
2380
103
|
return unless Doing.setting('run_after')
|
2381
104
|
|