doing 2.1.39 → 2.1.42
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
class WWID
|
5
|
+
##
|
6
|
+
## Create a process for an editor and wait for the file handle to return
|
7
|
+
##
|
8
|
+
## @param input [String] Text input for editor
|
9
|
+
##
|
10
|
+
def fork_editor(input = '', message: :default)
|
11
|
+
# raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
|
12
|
+
|
13
|
+
raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
14
|
+
|
15
|
+
tmpfile = Tempfile.new(['doing_temp', '.doing'])
|
16
|
+
|
17
|
+
File.open(tmpfile.path, 'w+') do |f|
|
18
|
+
f.puts input
|
19
|
+
unless message.nil?
|
20
|
+
f.puts message == :default ? '# First line is the entry title, lines after are added as a note' : message
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
|
25
|
+
|
26
|
+
trap('INT') do
|
27
|
+
begin
|
28
|
+
Process.kill(9, pid)
|
29
|
+
rescue StandardError
|
30
|
+
Errno::ESRCH
|
31
|
+
end
|
32
|
+
tmpfile.unlink
|
33
|
+
tmpfile.close!
|
34
|
+
exit 0
|
35
|
+
end
|
36
|
+
|
37
|
+
Process.wait(pid)
|
38
|
+
|
39
|
+
begin
|
40
|
+
if $?.exitstatus == 0
|
41
|
+
input = IO.read(tmpfile.path)
|
42
|
+
else
|
43
|
+
exit_now! 'Cancelled'
|
44
|
+
end
|
45
|
+
ensure
|
46
|
+
tmpfile.close
|
47
|
+
tmpfile.unlink
|
48
|
+
end
|
49
|
+
|
50
|
+
input.split(/\n/).delete_if(&:ignore?).join("\n")
|
51
|
+
end
|
52
|
+
|
53
|
+
##
|
54
|
+
## Takes a multi-line string and formats it as an entry
|
55
|
+
##
|
56
|
+
## @param input [String] The string to parse
|
57
|
+
##
|
58
|
+
## @return [Array] [[String]title, [Note]note]
|
59
|
+
##
|
60
|
+
def format_input(input)
|
61
|
+
raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
|
62
|
+
|
63
|
+
input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
|
64
|
+
title = input_lines[0]&.strip
|
65
|
+
raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
|
66
|
+
|
67
|
+
date = nil
|
68
|
+
iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
|
69
|
+
date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
|
70
|
+
|
71
|
+
raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
|
72
|
+
|
73
|
+
title.expand_date_tags(Doing.setting('date_tags'))
|
74
|
+
|
75
|
+
if title =~ date_rx
|
76
|
+
m = title.match(date_rx)
|
77
|
+
d = m['date']
|
78
|
+
date = if d =~ iso_rx
|
79
|
+
Time.parse(d)
|
80
|
+
else
|
81
|
+
d.chronify(guess: :begin)
|
82
|
+
end
|
83
|
+
title.sub!(date_rx, '').strip!
|
84
|
+
end
|
85
|
+
|
86
|
+
note = Note.new
|
87
|
+
note.add(input_lines[1..-1]) if input_lines.length > 1
|
88
|
+
# If title line ends in a parenthetical, use that as the note
|
89
|
+
if note.empty? && title =~ /\s+\(.*?\)$/
|
90
|
+
title.sub!(/\s+\((?<note>.*?)\)$/) do
|
91
|
+
m = Regexp.last_match
|
92
|
+
note.add(m['note'])
|
93
|
+
''
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
note.strip_lines!
|
98
|
+
note.compress
|
99
|
+
|
100
|
+
[date, title, note]
|
101
|
+
end
|
102
|
+
|
103
|
+
def add_with_editor(**options)
|
104
|
+
raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
105
|
+
|
106
|
+
input = options[:date].strftime('%F %R | ')
|
107
|
+
input += options[:title]
|
108
|
+
input += "\n#{options[:note]}" if options[:note]
|
109
|
+
input = fork_editor(input).strip
|
110
|
+
|
111
|
+
d, title, note = format_input(input)
|
112
|
+
raise EmptyInput, 'No content' if title.empty?
|
113
|
+
|
114
|
+
if options[:ask]
|
115
|
+
ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
|
116
|
+
note.add(ask_note) unless ask_note.empty?
|
117
|
+
end
|
118
|
+
|
119
|
+
date = d.nil? ? options[:date] : d
|
120
|
+
finish = options[:finish_last] || false
|
121
|
+
add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
|
122
|
+
write(@doing_file)
|
123
|
+
end
|
124
|
+
|
125
|
+
def edit_items(items)
|
126
|
+
items.sort_by! { |i| i.date }
|
127
|
+
editable_items = []
|
128
|
+
|
129
|
+
items.each do |i|
|
130
|
+
editable = "#{i.date.strftime('%F %R')} | #{i.title}"
|
131
|
+
old_note = i.note ? i.note.strip_lines.join("\n") : nil
|
132
|
+
editable += "\n#{old_note}" unless old_note.nil?
|
133
|
+
editable_items << editable
|
134
|
+
end
|
135
|
+
divider = "-----------"
|
136
|
+
notice =<<~EONOTICE
|
137
|
+
|
138
|
+
# - You may delete entries, but leave all divider lines (---) in place.
|
139
|
+
# - Start and @done dates replaced with a time string (yesterday 3pm) will
|
140
|
+
# be parsed automatically. Do not delete the pipe (|) between start date
|
141
|
+
# and entry title.
|
142
|
+
EONOTICE
|
143
|
+
input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n"
|
144
|
+
|
145
|
+
new_items = fork_editor(input, message: notice).split(/^#{divider}/).map(&:strip)
|
146
|
+
|
147
|
+
new_items.each_with_index do |new_item, i|
|
148
|
+
input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
|
149
|
+
first_line = input_lines[0]&.strip
|
150
|
+
|
151
|
+
if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
|
152
|
+
deleted = @content.delete_item(items[i], single: new_items.count == 1)
|
153
|
+
Hooks.trigger :post_entry_removed, self, deleted
|
154
|
+
Doing.logger.info('Deleted:', deleted.title)
|
155
|
+
else
|
156
|
+
date, title, note = format_input(new_item)
|
157
|
+
|
158
|
+
note.map!(&:strip)
|
159
|
+
note.delete_if(&:ignore?)
|
160
|
+
item = items[i]
|
161
|
+
old_item = item.clone
|
162
|
+
item.date = date || items[i].date
|
163
|
+
item.title = title
|
164
|
+
item.note = note
|
165
|
+
if (item.equal?(old_item))
|
166
|
+
Doing.logger.count(:skipped, level: :debug)
|
167
|
+
else
|
168
|
+
Doing.logger.count(:updated)
|
169
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
##
|
176
|
+
## Edit the last entry
|
177
|
+
##
|
178
|
+
## @param section [String] The section, default "All"
|
179
|
+
##
|
180
|
+
def edit_last(section: 'All', options: {})
|
181
|
+
options[:section] = guess_section(section)
|
182
|
+
|
183
|
+
item = last_entry(options)
|
184
|
+
|
185
|
+
if item.nil?
|
186
|
+
logger.debug('Skipped:', 'No entries found')
|
187
|
+
return
|
188
|
+
end
|
189
|
+
|
190
|
+
old_item = item.clone
|
191
|
+
content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
|
192
|
+
content << item.note.strip_lines.join("\n") unless item.note.empty?
|
193
|
+
new_item = fork_editor(content.join("\n"))
|
194
|
+
raise UserCancelled, 'No change' if new_item.strip == content.join("\n").strip
|
195
|
+
|
196
|
+
date, title, note = format_input(new_item)
|
197
|
+
date ||= item.date
|
198
|
+
|
199
|
+
if title.nil? || title.empty?
|
200
|
+
logger.debug('Skipped:', 'No content provided')
|
201
|
+
elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
|
202
|
+
logger.debug('Skipped:', 'No change in content')
|
203
|
+
else
|
204
|
+
item.date = date unless date.nil?
|
205
|
+
item.title = title
|
206
|
+
item.note.add(note, replace: true)
|
207
|
+
logger.info('Edited:', item.title)
|
208
|
+
Hooks.trigger :post_entry_updated, self, item, old_item
|
209
|
+
|
210
|
+
write(@doing_file)
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
class WWID
|
5
|
+
##
|
6
|
+
## Initializes the doing file.
|
7
|
+
##
|
8
|
+
## @param path [String] Override path to a doing file, optional
|
9
|
+
##
|
10
|
+
def init_doing_file(path = nil)
|
11
|
+
@doing_file = File.expand_path(Doing.setting('doing_file'))
|
12
|
+
|
13
|
+
if path.nil?
|
14
|
+
create(@doing_file) unless File.exist?(@doing_file)
|
15
|
+
input = IO.read(@doing_file)
|
16
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
17
|
+
logger.debug('Read:', "read file #{@doing_file}")
|
18
|
+
elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
|
19
|
+
@doing_file = File.expand_path(path)
|
20
|
+
input = IO.read(File.expand_path(path))
|
21
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
22
|
+
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
23
|
+
elsif path.length < 256
|
24
|
+
@doing_file = File.expand_path(path)
|
25
|
+
create(path)
|
26
|
+
input = IO.read(File.expand_path(path))
|
27
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
28
|
+
logger.debug('Read:', "read file #{File.expand_path(path)}")
|
29
|
+
end
|
30
|
+
|
31
|
+
@other_content_top = []
|
32
|
+
@other_content_bottom = []
|
33
|
+
|
34
|
+
section = nil
|
35
|
+
lines = input.split(/[\n\r]/)
|
36
|
+
|
37
|
+
lines.each do |line|
|
38
|
+
next if line =~ /^\s*$/
|
39
|
+
|
40
|
+
if line =~ /^(\S[\S ]+):\s*(@[\w\-_.]+\s*)*$/
|
41
|
+
section = Regexp.last_match(1)
|
42
|
+
@content.add_section(Section.new(section, original: line), log: false)
|
43
|
+
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
|
44
|
+
if section.nil?
|
45
|
+
section = 'Uncategorized'
|
46
|
+
@content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
|
47
|
+
end
|
48
|
+
|
49
|
+
date = Regexp.last_match(1).strip
|
50
|
+
title = Regexp.last_match(2).strip
|
51
|
+
item = Item.new(date, title, section)
|
52
|
+
@content.push(item)
|
53
|
+
elsif @content.count.zero?
|
54
|
+
# if content[section].items.length - 1 == current
|
55
|
+
@other_content_top.push(line)
|
56
|
+
elsif line =~ /^\S/
|
57
|
+
@other_content_bottom.push(line)
|
58
|
+
else
|
59
|
+
prev_item = @content.last
|
60
|
+
prev_item.note = Note.new unless prev_item.note
|
61
|
+
|
62
|
+
prev_item.note.add(line)
|
63
|
+
# end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
Hooks.trigger :post_read, self
|
68
|
+
@initial_content = @content.clone
|
69
|
+
end
|
70
|
+
|
71
|
+
##
|
72
|
+
## Create a new doing file
|
73
|
+
##
|
74
|
+
def create(filename = nil)
|
75
|
+
filename = @doing_file if filename.nil?
|
76
|
+
return if File.exist?(filename) && File.stat(filename).size.positive?
|
77
|
+
|
78
|
+
FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
|
79
|
+
|
80
|
+
File.open(filename, 'w+') do |f|
|
81
|
+
f.puts "#{Doing.setting('current_section')}:"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
##
|
86
|
+
## Write content to file or STDOUT
|
87
|
+
##
|
88
|
+
## @param file [String] The filepath to write to
|
89
|
+
##
|
90
|
+
def write(file = nil, backup: true)
|
91
|
+
Hooks.trigger :pre_write, self, file
|
92
|
+
output = combined_content
|
93
|
+
if file.nil?
|
94
|
+
$stdout.puts output
|
95
|
+
else
|
96
|
+
Util.write_to_file(file, output, backup: backup)
|
97
|
+
run_after if Doing.setting('run_after')
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
##
|
102
|
+
## Rename doing file with date and start fresh one
|
103
|
+
##
|
104
|
+
def rotate(opt)
|
105
|
+
opt ||= {}
|
106
|
+
keep = opt[:keep] || 0
|
107
|
+
tags = []
|
108
|
+
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
109
|
+
bool = opt[:bool] || :and
|
110
|
+
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
111
|
+
|
112
|
+
section = guess_section(sect)
|
113
|
+
|
114
|
+
section_items = @content.in_section(section)
|
115
|
+
max = section_items.count - keep.to_i
|
116
|
+
|
117
|
+
counter = 0
|
118
|
+
new_content = Items.new
|
119
|
+
|
120
|
+
section_items.each do |item|
|
121
|
+
break if counter >= max
|
122
|
+
if opt[:before]
|
123
|
+
time_string = opt[:before]
|
124
|
+
cutoff = time_string.chronify(guess: :begin)
|
125
|
+
end
|
126
|
+
|
127
|
+
unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
|
128
|
+
new_item = @content.delete(item)
|
129
|
+
Hooks.trigger :post_entry_removed, self, item.clone
|
130
|
+
raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
|
131
|
+
|
132
|
+
new_content.add_section(new_item.section, log: false)
|
133
|
+
new_content.push(new_item)
|
134
|
+
counter += 1
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
if counter.positive?
|
139
|
+
logger.count(:rotated,
|
140
|
+
level: :info,
|
141
|
+
count: counter,
|
142
|
+
message: "Rotated %count %items")
|
143
|
+
else
|
144
|
+
logger.info('Skipped:', 'No items were rotated')
|
145
|
+
end
|
146
|
+
|
147
|
+
write(@doing_file)
|
148
|
+
|
149
|
+
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
150
|
+
if File.exist?(file)
|
151
|
+
init_doing_file(file)
|
152
|
+
@content.concat(new_content).uniq!
|
153
|
+
logger.warn('File update:', "added entries to existing file: #{file}")
|
154
|
+
else
|
155
|
+
@content = new_content
|
156
|
+
logger.warn('File update:', "created new file: #{file}")
|
157
|
+
end
|
158
|
+
|
159
|
+
write(file, backup: false)
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
##
|
165
|
+
## Wraps doing file content with additional
|
166
|
+
## header/footer content
|
167
|
+
##
|
168
|
+
## @return [String] concatenated content
|
169
|
+
## @api private
|
170
|
+
def combined_content
|
171
|
+
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
172
|
+
was_color = Color.coloring?
|
173
|
+
Color.coloring = false
|
174
|
+
@content.dedup!(match_section: true)
|
175
|
+
output += @content.to_s
|
176
|
+
output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
177
|
+
# Just strip all ANSI colors from the content before writing to doing file
|
178
|
+
Color.coloring = was_color
|
179
|
+
|
180
|
+
output.uncolor
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
class WWID
|
5
|
+
# Use fzf to filter an Items object with a search query.
|
6
|
+
# Faster than {#filter_items} when all you need is a
|
7
|
+
# text search of the title and note
|
8
|
+
#
|
9
|
+
# @param items [Items] an Items object
|
10
|
+
# @param query [String] The search query
|
11
|
+
# @param case_type [Symbol] The case type (:smart, :sensitive, :ignore)
|
12
|
+
#
|
13
|
+
# @return [Items] Filtered Items array
|
14
|
+
#
|
15
|
+
def fuzzy_filter_items(items, query, case_type: :smart)
|
16
|
+
scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
|
17
|
+
|
18
|
+
fzf_args = [
|
19
|
+
'--multi',
|
20
|
+
%(--filter="#{query.sub(/^'?/, "'")}"),
|
21
|
+
'--no-sort',
|
22
|
+
'-d "\|"',
|
23
|
+
'--nth=1'
|
24
|
+
]
|
25
|
+
fzf_args << case case_type.normalize_case
|
26
|
+
when :smart
|
27
|
+
query =~ /[A-Z]/ ? '+i' : '-i'
|
28
|
+
when :sensitive
|
29
|
+
'+i'
|
30
|
+
when :ignore
|
31
|
+
'-i'
|
32
|
+
end
|
33
|
+
|
34
|
+
# fzf_args << '-e' if opt[:exact]
|
35
|
+
# puts fzf_args.join(' ')
|
36
|
+
res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
|
37
|
+
selected = Items.new
|
38
|
+
res.split(/\n/).each do |item|
|
39
|
+
idx = item.match(/\|(\d+)$/)[1].to_i
|
40
|
+
selected.push(items[idx])
|
41
|
+
end
|
42
|
+
selected
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
## Filter items based on search criteria
|
47
|
+
##
|
48
|
+
## @param items [Array] The items to filter (if empty, filters all items)
|
49
|
+
## @param opt [Hash] The filter parameters
|
50
|
+
##
|
51
|
+
## @option opt [String] :section ('all')
|
52
|
+
## @option opt [Boolean] :unfinished (false)
|
53
|
+
## @option opt [Array or String] :tag ([]) Array or comma-separated string
|
54
|
+
## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
|
55
|
+
## @option opt [String] :search ('') string, optional regex with `/string/`
|
56
|
+
## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
|
57
|
+
## @option opt [Boolean] :only_timed (false)
|
58
|
+
## @option opt [String] :before (nil) Date/Time string, unparsed
|
59
|
+
## @option opt [String] :after (nil) Date/Time string, unparsed
|
60
|
+
## @option opt [Boolean] :today (false) limit to entries from today
|
61
|
+
## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
|
62
|
+
## @option opt [Number] :count (0) max entries to return
|
63
|
+
## @option opt [String] :age (new) 'old' or 'new'
|
64
|
+
## @option opt [Array] :val (nil) Array of tag value queries
|
65
|
+
##
|
66
|
+
def filter_items(items = Items.new, opt: {})
|
67
|
+
logger.benchmark(:filter_items, :start)
|
68
|
+
time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
|
69
|
+
|
70
|
+
if items.nil? || items.empty?
|
71
|
+
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
72
|
+
items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
|
73
|
+
end
|
74
|
+
|
75
|
+
unless opt[:time_filter]
|
76
|
+
opt[:time_filter] = [nil, nil]
|
77
|
+
if opt[:from] && !opt[:date_filter]
|
78
|
+
if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
|
79
|
+
opt[:time_filter] = opt[:from]
|
80
|
+
elsif opt[:from][0].is_a?(Time)
|
81
|
+
opt[:date_filter] = opt[:from]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
if opt[:before].is_a?(String) && opt[:before] =~ time_rx
|
87
|
+
opt[:time_filter][1] = opt[:before]
|
88
|
+
opt[:before] = nil
|
89
|
+
end
|
90
|
+
|
91
|
+
if opt[:after].is_a?(String) && opt[:after] =~ time_rx
|
92
|
+
opt[:time_filter][0] = opt[:after]
|
93
|
+
opt[:after] = nil
|
94
|
+
end
|
95
|
+
|
96
|
+
items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
|
97
|
+
|
98
|
+
filtered_items = items.select do |item|
|
99
|
+
keep = true
|
100
|
+
if opt[:unfinished]
|
101
|
+
finished = item.tags?('done', :and)
|
102
|
+
finished = opt[:not] ? !finished : finished
|
103
|
+
keep = false if finished
|
104
|
+
end
|
105
|
+
|
106
|
+
if keep && opt[:val]&.count&.positive?
|
107
|
+
bool = opt[:bool].normalize_bool if opt[:bool]
|
108
|
+
bool ||= :and
|
109
|
+
bool = :and if bool == :pattern
|
110
|
+
|
111
|
+
val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
|
112
|
+
keep = false unless val_match
|
113
|
+
keep = opt[:not] ? !keep : keep
|
114
|
+
end
|
115
|
+
|
116
|
+
if keep && opt[:tag]
|
117
|
+
opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
|
118
|
+
opt[:tag_bool] ||= :and
|
119
|
+
tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
|
120
|
+
keep = false unless tag_match
|
121
|
+
keep = opt[:not] ? !keep : keep
|
122
|
+
end
|
123
|
+
|
124
|
+
if keep && opt[:search]
|
125
|
+
search_match = if opt[:search].nil? || opt[:search].empty?
|
126
|
+
true
|
127
|
+
else
|
128
|
+
item.search(opt[:search], case_type: opt[:case].normalize_case)
|
129
|
+
end
|
130
|
+
|
131
|
+
keep = false unless search_match
|
132
|
+
keep = opt[:not] ? !keep : keep
|
133
|
+
end
|
134
|
+
|
135
|
+
if keep && opt[:date_filter]&.length == 2
|
136
|
+
start_date = opt[:date_filter][0]
|
137
|
+
end_date = opt[:date_filter][1]
|
138
|
+
|
139
|
+
in_date_range = if end_date
|
140
|
+
item.date >= start_date && item.date <= end_date
|
141
|
+
else
|
142
|
+
item.date.strftime('%F') == start_date.strftime('%F')
|
143
|
+
end
|
144
|
+
keep = false unless in_date_range
|
145
|
+
keep = opt[:not] ? !keep : keep
|
146
|
+
end
|
147
|
+
|
148
|
+
if keep && opt[:time_filter][0] || opt[:time_filter][1]
|
149
|
+
start_string = if opt[:time_filter][0].nil?
|
150
|
+
"#{item.date.strftime('%Y-%m-%d')} 12am"
|
151
|
+
else
|
152
|
+
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
|
153
|
+
end
|
154
|
+
start_time = start_string.chronify(guess: :begin)
|
155
|
+
|
156
|
+
end_string = if opt[:time_filter][1].nil?
|
157
|
+
"#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
|
158
|
+
else
|
159
|
+
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
|
160
|
+
end
|
161
|
+
end_time = end_string.chronify(guess: :end)
|
162
|
+
|
163
|
+
in_time_range = item.date >= start_time && item.date <= end_time
|
164
|
+
keep = false unless in_time_range
|
165
|
+
keep = opt[:not] ? !keep : keep
|
166
|
+
end
|
167
|
+
|
168
|
+
keep = false if keep && opt[:only_timed] && !item.interval
|
169
|
+
|
170
|
+
if keep && opt[:tag_filter]
|
171
|
+
keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
|
172
|
+
keep = opt[:not] ? !keep : keep
|
173
|
+
end
|
174
|
+
|
175
|
+
if keep && opt[:before]
|
176
|
+
before = opt[:before]
|
177
|
+
cutoff = if before =~ time_rx
|
178
|
+
"#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
|
179
|
+
elsif before.is_a?(String)
|
180
|
+
before.chronify(guess: :begin)
|
181
|
+
else
|
182
|
+
before
|
183
|
+
end
|
184
|
+
keep = cutoff && item.date <= cutoff
|
185
|
+
keep = opt[:not] ? !keep : keep
|
186
|
+
end
|
187
|
+
|
188
|
+
if keep && opt[:after]
|
189
|
+
after = opt[:after]
|
190
|
+
cutoff = if after =~ time_rx
|
191
|
+
"#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
|
192
|
+
elsif after.is_a?(String)
|
193
|
+
after.chronify(guess: :end)
|
194
|
+
else
|
195
|
+
after
|
196
|
+
end
|
197
|
+
keep = cutoff && item.date >= cutoff
|
198
|
+
keep = opt[:not] ? !keep : keep
|
199
|
+
end
|
200
|
+
|
201
|
+
if keep && opt[:today]
|
202
|
+
keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
|
203
|
+
keep = opt[:not] ? !keep : keep
|
204
|
+
elsif keep && opt[:yesterday]
|
205
|
+
keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
|
206
|
+
keep = opt[:not] ? !keep : keep
|
207
|
+
end
|
208
|
+
|
209
|
+
keep
|
210
|
+
end
|
211
|
+
count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
|
212
|
+
|
213
|
+
output = Items.new
|
214
|
+
|
215
|
+
if opt[:age] && opt[:age].normalize_age == :oldest
|
216
|
+
output.concat(filtered_items.slice(0, count).reverse)
|
217
|
+
else
|
218
|
+
output.concat(filtered_items.reverse.slice(0, count))
|
219
|
+
end
|
220
|
+
|
221
|
+
logger.benchmark(:filter_items, :finish)
|
222
|
+
|
223
|
+
output
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
class WWID
|
5
|
+
##
|
6
|
+
## Attempt to match a string with an existing section
|
7
|
+
##
|
8
|
+
## @param frag [String] The user-provided string
|
9
|
+
## @param guessed [Boolean] already guessed and failed
|
10
|
+
##
|
11
|
+
def guess_section(frag, guessed: false, suggest: false)
|
12
|
+
return 'All' if frag =~ /^all$/i
|
13
|
+
|
14
|
+
frag ||= Doing.setting('current_section')
|
15
|
+
|
16
|
+
return frag.cap_first if @content.section?(frag)
|
17
|
+
|
18
|
+
found = @content.guess_section(frag, distance: 2)
|
19
|
+
|
20
|
+
section = found ? found.title : nil
|
21
|
+
|
22
|
+
return section if suggest
|
23
|
+
|
24
|
+
unless section || guessed
|
25
|
+
alt = guess_view(frag, guessed: true, suggest: true)
|
26
|
+
if alt
|
27
|
+
prompt = Color.template("{bw}Did you mean `{xy}doing {by}view {xy}#{alt}`{bw}?{x}")
|
28
|
+
meant_view = Prompt.yn(prompt, default_response: 'n')
|
29
|
+
|
30
|
+
msg = format('%<y>srun with `%<w>sdoing view %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
|
31
|
+
raise Errors::WrongCommand.new(msg, topic: 'Try again:') if meant_view
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
|
36
|
+
|
37
|
+
if res
|
38
|
+
@content.add_section(frag.cap_first, log: true)
|
39
|
+
write(@doing_file)
|
40
|
+
return frag.cap_first
|
41
|
+
end
|
42
|
+
|
43
|
+
raise Errors::InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
|
44
|
+
end
|
45
|
+
section ? section.cap_first : guessed
|
46
|
+
end
|
47
|
+
|
48
|
+
##
|
49
|
+
## Attempt to match a string with an existing view
|
50
|
+
##
|
51
|
+
## @param frag [String] The user-provided string
|
52
|
+
## @param guessed [Boolean] already guessed
|
53
|
+
##
|
54
|
+
def guess_view(frag, guessed: false, suggest: false)
|
55
|
+
views.each { |view| return view if frag.downcase == view.downcase }
|
56
|
+
view = nil
|
57
|
+
re = frag.to_rx(distance: 2, case_type: :ignore)
|
58
|
+
views.each do |v|
|
59
|
+
next unless v =~ /#{re}/i
|
60
|
+
|
61
|
+
logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
|
62
|
+
view = v
|
63
|
+
break
|
64
|
+
end
|
65
|
+
unless view || guessed
|
66
|
+
alt = guess_section(frag, guessed: true, suggest: true)
|
67
|
+
|
68
|
+
raise Errors::InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
|
69
|
+
|
70
|
+
prompt = Color.template("{bw}Did you mean `{xy}doing {by}show {xy}#{alt}`{bw}?{x}")
|
71
|
+
meant_view = Prompt.yn(prompt, default_response: 'n')
|
72
|
+
|
73
|
+
if meant_view
|
74
|
+
msg = format('%<y>srun with `%<w>sdoing show %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
|
75
|
+
raise Errors::WrongCommand.new(msg, topic: 'Try again:')
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
raise Errors::InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
|
80
|
+
|
81
|
+
end
|
82
|
+
view
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|