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