doing 1.0.92 → 2.0.5.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/AUTHORS +19 -0
- data/CHANGELOG.md +596 -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 +312 -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 +347 -0
- data/lib/doing/symbol.rb +16 -0
- data/lib/doing/time.rb +18 -0
- data/lib/doing/util.rb +186 -0
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +1868 -2356
- data/lib/doing/wwidfile.rb +117 -0
- data/lib/doing.rb +44 -4
- data/lib/examples/commands/wiki.rb +81 -0
- data/lib/examples/plugins/hooks.rb +22 -0
- data/lib/examples/plugins/say_export.rb +202 -0
- data/lib/examples/plugins/templates/wiki.css +169 -0
- data/lib/examples/plugins/templates/wiki.haml +27 -0
- data/lib/examples/plugins/templates/wiki_index.haml +18 -0
- data/lib/examples/plugins/wiki_export.rb +87 -0
- data/lib/templates/doing-markdown.erb +5 -0
- data/man/doing.1 +964 -0
- data/man/doing.1.html +711 -0
- data/man/doing.1.ronn +600 -0
- data/package-lock.json +3 -0
- data/rdoc_to_mmd.rb +42 -0
- data/rdocfixer.rb +13 -0
- data/scripts/generate_bash_completions.rb +210 -0
- data/scripts/generate_fish_completions.rb +201 -0
- data/scripts/generate_zsh_completions.rb +164 -0
- metadata +82 -7
- data/lib/doing/helpers.rb +0 -191
- data/lib/doing/markdown_export.rb +0 -16
data/lib/doing/wwid.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
#!/usr/bin/ruby
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
4
|
require 'deep_merge'
|
4
5
|
require 'open3'
|
@@ -6,2681 +7,2192 @@ 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
|
-
|
1328
|
-
|
1329
|
-
|
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
|
1019
|
+
|
1020
|
+
@content = { 'Export' => { :original => 'Export:', :items => items } }
|
1021
|
+
options = { section: 'Export' }
|
1334
1022
|
|
1335
|
-
|
1336
|
-
|
1337
|
-
|
1338
|
-
|
1339
|
-
if date
|
1340
|
-
title += " @#{tag}(#{done_date.strftime('%F %R')})"
|
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
|
-
|
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
1031
|
|
1364
|
-
|
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
|
-
|
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!('done', remove: true) if tag =~ /done/ && !should_date
|
1182
|
+
item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
|
1183
|
+
added << tag if old_title != item.title
|
1184
|
+
end
|
1185
|
+
end
|
1186
|
+
end
|
1485
1187
|
|
1486
|
-
|
1188
|
+
log_change(tags_added: added, tags_removed: removed)
|
1487
1189
|
|
1488
|
-
|
1190
|
+
item.note.add(opt[:note]) if opt[:note]
|
1489
1191
|
|
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']}"))
|
1192
|
+
if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
|
1193
|
+
move_item(item, 'Archive', label: true)
|
1194
|
+
elsif opt[:archive] && opt[:count].zero?
|
1195
|
+
logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
|
1196
|
+
end
|
1530
1197
|
end
|
1198
|
+
|
1199
|
+
write(@doing_file)
|
1531
1200
|
end
|
1532
1201
|
|
1533
|
-
|
1202
|
+
##
|
1203
|
+
## @brief Move item from current section to
|
1204
|
+
## destination section
|
1205
|
+
##
|
1206
|
+
## @param item The item
|
1207
|
+
## @param section The destination section
|
1208
|
+
##
|
1209
|
+
## @return Updated item
|
1210
|
+
##
|
1211
|
+
def move_item(item, section, label: true)
|
1212
|
+
from = item.section
|
1213
|
+
new_item = @content[item.section][:items].delete(item)
|
1214
|
+
new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
|
1215
|
+
new_item.section = section
|
1216
|
+
|
1217
|
+
@content[section][:items].concat([new_item])
|
1218
|
+
|
1219
|
+
logger.count(section == 'Archive' ? :archived : :moved)
|
1220
|
+
logger.debug("Entry #{section == 'Archive' ? 'archived' : 'moved'}:",
|
1221
|
+
"#{new_item.title.truncate(60)} from #{from} to #{section}")
|
1222
|
+
new_item
|
1223
|
+
end
|
1224
|
+
|
1225
|
+
##
|
1226
|
+
## @brief Get next item in the index
|
1227
|
+
##
|
1228
|
+
## @param item
|
1229
|
+
##
|
1230
|
+
def next_item(item, options = {})
|
1231
|
+
items = filter_items([], opt: options)
|
1232
|
+
|
1233
|
+
idx = items.index(item)
|
1234
|
+
|
1235
|
+
idx.positive? ? items[idx - 1] : nil
|
1236
|
+
end
|
1237
|
+
|
1238
|
+
##
|
1239
|
+
## @brief Delete an item from the index
|
1240
|
+
##
|
1241
|
+
## @param item The item
|
1242
|
+
##
|
1243
|
+
def delete_item(item)
|
1244
|
+
section = item.section
|
1245
|
+
|
1246
|
+
section_items = @content[section][:items]
|
1247
|
+
deleted = section_items.delete(item)
|
1248
|
+
logger.count(:deleted)
|
1249
|
+
logger.debug('Entry deleted:', deleted.title)
|
1250
|
+
end
|
1251
|
+
|
1252
|
+
##
|
1253
|
+
## @brief Update an item in the index with a modified item
|
1254
|
+
##
|
1255
|
+
## @param old_item The old item
|
1256
|
+
## @param new_item The new item
|
1257
|
+
##
|
1258
|
+
def update_item(old_item, new_item)
|
1259
|
+
section = old_item.section
|
1260
|
+
|
1261
|
+
section_items = @content[section][:items]
|
1262
|
+
s_idx = section_items.index { |item| item.equal?(old_item) }
|
1263
|
+
|
1264
|
+
unless s_idx
|
1265
|
+
Doing.logger.error('Fail to update:', 'Could not find item in index')
|
1266
|
+
raise Errors::ItemNotFound, 'Unable to find item in index, did it mutate?'
|
1267
|
+
end
|
1268
|
+
|
1269
|
+
return if section_items[s_idx].equal?(new_item)
|
1534
1270
|
|
1535
|
-
|
1536
|
-
|
1537
|
-
|
1538
|
-
|
1539
|
-
add_item(title.cap_first, opt[:section], { note: note.join(' ').rstrip, back: opt[:back] })
|
1271
|
+
section_items[s_idx] = new_item
|
1272
|
+
logger.count(:updated)
|
1273
|
+
logger.debug('Entry updated:', section_items[s_idx].title.truncate(60))
|
1274
|
+
new_item
|
1540
1275
|
end
|
1541
1276
|
|
1542
|
-
|
1543
|
-
|
1277
|
+
##
|
1278
|
+
## @brief Edit the last entry
|
1279
|
+
##
|
1280
|
+
## @param section (String) The section, default "All"
|
1281
|
+
##
|
1282
|
+
def edit_last(section: 'All', options: {})
|
1283
|
+
options[:section] = guess_section(section)
|
1544
1284
|
|
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" : ''
|
1285
|
+
item = last_entry(options)
|
1552
1286
|
|
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}~")
|
1287
|
+
if item.nil?
|
1288
|
+
logger.debug('Skipped:', 'No entries found')
|
1289
|
+
return
|
1565
1290
|
end
|
1566
1291
|
|
1567
|
-
|
1568
|
-
|
1569
|
-
|
1292
|
+
content = [item.title.dup]
|
1293
|
+
content << item.note.to_s unless item.note.empty?
|
1294
|
+
new_item = fork_editor(content.join("\n"))
|
1295
|
+
title, note = format_input(new_item)
|
1570
1296
|
|
1571
|
-
if
|
1572
|
-
|
1573
|
-
|
1574
|
-
|
1575
|
-
|
1576
|
-
|
1297
|
+
if title.nil? || title.empty?
|
1298
|
+
logger.debug('Skipped:', 'No content provided')
|
1299
|
+
elsif title == item.title && note.equal?(item.note)
|
1300
|
+
logger.debug('Skipped:', 'No change in content')
|
1301
|
+
else
|
1302
|
+
item.title = title
|
1303
|
+
item.note.add(note, replace: true)
|
1304
|
+
logger.info('Edited:', item.title)
|
1305
|
+
|
1306
|
+
write(@doing_file)
|
1577
1307
|
end
|
1578
1308
|
end
|
1579
|
-
end
|
1580
1309
|
|
1581
|
-
|
1582
|
-
|
1583
|
-
|
1584
|
-
|
1585
|
-
|
1586
|
-
|
1587
|
-
|
1588
|
-
|
1589
|
-
|
1590
|
-
|
1591
|
-
|
1592
|
-
|
1310
|
+
##
|
1311
|
+
## @brief Accepts one tag and the raw text of a new item if the passed tag
|
1312
|
+
## is on any item, it's replaced with @done. if new_item is not
|
1313
|
+
## nil, it's tagged with the passed tag and inserted. This is for
|
1314
|
+
## use where only one instance of a given tag should exist
|
1315
|
+
## (@meanwhile)
|
1316
|
+
##
|
1317
|
+
## @param tag (String) Tag to replace
|
1318
|
+
## @param opt (Hash) Additional Options
|
1319
|
+
##
|
1320
|
+
def stop_start(target_tag, opt = {})
|
1321
|
+
tag = target_tag.dup
|
1322
|
+
opt[:section] ||= @config['current_section']
|
1323
|
+
opt[:archive] ||= false
|
1324
|
+
opt[:back] ||= Time.now
|
1325
|
+
opt[:new_item] ||= false
|
1326
|
+
opt[:note] ||= false
|
1593
1327
|
|
1594
|
-
|
1595
|
-
## @brief Rename doing file with date and start fresh one
|
1596
|
-
##
|
1597
|
-
def rotate(opt = {})
|
1598
|
-
count = opt[:keep] || 0
|
1599
|
-
tags = []
|
1600
|
-
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
1601
|
-
bool = opt[:bool] || :and
|
1602
|
-
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
1603
|
-
|
1604
|
-
if sect =~ /^all$/i
|
1605
|
-
all_sections = sections.dup
|
1606
|
-
else
|
1607
|
-
all_sections = [sect]
|
1608
|
-
end
|
1328
|
+
opt[:section] = guess_section(opt[:section])
|
1609
1329
|
|
1610
|
-
|
1611
|
-
new_content = {}
|
1330
|
+
tag.sub!(/^@/, '')
|
1612
1331
|
|
1332
|
+
found_items = 0
|
1613
1333
|
|
1614
|
-
|
1615
|
-
|
1616
|
-
new_content[section] = {}
|
1617
|
-
new_content[section]['original'] = @content[section]['original']
|
1618
|
-
new_content[section]['items'] = []
|
1334
|
+
@content[opt[:section]][:items].each_with_index do |item, i|
|
1335
|
+
next unless item.title =~ /@#{tag}/
|
1619
1336
|
|
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
|
1627
|
-
|
1628
|
-
items.delete_if do |item|
|
1629
|
-
if ((!tags.empty? && item.has_tags?(tags, bool)) || (opt[:search] && item.matches_search?(opt[:search].to_s)) || (opt[:before] && item['date'] < cutoff))
|
1630
|
-
moved_items.push(item)
|
1631
|
-
counter += 1
|
1632
|
-
true
|
1633
|
-
else
|
1634
|
-
false
|
1635
|
-
end
|
1636
|
-
end
|
1637
|
-
@content[section]['items'] = items
|
1638
|
-
new_content[section]['items'] = moved_items
|
1639
|
-
@results.push("Rotated #{moved_items.length} items from #{section}")
|
1640
|
-
else
|
1641
|
-
new_content[section]['items'] = []
|
1642
|
-
moved_items = []
|
1337
|
+
item.title.add_tags!([tag, 'done'], remove: true)
|
1338
|
+
item.tag('done', value: opt[:back].strftime('%F %R'))
|
1643
1339
|
|
1644
|
-
|
1340
|
+
found_items += 1
|
1645
1341
|
|
1646
|
-
if
|
1647
|
-
|
1342
|
+
if opt[:archive] && opt[:section] != 'Archive'
|
1343
|
+
item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
|
1344
|
+
move_item(item, 'Archive', label: false)
|
1345
|
+
logger.count(:completed_archived)
|
1346
|
+
logger.debug('Completed/archived:', item.title)
|
1648
1347
|
else
|
1649
|
-
|
1348
|
+
logger.count(:completed)
|
1349
|
+
logger.debug('Completed:', item.title)
|
1650
1350
|
end
|
1351
|
+
end
|
1651
1352
|
|
1652
|
-
|
1653
|
-
[]
|
1654
|
-
else
|
1655
|
-
items[0..count - 1]
|
1656
|
-
end
|
1657
|
-
new_content[section]['items'] = moved_items
|
1353
|
+
logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
|
1658
1354
|
|
1659
|
-
|
1355
|
+
if opt[:new_item]
|
1356
|
+
title, note = format_input(opt[:new_item])
|
1357
|
+
note.add(opt[:note]) if opt[:note]
|
1358
|
+
title.tag!(tag)
|
1359
|
+
add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
|
1660
1360
|
end
|
1361
|
+
|
1362
|
+
write(@doing_file)
|
1661
1363
|
end
|
1662
1364
|
|
1663
|
-
|
1365
|
+
##
|
1366
|
+
## @brief Write content to file or STDOUT
|
1367
|
+
##
|
1368
|
+
## @param file (String) The filepath to write to
|
1369
|
+
##
|
1370
|
+
def write(file = nil, backup: true)
|
1371
|
+
Hooks.trigger :pre_write, self, file
|
1372
|
+
output = wrapped_content
|
1664
1373
|
|
1665
|
-
|
1666
|
-
|
1667
|
-
|
1668
|
-
|
1669
|
-
|
1670
|
-
|
1374
|
+
if file.nil?
|
1375
|
+
$stdout.puts output
|
1376
|
+
else
|
1377
|
+
Util.write_to_file(file, output, backup: backup)
|
1378
|
+
run_after if @config.key?('run_after')
|
1379
|
+
end
|
1671
1380
|
end
|
1672
1381
|
|
1673
|
-
|
1674
|
-
|
1382
|
+
def wrapped_content
|
1383
|
+
output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
|
1675
1384
|
|
1676
|
-
|
1677
|
-
|
1678
|
-
|
1679
|
-
|
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
|
1685
|
-
|
1686
|
-
##
|
1687
|
-
## @brief List available views
|
1688
|
-
##
|
1689
|
-
## @return (Array) View names
|
1690
|
-
##
|
1691
|
-
def views
|
1692
|
-
@config.has_key?('views') ? @config['views'].keys : []
|
1693
|
-
end
|
1385
|
+
@content.each do |title, section|
|
1386
|
+
output += "#{section[:original]}\n"
|
1387
|
+
output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
|
1388
|
+
end
|
1694
1389
|
|
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
|
1390
|
+
output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
|
1391
|
+
end
|
1704
1392
|
|
1705
|
-
|
1706
|
-
|
1707
|
-
|
1708
|
-
|
1709
|
-
|
1710
|
-
|
1711
|
-
|
1393
|
+
##
|
1394
|
+
## @brief Restore a backed up version of a file
|
1395
|
+
##
|
1396
|
+
## @param file (String) The filepath to restore
|
1397
|
+
##
|
1398
|
+
def restore_backup(file)
|
1399
|
+
if File.exist?("#{file}~")
|
1400
|
+
FileUtils.cp("#{file}~", file)
|
1401
|
+
logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
|
1402
|
+
else
|
1403
|
+
logger.error('Restore error:', 'No backup file found')
|
1404
|
+
end
|
1405
|
+
end
|
1712
1406
|
|
1713
|
-
|
1714
|
-
|
1407
|
+
##
|
1408
|
+
## @brief Rename doing file with date and start fresh one
|
1409
|
+
##
|
1410
|
+
def rotate(opt = {})
|
1411
|
+
keep = opt[:keep] || 0
|
1412
|
+
tags = []
|
1413
|
+
tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
|
1414
|
+
bool = opt[:bool] || :and
|
1415
|
+
sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
|
1715
1416
|
|
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
|
1417
|
+
if sect =~ /^all$/i
|
1418
|
+
all_sections = sections.dup
|
1762
1419
|
else
|
1763
|
-
|
1764
|
-
opt[:section] = @content[section]
|
1420
|
+
all_sections = [sect]
|
1765
1421
|
end
|
1766
|
-
end
|
1767
1422
|
|
1768
|
-
|
1423
|
+
counter = 0
|
1424
|
+
new_content = {}
|
1425
|
+
|
1769
1426
|
|
1770
|
-
|
1427
|
+
all_sections.each do |section|
|
1428
|
+
items = @content[section][:items].dup
|
1429
|
+
new_content[section] = {}
|
1430
|
+
new_content[section][:original] = @content[section][:original]
|
1431
|
+
new_content[section][:items] = []
|
1432
|
+
|
1433
|
+
moved_items = []
|
1434
|
+
if !tags.empty? || opt[:search] || opt[:before]
|
1435
|
+
if opt[:before]
|
1436
|
+
time_string = opt[:before]
|
1437
|
+
cutoff = chronify(time_string, guess: :begin)
|
1438
|
+
end
|
1771
1439
|
|
1772
|
-
|
1773
|
-
|
1774
|
-
|
1775
|
-
|
1776
|
-
|
1777
|
-
|
1440
|
+
items.delete_if do |item|
|
1441
|
+
if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
|
1442
|
+
moved_items.push(item)
|
1443
|
+
counter += 1
|
1444
|
+
true
|
1445
|
+
else
|
1446
|
+
false
|
1447
|
+
end
|
1448
|
+
end
|
1449
|
+
@content[section][:items] = items
|
1450
|
+
new_content[section][:items] = moved_items
|
1451
|
+
logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
|
1778
1452
|
else
|
1779
|
-
|
1780
|
-
|
1781
|
-
end
|
1782
|
-
end
|
1453
|
+
new_content[section][:items] = []
|
1454
|
+
moved_items = []
|
1783
1455
|
|
1784
|
-
|
1785
|
-
items.select! { |item| item.has_tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool']) }
|
1786
|
-
end
|
1456
|
+
count = items.length < keep ? items.length : keep
|
1787
1457
|
|
1788
|
-
|
1789
|
-
|
1790
|
-
|
1458
|
+
if items.count > count
|
1459
|
+
moved_items.concat(items[count..-1])
|
1460
|
+
else
|
1461
|
+
moved_items.concat(items)
|
1462
|
+
end
|
1791
1463
|
|
1792
|
-
|
1793
|
-
|
1794
|
-
|
1464
|
+
@content[section][:items] = if count.zero?
|
1465
|
+
[]
|
1466
|
+
else
|
1467
|
+
items[0..count - 1]
|
1468
|
+
end
|
1469
|
+
new_content[section][:items] = moved_items
|
1470
|
+
|
1471
|
+
logger.warn('Rotated:', "#{items.length - count} items from #{section}")
|
1472
|
+
end
|
1795
1473
|
end
|
1796
|
-
end
|
1797
1474
|
|
1798
|
-
|
1799
|
-
|
1800
|
-
|
1801
|
-
|
1802
|
-
|
1803
|
-
|
1475
|
+
write(@doing_file)
|
1476
|
+
|
1477
|
+
file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
|
1478
|
+
if File.exist?(file)
|
1479
|
+
init_doing_file(file)
|
1480
|
+
@content.deep_merge(new_content)
|
1481
|
+
logger.warn('File update:', "Added entries to existing file: #{file}")
|
1482
|
+
else
|
1483
|
+
@content = new_content
|
1484
|
+
logger.warn('File update:', "Created new file: #{file}")
|
1804
1485
|
end
|
1805
|
-
end
|
1806
1486
|
|
1807
|
-
|
1808
|
-
|
1809
|
-
|
1810
|
-
|
1811
|
-
|
1812
|
-
|
1487
|
+
write(file, backup: false)
|
1488
|
+
end
|
1489
|
+
|
1490
|
+
##
|
1491
|
+
## @brief Generate a menu of sections and allow user selection
|
1492
|
+
##
|
1493
|
+
## @return (String) The selected section name
|
1494
|
+
##
|
1495
|
+
def choose_section
|
1496
|
+
choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
|
1497
|
+
choice ? choice.strip : choice
|
1498
|
+
end
|
1499
|
+
|
1500
|
+
##
|
1501
|
+
## @brief List available views
|
1502
|
+
##
|
1503
|
+
## @return (Array) View names
|
1504
|
+
##
|
1505
|
+
def views
|
1506
|
+
@config.has_key?('views') ? @config['views'].keys : []
|
1507
|
+
end
|
1508
|
+
|
1509
|
+
##
|
1510
|
+
## @brief Generate a menu of views and allow user selection
|
1511
|
+
##
|
1512
|
+
## @return (String) The selected view name
|
1513
|
+
##
|
1514
|
+
def choose_view
|
1515
|
+
choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
|
1516
|
+
choice ? choice.strip : choice
|
1517
|
+
end
|
1518
|
+
|
1519
|
+
##
|
1520
|
+
## @brief Gets a view from configuration
|
1521
|
+
##
|
1522
|
+
## @param title (String) The title of the view to retrieve
|
1523
|
+
##
|
1524
|
+
def get_view(title)
|
1525
|
+
return @config['views'][title] if @config['views'].has_key?(title)
|
1526
|
+
|
1527
|
+
false
|
1528
|
+
end
|
1529
|
+
|
1530
|
+
##
|
1531
|
+
## @brief Display contents of a section based on options
|
1532
|
+
##
|
1533
|
+
## @param opt (Hash) Additional Options
|
1534
|
+
##
|
1535
|
+
def list_section(opt = {})
|
1536
|
+
opt[:count] ||= 0
|
1537
|
+
opt[:age] ||= 'newest'
|
1538
|
+
opt[:format] ||= @config.dig('templates', 'default', 'date_format')
|
1539
|
+
opt[:order] ||= @config.dig('templates', 'default', 'order') || 'asc'
|
1540
|
+
opt[:tag_order] ||= 'asc'
|
1541
|
+
opt[:tags_color] ||= false
|
1542
|
+
opt[:template] ||= @config.dig('templates', 'default', 'template')
|
1543
|
+
|
1544
|
+
# opt[:highlight] ||= true
|
1545
|
+
title = ''
|
1546
|
+
is_single = true
|
1547
|
+
if opt[:section].nil?
|
1548
|
+
opt[:section] = choose_section
|
1549
|
+
title = opt[:section]
|
1550
|
+
elsif opt[:section].instance_of?(String)
|
1551
|
+
if opt[:section] =~ /^all$/i
|
1552
|
+
title = if opt[:page_title]
|
1553
|
+
opt[:page_title]
|
1554
|
+
elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
|
1555
|
+
opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
|
1556
|
+
else
|
1557
|
+
'doing'
|
1558
|
+
end
|
1559
|
+
else
|
1560
|
+
title = guess_section(opt[:section])
|
1561
|
+
end
|
1813
1562
|
end
|
1814
|
-
end
|
1815
1563
|
|
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
|
1564
|
+
items = filter_items([], opt: opt).reverse
|
1831
1565
|
|
1832
|
-
|
1566
|
+
items.reverse! if opt[:order] =~ /^d/i
|
1833
1567
|
|
1834
|
-
out = ''
|
1835
1568
|
|
1836
|
-
|
1569
|
+
if opt[:interactive]
|
1570
|
+
opt[:menu] = !opt[:force]
|
1571
|
+
opt[:query] = '' # opt[:search]
|
1572
|
+
opt[:multiple] = true
|
1573
|
+
selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
|
1837
1574
|
|
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]
|
1575
|
+
if selected.empty?
|
1576
|
+
logger.debug('Skipped:', 'No selection')
|
1577
|
+
return
|
1878
1578
|
end
|
1879
1579
|
|
1880
|
-
|
1580
|
+
act_on(selected, opt)
|
1581
|
+
return
|
1582
|
+
end
|
1881
1583
|
|
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
1584
|
|
1891
|
-
|
1585
|
+
opt[:output] ||= 'template'
|
1892
1586
|
|
1893
|
-
|
1587
|
+
opt[:wrap_width] ||= @config['templates']['default']['wrap_width']
|
1894
1588
|
|
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
|
-
}
|
1589
|
+
output(items, title, is_single, opt)
|
1590
|
+
end
|
1903
1591
|
|
1904
|
-
|
1905
|
-
|
1906
|
-
|
1907
|
-
|
1908
|
-
|
1909
|
-
|
1592
|
+
def output(items, title, is_single, opt = {})
|
1593
|
+
out = nil
|
1594
|
+
|
1595
|
+
raise Errors::InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
|
1596
|
+
|
1597
|
+
export_options = { page_title: title, is_single: is_single, options: opt }
|
1598
|
+
|
1599
|
+
Plugins.plugins[:export].each do |_, options|
|
1600
|
+
next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
|
1601
|
+
|
1602
|
+
out = options[:class].render(self, items, variables: export_options)
|
1603
|
+
break
|
1910
1604
|
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
1605
|
|
1972
|
-
|
1973
|
-
|
1606
|
+
out
|
1607
|
+
end
|
1974
1608
|
|
1975
|
-
|
1976
|
-
|
1977
|
-
|
1978
|
-
|
1979
|
-
|
1980
|
-
|
1981
|
-
|
1609
|
+
def load_plugins
|
1610
|
+
if @config.key?('plugins') && @config['plugins']['plugin_path']
|
1611
|
+
add_dir = @config['plugins']['plugin_path']
|
1612
|
+
else
|
1613
|
+
add_dir = File.join(@user_home, '.config', 'doing', 'plugins')
|
1614
|
+
begin
|
1615
|
+
FileUtils.mkdir_p(add_dir) if add_dir
|
1616
|
+
rescue
|
1617
|
+
nil
|
1618
|
+
end
|
1982
1619
|
end
|
1983
1620
|
|
1984
|
-
|
1985
|
-
|
1986
|
-
else
|
1987
|
-
haml_template
|
1988
|
-
end
|
1621
|
+
Plugins.load_plugins(add_dir)
|
1622
|
+
end
|
1989
1623
|
|
1990
|
-
|
1991
|
-
|
1992
|
-
|
1993
|
-
|
1994
|
-
|
1624
|
+
##
|
1625
|
+
## @brief Move entries from a section to Archive or other specified
|
1626
|
+
## section
|
1627
|
+
##
|
1628
|
+
## @param section (String) The source section
|
1629
|
+
## @param options (Hash) Options
|
1630
|
+
##
|
1631
|
+
def archive(section = @config['current_section'], options = {})
|
1632
|
+
count = options[:keep] || 0
|
1633
|
+
destination = options[:destination] || 'Archive'
|
1634
|
+
tags = options[:tags] || []
|
1635
|
+
bool = options[:bool] || :and
|
1995
1636
|
|
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
|
1637
|
+
section = choose_section if section.nil? || section =~ /choose/i
|
1638
|
+
archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
|
1639
|
+
section = guess_section(section) unless archive_all
|
2020
1640
|
|
2021
|
-
|
1641
|
+
add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
|
2022
1642
|
|
2023
|
-
|
2024
|
-
interval ||= false
|
1643
|
+
destination = guess_section(destination)
|
2025
1644
|
|
2026
|
-
|
1645
|
+
if sections.include?(destination) && (sections.include?(section) || archive_all)
|
1646
|
+
do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
|
1647
|
+
write(doing_file)
|
1648
|
+
else
|
1649
|
+
raise Errors::InvalidArgument, 'Either source or destination does not exist'
|
1650
|
+
end
|
1651
|
+
end
|
2027
1652
|
|
2028
|
-
|
2029
|
-
|
2030
|
-
|
2031
|
-
|
2032
|
-
|
2033
|
-
|
2034
|
-
|
2035
|
-
|
2036
|
-
|
1653
|
+
##
|
1654
|
+
## @brief Helper function, performs the actual archiving
|
1655
|
+
##
|
1656
|
+
## @param section (String) The source section
|
1657
|
+
## @param destination (String) The destination section
|
1658
|
+
## @param opt (Hash) Additional Options
|
1659
|
+
##
|
1660
|
+
def do_archive(sect, destination, opt = {})
|
1661
|
+
count = opt[:count] || 0
|
1662
|
+
tags = opt[:tags] || []
|
1663
|
+
bool = opt[:bool] || :and
|
1664
|
+
label = opt[:label] || true
|
1665
|
+
|
1666
|
+
if sect =~ /^all$/i
|
1667
|
+
all_sections = sections.dup
|
1668
|
+
all_sections.delete(destination)
|
1669
|
+
else
|
1670
|
+
all_sections = [sect]
|
2037
1671
|
end
|
2038
1672
|
|
2039
|
-
|
2040
|
-
IO.read(File.expand_path(@config['html_template']['markdown']))
|
2041
|
-
else
|
2042
|
-
markdown_template
|
2043
|
-
end
|
1673
|
+
counter = 0
|
2044
1674
|
|
2045
|
-
|
1675
|
+
all_sections.each do |section|
|
1676
|
+
items = @content[section][:items].dup
|
2046
1677
|
|
2047
|
-
|
2048
|
-
|
2049
|
-
|
2050
|
-
|
2051
|
-
|
2052
|
-
|
2053
|
-
flag = colors[@config['marker_color']]
|
2054
|
-
reset = colors['default']
|
2055
|
-
else
|
2056
|
-
flag = ''
|
2057
|
-
reset = ''
|
2058
|
-
end
|
1678
|
+
moved_items = []
|
1679
|
+
if !tags.empty? || opt[:search] || opt[:before]
|
1680
|
+
if opt[:before]
|
1681
|
+
time_string = opt[:before]
|
1682
|
+
cutoff = chronify(time_string, guess: :begin)
|
1683
|
+
end
|
2059
1684
|
|
2060
|
-
|
2061
|
-
|
2062
|
-
|
1685
|
+
items.delete_if do |item|
|
1686
|
+
if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
|
1687
|
+
moved_items.push(item)
|
1688
|
+
counter += 1
|
1689
|
+
true
|
1690
|
+
else
|
1691
|
+
false
|
1692
|
+
end
|
2063
1693
|
end
|
2064
|
-
|
2065
|
-
|
2066
|
-
|
2067
|
-
|
2068
|
-
|
2069
|
-
|
2070
|
-
|
1694
|
+
moved_items.each do |item|
|
1695
|
+
if label
|
1696
|
+
item.title = if section == @config['current_section']
|
1697
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
1698
|
+
else
|
1699
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
1700
|
+
end
|
1701
|
+
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
2071
1702
|
end
|
2072
1703
|
end
|
2073
|
-
note = "\n#{note_lines.join("\n").chomp}"
|
2074
|
-
else
|
2075
|
-
note = ''
|
2076
|
-
end
|
2077
|
-
output = opt[:template].dup
|
2078
1704
|
|
2079
|
-
|
2080
|
-
|
2081
|
-
|
2082
|
-
|
2083
|
-
m
|
1705
|
+
@content[section][:items] = items
|
1706
|
+
@content[destination][:items].concat(moved_items)
|
1707
|
+
if moved_items.length.positive?
|
1708
|
+
logger.info('Archived:', "#{moved_items.length} items from #{section} to #{destination}")
|
2084
1709
|
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)
|
1710
|
+
else
|
1711
|
+
count = items.length if items.length < count
|
2092
1712
|
|
2093
|
-
|
2094
|
-
|
2095
|
-
|
1713
|
+
items.map! do |item|
|
1714
|
+
if label
|
1715
|
+
item.title = if section == @config['current_section']
|
1716
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
|
1717
|
+
else
|
1718
|
+
item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
|
1719
|
+
end
|
1720
|
+
logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
|
1721
|
+
end
|
1722
|
+
item
|
1723
|
+
end
|
2096
1724
|
|
2097
|
-
|
2098
|
-
|
2099
|
-
flag + item['title'].gsub(/(.{1,#{opt[:wrap_width]}})(\s+|\Z)/, "\\1\n\t ").chomp + reset
|
1725
|
+
if items.count > count
|
1726
|
+
@content[destination][:items].concat(items[count..-1])
|
2100
1727
|
else
|
2101
|
-
|
1728
|
+
@content[destination][:items].concat(items)
|
2102
1729
|
end
|
2103
|
-
end
|
2104
|
-
|
2105
|
-
output.sub!(/%section/, item['section']) if item['section']
|
2106
1730
|
|
2107
|
-
|
2108
|
-
|
2109
|
-
|
2110
|
-
|
2111
|
-
|
2112
|
-
|
2113
|
-
|
2114
|
-
|
1731
|
+
@content[section][:items] = if count.zero?
|
1732
|
+
[]
|
1733
|
+
else
|
1734
|
+
items[0..count - 1]
|
1735
|
+
end
|
1736
|
+
logger.count(destination == 'Archive' ? :archived : :moved,
|
1737
|
+
count: items.length - count,
|
1738
|
+
message: "%count %items from #{section} to #{destination}")
|
1739
|
+
# logger.info('Archived:', "#{items.length - count} items from #{section} to #{destination}")
|
2115
1740
|
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
|
2125
|
-
end
|
2126
|
-
output.gsub!(/%n/, "\n")
|
2127
|
-
output.gsub!(/%t/, "\t")
|
2128
|
-
|
2129
|
-
out += "#{output}\n"
|
2130
1741
|
end
|
2131
|
-
|
2132
|
-
out += tag_times(format: :text, sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) if opt[:totals]
|
2133
1742
|
end
|
2134
|
-
out
|
2135
|
-
end
|
2136
1743
|
|
2137
|
-
|
2138
|
-
|
2139
|
-
|
2140
|
-
|
2141
|
-
|
2142
|
-
|
2143
|
-
|
2144
|
-
|
2145
|
-
|
2146
|
-
|
2147
|
-
|
2148
|
-
|
1744
|
+
##
|
1745
|
+
## @brief Show all entries from the current day
|
1746
|
+
##
|
1747
|
+
## @param times (Boolean) show times
|
1748
|
+
## @param output (String) output format
|
1749
|
+
## @param opt (Hash) Options
|
1750
|
+
##
|
1751
|
+
def today(times = true, output = nil, opt = {})
|
1752
|
+
opt[:totals] ||= false
|
1753
|
+
opt[:sort_tags] ||= false
|
1754
|
+
|
1755
|
+
cfg = @config['templates']['today']
|
1756
|
+
options = {
|
1757
|
+
after: opt[:after],
|
1758
|
+
before: opt[:before],
|
1759
|
+
count: 0,
|
1760
|
+
format: cfg['date_format'],
|
1761
|
+
order: 'asc',
|
1762
|
+
output: output,
|
1763
|
+
section: opt[:section],
|
1764
|
+
sort_tags: opt[:sort_tags],
|
1765
|
+
template: cfg['template'],
|
1766
|
+
times: times,
|
1767
|
+
today: true,
|
1768
|
+
totals: opt[:totals],
|
1769
|
+
wrap_width: cfg['wrap_width']
|
1770
|
+
}
|
1771
|
+
list_section(options)
|
1772
|
+
end
|
1773
|
+
|
1774
|
+
##
|
1775
|
+
## @brief Display entries within a date range
|
1776
|
+
##
|
1777
|
+
## @param dates (Array) [start, end]
|
1778
|
+
## @param section (String) The section
|
1779
|
+
## @param times (Bool) Show times
|
1780
|
+
## @param output (String) Output format
|
1781
|
+
## @param opt (Hash) Additional Options
|
1782
|
+
##
|
1783
|
+
def list_date(dates, section, times = nil, output = nil, opt = {})
|
1784
|
+
opt[:totals] ||= false
|
1785
|
+
opt[:sort_tags] ||= false
|
1786
|
+
section = guess_section(section)
|
1787
|
+
# :date_filter expects an array with start and end date
|
1788
|
+
dates = [dates, dates] if dates.instance_of?(String)
|
1789
|
+
|
1790
|
+
list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
|
1791
|
+
output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
|
1792
|
+
end
|
1793
|
+
|
1794
|
+
##
|
1795
|
+
## @brief Show entries from the previous day
|
1796
|
+
##
|
1797
|
+
## @param section (String) The section
|
1798
|
+
## @param times (Bool) Show times
|
1799
|
+
## @param output (String) Output format
|
1800
|
+
## @param opt (Hash) Additional Options
|
1801
|
+
##
|
1802
|
+
def yesterday(section, times = nil, output = nil, opt = {})
|
1803
|
+
opt[:totals] ||= false
|
1804
|
+
opt[:sort_tags] ||= false
|
1805
|
+
section = guess_section(section)
|
1806
|
+
y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
|
1807
|
+
opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
|
1808
|
+
opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
|
1809
|
+
|
1810
|
+
options = {
|
1811
|
+
after: opt[:after],
|
1812
|
+
before: opt[:before],
|
1813
|
+
count: 0,
|
1814
|
+
order: opt[:order],
|
1815
|
+
output: output,
|
1816
|
+
section: section,
|
1817
|
+
sort_tags: opt[:sort_tags],
|
1818
|
+
tag_order: opt[:tag_order],
|
1819
|
+
times: times,
|
1820
|
+
totals: opt[:totals],
|
1821
|
+
yesterday: true
|
1822
|
+
}
|
2149
1823
|
|
2150
|
-
|
2151
|
-
|
2152
|
-
|
1824
|
+
list_section(options)
|
1825
|
+
end
|
1826
|
+
|
1827
|
+
##
|
1828
|
+
## @brief Show recent entries
|
1829
|
+
##
|
1830
|
+
## @param count (Integer) The number to show
|
1831
|
+
## @param section (String) The section to show from, default Currently
|
1832
|
+
## @param opt (Hash) Additional Options
|
1833
|
+
##
|
1834
|
+
def recent(count = 10, section = nil, opt = {})
|
1835
|
+
times = opt[:t] || true
|
1836
|
+
opt[:totals] ||= false
|
1837
|
+
opt[:sort_tags] ||= false
|
1838
|
+
|
1839
|
+
cfg = @config['templates']['recent']
|
1840
|
+
section ||= @config['current_section']
|
1841
|
+
section = guess_section(section)
|
1842
|
+
|
1843
|
+
list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
|
1844
|
+
format: cfg['date_format'], template: cfg['template'],
|
1845
|
+
order: 'asc', times: times, totals: opt[:totals],
|
1846
|
+
sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
|
1847
|
+
end
|
1848
|
+
|
1849
|
+
##
|
1850
|
+
## @brief Show the last entry
|
1851
|
+
##
|
1852
|
+
## @param times (Bool) Show times
|
1853
|
+
## @param section (String) Section to pull from, default Currently
|
1854
|
+
##
|
1855
|
+
def last(times: true, section: nil, options: {})
|
1856
|
+
section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
|
1857
|
+
cfg = @config['templates']['last']
|
1858
|
+
|
1859
|
+
opts = {
|
1860
|
+
section: section,
|
1861
|
+
wrap_width: cfg['wrap_width'],
|
1862
|
+
count: 1,
|
1863
|
+
format: cfg['date_format'],
|
1864
|
+
template: cfg['template'],
|
1865
|
+
times: times
|
1866
|
+
}
|
2153
1867
|
|
2154
|
-
|
1868
|
+
if options[:tag]
|
1869
|
+
opts[:tag_filter] = {
|
1870
|
+
'tags' => options[:tag],
|
1871
|
+
'bool' => options[:tag_bool]
|
1872
|
+
}
|
1873
|
+
end
|
2155
1874
|
|
2156
|
-
|
1875
|
+
opts[:search] = options[:search] if options[:search]
|
2157
1876
|
|
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'
|
1877
|
+
list_section(opts)
|
2163
1878
|
end
|
2164
|
-
end
|
2165
1879
|
|
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
|
1880
|
+
##
|
1881
|
+
## @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
|
1882
|
+
## Does not repeat tags in a title, and only converts the first instance of an
|
1883
|
+
## untagged keyword
|
1884
|
+
##
|
1885
|
+
## @param text (String) The text to tag
|
1886
|
+
##
|
1887
|
+
def autotag(text)
|
1888
|
+
return unless text
|
1889
|
+
return text unless @auto_tag
|
2185
1890
|
|
2186
|
-
|
1891
|
+
original = text.dup
|
2187
1892
|
|
2188
|
-
|
2189
|
-
|
1893
|
+
current_tags = text.scan(/@\w+/)
|
1894
|
+
whitelisted = []
|
1895
|
+
@config['autotag']['whitelist'].each do |tag|
|
1896
|
+
next if text =~ /@#{tag}\b/i
|
2190
1897
|
|
2191
|
-
|
2192
|
-
|
2193
|
-
|
2194
|
-
|
2195
|
-
time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
|
2196
|
-
cutoff = chronify(time_string)
|
1898
|
+
text.sub!(/(?<!@)\b(#{tag.strip})\b/i) do |m|
|
1899
|
+
m.downcase! if tag =~ /[a-z]/
|
1900
|
+
whitelisted.push("@#{m}")
|
1901
|
+
"@#{m}"
|
2197
1902
|
end
|
1903
|
+
end
|
1904
|
+
tail_tags = []
|
1905
|
+
@config['autotag']['synonyms'].each do |tag, v|
|
1906
|
+
v.each do |word|
|
1907
|
+
next unless text =~ /\b#{word}\b/i
|
2198
1908
|
|
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
|
1909
|
+
tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
|
2207
1910
|
end
|
2208
|
-
|
2209
|
-
|
2210
|
-
|
2211
|
-
|
2212
|
-
|
2213
|
-
|
2214
|
-
|
2215
|
-
|
2216
|
-
|
2217
|
-
|
2218
|
-
|
2219
|
-
|
2220
|
-
|
2221
|
-
|
2222
|
-
|
2223
|
-
|
2224
|
-
|
1911
|
+
end
|
1912
|
+
if @config['autotag'].key? 'transform'
|
1913
|
+
@config['autotag']['transform'].each do |tag|
|
1914
|
+
next unless tag =~ /\S+:\S+/
|
1915
|
+
|
1916
|
+
rx, r = tag.split(/:/)
|
1917
|
+
r.gsub!(/\$/, '\\')
|
1918
|
+
rx.sub!(/^@/, '')
|
1919
|
+
regex = Regexp.new('@' + rx + '\b')
|
1920
|
+
|
1921
|
+
matches = text.scan(regex)
|
1922
|
+
next unless matches
|
1923
|
+
|
1924
|
+
matches.each do |m|
|
1925
|
+
new_tag = r
|
1926
|
+
if m.is_a?(Array)
|
1927
|
+
index = 1
|
1928
|
+
m.each do |v|
|
1929
|
+
new_tag.gsub!('\\' + index.to_s, v)
|
1930
|
+
index += 1
|
1931
|
+
end
|
1932
|
+
end
|
1933
|
+
tail_tags.push(new_tag)
|
2225
1934
|
end
|
2226
|
-
item
|
2227
1935
|
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
1936
|
end
|
2243
|
-
end
|
2244
|
-
end
|
2245
1937
|
|
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
|
1938
|
+
logger.debug('Autotag:', "Whitelisted tags: #{whitelisted.join(', ')}") unless whitelisted.empty?
|
1939
|
+
new_tags = whitelisted
|
1940
|
+
unless tail_tags.empty?
|
1941
|
+
tags = tail_tags.uniq.map { |t| "@#{t}".cyan }.join(' ')
|
1942
|
+
logger.debug('Autotag:', "Synonym tags: #{tags}")
|
1943
|
+
tags_a = tail_tags.map { |t| "@#{t}" }
|
1944
|
+
text.add_tags!(tags_a.join(' '))
|
1945
|
+
new_tags.concat(tags_a)
|
1946
|
+
end
|
2324
1947
|
|
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
|
1948
|
+
unless text == original
|
1949
|
+
logger.info('Autotag:', "added #{new_tags.join(', ')} to \"#{text}\"")
|
1950
|
+
else
|
1951
|
+
logger.debug('Autotag:', "no change to \"#{text}\"")
|
1952
|
+
end
|
2344
1953
|
|
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
|
1954
|
+
text
|
1955
|
+
end
|
2377
1956
|
|
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
|
1957
|
+
##
|
1958
|
+
## @brief Get total elapsed time for all tags in
|
1959
|
+
## selection
|
1960
|
+
##
|
1961
|
+
## @param format (String) return format (html,
|
1962
|
+
## json, or text)
|
1963
|
+
## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
|
1964
|
+
## @param sort_order (String) The sort order (asc or desc)
|
1965
|
+
##
|
1966
|
+
def tag_times(format: :text, sort_by_name: false, sort_order: 'asc')
|
1967
|
+
return '' if @timers.empty?
|
2399
1968
|
|
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
|
1969
|
+
max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1
|
2425
1970
|
|
2426
|
-
|
1971
|
+
total = @timers.delete('All')
|
2427
1972
|
|
2428
|
-
|
2429
|
-
|
1973
|
+
tags_data = @timers.delete_if { |_k, v| v == 0 }
|
1974
|
+
sorted_tags_data = if sort_by_name
|
1975
|
+
tags_data.sort_by { |k, _v| k }
|
1976
|
+
else
|
1977
|
+
tags_data.sort_by { |_k, v| v }
|
1978
|
+
end
|
2430
1979
|
|
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>
|
1980
|
+
sorted_tags_data.reverse! if sort_order =~ /^asc/i
|
1981
|
+
case format
|
1982
|
+
when :html
|
1983
|
+
|
1984
|
+
output = <<EOS
|
1985
|
+
<table>
|
1986
|
+
<caption id="tagtotals">Tag Totals</caption>
|
1987
|
+
<colgroup>
|
1988
|
+
<col style="text-align:left;"/>
|
1989
|
+
<col style="text-align:left;"/>
|
1990
|
+
</colgroup>
|
1991
|
+
<thead>
|
1992
|
+
<tr>
|
1993
|
+
<th style="text-align:left;">project</th>
|
1994
|
+
<th style="text-align:left;">time</th>
|
1995
|
+
</tr>
|
1996
|
+
</thead>
|
1997
|
+
<tbody>
|
1998
|
+
EOS
|
1999
|
+
sorted_tags_data.reverse.each do |k, v|
|
2000
|
+
if v > 0
|
2001
|
+
output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % format_time(v)}</td></tr>\n"
|
2002
|
+
end
|
2003
|
+
end
|
2004
|
+
tail = <<EOS
|
2005
|
+
<tr>
|
2006
|
+
<td style="text-align:left;" colspan="2"></td>
|
2007
|
+
</tr>
|
2008
|
+
</tbody>
|
2009
|
+
<tfoot>
|
2466
2010
|
<tr>
|
2467
|
-
<
|
2468
|
-
<
|
2011
|
+
<td style="text-align:left;"><strong>Total</strong></td>
|
2012
|
+
<td style="text-align:left;">#{'%02d:%02d:%02d' % format_time(total)}</td>
|
2469
2013
|
</tr>
|
2470
|
-
</
|
2471
|
-
|
2014
|
+
</tfoot>
|
2015
|
+
</table>
|
2472
2016
|
EOS
|
2473
|
-
|
2474
|
-
|
2475
|
-
|
2017
|
+
output + tail
|
2018
|
+
when :markdown
|
2019
|
+
pad = sorted_tags_data.map {|k, v| k }.group_by(&:size).max.last[0].length
|
2020
|
+
output = <<~EOS
|
2021
|
+
| #{' ' * (pad - 7) }project | time |
|
2022
|
+
| #{'-' * (pad - 1)}: | :------- |
|
2023
|
+
EOS
|
2024
|
+
sorted_tags_data.reverse.each do |k, v|
|
2025
|
+
if v > 0
|
2026
|
+
output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % format_time(v)} |\n"
|
2027
|
+
end
|
2476
2028
|
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"
|
2029
|
+
tail = "[Tag Totals]"
|
2030
|
+
output + tail
|
2031
|
+
when :json
|
2032
|
+
output = []
|
2033
|
+
sorted_tags_data.reverse.each do |k, v|
|
2034
|
+
d, h, m = format_time(v)
|
2035
|
+
output << {
|
2036
|
+
'tag' => k,
|
2037
|
+
'seconds' => v,
|
2038
|
+
'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
|
2039
|
+
}
|
2501
2040
|
end
|
2502
|
-
|
2503
|
-
|
2504
|
-
|
2505
|
-
|
2506
|
-
|
2507
|
-
|
2508
|
-
|
2509
|
-
|
2510
|
-
|
2511
|
-
'
|
2512
|
-
|
2513
|
-
|
2514
|
-
|
2515
|
-
|
2516
|
-
|
2517
|
-
|
2041
|
+
output
|
2042
|
+
when :human
|
2043
|
+
output = []
|
2044
|
+
sorted_tags_data.reverse.each do |k, v|
|
2045
|
+
spacer = ''
|
2046
|
+
(max - k.length).times do
|
2047
|
+
spacer += ' '
|
2048
|
+
end
|
2049
|
+
d, h, m = format_time(v, human: true)
|
2050
|
+
output.push("┃ #{spacer}#{k}:#{format('%<h> 4dh %<m>02dm', h: h, m: m)} ┃")
|
2051
|
+
end
|
2052
|
+
|
2053
|
+
header = '┏━━ Tag Totals '
|
2054
|
+
(max - 2).times { header += '━' }
|
2055
|
+
header += '┓'
|
2056
|
+
footer = '┗'
|
2057
|
+
(max + 12).times { footer += '━' }
|
2058
|
+
footer += '┛'
|
2059
|
+
divider = '┣'
|
2060
|
+
(max + 12).times { divider += '━' }
|
2061
|
+
divider += '┫'
|
2062
|
+
output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
|
2063
|
+
d, h, m = format_time(total, human: true)
|
2064
|
+
output += "\n#{divider}"
|
2518
2065
|
spacer = ''
|
2519
|
-
(max -
|
2066
|
+
(max - 6).times do
|
2520
2067
|
spacer += ' '
|
2521
2068
|
end
|
2522
|
-
|
2523
|
-
|
2524
|
-
|
2525
|
-
|
2526
|
-
|
2527
|
-
|
2528
|
-
|
2529
|
-
|
2069
|
+
total = "┃ #{spacer}total: "
|
2070
|
+
total += format('%<h> 4dh %<m>02dm', h: h, m: m)
|
2071
|
+
total += ' ┃'
|
2072
|
+
output += "\n#{total}"
|
2073
|
+
output += "\n#{footer}"
|
2074
|
+
output
|
2075
|
+
else
|
2076
|
+
output = []
|
2077
|
+
sorted_tags_data.reverse.each do |k, v|
|
2078
|
+
spacer = ''
|
2079
|
+
(max - k.length).times do
|
2080
|
+
spacer += ' '
|
2081
|
+
end
|
2082
|
+
d, h, m = format_time(v)
|
2083
|
+
output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
|
2084
|
+
end
|
2530
2085
|
|
2531
|
-
|
2532
|
-
|
2533
|
-
|
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}"
|
2086
|
+
output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
|
2087
|
+
d, h, m = format_time(total)
|
2088
|
+
output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
|
2089
|
+
output
|
2550
2090
|
end
|
2551
2091
|
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
2092
|
|
2557
|
-
|
2093
|
+
##
|
2094
|
+
## @brief Gets the interval between entry's start
|
2095
|
+
## date and @done date
|
2096
|
+
##
|
2097
|
+
## @param item (Hash) The entry
|
2098
|
+
## @param formatted (Bool) Return human readable
|
2099
|
+
## time (default seconds)
|
2100
|
+
## @param record (Bool) Add the interval to the
|
2101
|
+
## total for each tag
|
2102
|
+
##
|
2103
|
+
## @return Interval in seconds, or [d, h, m] array if
|
2104
|
+
## formatted is true. False if no end date or
|
2105
|
+
## interval is 0
|
2106
|
+
##
|
2107
|
+
def get_interval(item, formatted: true, record: true)
|
2108
|
+
if item.interval
|
2109
|
+
seconds = item.interval
|
2110
|
+
record_tag_times(item, seconds) if record
|
2111
|
+
return seconds.positive? ? seconds : false unless formatted
|
2112
|
+
|
2113
|
+
return seconds.positive? ? format('%02d:%02d:%02d', *format_time(seconds)) : false
|
2558
2114
|
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)
|
2115
|
+
|
2116
|
+
false
|
2117
|
+
end
|
2118
|
+
|
2119
|
+
##
|
2120
|
+
## @brief Record times for item tags
|
2121
|
+
##
|
2122
|
+
## @param item The item
|
2123
|
+
##
|
2124
|
+
def record_tag_times(item, seconds)
|
2125
|
+
item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
|
2126
|
+
return if @recorded_items.include?(item_hash)
|
2127
|
+
item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
|
2128
|
+
k = m[0] == 'done' ? 'All' : m[0].downcase
|
2129
|
+
if @timers.key?(k)
|
2130
|
+
@timers[k] += seconds
|
2131
|
+
else
|
2132
|
+
@timers[k] = seconds
|
2582
2133
|
end
|
2134
|
+
@recorded_items.push(item_hash)
|
2583
2135
|
end
|
2584
2136
|
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
2137
|
|
2595
|
-
|
2138
|
+
##
|
2139
|
+
## @brief Format human readable time from seconds
|
2140
|
+
##
|
2141
|
+
## @param seconds The seconds
|
2142
|
+
##
|
2143
|
+
def format_time(seconds, human: false)
|
2144
|
+
return [0, 0, 0] if seconds.nil?
|
2596
2145
|
|
2597
|
-
|
2598
|
-
|
2599
|
-
|
2600
|
-
|
2601
|
-
|
2602
|
-
|
2603
|
-
|
2604
|
-
|
2605
|
-
|
2606
|
-
|
2607
|
-
|
2608
|
-
|
2609
|
-
|
2610
|
-
|
2146
|
+
if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
|
2147
|
+
h = Regexp.last_match(1)
|
2148
|
+
m = Regexp.last_match(2)
|
2149
|
+
s = Regexp.last_match(3)
|
2150
|
+
seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
|
2151
|
+
end
|
2152
|
+
minutes = (seconds / 60).to_i
|
2153
|
+
hours = (minutes / 60).to_i
|
2154
|
+
if human
|
2155
|
+
minutes = (minutes % 60).to_i
|
2156
|
+
[0, hours, minutes]
|
2157
|
+
else
|
2158
|
+
days = (hours / 24).to_i
|
2159
|
+
hours = (hours % 24).to_i
|
2160
|
+
minutes = (minutes % 60).to_i
|
2161
|
+
[days, hours, minutes]
|
2162
|
+
end
|
2611
2163
|
end
|
2612
2164
|
|
2613
|
-
|
2614
|
-
done = Time.parse(Regexp.last_match(1))
|
2615
|
-
else
|
2616
|
-
return false
|
2617
|
-
end
|
2165
|
+
private
|
2618
2166
|
|
2619
|
-
|
2620
|
-
|
2621
|
-
else
|
2622
|
-
item['date']
|
2623
|
-
end
|
2167
|
+
def run_after
|
2168
|
+
return unless @config.key?('run_after')
|
2624
2169
|
|
2625
|
-
|
2170
|
+
_, stderr, status = Open3.capture3(@config['run_after'])
|
2171
|
+
return unless status.exitstatus.positive?
|
2626
2172
|
|
2627
|
-
|
2628
|
-
|
2173
|
+
logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
|
2174
|
+
logger.log_now(:error, 'STDERR output:', stderr)
|
2629
2175
|
end
|
2630
2176
|
|
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
|
2177
|
+
def log_change(tags_added: [], tags_removed: [], count: 1)
|
2178
|
+
if tags_added.empty? && tags_removed.empty?
|
2179
|
+
logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
|
2650
2180
|
else
|
2651
|
-
@timers[k] = seconds
|
2652
|
-
end
|
2653
|
-
@recorded_items.push(item)
|
2654
|
-
end
|
2655
|
-
end
|
2656
2181
|
|
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
|
2182
|
+
if tags_added.empty?
|
2183
|
+
logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
|
2184
|
+
# logger.debug('No tags added:', %("#{item.title}" in #{item.section}))
|
2185
|
+
else
|
2186
|
+
logger.count(:added_tags, tag: tags_added, message: '%tags added to %count %items')
|
2187
|
+
# logger.info('Added tags:', %(#{did_add} to "#{item.title}" in #{item.section}))
|
2188
|
+
end
|
2678
2189
|
|
2679
|
-
|
2680
|
-
|
2681
|
-
|
2682
|
-
|
2683
|
-
|
2190
|
+
if tags_removed.empty?
|
2191
|
+
logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
|
2192
|
+
else
|
2193
|
+
logger.count(:removed_tags, tag: tags_removed, message: '%tags removed from %count %items')
|
2194
|
+
end
|
2195
|
+
end
|
2684
2196
|
end
|
2685
2197
|
end
|
2686
2198
|
end
|