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