doing 2.1.1pre → 2.1.5pre
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 +19 -15
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/CHANGELOG.md +58 -8
- data/Gemfile.lock +25 -1
- data/README.md +1 -1
- data/Rakefile +2 -0
- data/bin/doing +447 -149
- data/doc/Array.html +63 -1
- data/doc/BooleanTermParser/Clause.html +293 -0
- data/doc/BooleanTermParser/Operator.html +172 -0
- data/doc/BooleanTermParser/Query.html +417 -0
- data/doc/BooleanTermParser/QueryParser.html +135 -0
- data/doc/BooleanTermParser/QueryTransformer.html +124 -0
- data/doc/BooleanTermParser.html +115 -0
- data/doc/Doing/CLIFormat.html +131 -0
- data/doc/Doing/Color.html +2 -2
- data/doc/Doing/Completion.html +1 -1
- data/doc/Doing/Configuration.html +168 -73
- 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 +177 -86
- data/doc/Doing/Items.html +36 -2
- data/doc/Doing/LogAdapter.html +70 -1
- data/doc/Doing/Note.html +5 -134
- data/doc/Doing/Pager.html +1 -1
- data/doc/Doing/Plugins.html +380 -40
- data/doc/Doing/Prompt.html +70 -18
- data/doc/Doing/Section.html +1 -1
- data/doc/Doing/TemplateString.html +713 -0
- data/doc/Doing/Util/Backup.html +686 -0
- data/doc/Doing/Util.html +16 -4
- data/doc/Doing/WWID.html +133 -73
- data/doc/Doing.html +4 -4
- 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/PhraseParser/Operator.html +172 -0
- data/doc/PhraseParser/PhraseClause.html +303 -0
- data/doc/PhraseParser/Query.html +495 -0
- data/doc/PhraseParser/QueryParser.html +136 -0
- data/doc/PhraseParser/QueryTransformer.html +124 -0
- data/doc/PhraseParser/TermClause.html +293 -0
- data/doc/PhraseParser.html +115 -0
- data/doc/Status.html +1 -1
- data/doc/String.html +319 -13
- data/doc/Symbol.html +35 -1
- data/doc/Time.html +70 -2
- data/doc/_index.html +132 -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 +648 -160
- data/doc/top-level-namespace.html +2 -2
- data/doing.gemspec +3 -0
- data/doing.rdoc +263 -82
- data/lib/completion/doing.bash +18 -18
- data/lib/doing/array.rb +9 -0
- data/lib/doing/boolean_term_parser.rb +86 -0
- data/lib/doing/configuration.rb +63 -24
- data/lib/doing/item.rb +112 -10
- data/lib/doing/items.rb +6 -0
- data/lib/doing/log_adapter.rb +28 -0
- data/lib/doing/note.rb +31 -30
- data/lib/doing/phrase_parser.rb +124 -0
- data/lib/doing/plugin_manager.rb +57 -13
- data/lib/doing/plugins/export/dayone_export.rb +209 -0
- data/lib/doing/plugins/export/template_export.rb +113 -81
- data/lib/doing/prompt.rb +26 -13
- data/lib/doing/string.rb +114 -29
- data/lib/doing/string_chronify.rb +5 -1
- data/lib/doing/symbol.rb +4 -0
- data/lib/doing/template_string.rb +197 -0
- data/lib/doing/time.rb +32 -0
- data/lib/doing/util.rb +6 -7
- data/lib/doing/util_backup.rb +287 -0
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +152 -55
- data/lib/doing.rb +9 -0
- data/lib/templates/doing-dayone-entry.erb +6 -0
- data/lib/templates/doing-dayone.erb +5 -0
- metadata +85 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doing
|
|
4
|
+
##
|
|
5
|
+
## Template string formatting
|
|
6
|
+
##
|
|
7
|
+
class TemplateString < String
|
|
8
|
+
class ::String
|
|
9
|
+
##
|
|
10
|
+
## Extract the longest valid color from a string.
|
|
11
|
+
##
|
|
12
|
+
## Allows %colors to bleed into other text and still
|
|
13
|
+
## be recognized, e.g. %greensomething still finds
|
|
14
|
+
## %green.
|
|
15
|
+
##
|
|
16
|
+
## @return [String] a valid color name
|
|
17
|
+
## @api private
|
|
18
|
+
def validate_color
|
|
19
|
+
valid_color = nil
|
|
20
|
+
compiled = ''
|
|
21
|
+
split('').each do |char|
|
|
22
|
+
compiled += char
|
|
23
|
+
valid_color = compiled if Color.attributes.include?(compiled.to_sym)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
valid_color
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :original
|
|
31
|
+
|
|
32
|
+
include Color
|
|
33
|
+
def initialize(string, placeholders: {}, force_color: false, wrap_width: 0, color: '', tags_color: '', reset: '')
|
|
34
|
+
Color.coloring = true if force_color
|
|
35
|
+
@colors = nil
|
|
36
|
+
@original = string
|
|
37
|
+
super(Color.reset + string)
|
|
38
|
+
|
|
39
|
+
placeholders.each { |k, v| fill(k, v, wrap_width: wrap_width, color: color, tags_color: tags_color) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
## Test if string contains any valid %colors
|
|
44
|
+
##
|
|
45
|
+
## @return [Boolean] True if colors, False otherwise.
|
|
46
|
+
##
|
|
47
|
+
def colors?
|
|
48
|
+
scan(/%([a-z]+)/).each do
|
|
49
|
+
return true if Regexp.last_match(1).validate_color
|
|
50
|
+
end
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reparse
|
|
55
|
+
@parsed_colors = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
## Return string with %colors replaced with escape codes
|
|
60
|
+
##
|
|
61
|
+
## @return [String] colorized string
|
|
62
|
+
##
|
|
63
|
+
def colored
|
|
64
|
+
reparse
|
|
65
|
+
parsed_colors[:string].apply_colors(parsed_colors[:colors])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
## Remove all valid %colors from string
|
|
70
|
+
##
|
|
71
|
+
## @return [String] cleaned string
|
|
72
|
+
##
|
|
73
|
+
def raw
|
|
74
|
+
parsed_colors[:string].uncolor
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parsed_colors
|
|
78
|
+
@parsed_colors ||= parse_colors
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
## Parse a template string for %colors and return a hash
|
|
83
|
+
## of colors and string locations
|
|
84
|
+
##
|
|
85
|
+
## @return [Hash] Uncolored string and array of colors and locations
|
|
86
|
+
def parse_colors
|
|
87
|
+
working = dup
|
|
88
|
+
color_array = []
|
|
89
|
+
|
|
90
|
+
scan(/(?<!\\)(%([a-z]+))/).each do |color|
|
|
91
|
+
valid_color = color[1].validate_color
|
|
92
|
+
next unless valid_color
|
|
93
|
+
|
|
94
|
+
idx = working.match(/(?<!\\)%#{valid_color}/).begin(0)
|
|
95
|
+
color_array.push({ name: valid_color, color: Color.send(valid_color), index: idx })
|
|
96
|
+
working.sub!(/(?<!\\)%#{valid_color}/, '')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
{ string: working, colors: color_array }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
## Apply a color array to a string
|
|
104
|
+
##
|
|
105
|
+
## @param color_array [Array] Array of hashes
|
|
106
|
+
## containing :name, :color,
|
|
107
|
+
## :index
|
|
108
|
+
##
|
|
109
|
+
def apply_colors(color_array)
|
|
110
|
+
str = dup
|
|
111
|
+
color_array.reverse.each do |color|
|
|
112
|
+
c = color[:color].empty? ? Color.send(color[:name]) : color[:color]
|
|
113
|
+
str.insert(color[:index], c)
|
|
114
|
+
end
|
|
115
|
+
str
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def fill(placeholder, value, wrap_width: 0, color: '', tags_color: '', reset: '')
|
|
119
|
+
reparse
|
|
120
|
+
rx = /(?mi)(?<!\\)%(?<width>-?\d+)?(?:\^(?<mchar>.))?(?:(?<ichar>[ _t]|[^a-z0-9])(?<icount>\d+))?(?<prefix>.[ _t]?)?#{placeholder.sub(/^%/, '')}(?<after>.*?)$/
|
|
121
|
+
ph = raw.match(rx)
|
|
122
|
+
|
|
123
|
+
return unless ph
|
|
124
|
+
placeholder_offset = ph.begin(0)
|
|
125
|
+
last_colors = parsed_colors[:colors].select { |v| v[:index] <= placeholder_offset + 4 }
|
|
126
|
+
|
|
127
|
+
last_color = last_colors.map { |v| v[:color] }.pop(3).join('')
|
|
128
|
+
|
|
129
|
+
sub!(rx) do
|
|
130
|
+
m = Regexp.last_match
|
|
131
|
+
|
|
132
|
+
after = m['after']
|
|
133
|
+
|
|
134
|
+
if value.nil? || value.empty?
|
|
135
|
+
after
|
|
136
|
+
else
|
|
137
|
+
pad = m['width'].to_i
|
|
138
|
+
mark = m['mchar'] || ''
|
|
139
|
+
if placeholder == 'shortdate' && m['width'].nil?
|
|
140
|
+
pad = 13
|
|
141
|
+
end
|
|
142
|
+
indent = nil
|
|
143
|
+
if m['ichar']
|
|
144
|
+
char = m['ichar'] =~ /t/ ? "\t" : ' '
|
|
145
|
+
indent = char * m['icount'].to_i
|
|
146
|
+
end
|
|
147
|
+
indent ||= placeholder =~ /^title/ ? '' : "\t"
|
|
148
|
+
prefix = m['prefix']
|
|
149
|
+
if placeholder =~ /^title/
|
|
150
|
+
color = last_color + color
|
|
151
|
+
|
|
152
|
+
if wrap_width.positive? || pad.positive?
|
|
153
|
+
width = pad.positive? ? pad : wrap_width
|
|
154
|
+
|
|
155
|
+
out = value.gsub(/%/, '\%').strip.wrap(width,
|
|
156
|
+
pad: pad,
|
|
157
|
+
indent: indent,
|
|
158
|
+
offset: placeholder_offset,
|
|
159
|
+
prefix: prefix,
|
|
160
|
+
color: color,
|
|
161
|
+
after: after,
|
|
162
|
+
reset: reset,
|
|
163
|
+
pad_first: false)
|
|
164
|
+
out.highlight_tags!(tags_color, last_color: color) if tags_color && !tags_color.empty?
|
|
165
|
+
out
|
|
166
|
+
else
|
|
167
|
+
out = format("%s%s%#{pad}s%s", prefix, color, value.gsub(/%/, '\%').sub(/\s*$/, ''), after)
|
|
168
|
+
out.highlight_tags!(tags_color, last_color: color) if tags_color && !tags_color.empty?
|
|
169
|
+
out
|
|
170
|
+
end
|
|
171
|
+
elsif placeholder =~ /^note/
|
|
172
|
+
if wrap_width.positive? || pad.positive?
|
|
173
|
+
width = pad.positive? ? pad : wrap_width
|
|
174
|
+
outstring = value.map do |l|
|
|
175
|
+
if l.empty?
|
|
176
|
+
' '
|
|
177
|
+
else
|
|
178
|
+
line = l.gsub(/%/, '\%').strip.wrap(width, pad: pad, indent: indent, offset: 0, prefix: prefix, color: last_color, after: after, reset: reset, pad_first: true)
|
|
179
|
+
line.highlight_tags!(tags_color, last_color: last_color) unless tags_color.nil? || tags_color.empty?
|
|
180
|
+
"#{line} "
|
|
181
|
+
end
|
|
182
|
+
end.join("\n")
|
|
183
|
+
"\n#{last_color}#{mark}#{outstring} "
|
|
184
|
+
else
|
|
185
|
+
out = format("\n%s%s%s%#{pad}s%s", indent, prefix, last_color, value.join("\n#{indent}#{prefix}").gsub(/%/, '\%').sub(/\s*$/, ''), after)
|
|
186
|
+
out.highlight_tags!(tags_color, last_color: last_color) if tags_color && !tags_color.empty?
|
|
187
|
+
out
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
format("%s%#{pad}s%s", prefix, value.gsub(/%/, '\%').sub(/\s*$/, ''), after)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
@parsed_colors = parse_colors
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
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
|
@@ -112,20 +112,19 @@ module Doing
|
|
|
112
112
|
puts content
|
|
113
113
|
return
|
|
114
114
|
end
|
|
115
|
-
|
|
115
|
+
Doing.logger.benchmark(:write_file, :start)
|
|
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
|
|
125
122
|
Doing.logger.debug('Write:', "File written: #{file}")
|
|
126
123
|
end
|
|
127
|
-
|
|
124
|
+
Doing.logger.benchmark(:_post_write_hook, :start)
|
|
128
125
|
Hooks.trigger :post_write, file
|
|
126
|
+
Doing.logger.benchmark(:_post_write_hook, :finish)
|
|
127
|
+
Doing.logger.benchmark(:write_file, :finish)
|
|
129
128
|
end
|
|
130
129
|
|
|
131
130
|
def safe_load_file(filename)
|
|
@@ -133,7 +132,7 @@ module Doing
|
|
|
133
132
|
end
|
|
134
133
|
|
|
135
134
|
def default_editor
|
|
136
|
-
@default_editor
|
|
135
|
+
@default_editor ||= find_default_editor
|
|
137
136
|
end
|
|
138
137
|
|
|
139
138
|
def editor_with_args
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'zlib'
|
|
3
|
+
|
|
4
|
+
module Doing
|
|
5
|
+
module Util
|
|
6
|
+
## Backup utils
|
|
7
|
+
module Backup
|
|
8
|
+
extend self
|
|
9
|
+
include Util
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
## Delete all but most recent 5 backups
|
|
13
|
+
##
|
|
14
|
+
## @param limit Maximum number of backups to retain
|
|
15
|
+
##
|
|
16
|
+
def prune_backups(filename, limit = 10)
|
|
17
|
+
backups = get_backups(filename)
|
|
18
|
+
return unless backups.count > limit
|
|
19
|
+
|
|
20
|
+
backups[limit..-1].each do |file|
|
|
21
|
+
FileUtils.rm(File.join(backup_dir, file))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
## Restore the most recent backup. If a filename is
|
|
27
|
+
## provided, only backups of that filename will be used.
|
|
28
|
+
##
|
|
29
|
+
## @param filename The filename to restore, if
|
|
30
|
+
## different from default
|
|
31
|
+
##
|
|
32
|
+
def restore_last_backup(filename = nil, count: 1)
|
|
33
|
+
Doing.logger.benchmark(:restore_backup, :start)
|
|
34
|
+
filename ||= Doing.config.settings['doing_file']
|
|
35
|
+
|
|
36
|
+
result = get_backups(filename).slice(count - 1)
|
|
37
|
+
raise DoingRuntimeError, 'End of undo history' if result.nil?
|
|
38
|
+
|
|
39
|
+
backup_file = File.join(backup_dir, result)
|
|
40
|
+
|
|
41
|
+
save_undone(filename)
|
|
42
|
+
FileUtils.mv(backup_file, filename)
|
|
43
|
+
prune_backups_after(File.basename(backup_file))
|
|
44
|
+
Doing.logger.warn('File update:', "restored from #{result}")
|
|
45
|
+
Doing.logger.benchmark(:restore_backup, :finish)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
## Undo last undo
|
|
50
|
+
##
|
|
51
|
+
## @param filename The filename
|
|
52
|
+
##
|
|
53
|
+
def redo_backup(filename = nil, count: 1)
|
|
54
|
+
filename ||= Doing.config.settings['doing_file']
|
|
55
|
+
# redo_file = File.join(backup_dir, "undone___#{File.basename(filename)}")
|
|
56
|
+
undones = Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).sort
|
|
57
|
+
total = undones.count
|
|
58
|
+
count = total if count > total
|
|
59
|
+
|
|
60
|
+
skipped = undones.slice!(0, count)
|
|
61
|
+
undone = skipped.pop
|
|
62
|
+
|
|
63
|
+
raise DoingRuntimeError, 'End of redo history' if undone.nil?
|
|
64
|
+
|
|
65
|
+
redo_file = File.join(backup_dir, undone)
|
|
66
|
+
|
|
67
|
+
FileUtils.move(redo_file, filename)
|
|
68
|
+
|
|
69
|
+
skipped.each do |f|
|
|
70
|
+
FileUtils.mv(File.join(backup_dir, f), File.join(backup_dir, f.sub(/^undone/, '')))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Doing.logger.warn('File update:', "restored undo step #{count}/#{total}")
|
|
74
|
+
Doing.logger.debug('Backup:', "#{total - skipped.count - 1} redos remaining")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clear_undone(filename = nil)
|
|
78
|
+
filename ||= Doing.config.settings['doing_file']
|
|
79
|
+
# redo_file = File.join(backup_dir, "undone___#{File.basename(filename)}")
|
|
80
|
+
Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).each do |f|
|
|
81
|
+
FileUtils.rm(File.join(backup_dir, f))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
## Select from recent undos. If a filename is
|
|
87
|
+
## provided, only backups of that filename will be used.
|
|
88
|
+
##
|
|
89
|
+
## @param filename The filename to restore
|
|
90
|
+
##
|
|
91
|
+
def select_redo(filename = nil)
|
|
92
|
+
filename ||= Doing.config.settings['doing_file']
|
|
93
|
+
|
|
94
|
+
undones = Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).sort
|
|
95
|
+
|
|
96
|
+
raise DoingRuntimeError, 'End of redo history' if undones.empty?
|
|
97
|
+
|
|
98
|
+
total = undones.count
|
|
99
|
+
options = undones.each_with_object([]) do |file, arr|
|
|
100
|
+
d, _base = date_of_backup(file)
|
|
101
|
+
next if d.nil?
|
|
102
|
+
|
|
103
|
+
arr.push("#{d.time_ago}\t#{File.join(backup_dir, file)}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
backup_file = show_menu(options, filename)
|
|
107
|
+
idx = undones.index(File.basename(backup_file))
|
|
108
|
+
skipped = undones.slice!(idx, undones.count - idx)
|
|
109
|
+
undone = skipped.shift
|
|
110
|
+
|
|
111
|
+
redo_file = File.join(backup_dir, undone)
|
|
112
|
+
|
|
113
|
+
FileUtils.move(redo_file, filename)
|
|
114
|
+
|
|
115
|
+
skipped.each do |f|
|
|
116
|
+
FileUtils.mv(File.join(backup_dir, f), File.join(backup_dir, f.sub(/^undone/, '')))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Doing.logger.warn('File update:', "restored undo step #{idx}/#{total}")
|
|
120
|
+
Doing.logger.debug('Backup:', "#{total - skipped.count - 1} redos remaining")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
## Select from recent backups. If a filename is
|
|
125
|
+
## provided, only backups of that filename will be used.
|
|
126
|
+
##
|
|
127
|
+
## @param filename The filename to restore
|
|
128
|
+
##
|
|
129
|
+
def select_backup(filename = nil)
|
|
130
|
+
filename ||= Doing.config.settings['doing_file']
|
|
131
|
+
|
|
132
|
+
options = get_backups(filename).each_with_object([]) do |file, arr|
|
|
133
|
+
d, _base = date_of_backup(file)
|
|
134
|
+
next if d.nil?
|
|
135
|
+
arr.push("#{d.time_ago}\t#{File.join(backup_dir, file)}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
backup_file = show_menu(options, filename)
|
|
139
|
+
write_to_file(File.join(backup_dir, "undone___#{File.basename(filename)}"), IO.read(filename), backup: false)
|
|
140
|
+
FileUtils.mv(backup_file, filename)
|
|
141
|
+
prune_backups_after(File.basename(backup_file))
|
|
142
|
+
Doing.logger.warn('File update:', "restored from #{backup_file}")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def show_menu(options, filename)
|
|
146
|
+
if TTY::Which.which('colordiff')
|
|
147
|
+
preview = 'colordiff -U 1'
|
|
148
|
+
pipe = '| awk "(NR>2)"'
|
|
149
|
+
elsif TTY::Which.which('git')
|
|
150
|
+
preview = 'git --no-pager diff -U1 --color=always --minimal --word-diff'
|
|
151
|
+
pipe = ' | awk "(NR>4)"'
|
|
152
|
+
else
|
|
153
|
+
preview = 'diff -U 1'
|
|
154
|
+
pipe = if TTY::Which.which('delta')
|
|
155
|
+
' | delta --no-gitconfig --syntax-theme=1337'
|
|
156
|
+
elsif TTY::Which.which('diff-so-fancy')
|
|
157
|
+
' | diff-so-fancy'
|
|
158
|
+
elsif TTY::Which.which('ydiff')
|
|
159
|
+
' | ydiff -c always --wrap < /dev/tty'
|
|
160
|
+
else
|
|
161
|
+
cmd = 'sed -e "s/^-/`echo -e "\033[31m"`-/;s/^+/`echo -e "\033[32m"`+/;s/^@/`echo -e "\033[34m"`@/;s/\$/`echo -e "\033[0m"`/"'
|
|
162
|
+
"| bash -c #{Shellwords.escape(cmd)}"
|
|
163
|
+
end
|
|
164
|
+
pipe += ' | awk "(NR>2)"'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
result = Doing::Prompt.choose_from(options,
|
|
168
|
+
prompt: 'Select a backup to restore',
|
|
169
|
+
sorted: false,
|
|
170
|
+
fzf_args: [
|
|
171
|
+
'--delimiter="\t"',
|
|
172
|
+
'--with-nth=1',
|
|
173
|
+
%(--preview='#{preview} "#{filename}" {2} #{pipe}'),
|
|
174
|
+
'--disabled',
|
|
175
|
+
'--height=10',
|
|
176
|
+
'--preview-window="right,70%,nowrap,follow"',
|
|
177
|
+
'--header="Select a revision to restore"'
|
|
178
|
+
])
|
|
179
|
+
raise UserCancelled unless result
|
|
180
|
+
|
|
181
|
+
result.strip.split(/\t/).last
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
##
|
|
185
|
+
## Writes a copy of the content to a dated backup file
|
|
186
|
+
## in a hidden directory
|
|
187
|
+
##
|
|
188
|
+
## @param content The data to back up
|
|
189
|
+
##
|
|
190
|
+
def write_backup(filename = nil)
|
|
191
|
+
Doing.logger.benchmark(:_write_backup, :start)
|
|
192
|
+
filename ||= Doing.config.settings['doing_file']
|
|
193
|
+
|
|
194
|
+
unless File.exist?(filename)
|
|
195
|
+
Doing.logger.debug('Backup:', "original file doesn't exist (#{filename})")
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
backup_file = File.join(backup_dir, "#{timestamp_filename}___#{File.basename(filename)}")
|
|
200
|
+
# compressed = Zlib::Deflate.deflate(content)
|
|
201
|
+
# Zlib::GzipWriter.open(backup_file + '.gz') do |gz|
|
|
202
|
+
# gz.write(IO.read(filename))
|
|
203
|
+
# end
|
|
204
|
+
|
|
205
|
+
FileUtils.cp(filename, backup_file)
|
|
206
|
+
|
|
207
|
+
prune_backups(filename, 15)
|
|
208
|
+
clear_undone(filename)
|
|
209
|
+
Doing.logger.benchmark(:_write_backup, :finish)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def timestamp_filename
|
|
215
|
+
Time.now.strftime('%Y-%m-%d_%H.%M.%S')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def get_backups(filename = nil)
|
|
219
|
+
filename ||= Doing.config.settings['doing_file']
|
|
220
|
+
backups = Dir.glob("*___#{File.basename(filename)}", base: backup_dir).sort.reverse
|
|
221
|
+
backups.delete_if { |f| f =~ /^undone/ }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def save_undone(filename = nil)
|
|
225
|
+
filename ||= Doing.config.settings['doing_file']
|
|
226
|
+
undone_file = File.join(backup_dir, "undone#{timestamp_filename}___#{File.basename(filename)}")
|
|
227
|
+
FileUtils.cp(filename, undone_file)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
##
|
|
231
|
+
## Retrieve date from backup filename
|
|
232
|
+
##
|
|
233
|
+
## @param filename The filename
|
|
234
|
+
##
|
|
235
|
+
def date_of_backup(filename)
|
|
236
|
+
m = filename.match(/^(?:undone)?(?<date>\d{4}-\d{2}-\d{2})_(?<time>\d{2}\.\d{2}\.\d{2})___(?<file>.*?)$/)
|
|
237
|
+
return nil if m.nil?
|
|
238
|
+
|
|
239
|
+
[Time.parse("#{m['date']} #{m['time'].gsub(/\./, ':')}"), m['file']]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
##
|
|
243
|
+
## Return a location for storing backups, creating if needed
|
|
244
|
+
##
|
|
245
|
+
## @return Path to backup directory
|
|
246
|
+
##
|
|
247
|
+
def backup_dir
|
|
248
|
+
@backup_dir ||= create_backup_dir
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def create_backup_dir
|
|
252
|
+
dir = File.expand_path(Doing.config.settings['backup_dir']) || File.join(user_home, '.doing_backup')
|
|
253
|
+
if File.exist?(dir) && !File.directory?(dir)
|
|
254
|
+
raise DoingRuntimeError, "Backup error: #{dir} is not a directory"
|
|
255
|
+
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
unless File.exist?(dir)
|
|
259
|
+
FileUtils.mkdir_p(dir)
|
|
260
|
+
Doing.logger.warn('Backup:', "backup directory created at #{dir}")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
dir
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
##
|
|
267
|
+
## Delete backups newer than selected filename
|
|
268
|
+
##
|
|
269
|
+
## @param filename The filename
|
|
270
|
+
##
|
|
271
|
+
def prune_backups_after(filename)
|
|
272
|
+
target_date, base = date_of_backup(filename)
|
|
273
|
+
return if target_date.nil?
|
|
274
|
+
|
|
275
|
+
counter = 0
|
|
276
|
+
get_backups(base).each do |file|
|
|
277
|
+
date, _base = date_of_backup(file)
|
|
278
|
+
if date && target_date < date
|
|
279
|
+
FileUtils.mv(File.join(backup_dir, file), File.join(backup_dir, "undone#{file}"))
|
|
280
|
+
counter += 1
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
Doing.logger.debug('Backup:', "deleted #{counter} files newer than restored backup")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
data/lib/doing/version.rb
CHANGED