doing 1.0.92 → 2.0.5.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AUTHORS +19 -0
- data/CHANGELOG.md +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
|