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