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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +13 -9
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +42 -10
  6. data/Gemfile.lock +23 -1
  7. data/README.md +1 -1
  8. data/Rakefile +2 -0
  9. data/bin/doing +421 -156
  10. data/doc/Array.html +1 -1
  11. data/doc/Doing/Color.html +1 -1
  12. data/doc/Doing/Completion.html +1 -1
  13. data/doc/Doing/Configuration.html +81 -90
  14. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  15. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  16. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  17. data/doc/Doing/Errors/EmptyInput.html +1 -1
  18. data/doc/Doing/Errors/NoResults.html +1 -1
  19. data/doc/Doing/Errors/PluginException.html +1 -1
  20. data/doc/Doing/Errors/UserCancelled.html +1 -1
  21. data/doc/Doing/Errors/WrongCommand.html +1 -1
  22. data/doc/Doing/Errors.html +1 -1
  23. data/doc/Doing/Hooks.html +1 -1
  24. data/doc/Doing/Item.html +84 -20
  25. data/doc/Doing/Items.html +35 -1
  26. data/doc/Doing/LogAdapter.html +1 -1
  27. data/doc/Doing/Note.html +1 -1
  28. data/doc/Doing/Pager.html +1 -1
  29. data/doc/Doing/Plugins.html +1 -1
  30. data/doc/Doing/Prompt.html +70 -18
  31. data/doc/Doing/Section.html +1 -1
  32. data/doc/Doing/Util.html +16 -4
  33. data/doc/Doing/WWID.html +27 -147
  34. data/doc/Doing.html +3 -3
  35. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  36. data/doc/GLI/Commands.html +1 -1
  37. data/doc/GLI.html +1 -1
  38. data/doc/Hash.html +1 -1
  39. data/doc/Status.html +1 -1
  40. data/doc/String.html +344 -4
  41. data/doc/Symbol.html +1 -1
  42. data/doc/Time.html +70 -2
  43. data/doc/_index.html +125 -4
  44. data/doc/class_list.html +1 -1
  45. data/doc/file.README.html +2 -2
  46. data/doc/index.html +2 -2
  47. data/doc/method_list.html +537 -193
  48. data/doc/top-level-namespace.html +2 -2
  49. data/doing.gemspec +2 -0
  50. data/doing.rdoc +276 -75
  51. data/lib/completion/doing.bash +20 -20
  52. data/lib/doing/boolean_term_parser.rb +86 -0
  53. data/lib/doing/configuration.rb +36 -19
  54. data/lib/doing/item.rb +102 -9
  55. data/lib/doing/items.rb +6 -0
  56. data/lib/doing/phrase_parser.rb +124 -0
  57. data/lib/doing/plugins/export/template_export.rb +29 -2
  58. data/lib/doing/prompt.rb +21 -11
  59. data/lib/doing/string.rb +47 -3
  60. data/lib/doing/string_chronify.rb +85 -0
  61. data/lib/doing/time.rb +32 -0
  62. data/lib/doing/util.rb +2 -5
  63. data/lib/doing/util_backup.rb +235 -0
  64. data/lib/doing/version.rb +1 -1
  65. data/lib/doing/wwid.rb +224 -124
  66. data/lib/doing.rb +7 -0
  67. 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! { |line| line.chomp.gsub(/(.{1,#{width}})(\s+|\Z)/, "\\1\n") }
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 "#{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 "#{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 [Hash] options
129
- ## @param include_section [Boolean] include section
138
+ ## @param opt Additional options
130
139
  ##
131
- ## @option opt [String] :header
132
- ## @option opt [String] :prompt
133
- ## @option opt [String] :query
134
- ## @option opt [Boolean] :show_if_single
135
- ## @option opt [Boolean] :menu
136
- ## @option opt [Boolean] :sort
137
- ## @option opt [Boolean] :multiple
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 line.join(' ').uncolor.length + word.uncolor.length + 1 > len
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 { |t| t.strip.sub(/^@/, '') }
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
- if File.exist?(file) && backup
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 = find_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
@@ -1,3 +1,3 @@
1
1
  module Doing
2
- VERSION = '2.1.0pre'
2
+ VERSION = '2.1.4pre'
3
3
  end