doing 2.1.39 → 2.1.40
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/CHANGELOG.md +23 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -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/on.rb +3 -16
- 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 +6 -17
- 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 +15 -8
- 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 +90 -1615
- 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 +293 -773
- data/docs/doc/top-level-namespace.html +3 -3
- data/docs/index.md +1 -1
- data/doing.rdoc +49 -7
- data/lib/completion/_doing.zsh +5 -5
- data/lib/completion/doing.bash +8 -8
- data/lib/completion/doing.fish +7 -2
- data/lib/doing/add_options.rb +31 -1
- data/lib/doing/chronify/array.rb +64 -22
- 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 -667
- data/lib/doing/items.rb +38 -13
- 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/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 -2317
- metadata +19 -2
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doing
|
|
4
|
+
class WWID
|
|
5
|
+
# Item modification methods for WWID class
|
|
6
|
+
module Modify
|
|
7
|
+
##
|
|
8
|
+
## Adds an entry
|
|
9
|
+
##
|
|
10
|
+
## @param title [String] The entry title
|
|
11
|
+
## @param section [String] The section to add to
|
|
12
|
+
## @param opt [Hash] Additional Options
|
|
13
|
+
##
|
|
14
|
+
## @option opt :date [Date] item start date
|
|
15
|
+
## @option opt :note [Array] item note (will be converted if value is String)
|
|
16
|
+
## @option opt :back [Date] backdate
|
|
17
|
+
## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
|
|
18
|
+
## @option opt :done [Date] If set, adds a @done tag to new entry
|
|
19
|
+
##
|
|
20
|
+
def add_item(title, section = nil, opt)
|
|
21
|
+
opt ||= {}
|
|
22
|
+
section ||= Doing.setting('current_section')
|
|
23
|
+
@content.add_section(section, log: false)
|
|
24
|
+
opt[:back] ||= opt[:date] ? opt[:date] : Time.now
|
|
25
|
+
opt[:date] ||= Time.now
|
|
26
|
+
note = Note.new
|
|
27
|
+
opt[:timed] ||= false
|
|
28
|
+
|
|
29
|
+
note.add(opt[:note]) if opt[:note]
|
|
30
|
+
|
|
31
|
+
title = [title.strip.cap_first]
|
|
32
|
+
title = title.join(' ')
|
|
33
|
+
|
|
34
|
+
if Doing.auto_tag
|
|
35
|
+
title = autotag(title)
|
|
36
|
+
title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
title.compress!
|
|
40
|
+
entry = Item.new(opt[:back], title.strip, section)
|
|
41
|
+
|
|
42
|
+
if opt[:done] && entry.should_finish?
|
|
43
|
+
if entry.should_time?
|
|
44
|
+
entry.tag('done', value: opt[:done])
|
|
45
|
+
else
|
|
46
|
+
entry.tag('done')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
entry.note = note
|
|
51
|
+
|
|
52
|
+
items = @content.clone
|
|
53
|
+
if opt[:timed]
|
|
54
|
+
items.reverse!
|
|
55
|
+
items.each_with_index do |i, x|
|
|
56
|
+
next if i.title =~ / @done/
|
|
57
|
+
|
|
58
|
+
finish_date = verify_duration(i.date, opt[:back], title: i.title)
|
|
59
|
+
items[x].tag('done', value: finish_date.strftime('%F %R'))
|
|
60
|
+
break
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Hooks.trigger :pre_entry_add, self, entry
|
|
65
|
+
|
|
66
|
+
@content.push(entry)
|
|
67
|
+
# logger.count(:added, level: :debug)
|
|
68
|
+
logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
|
|
69
|
+
|
|
70
|
+
Hooks.trigger :post_entry_added, self, entry
|
|
71
|
+
entry
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reset start date to current time, optionally remove
|
|
75
|
+
# done tag (resume)
|
|
76
|
+
#
|
|
77
|
+
# @param item [Item] the item to reset/resume
|
|
78
|
+
# @param resume [Boolean] removing @done tag if true
|
|
79
|
+
#
|
|
80
|
+
def reset_item(item, date: nil, finish_date: nil, resume: false)
|
|
81
|
+
date ||= Time.now
|
|
82
|
+
item.date = date
|
|
83
|
+
if finish_date
|
|
84
|
+
item.tag('done', remove: true)
|
|
85
|
+
item.tag('done', value: finish_date.strftime('%F %R'))
|
|
86
|
+
else
|
|
87
|
+
item.tag('done', remove: true) if resume
|
|
88
|
+
end
|
|
89
|
+
logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
|
|
90
|
+
item
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Duplicate an item and add it as a new item
|
|
94
|
+
#
|
|
95
|
+
# @param item [Item] the item to duplicate
|
|
96
|
+
# @param opt [Hash] additional options
|
|
97
|
+
#
|
|
98
|
+
# @option opt :editor [Boolean] open new item in editor
|
|
99
|
+
# @option opt :date [String] set start date
|
|
100
|
+
# @option opt :in [String] add new item to section :in
|
|
101
|
+
# @option opt :note [Note] add note to new item
|
|
102
|
+
#
|
|
103
|
+
# @return nothing
|
|
104
|
+
#
|
|
105
|
+
def repeat_item(item, opt)
|
|
106
|
+
opt ||= {}
|
|
107
|
+
old_item = item.clone
|
|
108
|
+
if item.should_finish?
|
|
109
|
+
if item.should_time?
|
|
110
|
+
finish_date = verify_duration(item.date, Time.now, title: item.title)
|
|
111
|
+
item.title.tag!('done', value: finish_date.strftime('%F %R'))
|
|
112
|
+
else
|
|
113
|
+
item.title.tag!('done')
|
|
114
|
+
end
|
|
115
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Remove @done tag
|
|
119
|
+
title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
|
|
120
|
+
section = opt[:in].nil? ? item.section : guess_section(opt[:in])
|
|
121
|
+
Doing.auto_tag = false
|
|
122
|
+
|
|
123
|
+
note = opt[:note] || Note.new
|
|
124
|
+
|
|
125
|
+
if opt[:editor]
|
|
126
|
+
start = opt[:date] ? opt[:date] : Time.now
|
|
127
|
+
to_edit = "#{start.strftime('%F %R')} | #{title}"
|
|
128
|
+
to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
|
|
129
|
+
new_item = fork_editor(to_edit)
|
|
130
|
+
date, title, note = format_input(new_item)
|
|
131
|
+
|
|
132
|
+
opt[:date] = date unless date.nil?
|
|
133
|
+
|
|
134
|
+
if title.nil? || title.empty?
|
|
135
|
+
logger.warn('Skipped:', 'No content provided')
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @content.update_item(original, item)
|
|
141
|
+
add_item(title, section, { note: note, back: opt[:date], timed: false })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
##
|
|
145
|
+
## Restart the last entry
|
|
146
|
+
##
|
|
147
|
+
## @param opt [Hash] Additional Options
|
|
148
|
+
##
|
|
149
|
+
def repeat_last(opt)
|
|
150
|
+
opt ||= {}
|
|
151
|
+
opt[:section] ||= 'all'
|
|
152
|
+
opt[:section] = guess_section(opt[:section])
|
|
153
|
+
opt[:note] ||= []
|
|
154
|
+
opt[:tag] ||= []
|
|
155
|
+
opt[:tag_bool] ||= :and
|
|
156
|
+
|
|
157
|
+
last = last_entry(opt)
|
|
158
|
+
if last.nil?
|
|
159
|
+
logger.warn('Skipped:', 'No previous entry found')
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
repeat_item(last, opt)
|
|
164
|
+
write(@doing_file)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
##
|
|
169
|
+
## Tag the last entry or X entries
|
|
170
|
+
##
|
|
171
|
+
## @param opt [Hash] Additional Options (see
|
|
172
|
+
## #filter_items for filtering
|
|
173
|
+
## options)
|
|
174
|
+
##
|
|
175
|
+
## @see #filter_items
|
|
176
|
+
##
|
|
177
|
+
def tag_last(opt) # hooked
|
|
178
|
+
opt ||= {}
|
|
179
|
+
opt[:count] ||= 1
|
|
180
|
+
opt[:archive] ||= false
|
|
181
|
+
opt[:tags] ||= ['done']
|
|
182
|
+
opt[:sequential] ||= false
|
|
183
|
+
opt[:date] ||= false
|
|
184
|
+
opt[:remove] ||= false
|
|
185
|
+
opt[:update] ||= false
|
|
186
|
+
opt[:autotag] ||= false
|
|
187
|
+
opt[:back] ||= false
|
|
188
|
+
opt[:unfinished] ||= false
|
|
189
|
+
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
|
190
|
+
|
|
191
|
+
items = filter_items(Items.new, opt: opt)
|
|
192
|
+
|
|
193
|
+
if opt[:interactive]
|
|
194
|
+
items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
|
|
195
|
+
header: '',
|
|
196
|
+
prompt: 'Select entries to tag > ',
|
|
197
|
+
multiple: true,
|
|
198
|
+
sort: true,
|
|
199
|
+
show_if_single: true)
|
|
200
|
+
|
|
201
|
+
raise NoResults, 'no items selected' if items.empty?
|
|
202
|
+
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
raise NoResults, 'no items matched your search' if items.empty?
|
|
206
|
+
|
|
207
|
+
if opt[:tags].empty? && !opt[:autotag]
|
|
208
|
+
completions = opt[:remove] ? all_tags(items) : all_tags(@content)
|
|
209
|
+
if opt[:remove]
|
|
210
|
+
puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
|
|
211
|
+
else
|
|
212
|
+
puts "#{yellow}Use tab to complete known tags"
|
|
213
|
+
end
|
|
214
|
+
opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
|
|
215
|
+
completions: completions,
|
|
216
|
+
default_response: '').to_tags
|
|
217
|
+
raise UserCancelled, 'No tags provided' if opt[:tags].empty?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
items.each do |item|
|
|
221
|
+
old_item = item.clone
|
|
222
|
+
added = []
|
|
223
|
+
removed = []
|
|
224
|
+
|
|
225
|
+
item.date = opt[:start_date] if opt[:start_date]
|
|
226
|
+
|
|
227
|
+
if opt[:autotag]
|
|
228
|
+
new_title = autotag(item.title) if Doing.auto_tag
|
|
229
|
+
if new_title == item.title
|
|
230
|
+
logger.count(:skipped, level: :debug, message: '%count unchaged %items')
|
|
231
|
+
# logger.debug('Autotag:', 'No changes')
|
|
232
|
+
else
|
|
233
|
+
logger.count(:added_tags)
|
|
234
|
+
logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
|
|
235
|
+
item.title = new_title
|
|
236
|
+
end
|
|
237
|
+
else
|
|
238
|
+
if opt[:done_date]
|
|
239
|
+
done_date = opt[:done_date]
|
|
240
|
+
elsif opt[:sequential]
|
|
241
|
+
next_entry = next_item(item)
|
|
242
|
+
|
|
243
|
+
done_date = if next_entry.nil?
|
|
244
|
+
Time.now
|
|
245
|
+
else
|
|
246
|
+
next_entry.date - 60
|
|
247
|
+
end
|
|
248
|
+
else
|
|
249
|
+
done_date = item.calculate_end_date(opt)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
opt[:tags].each do |tag|
|
|
253
|
+
if tag == 'done' && !item.should_finish?
|
|
254
|
+
|
|
255
|
+
Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
|
|
256
|
+
logger.count(:skipped, level: :debug)
|
|
257
|
+
next
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
tag = tag.strip
|
|
261
|
+
|
|
262
|
+
if tag =~ /^(\S+)\((.*?)\)$/
|
|
263
|
+
m = Regexp.last_match
|
|
264
|
+
tag = m[1]
|
|
265
|
+
opt[:value] ||= m[2]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if tag =~ /^done$/ && opt[:date] && item.should_time?
|
|
269
|
+
max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
|
|
270
|
+
max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
|
|
271
|
+
elapsed = done_date - item.date
|
|
272
|
+
|
|
273
|
+
if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
|
|
274
|
+
puts boldwhite(item.title)
|
|
275
|
+
human = elapsed.time_string(format: :natural)
|
|
276
|
+
res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
|
|
277
|
+
unless res
|
|
278
|
+
new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
|
|
279
|
+
raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
|
|
280
|
+
|
|
281
|
+
opt[:took] = new_elapsed
|
|
282
|
+
done_date = item.calculate_end_date(opt) if opt[:took]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if opt[:remove] || opt[:rename] || opt[:value]
|
|
288
|
+
rename_to = nil
|
|
289
|
+
if opt[:value]
|
|
290
|
+
rename_to = tag
|
|
291
|
+
elsif opt[:rename]
|
|
292
|
+
rename_to = tag
|
|
293
|
+
tag = opt[:rename]
|
|
294
|
+
end
|
|
295
|
+
old_title = item.title.dup
|
|
296
|
+
force = opt[:value].nil? ? false : true
|
|
297
|
+
item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
|
|
298
|
+
if old_title != item.title
|
|
299
|
+
removed << tag
|
|
300
|
+
added << rename_to if rename_to
|
|
301
|
+
else
|
|
302
|
+
logger.count(:skipped, level: :debug)
|
|
303
|
+
end
|
|
304
|
+
else
|
|
305
|
+
old_title = item.title.dup
|
|
306
|
+
should_date = opt[:date] && item.should_time?
|
|
307
|
+
item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
|
|
308
|
+
item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
|
|
309
|
+
added << tag if old_title != item.title
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
|
|
315
|
+
|
|
316
|
+
item.note.add(opt[:note]) if opt[:note]
|
|
317
|
+
|
|
318
|
+
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
|
319
|
+
item.move_to('Archive', label: true)
|
|
320
|
+
elsif opt[:archive] && opt[:count].zero?
|
|
321
|
+
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
item.expand_date_tags(Doing.setting('date_tags'))
|
|
325
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
write(@doing_file)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
##
|
|
332
|
+
## Accepts one tag and the raw text of a new item if the
|
|
333
|
+
## passed tag is on any item, it's replaced with @done.
|
|
334
|
+
## if new_item is not nil, it's tagged with the passed
|
|
335
|
+
## tag and inserted. This is for use where only one
|
|
336
|
+
## instance of a given tag should exist (@meanwhile)
|
|
337
|
+
##
|
|
338
|
+
## @param target_tag [String] Tag to replace
|
|
339
|
+
## @param opt [Hash] Additional Options
|
|
340
|
+
##
|
|
341
|
+
## @option opt :section [String] target section
|
|
342
|
+
## @option opt :archive [Boolean] archive old item
|
|
343
|
+
## @option opt :back [Date] backdate new item
|
|
344
|
+
## @option opt :new_item [String] content to use for new item
|
|
345
|
+
## @option opt :note [Array] note content for new item
|
|
346
|
+
def stop_start(target_tag, opt)
|
|
347
|
+
opt ||= {}
|
|
348
|
+
tag = target_tag.dup
|
|
349
|
+
opt[:section] ||= Doing.setting('current_section')
|
|
350
|
+
opt[:archive] ||= false
|
|
351
|
+
opt[:back] ||= Time.now
|
|
352
|
+
opt[:new_item] ||= false
|
|
353
|
+
opt[:note] ||= false
|
|
354
|
+
|
|
355
|
+
opt[:section] = guess_section(opt[:section])
|
|
356
|
+
|
|
357
|
+
tag.sub!(/^@/, '')
|
|
358
|
+
|
|
359
|
+
found_items = 0
|
|
360
|
+
|
|
361
|
+
@content.each_with_index do |item, i|
|
|
362
|
+
old_item = i.clone
|
|
363
|
+
next unless item.section == opt[:section] || opt[:section] =~ /all/i
|
|
364
|
+
|
|
365
|
+
next unless item.title =~ /@#{tag}/
|
|
366
|
+
|
|
367
|
+
item.title.add_tags!([tag, 'done'], remove: true)
|
|
368
|
+
item.tag('done', value: opt[:back].strftime('%F %R'))
|
|
369
|
+
|
|
370
|
+
found_items += 1
|
|
371
|
+
|
|
372
|
+
if opt[:archive] && opt[:section] != 'Archive'
|
|
373
|
+
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
|
374
|
+
item.move_to('Archive', label: false, log: false)
|
|
375
|
+
logger.count(:completed_archived)
|
|
376
|
+
logger.info('Completed/archived:', item.title)
|
|
377
|
+
else
|
|
378
|
+
logger.count(:completed)
|
|
379
|
+
logger.info('Completed:', item.title)
|
|
380
|
+
end
|
|
381
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
|
386
|
+
|
|
387
|
+
if opt[:new_item]
|
|
388
|
+
date, title, note = format_input(opt[:new_item])
|
|
389
|
+
opt[:back] = date unless date.nil?
|
|
390
|
+
note.add(opt[:note]) if opt[:note]
|
|
391
|
+
title.tag!(tag)
|
|
392
|
+
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
write(@doing_file)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
##
|
|
399
|
+
## Delete a set of items from the main index
|
|
400
|
+
##
|
|
401
|
+
## @param items [Array] The items to delete
|
|
402
|
+
## @param force [Boolean] Force deletion without confirmation
|
|
403
|
+
##
|
|
404
|
+
def delete_items(items, force: false)
|
|
405
|
+
items.slice(0, 5).each { |i| puts i.to_pretty } unless force
|
|
406
|
+
puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
|
|
407
|
+
|
|
408
|
+
res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
|
|
409
|
+
return unless res
|
|
410
|
+
|
|
411
|
+
items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
|
|
412
|
+
# write(@doing_file)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
##
|
|
416
|
+
## Move entries from a section to Archive or other specified
|
|
417
|
+
## section
|
|
418
|
+
##
|
|
419
|
+
## @param section [String] The source section
|
|
420
|
+
## @param options [Hash] Options
|
|
421
|
+
##
|
|
422
|
+
def archive(section = Doing.setting('current_section'), options)
|
|
423
|
+
options ||= {}
|
|
424
|
+
count = options[:keep] || 0
|
|
425
|
+
destination = options[:destination] || 'Archive'
|
|
426
|
+
tags = options[:tags] || []
|
|
427
|
+
bool = options[:bool] || :and
|
|
428
|
+
|
|
429
|
+
section = choose_section if section.nil? || section =~ /choose/i
|
|
430
|
+
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
|
431
|
+
section = guess_section(section) unless archive_all
|
|
432
|
+
|
|
433
|
+
@content.add_section(destination, log: true)
|
|
434
|
+
# add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
|
|
435
|
+
|
|
436
|
+
destination = guess_section(destination)
|
|
437
|
+
|
|
438
|
+
if @content.section?(destination) && (@content.section?(section) || archive_all)
|
|
439
|
+
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] })
|
|
440
|
+
write(doing_file)
|
|
441
|
+
else
|
|
442
|
+
raise InvalidArgument, 'Either source or destination does not exist'
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
##
|
|
447
|
+
## Uses 'autotag' configuration to turn keywords into tags for time tracking.
|
|
448
|
+
## Does not repeat tags in a title, and only converts the first instance of an
|
|
449
|
+
## untagged keyword
|
|
450
|
+
##
|
|
451
|
+
## @param string [String] The text to tag
|
|
452
|
+
##
|
|
453
|
+
def autotag(string)
|
|
454
|
+
return unless string
|
|
455
|
+
return string unless Doing.auto_tag
|
|
456
|
+
|
|
457
|
+
original = string.dup
|
|
458
|
+
text = string.dup
|
|
459
|
+
|
|
460
|
+
current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
|
|
461
|
+
tagged = {
|
|
462
|
+
whitelisted: [],
|
|
463
|
+
synonyms: [],
|
|
464
|
+
transformed: [],
|
|
465
|
+
replaced: []
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
Doing.setting('autotag.whitelist').each do |tag|
|
|
469
|
+
next if text =~ /@#{tag}\b/i
|
|
470
|
+
|
|
471
|
+
text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
|
|
472
|
+
m.downcase! unless tag =~ /[A-Z]/
|
|
473
|
+
tagged[:whitelisted].push(m)
|
|
474
|
+
"@#{m}"
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
Doing.setting('autotag.synonyms').each do |tag, v|
|
|
479
|
+
v.each do |word|
|
|
480
|
+
word = word.wildcard_to_rx
|
|
481
|
+
next unless text =~ /\b#{word}\b/i
|
|
482
|
+
|
|
483
|
+
unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
|
|
484
|
+
tagged[:synonyms].push(tag)
|
|
485
|
+
tagged[:synonyms] = tagged[:synonyms].uniq
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
if Doing.setting('autotag.transform')
|
|
491
|
+
Doing.setting('autotag.transform').each do |tag|
|
|
492
|
+
next unless tag =~ /\S+:\S+/
|
|
493
|
+
|
|
494
|
+
if tag =~ /::/
|
|
495
|
+
rx, r = tag.split(/::/)
|
|
496
|
+
else
|
|
497
|
+
rx, r = tag.split(/:/)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
flag_rx = %r{/([r]+)$}
|
|
501
|
+
if r =~ flag_rx
|
|
502
|
+
flags = r.match(flag_rx)[1].split(//)
|
|
503
|
+
r.sub!(flag_rx, '')
|
|
504
|
+
end
|
|
505
|
+
r.gsub!(/\$/, '\\')
|
|
506
|
+
rx.sub!(/^@?/, '@')
|
|
507
|
+
regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
|
|
508
|
+
|
|
509
|
+
text.sub!(regex) do
|
|
510
|
+
m = Regexp.last_match
|
|
511
|
+
new_tag = r
|
|
512
|
+
|
|
513
|
+
m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
|
|
514
|
+
new_tag.gsub!("\\#{idx + 1}", v)
|
|
515
|
+
end
|
|
516
|
+
# Replace original tag if /r
|
|
517
|
+
if flags&.include?('r')
|
|
518
|
+
tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
|
519
|
+
new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
|
|
520
|
+
else
|
|
521
|
+
tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
|
522
|
+
tagged[:transformed] = tagged[:transformed].uniq
|
|
523
|
+
m[0]
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
|
|
530
|
+
logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
|
|
531
|
+
logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
|
|
532
|
+
logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
|
|
533
|
+
|
|
534
|
+
tail_tags = tagged[:synonyms].concat(tagged[:transformed])
|
|
535
|
+
tail_tags.sort!
|
|
536
|
+
tail_tags.uniq!
|
|
537
|
+
|
|
538
|
+
text.add_tags!(tail_tags) unless tail_tags.empty?
|
|
539
|
+
|
|
540
|
+
if text == original
|
|
541
|
+
logger.debug('Autotag:', "no change to \"#{text.strip}\"")
|
|
542
|
+
else
|
|
543
|
+
new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
|
|
544
|
+
logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
|
|
545
|
+
logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
text.dedup_tags
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
private
|
|
552
|
+
|
|
553
|
+
##
|
|
554
|
+
## Helper function, performs the actual archiving
|
|
555
|
+
##
|
|
556
|
+
## @param section [String] The source section
|
|
557
|
+
## @param destination [String] The destination
|
|
558
|
+
## section
|
|
559
|
+
## @param opt [Hash] Additional Options
|
|
560
|
+
## @api private
|
|
561
|
+
def do_archive(section, destination, opt)
|
|
562
|
+
opt ||= {}
|
|
563
|
+
count = opt[:count] || 0
|
|
564
|
+
tags = opt[:tags] || []
|
|
565
|
+
bool = opt[:bool] || :and
|
|
566
|
+
label = opt[:label] || true
|
|
567
|
+
|
|
568
|
+
section = guess_section(section)
|
|
569
|
+
destination = guess_section(destination)
|
|
570
|
+
|
|
571
|
+
section_items = @content.in_section(section)
|
|
572
|
+
max = section_items.count - count.to_i
|
|
573
|
+
|
|
574
|
+
opt[:after] = opt[:from][0] if opt[:from]
|
|
575
|
+
opt[:before] = opt[:from][1] if opt[:from]
|
|
576
|
+
|
|
577
|
+
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
|
578
|
+
|
|
579
|
+
if opt[:before].is_a?(String) && opt[:before] =~ time_rx
|
|
580
|
+
opt[:before] = opt[:before].chronify(guess: :end, future: false)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
if opt[:after].is_a?(String) && opt[:after] =~ time_rx
|
|
584
|
+
opt[:after] = opt[:after].chronify(guess: :begin, future: false)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
counter = 0
|
|
588
|
+
|
|
589
|
+
@content.map do |item|
|
|
590
|
+
break if counter >= max
|
|
591
|
+
|
|
592
|
+
next if item.section.downcase == destination.downcase
|
|
593
|
+
|
|
594
|
+
next if item.section.downcase != section.downcase && section != /^all$/i
|
|
595
|
+
|
|
596
|
+
next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
|
|
597
|
+
|
|
598
|
+
next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
|
|
599
|
+
|
|
600
|
+
counter += 1
|
|
601
|
+
old_item = item.clone
|
|
602
|
+
item.move_to(destination, label: label, log: false)
|
|
603
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
604
|
+
item
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
if counter.positive?
|
|
608
|
+
logger.count(destination == 'Archive' ? :archived : :moved,
|
|
609
|
+
level: :info,
|
|
610
|
+
count: counter,
|
|
611
|
+
message: "%count %items from #{section} to #{destination}")
|
|
612
|
+
else
|
|
613
|
+
logger.info('Skipped:', 'No items were moved')
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doing
|
|
4
|
+
class WWID
|
|
5
|
+
# Tag methods for WWID class
|
|
6
|
+
module Tags
|
|
7
|
+
##
|
|
8
|
+
## List all tags that exist on given items
|
|
9
|
+
##
|
|
10
|
+
## @param items [Array] array of Item
|
|
11
|
+
## @param opt [Hash] additional options
|
|
12
|
+
## @param counts [Boolean] Include tag counts in
|
|
13
|
+
## results
|
|
14
|
+
##
|
|
15
|
+
## @return [Hash] if counts is true, returns a hash with {
|
|
16
|
+
## tag: count }.
|
|
17
|
+
## @return [Array] If counts is false, returns a simple
|
|
18
|
+
## array of tags.
|
|
19
|
+
##
|
|
20
|
+
def all_tags(items, opt: {}, counts: false)
|
|
21
|
+
if counts
|
|
22
|
+
all_tags = {}
|
|
23
|
+
items.each do |item|
|
|
24
|
+
item.tags.each do |tag|
|
|
25
|
+
if all_tags.key?(tag.downcase)
|
|
26
|
+
all_tags[tag.downcase] += 1
|
|
27
|
+
else
|
|
28
|
+
all_tags[tag.downcase] = 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
all_tags.sort_by { |_, count| count }
|
|
34
|
+
else
|
|
35
|
+
all_tags = []
|
|
36
|
+
items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
|
|
37
|
+
all_tags.sort
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tag_groups(items, opt: {})
|
|
42
|
+
all_items = filter_items(items, opt: opt)
|
|
43
|
+
tags = all_tags(all_items, opt: {})
|
|
44
|
+
groups = {}
|
|
45
|
+
tags.each do |tag|
|
|
46
|
+
groups[tag] ||= []
|
|
47
|
+
groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
groups
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|