doing 2.1.39 → 2.1.42
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/.yardopts +1 -1
- data/CHANGELOG.md +67 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/Rakefile +4 -4
- data/bin/commands/again.rb +1 -3
- data/bin/commands/changes.rb +50 -34
- data/bin/commands/commands.rb +77 -52
- data/bin/commands/commands_accepting.rb +57 -53
- data/bin/commands/config.rb +45 -36
- data/bin/commands/done.rb +1 -18
- data/bin/commands/finish.rb +90 -59
- data/bin/commands/flag.rb +5 -1
- 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 +151 -107
- data/bin/commands/on.rb +8 -18
- 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/tag_dir.rb +49 -15
- data/bin/commands/today.rb +2 -13
- data/bin/commands/undo.rb +4 -6
- data/bin/commands/view.rb +1 -1
- data/bin/commands/yesterday.rb +2 -13
- data/bin/doing +15 -8
- data/{Dockerfile → docker/Dockerfile} +3 -1
- data/{Dockerfile-2.6 → docker/Dockerfile-2.6} +2 -2
- data/{Dockerfile-2.7 → docker/Dockerfile-2.7} +2 -2
- data/{Dockerfile-3.0 → docker/Dockerfile-3.0} +2 -2
- data/{bash_profile → docker/bash_profile} +0 -0
- data/{inputrc → docker/inputrc} +0 -0
- data/docs/doc/Array.html +85 -2
- 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/ArrayNestedHash.html +198 -0
- data/docs/doc/Doing/ArrayTags.html +424 -0
- data/docs/doc/Doing/CSVExport.html +266 -0
- data/docs/doc/Doing/CalendarImport.html +232 -0
- data/docs/doc/Doing/Change.html +617 -0
- data/docs/doc/Doing/Changes.html +468 -0
- data/docs/doc/Doing/ChronifyArray.html +347 -0
- data/docs/doc/Doing/ChronifyNumeric.html +271 -0
- data/docs/doc/Doing/ChronifyString.html +682 -0
- data/docs/doc/Doing/Color.html +167 -21
- data/docs/doc/Doing/Completion/BashCompletions.html +445 -0
- data/docs/doc/Doing/Completion/FishCompletions.html +445 -0
- data/docs/doc/Doing/Completion/StringUtils.html +229 -0
- data/docs/doc/Doing/Completion/ZshCompletions.html +445 -0
- data/docs/doc/Doing/Completion.html +17 -3
- data/docs/doc/Doing/Configuration.html +3 -2
- data/docs/doc/Doing/DayOneRenderer.html +383 -0
- data/docs/doc/Doing/DayoneExport.html +290 -0
- data/docs/doc/Doing/DoingImport.html +391 -0
- data/docs/doc/Doing/Entry.html +381 -0
- 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/HTMLExport.html +256 -0
- data/docs/doc/Doing/Hooks.html +1 -1
- data/docs/doc/Doing/Item.html +179 -1660
- data/docs/doc/Doing/ItemDates.html +564 -0
- data/docs/doc/Doing/ItemQuery.html +614 -0
- data/docs/doc/Doing/ItemState.html +387 -0
- data/docs/doc/Doing/ItemTags.html +498 -0
- data/docs/doc/Doing/Items.html +581 -15
- data/docs/doc/Doing/JSONExport.html +222 -0
- data/docs/doc/Doing/Logger.html +1 -1
- data/docs/doc/Doing/MarkdownExport.html +266 -0
- data/docs/doc/Doing/MarkdownRenderer.html +383 -0
- data/docs/doc/Doing/Note.html +18 -4
- data/docs/doc/Doing/Pager.html +1 -1
- data/docs/doc/Doing/Plugins.html +181 -76
- data/docs/doc/Doing/Prompt.html +32 -683
- data/docs/doc/Doing/PromptChoose.html +484 -0
- data/docs/doc/Doing/PromptFZF.html +391 -0
- data/docs/doc/Doing/PromptInput.html +572 -0
- data/docs/doc/Doing/PromptSTD.html +293 -0
- data/docs/doc/Doing/PromptYN.html +237 -0
- data/docs/doc/Doing/Section.html +58 -2
- data/docs/doc/Doing/StringHighlight.html +533 -0
- data/docs/doc/Doing/StringNormalize.html +929 -0
- data/docs/doc/Doing/StringQuery.html +725 -0
- data/docs/doc/Doing/StringTags.html +884 -0
- data/docs/doc/Doing/StringTransform.html +599 -0
- data/docs/doc/Doing/StringTruncate.html +448 -0
- data/docs/doc/Doing/StringURL.html +409 -0
- data/docs/doc/Doing/SymbolNormalize.html +341 -0
- data/docs/doc/Doing/TaskPaperExport.html +222 -0
- data/docs/doc/Doing/TemplateExport.html +249 -0
- data/docs/doc/Doing/TemplateString.html +102 -3
- data/docs/doc/Doing/TimingImport.html +285 -0
- data/docs/doc/Doing/Types.html +1 -1
- data/docs/doc/Doing/Util/Backup.html +11 -163
- data/docs/doc/Doing/Util.html +67 -10
- data/docs/doc/Doing/Version.html +523 -0
- data/docs/doc/Doing/WWID/WWIDUtil.html +510 -0
- data/docs/doc/Doing/WWID.html +476 -139
- data/docs/doc/Doing/WWIDDisplay.html +865 -0
- data/docs/doc/Doing/WWIDEditor.html +466 -0
- data/docs/doc/Doing/WWIDFileTools.html +359 -0
- data/docs/doc/Doing/WWIDFilter.html +466 -0
- data/docs/doc/Doing/WWIDGuess.html +299 -0
- data/docs/doc/Doing/WWIDInteractive.html +752 -0
- data/docs/doc/Doing/WWIDModify.html +1078 -0
- data/docs/doc/Doing/WWIDTags.html +302 -0
- data/docs/doc/Doing/WWIDTimers.html +359 -0
- data/docs/doc/Doing/WWIDUtil.html +510 -0
- data/docs/doc/Doing.html +9 -6
- 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/Numeric.html +23 -78
- 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 +58 -633
- data/docs/doc/Symbol.html +9 -224
- data/docs/doc/Time.html +119 -13
- data/docs/doc/TrueClass.html +1 -1
- data/docs/doc/_index.html +348 -4
- 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 +1904 -592
- data/docs/doc/top-level-namespace.html +12 -4
- data/docs/index.md +1 -1
- data/doing.rdoc +67 -15
- data/lib/completion/_doing.zsh +6 -6
- data/lib/completion/doing.bash +10 -10
- data/lib/completion/doing.fish +10 -3
- data/lib/doing/add_options.rb +39 -1
- data/lib/doing/array/array.rb +18 -12
- data/lib/doing/array/cleanup.rb +31 -0
- data/lib/doing/array/nested_hash.rb +1 -1
- data/lib/doing/array/tags.rb +6 -5
- data/lib/doing/changelog/changelog.rb +6 -0
- data/lib/doing/chronify/array.rb +65 -25
- data/lib/doing/chronify/chronify.rb +12 -0
- data/lib/doing/chronify/numeric.rb +3 -2
- data/lib/doing/chronify/string.rb +1 -1
- data/lib/doing/colors.rb +77 -30
- data/lib/doing/completion/completion_string.rb +25 -0
- data/lib/doing/completion.rb +4 -5
- data/lib/doing/configuration.rb +7 -3
- data/lib/doing/errors.rb +51 -35
- data/lib/doing/good.rb +8 -0
- data/lib/doing/hooks.rb +3 -3
- data/lib/doing/item/dates.rb +112 -0
- data/lib/doing/item/item.rb +128 -0
- data/lib/doing/{item.rb → item/query.rb} +2 -353
- data/lib/doing/item/state.rb +59 -0
- data/lib/doing/item/tags.rb +87 -0
- data/lib/doing/items/filter.rb +67 -0
- data/lib/doing/items/items.rb +57 -0
- data/lib/doing/items/modify.rb +36 -0
- data/lib/doing/items/sections.rb +83 -0
- data/lib/doing/items/util.rb +74 -0
- data/lib/doing/normalize.rb +10 -2
- data/lib/doing/note.rb +1 -1
- data/lib/doing/pager.rb +9 -3
- data/lib/doing/plugin_manager.rb +33 -8
- data/lib/doing/plugins/export/markdown_export.rb +4 -2
- data/lib/doing/plugins/export/template_export.rb +4 -4
- data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
- data/lib/doing/plugins/import/doing_import.rb +1 -1
- data/lib/doing/prompt/choose.rb +118 -0
- data/lib/doing/prompt/fzf.rb +84 -0
- data/lib/doing/prompt/input.rb +129 -0
- data/lib/doing/prompt/prompt.rb +41 -0
- data/lib/doing/prompt/std.rb +32 -0
- data/lib/doing/prompt/yn.rb +64 -0
- data/lib/doing/section.rb +4 -0
- data/lib/doing/string/highlight.rb +1 -1
- data/lib/doing/string/query.rb +1 -1
- data/lib/doing/string/string.rb +18 -7
- data/lib/doing/string/tags.rb +14 -3
- data/lib/doing/string/transform.rb +7 -1
- data/lib/doing/string/truncate.rb +1 -1
- data/lib/doing/string/url.rb +1 -1
- data/lib/doing/time.rb +19 -1
- data/lib/doing/util.rb +12 -6
- data/lib/doing/util_backup.rb +62 -57
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid/display.rb +396 -0
- data/lib/doing/wwid/editor.rb +214 -0
- data/lib/doing/wwid/filetools.rb +183 -0
- data/lib/doing/wwid/filter.rb +226 -0
- data/lib/doing/wwid/guess.rb +85 -0
- data/lib/doing/wwid/interactive.rb +377 -0
- data/lib/doing/wwid/modify.rb +617 -0
- data/lib/doing/wwid/tags.rb +51 -0
- data/lib/doing/wwid/timers.rb +342 -0
- data/lib/doing/wwid/wwid.rb +121 -0
- data/lib/doing/wwid/wwidutil.rb +101 -0
- data/lib/doing.rb +7 -7
- data/lib/helpers/threaded_tests.rb +1 -0
- metadata +94 -14
- data/lib/doing/changelog.rb +0 -6
- data/lib/doing/completion/string.rb +0 -17
- data/lib/doing/items.rb +0 -196
- data/lib/doing/prompt.rb +0 -330
- data/lib/doing/wwid.rb +0 -2398
data/lib/doing/wwid.rb
DELETED
|
@@ -1,2398 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'deep_merge'
|
|
5
|
-
require 'open3'
|
|
6
|
-
require 'pp'
|
|
7
|
-
require 'shellwords'
|
|
8
|
-
require 'erb'
|
|
9
|
-
|
|
10
|
-
module Doing
|
|
11
|
-
##
|
|
12
|
-
## Main "What Was I Doing" methods
|
|
13
|
-
##
|
|
14
|
-
class WWID
|
|
15
|
-
attr_reader :additional_configs, :current_section, :doing_file, :content
|
|
16
|
-
|
|
17
|
-
attr_accessor :config, :config_file, :default_option
|
|
18
|
-
|
|
19
|
-
include Color
|
|
20
|
-
# include Util
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
## Initializes the object.
|
|
24
|
-
##
|
|
25
|
-
def initialize
|
|
26
|
-
@timers = {}
|
|
27
|
-
@recorded_items = []
|
|
28
|
-
@content = Items.new
|
|
29
|
-
Doing.auto_tag = true
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# For backwards compatibility where @wwid.config was accessed instead of Doing.config.settings
|
|
33
|
-
def config
|
|
34
|
-
Doing.config.settings
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
##
|
|
38
|
-
## Logger
|
|
39
|
-
##
|
|
40
|
-
## Responds to :debug, :info, :warn, and :error
|
|
41
|
-
##
|
|
42
|
-
## Each method takes a topic, and a message or block
|
|
43
|
-
##
|
|
44
|
-
## Example: debug('Hooks', 'Hook 1 triggered')
|
|
45
|
-
##
|
|
46
|
-
def logger
|
|
47
|
-
@logger ||= Doing.logger
|
|
48
|
-
end
|
|
49
|
-
|
|
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
|
-
##
|
|
228
|
-
## List sections
|
|
229
|
-
##
|
|
230
|
-
## @return [Array] section titles
|
|
231
|
-
##
|
|
232
|
-
def sections
|
|
233
|
-
@content.section_titles
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
##
|
|
237
|
-
## Attempt to match a string with an existing section
|
|
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)$/i
|
|
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
|
|
890
|
-
##
|
|
891
|
-
## Options hash is shared with #filter_items and #act_on
|
|
892
|
-
##
|
|
893
|
-
def interactive(opt)
|
|
894
|
-
opt ||= {}
|
|
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)
|
|
923
|
-
end
|
|
924
|
-
|
|
925
|
-
##
|
|
926
|
-
## Perform actions on a set of entries. If
|
|
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
|
|
933
|
-
##
|
|
934
|
-
## @option opt [Boolean] :editor
|
|
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
|
|
945
|
-
##
|
|
946
|
-
def act_on(items, opt)
|
|
947
|
-
opt ||= {}
|
|
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
|
|
1203
|
-
|
|
1204
|
-
finish_date
|
|
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
|
-
tag = tag.strip
|
|
1296
|
-
|
|
1297
|
-
if tag =~ /^(\S+)\((.*?)\)$/
|
|
1298
|
-
m = Regexp.last_match
|
|
1299
|
-
tag = m[1]
|
|
1300
|
-
opt[:value] ||= m[2]
|
|
1301
|
-
end
|
|
1302
|
-
|
|
1303
|
-
if tag =~ /^done$/ && opt[:date] && item.should_time?
|
|
1304
|
-
max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
|
|
1305
|
-
max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
|
|
1306
|
-
elapsed = done_date - item.date
|
|
1307
|
-
|
|
1308
|
-
if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
|
|
1309
|
-
puts boldwhite(item.title)
|
|
1310
|
-
human = elapsed.time_string(format: :natural)
|
|
1311
|
-
res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
|
|
1312
|
-
unless res
|
|
1313
|
-
new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
|
|
1314
|
-
raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
|
|
1315
|
-
|
|
1316
|
-
opt[:took] = new_elapsed
|
|
1317
|
-
done_date = item.calculate_end_date(opt) if opt[:took]
|
|
1318
|
-
end
|
|
1319
|
-
end
|
|
1320
|
-
end
|
|
1321
|
-
|
|
1322
|
-
if opt[:remove] || opt[:rename] || opt[:value]
|
|
1323
|
-
rename_to = nil
|
|
1324
|
-
if opt[:value]
|
|
1325
|
-
rename_to = tag
|
|
1326
|
-
elsif opt[:rename]
|
|
1327
|
-
rename_to = tag
|
|
1328
|
-
tag = opt[:rename]
|
|
1329
|
-
end
|
|
1330
|
-
old_title = item.title.dup
|
|
1331
|
-
force = opt[:value].nil? ? false : true
|
|
1332
|
-
item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
|
|
1333
|
-
if old_title != item.title
|
|
1334
|
-
removed << tag
|
|
1335
|
-
added << rename_to if rename_to
|
|
1336
|
-
else
|
|
1337
|
-
logger.count(:skipped, level: :debug)
|
|
1338
|
-
end
|
|
1339
|
-
else
|
|
1340
|
-
old_title = item.title.dup
|
|
1341
|
-
should_date = opt[:date] && item.should_time?
|
|
1342
|
-
item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
|
|
1343
|
-
item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
|
|
1344
|
-
added << tag if old_title != item.title
|
|
1345
|
-
end
|
|
1346
|
-
end
|
|
1347
|
-
end
|
|
1348
|
-
|
|
1349
|
-
logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
|
|
1350
|
-
|
|
1351
|
-
item.note.add(opt[:note]) if opt[:note]
|
|
1352
|
-
|
|
1353
|
-
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
|
1354
|
-
item.move_to('Archive', label: true)
|
|
1355
|
-
elsif opt[:archive] && opt[:count].zero?
|
|
1356
|
-
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
|
1357
|
-
end
|
|
1358
|
-
|
|
1359
|
-
item.expand_date_tags(Doing.setting('date_tags'))
|
|
1360
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
1361
|
-
end
|
|
1362
|
-
|
|
1363
|
-
write(@doing_file)
|
|
1364
|
-
end
|
|
1365
|
-
|
|
1366
|
-
##
|
|
1367
|
-
## Get next item in the index
|
|
1368
|
-
##
|
|
1369
|
-
## @param item [Item] target item
|
|
1370
|
-
## @param options [Hash] additional options
|
|
1371
|
-
## @see #filter_items
|
|
1372
|
-
##
|
|
1373
|
-
## @return [Item] the next chronological item in the index
|
|
1374
|
-
##
|
|
1375
|
-
def next_item(item, options = {})
|
|
1376
|
-
options ||= {}
|
|
1377
|
-
items = filter_items(Items.new, opt: options)
|
|
1378
|
-
|
|
1379
|
-
idx = items.index(item)
|
|
1380
|
-
|
|
1381
|
-
idx.positive? ? items[idx - 1] : nil
|
|
1382
|
-
end
|
|
1383
|
-
|
|
1384
|
-
##
|
|
1385
|
-
## Edit the last entry
|
|
1386
|
-
##
|
|
1387
|
-
## @param section [String] The section, default "All"
|
|
1388
|
-
##
|
|
1389
|
-
def edit_last(section: 'All', options: {})
|
|
1390
|
-
options[:section] = guess_section(section)
|
|
1391
|
-
|
|
1392
|
-
item = last_entry(options)
|
|
1393
|
-
|
|
1394
|
-
if item.nil?
|
|
1395
|
-
logger.debug('Skipped:', 'No entries found')
|
|
1396
|
-
return
|
|
1397
|
-
end
|
|
1398
|
-
|
|
1399
|
-
old_item = item.clone
|
|
1400
|
-
content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
|
|
1401
|
-
content << item.note.strip_lines.join("\n") unless item.note.empty?
|
|
1402
|
-
new_item = fork_editor(content.join("\n"))
|
|
1403
|
-
date, title, note = format_input(new_item)
|
|
1404
|
-
date ||= item.date
|
|
1405
|
-
|
|
1406
|
-
if title.nil? || title.empty?
|
|
1407
|
-
logger.debug('Skipped:', 'No content provided')
|
|
1408
|
-
elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
|
|
1409
|
-
logger.debug('Skipped:', 'No change in content')
|
|
1410
|
-
else
|
|
1411
|
-
item.date = date unless date.nil?
|
|
1412
|
-
item.title = title
|
|
1413
|
-
item.note.add(note, replace: true)
|
|
1414
|
-
logger.info('Edited:', item.title)
|
|
1415
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
1416
|
-
|
|
1417
|
-
write(@doing_file)
|
|
1418
|
-
end
|
|
1419
|
-
end
|
|
1420
|
-
|
|
1421
|
-
##
|
|
1422
|
-
## Accepts one tag and the raw text of a new item if the
|
|
1423
|
-
## passed tag is on any item, it's replaced with @done.
|
|
1424
|
-
## if new_item is not nil, it's tagged with the passed
|
|
1425
|
-
## tag and inserted. This is for use where only one
|
|
1426
|
-
## instance of a given tag should exist (@meanwhile)
|
|
1427
|
-
##
|
|
1428
|
-
## @param target_tag [String] Tag to replace
|
|
1429
|
-
## @param opt [Hash] Additional Options
|
|
1430
|
-
##
|
|
1431
|
-
## @option opt :section [String] target section
|
|
1432
|
-
## @option opt :archive [Boolean] archive old item
|
|
1433
|
-
## @option opt :back [Date] backdate new item
|
|
1434
|
-
## @option opt :new_item [String] content to use for new item
|
|
1435
|
-
## @option opt :note [Array] note content for new item
|
|
1436
|
-
def stop_start(target_tag, opt)
|
|
1437
|
-
opt ||= {}
|
|
1438
|
-
tag = target_tag.dup
|
|
1439
|
-
opt[:section] ||= Doing.setting('current_section')
|
|
1440
|
-
opt[:archive] ||= false
|
|
1441
|
-
opt[:back] ||= Time.now
|
|
1442
|
-
opt[:new_item] ||= false
|
|
1443
|
-
opt[:note] ||= false
|
|
1444
|
-
|
|
1445
|
-
opt[:section] = guess_section(opt[:section])
|
|
1446
|
-
|
|
1447
|
-
tag.sub!(/^@/, '')
|
|
1448
|
-
|
|
1449
|
-
found_items = 0
|
|
1450
|
-
|
|
1451
|
-
@content.each_with_index do |item, i|
|
|
1452
|
-
old_item = i.clone
|
|
1453
|
-
next unless item.section == opt[:section] || opt[:section] =~ /all/i
|
|
1454
|
-
|
|
1455
|
-
next unless item.title =~ /@#{tag}/
|
|
1456
|
-
|
|
1457
|
-
item.title.add_tags!([tag, 'done'], remove: true)
|
|
1458
|
-
item.tag('done', value: opt[:back].strftime('%F %R'))
|
|
1459
|
-
|
|
1460
|
-
found_items += 1
|
|
1461
|
-
|
|
1462
|
-
if opt[:archive] && opt[:section] != 'Archive'
|
|
1463
|
-
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
|
1464
|
-
item.move_to('Archive', label: false, log: false)
|
|
1465
|
-
logger.count(:completed_archived)
|
|
1466
|
-
logger.info('Completed/archived:', item.title)
|
|
1467
|
-
else
|
|
1468
|
-
logger.count(:completed)
|
|
1469
|
-
logger.info('Completed:', item.title)
|
|
1470
|
-
end
|
|
1471
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
1472
|
-
end
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
|
1476
|
-
|
|
1477
|
-
if opt[:new_item]
|
|
1478
|
-
date, title, note = format_input(opt[:new_item])
|
|
1479
|
-
opt[:back] = date unless date.nil?
|
|
1480
|
-
note.add(opt[:note]) if opt[:note]
|
|
1481
|
-
title.tag!(tag)
|
|
1482
|
-
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
|
1483
|
-
end
|
|
1484
|
-
|
|
1485
|
-
write(@doing_file)
|
|
1486
|
-
end
|
|
1487
|
-
|
|
1488
|
-
##
|
|
1489
|
-
## Write content to file or STDOUT
|
|
1490
|
-
##
|
|
1491
|
-
## @param file [String] The filepath to write to
|
|
1492
|
-
##
|
|
1493
|
-
def write(file = nil, backup: true)
|
|
1494
|
-
Hooks.trigger :pre_write, self, file
|
|
1495
|
-
output = combined_content
|
|
1496
|
-
if file.nil?
|
|
1497
|
-
$stdout.puts output
|
|
1498
|
-
else
|
|
1499
|
-
Util.write_to_file(file, output, backup: backup)
|
|
1500
|
-
run_after if Doing.setting('run_after')
|
|
1501
|
-
end
|
|
1502
|
-
end
|
|
1503
|
-
|
|
1504
|
-
##
|
|
1505
|
-
## Rename doing file with date and start fresh one
|
|
1506
|
-
##
|
|
1507
|
-
def rotate(opt)
|
|
1508
|
-
opt ||= {}
|
|
1509
|
-
keep = opt[:keep] || 0
|
|
1510
|
-
tags = []
|
|
1511
|
-
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
|
1512
|
-
bool = opt[:bool] || :and
|
|
1513
|
-
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
|
1514
|
-
|
|
1515
|
-
section = guess_section(sect)
|
|
1516
|
-
|
|
1517
|
-
section_items = @content.in_section(section)
|
|
1518
|
-
max = section_items.count - keep.to_i
|
|
1519
|
-
|
|
1520
|
-
counter = 0
|
|
1521
|
-
new_content = Items.new
|
|
1522
|
-
|
|
1523
|
-
section_items.each do |item|
|
|
1524
|
-
break if counter >= max
|
|
1525
|
-
if opt[:before]
|
|
1526
|
-
time_string = opt[:before]
|
|
1527
|
-
cutoff = time_string.chronify(guess: :begin)
|
|
1528
|
-
end
|
|
1529
|
-
|
|
1530
|
-
unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
|
|
1531
|
-
new_item = @content.delete(item)
|
|
1532
|
-
Hooks.trigger :post_entry_removed, self, item.clone
|
|
1533
|
-
raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
|
|
1534
|
-
|
|
1535
|
-
new_content.add_section(new_item.section, log: false)
|
|
1536
|
-
new_content.push(new_item)
|
|
1537
|
-
counter += 1
|
|
1538
|
-
end
|
|
1539
|
-
end
|
|
1540
|
-
|
|
1541
|
-
if counter.positive?
|
|
1542
|
-
logger.count(:rotated,
|
|
1543
|
-
level: :info,
|
|
1544
|
-
count: counter,
|
|
1545
|
-
message: "Rotated %count %items")
|
|
1546
|
-
else
|
|
1547
|
-
logger.info('Skipped:', 'No items were rotated')
|
|
1548
|
-
end
|
|
1549
|
-
|
|
1550
|
-
write(@doing_file)
|
|
1551
|
-
|
|
1552
|
-
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
|
1553
|
-
if File.exist?(file)
|
|
1554
|
-
init_doing_file(file)
|
|
1555
|
-
@content.concat(new_content).uniq!
|
|
1556
|
-
logger.warn('File update:', "added entries to existing file: #{file}")
|
|
1557
|
-
else
|
|
1558
|
-
@content = new_content
|
|
1559
|
-
logger.warn('File update:', "created new file: #{file}")
|
|
1560
|
-
end
|
|
1561
|
-
|
|
1562
|
-
write(file, backup: false)
|
|
1563
|
-
end
|
|
1564
|
-
|
|
1565
|
-
##
|
|
1566
|
-
## Generate a menu of sections and allow user selection
|
|
1567
|
-
##
|
|
1568
|
-
## @return [String] The selected section name
|
|
1569
|
-
##
|
|
1570
|
-
def choose_section(include_all: false)
|
|
1571
|
-
options = @content.section_titles.sort
|
|
1572
|
-
options.unshift('All') if include_all
|
|
1573
|
-
choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
|
1574
|
-
choice ? choice.strip : choice
|
|
1575
|
-
end
|
|
1576
|
-
|
|
1577
|
-
##
|
|
1578
|
-
## Generate a menu of tags and allow user selection
|
|
1579
|
-
##
|
|
1580
|
-
## @return [String] The selected tag name
|
|
1581
|
-
##
|
|
1582
|
-
def choose_tag(section = 'All', items: nil, include_all: false)
|
|
1583
|
-
items ||= @content.in_section(section)
|
|
1584
|
-
tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" }
|
|
1585
|
-
tags.unshift('No tag filter') if include_all
|
|
1586
|
-
choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%'])
|
|
1587
|
-
choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice
|
|
1588
|
-
end
|
|
1589
|
-
|
|
1590
|
-
##
|
|
1591
|
-
## Generate a menu of sections and tags and allow user selection
|
|
1592
|
-
##
|
|
1593
|
-
## @return [String] The selected section or tag name
|
|
1594
|
-
##
|
|
1595
|
-
def choose_section_tag
|
|
1596
|
-
options = @content.section_titles.sort
|
|
1597
|
-
options.concat(@content.all_tags.sort.map { |t| "@#{t}" })
|
|
1598
|
-
choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%'])
|
|
1599
|
-
choice ? choice.strip : choice
|
|
1600
|
-
end
|
|
1601
|
-
|
|
1602
|
-
##
|
|
1603
|
-
## List available views
|
|
1604
|
-
##
|
|
1605
|
-
## @return [Array] View names
|
|
1606
|
-
##
|
|
1607
|
-
def views
|
|
1608
|
-
Doing.setting('views') ? Doing.setting('views').keys : []
|
|
1609
|
-
end
|
|
1610
|
-
|
|
1611
|
-
##
|
|
1612
|
-
## Generate a menu of views and allow user selection
|
|
1613
|
-
##
|
|
1614
|
-
## @return [String] The selected view name
|
|
1615
|
-
##
|
|
1616
|
-
def choose_view
|
|
1617
|
-
choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
|
1618
|
-
choice ? choice.strip : choice
|
|
1619
|
-
end
|
|
1620
|
-
|
|
1621
|
-
##
|
|
1622
|
-
## Gets a view from configuration
|
|
1623
|
-
##
|
|
1624
|
-
## @param title [String] The title of the view to retrieve
|
|
1625
|
-
##
|
|
1626
|
-
def get_view(title)
|
|
1627
|
-
return Doing.setting(['views', title], nil)
|
|
1628
|
-
|
|
1629
|
-
false
|
|
1630
|
-
end
|
|
1631
|
-
|
|
1632
|
-
##
|
|
1633
|
-
## Display contents of a section based on options
|
|
1634
|
-
##
|
|
1635
|
-
## @param opt [Hash] Additional Options
|
|
1636
|
-
##
|
|
1637
|
-
def list_section(opt, items: Items.new)
|
|
1638
|
-
logger.benchmark(:list_section, :start)
|
|
1639
|
-
opt[:config_template] ||= 'default'
|
|
1640
|
-
|
|
1641
|
-
tpl_cfg = Doing.setting(['templates', opt[:config_template]])
|
|
1642
|
-
|
|
1643
|
-
cfg = if opt[:view_template]
|
|
1644
|
-
Doing.setting(['views', opt[:view_template]]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true })
|
|
1645
|
-
else
|
|
1646
|
-
tpl_cfg
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
|
-
cfg.deep_merge({
|
|
1650
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
|
1651
|
-
'date_format' => Doing.setting('default_date_format'),
|
|
1652
|
-
'order' => Doing.setting('order') || :asc,
|
|
1653
|
-
'tags_color' => Doing.setting('tags_color'),
|
|
1654
|
-
'duration' => Doing.setting('duration'),
|
|
1655
|
-
'interval_format' => Doing.setting('interval_format')
|
|
1656
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
|
1657
|
-
|
|
1658
|
-
opt[:duration] ||= cfg['duration'] || false
|
|
1659
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
|
1660
|
-
opt[:count] ||= 0
|
|
1661
|
-
opt[:age] ||= :newest
|
|
1662
|
-
opt[:age] = opt[:age].normalize_age
|
|
1663
|
-
opt[:format] ||= cfg['date_format']
|
|
1664
|
-
opt[:order] ||= cfg['order'] || :asc
|
|
1665
|
-
opt[:tag_order] ||= :asc
|
|
1666
|
-
opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil?
|
|
1667
|
-
opt[:template] ||= cfg['template']
|
|
1668
|
-
opt[:sort_tags] ||= opt[:tag_sort]
|
|
1669
|
-
|
|
1670
|
-
# opt[:highlight] ||= true
|
|
1671
|
-
title = ''
|
|
1672
|
-
is_single = true
|
|
1673
|
-
if opt[:section].nil?
|
|
1674
|
-
opt[:section] = choose_section
|
|
1675
|
-
title = opt[:section]
|
|
1676
|
-
elsif opt[:section].instance_of?(String)
|
|
1677
|
-
title = if opt[:section] =~ /^all$/i
|
|
1678
|
-
if opt[:page_title]
|
|
1679
|
-
opt[:page_title]
|
|
1680
|
-
elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
|
|
1681
|
-
opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
|
|
1682
|
-
else
|
|
1683
|
-
'doing'
|
|
1684
|
-
end
|
|
1685
|
-
else
|
|
1686
|
-
guess_section(opt[:section])
|
|
1687
|
-
end
|
|
1688
|
-
end
|
|
1689
|
-
|
|
1690
|
-
items = filter_items(items, opt: opt)
|
|
1691
|
-
|
|
1692
|
-
items.reverse! unless opt[:order].normalize_order == :desc
|
|
1693
|
-
|
|
1694
|
-
if opt[:delete]
|
|
1695
|
-
delete_items(items, force: opt[:force])
|
|
1696
|
-
|
|
1697
|
-
write(@doing_file)
|
|
1698
|
-
return
|
|
1699
|
-
elsif opt[:editor]
|
|
1700
|
-
edit_items(items)
|
|
1701
|
-
|
|
1702
|
-
write(@doing_file)
|
|
1703
|
-
return
|
|
1704
|
-
elsif opt[:interactive]
|
|
1705
|
-
opt[:menu] = !opt[:force]
|
|
1706
|
-
opt[:query] = '' # opt[:search]
|
|
1707
|
-
opt[:multiple] = true
|
|
1708
|
-
selected = Prompt.choose_from_items(items.reverse, include_section: opt[:section] =~ /^all$/i, **opt)
|
|
1709
|
-
|
|
1710
|
-
raise NoResults, 'no items selected' if selected.nil? || selected.empty?
|
|
1711
|
-
|
|
1712
|
-
act_on(selected, opt)
|
|
1713
|
-
return
|
|
1714
|
-
end
|
|
1715
|
-
|
|
1716
|
-
opt[:output] ||= 'template'
|
|
1717
|
-
opt[:wrap_width] ||= Doing.setting('templates.default.wrap_width', 0)
|
|
1718
|
-
|
|
1719
|
-
logger.benchmark(:list_section, :finish)
|
|
1720
|
-
output(items, title, is_single, opt)
|
|
1721
|
-
end
|
|
1722
|
-
|
|
1723
|
-
##
|
|
1724
|
-
## Move entries from a section to Archive or other specified
|
|
1725
|
-
## section
|
|
1726
|
-
##
|
|
1727
|
-
## @param section [String] The source section
|
|
1728
|
-
## @param options [Hash] Options
|
|
1729
|
-
##
|
|
1730
|
-
def archive(section = Doing.setting('current_section'), options)
|
|
1731
|
-
options ||= {}
|
|
1732
|
-
count = options[:keep] || 0
|
|
1733
|
-
destination = options[:destination] || 'Archive'
|
|
1734
|
-
tags = options[:tags] || []
|
|
1735
|
-
bool = options[:bool] || :and
|
|
1736
|
-
|
|
1737
|
-
section = choose_section if section.nil? || section =~ /choose/i
|
|
1738
|
-
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
|
1739
|
-
section = guess_section(section) unless archive_all
|
|
1740
|
-
|
|
1741
|
-
@content.add_section(destination, log: true)
|
|
1742
|
-
# add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
|
|
1743
|
-
|
|
1744
|
-
destination = guess_section(destination)
|
|
1745
|
-
|
|
1746
|
-
if @content.section?(destination) && (@content.section?(section) || archive_all)
|
|
1747
|
-
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] })
|
|
1748
|
-
write(doing_file)
|
|
1749
|
-
else
|
|
1750
|
-
raise InvalidArgument, 'Either source or destination does not exist'
|
|
1751
|
-
end
|
|
1752
|
-
end
|
|
1753
|
-
|
|
1754
|
-
##
|
|
1755
|
-
## Show all entries from the current day
|
|
1756
|
-
##
|
|
1757
|
-
## @param times [Boolean] show times
|
|
1758
|
-
## @param output [String] output format
|
|
1759
|
-
## @param opt [Hash] Options
|
|
1760
|
-
##
|
|
1761
|
-
def today(times = true, output = nil, opt)
|
|
1762
|
-
opt ||= {}
|
|
1763
|
-
opt[:totals] ||= false
|
|
1764
|
-
opt[:sort_tags] ||= false
|
|
1765
|
-
|
|
1766
|
-
cfg = Doing.setting('templates').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
|
1767
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
|
1768
|
-
'date_format' => Doing.setting('default_date_format'),
|
|
1769
|
-
'order' => Doing.setting('order') || :asc,
|
|
1770
|
-
'tags_color' => Doing.setting('tags_color'),
|
|
1771
|
-
'duration' => Doing.setting('duration'),
|
|
1772
|
-
'interval_format' => Doing.setting('interval_format')
|
|
1773
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
|
1774
|
-
|
|
1775
|
-
template = opt[:template] || cfg['template']
|
|
1776
|
-
|
|
1777
|
-
opt[:duration] ||= cfg['duration'] || false
|
|
1778
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
|
1779
|
-
|
|
1780
|
-
options = {
|
|
1781
|
-
after: opt[:after],
|
|
1782
|
-
before: opt[:before],
|
|
1783
|
-
count: 0,
|
|
1784
|
-
duration: opt[:duration],
|
|
1785
|
-
from: opt[:from],
|
|
1786
|
-
format: cfg['date_format'],
|
|
1787
|
-
interval_format: opt[:interval_format],
|
|
1788
|
-
only_timed: opt[:only_timed],
|
|
1789
|
-
order: cfg['order'] || :asc,
|
|
1790
|
-
output: output,
|
|
1791
|
-
section: opt[:section],
|
|
1792
|
-
sort_tags: opt[:sort_tags],
|
|
1793
|
-
template: template,
|
|
1794
|
-
times: times,
|
|
1795
|
-
today: true,
|
|
1796
|
-
totals: opt[:totals],
|
|
1797
|
-
wrap_width: cfg['wrap_width'],
|
|
1798
|
-
tags_color: cfg['tags_color'],
|
|
1799
|
-
config_template: opt[:config_template]
|
|
1800
|
-
}
|
|
1801
|
-
list_section(options)
|
|
1802
|
-
end
|
|
1803
|
-
|
|
1804
|
-
##
|
|
1805
|
-
## Display entries within a date range
|
|
1806
|
-
##
|
|
1807
|
-
## @param dates [Array] [start, end]
|
|
1808
|
-
## @param section [String] The section
|
|
1809
|
-
## @param times (Bool) Show times
|
|
1810
|
-
## @param output [String] Output format
|
|
1811
|
-
## @param opt [Hash] Additional Options
|
|
1812
|
-
##
|
|
1813
|
-
def list_date(dates, section, times = nil, output = nil, opt)
|
|
1814
|
-
opt ||= {}
|
|
1815
|
-
opt[:totals] ||= false
|
|
1816
|
-
opt[:sort_tags] ||= false
|
|
1817
|
-
section = guess_section(section)
|
|
1818
|
-
# :date_filter expects an array with start and end date
|
|
1819
|
-
dates = dates.split_date_range if dates.instance_of?(String)
|
|
1820
|
-
|
|
1821
|
-
opt[:section] = section
|
|
1822
|
-
opt[:count] = 0
|
|
1823
|
-
opt[:order] = :asc
|
|
1824
|
-
opt[:date_filter] = dates
|
|
1825
|
-
opt[:times] = times
|
|
1826
|
-
opt[:output] = output
|
|
1827
|
-
|
|
1828
|
-
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
|
1829
|
-
if opt[:from] && opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
|
|
1830
|
-
opt[:time_filter] = opt[:from]
|
|
1831
|
-
end
|
|
1832
|
-
|
|
1833
|
-
list_section(opt)
|
|
1834
|
-
end
|
|
1835
|
-
|
|
1836
|
-
##
|
|
1837
|
-
## Show entries from the previous day
|
|
1838
|
-
##
|
|
1839
|
-
## @param section [String] The section
|
|
1840
|
-
## @param times (Bool) Show times
|
|
1841
|
-
## @param output [String] Output format
|
|
1842
|
-
## @param opt [Hash] Additional Options
|
|
1843
|
-
##
|
|
1844
|
-
def yesterday(section, times = nil, output = nil, opt)
|
|
1845
|
-
opt ||= {}
|
|
1846
|
-
opt[:totals] ||= false
|
|
1847
|
-
opt[:sort_tags] ||= false
|
|
1848
|
-
opt[:config_template] ||= 'today'
|
|
1849
|
-
opt[:yesterday] = true
|
|
1850
|
-
|
|
1851
|
-
section = guess_section(section)
|
|
1852
|
-
y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
|
|
1853
|
-
opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
|
|
1854
|
-
opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
|
|
1855
|
-
|
|
1856
|
-
opt[:output] = output
|
|
1857
|
-
opt[:section] = section
|
|
1858
|
-
opt[:times] = times
|
|
1859
|
-
opt[:count] = 0
|
|
1860
|
-
|
|
1861
|
-
list_section(opt)
|
|
1862
|
-
end
|
|
1863
|
-
|
|
1864
|
-
##
|
|
1865
|
-
## Show recent entries
|
|
1866
|
-
##
|
|
1867
|
-
## @param count [Integer] The number to show
|
|
1868
|
-
## @param section [String] The section to show from, default Currently
|
|
1869
|
-
## @param opt [Hash] Additional Options
|
|
1870
|
-
##
|
|
1871
|
-
def recent(count = 10, section = nil, opt)
|
|
1872
|
-
opt ||= {}
|
|
1873
|
-
times = opt[:t] || true
|
|
1874
|
-
opt[:totals] ||= false
|
|
1875
|
-
opt[:sort_tags] ||= false
|
|
1876
|
-
|
|
1877
|
-
cfg = Doing.setting('templates.recent').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
|
1878
|
-
'wrap_width' => Doing.setting('wrap_width') || 0,
|
|
1879
|
-
'date_format' => Doing.setting('default_date_format'),
|
|
1880
|
-
'order' => Doing.setting('order') || :asc,
|
|
1881
|
-
'tags_color' => Doing.setting('tags_color'),
|
|
1882
|
-
'duration' => Doing.setting('duration'),
|
|
1883
|
-
'interval_format' => Doing.setting('interval_format')
|
|
1884
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
|
1885
|
-
opt[:duration] ||= cfg['duration'] || false
|
|
1886
|
-
opt[:interval_format] ||= cfg['interval_format'] || 'text'
|
|
1887
|
-
|
|
1888
|
-
section ||= Doing.setting('current_section')
|
|
1889
|
-
section = guess_section(section)
|
|
1890
|
-
|
|
1891
|
-
opt[:section] = section
|
|
1892
|
-
opt[:wrap_width] = cfg['wrap_width']
|
|
1893
|
-
opt[:count] = count
|
|
1894
|
-
opt[:format] = cfg['date_format']
|
|
1895
|
-
opt[:template] = opt[:template] || cfg['template']
|
|
1896
|
-
opt[:order] = :asc
|
|
1897
|
-
opt[:times] = times
|
|
1898
|
-
|
|
1899
|
-
list_section(opt)
|
|
1900
|
-
end
|
|
1901
|
-
|
|
1902
|
-
##
|
|
1903
|
-
## Show the last entry
|
|
1904
|
-
##
|
|
1905
|
-
## @param times (Bool) Show times
|
|
1906
|
-
## @param section [String] Section to pull from, default Currently
|
|
1907
|
-
##
|
|
1908
|
-
def last(times: true, section: nil, options: {})
|
|
1909
|
-
section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
|
|
1910
|
-
cfg = Doing.setting(['templates', options[:config_template]]).deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
|
|
1911
|
-
'wrap_width' => Doing.setting('wrap_width', 0),
|
|
1912
|
-
'date_format' => Doing.setting('default_date_format'),
|
|
1913
|
-
'order' => Doing.setting('order', :asc),
|
|
1914
|
-
'tags_color' => Doing.setting('tags_color'),
|
|
1915
|
-
'duration' => Doing.setting('duration'),
|
|
1916
|
-
'interval_format' => Doing.setting('interval_format')
|
|
1917
|
-
}, { extend_existing_arrays: true, sort_merged_arrays: true })
|
|
1918
|
-
options[:duration] ||= cfg['duration'] || false
|
|
1919
|
-
options[:interval_format] ||= cfg['interval_format'] || 'text'
|
|
1920
|
-
|
|
1921
|
-
opts = {
|
|
1922
|
-
case: options[:case],
|
|
1923
|
-
config_template: 'last',
|
|
1924
|
-
count: 1,
|
|
1925
|
-
delete: options[:delete],
|
|
1926
|
-
duration: options[:duration],
|
|
1927
|
-
format: cfg['date_format'],
|
|
1928
|
-
interval_format: options[:interval_format],
|
|
1929
|
-
not: options[:negate],
|
|
1930
|
-
section: section,
|
|
1931
|
-
template: options[:template] || cfg['template'],
|
|
1932
|
-
times: times,
|
|
1933
|
-
val: options[:val],
|
|
1934
|
-
wrap_width: cfg['wrap_width']
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
if options[:tag]
|
|
1938
|
-
opts[:tag_filter] = {
|
|
1939
|
-
'tags' => options[:tag],
|
|
1940
|
-
'bool' => options[:tag_bool]
|
|
1941
|
-
}
|
|
1942
|
-
end
|
|
1943
|
-
|
|
1944
|
-
opts[:search] = options[:search] if options[:search]
|
|
1945
|
-
|
|
1946
|
-
list_section(opts)
|
|
1947
|
-
end
|
|
1948
|
-
|
|
1949
|
-
##
|
|
1950
|
-
## Uses 'autotag' configuration to turn keywords into tags for time tracking.
|
|
1951
|
-
## Does not repeat tags in a title, and only converts the first instance of an
|
|
1952
|
-
## untagged keyword
|
|
1953
|
-
##
|
|
1954
|
-
## @param string [String] The text to tag
|
|
1955
|
-
##
|
|
1956
|
-
def autotag(string)
|
|
1957
|
-
return unless string
|
|
1958
|
-
return string unless Doing.auto_tag
|
|
1959
|
-
|
|
1960
|
-
original = string.dup
|
|
1961
|
-
text = string.dup
|
|
1962
|
-
|
|
1963
|
-
current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
|
|
1964
|
-
tagged = {
|
|
1965
|
-
whitelisted: [],
|
|
1966
|
-
synonyms: [],
|
|
1967
|
-
transformed: [],
|
|
1968
|
-
replaced: []
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
Doing.setting('autotag.whitelist').each do |tag|
|
|
1972
|
-
next if text =~ /@#{tag}\b/i
|
|
1973
|
-
|
|
1974
|
-
text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
|
|
1975
|
-
m.downcase! unless tag =~ /[A-Z]/
|
|
1976
|
-
tagged[:whitelisted].push(m)
|
|
1977
|
-
"@#{m}"
|
|
1978
|
-
end
|
|
1979
|
-
end
|
|
1980
|
-
|
|
1981
|
-
Doing.setting('autotag.synonyms').each do |tag, v|
|
|
1982
|
-
v.each do |word|
|
|
1983
|
-
word = word.wildcard_to_rx
|
|
1984
|
-
next unless text =~ /\b#{word}\b/i
|
|
1985
|
-
|
|
1986
|
-
unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
|
|
1987
|
-
tagged[:synonyms].push(tag)
|
|
1988
|
-
tagged[:synonyms] = tagged[:synonyms].uniq
|
|
1989
|
-
end
|
|
1990
|
-
end
|
|
1991
|
-
end
|
|
1992
|
-
|
|
1993
|
-
if Doing.setting('autotag.transform')
|
|
1994
|
-
Doing.setting('autotag.transform').each do |tag|
|
|
1995
|
-
next unless tag =~ /\S+:\S+/
|
|
1996
|
-
|
|
1997
|
-
if tag =~ /::/
|
|
1998
|
-
rx, r = tag.split(/::/)
|
|
1999
|
-
else
|
|
2000
|
-
rx, r = tag.split(/:/)
|
|
2001
|
-
end
|
|
2002
|
-
|
|
2003
|
-
flag_rx = %r{/([r]+)$}
|
|
2004
|
-
if r =~ flag_rx
|
|
2005
|
-
flags = r.match(flag_rx)[1].split(//)
|
|
2006
|
-
r.sub!(flag_rx, '')
|
|
2007
|
-
end
|
|
2008
|
-
r.gsub!(/\$/, '\\')
|
|
2009
|
-
rx.sub!(/^@?/, '@')
|
|
2010
|
-
regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
|
|
2011
|
-
|
|
2012
|
-
text.sub!(regex) do
|
|
2013
|
-
m = Regexp.last_match
|
|
2014
|
-
new_tag = r
|
|
2015
|
-
|
|
2016
|
-
m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
|
|
2017
|
-
new_tag.gsub!("\\#{idx + 1}", v)
|
|
2018
|
-
end
|
|
2019
|
-
# Replace original tag if /r
|
|
2020
|
-
if flags&.include?('r')
|
|
2021
|
-
tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
|
2022
|
-
new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
|
|
2023
|
-
else
|
|
2024
|
-
tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
|
|
2025
|
-
tagged[:transformed] = tagged[:transformed].uniq
|
|
2026
|
-
m[0]
|
|
2027
|
-
end
|
|
2028
|
-
end
|
|
2029
|
-
end
|
|
2030
|
-
end
|
|
2031
|
-
|
|
2032
|
-
logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
|
|
2033
|
-
logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
|
|
2034
|
-
logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
|
|
2035
|
-
logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
|
|
2036
|
-
|
|
2037
|
-
tail_tags = tagged[:synonyms].concat(tagged[:transformed])
|
|
2038
|
-
tail_tags.sort!
|
|
2039
|
-
tail_tags.uniq!
|
|
2040
|
-
|
|
2041
|
-
text.add_tags!(tail_tags) unless tail_tags.empty?
|
|
2042
|
-
|
|
2043
|
-
if text == original
|
|
2044
|
-
logger.debug('Autotag:', "no change to \"#{text.strip}\"")
|
|
2045
|
-
else
|
|
2046
|
-
new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
|
|
2047
|
-
logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
|
|
2048
|
-
logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
|
|
2049
|
-
end
|
|
2050
|
-
|
|
2051
|
-
text.dedup_tags
|
|
2052
|
-
end
|
|
2053
|
-
|
|
2054
|
-
##
|
|
2055
|
-
## Get total elapsed time for all tags in
|
|
2056
|
-
## selection
|
|
2057
|
-
##
|
|
2058
|
-
## @param format [String] return format (html,
|
|
2059
|
-
## json, or text)
|
|
2060
|
-
## @param sort_by [Symbol] Sort by :name or :time
|
|
2061
|
-
## @param sort_order [Symbol] The sort order (:asc or :desc)
|
|
2062
|
-
##
|
|
2063
|
-
def tag_times(format: :text, sort_by: :time, sort_order: :asc)
|
|
2064
|
-
return '' if @timers.empty?
|
|
2065
|
-
|
|
2066
|
-
max = @timers.keys.sort_by(&:length).reverse[0].length + 1
|
|
2067
|
-
|
|
2068
|
-
total = @timers.delete('All')
|
|
2069
|
-
|
|
2070
|
-
tags_data = @timers.delete_if { |_k, v| v.zero? }
|
|
2071
|
-
sorted_tags_data = if sort_by.normalize_tag_sort == :name
|
|
2072
|
-
tags_data.sort_by { |k, _v| k }
|
|
2073
|
-
else
|
|
2074
|
-
tags_data.sort_by { |_k, v| v }
|
|
2075
|
-
end
|
|
2076
|
-
|
|
2077
|
-
sorted_tags_data.reverse! if sort_order.normalize_order == :asc
|
|
2078
|
-
case format
|
|
2079
|
-
when :html
|
|
2080
|
-
|
|
2081
|
-
output = <<EOHEAD
|
|
2082
|
-
<table>
|
|
2083
|
-
<caption id="tagtotals">Tag Totals</caption>
|
|
2084
|
-
<colgroup>
|
|
2085
|
-
<col style="text-align:left;"/>
|
|
2086
|
-
<col style="text-align:left;"/>
|
|
2087
|
-
</colgroup>
|
|
2088
|
-
<thead>
|
|
2089
|
-
<tr>
|
|
2090
|
-
<th style="text-align:left;">project</th>
|
|
2091
|
-
<th style="text-align:left;">time</th>
|
|
2092
|
-
</tr>
|
|
2093
|
-
</thead>
|
|
2094
|
-
<tbody>
|
|
2095
|
-
EOHEAD
|
|
2096
|
-
sorted_tags_data.reverse.each do |k, v|
|
|
2097
|
-
if v.positive?
|
|
2098
|
-
output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
|
|
2099
|
-
end
|
|
2100
|
-
end
|
|
2101
|
-
tail = <<EOTAIL
|
|
2102
|
-
<tr>
|
|
2103
|
-
<td style="text-align:left;" colspan="2"></td>
|
|
2104
|
-
</tr>
|
|
2105
|
-
</tbody>
|
|
2106
|
-
<tfoot>
|
|
2107
|
-
<tr>
|
|
2108
|
-
<td style="text-align:left;"><strong>Total</strong></td>
|
|
2109
|
-
<td style="text-align:left;">#{total.time_string(format: :clock)}</td>
|
|
2110
|
-
</tr>
|
|
2111
|
-
</tfoot>
|
|
2112
|
-
</table>
|
|
2113
|
-
EOTAIL
|
|
2114
|
-
output + tail
|
|
2115
|
-
when :markdown
|
|
2116
|
-
pad = sorted_tags_data.map { |k, _| k }.group_by(&:size).max.last[0].length
|
|
2117
|
-
pad = 7 if pad < 7
|
|
2118
|
-
output = <<~EOHEADER
|
|
2119
|
-
| #{' ' * (pad - 7)}project | time |
|
|
2120
|
-
| #{'-' * (pad - 1)}: | :------- |
|
|
2121
|
-
EOHEADER
|
|
2122
|
-
sorted_tags_data.reverse.each do |k, v|
|
|
2123
|
-
if v.positive?
|
|
2124
|
-
output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
|
|
2125
|
-
end
|
|
2126
|
-
end
|
|
2127
|
-
tail = '[Tag Totals]'
|
|
2128
|
-
output + tail
|
|
2129
|
-
when :json
|
|
2130
|
-
output = []
|
|
2131
|
-
sorted_tags_data.reverse.each do |k, v|
|
|
2132
|
-
output << {
|
|
2133
|
-
'tag' => k,
|
|
2134
|
-
'seconds' => v,
|
|
2135
|
-
'formatted' => v.time_string(format: :clock)
|
|
2136
|
-
}
|
|
2137
|
-
end
|
|
2138
|
-
output
|
|
2139
|
-
when :human
|
|
2140
|
-
output = []
|
|
2141
|
-
sorted_tags_data.reverse.each do |k, v|
|
|
2142
|
-
spacer = ''
|
|
2143
|
-
(max - k.length).times do
|
|
2144
|
-
spacer += ' '
|
|
2145
|
-
end
|
|
2146
|
-
output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
|
|
2147
|
-
end
|
|
2148
|
-
|
|
2149
|
-
header = '┏━━ Tag Totals '
|
|
2150
|
-
(max - 2).times { header += '━' }
|
|
2151
|
-
header += '┓'
|
|
2152
|
-
footer = '┗'
|
|
2153
|
-
(max + 12).times { footer += '━' }
|
|
2154
|
-
footer += '┛'
|
|
2155
|
-
divider = '┣'
|
|
2156
|
-
(max + 12).times { divider += '━' }
|
|
2157
|
-
divider += '┫'
|
|
2158
|
-
output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
|
|
2159
|
-
output += "\n#{divider}"
|
|
2160
|
-
spacer = ''
|
|
2161
|
-
(max - 6).times do
|
|
2162
|
-
spacer += ' '
|
|
2163
|
-
end
|
|
2164
|
-
total_time = total.time_string(format: :hm)
|
|
2165
|
-
total = "┃ #{spacer}total: "
|
|
2166
|
-
total += total_time
|
|
2167
|
-
total += ' ┃'
|
|
2168
|
-
output += "\n#{total}"
|
|
2169
|
-
output += "\n#{footer}"
|
|
2170
|
-
output
|
|
2171
|
-
else
|
|
2172
|
-
output = []
|
|
2173
|
-
sorted_tags_data.reverse.each do |k, v|
|
|
2174
|
-
spacer = ''
|
|
2175
|
-
(max - k.length).times do
|
|
2176
|
-
spacer += ' '
|
|
2177
|
-
end
|
|
2178
|
-
output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
|
|
2179
|
-
end
|
|
2180
|
-
|
|
2181
|
-
output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
|
|
2182
|
-
output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
|
|
2183
|
-
output
|
|
2184
|
-
end
|
|
2185
|
-
end
|
|
2186
|
-
|
|
2187
|
-
##
|
|
2188
|
-
## Gets the interval between entry's start
|
|
2189
|
-
## date and @done date
|
|
2190
|
-
##
|
|
2191
|
-
## @param item [Item] The entry
|
|
2192
|
-
## @param formatted [Boolean] Return human readable
|
|
2193
|
-
## time (default seconds)
|
|
2194
|
-
## @param record [Boolean] Add the interval to the
|
|
2195
|
-
## total for each tag
|
|
2196
|
-
##
|
|
2197
|
-
## @return Interval in seconds, or [d, h, m] array if
|
|
2198
|
-
## formatted is true. False if no end date or
|
|
2199
|
-
## interval is 0
|
|
2200
|
-
##
|
|
2201
|
-
def get_interval(item, formatted: true, record: true)
|
|
2202
|
-
if item.interval
|
|
2203
|
-
seconds = item.interval
|
|
2204
|
-
record_tag_times(item, seconds) if record
|
|
2205
|
-
return seconds.positive? ? seconds : false unless formatted
|
|
2206
|
-
|
|
2207
|
-
return seconds.positive? ? seconds.time_string(format: :clock) : false
|
|
2208
|
-
end
|
|
2209
|
-
|
|
2210
|
-
false
|
|
2211
|
-
end
|
|
2212
|
-
|
|
2213
|
-
##
|
|
2214
|
-
## Load configuration files and updated the @settings
|
|
2215
|
-
## attribute with a Doing::Configuration object
|
|
2216
|
-
##
|
|
2217
|
-
## @param filename [String] (optional) path to
|
|
2218
|
-
## alternative config file
|
|
2219
|
-
##
|
|
2220
|
-
def configure(filename = nil)
|
|
2221
|
-
logger.benchmark(:configure, :start)
|
|
2222
|
-
|
|
2223
|
-
if filename
|
|
2224
|
-
Doing.config_with(filename, { ignore_local: true })
|
|
2225
|
-
elsif ENV['DOING_CONFIG']
|
|
2226
|
-
Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true })
|
|
2227
|
-
end
|
|
2228
|
-
|
|
2229
|
-
logger.benchmark(:configure, :finish)
|
|
2230
|
-
|
|
2231
|
-
Doing.set('backup_dir', ENV['DOING_BACKUP_DIR']) if ENV['DOING_BACKUP_DIR']
|
|
2232
|
-
end
|
|
2233
|
-
|
|
2234
|
-
def get_diff(filename = nil)
|
|
2235
|
-
configure if Doing.settings.nil?
|
|
2236
|
-
|
|
2237
|
-
filename ||= Doing.setting('doing_file')
|
|
2238
|
-
init_doing_file(filename)
|
|
2239
|
-
current_content = @content.clone
|
|
2240
|
-
backup_file = Util::Backup.last_backup(filename, count: 1)
|
|
2241
|
-
raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil?
|
|
2242
|
-
|
|
2243
|
-
backup = WWID.new
|
|
2244
|
-
backup.config = Doing.settings
|
|
2245
|
-
backup.init_doing_file(backup_file)
|
|
2246
|
-
current_content.diff(backup.content)
|
|
2247
|
-
end
|
|
2248
|
-
|
|
2249
|
-
private
|
|
2250
|
-
|
|
2251
|
-
##
|
|
2252
|
-
## Wraps doing file content with additional
|
|
2253
|
-
## header/footer content
|
|
2254
|
-
##
|
|
2255
|
-
## @return [String] concatenated content
|
|
2256
|
-
##
|
|
2257
|
-
def combined_content
|
|
2258
|
-
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
|
2259
|
-
was_color = Color.coloring?
|
|
2260
|
-
Color.coloring = false
|
|
2261
|
-
@content.dedup!(match_section: true)
|
|
2262
|
-
output += @content.to_s
|
|
2263
|
-
output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
|
2264
|
-
# Just strip all ANSI colors from the content before writing to doing file
|
|
2265
|
-
Color.coloring = was_color
|
|
2266
|
-
|
|
2267
|
-
output.uncolor
|
|
2268
|
-
end
|
|
2269
|
-
|
|
2270
|
-
##
|
|
2271
|
-
## Generate output using available export plugins
|
|
2272
|
-
##
|
|
2273
|
-
## @param items [Array] The items
|
|
2274
|
-
## @param title [String] Page title
|
|
2275
|
-
## @param is_single [Boolean] Indicates if single
|
|
2276
|
-
## section
|
|
2277
|
-
## @param opt [Hash] Additional options
|
|
2278
|
-
##
|
|
2279
|
-
## @return [String] formatted output based on opt[:output]
|
|
2280
|
-
## template trigger
|
|
2281
|
-
##
|
|
2282
|
-
def output(items, title, is_single, opt)
|
|
2283
|
-
logger.benchmark(:output, :start)
|
|
2284
|
-
opt ||= {}
|
|
2285
|
-
out = nil
|
|
2286
|
-
|
|
2287
|
-
raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
|
|
2288
|
-
|
|
2289
|
-
export_options = { page_title: title, is_single: is_single, options: opt }
|
|
2290
|
-
|
|
2291
|
-
Hooks.trigger :pre_export, self, opt[:output], items
|
|
2292
|
-
|
|
2293
|
-
Plugins.plugins[:export].each do |_, options|
|
|
2294
|
-
next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
|
2295
|
-
|
|
2296
|
-
out = options[:class].render(self, items, variables: export_options)
|
|
2297
|
-
break
|
|
2298
|
-
end
|
|
2299
|
-
|
|
2300
|
-
logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
|
|
2301
|
-
logger.benchmark(:output, :finish)
|
|
2302
|
-
out
|
|
2303
|
-
end
|
|
2304
|
-
|
|
2305
|
-
##
|
|
2306
|
-
## Record times for item tags
|
|
2307
|
-
##
|
|
2308
|
-
## @param item [Item] The item to record
|
|
2309
|
-
##
|
|
2310
|
-
def record_tag_times(item, seconds)
|
|
2311
|
-
item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
|
|
2312
|
-
return if @recorded_items.include?(item_hash)
|
|
2313
|
-
item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
|
|
2314
|
-
k = m[0] == 'done' ? 'All' : m[0].downcase
|
|
2315
|
-
if @timers.key?(k)
|
|
2316
|
-
@timers[k] += seconds
|
|
2317
|
-
else
|
|
2318
|
-
@timers[k] = seconds
|
|
2319
|
-
end
|
|
2320
|
-
@recorded_items.push(item_hash)
|
|
2321
|
-
end
|
|
2322
|
-
end
|
|
2323
|
-
|
|
2324
|
-
##
|
|
2325
|
-
## Helper function, performs the actual archiving
|
|
2326
|
-
##
|
|
2327
|
-
## @param section [String] The source section
|
|
2328
|
-
## @param destination [String] The destination
|
|
2329
|
-
## section
|
|
2330
|
-
## @param opt [Hash] Additional Options
|
|
2331
|
-
##
|
|
2332
|
-
def do_archive(section, destination, opt)
|
|
2333
|
-
opt ||= {}
|
|
2334
|
-
count = opt[:count] || 0
|
|
2335
|
-
tags = opt[:tags] || []
|
|
2336
|
-
bool = opt[:bool] || :and
|
|
2337
|
-
label = opt[:label] || true
|
|
2338
|
-
|
|
2339
|
-
section = guess_section(section)
|
|
2340
|
-
destination = guess_section(destination)
|
|
2341
|
-
|
|
2342
|
-
section_items = @content.in_section(section)
|
|
2343
|
-
max = section_items.count - count.to_i
|
|
2344
|
-
|
|
2345
|
-
opt[:after] = opt[:from][0] if opt[:from]
|
|
2346
|
-
opt[:before] = opt[:from][1] if opt[:from]
|
|
2347
|
-
|
|
2348
|
-
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
|
|
2349
|
-
|
|
2350
|
-
if opt[:before].is_a?(String) && opt[:before] =~ time_rx
|
|
2351
|
-
opt[:before] = opt[:before].chronify(guess: :end, future: false)
|
|
2352
|
-
end
|
|
2353
|
-
|
|
2354
|
-
if opt[:after].is_a?(String) && opt[:after] =~ time_rx
|
|
2355
|
-
opt[:after] = opt[:after].chronify(guess: :begin, future: false)
|
|
2356
|
-
end
|
|
2357
|
-
|
|
2358
|
-
counter = 0
|
|
2359
|
-
|
|
2360
|
-
@content.map do |item|
|
|
2361
|
-
break if counter >= max
|
|
2362
|
-
|
|
2363
|
-
next if item.section.downcase == destination.downcase
|
|
2364
|
-
|
|
2365
|
-
next if item.section.downcase != section.downcase && section != /^all$/i
|
|
2366
|
-
|
|
2367
|
-
next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
|
|
2368
|
-
|
|
2369
|
-
next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
|
|
2370
|
-
|
|
2371
|
-
counter += 1
|
|
2372
|
-
old_item = item.clone
|
|
2373
|
-
item.move_to(destination, label: label, log: false)
|
|
2374
|
-
Hooks.trigger :post_entry_updated, self, item, old_item
|
|
2375
|
-
item
|
|
2376
|
-
end
|
|
2377
|
-
|
|
2378
|
-
if counter.positive?
|
|
2379
|
-
logger.count(destination == 'Archive' ? :archived : :moved,
|
|
2380
|
-
level: :info,
|
|
2381
|
-
count: counter,
|
|
2382
|
-
message: "%count %items from #{section} to #{destination}")
|
|
2383
|
-
else
|
|
2384
|
-
logger.info('Skipped:', 'No items were moved')
|
|
2385
|
-
end
|
|
2386
|
-
end
|
|
2387
|
-
|
|
2388
|
-
def run_after
|
|
2389
|
-
return unless Doing.setting('run_after')
|
|
2390
|
-
|
|
2391
|
-
_, stderr, status = Open3.capture3(Doing.setting('run_after'))
|
|
2392
|
-
return unless status.exitstatus.positive?
|
|
2393
|
-
|
|
2394
|
-
logger.log_now(:error, 'Script error:', "Error running #{Doing.setting('run_after')}")
|
|
2395
|
-
logger.log_now(:error, 'STDERR output:', stderr)
|
|
2396
|
-
end
|
|
2397
|
-
end
|
|
2398
|
-
end
|