doing 1.0.90 → 2.0.2.pre
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/AUTHORS +19 -0
- data/CHANGELOG.md +590 -0
- data/COMMANDS.md +1181 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +110 -0
- data/LICENSE +23 -0
- data/README.md +14 -697
- data/Rakefile +79 -0
- data/_config.yml +1 -0
- data/bin/doing +1037 -481
- data/doing.fish +278 -0
- data/doing.gemspec +34 -0
- data/doing.rdoc +1759 -0
- data/example_plugin.rb +209 -0
- data/generate_completions.sh +4 -0
- data/img/doing-colors.jpg +0 -0
- data/img/doing-printf-wrap-800.jpg +0 -0
- data/img/doing-show-note-formatting-800.jpg +0 -0
- data/lib/completion/_doing.zsh +151 -0
- data/lib/completion/doing.bash +416 -0
- data/lib/completion/doing.fish +278 -0
- data/lib/doing/array.rb +8 -0
- data/lib/doing/cli_status.rb +66 -0
- data/lib/doing/colors.rb +136 -0
- data/lib/doing/configuration.rb +310 -0
- data/lib/doing/errors.rb +102 -0
- data/lib/doing/hash.rb +31 -0
- data/lib/doing/hooks.rb +59 -0
- data/lib/doing/item.rb +155 -0
- data/lib/doing/log_adapter.rb +342 -0
- data/lib/doing/markdown_document_listener.rb +174 -0
- data/lib/doing/note.rb +59 -0
- data/lib/doing/pager.rb +95 -0
- data/lib/doing/plugin_manager.rb +208 -0
- data/lib/doing/plugins/export/csv_export.rb +48 -0
- data/lib/doing/plugins/export/html_export.rb +83 -0
- data/lib/doing/plugins/export/json_export.rb +140 -0
- data/lib/doing/plugins/export/markdown_export.rb +85 -0
- data/lib/doing/plugins/export/taskpaper_export.rb +34 -0
- data/lib/doing/plugins/export/template_export.rb +141 -0
- data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
- data/lib/doing/plugins/import/calendar_import.rb +76 -0
- data/lib/doing/plugins/import/doing_import.rb +144 -0
- data/lib/doing/plugins/import/timing_import.rb +78 -0
- data/lib/doing/string.rb +346 -0
- data/lib/doing/symbol.rb +16 -0
- data/lib/doing/time.rb +18 -0
- data/lib/doing/util.rb +186 -0
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +1838 -2266
- data/lib/doing/wwidfile.rb +117 -0
- data/lib/doing.rb +43 -2
- data/lib/examples/commands/wiki.rb +80 -0
- data/lib/examples/plugins/hooks.rb +22 -0
- data/lib/examples/plugins/say_export.rb +202 -0
- data/lib/examples/plugins/templates/wiki.css +169 -0
- data/lib/examples/plugins/templates/wiki.haml +27 -0
- data/lib/examples/plugins/templates/wiki_index.haml +18 -0
- data/lib/examples/plugins/wiki_export.rb +87 -0
- data/lib/templates/doing-markdown.erb +5 -0
- data/man/doing.1 +964 -0
- data/man/doing.1.html +711 -0
- data/man/doing.1.ronn +600 -0
- data/package-lock.json +3 -0
- data/rdoc_to_mmd.rb +42 -0
- data/rdocfixer.rb +13 -0
- data/scripts/generate_bash_completions.rb +210 -0
- data/scripts/generate_fish_completions.rb +201 -0
- data/scripts/generate_zsh_completions.rb +164 -0
- metadata +82 -6
- data/lib/doing/helpers.rb +0 -121
data/lib/doing/wwid.rb
CHANGED
@@ -1,2587 +1,2159 @@
|
|
1
1
|
#!/usr/bin/ruby
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
4
|
require 'deep_merge'
|
4
5
|
require 'open3'
|
5
6
|
require 'pp'
|
6
7
|
require 'shellwords'
|
8
|
+
require 'erb'
|
9
|
+
|
10
|
+
module Doing
|
11
|
+
##
|
12
|
+
## @brief 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, :auto_tag, :default_option
|
18
|
+
|
19
|
+
# include Util
|
20
|
+
|
21
|
+
##
|
22
|
+
## @brief Initializes the object.
|
23
|
+
##
|
24
|
+
def initialize
|
25
|
+
@timers = {}
|
26
|
+
@recorded_items = []
|
27
|
+
@content = {}
|
28
|
+
@doingrc_needs_update = false
|
29
|
+
@default_config_file = '.doingrc'
|
30
|
+
@auto_tag = true
|
31
|
+
@user_home = Util.user_home
|
32
|
+
end
|
33
|
+
|
34
|
+
##
|
35
|
+
## @brief Logger
|
36
|
+
##
|
37
|
+
## Responds to :debug, :info, :warn, and :error
|
38
|
+
##
|
39
|
+
## Each method takes a topic, and a message or block
|
40
|
+
##
|
41
|
+
## Example: debug('Hooks', 'Hook 1 triggered')
|
42
|
+
##
|
43
|
+
def logger
|
44
|
+
@logger ||= Doing.logger
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
## @brief Initializes the doing file.
|
49
|
+
##
|
50
|
+
## @param path (String) Override path to a doing file, optional
|
51
|
+
##
|
52
|
+
def init_doing_file(path = nil)
|
53
|
+
@doing_file = File.expand_path(@config['doing_file'])
|
54
|
+
|
55
|
+
if path.nil?
|
56
|
+
create(@doing_file) unless File.exist?(@doing_file)
|
57
|
+
input = IO.read(@doing_file)
|
58
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
59
|
+
elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
|
60
|
+
@doing_file = File.expand_path(path)
|
61
|
+
input = IO.read(File.expand_path(path))
|
62
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
63
|
+
elsif path.length < 256
|
64
|
+
@doing_file = File.expand_path(path)
|
65
|
+
create(path)
|
66
|
+
input = IO.read(File.expand_path(path))
|
67
|
+
input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
|
68
|
+
end
|
7
69
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
70
|
+
@other_content_top = []
|
71
|
+
@other_content_bottom = []
|
72
|
+
|
73
|
+
section = 'Uncategorized'
|
74
|
+
lines = input.split(/[\n\r]/)
|
75
|
+
current = 0
|
76
|
+
|
77
|
+
lines.each do |line|
|
78
|
+
next if line =~ /^\s*$/
|
79
|
+
|
80
|
+
if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
|
81
|
+
section = Regexp.last_match(1)
|
82
|
+
@content[section] = {}
|
83
|
+
@content[section][:original] = line
|
84
|
+
@content[section][:items] = []
|
85
|
+
current = 0
|
86
|
+
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
|
87
|
+
date = Regexp.last_match(1).strip
|
88
|
+
title = Regexp.last_match(2).strip
|
89
|
+
item = Item.new(date, title, section)
|
90
|
+
@content[section][:items].push(item)
|
91
|
+
current += 1
|
92
|
+
elsif current.zero?
|
93
|
+
# if content[section][:items].length - 1 == current
|
94
|
+
@other_content_top.push(line)
|
95
|
+
elsif line =~ /^\S/
|
96
|
+
@other_content_bottom.push(line)
|
97
|
+
else
|
98
|
+
prev_item = @content[section][:items][current - 1]
|
99
|
+
prev_item.note = Note.new unless prev_item.note
|
35
100
|
|
36
|
-
|
101
|
+
prev_item.note.add(line)
|
102
|
+
# end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
Hooks.trigger :post_read, self
|
106
|
+
end
|
37
107
|
|
38
|
-
|
39
|
-
|
108
|
+
##
|
109
|
+
## @brief Create a new doing file
|
110
|
+
##
|
111
|
+
def create(filename = nil)
|
112
|
+
filename = @doing_file if filename.nil?
|
113
|
+
return if File.exist?(filename) && File.stat(filename).size.positive?
|
40
114
|
|
41
|
-
|
115
|
+
File.open(filename, 'w+') do |f|
|
116
|
+
f.puts "#{@config['current_section']}:"
|
117
|
+
end
|
42
118
|
end
|
43
119
|
|
44
|
-
|
45
|
-
|
120
|
+
##
|
121
|
+
## @brief Create a process for an editor and wait for the file handle to return
|
122
|
+
##
|
123
|
+
## @param input (String) Text input for editor
|
124
|
+
##
|
125
|
+
def fork_editor(input = '')
|
126
|
+
# raise Errors::NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
|
46
127
|
|
47
|
-
|
48
|
-
## @brief Reads a configuration.
|
49
|
-
##
|
50
|
-
def read_config(opt = {})
|
51
|
-
@config_file ||= if Dir.respond_to?('home')
|
52
|
-
File.join(Dir.home, @default_config_file)
|
53
|
-
else
|
54
|
-
File.join(File.expand_path('~'), @default_config_file)
|
55
|
-
end
|
56
|
-
|
57
|
-
additional_configs = if opt[:ignore_local]
|
58
|
-
[]
|
59
|
-
else
|
60
|
-
find_local_config
|
61
|
-
end
|
128
|
+
raise Errors::MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
|
62
129
|
|
63
|
-
|
64
|
-
@local_config = {}
|
130
|
+
tmpfile = Tempfile.new(['doing', '.md'])
|
65
131
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
@local_config = @local_config.deep_merge(new_config)
|
132
|
+
File.open(tmpfile.path, 'w+') do |f|
|
133
|
+
f.puts input
|
134
|
+
f.puts "\n# The first line is the entry title, any lines after that are added as a note"
|
70
135
|
end
|
71
136
|
|
72
|
-
#
|
73
|
-
rescue StandardError
|
74
|
-
@config = {}
|
75
|
-
@local_config = {}
|
76
|
-
# exit_now! "error reading config"
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
##
|
81
|
-
## @brief Read user configuration and merge with defaults
|
82
|
-
##
|
83
|
-
## @param opt (Hash) Additional Options
|
84
|
-
##
|
85
|
-
def configure(opt = {})
|
86
|
-
@timers = {}
|
87
|
-
@recorded_items = []
|
88
|
-
opt[:ignore_local] ||= false
|
89
|
-
|
90
|
-
@config_file ||= File.join(@user_home, @default_config_file)
|
91
|
-
|
92
|
-
read_config({ ignore_local: opt[:ignore_local] })
|
93
|
-
|
94
|
-
@config = {} if @config.nil?
|
95
|
-
|
96
|
-
@config['autotag'] ||= {}
|
97
|
-
@config['autotag']['whitelist'] ||= []
|
98
|
-
@config['autotag']['synonyms'] ||= {}
|
99
|
-
@config['doing_file'] ||= '~/what_was_i_doing.md'
|
100
|
-
@config['current_section'] ||= 'Currently'
|
101
|
-
@config['config_editor_app'] ||= nil
|
102
|
-
@config['editor_app'] ||= nil
|
103
|
-
|
104
|
-
@config['html_template'] ||= {}
|
105
|
-
@config['html_template']['haml'] ||= nil
|
106
|
-
@config['html_template']['css'] ||= nil
|
107
|
-
|
108
|
-
@config['templates'] ||= {}
|
109
|
-
@config['templates']['default'] ||= {
|
110
|
-
'date_format' => '%Y-%m-%d %H:%M',
|
111
|
-
'template' => '%date | %title%note',
|
112
|
-
'wrap_width' => 0
|
113
|
-
}
|
114
|
-
@config['templates']['today'] ||= {
|
115
|
-
'date_format' => '%_I:%M%P',
|
116
|
-
'template' => '%date: %title %interval%note',
|
117
|
-
'wrap_width' => 0
|
118
|
-
}
|
119
|
-
@config['templates']['last'] ||= {
|
120
|
-
'date_format' => '%-I:%M%P on %a',
|
121
|
-
'template' => '%title (at %date)%odnote',
|
122
|
-
'wrap_width' => 88
|
123
|
-
}
|
124
|
-
@config['templates']['recent'] ||= {
|
125
|
-
'date_format' => '%_I:%M%P',
|
126
|
-
'template' => '%shortdate: %title (%section)',
|
127
|
-
'wrap_width' => 88,
|
128
|
-
'count' => 10
|
129
|
-
}
|
130
|
-
@config['views'] ||= {
|
131
|
-
'done' => {
|
132
|
-
'date_format' => '%_I:%M%P',
|
133
|
-
'template' => '%date | %title%note',
|
134
|
-
'wrap_width' => 0,
|
135
|
-
'section' => 'All',
|
136
|
-
'count' => 0,
|
137
|
-
'order' => 'desc',
|
138
|
-
'tags' => 'done complete cancelled',
|
139
|
-
'tags_bool' => 'OR'
|
140
|
-
},
|
141
|
-
'color' => {
|
142
|
-
'date_format' => '%F %_I:%M%P',
|
143
|
-
'template' => '%boldblack%date %boldgreen| %boldwhite%title%default%note',
|
144
|
-
'wrap_width' => 0,
|
145
|
-
'section' => 'Currently',
|
146
|
-
'count' => 10,
|
147
|
-
'order' => 'asc'
|
148
|
-
}
|
149
|
-
}
|
150
|
-
@config['marker_tag'] ||= 'flagged'
|
151
|
-
@config['marker_color'] ||= 'red'
|
152
|
-
@config['default_tags'] ||= []
|
153
|
-
@config['tag_sort'] ||= 'time'
|
154
|
-
|
155
|
-
@current_section = config['current_section']
|
156
|
-
@default_template = config['templates']['default']['template']
|
157
|
-
@default_date_format = config['templates']['default']['date_format']
|
158
|
-
|
159
|
-
@config[:include_notes] ||= true
|
137
|
+
pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
|
160
138
|
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
139
|
+
trap('INT') do
|
140
|
+
begin
|
141
|
+
Process.kill(9, pid)
|
142
|
+
rescue StandardError
|
143
|
+
Errno::ESRCH
|
144
|
+
end
|
145
|
+
tmpfile.unlink
|
146
|
+
tmpfile.close!
|
147
|
+
exit 0
|
148
|
+
end
|
166
149
|
|
167
|
-
|
150
|
+
Process.wait(pid)
|
168
151
|
|
169
|
-
|
152
|
+
begin
|
153
|
+
if $?.exitstatus == 0
|
154
|
+
input = IO.read(tmpfile.path)
|
155
|
+
else
|
156
|
+
exit_now! 'Cancelled'
|
157
|
+
end
|
158
|
+
ensure
|
159
|
+
tmpfile.close
|
160
|
+
tmpfile.unlink
|
161
|
+
end
|
170
162
|
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
163
|
+
input.split(/\n/).delete_if(&:ignore?).join("\n")
|
164
|
+
end
|
165
|
+
|
166
|
+
##
|
167
|
+
## @brief Takes a multi-line string and formats it as an entry
|
168
|
+
##
|
169
|
+
## @return (Array) [(String)title, (Array)note]
|
170
|
+
##
|
171
|
+
## @param input (String) The string to parse
|
172
|
+
##
|
173
|
+
## @return (Array) [(String)title, (Note)note]
|
174
|
+
##
|
175
|
+
def format_input(input)
|
176
|
+
raise Errors::EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
|
177
|
+
|
178
|
+
input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
|
179
|
+
title = input_lines[0]&.strip
|
180
|
+
raise Errors::EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
|
181
|
+
|
182
|
+
note = Note.new
|
183
|
+
note.add(input_lines[1..-1]) if input_lines.length > 1
|
184
|
+
# If title line ends in a parenthetical, use that as the note
|
185
|
+
if note.empty? && title =~ /\s+\(.*?\)$/
|
186
|
+
title.sub!(/\s+\((.*?)\)$/) do
|
187
|
+
m = Regexp.last_match
|
188
|
+
note.add(m[1])
|
189
|
+
''
|
190
|
+
end
|
191
|
+
end
|
175
192
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
input
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
193
|
+
note.strip_lines!
|
194
|
+
note.compress
|
195
|
+
|
196
|
+
[title, note]
|
197
|
+
end
|
198
|
+
|
199
|
+
##
|
200
|
+
## @brief Converts input string into a Time object when input takes on the
|
201
|
+
## following formats:
|
202
|
+
## - interval format e.g. '1d2h30m', '45m' etc.
|
203
|
+
## - a semantic phrase e.g. 'yesterday 5:30pm'
|
204
|
+
## - a strftime e.g. '2016-03-15 15:32:04 PDT'
|
205
|
+
##
|
206
|
+
## @param input (String) String to chronify
|
207
|
+
##
|
208
|
+
## @return (DateTime) result
|
209
|
+
##
|
210
|
+
def chronify(input, future: false, guess: :begin)
|
211
|
+
now = Time.now
|
212
|
+
raise Errors::InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
|
213
|
+
|
214
|
+
secs_ago = if input.match(/^(\d+)$/)
|
215
|
+
# plain number, assume minutes
|
216
|
+
Regexp.last_match(1).to_i * 60
|
217
|
+
elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
|
218
|
+
# day/hour/minute format e.g. 1d2h30m
|
219
|
+
[[m['day'], 24 * 3600],
|
220
|
+
[m['hour'], 3600],
|
221
|
+
[m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
|
222
|
+
end
|
200
223
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
section = 'Uncategorized'
|
205
|
-
lines = input.split(/[\n\r]/)
|
206
|
-
current = 0
|
207
|
-
|
208
|
-
lines.each do |line|
|
209
|
-
next if line =~ /^\s*$/
|
210
|
-
|
211
|
-
if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
|
212
|
-
section = Regexp.last_match(1)
|
213
|
-
@content[section] = {}
|
214
|
-
@content[section]['original'] = line
|
215
|
-
@content[section]['items'] = []
|
216
|
-
current = 0
|
217
|
-
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
|
218
|
-
date = Time.parse(Regexp.last_match(1))
|
219
|
-
title = Regexp.last_match(2)
|
220
|
-
@content[section]['items'].push({ 'title' => title, 'date' => date, 'section' => section })
|
221
|
-
current += 1
|
222
|
-
elsif current.zero?
|
223
|
-
# if content[section]['items'].length - 1 == current
|
224
|
-
@other_content_top.push(line)
|
225
|
-
elsif line =~ /^\S/
|
226
|
-
@other_content_bottom.push(line)
|
224
|
+
if secs_ago
|
225
|
+
now - secs_ago
|
227
226
|
else
|
228
|
-
|
229
|
-
|
230
|
-
@content[section]['items'][current - 1]['note'].push(line.chomp)
|
231
|
-
# end
|
227
|
+
Chronic.parse(input, { guess: guess, context: future ? :future : :past, ambiguous_time_range: 8 })
|
232
228
|
end
|
233
229
|
end
|
234
|
-
end
|
235
230
|
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
231
|
+
##
|
232
|
+
## @brief Converts simple strings into seconds that can be added to a Time
|
233
|
+
## object
|
234
|
+
##
|
235
|
+
## @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
|
236
|
+
## 1.5d, 1h20m, etc.)
|
237
|
+
##
|
238
|
+
## @return (Integer) seconds
|
239
|
+
##
|
240
|
+
def chronify_qty(qty)
|
241
|
+
minutes = 0
|
242
|
+
case qty.strip
|
243
|
+
when /^(\d+):(\d\d)$/
|
244
|
+
minutes += Regexp.last_match(1).to_i * 60
|
245
|
+
minutes += Regexp.last_match(2).to_i
|
246
|
+
when /^(\d+(?:\.\d+)?)([hmd])?$/
|
247
|
+
amt = Regexp.last_match(1)
|
248
|
+
type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
|
249
|
+
|
250
|
+
minutes = case type.downcase
|
251
|
+
when 'm'
|
252
|
+
amt.to_i
|
253
|
+
when 'h'
|
254
|
+
(amt.to_f * 60).round
|
255
|
+
when 'd'
|
256
|
+
(amt.to_f * 60 * 24).round
|
257
|
+
else
|
258
|
+
minutes
|
259
|
+
end
|
260
|
+
end
|
261
|
+
minutes * 60
|
262
|
+
end
|
263
|
+
|
264
|
+
##
|
265
|
+
## @brief List sections
|
266
|
+
##
|
267
|
+
## @return (Array) section titles
|
268
|
+
##
|
269
|
+
def sections
|
270
|
+
@content.keys
|
271
|
+
end
|
272
|
+
|
273
|
+
##
|
274
|
+
## @brief Adds a section.
|
275
|
+
##
|
276
|
+
## @param title (String) The new section title
|
277
|
+
##
|
278
|
+
def add_section(title)
|
279
|
+
if @content.key?(title.cap_first)
|
280
|
+
logger.debug('Skipped': 'Section already exists')
|
281
|
+
return
|
282
|
+
end
|
244
283
|
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
## @return (String) CSS template
|
249
|
-
##
|
250
|
-
def css_template
|
251
|
-
IO.read(File.join(File.dirname(__FILE__), '../templates/doing.css'))
|
252
|
-
end
|
284
|
+
@content[title.cap_first] = { :original => "#{title}:", :items => [] }
|
285
|
+
logger.info('New section:', %("#{title.cap_first}"))
|
286
|
+
end
|
253
287
|
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
288
|
+
##
|
289
|
+
## @brief Attempt to match a string with an existing section
|
290
|
+
##
|
291
|
+
## @param frag (String) The user-provided string
|
292
|
+
## @param guessed (Boolean) already guessed and failed
|
293
|
+
##
|
294
|
+
def guess_section(frag, guessed: false, suggest: false)
|
295
|
+
return 'All' if frag =~ /^all$/i
|
296
|
+
frag ||= @config['current_section']
|
260
297
|
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
298
|
+
sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
|
299
|
+
section = false
|
300
|
+
re = frag.split('').join('.*?')
|
301
|
+
sections.each do |sect|
|
302
|
+
next unless sect =~ /#{re}/i
|
265
303
|
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
##
|
271
|
-
def fork_editor(input = '')
|
272
|
-
tmpfile = Tempfile.new(['doing', '.md'])
|
304
|
+
logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
|
305
|
+
section = sect
|
306
|
+
break
|
307
|
+
end
|
273
308
|
|
274
|
-
|
275
|
-
f.puts input
|
276
|
-
f.puts "\n# The first line is the entry title, any lines after that are added as a note"
|
277
|
-
end
|
309
|
+
return section if suggest
|
278
310
|
|
279
|
-
|
311
|
+
unless section || guessed
|
312
|
+
alt = guess_view(frag, guessed: true, suggest: true)
|
313
|
+
if alt
|
314
|
+
meant_view = yn("Did you mean `doing view #{alt}`?", default_response: 'n')
|
315
|
+
raise Errors::InvalidSection, "Run again with `doing view #{alt}`" if meant_view
|
316
|
+
end
|
280
317
|
|
281
|
-
|
282
|
-
begin
|
283
|
-
Process.kill(9, pid)
|
284
|
-
rescue StandardError
|
285
|
-
Errno::ESRCH
|
286
|
-
end
|
287
|
-
tmpfile.unlink
|
288
|
-
tmpfile.close!
|
289
|
-
exit 0
|
290
|
-
end
|
318
|
+
res = yn("Section #{frag} not found, create it", default_response: 'n')
|
291
319
|
|
292
|
-
|
320
|
+
if res
|
321
|
+
add_section(frag.cap_first)
|
322
|
+
write(@doing_file)
|
323
|
+
return frag.cap_first
|
324
|
+
end
|
293
325
|
|
294
|
-
|
295
|
-
|
296
|
-
|
326
|
+
raise Errors::InvalidSection, "Unknown section: #{frag}"
|
327
|
+
end
|
328
|
+
section ? section.cap_first : guessed
|
329
|
+
end
|
330
|
+
|
331
|
+
##
|
332
|
+
## @brief Ask a yes or no question in the terminal
|
333
|
+
##
|
334
|
+
## @param question (String) The question to ask
|
335
|
+
## @param default (Bool) default response if no input
|
336
|
+
##
|
337
|
+
## @return (Bool) yes or no
|
338
|
+
##
|
339
|
+
def yn(question, default_response: false)
|
340
|
+
if default_response.is_a?(String)
|
341
|
+
default = default_response =~ /y/i ? true : false
|
297
342
|
else
|
298
|
-
|
343
|
+
default = default_response
|
299
344
|
end
|
300
|
-
ensure
|
301
|
-
tmpfile.close
|
302
|
-
tmpfile.unlink
|
303
|
-
end
|
304
|
-
|
305
|
-
input.split(/\n/).delete_if {|line| line =~ /^#/ }.join("\n")
|
306
|
-
end
|
307
345
|
|
308
|
-
|
309
|
-
|
310
|
-
#
|
311
|
-
# @return (Array) [(String)title, (Array)note]
|
312
|
-
#
|
313
|
-
# @param input (String) The string to parse
|
314
|
-
#
|
315
|
-
def format_input(input)
|
316
|
-
exit_now! 'No content in entry' if input.nil? || input.strip.empty?
|
317
|
-
|
318
|
-
input_lines = input.split(/[\n\r]+/).delete_if {|line| line =~ /^#/ || line =~ /^\s*$/ }
|
319
|
-
title = input_lines[0]&.strip
|
320
|
-
exit_now! 'No content in first line' if title.nil? || title.strip.empty?
|
321
|
-
|
322
|
-
note = input_lines.length > 1 ? input_lines[1..-1] : []
|
323
|
-
# If title line ends in a parenthetical, use that as the note
|
324
|
-
if note.empty? && title =~ /\s+\(.*?\)$/
|
325
|
-
title.sub!(/\s+\((.*?)\)$/) do
|
326
|
-
m = Regexp.last_match
|
327
|
-
note.push(m[1])
|
328
|
-
''
|
329
|
-
end
|
330
|
-
end
|
346
|
+
# if global --default is set, answer default
|
347
|
+
return default if @default_option
|
331
348
|
|
332
|
-
|
333
|
-
|
349
|
+
# if this isn't an interactive shell, answer default
|
350
|
+
return default unless $stdout.isatty
|
334
351
|
|
335
|
-
|
336
|
-
|
352
|
+
# clear the buffer
|
353
|
+
if ARGV&.length
|
354
|
+
ARGV.length.times do
|
355
|
+
ARGV.shift
|
356
|
+
end
|
357
|
+
end
|
358
|
+
system 'stty cbreak'
|
337
359
|
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
# - a semantic phrase e.g. 'yesterday 5:30pm'
|
343
|
-
# - a strftime e.g. '2016-03-15 15:32:04 PDT'
|
344
|
-
#
|
345
|
-
# @param input (String) String to chronify
|
346
|
-
#
|
347
|
-
# @return (DateTime) result
|
348
|
-
#
|
349
|
-
def chronify(input)
|
350
|
-
now = Time.now
|
351
|
-
exit_now! "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
|
352
|
-
|
353
|
-
secs_ago = if input.match(/^(\d+)$/)
|
354
|
-
# plain number, assume minutes
|
355
|
-
Regexp.last_match(1).to_i * 60
|
356
|
-
elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
|
357
|
-
# day/hour/minute format e.g. 1d2h30m
|
358
|
-
[[m['day'], 24 * 3600],
|
359
|
-
[m['hour'], 3600],
|
360
|
-
[m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
|
361
|
-
end
|
362
|
-
|
363
|
-
if secs_ago
|
364
|
-
now - secs_ago
|
365
|
-
else
|
366
|
-
Chronic.parse(input, { context: :past, ambiguous_time_range: 8 })
|
367
|
-
end
|
368
|
-
end
|
360
|
+
cw = Color.white
|
361
|
+
cbw = Color.boldwhite
|
362
|
+
cbg = Color.boldgreen
|
363
|
+
cd = Color.default
|
369
364
|
|
370
|
-
|
371
|
-
|
372
|
-
# object
|
373
|
-
#
|
374
|
-
# @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
|
375
|
-
# 1.5d, 1h20m, etc.)
|
376
|
-
#
|
377
|
-
# @return (Integer) seconds
|
378
|
-
#
|
379
|
-
def chronify_qty(qty)
|
380
|
-
minutes = 0
|
381
|
-
case qty.strip
|
382
|
-
when /^(\d+):(\d\d)$/
|
383
|
-
minutes += Regexp.last_match(1).to_i * 60
|
384
|
-
minutes += Regexp.last_match(2).to_i
|
385
|
-
when /^(\d+(?:\.\d+)?)([hmd])?$/
|
386
|
-
amt = Regexp.last_match(1)
|
387
|
-
type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
|
388
|
-
|
389
|
-
minutes = case type.downcase
|
390
|
-
when 'm'
|
391
|
-
amt.to_i
|
392
|
-
when 'h'
|
393
|
-
(amt.to_f * 60).round
|
394
|
-
when 'd'
|
395
|
-
(amt.to_f * 60 * 24).round
|
365
|
+
options = unless default.nil?
|
366
|
+
"#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
|
396
367
|
else
|
397
|
-
|
368
|
+
"#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
|
398
369
|
end
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
370
|
+
$stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
|
371
|
+
res = $stdin.sysread 1
|
372
|
+
puts
|
373
|
+
system 'stty cooked'
|
374
|
+
|
375
|
+
res.chomp!
|
376
|
+
res.downcase!
|
377
|
+
|
378
|
+
return default if res.empty?
|
379
|
+
|
380
|
+
res =~ /y/i ? true : false
|
381
|
+
end
|
382
|
+
|
383
|
+
##
|
384
|
+
## @brief Attempt to match a string with an existing view
|
385
|
+
##
|
386
|
+
## @param frag (String) The user-provided string
|
387
|
+
## @param guessed (Boolean) already guessed
|
388
|
+
##
|
389
|
+
def guess_view(frag, guessed: false, suggest: false)
|
390
|
+
views.each { |view| return view if frag.downcase == view.downcase }
|
391
|
+
view = false
|
392
|
+
re = frag.split('').join('.*?')
|
393
|
+
views.each do |v|
|
394
|
+
next unless v =~ /#{re}/i
|
395
|
+
|
396
|
+
logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
|
397
|
+
view = v
|
398
|
+
break
|
399
|
+
end
|
400
|
+
unless view || guessed
|
401
|
+
guess = guess_section(frag, guessed: true, suggest: true)
|
402
|
+
exit_now! "Did you mean `doing show #{guess}`?" if guess
|
421
403
|
|
422
|
-
|
423
|
-
## @brief Attempt to match a string with an existing section
|
424
|
-
##
|
425
|
-
## @param frag (String) The user-provided string
|
426
|
-
## @param guessed (Boolean) already guessed and failed
|
427
|
-
##
|
428
|
-
def guess_section(frag, guessed: false)
|
429
|
-
return 'All' if frag =~ /^all$/i
|
430
|
-
frag ||= @current_section
|
431
|
-
sections.each { |section| return section.cap_first if frag.downcase == section.downcase }
|
432
|
-
section = false
|
433
|
-
re = frag.split('').join('.*?')
|
434
|
-
sections.each do |sect|
|
435
|
-
next unless sect =~ /#{re}/i
|
436
|
-
|
437
|
-
warn "Assuming you meant #{sect}"
|
438
|
-
section = sect
|
439
|
-
break
|
440
|
-
end
|
441
|
-
unless section || guessed
|
442
|
-
alt = guess_view(frag, true)
|
443
|
-
exit_now! "Did you mean `doing view #{alt}`?" if alt
|
404
|
+
raise Errors::InvalidView, "Unknown view: #{frag}"
|
444
405
|
|
445
|
-
|
406
|
+
end
|
407
|
+
view
|
408
|
+
end
|
409
|
+
|
410
|
+
##
|
411
|
+
## @brief Adds an entry
|
412
|
+
##
|
413
|
+
## @param title (String) The entry title
|
414
|
+
## @param section (String) The section to add to
|
415
|
+
## @param opt (Hash) Additional Options {:date, :note, :back, :timed}
|
416
|
+
##
|
417
|
+
def add_item(title, section = nil, opt = {})
|
418
|
+
section ||= @config['current_section']
|
419
|
+
add_section(section) unless @content.key?(section)
|
420
|
+
opt[:date] ||= Time.now
|
421
|
+
opt[:note] ||= []
|
422
|
+
opt[:back] ||= Time.now
|
423
|
+
opt[:timed] ||= false
|
424
|
+
|
425
|
+
opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
|
426
|
+
|
427
|
+
title = [title.strip.cap_first]
|
428
|
+
title = title.join(' ')
|
429
|
+
|
430
|
+
if @auto_tag
|
431
|
+
title = autotag(title)
|
432
|
+
title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
|
433
|
+
end
|
446
434
|
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
435
|
+
title.gsub!(/ +/, ' ')
|
436
|
+
entry = Item.new(opt[:back], title.strip, section)
|
437
|
+
entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
|
438
|
+
items = @content[section][:items]
|
439
|
+
if opt[:timed]
|
440
|
+
items.reverse!
|
441
|
+
items.each_with_index do |i, x|
|
442
|
+
next if i.title =~ / @done/
|
443
|
+
|
444
|
+
items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
|
445
|
+
break
|
446
|
+
end
|
447
|
+
items.reverse!
|
451
448
|
end
|
452
449
|
|
453
|
-
|
450
|
+
items.push(entry)
|
451
|
+
logger.count(:added)
|
452
|
+
logger.debug('Entry added:', %("#{entry.title}" to #{section}))
|
454
453
|
end
|
455
|
-
section ? section.cap_first : guessed
|
456
|
-
end
|
457
|
-
|
458
|
-
##
|
459
|
-
## @brief Ask a yes or no question in the terminal
|
460
|
-
##
|
461
|
-
## @param question (String) The question to ask
|
462
|
-
## @param default (Bool) default response if no input
|
463
|
-
##
|
464
|
-
## @return (Bool) yes or no
|
465
|
-
##
|
466
|
-
def yn(question, default_response: false)
|
467
|
-
default = default_response ? default_response : 'n'
|
468
454
|
|
469
|
-
|
470
|
-
|
455
|
+
##
|
456
|
+
## @brief Remove items from a list that already exist in @content
|
457
|
+
##
|
458
|
+
## @param items (Array) The items to deduplicate
|
459
|
+
## @param no_overlap (Boolean) Remove items with overlapping time spans
|
460
|
+
##
|
461
|
+
def dedup(items, no_overlap = false)
|
471
462
|
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
ARGV.shift
|
463
|
+
combined = []
|
464
|
+
@content.each do |_k, v|
|
465
|
+
combined += v[:items]
|
476
466
|
end
|
477
|
-
end
|
478
|
-
system 'stty cbreak'
|
479
|
-
|
480
|
-
cw = colors['white']
|
481
|
-
cbw = colors['boldwhite']
|
482
|
-
cbg = colors['boldgreen']
|
483
|
-
cd = colors['default']
|
484
|
-
|
485
|
-
options = if default
|
486
|
-
default =~ /y/i ? "#{cw}[#{cbg}Y#{cw}/#{cbw}n#{cw}]#{cd}" : "#{cw}[#{cbw}y#{cw}/#{cbg}N#{cw}]#{cd}"
|
487
|
-
else
|
488
|
-
"#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
|
489
|
-
end
|
490
|
-
$stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
|
491
|
-
res = $stdin.sysread 1
|
492
|
-
puts
|
493
|
-
system 'stty cooked'
|
494
|
-
|
495
|
-
res.chomp!
|
496
|
-
res.downcase!
|
497
467
|
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
## @param guessed (Boolean) already guessed
|
508
|
-
##
|
509
|
-
def guess_view(frag, guessed = false)
|
510
|
-
views.each { |view| return view if frag.downcase == view.downcase }
|
511
|
-
view = false
|
512
|
-
re = frag.split('').join('.*?')
|
513
|
-
views.each do |v|
|
514
|
-
next unless v =~ /#{re}/i
|
515
|
-
|
516
|
-
warn "Assuming you meant #{v}"
|
517
|
-
view = v
|
518
|
-
break
|
519
|
-
end
|
520
|
-
unless view || guessed
|
521
|
-
alt = guess_section(frag, guessed: true)
|
522
|
-
if alt
|
523
|
-
exit_now! "Did you mean `doing show #{alt}`?"
|
524
|
-
else
|
525
|
-
exit_now! "Unknown view: #{frag}"
|
468
|
+
items.delete_if do |item|
|
469
|
+
duped = false
|
470
|
+
combined.each do |comp|
|
471
|
+
duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
|
472
|
+
break if duped
|
473
|
+
end
|
474
|
+
logger.count(:skipped, level: :debug, message: 'overlapping entry') if duped
|
475
|
+
logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
|
476
|
+
duped
|
526
477
|
end
|
527
478
|
end
|
528
|
-
view
|
529
|
-
end
|
530
479
|
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
opt[:timed] ||= false
|
545
|
-
|
546
|
-
opt[:note] = [opt[:note]] if opt[:note].instance_of?(String)
|
547
|
-
|
548
|
-
title = [title.strip.cap_first]
|
549
|
-
title = title.join(' ')
|
550
|
-
|
551
|
-
if @auto_tag
|
552
|
-
title = autotag(title)
|
553
|
-
unless @config['default_tags'].empty?
|
554
|
-
default_tags = @config['default_tags'].map do |t|
|
555
|
-
next if t.nil?
|
556
|
-
|
557
|
-
dt = t.sub(/^ *@/, '').chomp
|
558
|
-
if title =~ /@#{dt}/
|
559
|
-
''
|
560
|
-
else
|
561
|
-
" @#{dt}"
|
480
|
+
##
|
481
|
+
## @brief Imports external entries
|
482
|
+
##
|
483
|
+
## @param path (String) Path to JSON report file
|
484
|
+
## @param opt (Hash) Additional Options
|
485
|
+
##
|
486
|
+
def import(paths, opt = {})
|
487
|
+
Plugins.plugins[:import].each do |_, options|
|
488
|
+
next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
489
|
+
|
490
|
+
if paths.count.positive?
|
491
|
+
paths.each do |path|
|
492
|
+
options[:class].import(self, path, options: opt)
|
562
493
|
end
|
494
|
+
else
|
495
|
+
options[:class].import(self, nil, options: opt)
|
563
496
|
end
|
564
|
-
default_tags.delete_if { |t| t == '' }
|
565
|
-
title += default_tags.join(' ')
|
566
|
-
end
|
567
|
-
end
|
568
|
-
title.gsub!(/ +/, ' ')
|
569
|
-
entry = { 'title' => title.strip, 'date' => opt[:back] }
|
570
|
-
entry['note'] = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
|
571
|
-
items = @content[section]['items']
|
572
|
-
if opt[:timed]
|
573
|
-
items.reverse!
|
574
|
-
items.each_with_index do |i, x|
|
575
|
-
next if i['title'] =~ / @done/
|
576
|
-
|
577
|
-
items[x]['title'] = "#{i['title']} @done(#{opt[:back].strftime('%F %R')})"
|
578
497
|
break
|
579
498
|
end
|
580
|
-
items.reverse!
|
581
499
|
end
|
582
|
-
items.push(entry)
|
583
|
-
@content[section]['items'] = items
|
584
|
-
@results.push(%(Added "#{entry['title']}" to #{section}))
|
585
|
-
end
|
586
500
|
|
587
|
-
|
588
|
-
|
589
|
-
|
501
|
+
##
|
502
|
+
## @brief Return the content of the last note for a given section
|
503
|
+
##
|
504
|
+
## @param section (String) The section to retrieve from, default
|
505
|
+
## All
|
506
|
+
##
|
507
|
+
def last_note(section = 'All')
|
508
|
+
section = guess_section(section)
|
590
509
|
|
591
|
-
|
592
|
-
return true if same_time?(item_a, item_b)
|
510
|
+
last_item = last_entry({ section: section })
|
593
511
|
|
594
|
-
|
595
|
-
interval = get_interval(item_a, formatted: false, record: false)
|
596
|
-
end_a = interval ? start_a + interval.to_i : start_a
|
597
|
-
start_b = item_b['date']
|
598
|
-
interval = get_interval(item_b, formatted: false, record: false)
|
599
|
-
end_b = interval ? start_b + interval.to_i : start_b
|
600
|
-
(start_a >= start_b && start_a <= end_b) || (end_a >= start_b && end_a <= end_b) || (start_a < start_b && end_a > end_b)
|
601
|
-
end
|
512
|
+
raise Errors::NoEntryError, 'No entry found' unless last_item
|
602
513
|
|
603
|
-
|
514
|
+
logger.log_now(:info, 'Edit note:', last_item.title)
|
604
515
|
|
605
|
-
|
606
|
-
|
607
|
-
combined += v['items']
|
516
|
+
note = last_item.note&.to_s || ''
|
517
|
+
"#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
|
608
518
|
end
|
609
519
|
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
break if duped
|
520
|
+
def reset_item(item, resume: false)
|
521
|
+
item.date = Time.now
|
522
|
+
if resume
|
523
|
+
item.tag('done', remove: true)
|
615
524
|
end
|
616
|
-
#
|
617
|
-
|
525
|
+
Doing.logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
|
526
|
+
item
|
618
527
|
end
|
619
|
-
end
|
620
528
|
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
## @param opt (Hash) Additional Options
|
627
|
-
##
|
628
|
-
def import_timing(path, opt = {})
|
629
|
-
section = opt[:section] || @current_section
|
630
|
-
opt[:no_overlap] ||= false
|
631
|
-
opt[:autotag] ||= @auto_tag
|
632
|
-
|
633
|
-
add_section(section) unless @content.has_key?(section)
|
634
|
-
|
635
|
-
add_tags = opt[:tag] ? opt[:tag].split(/[ ,]+/).map { |t| t.sub(/^@?/, '@') }.join(' ') : ''
|
636
|
-
prefix = opt[:prefix] ? opt[:prefix] : '[Timing.app]'
|
637
|
-
exit_now! "File not found" unless File.exist?(File.expand_path(path))
|
638
|
-
|
639
|
-
data = JSON.parse(IO.read(File.expand_path(path)))
|
640
|
-
new_items = []
|
641
|
-
data.each do |entry|
|
642
|
-
# Only process task entries
|
643
|
-
next if entry.key?('activityType') && entry['activityType'] != 'Task'
|
644
|
-
# Only process entries with a start and end date
|
645
|
-
next unless entry.key?('startDate') && entry.key?('endDate')
|
646
|
-
|
647
|
-
# Round down seconds and convert UTC to local time
|
648
|
-
start_time = Time.parse(entry['startDate'].sub(/:\d\dZ$/, ':00Z')).getlocal
|
649
|
-
end_time = Time.parse(entry['endDate'].sub(/:\d\dZ$/, ':00Z')).getlocal
|
650
|
-
next unless start_time && end_time
|
651
|
-
|
652
|
-
tags = entry['project'].split(/ ▸ /).map {|proj| proj.gsub(/[^a-z0-9]+/i, '').downcase }
|
653
|
-
title = "#{prefix} "
|
654
|
-
title += entry.key?('activityTitle') && entry['activityTitle'] != '(Untitled Task)' ? entry['activityTitle'] : 'Working on'
|
655
|
-
tags.each do |tag|
|
656
|
-
if title =~ /\b#{tag}\b/i
|
657
|
-
title.sub!(/\b#{tag}\b/i, "@#{tag}")
|
529
|
+
def repeat_item(item, opt = {})
|
530
|
+
original = item.dup
|
531
|
+
if item.should_finish?
|
532
|
+
if item.should_time?
|
533
|
+
item.title.tag!('done', value: Time.now.strftime('%F %R'))
|
658
534
|
else
|
659
|
-
title
|
535
|
+
item.title.tag!('done')
|
660
536
|
end
|
661
537
|
end
|
662
|
-
|
663
|
-
|
664
|
-
title.
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
538
|
+
|
539
|
+
# Remove @done tag
|
540
|
+
title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
|
541
|
+
section = opt[:in].nil? ? item.section : guess_section(opt[:in])
|
542
|
+
@auto_tag = false
|
543
|
+
|
544
|
+
note = opt[:note] || Note.new
|
545
|
+
|
546
|
+
if opt[:editor]
|
547
|
+
to_edit = title
|
548
|
+
to_edit += "\n#{note.to_s}" unless note.empty?
|
549
|
+
new_item = fork_editor(to_edit)
|
550
|
+
title, note = format_input(new_item)
|
551
|
+
|
552
|
+
if title.nil? || title.empty?
|
553
|
+
logger.debug('Skipped:', 'No content provided')
|
554
|
+
return
|
555
|
+
end
|
556
|
+
end
|
557
|
+
|
558
|
+
update_item(original, item)
|
559
|
+
add_item(title, section, { note: note, back: opt[:date], timed: true })
|
560
|
+
write(@doing_file)
|
669
561
|
end
|
670
|
-
total = new_items.count
|
671
|
-
new_items = dedup(new_items, opt[:no_overlap])
|
672
|
-
dups = total - new_items.count
|
673
|
-
@results.push(%(Skipped #{dups} items with overlapping times)) if dups > 0
|
674
|
-
@content[section]['items'].concat(new_items)
|
675
|
-
@results.push(%(Imported #{new_items.count} items to #{section}))
|
676
|
-
end
|
677
562
|
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
563
|
+
##
|
564
|
+
## @brief Restart the last entry
|
565
|
+
##
|
566
|
+
## @param opt (Hash) Additional Options
|
567
|
+
##
|
568
|
+
def repeat_last(opt = {})
|
569
|
+
opt[:section] ||= 'all'
|
570
|
+
opt[:note] ||= []
|
571
|
+
opt[:tag] ||= []
|
572
|
+
opt[:tag_bool] ||= :and
|
573
|
+
|
574
|
+
last = last_entry(opt)
|
575
|
+
if last.nil?
|
576
|
+
logger.debug('Skipped:', 'No previous entry found')
|
577
|
+
return
|
690
578
|
end
|
691
|
-
|
579
|
+
|
580
|
+
repeat_item(last, opt)
|
692
581
|
end
|
693
582
|
|
694
|
-
|
583
|
+
##
|
584
|
+
## @brief Get the last entry
|
585
|
+
##
|
586
|
+
## @param opt (Hash) Additional Options
|
587
|
+
##
|
588
|
+
def last_entry(opt = {})
|
589
|
+
opt[:tag_bool] ||= :and
|
590
|
+
opt[:section] ||= @config['current_section']
|
695
591
|
|
696
|
-
|
697
|
-
warn "Editing note for #{last_item['title']}"
|
698
|
-
note = ''
|
699
|
-
note = last_item['note'].map(&:strip).join("\n") unless last_item['note'].nil?
|
700
|
-
"#{last_item['title']}\n# EDIT BELOW THIS LINE ------------\n#{note}"
|
701
|
-
end
|
592
|
+
items = filter_items([], opt: opt)
|
702
593
|
|
703
|
-
|
704
|
-
## @brief Restart the last entry
|
705
|
-
##
|
706
|
-
## @param opt (Hash) Additional Options
|
707
|
-
##
|
708
|
-
def restart_last(opt = {})
|
709
|
-
opt[:section] ||= 'all'
|
710
|
-
opt[:note] ||= []
|
711
|
-
opt[:tag] ||= []
|
712
|
-
opt[:tag_bool] ||= :and
|
713
|
-
|
714
|
-
last = last_entry(opt)
|
715
|
-
if last.nil?
|
716
|
-
@results.push(%(No previous entry found))
|
717
|
-
return
|
718
|
-
end
|
719
|
-
unless last.has_tags?(['done'], 'ALL')
|
720
|
-
new_item = last.dup
|
721
|
-
new_item['title'] += " @done(#{Time.now.strftime('%F %R')})"
|
722
|
-
update_item(last, new_item)
|
723
|
-
end
|
724
|
-
# Remove @done tag
|
725
|
-
title = last['title'].sub(/\s*@done(\(.*?\))?/, '').chomp
|
726
|
-
section = opt[:in].nil? ? last['section'] : guess_section(opt[:in])
|
727
|
-
@auto_tag = false
|
728
|
-
add_item(title, section, { note: opt[:note], back: opt[:date], timed: true })
|
729
|
-
write(@doing_file)
|
730
|
-
end
|
594
|
+
logger.debug('Filtered:', "Parameters matched #{items.count} entries")
|
731
595
|
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
sec_arr = []
|
742
|
-
|
743
|
-
if opt[:section].nil?
|
744
|
-
sec_arr = [@current_section]
|
745
|
-
elsif opt[:section].instance_of?(String)
|
746
|
-
if opt[:section] =~ /^all$/i
|
747
|
-
combined = { 'items' => [] }
|
748
|
-
@content.each do |_k, v|
|
749
|
-
combined['items'] += v['items']
|
750
|
-
end
|
751
|
-
items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
|
752
|
-
sec_arr.push(items[0]['section'])
|
596
|
+
if opt[:interactive]
|
597
|
+
last_entry = choose_from_items(items, {
|
598
|
+
menu: true,
|
599
|
+
header: '',
|
600
|
+
prompt: 'Select an entry > ',
|
601
|
+
multiple: false,
|
602
|
+
sort: false,
|
603
|
+
show_if_single: true
|
604
|
+
}, include_section: opt[:section] =~ /^all$/i )
|
753
605
|
else
|
754
|
-
|
606
|
+
last_entry = items.max_by { |item| item.date }
|
755
607
|
end
|
608
|
+
|
609
|
+
last_entry
|
756
610
|
end
|
757
611
|
|
758
|
-
|
759
|
-
|
760
|
-
|
612
|
+
##
|
613
|
+
## @brief Generate a menu of options and allow user selection
|
614
|
+
##
|
615
|
+
## @return (String) The selected option
|
616
|
+
##
|
617
|
+
def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
|
618
|
+
return nil unless $stdout.isatty
|
619
|
+
|
620
|
+
fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
|
621
|
+
# fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
|
622
|
+
fzf_args << %(--prompt "#{prompt}")
|
623
|
+
fzf_args << '--multi' if multiple
|
624
|
+
header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
|
625
|
+
fzf_args << %(--header "#{header}")
|
626
|
+
options.sort! if sorted
|
627
|
+
res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
|
628
|
+
return false if res.strip.size.zero?
|
629
|
+
|
630
|
+
res
|
761
631
|
end
|
762
632
|
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
633
|
+
def all_tags(items, opt: {})
|
634
|
+
all_tags = []
|
635
|
+
items.each { |item| all_tags.concat(item.tags).uniq! }
|
636
|
+
all_tags.sort
|
767
637
|
end
|
768
638
|
|
769
|
-
|
770
|
-
|
639
|
+
def tag_groups(items, opt: {})
|
640
|
+
all_items = filter_items(items, opt: opt)
|
641
|
+
tags = all_tags(all_items, opt: {})
|
642
|
+
tag_groups = {}
|
643
|
+
tags.each do |tag|
|
644
|
+
tag_groups[tag] ||= []
|
645
|
+
tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
|
646
|
+
end
|
771
647
|
|
772
|
-
|
773
|
-
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
779
|
-
|
780
|
-
|
781
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
648
|
+
tag_groups
|
649
|
+
end
|
650
|
+
|
651
|
+
##
|
652
|
+
## @brief Filter items based on search criteria
|
653
|
+
##
|
654
|
+
## @param items (Array) The items to filter (if empty, filters all items)
|
655
|
+
## @param opt (Hash) The filter parameters
|
656
|
+
##
|
657
|
+
## Available filter options in opt object
|
658
|
+
##
|
659
|
+
## - +:section+ (String)
|
660
|
+
## - +:unfinished+ (Boolean)
|
661
|
+
## - +:tag+ (Array or comma-separated string)
|
662
|
+
## - +:tag_bool+ (:and, :or, :not)
|
663
|
+
## - +:search+ (string, optional regex with //)
|
664
|
+
## - +:date_filter+ (Array[(Time)start, (Time)end])
|
665
|
+
## - +:only_timed+ (Boolean)
|
666
|
+
## - +:before+ (Date/Time string, unparsed)
|
667
|
+
## - +:after+ (Date/Time string, unparsed)
|
668
|
+
## - +:today+ (Boolean)
|
669
|
+
## - +:yesterday+ (Boolean)
|
670
|
+
## - +:count+ (Number to return)
|
671
|
+
## - +:age+ (String, 'old' or 'new')
|
672
|
+
##
|
673
|
+
def filter_items(items = [], opt: {})
|
674
|
+
if items.nil? || items.empty?
|
675
|
+
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
676
|
+
|
677
|
+
items = if section =~ /^all$/i
|
678
|
+
@content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
|
679
|
+
else
|
680
|
+
@content[section][:items].dup
|
681
|
+
end
|
682
|
+
end
|
789
683
|
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
def interactive(opt = {})
|
796
|
-
fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
|
684
|
+
items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
|
685
|
+
filtered_items = items.select do |item|
|
686
|
+
keep = true
|
687
|
+
finished = opt[:unfinished] && item.tags?('done', :and)
|
688
|
+
keep = false if finished
|
797
689
|
|
798
|
-
|
690
|
+
if keep && opt[:tag]
|
691
|
+
opt[:tag_bool] ||= :and
|
692
|
+
tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
|
693
|
+
keep = false unless tag_match
|
694
|
+
end
|
799
695
|
|
696
|
+
if keep && opt[:search]
|
697
|
+
search_match = opt[:search].nil? || opt[:search].empty? ? true : item.search(opt[:search])
|
698
|
+
keep = false unless search_match
|
699
|
+
end
|
800
700
|
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
combined['items'] += v['items']
|
805
|
-
end
|
806
|
-
items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
|
807
|
-
else
|
808
|
-
items = @content[section]['items']
|
809
|
-
end
|
701
|
+
if keep && opt[:date_filter]&.length == 2
|
702
|
+
start_date = opt[:date_filter][0]
|
703
|
+
end_date = opt[:date_filter][1]
|
810
704
|
|
705
|
+
in_date_range = if end_date
|
706
|
+
item.date >= start_date && item.date <= end_date
|
707
|
+
else
|
708
|
+
item.date.strftime('%F') == start_date.strftime('%F')
|
709
|
+
end
|
710
|
+
keep = false unless in_date_range
|
711
|
+
end
|
811
712
|
|
812
|
-
|
813
|
-
out = [
|
814
|
-
i,
|
815
|
-
') ',
|
816
|
-
item['date'],
|
817
|
-
' | ',
|
818
|
-
item['title']
|
819
|
-
]
|
820
|
-
if section =~ /^all/i
|
821
|
-
out.concat([
|
822
|
-
' (',
|
823
|
-
item['section'],
|
824
|
-
') '
|
825
|
-
])
|
826
|
-
end
|
827
|
-
out.join('')
|
828
|
-
end
|
829
|
-
fzf_args = [
|
830
|
-
%(--header="Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"),
|
831
|
-
%(--prompt="Select entries to act on > "),
|
832
|
-
'-1',
|
833
|
-
'-m',
|
834
|
-
'--bind ctrl-a:select-all',
|
835
|
-
%(-q "#{opt[:query]}")
|
836
|
-
]
|
837
|
-
if !opt[:menu]
|
838
|
-
exit_now! "Can't skip menu when no query is provided" unless opt[:query]
|
839
|
-
|
840
|
-
fzf_args.concat([%(--filter="#{opt[:query]}"), '--no-sort'])
|
841
|
-
end
|
713
|
+
keep = false if keep && opt[:only_timed] && !item.interval
|
842
714
|
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
idx = item.match(/^(\d+)\)/)[1].to_i
|
847
|
-
selected.push(items[idx])
|
848
|
-
end
|
715
|
+
if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
|
716
|
+
keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
|
717
|
+
end
|
849
718
|
|
850
|
-
|
851
|
-
|
852
|
-
|
853
|
-
|
719
|
+
if keep && opt[:before]
|
720
|
+
time_string = opt[:before]
|
721
|
+
cutoff = chronify(time_string, guess: :begin)
|
722
|
+
keep = cutoff && item.date <= cutoff
|
723
|
+
end
|
854
724
|
|
855
|
-
|
856
|
-
|
857
|
-
|
858
|
-
|
859
|
-
|
860
|
-
break
|
861
|
-
end
|
862
|
-
end
|
725
|
+
if keep && opt[:after]
|
726
|
+
time_string = opt[:after]
|
727
|
+
cutoff = chronify(time_string, guess: :end)
|
728
|
+
keep = cutoff && item.date >= cutoff
|
729
|
+
end
|
863
730
|
|
864
|
-
|
865
|
-
|
866
|
-
|
867
|
-
|
868
|
-
'cancel',
|
869
|
-
'delete',
|
870
|
-
'finish',
|
871
|
-
'flag',
|
872
|
-
'archive',
|
873
|
-
'move',
|
874
|
-
'edit',
|
875
|
-
'output formatted'
|
876
|
-
],
|
877
|
-
prompt: 'What do you want to do with the selected items? > ',
|
878
|
-
multiple: true,
|
879
|
-
fzf_args: ['--height=60%', '--tac', '--no-sort'])
|
880
|
-
return unless choice
|
881
|
-
|
882
|
-
to_do = choice.strip.split(/\n/)
|
883
|
-
to_do.each do |action|
|
884
|
-
case action
|
885
|
-
when /(add|remove) tag/
|
886
|
-
type = action =~ /^add/ ? 'add' : 'remove'
|
887
|
-
if opt[:tag]
|
888
|
-
exit_now! "'add tag' and 'remove tag' can not be used together"
|
889
|
-
end
|
890
|
-
print "#{colors['yellow']}Tag to #{type}: #{colors['reset']}"
|
891
|
-
tag = STDIN.gets
|
892
|
-
return if tag =~ /^ *$/
|
893
|
-
opt[:tag] = tag.strip.sub(/^@/, '')
|
894
|
-
opt[:remove] = true if type == 'remove'
|
895
|
-
when /output formatted/
|
896
|
-
output_format = choose_from(%w[doing taskpaper json timeline html csv].sort, prompt: 'Which output format? > ', fzf_args: ['--height=60%', '--tac', '--no-sort'])
|
897
|
-
return if tag =~ /^ *$/
|
898
|
-
opt[:output] = output_format.strip
|
899
|
-
res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
|
900
|
-
if res
|
901
|
-
print "#{colors['yellow']}File path/name: #{colors['reset']}"
|
902
|
-
filename = STDIN.gets.strip
|
903
|
-
return if filename.empty?
|
904
|
-
opt[:save_to] = filename
|
905
|
-
end
|
906
|
-
when /archive/
|
907
|
-
opt[:archive] = true
|
908
|
-
when /delete/
|
909
|
-
opt[:delete] = true
|
910
|
-
when /edit/
|
911
|
-
opt[:editor] = true
|
912
|
-
when /finish/
|
913
|
-
opt[:finish] = true
|
914
|
-
when /cancel/
|
915
|
-
opt[:cancel] = true
|
916
|
-
when /move/
|
917
|
-
section = choose_section.strip
|
918
|
-
opt[:move] = section.strip unless section =~ /^ *$/
|
919
|
-
when /flag/
|
920
|
-
opt[:flag] = true
|
731
|
+
if keep && opt[:today]
|
732
|
+
keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
|
733
|
+
elsif keep && opt[:yesterday]
|
734
|
+
keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
|
921
735
|
end
|
922
|
-
end
|
923
|
-
end
|
924
736
|
|
925
|
-
|
926
|
-
res = opt[:force] ? true : yn("Delete #{selected.size} items?", default_response: 'y')
|
927
|
-
if res
|
928
|
-
selected.each { |item| delete_item(item) }
|
929
|
-
write(@doing_file)
|
737
|
+
keep
|
930
738
|
end
|
931
|
-
|
932
|
-
end
|
739
|
+
count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length
|
933
740
|
|
934
|
-
|
935
|
-
|
936
|
-
|
937
|
-
|
938
|
-
untag_item(item, tag)
|
939
|
-
else
|
940
|
-
tag_item(item, tag, date: false)
|
941
|
-
end
|
741
|
+
if opt[:age] =~ /^o/i
|
742
|
+
filtered_items.slice(0, count).reverse
|
743
|
+
else
|
744
|
+
filtered_items.reverse.slice(0, count)
|
942
745
|
end
|
943
|
-
end
|
944
746
|
|
945
|
-
if opt[:finish] || opt[:cancel]
|
946
|
-
tag = 'done'
|
947
|
-
selected.map! do |item|
|
948
|
-
if opt[:remove]
|
949
|
-
untag_item(item, tag)
|
950
|
-
else
|
951
|
-
tag_item(item, tag, date: !opt[:cancel])
|
952
|
-
end
|
953
|
-
end
|
954
747
|
end
|
955
748
|
|
956
|
-
|
957
|
-
|
958
|
-
|
959
|
-
|
960
|
-
|
961
|
-
|
962
|
-
|
963
|
-
|
749
|
+
##
|
750
|
+
## @brief Display an interactive menu of entries
|
751
|
+
##
|
752
|
+
## @param opt (Hash) Additional options
|
753
|
+
##
|
754
|
+
def interactive(opt = {})
|
755
|
+
section = opt[:section] ? guess_section(opt[:section]) : 'All'
|
756
|
+
opt[:query] = opt[:search] if opt[:search] && !opt[:query]
|
757
|
+
opt[:multiple] = true
|
758
|
+
items = filter_items([], opt: { section: section, search: opt[:search] })
|
759
|
+
|
760
|
+
selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
|
761
|
+
|
762
|
+
if selection.empty?
|
763
|
+
logger.debug('Skipped:', 'No selection')
|
764
|
+
return
|
964
765
|
end
|
965
|
-
end
|
966
766
|
|
967
|
-
|
968
|
-
section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
|
969
|
-
selected.map! {|item| move_item(item, section) }
|
767
|
+
act_on(selection, opt)
|
970
768
|
end
|
971
769
|
|
972
|
-
|
770
|
+
def choose_from_items(items, opt = {}, include_section: false)
|
771
|
+
return nil unless $stdout.isatty
|
973
772
|
|
974
|
-
|
773
|
+
return nil unless items.count.positive?
|
975
774
|
|
976
|
-
|
775
|
+
opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
|
776
|
+
opt[:prompt] ||= "Select entries to act on > "
|
977
777
|
|
978
|
-
|
979
|
-
|
980
|
-
|
981
|
-
|
982
|
-
|
778
|
+
pad = items.length.to_s.length
|
779
|
+
options = items.map.with_index do |item, i|
|
780
|
+
out = [
|
781
|
+
format("%#{pad}d", i),
|
782
|
+
') ',
|
783
|
+
format('%13s', item.date.relative_date),
|
784
|
+
' | ',
|
785
|
+
item.title
|
786
|
+
]
|
787
|
+
if include_section
|
788
|
+
out.concat([
|
789
|
+
' (',
|
790
|
+
item.section,
|
791
|
+
') '
|
792
|
+
])
|
793
|
+
end
|
794
|
+
out.join('')
|
983
795
|
end
|
984
|
-
divider = "\n-----------\n"
|
985
|
-
input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
|
986
796
|
|
987
|
-
|
797
|
+
fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
|
988
798
|
|
989
|
-
|
799
|
+
fzf_args = [
|
800
|
+
%(--header="#{opt[:header]}"),
|
801
|
+
%(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
|
802
|
+
opt[:multiple] ? '--multi' : '--no-multi',
|
803
|
+
'-0',
|
804
|
+
'--bind ctrl-a:select-all',
|
805
|
+
%(-q "#{opt[:query]}")
|
806
|
+
]
|
807
|
+
fzf_args.push('-1') unless opt[:show_if_single]
|
990
808
|
|
991
|
-
|
992
|
-
|
809
|
+
unless opt[:menu]
|
810
|
+
raise Errors::InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query]
|
993
811
|
|
994
|
-
|
995
|
-
|
996
|
-
else
|
997
|
-
note = input_lines.length > 1 ? input_lines[1..-1] : []
|
812
|
+
fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
|
813
|
+
end
|
998
814
|
|
999
|
-
|
1000
|
-
|
815
|
+
res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
|
816
|
+
selected = []
|
817
|
+
res.split(/\n/).each do |item|
|
818
|
+
idx = item.match(/^ *(\d+)\)/)[1].to_i
|
819
|
+
selected.push(items[idx])
|
820
|
+
end
|
1001
821
|
|
1002
|
-
|
1003
|
-
|
822
|
+
opt[:multiple] ? selected : selected[0]
|
823
|
+
end
|
1004
824
|
|
1005
|
-
|
1006
|
-
|
1007
|
-
|
1008
|
-
|
1009
|
-
|
825
|
+
def act_on(items, opt = {})
|
826
|
+
actions = %i[editor delete tag flag finish cancel archive output save_to]
|
827
|
+
has_action = false
|
828
|
+
actions.each do |a|
|
829
|
+
if opt[a]
|
830
|
+
has_action = true
|
831
|
+
break
|
1010
832
|
end
|
1011
833
|
end
|
1012
834
|
|
1013
|
-
|
1014
|
-
|
835
|
+
unless has_action
|
836
|
+
actions = [
|
837
|
+
'add tag',
|
838
|
+
'remove tag',
|
839
|
+
'cancel',
|
840
|
+
'delete',
|
841
|
+
'finish',
|
842
|
+
'flag',
|
843
|
+
'archive',
|
844
|
+
'move',
|
845
|
+
'edit',
|
846
|
+
'output formatted'
|
847
|
+
]
|
848
|
+
|
849
|
+
actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
|
850
|
+
|
851
|
+
choice = choose_from(actions,
|
852
|
+
prompt: 'What do you want to do with the selected items? > ',
|
853
|
+
multiple: true,
|
854
|
+
sorted: false,
|
855
|
+
fzf_args: ['--height=60%', '--tac', '--no-sort'])
|
856
|
+
return unless choice
|
857
|
+
|
858
|
+
to_do = choice.strip.split(/\n/)
|
859
|
+
to_do.each do |action|
|
860
|
+
case action
|
861
|
+
when /resume/
|
862
|
+
opt[:resume] = true
|
863
|
+
when /reset/
|
864
|
+
opt[:reset] = true
|
865
|
+
when /(add|remove) tag/
|
866
|
+
type = action =~ /^add/ ? 'add' : 'remove'
|
867
|
+
raise Errors::InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
|
868
|
+
|
869
|
+
print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
|
870
|
+
tag = $stdin.gets
|
871
|
+
next if tag =~ /^ *$/
|
872
|
+
|
873
|
+
opt[:tag] = tag.strip.sub(/^@/, '')
|
874
|
+
opt[:remove] = true if type == 'remove'
|
875
|
+
when /output formatted/
|
876
|
+
output_format = choose_from(Plugins.available_plugins(type: :export).sort,
|
877
|
+
prompt: 'Which output format? > ',
|
878
|
+
fzf_args: ['--height=60%', '--tac', '--no-sort'])
|
879
|
+
next if tag =~ /^ *$/
|
880
|
+
|
881
|
+
unless output_format
|
882
|
+
raise Errors::UserCancelled, 'Cancelled'
|
883
|
+
end
|
884
|
+
|
885
|
+
opt[:output] = output_format.strip
|
886
|
+
res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
|
887
|
+
if res
|
888
|
+
print "#{Color.yellow}File path/name: #{Color.reset}"
|
889
|
+
filename = $stdin.gets.strip
|
890
|
+
next if filename.empty?
|
1015
891
|
|
1016
|
-
|
1017
|
-
|
1018
|
-
|
1019
|
-
|
892
|
+
opt[:save_to] = filename
|
893
|
+
end
|
894
|
+
when /archive/
|
895
|
+
opt[:archive] = true
|
896
|
+
when /delete/
|
897
|
+
opt[:delete] = true
|
898
|
+
when /edit/
|
899
|
+
opt[:editor] = true
|
900
|
+
when /finish/
|
901
|
+
opt[:finish] = true
|
902
|
+
when /cancel/
|
903
|
+
opt[:cancel] = true
|
904
|
+
when /move/
|
905
|
+
section = choose_section.strip
|
906
|
+
opt[:move] = section.strip unless section =~ /^ *$/
|
907
|
+
when /flag/
|
908
|
+
opt[:flag] = true
|
909
|
+
end
|
910
|
+
end
|
1020
911
|
end
|
1021
912
|
|
1022
|
-
|
1023
|
-
|
913
|
+
if opt[:resume] || opt[:reset]
|
914
|
+
if items.count > 1
|
915
|
+
logger.error('Error:', 'resume and restart can only be used on a single entry')
|
916
|
+
else
|
917
|
+
item = items[0]
|
918
|
+
if opt[:resume] && !opt[:reset]
|
919
|
+
repeat_item(item, { editor: opt[:editor] })
|
920
|
+
elsif opt[:reset]
|
921
|
+
if item.tags?('done', :and) && !opt[:resume]
|
922
|
+
res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
|
923
|
+
else
|
924
|
+
res = opt[:resume]
|
925
|
+
end
|
926
|
+
update_item(item, reset_item(item, resume: res))
|
927
|
+
end
|
928
|
+
write(@doing_file)
|
929
|
+
end
|
930
|
+
return
|
931
|
+
end
|
1024
932
|
|
1025
|
-
|
1026
|
-
|
1027
|
-
|
1028
|
-
|
1029
|
-
|
1030
|
-
|
1031
|
-
|
933
|
+
if opt[:delete]
|
934
|
+
res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
|
935
|
+
if res
|
936
|
+
items.each { |item| delete_item(item) }
|
937
|
+
write(@doing_file)
|
938
|
+
end
|
939
|
+
return
|
1032
940
|
end
|
1033
941
|
|
1034
|
-
|
942
|
+
if opt[:flag]
|
943
|
+
tag = @config['marker_tag'] || 'flagged'
|
944
|
+
items.map! do |item|
|
945
|
+
tag_item(item, tag, date: false, remove: opt[:remove])
|
946
|
+
end
|
947
|
+
end
|
1035
948
|
|
1036
|
-
if opt[:
|
1037
|
-
|
1038
|
-
|
1039
|
-
|
1040
|
-
|
949
|
+
if opt[:finish] || opt[:cancel]
|
950
|
+
tag = 'done'
|
951
|
+
items.map! do |item|
|
952
|
+
if item.should_finish?
|
953
|
+
should_date = !opt[:cancel] && item.should_time?
|
954
|
+
tag_item(item, tag, date: should_date, remove: opt[:remove])
|
955
|
+
end
|
1041
956
|
end
|
957
|
+
end
|
1042
958
|
|
1043
|
-
|
1044
|
-
|
959
|
+
if opt[:tag]
|
960
|
+
tag = opt[:tag]
|
961
|
+
items.map! do |item|
|
962
|
+
tag_item(item, tag, date: false, remove: opt[:remove])
|
1045
963
|
end
|
964
|
+
end
|
1046
965
|
|
1047
|
-
|
1048
|
-
|
1049
|
-
|
966
|
+
if opt[:archive] || opt[:move]
|
967
|
+
section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
|
968
|
+
items.map! {|item| move_item(item, section) }
|
1050
969
|
end
|
1051
|
-
end
|
1052
|
-
end
|
1053
970
|
|
1054
|
-
|
1055
|
-
## @brief Tag the last entry or X entries
|
1056
|
-
##
|
1057
|
-
## @param opt (Hash) Additional Options
|
1058
|
-
##
|
1059
|
-
def tag_last(opt = {})
|
1060
|
-
opt[:section] ||= nil
|
1061
|
-
opt[:count] ||= 1
|
1062
|
-
opt[:archive] ||= false
|
1063
|
-
opt[:tags] ||= ['done']
|
1064
|
-
opt[:sequential] ||= false
|
1065
|
-
opt[:date] ||= false
|
1066
|
-
opt[:remove] ||= false
|
1067
|
-
opt[:autotag] ||= false
|
1068
|
-
opt[:back] ||= false
|
1069
|
-
opt[:took] ||= nil
|
1070
|
-
opt[:unfinished] ||= false
|
1071
|
-
|
1072
|
-
sec_arr = []
|
1073
|
-
|
1074
|
-
if opt[:section].nil?
|
1075
|
-
if opt[:search] || opt[:tag]
|
1076
|
-
sec_arr = sections
|
1077
|
-
else
|
1078
|
-
sec_arr = [@current_section]
|
1079
|
-
end
|
1080
|
-
elsif opt[:section].instance_of?(String)
|
1081
|
-
if opt[:section] =~ /^all$/i
|
1082
|
-
if opt[:count] == 1
|
1083
|
-
combined = { 'items' => [] }
|
1084
|
-
@content.each do |_k, v|
|
1085
|
-
combined['items'] += v['items']
|
1086
|
-
end
|
1087
|
-
items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
|
1088
|
-
sec_arr.push(items[0]['section'])
|
1089
|
-
elsif opt[:count] > 1
|
1090
|
-
if opt[:search] || opt[:tag]
|
1091
|
-
sec_arr = sections
|
1092
|
-
else
|
1093
|
-
exit_now! 'A count greater than one requires a section to be specified'
|
1094
|
-
end
|
1095
|
-
else
|
1096
|
-
sec_arr = sections
|
1097
|
-
end
|
1098
|
-
else
|
1099
|
-
sec_arr = [guess_section(opt[:section])]
|
1100
|
-
end
|
1101
|
-
end
|
971
|
+
write(@doing_file)
|
1102
972
|
|
1103
|
-
|
1104
|
-
if @content.key?(section)
|
973
|
+
if opt[:editor]
|
1105
974
|
|
1106
|
-
|
1107
|
-
idx = 0
|
1108
|
-
done_date = Time.now
|
1109
|
-
count = (opt[:count]).zero? ? items.length : opt[:count]
|
1110
|
-
items.map! do |item|
|
1111
|
-
break if idx == count
|
1112
|
-
finished = opt[:unfinished] && item.has_tags?('done', :and)
|
1113
|
-
tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.has_tags?(opt[:tag], opt[:tag_bool])
|
1114
|
-
search_match = opt[:search].nil? || opt[:search].empty? ? true : item.matches_search?(opt[:search])
|
1115
|
-
|
1116
|
-
if tag_match && search_match && !finished
|
1117
|
-
if opt[:autotag]
|
1118
|
-
new_title = autotag(item['title']) if @auto_tag
|
1119
|
-
if new_title == item['title']
|
1120
|
-
@results.push(%(Autotag: No changes))
|
1121
|
-
else
|
1122
|
-
@results.push("Tags updated: #{new_title}")
|
1123
|
-
item['title'] = new_title
|
1124
|
-
end
|
1125
|
-
else
|
1126
|
-
if opt[:sequential]
|
1127
|
-
next_entry = next_item(item)
|
975
|
+
editable_items = []
|
1128
976
|
|
1129
|
-
|
1130
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
|
1137
|
-
done_date = Time.now
|
1138
|
-
else
|
1139
|
-
done_date = item['date'] + opt[:took]
|
1140
|
-
end
|
1141
|
-
elsif opt[:back]
|
1142
|
-
if opt[:back].is_a? Integer
|
1143
|
-
done_date = item['date'] + opt[:back]
|
1144
|
-
else
|
1145
|
-
done_date = item['date'] + (opt[:back] - item['date'])
|
1146
|
-
end
|
1147
|
-
else
|
1148
|
-
done_date = Time.now
|
1149
|
-
end
|
977
|
+
items.each do |item|
|
978
|
+
editable = "#{item.date} | #{item.title}"
|
979
|
+
old_note = item.note ? item.note.to_s : nil
|
980
|
+
editable += "\n#{old_note}" unless old_note.nil?
|
981
|
+
editable_items << editable
|
982
|
+
end
|
983
|
+
divider = "\n-----------\n"
|
984
|
+
input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
|
1150
985
|
|
1151
|
-
|
1152
|
-
opt[:tags].each do |tag|
|
1153
|
-
tag = tag.strip
|
1154
|
-
if opt[:remove]
|
1155
|
-
if title =~ /@#{tag}\b/
|
1156
|
-
title.gsub!(/(^| )@#{tag}(\([^)]*\))?/, '')
|
1157
|
-
@results.push(%(Removed @#{tag}: "#{title}" in #{section}))
|
1158
|
-
end
|
1159
|
-
elsif title !~ /@#{tag}/
|
1160
|
-
title.chomp!
|
1161
|
-
title += if opt[:date]
|
1162
|
-
" @#{tag}(#{done_date.strftime('%F %R')})"
|
1163
|
-
else
|
1164
|
-
" @#{tag}"
|
1165
|
-
end
|
1166
|
-
@results.push(%(Added @#{tag}: "#{title}" in #{section}))
|
1167
|
-
end
|
1168
|
-
end
|
1169
|
-
item['title'] = title
|
1170
|
-
end
|
986
|
+
new_items = fork_editor(input).split(/#{divider}/)
|
1171
987
|
|
1172
|
-
|
1173
|
-
end
|
988
|
+
new_items.each_with_index do |new_item, i|
|
1174
989
|
|
1175
|
-
|
1176
|
-
|
990
|
+
input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
|
991
|
+
title = input_lines[0]&.strip
|
1177
992
|
|
1178
|
-
|
1179
|
-
|
1180
|
-
|
1181
|
-
|
1182
|
-
archived = @content[section]['items'][0..opt[:count] - 1].map do |i|
|
1183
|
-
i['title'].sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{i['section']})")
|
1184
|
-
i
|
1185
|
-
end.concat(@content['Archive']['items'])
|
1186
|
-
# slice [count] items off of [section] items
|
1187
|
-
@content[opt[:section]]['items'] = @content[opt[:section]]['items'][opt[:count]..-1]
|
1188
|
-
# overwrite archive section with concatenated array
|
1189
|
-
@content['Archive']['items'] = archived
|
1190
|
-
# log it
|
1191
|
-
result = opt[:count] == 1 ? '1 entry' : "#{opt[:count]} entries"
|
1192
|
-
@results.push("Archived #{result} from #{section}")
|
1193
|
-
elsif opt[:archive] && (opt[:count]).zero?
|
1194
|
-
@results.push('Archiving is skipped when operating on all entries') if (opt[:count]).zero?
|
1195
|
-
end
|
1196
|
-
else
|
1197
|
-
exit_now! "Section not found: #{section}"
|
1198
|
-
end
|
1199
|
-
end
|
1200
|
-
|
1201
|
-
write(@doing_file)
|
1202
|
-
end
|
993
|
+
if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
|
994
|
+
delete_item(items[i])
|
995
|
+
else
|
996
|
+
note = input_lines.length > 1 ? input_lines[1..-1] : []
|
1203
997
|
|
1204
|
-
|
1205
|
-
|
1206
|
-
new_item = item.dup
|
1207
|
-
new_item['section'] = section
|
998
|
+
note.map!(&:strip)
|
999
|
+
note.delete_if(&:ignore?)
|
1208
1000
|
|
1209
|
-
|
1210
|
-
|
1211
|
-
@content[old_section]['items'] = section_items
|
1001
|
+
date = title.match(/^([\d\-: ]+) \| /)[1]
|
1002
|
+
title.sub!(/^([\d\-: ]+) \| /, '')
|
1212
1003
|
|
1213
|
-
|
1214
|
-
|
1215
|
-
|
1216
|
-
|
1004
|
+
item = items[i]
|
1005
|
+
item.title = title
|
1006
|
+
item.note = note
|
1007
|
+
item.date = Time.parse(date) || items[i].date
|
1008
|
+
end
|
1009
|
+
end
|
1217
1010
|
|
1218
|
-
|
1219
|
-
|
1220
|
-
end
|
1011
|
+
write(@doing_file)
|
1012
|
+
end
|
1221
1013
|
|
1222
|
-
|
1223
|
-
|
1224
|
-
|
1225
|
-
|
1226
|
-
|
1227
|
-
def next_item(old_item)
|
1228
|
-
combined = { 'items' => [] }
|
1229
|
-
@content.each do |_k, v|
|
1230
|
-
combined['items'] += v['items']
|
1231
|
-
end
|
1232
|
-
items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
|
1233
|
-
idx = items.index(old_item)
|
1014
|
+
if opt[:output]
|
1015
|
+
items.map! do |item|
|
1016
|
+
item.title = "#{item.title} @project(#{item.section})"
|
1017
|
+
item
|
1018
|
+
end
|
1234
1019
|
|
1235
|
-
|
1236
|
-
|
1237
|
-
else
|
1238
|
-
nil
|
1239
|
-
end
|
1240
|
-
end
|
1020
|
+
@content = { 'Export' => { :original => 'Export:', :items => items } }
|
1021
|
+
options = { section: 'Export' }
|
1241
1022
|
|
1242
|
-
##
|
1243
|
-
## @brief Delete an item from the index
|
1244
|
-
##
|
1245
|
-
## @param old_item
|
1246
|
-
##
|
1247
|
-
def delete_item(old_item)
|
1248
|
-
section = old_item['section']
|
1249
1023
|
|
1250
|
-
|
1251
|
-
|
1252
|
-
|
1253
|
-
|
1254
|
-
|
1024
|
+
if opt[:output] =~ /doing/
|
1025
|
+
options[:output] = 'template'
|
1026
|
+
options[:template] = '- %date | %title%note'
|
1027
|
+
else
|
1028
|
+
options[:output] = opt[:output]
|
1029
|
+
options[:template] = opt[:template] || nil
|
1030
|
+
end
|
1255
1031
|
|
1256
|
-
|
1257
|
-
## @brief Remove a tag on an item from the index
|
1258
|
-
##
|
1259
|
-
## @param old_item (Item) The item to tag
|
1260
|
-
## @param tag (string) The tag to remove
|
1261
|
-
##
|
1262
|
-
def untag_item(old_item, tags)
|
1263
|
-
title = old_item['title'].dup
|
1264
|
-
if tags.is_a? ::String
|
1265
|
-
tags = tags.split(/ *, */).map {|t| t.strip.gsub(/\*/,'[^ (]*') }
|
1266
|
-
end
|
1032
|
+
output = list_section(options)
|
1267
1033
|
|
1268
|
-
|
1269
|
-
|
1270
|
-
|
1271
|
-
|
1272
|
-
|
1273
|
-
|
1274
|
-
update_item(old_item, new_item)
|
1275
|
-
return new_item
|
1276
|
-
else
|
1277
|
-
@results.push(%(Item isn't tagged @#{tag}: "#{title}" in #{old_item['section']}))
|
1278
|
-
return old_item
|
1279
|
-
end
|
1280
|
-
end
|
1281
|
-
end
|
1034
|
+
if opt[:save_to]
|
1035
|
+
file = File.expand_path(opt[:save_to])
|
1036
|
+
if File.exist?(file)
|
1037
|
+
# Create a backup copy for the undo command
|
1038
|
+
FileUtils.cp(file, "#{file}~")
|
1039
|
+
end
|
1282
1040
|
|
1283
|
-
|
1284
|
-
|
1285
|
-
|
1286
|
-
## @param old_item (Item) The item to tag
|
1287
|
-
## @param tag (string) The tag to apply
|
1288
|
-
## @param date (Boolean) Include timestamp?
|
1289
|
-
##
|
1290
|
-
def tag_item(old_item, tags, remove: false, date: false)
|
1291
|
-
title = old_item['title'].dup
|
1292
|
-
if tags.is_a? ::String
|
1293
|
-
tags = tags.split(/ *, */).map(&:strip)
|
1294
|
-
end
|
1041
|
+
File.open(file, 'w+') do |f|
|
1042
|
+
f.puts output
|
1043
|
+
end
|
1295
1044
|
|
1296
|
-
|
1297
|
-
tags.each do |tag|
|
1298
|
-
if title !~ /@#{tag}/
|
1299
|
-
title.chomp!
|
1300
|
-
if date
|
1301
|
-
title += " @#{tag}(#{done_date.strftime('%F %R')})"
|
1045
|
+
logger.warn('File written:', file)
|
1302
1046
|
else
|
1303
|
-
|
1047
|
+
Doing::Pager.page output
|
1304
1048
|
end
|
1305
|
-
new_item = old_item.dup
|
1306
|
-
new_item['title'] = title
|
1307
|
-
update_item(old_item, new_item)
|
1308
|
-
return new_item
|
1309
|
-
else
|
1310
|
-
@results.push(%(Item already @#{tag}: "#{title}" in #{old_item['section']}))
|
1311
|
-
return old_item
|
1312
1049
|
end
|
1313
1050
|
end
|
1314
|
-
end
|
1315
1051
|
|
1316
|
-
|
1317
|
-
|
1318
|
-
|
1319
|
-
|
1320
|
-
|
1321
|
-
|
1322
|
-
|
1323
|
-
|
1052
|
+
##
|
1053
|
+
## @brief Tag an item from the index
|
1054
|
+
##
|
1055
|
+
## @param item (Item) The item to tag
|
1056
|
+
## @param tags (string) The tag to apply
|
1057
|
+
## @param remove (Boolean) remove tags
|
1058
|
+
## @param date (Boolean) Include timestamp?
|
1059
|
+
##
|
1060
|
+
def tag_item(item, tags, remove: false, date: false)
|
1061
|
+
added = []
|
1062
|
+
removed = []
|
1324
1063
|
|
1325
|
-
|
1326
|
-
s_idx = section_items.index(old_item)
|
1064
|
+
tags = tags.to_tags if tags.is_a? ::String
|
1327
1065
|
|
1328
|
-
|
1329
|
-
@results.push("Entry updated: #{section_items[s_idx]['title']}")
|
1330
|
-
@content[section]['items'] = section_items
|
1331
|
-
end
|
1066
|
+
done_date = Time.now
|
1332
1067
|
|
1333
|
-
|
1334
|
-
|
1335
|
-
|
1336
|
-
|
1337
|
-
|
1338
|
-
|
1339
|
-
|
1068
|
+
tags.each do |tag|
|
1069
|
+
bool = remove ? :and : :not
|
1070
|
+
if item.tags?(tag, bool)
|
1071
|
+
item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
|
1072
|
+
remove ? removed.push(tag) : added.push(tag)
|
1073
|
+
end
|
1074
|
+
end
|
1340
1075
|
|
1341
|
-
|
1342
|
-
|
1343
|
-
|
1344
|
-
|
1076
|
+
log_change(tags_added: added, tags_removed: removed, count: 1)
|
1077
|
+
|
1078
|
+
item
|
1079
|
+
end
|
1080
|
+
|
1081
|
+
##
|
1082
|
+
## @brief Tag the last entry or X entries
|
1083
|
+
##
|
1084
|
+
## @param opt (Hash) Additional Options
|
1085
|
+
##
|
1086
|
+
def tag_last(opt = {})
|
1087
|
+
opt[:count] ||= 1
|
1088
|
+
opt[:archive] ||= false
|
1089
|
+
opt[:tags] ||= ['done']
|
1090
|
+
opt[:sequential] ||= false
|
1091
|
+
opt[:date] ||= false
|
1092
|
+
opt[:remove] ||= false
|
1093
|
+
opt[:autotag] ||= false
|
1094
|
+
opt[:back] ||= false
|
1095
|
+
opt[:unfinished] ||= false
|
1096
|
+
opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
|
1097
|
+
|
1098
|
+
items = filter_items([], opt: opt)
|
1099
|
+
|
1100
|
+
logger.info('Skipped:', 'no items matched your search') if items.empty?
|
1101
|
+
|
1102
|
+
if opt[:interactive]
|
1103
|
+
items = choose_from_items(items, {
|
1104
|
+
menu: true,
|
1105
|
+
header: '',
|
1106
|
+
prompt: 'Select entries to tag > ',
|
1107
|
+
multiple: true,
|
1108
|
+
sort: true,
|
1109
|
+
show_if_single: true
|
1110
|
+
}, include_section: opt[:section] =~ /^all$/i )
|
1111
|
+
|
1112
|
+
return if items.nil?
|
1345
1113
|
end
|
1346
|
-
# section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
|
1347
|
-
else
|
1348
|
-
items = @content[section]['items']
|
1349
|
-
end
|
1350
1114
|
|
1351
|
-
|
1115
|
+
items.each do |item|
|
1116
|
+
added = []
|
1117
|
+
removed = []
|
1118
|
+
|
1119
|
+
if opt[:autotag]
|
1120
|
+
new_title = autotag(item.title) if @auto_tag
|
1121
|
+
if new_title == item.title
|
1122
|
+
logger.count(:skipped, level: :debug, message: '%count unchaged %items')
|
1123
|
+
# logger.debug('Autotag:', 'No changes')
|
1124
|
+
else
|
1125
|
+
logger.count(:added_tags)
|
1126
|
+
logger.debug('Tags updated:', new_title)
|
1127
|
+
item.title = new_title
|
1128
|
+
end
|
1129
|
+
else
|
1130
|
+
if opt[:sequential]
|
1131
|
+
next_entry = next_item(item)
|
1132
|
+
|
1133
|
+
done_date = if next_entry.nil?
|
1134
|
+
Time.now
|
1135
|
+
else
|
1136
|
+
next_entry.date - 60
|
1137
|
+
end
|
1138
|
+
elsif opt[:took]
|
1139
|
+
if item.date + opt[:took] > Time.now
|
1140
|
+
item.date = Time.now - opt[:took]
|
1141
|
+
done_date = Time.now
|
1142
|
+
else
|
1143
|
+
done_date = item.date + opt[:took]
|
1144
|
+
end
|
1145
|
+
elsif opt[:back]
|
1146
|
+
done_date = if opt[:back].is_a? Integer
|
1147
|
+
item.date + opt[:back]
|
1148
|
+
else
|
1149
|
+
item.date + (opt[:back] - item.date)
|
1150
|
+
end
|
1151
|
+
else
|
1152
|
+
done_date = Time.now
|
1153
|
+
end
|
1352
1154
|
|
1353
|
-
|
1155
|
+
opt[:tags].each do |tag|
|
1156
|
+
if tag == 'done' && !item.should_finish?
|
1354
1157
|
|
1355
|
-
|
1356
|
-
|
1357
|
-
|
1358
|
-
|
1359
|
-
|
1158
|
+
Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
|
1159
|
+
logger.count(:skipped, level: :debug)
|
1160
|
+
next
|
1161
|
+
end
|
1162
|
+
|
1163
|
+
tag = tag.strip
|
1164
|
+
if opt[:remove] || opt[:rename]
|
1165
|
+
rename_to = nil
|
1166
|
+
if opt[:rename]
|
1167
|
+
rename_to = tag
|
1168
|
+
tag = opt[:rename]
|
1169
|
+
end
|
1170
|
+
old_title = item.title.dup
|
1171
|
+
item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex])
|
1172
|
+
if old_title != item.title
|
1173
|
+
removed << tag
|
1174
|
+
added << rename_to if rename_to
|
1175
|
+
else
|
1176
|
+
logger.count(:skipped, level: :debug)
|
1177
|
+
end
|
1178
|
+
else
|
1179
|
+
old_title = item.title.dup
|
1180
|
+
should_date = opt[:date] && item.should_time?
|
1181
|
+
item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
|
1182
|
+
added << tag if old_title != item.title
|
1183
|
+
end
|
1184
|
+
end
|
1360
1185
|
end
|
1361
|
-
|
1362
|
-
|
1363
|
-
|
1364
|
-
|
1365
|
-
|
1366
|
-
|
1186
|
+
|
1187
|
+
log_change(tags_added: added, tags_removed: removed)
|
1188
|
+
|
1189
|
+
item.note.add(opt[:note]) if opt[:note]
|
1190
|
+
|
1191
|
+
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
1192
|
+
move_item(item, 'Archive', label: true)
|
1193
|
+
elsif opt[:archive] && opt[:count].zero?
|
1194
|
+
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
1367
1195
|
end
|
1368
1196
|
end
|
1369
|
-
else
|
1370
|
-
idx = 0
|
1371
|
-
end
|
1372
1197
|
|
1373
|
-
if idx.nil?
|
1374
|
-
@results.push('No entries found')
|
1375
|
-
return
|
1376
|
-
end
|
1377
|
-
|
1378
|
-
section = items[idx]['section']
|
1379
|
-
|
1380
|
-
section_items = @content[section]['items']
|
1381
|
-
s_idx = section_items.index(items[idx])
|
1382
|
-
|
1383
|
-
current_item = section_items[s_idx]['title']
|
1384
|
-
old_note = section_items[s_idx]['note'] ? section_items[s_idx]['note'].map(&:strip).join("\n") : nil
|
1385
|
-
current_item += "\n#{old_note}" unless old_note.nil?
|
1386
|
-
new_item = fork_editor(current_item)
|
1387
|
-
title, note = format_input(new_item)
|
1388
|
-
|
1389
|
-
if title.nil? || title.empty?
|
1390
|
-
@results.push('No content provided')
|
1391
|
-
elsif title == section_items[s_idx]['title'] && note == old_note
|
1392
|
-
@results.push('No change in content')
|
1393
|
-
else
|
1394
|
-
section_items[s_idx]['title'] = title
|
1395
|
-
section_items[s_idx]['note'] = note
|
1396
|
-
@results.push("Entry edited: #{section_items[s_idx]['title']}")
|
1397
|
-
@content[section]['items'] = section_items
|
1398
1198
|
write(@doing_file)
|
1399
1199
|
end
|
1400
|
-
end
|
1401
|
-
|
1402
|
-
##
|
1403
|
-
## @brief Add a note to the last entry in a section
|
1404
|
-
##
|
1405
|
-
## @param section (String) The section, default "All"
|
1406
|
-
## @param note (String) The note to add
|
1407
|
-
## @param replace (Bool) Should replace existing note
|
1408
|
-
##
|
1409
|
-
def note_last(section, note, replace: false)
|
1410
|
-
section = guess_section(section)
|
1411
1200
|
|
1412
|
-
|
1413
|
-
|
1414
|
-
|
1415
|
-
|
1201
|
+
##
|
1202
|
+
## @brief Move item from current section to
|
1203
|
+
## destination section
|
1204
|
+
##
|
1205
|
+
## @param item The item
|
1206
|
+
## @param section The destination section
|
1207
|
+
##
|
1208
|
+
## @return Updated item
|
1209
|
+
##
|
1210
|
+
def move_item(item, section, label: true)
|
1211
|
+
from = item.section
|
1212
|
+
new_item = @content[item.section][:items].delete(item)
|
1213
|
+
new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
|
1214
|
+
new_item.section = section
|
1215
|
+
|
1216
|
+
@content[section][:items].concat([new_item])
|
1217
|
+
|
1218
|
+
logger.count(section == 'Archive' ? :archived : :moved)
|
1219
|
+
logger.debug("Entry #{section == 'Archive' ? 'archived' : 'moved'}:",
|
1220
|
+
"#{new_item.title.truncate(60)} from #{from} to #{section}")
|
1221
|
+
new_item
|
1222
|
+
end
|
1223
|
+
|
1224
|
+
##
|
1225
|
+
## @brief Get next item in the index
|
1226
|
+
##
|
1227
|
+
## @param item
|
1228
|
+
##
|
1229
|
+
def next_item(item, options = {})
|
1230
|
+
items = filter_items([], opt: options)
|
1231
|
+
|
1232
|
+
idx = items.index(item)
|
1233
|
+
|
1234
|
+
idx.positive? ? items[idx - 1] : nil
|
1235
|
+
end
|
1236
|
+
|
1237
|
+
##
|
1238
|
+
## @brief Delete an item from the index
|
1239
|
+
##
|
1240
|
+
## @param item The item
|
1241
|
+
##
|
1242
|
+
def delete_item(item)
|
1243
|
+
section = item.section
|
1244
|
+
|
1245
|
+
section_items = @content[section][:items]
|
1246
|
+
deleted = section_items.delete(item)
|
1247
|
+
logger.count(:deleted)
|
1248
|
+
logger.debug('Entry deleted:', deleted.title)
|
1249
|
+
end
|
1250
|
+
|
1251
|
+
##
|
1252
|
+
## @brief Update an item in the index with a modified item
|
1253
|
+
##
|
1254
|
+
## @param old_item The old item
|
1255
|
+
## @param new_item The new item
|
1256
|
+
##
|
1257
|
+
def update_item(old_item, new_item)
|
1258
|
+
section = old_item.section
|
1259
|
+
|
1260
|
+
section_items = @content[section][:items]
|
1261
|
+
s_idx = section_items.index { |item| item.equal?(old_item) }
|
1262
|
+
|
1263
|
+
unless s_idx
|
1264
|
+
Doing.logger.error('Fail to update:', 'Could not find item in index')
|
1265
|
+
raise Errors::ItemNotFound, 'Unable to find item in index, did it mutate?'
|
1416
1266
|
end
|
1417
|
-
section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
|
1418
|
-
end
|
1419
1267
|
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
1423
|
-
|
1424
|
-
|
1425
|
-
|
1426
|
-
current_note = [] if current_note.nil?
|
1427
|
-
title = items[0]['title']
|
1428
|
-
if replace
|
1429
|
-
items[0]['note'] = note
|
1430
|
-
if note.empty? && !current_note.empty?
|
1431
|
-
@results.push(%(Removed note from "#{title}"))
|
1432
|
-
elsif !current_note.empty? && !note.empty?
|
1433
|
-
@results.push(%(Replaced note from "#{title}"))
|
1434
|
-
elsif !note.empty?
|
1435
|
-
@results.push(%(Added note to "#{title}"))
|
1436
|
-
else
|
1437
|
-
@results.push(%(Entry "#{title}" has no note))
|
1438
|
-
end
|
1439
|
-
elsif current_note.instance_of?(Array)
|
1440
|
-
items[0]['note'] = current_note.concat(note)
|
1441
|
-
@results.push(%(Added note to "#{title}")) unless note.empty?
|
1442
|
-
else
|
1443
|
-
items[0]['note'] = note
|
1444
|
-
@results.push(%(Added note to "#{title}")) unless note.empty?
|
1268
|
+
return if section_items[s_idx].equal?(new_item)
|
1269
|
+
|
1270
|
+
section_items[s_idx] = new_item
|
1271
|
+
logger.count(:updated)
|
1272
|
+
logger.debug('Entry updated:', section_items[s_idx].title.truncate(60))
|
1273
|
+
new_item
|
1445
1274
|
end
|
1446
1275
|
|
1447
|
-
|
1276
|
+
##
|
1277
|
+
## @brief Edit the last entry
|
1278
|
+
##
|
1279
|
+
## @param section (String) The section, default "All"
|
1280
|
+
##
|
1281
|
+
def edit_last(section: 'All', options: {})
|
1282
|
+
options[:section] = guess_section(section)
|
1448
1283
|
|
1449
|
-
|
1284
|
+
item = last_entry(options)
|
1450
1285
|
|
1451
|
-
|
1452
|
-
|
1453
|
-
|
1454
|
-
|
1455
|
-
|
1456
|
-
|
1457
|
-
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
1461
|
-
|
1462
|
-
|
1463
|
-
|
1464
|
-
|
1465
|
-
opt[:back] ||= Time.now
|
1466
|
-
opt[:new_item] ||= false
|
1467
|
-
opt[:note] ||= false
|
1468
|
-
|
1469
|
-
opt[:section] = guess_section(opt[:section])
|
1470
|
-
|
1471
|
-
tag.sub!(/^@/, '')
|
1472
|
-
|
1473
|
-
found_items = 0
|
1474
|
-
@content[opt[:section]]['items'].each_with_index do |item, i|
|
1475
|
-
next unless item['title'] =~ /@#{tag}/
|
1476
|
-
|
1477
|
-
title = item['title'].gsub(/(^| )@(#{tag}|done)(\([^)]*\))?/, '')
|
1478
|
-
title += " @done(#{opt[:back].strftime('%F %R')})"
|
1479
|
-
|
1480
|
-
@content[opt[:section]]['items'][i]['title'] = title
|
1481
|
-
found_items += 1
|
1482
|
-
|
1483
|
-
if opt[:archive] && opt[:section] != 'Archive'
|
1484
|
-
@results.push(%(Completed and archived "#{@content[opt[:section]]['items'][i]['title']}"))
|
1485
|
-
archive_item = @content[opt[:section]]['items'][i]
|
1486
|
-
archive_item['title'] = i['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{i['section']})")
|
1487
|
-
@content['Archive']['items'].push(archive_item)
|
1488
|
-
@content[opt[:section]]['items'].delete_at(i)
|
1286
|
+
if item.nil?
|
1287
|
+
logger.debug('Skipped:', 'No entries found')
|
1288
|
+
return
|
1289
|
+
end
|
1290
|
+
|
1291
|
+
content = [item.title.dup]
|
1292
|
+
content << item.note.to_s unless item.note.empty?
|
1293
|
+
new_item = fork_editor(content.join("\n"))
|
1294
|
+
title, note = format_input(new_item)
|
1295
|
+
|
1296
|
+
if title.nil? || title.empty?
|
1297
|
+
logger.debug('Skipped:', 'No content provided')
|
1298
|
+
elsif title == item.title && note.equal?(item.note)
|
1299
|
+
logger.debug('Skipped:', 'No change in content')
|
1489
1300
|
else
|
1490
|
-
|
1301
|
+
item.title = title
|
1302
|
+
item.note.add(note, replace: true)
|
1303
|
+
logger.info('Edited:', item.title)
|
1304
|
+
|
1305
|
+
write(@doing_file)
|
1491
1306
|
end
|
1492
1307
|
end
|
1493
1308
|
|
1494
|
-
|
1309
|
+
##
|
1310
|
+
## @brief Accepts one tag and the raw text of a new item if the passed tag
|
1311
|
+
## is on any item, it's replaced with @done. if new_item is not
|
1312
|
+
## nil, it's tagged with the passed tag and inserted. This is for
|
1313
|
+
## use where only one instance of a given tag should exist
|
1314
|
+
## (@meanwhile)
|
1315
|
+
##
|
1316
|
+
## @param tag (String) Tag to replace
|
1317
|
+
## @param opt (Hash) Additional Options
|
1318
|
+
##
|
1319
|
+
def stop_start(target_tag, opt = {})
|
1320
|
+
tag = target_tag.dup
|
1321
|
+
opt[:section] ||= @config['current_section']
|
1322
|
+
opt[:archive] ||= false
|
1323
|
+
opt[:back] ||= Time.now
|
1324
|
+
opt[:new_item] ||= false
|
1325
|
+
opt[:note] ||= false
|
1495
1326
|
|
1496
|
-
|
1497
|
-
title, note = format_input(opt[:new_item])
|
1498
|
-
note.push(opt[:note].map(&:chomp)) if opt[:note]
|
1499
|
-
title += " @#{tag}"
|
1500
|
-
add_item(title.cap_first, opt[:section], { note: note.join(' ').rstrip, back: opt[:back] })
|
1501
|
-
end
|
1327
|
+
opt[:section] = guess_section(opt[:section])
|
1502
1328
|
|
1503
|
-
|
1504
|
-
end
|
1329
|
+
tag.sub!(/^@/, '')
|
1505
1330
|
|
1506
|
-
|
1507
|
-
## @brief Write content to file or STDOUT
|
1508
|
-
##
|
1509
|
-
## @param file (String) The filepath to write to
|
1510
|
-
##
|
1511
|
-
def write(file = nil, backup: true)
|
1512
|
-
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
1331
|
+
found_items = 0
|
1513
1332
|
|
1514
|
-
|
1515
|
-
|
1516
|
-
output += list_section({ section: title, template: "\t- %date | %title%note", highlight: false })
|
1517
|
-
end
|
1518
|
-
output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
1519
|
-
if file.nil?
|
1520
|
-
$stdout.puts output
|
1521
|
-
else
|
1522
|
-
file = File.expand_path(file)
|
1523
|
-
if File.exist?(file) && backup
|
1524
|
-
# Create a backup copy for the undo command
|
1525
|
-
FileUtils.cp(file, "#{file}~")
|
1526
|
-
end
|
1333
|
+
@content[opt[:section]][:items].each_with_index do |item, i|
|
1334
|
+
next unless item.title =~ /@#{tag}/
|
1527
1335
|
|
1528
|
-
|
1529
|
-
|
1530
|
-
|
1336
|
+
item.title.add_tags!([tag, 'done'], remove: true)
|
1337
|
+
item.tag('done', value: opt[:back].strftime('%F %R'))
|
1338
|
+
|
1339
|
+
found_items += 1
|
1531
1340
|
|
1532
|
-
|
1533
|
-
|
1534
|
-
|
1535
|
-
|
1536
|
-
|
1341
|
+
if opt[:archive] && opt[:section] != 'Archive'
|
1342
|
+
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
1343
|
+
move_item(item, 'Archive', label: false)
|
1344
|
+
logger.count(:completed_archived)
|
1345
|
+
logger.debug('Completed/archived:', item.title)
|
1346
|
+
else
|
1347
|
+
logger.count(:completed)
|
1348
|
+
logger.debug('Completed:', item.title)
|
1537
1349
|
end
|
1538
1350
|
end
|
1539
|
-
end
|
1540
|
-
end
|
1541
|
-
|
1542
|
-
##
|
1543
|
-
## @brief Restore a backed up version of a file
|
1544
|
-
##
|
1545
|
-
## @param file (String) The filepath to restore
|
1546
|
-
##
|
1547
|
-
def restore_backup(file)
|
1548
|
-
if File.exist?(file + '~')
|
1549
|
-
puts file + '~'
|
1550
|
-
FileUtils.cp(file + '~', file)
|
1551
|
-
@results.push("Restored #{file}")
|
1552
|
-
end
|
1553
|
-
end
|
1554
|
-
|
1555
|
-
##
|
1556
|
-
## @brief Rename doing file with date and start fresh one
|
1557
|
-
##
|
1558
|
-
def rotate(opt = {})
|
1559
|
-
count = opt[:keep] || 0
|
1560
|
-
tags = []
|
1561
|
-
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
1562
|
-
bool = opt[:bool] || :and
|
1563
|
-
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
1564
|
-
|
1565
|
-
if sect =~ /^all$/i
|
1566
|
-
all_sections = sections.dup
|
1567
|
-
else
|
1568
|
-
all_sections = [sect]
|
1569
|
-
end
|
1570
1351
|
|
1571
|
-
|
1572
|
-
new_content = {}
|
1352
|
+
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
1573
1353
|
|
1354
|
+
if opt[:new_item]
|
1355
|
+
title, note = format_input(opt[:new_item])
|
1356
|
+
note.add(opt[:note]) if opt[:note]
|
1357
|
+
title.tag!(tag)
|
1358
|
+
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
1359
|
+
end
|
1574
1360
|
|
1575
|
-
|
1576
|
-
|
1577
|
-
new_content[section] = {}
|
1578
|
-
new_content[section]['original'] = @content[section]['original']
|
1579
|
-
new_content[section]['items'] = []
|
1361
|
+
write(@doing_file)
|
1362
|
+
end
|
1580
1363
|
|
1581
|
-
|
1582
|
-
|
1583
|
-
|
1584
|
-
|
1585
|
-
|
1586
|
-
|
1587
|
-
|
1364
|
+
##
|
1365
|
+
## @brief Write content to file or STDOUT
|
1366
|
+
##
|
1367
|
+
## @param file (String) The filepath to write to
|
1368
|
+
##
|
1369
|
+
def write(file = nil, backup: true)
|
1370
|
+
Hooks.trigger :pre_write, self, file
|
1371
|
+
output = wrapped_content
|
1588
1372
|
|
1589
|
-
|
1590
|
-
|
1591
|
-
moved_items.push(item)
|
1592
|
-
counter += 1
|
1593
|
-
true
|
1594
|
-
else
|
1595
|
-
false
|
1596
|
-
end
|
1597
|
-
end
|
1598
|
-
@content[section]['items'] = items
|
1599
|
-
new_content[section]['items'] = moved_items
|
1600
|
-
@results.push("Rotated #{moved_items.length} items from #{section}")
|
1373
|
+
if file.nil?
|
1374
|
+
$stdout.puts output
|
1601
1375
|
else
|
1602
|
-
|
1603
|
-
|
1376
|
+
Util.write_to_file(file, output, backup: backup)
|
1377
|
+
run_after if @config.key?('run_after')
|
1378
|
+
end
|
1379
|
+
end
|
1604
1380
|
|
1605
|
-
|
1381
|
+
def wrapped_content
|
1382
|
+
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
1606
1383
|
|
1607
|
-
|
1608
|
-
|
1609
|
-
|
1610
|
-
|
1611
|
-
end
|
1384
|
+
@content.each do |title, section|
|
1385
|
+
output += "#{section[:original]}\n"
|
1386
|
+
output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
|
1387
|
+
end
|
1612
1388
|
|
1613
|
-
|
1614
|
-
|
1615
|
-
else
|
1616
|
-
items[0..count - 1]
|
1617
|
-
end
|
1618
|
-
new_content[section]['items'] = moved_items
|
1389
|
+
output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
1390
|
+
end
|
1619
1391
|
|
1620
|
-
|
1392
|
+
##
|
1393
|
+
## @brief Restore a backed up version of a file
|
1394
|
+
##
|
1395
|
+
## @param file (String) The filepath to restore
|
1396
|
+
##
|
1397
|
+
def restore_backup(file)
|
1398
|
+
if File.exist?("#{file}~")
|
1399
|
+
FileUtils.cp("#{file}~", file)
|
1400
|
+
logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
|
1401
|
+
else
|
1402
|
+
logger.error('Restore error:', 'No backup file found')
|
1621
1403
|
end
|
1622
1404
|
end
|
1623
1405
|
|
1624
|
-
|
1406
|
+
##
|
1407
|
+
## @brief Rename doing file with date and start fresh one
|
1408
|
+
##
|
1409
|
+
def rotate(opt = {})
|
1410
|
+
keep = opt[:keep] || 0
|
1411
|
+
tags = []
|
1412
|
+
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
1413
|
+
bool = opt[:bool] || :and
|
1414
|
+
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
1625
1415
|
|
1626
|
-
|
1627
|
-
|
1628
|
-
|
1629
|
-
|
1630
|
-
|
1631
|
-
@content = new_content
|
1632
|
-
end
|
1416
|
+
if sect =~ /^all$/i
|
1417
|
+
all_sections = sections.dup
|
1418
|
+
else
|
1419
|
+
all_sections = [sect]
|
1420
|
+
end
|
1633
1421
|
|
1634
|
-
|
1635
|
-
|
1422
|
+
counter = 0
|
1423
|
+
new_content = {}
|
1636
1424
|
|
1637
|
-
##
|
1638
|
-
## @brief Generate a menu of sections and allow user selection
|
1639
|
-
##
|
1640
|
-
## @return (String) The selected section name
|
1641
|
-
##
|
1642
|
-
def choose_section
|
1643
|
-
choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
1644
|
-
choice ? choice.strip : choice
|
1645
|
-
end
|
1646
1425
|
|
1647
|
-
|
1648
|
-
|
1649
|
-
|
1650
|
-
|
1651
|
-
|
1652
|
-
def views
|
1653
|
-
@config.has_key?('views') ? @config['views'].keys : []
|
1654
|
-
end
|
1426
|
+
all_sections.each do |section|
|
1427
|
+
items = @content[section][:items].dup
|
1428
|
+
new_content[section] = {}
|
1429
|
+
new_content[section][:original] = @content[section][:original]
|
1430
|
+
new_content[section][:items] = []
|
1655
1431
|
|
1656
|
-
|
1657
|
-
|
1658
|
-
|
1659
|
-
|
1660
|
-
|
1661
|
-
|
1662
|
-
choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
1663
|
-
choice ? choice.strip : choice
|
1664
|
-
end
|
1432
|
+
moved_items = []
|
1433
|
+
if !tags.empty? || opt[:search] || opt[:before]
|
1434
|
+
if opt[:before]
|
1435
|
+
time_string = opt[:before]
|
1436
|
+
cutoff = chronify(time_string, guess: :begin)
|
1437
|
+
end
|
1665
1438
|
|
1666
|
-
|
1667
|
-
|
1668
|
-
|
1669
|
-
|
1670
|
-
|
1671
|
-
|
1672
|
-
|
1439
|
+
items.delete_if do |item|
|
1440
|
+
if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
|
1441
|
+
moved_items.push(item)
|
1442
|
+
counter += 1
|
1443
|
+
true
|
1444
|
+
else
|
1445
|
+
false
|
1446
|
+
end
|
1447
|
+
end
|
1448
|
+
@content[section][:items] = items
|
1449
|
+
new_content[section][:items] = moved_items
|
1450
|
+
logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
|
1451
|
+
else
|
1452
|
+
new_content[section][:items] = []
|
1453
|
+
moved_items = []
|
1673
1454
|
|
1674
|
-
|
1675
|
-
end
|
1455
|
+
count = items.length < keep ? items.length : keep
|
1676
1456
|
|
1677
|
-
|
1678
|
-
|
1679
|
-
|
1680
|
-
|
1681
|
-
|
1682
|
-
|
1683
|
-
|
1684
|
-
|
1685
|
-
|
1686
|
-
|
1687
|
-
|
1688
|
-
|
1689
|
-
|
1690
|
-
|
1691
|
-
opt[:search] ||= false
|
1692
|
-
opt[:section] ||= nil
|
1693
|
-
opt[:sort_tags] ||= false
|
1694
|
-
opt[:tag_filter] ||= false
|
1695
|
-
opt[:tag_order] ||= 'asc'
|
1696
|
-
opt[:tags_color] ||= false
|
1697
|
-
opt[:template] ||= @default_template
|
1698
|
-
opt[:times] ||= false
|
1699
|
-
opt[:today] ||= false
|
1700
|
-
opt[:totals] ||= false
|
1701
|
-
|
1702
|
-
# opt[:highlight] ||= true
|
1703
|
-
section = ''
|
1704
|
-
if opt[:section].nil?
|
1705
|
-
section = choose_section
|
1706
|
-
opt[:section] = @content[section]
|
1707
|
-
elsif opt[:section].instance_of?(String)
|
1708
|
-
if opt[:section] =~ /^all$/i
|
1709
|
-
combined = { 'items' => [] }
|
1710
|
-
@content.each do |_k, v|
|
1711
|
-
combined['items'] += v['items']
|
1457
|
+
if items.count > count
|
1458
|
+
moved_items.concat(items[count..-1])
|
1459
|
+
else
|
1460
|
+
moved_items.concat(items)
|
1461
|
+
end
|
1462
|
+
|
1463
|
+
@content[section][:items] = if count.zero?
|
1464
|
+
[]
|
1465
|
+
else
|
1466
|
+
items[0..count - 1]
|
1467
|
+
end
|
1468
|
+
new_content[section][:items] = moved_items
|
1469
|
+
|
1470
|
+
logger.warn('Rotated:', "#{items.length - count} items from #{section}")
|
1712
1471
|
end
|
1713
|
-
|
1714
|
-
|
1715
|
-
|
1716
|
-
|
1472
|
+
end
|
1473
|
+
|
1474
|
+
write(@doing_file)
|
1475
|
+
|
1476
|
+
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
1477
|
+
if File.exist?(file)
|
1478
|
+
init_doing_file(file)
|
1479
|
+
@content.deep_merge(new_content)
|
1480
|
+
logger.warn('File update:', "Added entries to existing file: #{file}")
|
1481
|
+
else
|
1482
|
+
@content = new_content
|
1483
|
+
logger.warn('File update:', "Created new file: #{file}")
|
1484
|
+
end
|
1485
|
+
|
1486
|
+
write(file, backup: false)
|
1487
|
+
end
|
1488
|
+
|
1489
|
+
##
|
1490
|
+
## @brief Generate a menu of sections and allow user selection
|
1491
|
+
##
|
1492
|
+
## @return (String) The selected section name
|
1493
|
+
##
|
1494
|
+
def choose_section
|
1495
|
+
choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
1496
|
+
choice ? choice.strip : choice
|
1497
|
+
end
|
1498
|
+
|
1499
|
+
##
|
1500
|
+
## @brief List available views
|
1501
|
+
##
|
1502
|
+
## @return (Array) View names
|
1503
|
+
##
|
1504
|
+
def views
|
1505
|
+
@config.has_key?('views') ? @config['views'].keys : []
|
1506
|
+
end
|
1507
|
+
|
1508
|
+
##
|
1509
|
+
## @brief Generate a menu of views and allow user selection
|
1510
|
+
##
|
1511
|
+
## @return (String) The selected view name
|
1512
|
+
##
|
1513
|
+
def choose_view
|
1514
|
+
choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
1515
|
+
choice ? choice.strip : choice
|
1516
|
+
end
|
1517
|
+
|
1518
|
+
##
|
1519
|
+
## @brief Gets a view from configuration
|
1520
|
+
##
|
1521
|
+
## @param title (String) The title of the view to retrieve
|
1522
|
+
##
|
1523
|
+
def get_view(title)
|
1524
|
+
return @config['views'][title] if @config['views'].has_key?(title)
|
1525
|
+
|
1526
|
+
false
|
1527
|
+
end
|
1528
|
+
|
1529
|
+
##
|
1530
|
+
## @brief Display contents of a section based on options
|
1531
|
+
##
|
1532
|
+
## @param opt (Hash) Additional Options
|
1533
|
+
##
|
1534
|
+
def list_section(opt = {})
|
1535
|
+
opt[:count] ||= 0
|
1536
|
+
opt[:age] ||= 'newest'
|
1537
|
+
opt[:format] ||= @config.dig('templates', 'default', 'date_format')
|
1538
|
+
opt[:order] ||= @config.dig('templates', 'default', 'order') || 'asc'
|
1539
|
+
opt[:tag_order] ||= 'asc'
|
1540
|
+
opt[:tags_color] ||= false
|
1541
|
+
opt[:template] ||= @config.dig('templates', 'default', 'template')
|
1542
|
+
|
1543
|
+
# opt[:highlight] ||= true
|
1544
|
+
title = ''
|
1545
|
+
is_single = true
|
1546
|
+
if opt[:section].nil?
|
1547
|
+
opt[:section] = choose_section
|
1548
|
+
title = opt[:section]
|
1549
|
+
elsif opt[:section].instance_of?(String)
|
1550
|
+
if opt[:section] =~ /^all$/i
|
1551
|
+
title = if opt[:page_title]
|
1552
|
+
opt[:page_title]
|
1553
|
+
elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
|
1554
|
+
opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
|
1717
1555
|
else
|
1718
1556
|
'doing'
|
1719
1557
|
end
|
1720
|
-
|
1721
|
-
|
1722
|
-
|
1723
|
-
opt[:section] = @content[section]
|
1558
|
+
else
|
1559
|
+
title = guess_section(opt[:section])
|
1560
|
+
end
|
1724
1561
|
end
|
1725
|
-
end
|
1726
1562
|
|
1727
|
-
|
1563
|
+
items = filter_items([], opt: opt).reverse
|
1728
1564
|
|
1729
|
-
|
1565
|
+
items.reverse! if opt[:order] =~ /^d/i
|
1730
1566
|
|
1731
|
-
|
1732
|
-
|
1733
|
-
|
1734
|
-
|
1735
|
-
|
1736
|
-
|
1737
|
-
|
1738
|
-
|
1567
|
+
|
1568
|
+
if opt[:interactive]
|
1569
|
+
opt[:menu] = !opt[:force]
|
1570
|
+
opt[:query] = '' # opt[:search]
|
1571
|
+
opt[:multiple] = true
|
1572
|
+
selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
|
1573
|
+
|
1574
|
+
if selected.empty?
|
1575
|
+
logger.debug('Skipped:', 'No selection')
|
1576
|
+
return
|
1739
1577
|
end
|
1578
|
+
|
1579
|
+
act_on(selected, opt)
|
1580
|
+
return
|
1740
1581
|
end
|
1741
|
-
end
|
1742
1582
|
|
1743
|
-
if opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
|
1744
|
-
items.select! { |item| item.has_tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool']) }
|
1745
|
-
end
|
1746
1583
|
|
1747
|
-
|
1748
|
-
items.keep_if {|item| item.matches_search?(opt[:search]) }
|
1749
|
-
end
|
1584
|
+
opt[:output] ||= 'template'
|
1750
1585
|
|
1751
|
-
|
1752
|
-
items.delete_if do |item|
|
1753
|
-
get_interval(item, record: false) == false
|
1754
|
-
end
|
1755
|
-
end
|
1586
|
+
opt[:wrap_width] ||= @config['templates']['default']['wrap_width']
|
1756
1587
|
|
1757
|
-
|
1758
|
-
time_string = opt[:before]
|
1759
|
-
time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
|
1760
|
-
cutoff = chronify(time_string)
|
1761
|
-
if cutoff
|
1762
|
-
items.delete_if { |item| item['date'] >= cutoff }
|
1763
|
-
end
|
1588
|
+
output(items, title, is_single, opt)
|
1764
1589
|
end
|
1765
1590
|
|
1766
|
-
|
1767
|
-
|
1768
|
-
time_string += ' 11:59pm' if time_string !~ /(\d+:\d+|\d+[ap])/
|
1769
|
-
cutoff = chronify(time_string)
|
1770
|
-
if cutoff
|
1771
|
-
items.delete_if { |item| item['date'] <= cutoff }
|
1772
|
-
end
|
1773
|
-
end
|
1591
|
+
def output(items, title, is_single, opt = {})
|
1592
|
+
out = nil
|
1774
1593
|
|
1775
|
-
|
1776
|
-
items.delete_if do |item|
|
1777
|
-
item['date'] < Date.today.to_time
|
1778
|
-
end.reverse!
|
1779
|
-
section = Time.now.strftime('%A, %B %d')
|
1780
|
-
elsif opt[:yesterday]
|
1781
|
-
items.delete_if do |item|
|
1782
|
-
item['date'] <= Date.today.prev_day.to_time or
|
1783
|
-
item['date'] >= Date.today.to_time
|
1784
|
-
end.reverse!
|
1785
|
-
elsif opt[:age] =~ /oldest/i
|
1786
|
-
items = items[0..count]
|
1787
|
-
else
|
1788
|
-
items = items.reverse[0..count]
|
1789
|
-
end
|
1594
|
+
raise Errors::InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
|
1790
1595
|
|
1791
|
-
|
1596
|
+
export_options = { page_title: title, is_single: is_single, options: opt }
|
1792
1597
|
|
1793
|
-
|
1598
|
+
Plugins.plugins[:export].each do |_, options|
|
1599
|
+
next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
1794
1600
|
|
1795
|
-
|
1601
|
+
out = options[:class].render(self, items, variables: export_options)
|
1602
|
+
break
|
1603
|
+
end
|
1796
1604
|
|
1797
|
-
|
1798
|
-
|
1799
|
-
|
1800
|
-
|
1801
|
-
|
1802
|
-
|
1803
|
-
|
1804
|
-
|
1805
|
-
|
1806
|
-
|
1807
|
-
|
1808
|
-
|
1809
|
-
end
|
1810
|
-
out = output.join('')
|
1811
|
-
when /^(json|timeline)/i
|
1812
|
-
items_out = []
|
1813
|
-
max = items[-1]['date'].strftime('%F')
|
1814
|
-
min = items[0]['date'].strftime('%F')
|
1815
|
-
items.each_with_index do |i, index|
|
1816
|
-
if String.method_defined? :force_encoding
|
1817
|
-
title = i['title'].force_encoding('utf-8')
|
1818
|
-
note = i['note'].map { |line| line.force_encoding('utf-8').strip } if i['note']
|
1819
|
-
else
|
1820
|
-
title = i['title']
|
1821
|
-
note = i['note'].map { |line| line.strip } if i['note']
|
1822
|
-
end
|
1823
|
-
if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
|
1824
|
-
end_date = Time.parse(Regexp.last_match(1))
|
1825
|
-
interval = get_interval(i, formatted: false)
|
1826
|
-
end
|
1827
|
-
end_date ||= ''
|
1828
|
-
interval ||= 0
|
1829
|
-
note ||= ''
|
1830
|
-
|
1831
|
-
tags = []
|
1832
|
-
attributes = {}
|
1833
|
-
skip_tags = %w[meanwhile done cancelled flagged]
|
1834
|
-
i['title'].scan(/@([^(\s]+)(?:\((.*?)\))?/).each do |tag|
|
1835
|
-
tags.push(tag[0]) unless skip_tags.include?(tag[0])
|
1836
|
-
attributes[tag[0]] = tag[1] if tag[1]
|
1605
|
+
out
|
1606
|
+
end
|
1607
|
+
|
1608
|
+
def load_plugins
|
1609
|
+
if @config.key?('plugins') && @config['plugins']['plugin_path']
|
1610
|
+
add_dir = @config['plugins']['plugin_path']
|
1611
|
+
else
|
1612
|
+
add_dir = File.join(@user_home, '.config', 'doing', 'plugins')
|
1613
|
+
begin
|
1614
|
+
FileUtils.mkdir_p(add_dir) if add_dir
|
1615
|
+
rescue
|
1616
|
+
nil
|
1837
1617
|
end
|
1618
|
+
end
|
1838
1619
|
|
1839
|
-
|
1620
|
+
Plugins.load_plugins(add_dir)
|
1621
|
+
end
|
1840
1622
|
|
1841
|
-
|
1842
|
-
|
1843
|
-
|
1844
|
-
|
1845
|
-
|
1846
|
-
|
1847
|
-
|
1848
|
-
|
1623
|
+
##
|
1624
|
+
## @brief Move entries from a section to Archive or other specified
|
1625
|
+
## section
|
1626
|
+
##
|
1627
|
+
## @param section (String) The source section
|
1628
|
+
## @param options (Hash) Options
|
1629
|
+
##
|
1630
|
+
def archive(section = @config['current_section'], options = {})
|
1631
|
+
count = options[:keep] || 0
|
1632
|
+
destination = options[:destination] || 'Archive'
|
1633
|
+
tags = options[:tags] || []
|
1634
|
+
bool = options[:bool] || :and
|
1849
1635
|
|
1850
|
-
|
1636
|
+
section = choose_section if section.nil? || section =~ /choose/i
|
1637
|
+
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
1638
|
+
section = guess_section(section) unless archive_all
|
1851
1639
|
|
1852
|
-
|
1640
|
+
add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
|
1853
1641
|
|
1854
|
-
|
1855
|
-
new_item = {
|
1856
|
-
'id' => index + 1,
|
1857
|
-
'content' => title.strip, #+ " #{note}"
|
1858
|
-
'title' => title.strip + " (#{'%02d:%02d:%02d' % fmt_time(interval)})",
|
1859
|
-
'start' => i['date'].strftime('%F %T'),
|
1860
|
-
'type' => 'point'
|
1861
|
-
}
|
1642
|
+
destination = guess_section(destination)
|
1862
1643
|
|
1863
|
-
|
1864
|
-
|
1865
|
-
|
1866
|
-
|
1867
|
-
|
1868
|
-
end
|
1644
|
+
if sections.include?(destination) && (sections.include?(section) || archive_all)
|
1645
|
+
do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
|
1646
|
+
write(doing_file)
|
1647
|
+
else
|
1648
|
+
raise Errors::InvalidArgument, 'Either source or destination does not exist'
|
1869
1649
|
end
|
1870
|
-
|
1871
|
-
puts JSON.pretty_generate({
|
1872
|
-
'section' => section,
|
1873
|
-
'items' => items_out,
|
1874
|
-
'timers' => tag_times(format: 'json', sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order])
|
1875
|
-
})
|
1876
|
-
elsif opt[:output] == 'timeline'
|
1877
|
-
template = <<~EOTEMPLATE
|
1878
|
-
<!doctype html>
|
1879
|
-
<html>
|
1880
|
-
<head>
|
1881
|
-
<link href="https://unpkg.com/vis-timeline@7.4.9/dist/vis-timeline-graph2d.min.css" rel="stylesheet" type="text/css" />
|
1882
|
-
<script src="https://unpkg.com/vis-timeline@7.4.9/dist/vis-timeline-graph2d.min.js"></script>
|
1883
|
-
</head>
|
1884
|
-
<body>
|
1885
|
-
<div id="mytimeline"></div>
|
1886
|
-
#{' '}
|
1887
|
-
<script type="text/javascript">
|
1888
|
-
// DOM element where the Timeline will be attached
|
1889
|
-
var container = document.getElementById('mytimeline');
|
1890
|
-
#{' '}
|
1891
|
-
// Create a DataSet with data (enables two way data binding)
|
1892
|
-
var data = new vis.DataSet(#{items_out.to_json});
|
1893
|
-
#{' '}
|
1894
|
-
// Configuration for the Timeline
|
1895
|
-
var options = {
|
1896
|
-
width: '100%',
|
1897
|
-
height: '800px',
|
1898
|
-
margin: {
|
1899
|
-
item: 20
|
1900
|
-
},
|
1901
|
-
stack: true,
|
1902
|
-
min: '#{min}',
|
1903
|
-
max: '#{max}'
|
1904
|
-
};
|
1905
|
-
#{' '}
|
1906
|
-
// Create a Timeline
|
1907
|
-
var timeline = new vis.Timeline(container, data, options);
|
1908
|
-
</script>
|
1909
|
-
</body>
|
1910
|
-
</html>
|
1911
|
-
EOTEMPLATE
|
1912
|
-
return template
|
1913
|
-
end
|
1914
|
-
when /^html$/i
|
1915
|
-
page_title = section
|
1916
|
-
items_out = []
|
1917
|
-
items.each do |i|
|
1918
|
-
# if i.has_key?('note')
|
1919
|
-
# note = '<span class="note">' + i['note'].map{|n| n.strip }.join('<br>') + '</span>'
|
1920
|
-
# else
|
1921
|
-
# note = ''
|
1922
|
-
# end
|
1923
|
-
if String.method_defined? :force_encoding
|
1924
|
-
title = i['title'].force_encoding('utf-8').link_urls
|
1925
|
-
note = i['note'].map { |line| line.force_encoding('utf-8').strip.link_urls } if i['note']
|
1926
|
-
else
|
1927
|
-
title = i['title'].link_urls
|
1928
|
-
note = i['note'].map { |line| line.strip.link_urls } if i['note']
|
1929
|
-
end
|
1930
|
-
|
1931
|
-
interval = get_interval(i) if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
|
1932
|
-
interval ||= false
|
1650
|
+
end
|
1933
1651
|
|
1934
|
-
|
1935
|
-
|
1936
|
-
|
1937
|
-
|
1938
|
-
|
1939
|
-
|
1940
|
-
|
1652
|
+
##
|
1653
|
+
## @brief Helper function, performs the actual archiving
|
1654
|
+
##
|
1655
|
+
## @param section (String) The source section
|
1656
|
+
## @param destination (String) The destination section
|
1657
|
+
## @param opt (Hash) Additional Options
|
1658
|
+
##
|
1659
|
+
def do_archive(sect, destination, opt = {})
|
1660
|
+
count = opt[:count] || 0
|
1661
|
+
tags = opt[:tags] || []
|
1662
|
+
bool = opt[:bool] || :and
|
1663
|
+
label = opt[:label] || true
|
1664
|
+
|
1665
|
+
if sect =~ /^all$/i
|
1666
|
+
all_sections = sections.dup
|
1667
|
+
all_sections.delete(destination)
|
1668
|
+
else
|
1669
|
+
all_sections = [sect]
|
1941
1670
|
end
|
1942
1671
|
|
1943
|
-
|
1944
|
-
IO.read(File.expand_path(@config['html_template']['haml']))
|
1945
|
-
else
|
1946
|
-
haml_template
|
1947
|
-
end
|
1672
|
+
counter = 0
|
1948
1673
|
|
1949
|
-
|
1950
|
-
|
1951
|
-
else
|
1952
|
-
css_template
|
1953
|
-
end
|
1674
|
+
all_sections.each do |section|
|
1675
|
+
items = @content[section][:items].dup
|
1954
1676
|
|
1955
|
-
|
1956
|
-
|
1957
|
-
|
1958
|
-
|
1959
|
-
|
1960
|
-
|
1961
|
-
if opt[:highlight] && item['title'] =~ /@#{@config['marker_tag']}\b/i
|
1962
|
-
flag = colors[@config['marker_color']]
|
1963
|
-
reset = colors['default']
|
1964
|
-
else
|
1965
|
-
flag = ''
|
1966
|
-
reset = ''
|
1967
|
-
end
|
1677
|
+
moved_items = []
|
1678
|
+
if !tags.empty? || opt[:search] || opt[:before]
|
1679
|
+
if opt[:before]
|
1680
|
+
time_string = opt[:before]
|
1681
|
+
cutoff = chronify(time_string, guess: :begin)
|
1682
|
+
end
|
1968
1683
|
|
1969
|
-
|
1970
|
-
|
1971
|
-
|
1684
|
+
items.delete_if do |item|
|
1685
|
+
if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
|
1686
|
+
moved_items.push(item)
|
1687
|
+
counter += 1
|
1688
|
+
true
|
1689
|
+
else
|
1690
|
+
false
|
1691
|
+
end
|
1972
1692
|
end
|
1973
|
-
|
1974
|
-
|
1975
|
-
|
1976
|
-
|
1977
|
-
|
1693
|
+
moved_items.each do |item|
|
1694
|
+
if label
|
1695
|
+
item.title = if section == @config['current_section']
|
1696
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
1697
|
+
else
|
1698
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
1699
|
+
end
|
1700
|
+
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
1978
1701
|
end
|
1979
1702
|
end
|
1980
|
-
note = "\n#{note_lines.join("\n").chomp}"
|
1981
|
-
else
|
1982
|
-
note = ''
|
1983
|
-
end
|
1984
|
-
output = opt[:template].dup
|
1985
1703
|
|
1986
|
-
|
1987
|
-
|
1988
|
-
|
1989
|
-
|
1990
|
-
m
|
1704
|
+
@content[section][:items] = items
|
1705
|
+
@content[destination][:items].concat(moved_items)
|
1706
|
+
if moved_items.length.positive?
|
1707
|
+
logger.info('Archived:', "#{moved_items.length} items from #{section} to #{destination}")
|
1991
1708
|
end
|
1992
|
-
|
1993
|
-
|
1994
|
-
output.sub!(/%date/, item['date'].strftime(opt[:format]))
|
1995
|
-
|
1996
|
-
interval = get_interval(item, record: true) if item['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
|
1997
|
-
interval ||= ''
|
1998
|
-
output.sub!(/%interval/, interval)
|
1709
|
+
else
|
1710
|
+
count = items.length if items.length < count
|
1999
1711
|
|
2000
|
-
|
2001
|
-
|
2002
|
-
|
2003
|
-
|
2004
|
-
|
2005
|
-
|
2006
|
-
|
2007
|
-
|
2008
|
-
|
1712
|
+
items.map! do |item|
|
1713
|
+
if label
|
1714
|
+
item.title = if section == @config['current_section']
|
1715
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
1716
|
+
else
|
1717
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
1718
|
+
end
|
1719
|
+
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
1720
|
+
end
|
1721
|
+
item
|
2009
1722
|
end
|
2010
|
-
end
|
2011
1723
|
|
2012
|
-
|
2013
|
-
|
2014
|
-
flag + item['title'].gsub(/(.{1,#{opt[:wrap_width]}})(\s+|\Z)/, "\\1\n\t ").chomp + reset
|
1724
|
+
if items.count > count
|
1725
|
+
@content[destination][:items].concat(items[count..-1])
|
2015
1726
|
else
|
2016
|
-
|
1727
|
+
@content[destination][:items].concat(items)
|
2017
1728
|
end
|
2018
|
-
end
|
2019
|
-
|
2020
|
-
output.sub!(/%section/, item['section']) if item['section']
|
2021
1729
|
|
2022
|
-
|
2023
|
-
|
2024
|
-
|
2025
|
-
|
2026
|
-
|
2027
|
-
|
2028
|
-
|
2029
|
-
|
1730
|
+
@content[section][:items] = if count.zero?
|
1731
|
+
[]
|
1732
|
+
else
|
1733
|
+
items[0..count - 1]
|
1734
|
+
end
|
1735
|
+
logger.count(destination == 'Archive' ? :archived : :moved,
|
1736
|
+
count: items.length - count,
|
1737
|
+
message: "%count %items from #{section} to #{destination}")
|
1738
|
+
# logger.info('Archived:', "#{items.length - count} items from #{section} to #{destination}")
|
2030
1739
|
end
|
2031
|
-
output.sub!(/%note/, note)
|
2032
|
-
output.sub!(/%odnote/, note.gsub(/^\t*/, ''))
|
2033
|
-
output.sub!(/%chompnote/, note.gsub(/\n+/, ' ').gsub(/(^\s*|\s*$)/, '').gsub(/\s+/, ' '))
|
2034
|
-
output.gsub!(/%hr(_under)?/) do |_m|
|
2035
|
-
o = ''
|
2036
|
-
`tput cols`.to_i.times do
|
2037
|
-
o += Regexp.last_match(1).nil? ? '-' : '_'
|
2038
|
-
end
|
2039
|
-
o
|
2040
|
-
end
|
2041
|
-
output.gsub!(/%n/, "\n")
|
2042
|
-
output.gsub!(/%t/, "\t")
|
2043
|
-
|
2044
|
-
out += "#{output}\n"
|
2045
1740
|
end
|
2046
|
-
|
2047
|
-
out += tag_times(format: 'text', sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) if opt[:totals]
|
2048
1741
|
end
|
2049
|
-
out
|
2050
|
-
end
|
2051
1742
|
|
2052
|
-
|
2053
|
-
|
2054
|
-
|
2055
|
-
|
2056
|
-
|
2057
|
-
|
2058
|
-
|
2059
|
-
|
2060
|
-
|
2061
|
-
|
2062
|
-
|
2063
|
-
|
1743
|
+
##
|
1744
|
+
## @brief Show all entries from the current day
|
1745
|
+
##
|
1746
|
+
## @param times (Boolean) show times
|
1747
|
+
## @param output (String) output format
|
1748
|
+
## @param opt (Hash) Options
|
1749
|
+
##
|
1750
|
+
def today(times = true, output = nil, opt = {})
|
1751
|
+
opt[:totals] ||= false
|
1752
|
+
opt[:sort_tags] ||= false
|
1753
|
+
|
1754
|
+
cfg = @config['templates']['today']
|
1755
|
+
options = {
|
1756
|
+
after: opt[:after],
|
1757
|
+
before: opt[:before],
|
1758
|
+
count: 0,
|
1759
|
+
format: cfg['date_format'],
|
1760
|
+
order: 'asc',
|
1761
|
+
output: output,
|
1762
|
+
section: opt[:section],
|
1763
|
+
sort_tags: opt[:sort_tags],
|
1764
|
+
template: cfg['template'],
|
1765
|
+
times: times,
|
1766
|
+
today: true,
|
1767
|
+
totals: opt[:totals],
|
1768
|
+
wrap_width: cfg['wrap_width']
|
1769
|
+
}
|
1770
|
+
list_section(options)
|
1771
|
+
end
|
1772
|
+
|
1773
|
+
##
|
1774
|
+
## @brief Display entries within a date range
|
1775
|
+
##
|
1776
|
+
## @param dates (Array) [start, end]
|
1777
|
+
## @param section (String) The section
|
1778
|
+
## @param times (Bool) Show times
|
1779
|
+
## @param output (String) Output format
|
1780
|
+
## @param opt (Hash) Additional Options
|
1781
|
+
##
|
1782
|
+
def list_date(dates, section, times = nil, output = nil, opt = {})
|
1783
|
+
opt[:totals] ||= false
|
1784
|
+
opt[:sort_tags] ||= false
|
1785
|
+
section = guess_section(section)
|
1786
|
+
# :date_filter expects an array with start and end date
|
1787
|
+
dates = [dates, dates] if dates.instance_of?(String)
|
1788
|
+
|
1789
|
+
list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
|
1790
|
+
output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
|
1791
|
+
end
|
1792
|
+
|
1793
|
+
##
|
1794
|
+
## @brief Show entries from the previous day
|
1795
|
+
##
|
1796
|
+
## @param section (String) The section
|
1797
|
+
## @param times (Bool) Show times
|
1798
|
+
## @param output (String) Output format
|
1799
|
+
## @param opt (Hash) Additional Options
|
1800
|
+
##
|
1801
|
+
def yesterday(section, times = nil, output = nil, opt = {})
|
1802
|
+
opt[:totals] ||= false
|
1803
|
+
opt[:sort_tags] ||= false
|
1804
|
+
section = guess_section(section)
|
1805
|
+
y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
|
1806
|
+
opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
|
1807
|
+
opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
|
1808
|
+
|
1809
|
+
options = {
|
1810
|
+
after: opt[:after],
|
1811
|
+
before: opt[:before],
|
1812
|
+
count: 0,
|
1813
|
+
order: opt[:order],
|
1814
|
+
output: output,
|
1815
|
+
section: section,
|
1816
|
+
sort_tags: opt[:sort_tags],
|
1817
|
+
tag_order: opt[:tag_order],
|
1818
|
+
times: times,
|
1819
|
+
totals: opt[:totals],
|
1820
|
+
yesterday: true
|
1821
|
+
}
|
2064
1822
|
|
2065
|
-
|
2066
|
-
|
2067
|
-
|
1823
|
+
list_section(options)
|
1824
|
+
end
|
1825
|
+
|
1826
|
+
##
|
1827
|
+
## @brief Show recent entries
|
1828
|
+
##
|
1829
|
+
## @param count (Integer) The number to show
|
1830
|
+
## @param section (String) The section to show from, default Currently
|
1831
|
+
## @param opt (Hash) Additional Options
|
1832
|
+
##
|
1833
|
+
def recent(count = 10, section = nil, opt = {})
|
1834
|
+
times = opt[:t] || true
|
1835
|
+
opt[:totals] ||= false
|
1836
|
+
opt[:sort_tags] ||= false
|
1837
|
+
|
1838
|
+
cfg = @config['templates']['recent']
|
1839
|
+
section ||= @config['current_section']
|
1840
|
+
section = guess_section(section)
|
1841
|
+
|
1842
|
+
list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
|
1843
|
+
format: cfg['date_format'], template: cfg['template'],
|
1844
|
+
order: 'asc', times: times, totals: opt[:totals],
|
1845
|
+
sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
|
1846
|
+
end
|
1847
|
+
|
1848
|
+
##
|
1849
|
+
## @brief Show the last entry
|
1850
|
+
##
|
1851
|
+
## @param times (Bool) Show times
|
1852
|
+
## @param section (String) Section to pull from, default Currently
|
1853
|
+
##
|
1854
|
+
def last(times: true, section: nil, options: {})
|
1855
|
+
section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
|
1856
|
+
cfg = @config['templates']['last']
|
1857
|
+
|
1858
|
+
opts = {
|
1859
|
+
section: section,
|
1860
|
+
wrap_width: cfg['wrap_width'],
|
1861
|
+
count: 1,
|
1862
|
+
format: cfg['date_format'],
|
1863
|
+
template: cfg['template'],
|
1864
|
+
times: times
|
1865
|
+
}
|
2068
1866
|
|
2069
|
-
|
1867
|
+
if options[:tag]
|
1868
|
+
opts[:tag_filter] = {
|
1869
|
+
'tags' => options[:tag],
|
1870
|
+
'bool' => options[:tag_bool]
|
1871
|
+
}
|
1872
|
+
end
|
2070
1873
|
|
2071
|
-
|
1874
|
+
opts[:search] = options[:search] if options[:search]
|
2072
1875
|
|
2073
|
-
|
2074
|
-
do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
|
2075
|
-
write(doing_file)
|
2076
|
-
else
|
2077
|
-
exit_now! 'Either source or destination does not exist'
|
1876
|
+
list_section(opts)
|
2078
1877
|
end
|
2079
|
-
end
|
2080
1878
|
|
2081
|
-
|
2082
|
-
|
2083
|
-
|
2084
|
-
|
2085
|
-
|
2086
|
-
|
2087
|
-
|
2088
|
-
|
2089
|
-
|
2090
|
-
|
2091
|
-
bool = opt[:bool] || :and
|
2092
|
-
label = opt[:label] || true
|
2093
|
-
|
2094
|
-
if sect =~ /^all$/i
|
2095
|
-
all_sections = sections.dup
|
2096
|
-
all_sections.delete(destination)
|
2097
|
-
else
|
2098
|
-
all_sections = [sect]
|
2099
|
-
end
|
1879
|
+
##
|
1880
|
+
## @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
|
1881
|
+
## Does not repeat tags in a title, and only converts the first instance of an
|
1882
|
+
## untagged keyword
|
1883
|
+
##
|
1884
|
+
## @param text (String) The text to tag
|
1885
|
+
##
|
1886
|
+
def autotag(text)
|
1887
|
+
return unless text
|
1888
|
+
return text unless @auto_tag
|
2100
1889
|
|
2101
|
-
|
1890
|
+
original = text.dup
|
2102
1891
|
|
2103
|
-
|
2104
|
-
|
1892
|
+
current_tags = text.scan(/@\w+/)
|
1893
|
+
whitelisted = []
|
1894
|
+
@config['autotag']['whitelist'].each do |tag|
|
1895
|
+
next if text =~ /@#{tag}\b/i
|
2105
1896
|
|
2106
|
-
|
2107
|
-
|
2108
|
-
|
2109
|
-
|
2110
|
-
time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
|
2111
|
-
cutoff = chronify(time_string)
|
1897
|
+
text.sub!(/(?<!@)\b(#{tag.strip})\b/i) do |m|
|
1898
|
+
m.downcase! if tag =~ /[a-z]/
|
1899
|
+
whitelisted.push("@#{m}")
|
1900
|
+
"@#{m}"
|
2112
1901
|
end
|
1902
|
+
end
|
1903
|
+
tail_tags = []
|
1904
|
+
@config['autotag']['synonyms'].each do |tag, v|
|
1905
|
+
v.each do |word|
|
1906
|
+
next unless text =~ /\b#{word}\b/i
|
2113
1907
|
|
2114
|
-
|
2115
|
-
if ((!tags.empty? && item.has_tags?(tags, bool)) || (opt[:search] && item.matches_search?(opt[:search].to_s)) || (opt[:before] && item['date'] < cutoff))
|
2116
|
-
moved_items.push(item)
|
2117
|
-
counter += 1
|
2118
|
-
true
|
2119
|
-
else
|
2120
|
-
false
|
2121
|
-
end
|
1908
|
+
tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
|
2122
1909
|
end
|
2123
|
-
|
2124
|
-
|
2125
|
-
|
2126
|
-
|
2127
|
-
|
2128
|
-
|
2129
|
-
|
2130
|
-
|
2131
|
-
|
2132
|
-
|
2133
|
-
|
2134
|
-
|
2135
|
-
|
2136
|
-
|
2137
|
-
|
2138
|
-
|
2139
|
-
|
1910
|
+
end
|
1911
|
+
if @config['autotag'].key? 'transform'
|
1912
|
+
@config['autotag']['transform'].each do |tag|
|
1913
|
+
next unless tag =~ /\S+:\S+/
|
1914
|
+
|
1915
|
+
rx, r = tag.split(/:/)
|
1916
|
+
r.gsub!(/\$/, '\\')
|
1917
|
+
rx.sub!(/^@/, '')
|
1918
|
+
regex = Regexp.new('@' + rx + '\b')
|
1919
|
+
|
1920
|
+
matches = text.scan(regex)
|
1921
|
+
next unless matches
|
1922
|
+
|
1923
|
+
matches.each do |m|
|
1924
|
+
new_tag = r
|
1925
|
+
if m.is_a?(Array)
|
1926
|
+
index = 1
|
1927
|
+
m.each do |v|
|
1928
|
+
new_tag.gsub!('\\' + index.to_s, v)
|
1929
|
+
index += 1
|
1930
|
+
end
|
1931
|
+
end
|
1932
|
+
tail_tags.push(new_tag)
|
2140
1933
|
end
|
2141
|
-
item
|
2142
|
-
end
|
2143
|
-
|
2144
|
-
if items.count > count
|
2145
|
-
@content[destination]['items'].concat(items[count..-1])
|
2146
|
-
else
|
2147
|
-
@content[destination]['items'].concat(items)
|
2148
1934
|
end
|
2149
|
-
|
2150
|
-
@content[section]['items'] = if count.zero?
|
2151
|
-
[]
|
2152
|
-
else
|
2153
|
-
items[0..count - 1]
|
2154
|
-
end
|
2155
|
-
|
2156
|
-
@results.push("Archived #{items.length - count} items from #{section} to #{destination}")
|
2157
1935
|
end
|
2158
|
-
end
|
2159
|
-
end
|
2160
|
-
|
2161
|
-
##
|
2162
|
-
## @brief A dictionary of colors
|
2163
|
-
##
|
2164
|
-
## @return (String) ANSI escape sequence
|
2165
|
-
##
|
2166
|
-
def colors
|
2167
|
-
color = {}
|
2168
|
-
color['black'] = "\033[0;0;30m"
|
2169
|
-
color['red'] = "\033[0;0;31m"
|
2170
|
-
color['green'] = "\033[0;0;32m"
|
2171
|
-
color['yellow'] = "\033[0;0;33m"
|
2172
|
-
color['blue'] = "\033[0;0;34m"
|
2173
|
-
color['magenta'] = "\033[0;0;35m"
|
2174
|
-
color['cyan'] = "\033[0;0;36m"
|
2175
|
-
color['white'] = "\033[0;0;37m"
|
2176
|
-
color['bgblack'] = "\033[40m"
|
2177
|
-
color['bgred'] = "\033[41m"
|
2178
|
-
color['bggreen'] = "\033[42m"
|
2179
|
-
color['bgyellow'] = "\033[43m"
|
2180
|
-
color['bgblue'] = "\033[44m"
|
2181
|
-
color['bgmagenta'] = "\033[45m"
|
2182
|
-
color['bgcyan'] = "\033[46m"
|
2183
|
-
color['bgwhite'] = "\033[47m"
|
2184
|
-
color['boldblack'] = "\033[1;30m"
|
2185
|
-
color['boldred'] = "\033[1;31m"
|
2186
|
-
color['boldgreen'] = "\033[0;1;32m"
|
2187
|
-
color['boldyellow'] = "\033[0;1;33m"
|
2188
|
-
color['boldblue'] = "\033[0;1;34m"
|
2189
|
-
color['boldmagenta'] = "\033[0;1;35m"
|
2190
|
-
color['boldcyan'] = "\033[0;1;36m"
|
2191
|
-
color['boldwhite'] = "\033[0;1;37m"
|
2192
|
-
color['boldbgblack'] = "\033[1;40m"
|
2193
|
-
color['boldbgred'] = "\033[1;41m"
|
2194
|
-
color['boldbggreen'] = "\033[1;42m"
|
2195
|
-
color['boldbgyellow'] = "\033[1;43m"
|
2196
|
-
color['boldbgblue'] = "\033[1;44m"
|
2197
|
-
color['boldbgmagenta'] = "\033[1;45m"
|
2198
|
-
color['boldbgcyan'] = "\033[1;46m"
|
2199
|
-
color['boldbgwhite'] = "\033[1;47m"
|
2200
|
-
color['softpurple'] = "\033[0;35;40m"
|
2201
|
-
color['hotpants'] = "\033[7;34;40m"
|
2202
|
-
color['knightrider'] = "\033[7;30;40m"
|
2203
|
-
color['flamingo'] = "\033[7;31;47m"
|
2204
|
-
color['yeller'] = "\033[1;37;43m"
|
2205
|
-
color['whiteboard'] = "\033[1;30;47m"
|
2206
|
-
color['default'] = "\033[0;39m"
|
2207
|
-
color
|
2208
|
-
end
|
2209
1936
|
|
2210
|
-
|
2211
|
-
|
2212
|
-
|
2213
|
-
|
2214
|
-
|
2215
|
-
|
2216
|
-
|
2217
|
-
|
2218
|
-
|
2219
|
-
opt[:sort_tags] ||= false
|
2220
|
-
|
2221
|
-
cfg = @config['templates']['today']
|
2222
|
-
options = {
|
2223
|
-
after: opt[:after],
|
2224
|
-
before: opt[:before],
|
2225
|
-
count: 0,
|
2226
|
-
format: cfg['date_format'],
|
2227
|
-
order: 'asc',
|
2228
|
-
output: output,
|
2229
|
-
section: opt[:section],
|
2230
|
-
sort_tags: opt[:sort_tags],
|
2231
|
-
template: cfg['template'],
|
2232
|
-
times: times,
|
2233
|
-
today: true,
|
2234
|
-
totals: opt[:totals],
|
2235
|
-
wrap_width: cfg['wrap_width']
|
2236
|
-
}
|
2237
|
-
list_section(options)
|
2238
|
-
end
|
1937
|
+
logger.debug('Autotag:', "Whitelisted tags: #{whitelisted.join(', ')}") unless whitelisted.empty?
|
1938
|
+
new_tags = whitelisted
|
1939
|
+
unless tail_tags.empty?
|
1940
|
+
tags = tail_tags.uniq.map { |t| "@#{t}".cyan }.join(' ')
|
1941
|
+
logger.debug('Autotag:', "Synonym tags: #{tags}")
|
1942
|
+
tags_a = tail_tags.map { |t| "@#{t}" }
|
1943
|
+
text.add_tags!(tags_a.join(' '))
|
1944
|
+
new_tags.concat(tags_a)
|
1945
|
+
end
|
2239
1946
|
|
2240
|
-
|
2241
|
-
|
2242
|
-
|
2243
|
-
|
2244
|
-
|
2245
|
-
## @param times (Bool) Show times
|
2246
|
-
## @param output (String) Output format
|
2247
|
-
## @param opt (Hash) Additional Options
|
2248
|
-
##
|
2249
|
-
def list_date(dates, section, times = nil, output = nil, opt = {})
|
2250
|
-
opt[:totals] ||= false
|
2251
|
-
opt[:sort_tags] ||= false
|
2252
|
-
section = guess_section(section)
|
2253
|
-
# :date_filter expects an array with start and end date
|
2254
|
-
dates = [dates, dates] if dates.instance_of?(String)
|
2255
|
-
|
2256
|
-
list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
|
2257
|
-
output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
|
2258
|
-
end
|
1947
|
+
unless text == original
|
1948
|
+
logger.info('Autotag:', "added #{new_tags.join(', ')} to \"#{text}\"")
|
1949
|
+
else
|
1950
|
+
logger.debug('Autotag:', "no change to \"#{text}\"")
|
1951
|
+
end
|
2259
1952
|
|
2260
|
-
|
2261
|
-
|
2262
|
-
##
|
2263
|
-
## @param section (String) The section
|
2264
|
-
## @param times (Bool) Show times
|
2265
|
-
## @param output (String) Output format
|
2266
|
-
## @param opt (Hash) Additional Options
|
2267
|
-
##
|
2268
|
-
def yesterday(section, times = nil, output = nil, opt = {})
|
2269
|
-
opt[:totals] ||= false
|
2270
|
-
opt[:sort_tags] ||= false
|
2271
|
-
section = guess_section(section)
|
2272
|
-
y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
|
2273
|
-
opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
|
2274
|
-
opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
|
2275
|
-
|
2276
|
-
options = {
|
2277
|
-
after: opt[:after],
|
2278
|
-
before: opt[:before],
|
2279
|
-
count: 0,
|
2280
|
-
order: 'asc',
|
2281
|
-
output: output,
|
2282
|
-
section: section,
|
2283
|
-
sort_tags: opt[:sort_tags],
|
2284
|
-
tag_order: opt[:tag_order],
|
2285
|
-
times: times,
|
2286
|
-
totals: opt[:totals],
|
2287
|
-
yesterday: true
|
2288
|
-
}
|
2289
|
-
|
2290
|
-
list_section(options)
|
2291
|
-
end
|
1953
|
+
text
|
1954
|
+
end
|
2292
1955
|
|
2293
|
-
|
2294
|
-
|
2295
|
-
|
2296
|
-
|
2297
|
-
|
2298
|
-
|
2299
|
-
|
2300
|
-
|
2301
|
-
|
2302
|
-
|
2303
|
-
|
2304
|
-
|
2305
|
-
cfg = @config['templates']['recent']
|
2306
|
-
section ||= @current_section
|
2307
|
-
section = guess_section(section)
|
2308
|
-
|
2309
|
-
list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
|
2310
|
-
format: cfg['date_format'], template: cfg['template'],
|
2311
|
-
order: 'asc', times: times, totals: opt[:totals],
|
2312
|
-
sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
|
2313
|
-
end
|
1956
|
+
##
|
1957
|
+
## @brief Get total elapsed time for all tags in
|
1958
|
+
## selection
|
1959
|
+
##
|
1960
|
+
## @param format (String) return format (html,
|
1961
|
+
## json, or text)
|
1962
|
+
## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
|
1963
|
+
## @param sort_order (String) The sort order (asc or desc)
|
1964
|
+
##
|
1965
|
+
def tag_times(format: :text, sort_by_name: false, sort_order: 'asc')
|
1966
|
+
return '' if @timers.empty?
|
2314
1967
|
|
2315
|
-
|
2316
|
-
## @brief Show the last entry
|
2317
|
-
##
|
2318
|
-
## @param times (Bool) Show times
|
2319
|
-
## @param section (String) Section to pull from, default Currently
|
2320
|
-
##
|
2321
|
-
def last(times: true, section: nil, options: {})
|
2322
|
-
section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
|
2323
|
-
cfg = @config['templates']['last']
|
2324
|
-
|
2325
|
-
opts = {
|
2326
|
-
section: section,
|
2327
|
-
wrap_width: cfg['wrap_width'],
|
2328
|
-
count: 1,
|
2329
|
-
format: cfg['date_format'],
|
2330
|
-
template: cfg['template'],
|
2331
|
-
times: times
|
2332
|
-
}
|
2333
|
-
|
2334
|
-
if options[:tag]
|
2335
|
-
opts[:tag_filter] = {
|
2336
|
-
'tags' => options[:tag],
|
2337
|
-
'bool' => options[:tag_bool]
|
2338
|
-
}
|
2339
|
-
end
|
1968
|
+
max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1
|
2340
1969
|
|
2341
|
-
|
1970
|
+
total = @timers.delete('All')
|
2342
1971
|
|
2343
|
-
|
2344
|
-
|
1972
|
+
tags_data = @timers.delete_if { |_k, v| v == 0 }
|
1973
|
+
sorted_tags_data = if sort_by_name
|
1974
|
+
tags_data.sort_by { |k, _v| k }
|
1975
|
+
else
|
1976
|
+
tags_data.sort_by { |_k, v| v }
|
1977
|
+
end
|
2345
1978
|
|
2346
|
-
|
2347
|
-
|
2348
|
-
|
2349
|
-
|
2350
|
-
|
2351
|
-
|
2352
|
-
|
2353
|
-
|
2354
|
-
|
2355
|
-
|
2356
|
-
|
2357
|
-
|
2358
|
-
|
2359
|
-
|
2360
|
-
|
2361
|
-
|
2362
|
-
|
2363
|
-
|
2364
|
-
|
2365
|
-
|
2366
|
-
|
2367
|
-
|
2368
|
-
|
2369
|
-
|
2370
|
-
|
2371
|
-
if format == 'html'
|
2372
|
-
output = <<EOS
|
2373
|
-
<table>
|
2374
|
-
<caption id="tagtotals">Tag Totals</caption>
|
2375
|
-
<colgroup>
|
2376
|
-
<col style="text-align:left;"/>
|
2377
|
-
<col style="text-align:left;"/>
|
2378
|
-
</colgroup>
|
2379
|
-
<thead>
|
1979
|
+
sorted_tags_data.reverse! if sort_order =~ /^asc/i
|
1980
|
+
case format
|
1981
|
+
when :html
|
1982
|
+
|
1983
|
+
output = <<EOS
|
1984
|
+
<table>
|
1985
|
+
<caption id="tagtotals">Tag Totals</caption>
|
1986
|
+
<colgroup>
|
1987
|
+
<col style="text-align:left;"/>
|
1988
|
+
<col style="text-align:left;"/>
|
1989
|
+
</colgroup>
|
1990
|
+
<thead>
|
1991
|
+
<tr>
|
1992
|
+
<th style="text-align:left;">project</th>
|
1993
|
+
<th style="text-align:left;">time</th>
|
1994
|
+
</tr>
|
1995
|
+
</thead>
|
1996
|
+
<tbody>
|
1997
|
+
EOS
|
1998
|
+
sorted_tags_data.reverse.each do |k, v|
|
1999
|
+
if v > 0
|
2000
|
+
output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % fmt_time(v)}</td></tr>\n"
|
2001
|
+
end
|
2002
|
+
end
|
2003
|
+
tail = <<EOS
|
2380
2004
|
<tr>
|
2381
|
-
<
|
2382
|
-
<th style="text-align:left;">time</th>
|
2005
|
+
<td style="text-align:left;" colspan="2"></td>
|
2383
2006
|
</tr>
|
2384
|
-
</
|
2385
|
-
<
|
2007
|
+
</tbody>
|
2008
|
+
<tfoot>
|
2009
|
+
<tr>
|
2010
|
+
<td style="text-align:left;"><strong>Total</strong></td>
|
2011
|
+
<td style="text-align:left;">#{'%02d:%02d:%02d' % fmt_time(total)}</td>
|
2012
|
+
</tr>
|
2013
|
+
</tfoot>
|
2014
|
+
</table>
|
2386
2015
|
EOS
|
2387
|
-
|
2388
|
-
|
2389
|
-
|
2016
|
+
output + tail
|
2017
|
+
when :markdown
|
2018
|
+
pad = sorted_tags_data.map {|k, v| k }.group_by(&:size).max.last[0].length
|
2019
|
+
output = <<~EOS
|
2020
|
+
| #{' ' * (pad - 7) }project | time |
|
2021
|
+
| #{'-' * (pad - 1)}: | :------- |
|
2022
|
+
EOS
|
2023
|
+
sorted_tags_data.reverse.each do |k, v|
|
2024
|
+
if v > 0
|
2025
|
+
output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % fmt_time(v)} |\n"
|
2026
|
+
end
|
2390
2027
|
end
|
2391
|
-
|
2392
|
-
|
2393
|
-
|
2394
|
-
|
2395
|
-
|
2396
|
-
|
2397
|
-
|
2398
|
-
|
2399
|
-
|
2400
|
-
|
2401
|
-
|
2402
|
-
|
2403
|
-
|
2404
|
-
|
2405
|
-
|
2406
|
-
|
2407
|
-
|
2408
|
-
|
2409
|
-
|
2410
|
-
|
2411
|
-
|
2412
|
-
'
|
2413
|
-
}
|
2414
|
-
end
|
2415
|
-
output
|
2416
|
-
else
|
2417
|
-
output = []
|
2418
|
-
sorted_tags_data.reverse.each do |k, v|
|
2419
|
-
spacer = ''
|
2420
|
-
(max - k.length).times do
|
2421
|
-
spacer += ' '
|
2028
|
+
tail = "[Tag Totals]"
|
2029
|
+
output + tail
|
2030
|
+
when :json
|
2031
|
+
output = []
|
2032
|
+
sorted_tags_data.reverse.each do |k, v|
|
2033
|
+
d, h, m = fmt_time(v)
|
2034
|
+
output << {
|
2035
|
+
'tag' => k,
|
2036
|
+
'seconds' => v,
|
2037
|
+
'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
|
2038
|
+
}
|
2039
|
+
end
|
2040
|
+
output
|
2041
|
+
else
|
2042
|
+
output = []
|
2043
|
+
sorted_tags_data.reverse.each do |k, v|
|
2044
|
+
spacer = ''
|
2045
|
+
(max - k.length).times do
|
2046
|
+
spacer += ' '
|
2047
|
+
end
|
2048
|
+
d, h, m = fmt_time(v)
|
2049
|
+
output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
|
2422
2050
|
end
|
2423
|
-
output.push("#{k}:#{spacer}#{'%02d:%02d:%02d' % fmt_time(v)}")
|
2424
|
-
end
|
2425
|
-
|
2426
|
-
output = output.empty? ? '' : "\n--- Tag Totals ---\n" + output.join("\n")
|
2427
|
-
output += "\n\nTotal tracked: #{'%02d:%02d:%02d' % fmt_time(total)}\n"
|
2428
|
-
output
|
2429
|
-
end
|
2430
|
-
end
|
2431
2051
|
|
2432
|
-
|
2433
|
-
|
2434
|
-
|
2435
|
-
|
2436
|
-
# @param text (String) The text to tag
|
2437
|
-
#
|
2438
|
-
def autotag(text)
|
2439
|
-
return unless text
|
2440
|
-
return text unless @auto_tag
|
2441
|
-
|
2442
|
-
current_tags = text.scan(/@\w+/)
|
2443
|
-
whitelisted = []
|
2444
|
-
@config['autotag']['whitelist'].each do |tag|
|
2445
|
-
next if text =~ /@#{tag}\b/i
|
2446
|
-
|
2447
|
-
text.sub!(/(?<!@)(#{tag.strip})\b/i) do |m|
|
2448
|
-
m.downcase! if tag =~ /[a-z]/
|
2449
|
-
whitelisted.push("@#{m}")
|
2450
|
-
"@#{m}"
|
2052
|
+
output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
|
2053
|
+
d, h, m = fmt_time(total)
|
2054
|
+
output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
|
2055
|
+
output
|
2451
2056
|
end
|
2452
2057
|
end
|
2453
|
-
tail_tags = []
|
2454
|
-
@config['autotag']['synonyms'].each do |tag, v|
|
2455
|
-
v.each do |word|
|
2456
|
-
next unless text =~ /\b#{word}\b/i
|
2457
2058
|
|
2458
|
-
|
2059
|
+
##
|
2060
|
+
## @brief Gets the interval between entry's start
|
2061
|
+
## date and @done date
|
2062
|
+
##
|
2063
|
+
## @param item (Hash) The entry
|
2064
|
+
## @param formatted (Bool) Return human readable
|
2065
|
+
## time (default seconds)
|
2066
|
+
## @param record (Bool) Add the interval to the
|
2067
|
+
## total for each tag
|
2068
|
+
##
|
2069
|
+
## @return Interval in seconds, or [d, h, m] array if
|
2070
|
+
## formatted is true. False if no end date or
|
2071
|
+
## interval is 0
|
2072
|
+
##
|
2073
|
+
def get_interval(item, formatted: true, record: true)
|
2074
|
+
if item.interval
|
2075
|
+
seconds = item.interval
|
2076
|
+
record_tag_times(item, seconds) if record
|
2077
|
+
return seconds.positive? ? seconds : false unless formatted
|
2078
|
+
|
2079
|
+
return seconds.positive? ? format('%02d:%02d:%02d', *fmt_time(seconds)) : false
|
2459
2080
|
end
|
2460
|
-
|
2461
|
-
|
2462
|
-
|
2463
|
-
|
2464
|
-
|
2465
|
-
|
2466
|
-
|
2467
|
-
|
2468
|
-
|
2469
|
-
|
2470
|
-
|
2471
|
-
|
2472
|
-
|
2473
|
-
|
2474
|
-
|
2475
|
-
|
2476
|
-
|
2477
|
-
|
2478
|
-
new_tag = new_tag.gsub('\\' + index.to_s, v)
|
2479
|
-
index += 1
|
2480
|
-
end
|
2481
|
-
end
|
2482
|
-
tail_tags.push(new_tag)
|
2081
|
+
|
2082
|
+
false
|
2083
|
+
end
|
2084
|
+
|
2085
|
+
##
|
2086
|
+
## @brief Record times for item tags
|
2087
|
+
##
|
2088
|
+
## @param item The item
|
2089
|
+
##
|
2090
|
+
def record_tag_times(item, seconds)
|
2091
|
+
item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
|
2092
|
+
return if @recorded_items.include?(item_hash)
|
2093
|
+
item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
|
2094
|
+
k = m[0] == 'done' ? 'All' : m[0].downcase
|
2095
|
+
if @timers.key?(k)
|
2096
|
+
@timers[k] += seconds
|
2097
|
+
else
|
2098
|
+
@timers[k] = seconds
|
2483
2099
|
end
|
2100
|
+
@recorded_items.push(item_hash)
|
2484
2101
|
end
|
2485
2102
|
end
|
2486
|
-
@results.push("Whitelisted tags: #{whitelisted.join(', ')}") if whitelisted.length > 0
|
2487
|
-
if tail_tags.length > 0
|
2488
|
-
tags = tail_tags.uniq.map { |t| '@' + t }.join(' ')
|
2489
|
-
@results.push("Synonym tags: #{tags}")
|
2490
|
-
text + ' ' + tags
|
2491
|
-
else
|
2492
|
-
text
|
2493
|
-
end
|
2494
|
-
end
|
2495
2103
|
|
2496
|
-
|
2104
|
+
##
|
2105
|
+
## @brief Format human readable time from seconds
|
2106
|
+
##
|
2107
|
+
## @param seconds The seconds
|
2108
|
+
##
|
2109
|
+
def fmt_time(seconds)
|
2110
|
+
return [0, 0, 0] if seconds.nil?
|
2497
2111
|
|
2498
|
-
|
2499
|
-
|
2500
|
-
|
2501
|
-
|
2502
|
-
|
2503
|
-
|
2504
|
-
|
2505
|
-
|
2506
|
-
|
2507
|
-
|
2508
|
-
|
2509
|
-
|
2510
|
-
record_tag_times(item, seconds) if record
|
2511
|
-
return seconds > 0 ? '%02d:%02d:%02d' % fmt_time(seconds) : false
|
2112
|
+
if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
|
2113
|
+
h = Regexp.last_match(1)
|
2114
|
+
m = Regexp.last_match(2)
|
2115
|
+
s = Regexp.last_match(3)
|
2116
|
+
seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
|
2117
|
+
end
|
2118
|
+
minutes = (seconds / 60).to_i
|
2119
|
+
hours = (minutes / 60).to_i
|
2120
|
+
days = (hours / 24).to_i
|
2121
|
+
hours = (hours % 24).to_i
|
2122
|
+
minutes = (minutes % 60).to_i
|
2123
|
+
[days, hours, minutes]
|
2512
2124
|
end
|
2513
2125
|
|
2514
|
-
|
2515
|
-
done = Time.parse(Regexp.last_match(1))
|
2516
|
-
else
|
2517
|
-
return false
|
2518
|
-
end
|
2126
|
+
private
|
2519
2127
|
|
2520
|
-
|
2521
|
-
|
2522
|
-
else
|
2523
|
-
item['date']
|
2524
|
-
end
|
2128
|
+
def run_after
|
2129
|
+
return unless @config.key?('run_after')
|
2525
2130
|
|
2526
|
-
|
2131
|
+
_, stderr, status = Open3.capture3(@config['run_after'])
|
2132
|
+
return unless status.exitstatus.positive?
|
2527
2133
|
|
2528
|
-
|
2529
|
-
|
2134
|
+
logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
|
2135
|
+
logger.log_now(:error, 'STDERR output:', stderr)
|
2530
2136
|
end
|
2531
2137
|
|
2532
|
-
|
2533
|
-
|
2534
|
-
|
2535
|
-
|
2536
|
-
seconds > 0 ? '%02d:%02d:%02d' % fmt_time(seconds) : false
|
2537
|
-
end
|
2538
|
-
|
2539
|
-
##
|
2540
|
-
## @brief Record times for item tags
|
2541
|
-
##
|
2542
|
-
## @param item The item
|
2543
|
-
##
|
2544
|
-
def record_tag_times(item, seconds)
|
2545
|
-
return if @recorded_items.include?(item)
|
2546
|
-
|
2547
|
-
item['title'].scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
|
2548
|
-
k = m[0] == 'done' ? 'All' : m[0].downcase
|
2549
|
-
if @timers.key?(k)
|
2550
|
-
@timers[k] += seconds
|
2138
|
+
def log_change(tags_added: [], tags_removed: [], count: 1)
|
2139
|
+
if tags_added.empty? && tags_removed.empty?
|
2140
|
+
logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
|
2551
2141
|
else
|
2552
|
-
@timers[k] = seconds
|
2553
|
-
end
|
2554
|
-
@recorded_items.push(item)
|
2555
|
-
end
|
2556
|
-
end
|
2557
2142
|
|
2558
|
-
|
2559
|
-
|
2560
|
-
|
2561
|
-
|
2562
|
-
|
2563
|
-
|
2564
|
-
|
2565
|
-
|
2566
|
-
if seconds =~ /(\d+):(\d+):(\d+)/
|
2567
|
-
h = Regexp.last_match(1)
|
2568
|
-
m = Regexp.last_match(2)
|
2569
|
-
s = Regexp.last_match(3)
|
2570
|
-
seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
|
2571
|
-
end
|
2572
|
-
minutes = (seconds / 60).to_i
|
2573
|
-
hours = (minutes / 60).to_i
|
2574
|
-
days = (hours / 24).to_i
|
2575
|
-
hours = (hours % 24).to_i
|
2576
|
-
minutes = (minutes % 60).to_i
|
2577
|
-
[days, hours, minutes]
|
2578
|
-
end
|
2143
|
+
if tags_added.empty?
|
2144
|
+
logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
|
2145
|
+
# logger.debug('No tags added:', %("#{item.title}" in #{item.section}))
|
2146
|
+
else
|
2147
|
+
logger.count(:added_tags, tag: tags_added, message: '%tags added to %count %items')
|
2148
|
+
# logger.info('Added tags:', %(#{did_add} to "#{item.title}" in #{item.section}))
|
2149
|
+
end
|
2579
2150
|
|
2580
|
-
|
2581
|
-
|
2582
|
-
|
2583
|
-
|
2584
|
-
|
2151
|
+
if tags_removed.empty?
|
2152
|
+
logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
|
2153
|
+
else
|
2154
|
+
logger.count(:removed_tags, tag: tags_removed, message: '%tags removed from %count %items')
|
2155
|
+
end
|
2156
|
+
end
|
2585
2157
|
end
|
2586
2158
|
end
|
2587
2159
|
end
|