doing 2.1.0pre → 2.1.4pre
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/.yardoc/checksums +13 -9
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/CHANGELOG.md +42 -10
- data/Gemfile.lock +23 -1
- data/README.md +1 -1
- data/Rakefile +2 -0
- data/bin/doing +421 -156
- data/doc/Array.html +1 -1
- data/doc/Doing/Color.html +1 -1
- data/doc/Doing/Completion.html +1 -1
- data/doc/Doing/Configuration.html +81 -90
- data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
- data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
- data/doc/Doing/Errors/DoingStandardError.html +1 -1
- data/doc/Doing/Errors/EmptyInput.html +1 -1
- data/doc/Doing/Errors/NoResults.html +1 -1
- data/doc/Doing/Errors/PluginException.html +1 -1
- data/doc/Doing/Errors/UserCancelled.html +1 -1
- data/doc/Doing/Errors/WrongCommand.html +1 -1
- data/doc/Doing/Errors.html +1 -1
- data/doc/Doing/Hooks.html +1 -1
- data/doc/Doing/Item.html +84 -20
- data/doc/Doing/Items.html +35 -1
- data/doc/Doing/LogAdapter.html +1 -1
- data/doc/Doing/Note.html +1 -1
- data/doc/Doing/Pager.html +1 -1
- data/doc/Doing/Plugins.html +1 -1
- data/doc/Doing/Prompt.html +70 -18
- data/doc/Doing/Section.html +1 -1
- data/doc/Doing/Util.html +16 -4
- data/doc/Doing/WWID.html +27 -147
- data/doc/Doing.html +3 -3
- data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
- data/doc/GLI/Commands.html +1 -1
- data/doc/GLI.html +1 -1
- data/doc/Hash.html +1 -1
- data/doc/Status.html +1 -1
- data/doc/String.html +344 -4
- data/doc/Symbol.html +1 -1
- data/doc/Time.html +70 -2
- data/doc/_index.html +125 -4
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +2 -2
- data/doc/index.html +2 -2
- data/doc/method_list.html +537 -193
- data/doc/top-level-namespace.html +2 -2
- data/doing.gemspec +2 -0
- data/doing.rdoc +276 -75
- data/lib/completion/doing.bash +20 -20
- data/lib/doing/boolean_term_parser.rb +86 -0
- data/lib/doing/configuration.rb +36 -19
- data/lib/doing/item.rb +102 -9
- data/lib/doing/items.rb +6 -0
- data/lib/doing/phrase_parser.rb +124 -0
- data/lib/doing/plugins/export/template_export.rb +29 -2
- data/lib/doing/prompt.rb +21 -11
- data/lib/doing/string.rb +47 -3
- data/lib/doing/string_chronify.rb +85 -0
- data/lib/doing/time.rb +32 -0
- data/lib/doing/util.rb +2 -5
- data/lib/doing/util_backup.rb +235 -0
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +224 -124
- data/lib/doing.rb +7 -0
- metadata +46 -2
@@ -36,7 +36,10 @@ module Doing
|
|
36
36
|
|
37
37
|
if opt[:wrap_width]&.positive?
|
38
38
|
width = opt[:wrap_width]
|
39
|
-
note.map!
|
39
|
+
note.map! do |line|
|
40
|
+
line.simple_wrap(width)
|
41
|
+
# line.chomp.gsub(/(.{1,#{width}})(\s+|\Z)/, "\\1\n")
|
42
|
+
end
|
40
43
|
note = note.join("\n").split(/\n/).delete_if(&:empty?)
|
41
44
|
end
|
42
45
|
else
|
@@ -58,11 +61,35 @@ module Doing
|
|
58
61
|
format("%#{pad}s", item.date.strftime(opt[:format]))
|
59
62
|
end
|
60
63
|
|
61
|
-
interval = wwid.get_interval(item, record: true) if opt[:times]
|
64
|
+
interval = wwid.get_interval(item, record: true, formatted: false) if opt[:times]
|
65
|
+
if interval
|
66
|
+
case opt[:interval_format].to_sym
|
67
|
+
when :human
|
68
|
+
_d, h, m = wwid.format_time(interval, human: true)
|
69
|
+
interval = format('%<h> 4dh %<m>02dm', h: h, m: m)
|
70
|
+
else
|
71
|
+
d, h, m = wwid.format_time(interval)
|
72
|
+
interval = format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
|
73
|
+
end
|
74
|
+
end
|
62
75
|
|
63
76
|
interval ||= ''
|
64
77
|
output.sub!(/%interval/, interval)
|
65
78
|
|
79
|
+
duration = item.duration if opt[:duration]
|
80
|
+
if duration
|
81
|
+
case opt[:interval_format].to_sym
|
82
|
+
when :human
|
83
|
+
_d, h, m = wwid.format_time(duration, human: true)
|
84
|
+
duration = format('%<h> 4dh %<m>02dm', h: h, m: m)
|
85
|
+
else
|
86
|
+
d, h, m = wwid.format_time(duration)
|
87
|
+
duration = format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
duration ||= ''
|
91
|
+
output.sub!(/%duration/, duration)
|
92
|
+
|
66
93
|
output.sub!(/%(\d+)?shortdate/) do
|
67
94
|
pad = Regexp.last_match(1) || 13
|
68
95
|
format("%#{pad}s", item.date.relative_date)
|
data/lib/doing/prompt.rb
CHANGED
@@ -16,6 +16,14 @@ module Doing
|
|
16
16
|
@default_answer ||= false
|
17
17
|
end
|
18
18
|
|
19
|
+
def enter_text(prompt, default_response: '')
|
20
|
+
return default_response if @default_answer
|
21
|
+
|
22
|
+
print "#{yellow(prompt).sub(/:?$/, ':')} #{reset}"
|
23
|
+
$stdin.gets.strip
|
24
|
+
end
|
25
|
+
|
26
|
+
|
19
27
|
##
|
20
28
|
## Ask a yes or no question in the terminal
|
21
29
|
##
|
@@ -110,10 +118,12 @@ module Doing
|
|
110
118
|
return nil unless $stdout.isatty
|
111
119
|
|
112
120
|
# fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
|
113
|
-
fzf_args << %(--prompt
|
121
|
+
fzf_args << %(--prompt="#{prompt}")
|
122
|
+
fzf_args << "--height=#{options.count + 2}"
|
123
|
+
fzf_args << '--info=inline'
|
114
124
|
fzf_args << '--multi' if multiple
|
115
125
|
header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
|
116
|
-
fzf_args << %(--header
|
126
|
+
fzf_args << %(--header="#{header}")
|
117
127
|
options.sort! if sorted
|
118
128
|
res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
|
119
129
|
return false if res.strip.size.zero?
|
@@ -125,16 +135,16 @@ module Doing
|
|
125
135
|
## Create an interactive menu to select from a set of Items
|
126
136
|
##
|
127
137
|
## @param items [Array] list of items
|
128
|
-
## @param opt
|
129
|
-
## @param include_section [Boolean] include section
|
138
|
+
## @param opt Additional options
|
130
139
|
##
|
131
|
-
## @option opt [
|
132
|
-
## @option opt [String] :
|
133
|
-
## @option opt [String] :
|
134
|
-
## @option opt [
|
135
|
-
## @option opt [Boolean] :menu
|
136
|
-
## @option opt [Boolean] :
|
137
|
-
## @option opt [Boolean] :
|
140
|
+
## @option opt [Boolean] :include_section Include section name for each item in menu
|
141
|
+
## @option opt [String] :header A custom header string
|
142
|
+
## @option opt [String] :prompt A custom prompt string
|
143
|
+
## @option opt [String] :query Initial query
|
144
|
+
## @option opt [Boolean] :show_if_single Show menu even if there's only one option
|
145
|
+
## @option opt [Boolean] :menu Show menu
|
146
|
+
## @option opt [Boolean] :sort Sort options
|
147
|
+
## @option opt [Boolean] :multiple Allow multiple selections
|
138
148
|
## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
|
139
149
|
##
|
140
150
|
def choose_from_items(items, **opt)
|
data/lib/doing/string.rb
CHANGED
@@ -162,6 +162,29 @@ module Doing
|
|
162
162
|
replace uncolor
|
163
163
|
end
|
164
164
|
|
165
|
+
def simple_wrap(width)
|
166
|
+
str = gsub(/@\S+\(.*?\)/) { |tag| tag.gsub(/\s/, '%%%%') }
|
167
|
+
words = str.split(/ /).map { |word| word.gsub(/%%%%/, ' ') }
|
168
|
+
out = []
|
169
|
+
line = []
|
170
|
+
|
171
|
+
words.each do |word|
|
172
|
+
if word.uncolor.length >= width
|
173
|
+
chars = word.uncolor.split('')
|
174
|
+
out << chars.slice!(0, width - 1).join('') while chars.count >= width
|
175
|
+
line << chars.join('')
|
176
|
+
next
|
177
|
+
elsif line.join(' ').uncolor.length + word.uncolor.length + 1 > width
|
178
|
+
out.push(line.join(' '))
|
179
|
+
line.clear
|
180
|
+
end
|
181
|
+
|
182
|
+
line << word.uncolor
|
183
|
+
end
|
184
|
+
out.push(line.join(' '))
|
185
|
+
out.join("\n")
|
186
|
+
end
|
187
|
+
|
165
188
|
##
|
166
189
|
## Wrap string at word breaks, respecting tags
|
167
190
|
##
|
@@ -177,8 +200,14 @@ module Doing
|
|
177
200
|
words = str.split(/ /).map { |word| word.gsub(/%%%%/, ' ') }
|
178
201
|
out = []
|
179
202
|
line = []
|
203
|
+
|
180
204
|
words.each do |word|
|
181
|
-
if
|
205
|
+
if word.uncolor.length >= len
|
206
|
+
chars = word.uncolor.split('')
|
207
|
+
out << chars.slice!(0, len - 1).join('') while chars.count >= len
|
208
|
+
line << chars.join('')
|
209
|
+
next
|
210
|
+
elsif line.join(' ').uncolor.length + word.uncolor.length + 1 > len
|
182
211
|
out.push(line.join(' '))
|
183
212
|
line.clear
|
184
213
|
end
|
@@ -187,6 +216,7 @@ module Doing
|
|
187
216
|
end
|
188
217
|
out.push(line.join(' '))
|
189
218
|
note = ''
|
219
|
+
after = after.dup if after.frozen?
|
190
220
|
after.sub!(note_rx) do
|
191
221
|
note = Regexp.last_match(0)
|
192
222
|
''
|
@@ -210,6 +240,10 @@ module Doing
|
|
210
240
|
end
|
211
241
|
end
|
212
242
|
|
243
|
+
def pluralize(number)
|
244
|
+
number == 1 ? self : "#{self}s"
|
245
|
+
end
|
246
|
+
|
213
247
|
##
|
214
248
|
## Convert a sort order string to a qualified type
|
215
249
|
##
|
@@ -269,6 +303,8 @@ module Doing
|
|
269
303
|
:or
|
270
304
|
when /(not|none)/i
|
271
305
|
:not
|
306
|
+
when /^p/i
|
307
|
+
:pattern
|
272
308
|
else
|
273
309
|
default.is_a?(Symbol) ? default : default.normalize_bool
|
274
310
|
end
|
@@ -282,8 +318,16 @@ module Doing
|
|
282
318
|
gsub(/\((?!\?:)/, '(?:').downcase
|
283
319
|
end
|
284
320
|
|
321
|
+
def wildcard_to_rx
|
322
|
+
gsub(/\?/, '\S').gsub(/\*/, '\S*?')
|
323
|
+
end
|
324
|
+
|
325
|
+
def add_at
|
326
|
+
strip.sub(/^([+-]*)@/, '\1')
|
327
|
+
end
|
328
|
+
|
285
329
|
def to_tags
|
286
|
-
gsub(/ *, */, ' ').gsub(/ +/, ' ').split(/ /).sort.uniq.map
|
330
|
+
gsub(/ *, */, ' ').gsub(/ +/, ' ').split(/ /).sort.uniq.map(&:add_at)
|
287
331
|
end
|
288
332
|
|
289
333
|
def add_tags!(tags, remove: false)
|
@@ -503,7 +547,7 @@ module Doing
|
|
503
547
|
end
|
504
548
|
else
|
505
549
|
case self
|
506
|
-
when / *,
|
550
|
+
when /(^\[.*?\]$| *, *)/
|
507
551
|
gsub(/^\[ *| *\]$/, '').split(/ *, */)
|
508
552
|
when /^[0-9]+$/
|
509
553
|
to_i
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
# Chronify methods for strings
|
5
|
+
class ::String
|
6
|
+
##
|
7
|
+
## Converts input string into a Time object when input
|
8
|
+
## takes on the following formats:
|
9
|
+
## - interval format e.g. '1d2h30m', '45m'
|
10
|
+
## etc.
|
11
|
+
## - a semantic phrase e.g. 'yesterday
|
12
|
+
## 5:30pm'
|
13
|
+
## - a strftime e.g. '2016-03-15 15:32:04
|
14
|
+
## PDT'
|
15
|
+
##
|
16
|
+
## @param options Additional options
|
17
|
+
##
|
18
|
+
## @option options :future [Boolean] assume future date
|
19
|
+
## (default: false)
|
20
|
+
##
|
21
|
+
## @option options :guess [Symbol] :begin or :end to
|
22
|
+
## assume beginning or end of
|
23
|
+
## arbitrary time range
|
24
|
+
##
|
25
|
+
## @return [DateTime] result
|
26
|
+
##
|
27
|
+
def chronify(**options)
|
28
|
+
now = Time.now
|
29
|
+
raise InvalidTimeExpression, "Invalid time expression #{inspect}" if to_s.strip == ''
|
30
|
+
|
31
|
+
secs_ago = if match(/^(\d+)$/)
|
32
|
+
# plain number, assume minutes
|
33
|
+
Regexp.last_match(1).to_i * 60
|
34
|
+
elsif (m = match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
|
35
|
+
# day/hour/minute format e.g. 1d2h30m
|
36
|
+
[[m['day'], 24 * 3600],
|
37
|
+
[m['hour'], 3600],
|
38
|
+
[m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
|
39
|
+
end
|
40
|
+
|
41
|
+
if secs_ago
|
42
|
+
now - secs_ago
|
43
|
+
else
|
44
|
+
Chronic.parse(self, {
|
45
|
+
guess: options.fetch(:guess, :begin),
|
46
|
+
context: options.fetch(:future, false) ? :future : :past,
|
47
|
+
ambiguous_time_range: 8
|
48
|
+
})
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
## Converts simple strings into seconds that can be
|
54
|
+
## added to a Time object
|
55
|
+
##
|
56
|
+
## Input string can be HH:MM or XX[dhm][[XXhm][XXm]]
|
57
|
+
## (1d2h30m, 45m, 1.5d, 1h20m, etc.)
|
58
|
+
##
|
59
|
+
## @return [Integer] seconds
|
60
|
+
##
|
61
|
+
def chronify_qty
|
62
|
+
minutes = 0
|
63
|
+
case self.strip
|
64
|
+
when /^(\d+):(\d\d)$/
|
65
|
+
minutes += Regexp.last_match(1).to_i * 60
|
66
|
+
minutes += Regexp.last_match(2).to_i
|
67
|
+
when /^(\d+(?:\.\d+)?)([hmd])?$/
|
68
|
+
amt = Regexp.last_match(1)
|
69
|
+
type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
|
70
|
+
|
71
|
+
minutes = case type.downcase
|
72
|
+
when 'm'
|
73
|
+
amt.to_i
|
74
|
+
when 'h'
|
75
|
+
(amt.to_f * 60).round
|
76
|
+
when 'd'
|
77
|
+
(amt.to_f * 60 * 24).round
|
78
|
+
else
|
79
|
+
minutes
|
80
|
+
end
|
81
|
+
end
|
82
|
+
minutes * 60
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
data/lib/doing/time.rb
CHANGED
@@ -14,5 +14,37 @@ module Doing
|
|
14
14
|
strftime('%m/%d/%Y %_I:%M%P')
|
15
15
|
end
|
16
16
|
end
|
17
|
+
|
18
|
+
def humanize(seconds)
|
19
|
+
s = seconds
|
20
|
+
m = (s / 60).floor
|
21
|
+
s = (s % 60).floor
|
22
|
+
h = (m / 60).floor
|
23
|
+
m = (m % 60).floor
|
24
|
+
d = (h / 24).floor
|
25
|
+
h = h % 24
|
26
|
+
|
27
|
+
output = []
|
28
|
+
output.push("#{d} #{'day'.pluralize(d)}") if d.positive?
|
29
|
+
output.push("#{h} #{'hour'.pluralize(h)}") if h.positive?
|
30
|
+
output.push("#{m} #{'minute'.pluralize(m)}") if m.positive?
|
31
|
+
output.push("#{s} #{'second'.pluralize(s)}") if s.positive?
|
32
|
+
output.join(', ')
|
33
|
+
end
|
34
|
+
|
35
|
+
def time_ago
|
36
|
+
if self > Date.today.to_time
|
37
|
+
output = humanize(Time.now - self)
|
38
|
+
"#{output} ago"
|
39
|
+
elsif self > (Date.today - 1).to_time
|
40
|
+
"Yesterday at #{strftime('%_I:%M:%S%P')}"
|
41
|
+
elsif self > (Date.today - 6).to_time
|
42
|
+
strftime('%a %I:%M:%S%P')
|
43
|
+
elsif self.year == Date.today.year
|
44
|
+
strftime('%m/%d %I:%M:%S%P')
|
45
|
+
else
|
46
|
+
strftime('%m/%d/%Y %I:%M:%S%P')
|
47
|
+
end
|
48
|
+
end
|
17
49
|
end
|
18
50
|
end
|
data/lib/doing/util.rb
CHANGED
@@ -115,10 +115,7 @@ module Doing
|
|
115
115
|
|
116
116
|
file = File.expand_path(file)
|
117
117
|
|
118
|
-
|
119
|
-
# Create a backup copy for the undo command
|
120
|
-
FileUtils.cp(file, "#{file}~")
|
121
|
-
end
|
118
|
+
Backup.write_backup(file) if backup
|
122
119
|
|
123
120
|
File.open(file, 'w+') do |f|
|
124
121
|
f.puts content
|
@@ -133,7 +130,7 @@ module Doing
|
|
133
130
|
end
|
134
131
|
|
135
132
|
def default_editor
|
136
|
-
@default_editor
|
133
|
+
@default_editor ||= find_default_editor
|
137
134
|
end
|
138
135
|
|
139
136
|
def editor_with_args
|
@@ -0,0 +1,235 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doing
|
4
|
+
module Util
|
5
|
+
## Backup utils
|
6
|
+
module Backup
|
7
|
+
extend self
|
8
|
+
include Util
|
9
|
+
|
10
|
+
##
|
11
|
+
## Delete all but most recent 5 backups
|
12
|
+
##
|
13
|
+
## @param limit Maximum number of backups to retain
|
14
|
+
##
|
15
|
+
def prune_backups(filename, limit = 10)
|
16
|
+
backups = get_backups(filename)
|
17
|
+
return unless backups.count > limit
|
18
|
+
|
19
|
+
backups[limit..-1].each do |file|
|
20
|
+
FileUtils.rm(File.join(backup_dir, file))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
## Restore the most recent backup. If a filename is
|
26
|
+
## provided, only backups of that filename will be used.
|
27
|
+
##
|
28
|
+
## @param filename The filename to restore, if
|
29
|
+
## different from default
|
30
|
+
##
|
31
|
+
def restore_last_backup(filename = nil, count: 1)
|
32
|
+
filename ||= Doing.config.settings['doing_file']
|
33
|
+
|
34
|
+
result = get_backups(filename).slice(count - 1)
|
35
|
+
raise DoingRuntimeError, 'End of undo history' if result.nil?
|
36
|
+
|
37
|
+
backup_file = File.join(backup_dir, result)
|
38
|
+
|
39
|
+
save_undone(filename)
|
40
|
+
FileUtils.mv(backup_file, filename)
|
41
|
+
prune_backups_after(File.basename(backup_file))
|
42
|
+
Doing.logger.warn('File update:', "restored from #{result}")
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
## Undo last undo
|
47
|
+
##
|
48
|
+
## @param filename The filename
|
49
|
+
##
|
50
|
+
def redo_backup(filename = nil, count: 1)
|
51
|
+
filename ||= Doing.config.settings['doing_file']
|
52
|
+
# redo_file = File.join(backup_dir, "undone___#{File.basename(filename)}")
|
53
|
+
undones = Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).sort
|
54
|
+
total = undones.count
|
55
|
+
count = total if count > total
|
56
|
+
|
57
|
+
skipped = undones.slice!(0, count)
|
58
|
+
undone = skipped.pop
|
59
|
+
|
60
|
+
raise DoingRuntimeError, 'End of redo history' if undone.nil?
|
61
|
+
|
62
|
+
redo_file = File.join(backup_dir, undone)
|
63
|
+
|
64
|
+
FileUtils.move(redo_file, filename)
|
65
|
+
|
66
|
+
skipped.each do |f|
|
67
|
+
FileUtils.mv(File.join(backup_dir, f), File.join(backup_dir, f.sub(/^undone/, '')))
|
68
|
+
end
|
69
|
+
|
70
|
+
Doing.logger.warn('File update:', "restored undo step #{count}/#{total}")
|
71
|
+
Doing.logger.debug('Backup:', "#{total - skipped.count - 1} redos remaining")
|
72
|
+
end
|
73
|
+
|
74
|
+
def clear_undone(filename = nil)
|
75
|
+
filename ||= Doing.config.settings['doing_file']
|
76
|
+
# redo_file = File.join(backup_dir, "undone___#{File.basename(filename)}")
|
77
|
+
Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).each do |f|
|
78
|
+
FileUtils.rm(File.join(backup_dir, f))
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
##
|
83
|
+
## Select from recent backups. If a filename is
|
84
|
+
## provided, only backups of that filename will be used.
|
85
|
+
##
|
86
|
+
## @param filename The filename to restore
|
87
|
+
##
|
88
|
+
def select_backup(filename = nil)
|
89
|
+
filename ||= Doing.config.settings['doing_file']
|
90
|
+
|
91
|
+
options = get_backups(filename).each_with_object([]) do |file, arr|
|
92
|
+
d, _base = date_of_backup(file)
|
93
|
+
arr.push("#{d.time_ago}\t#{File.join(backup_dir, file)}")
|
94
|
+
end
|
95
|
+
|
96
|
+
backup_file = show_menu(options, filename)
|
97
|
+
write_to_file(File.join(backup_dir, "undone___#{File.basename(filename)}"), IO.read(filename), backup: false)
|
98
|
+
FileUtils.mv(backup_file, filename)
|
99
|
+
prune_backups_after(File.basename(backup_file))
|
100
|
+
Doing.logger.warn('File update:', "restored from #{backup_file}")
|
101
|
+
end
|
102
|
+
|
103
|
+
def show_menu(options, filename)
|
104
|
+
if TTY::Which.which('colordiff')
|
105
|
+
preview = 'colordiff -U 1'
|
106
|
+
pipe = '| awk "(NR>2)"'
|
107
|
+
elsif TTY::Which.which('git')
|
108
|
+
preview = 'git --no-pager diff -U1 --color=always --minimal --word-diff'
|
109
|
+
pipe = ' | awk "(NR>4)"'
|
110
|
+
else
|
111
|
+
preview = 'diff -U 1'
|
112
|
+
pipe = if TTY::Which.which('delta')
|
113
|
+
' | delta --no-gitconfig --syntax-theme=1337'
|
114
|
+
elsif TTY::Which.which('diff-so-fancy')
|
115
|
+
' | diff-so-fancy'
|
116
|
+
elsif TTY::Which.which('ydiff')
|
117
|
+
' | ydiff -c always --wrap < /dev/tty'
|
118
|
+
else
|
119
|
+
cmd = 'sed -e "s/^-/`echo -e "\033[31m"`-/;s/^+/`echo -e "\033[32m"`+/;s/^@/`echo -e "\033[34m"`@/;s/\$/`echo -e "\033[0m"`/"'
|
120
|
+
"| bash -c #{Shellwords.escape(cmd)}"
|
121
|
+
end
|
122
|
+
pipe += ' | awk "(NR>2)"'
|
123
|
+
end
|
124
|
+
|
125
|
+
result = Doing::Prompt.choose_from(options,
|
126
|
+
sorted: false,
|
127
|
+
fzf_args: [
|
128
|
+
'--delimiter="\t"',
|
129
|
+
'--with-nth=1',
|
130
|
+
%(--preview='#{preview} "#{filename}" {2} #{pipe}'),
|
131
|
+
'--disabled',
|
132
|
+
'--preview-window="right,70%,nowrap,follow"'
|
133
|
+
])
|
134
|
+
raise UserCancelled unless result
|
135
|
+
|
136
|
+
result.strip.split(/\t/).last
|
137
|
+
end
|
138
|
+
|
139
|
+
##
|
140
|
+
## Writes a copy of the content to a dated backup file
|
141
|
+
## in a hidden directory
|
142
|
+
##
|
143
|
+
## @param content The data to back up
|
144
|
+
##
|
145
|
+
def write_backup(filename = nil)
|
146
|
+
filename ||= Doing.config.settings['doing_file']
|
147
|
+
|
148
|
+
unless File.exist?(filename)
|
149
|
+
Doing.logger.debug('Backup:', "original file doesn't exist (#{filename})")
|
150
|
+
return
|
151
|
+
end
|
152
|
+
|
153
|
+
backup_file = File.join(backup_dir, "#{timestamp_filename}___#{File.basename(filename)}")
|
154
|
+
# compressed = Zlib::Deflate.deflate(content)
|
155
|
+
|
156
|
+
FileUtils.cp(filename, backup_file)
|
157
|
+
|
158
|
+
prune_backups(filename, 100)
|
159
|
+
clear_undone(filename)
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def timestamp_filename
|
165
|
+
Time.now.strftime('%Y-%m-%d_%H.%M.%S')
|
166
|
+
end
|
167
|
+
|
168
|
+
def get_backups(filename = nil)
|
169
|
+
filename ||= Doing.config.settings['doing_file']
|
170
|
+
backups = Dir.glob("*___#{File.basename(filename)}", base: backup_dir).sort.reverse
|
171
|
+
backups.delete_if { |f| f =~ /^undone/ }
|
172
|
+
end
|
173
|
+
|
174
|
+
def save_undone(filename = nil)
|
175
|
+
filename ||= Doing.config.settings['doing_file']
|
176
|
+
undone_file = File.join(backup_dir, "undone#{timestamp_filename}___#{File.basename(filename)}")
|
177
|
+
FileUtils.cp(filename, undone_file)
|
178
|
+
end
|
179
|
+
|
180
|
+
##
|
181
|
+
## Retrieve date from backup filename
|
182
|
+
##
|
183
|
+
## @param filename The filename
|
184
|
+
##
|
185
|
+
def date_of_backup(filename)
|
186
|
+
m = filename.match(/^(?<date>\d{4}-\d{2}-\d{2})_(?<time>\d{2}\.\d{2}\.\d{2})___(?<file>.*?)$/)
|
187
|
+
return nil if m.nil?
|
188
|
+
|
189
|
+
[Time.parse("#{m['date']} #{m['time'].gsub(/\./, ':')}"), m['file']]
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
## Return a location for storing backups, creating if needed
|
194
|
+
##
|
195
|
+
## @return Path to backup directory
|
196
|
+
##
|
197
|
+
def backup_dir
|
198
|
+
@backup_dir ||= create_backup_dir
|
199
|
+
end
|
200
|
+
|
201
|
+
def create_backup_dir
|
202
|
+
dir = File.expand_path(Doing.config.settings['backup_dir']) || File.join(user_home, '.doing_backup')
|
203
|
+
if File.exist?(dir) && !File.directory?(dir)
|
204
|
+
raise DoingRuntimeError, "Backup error: #{dir} is not a directory"
|
205
|
+
|
206
|
+
end
|
207
|
+
|
208
|
+
unless File.exist?(dir)
|
209
|
+
FileUtils.mkdir_p(dir)
|
210
|
+
Doing.logger.warn('Backup:', "backup directory created at #{dir}")
|
211
|
+
end
|
212
|
+
|
213
|
+
dir
|
214
|
+
end
|
215
|
+
|
216
|
+
##
|
217
|
+
## Delete backups newer than selected filename
|
218
|
+
##
|
219
|
+
## @param filename The filename
|
220
|
+
##
|
221
|
+
def prune_backups_after(filename)
|
222
|
+
target_date, base = date_of_backup(filename)
|
223
|
+
counter = 0
|
224
|
+
get_backups(base).each do |file|
|
225
|
+
date, _base = date_of_backup(file)
|
226
|
+
if date && target_date < date
|
227
|
+
FileUtils.mv(File.join(backup_dir, file), File.join(backup_dir, "undone#{file}"))
|
228
|
+
counter += 1
|
229
|
+
end
|
230
|
+
end
|
231
|
+
Doing.logger.debug('Backup:', "deleted #{counter} files newer than restored backup")
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
data/lib/doing/version.rb
CHANGED