doing 2.1.3 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +13 -10
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +27 -0
  6. data/Gemfile.lock +23 -1
  7. data/README.md +1 -1
  8. data/bin/doing +253 -63
  9. data/doc/Array.html +1 -1
  10. data/doc/Doing/Color.html +1 -1
  11. data/doc/Doing/Completion.html +1 -1
  12. data/doc/Doing/Configuration.html +42 -1
  13. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  14. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  15. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  16. data/doc/Doing/Errors/EmptyInput.html +1 -1
  17. data/doc/Doing/Errors/NoResults.html +1 -1
  18. data/doc/Doing/Errors/PluginException.html +1 -1
  19. data/doc/Doing/Errors/UserCancelled.html +1 -1
  20. data/doc/Doing/Errors/WrongCommand.html +1 -1
  21. data/doc/Doing/Errors.html +1 -1
  22. data/doc/Doing/Hooks.html +1 -1
  23. data/doc/Doing/Item.html +37 -3
  24. data/doc/Doing/Items.html +35 -1
  25. data/doc/Doing/LogAdapter.html +1 -1
  26. data/doc/Doing/Note.html +1 -1
  27. data/doc/Doing/Pager.html +1 -1
  28. data/doc/Doing/Plugins.html +1 -1
  29. data/doc/Doing/Prompt.html +35 -1
  30. data/doc/Doing/Section.html +1 -1
  31. data/doc/Doing/Util.html +16 -4
  32. data/doc/Doing/WWID.html +131 -71
  33. data/doc/Doing.html +3 -3
  34. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  35. data/doc/GLI/Commands.html +1 -1
  36. data/doc/GLI.html +1 -1
  37. data/doc/Hash.html +1 -1
  38. data/doc/Status.html +1 -1
  39. data/doc/String.html +104 -2
  40. data/doc/Symbol.html +1 -1
  41. data/doc/Time.html +70 -2
  42. data/doc/_index.html +125 -4
  43. data/doc/class_list.html +1 -1
  44. data/doc/file.README.html +2 -2
  45. data/doc/index.html +2 -2
  46. data/doc/method_list.html +480 -144
  47. data/doc/top-level-namespace.html +2 -2
  48. data/doing.gemspec +2 -0
  49. data/doing.rdoc +155 -66
  50. data/lib/doing/boolean_term_parser.rb +86 -0
  51. data/lib/doing/configuration.rb +13 -4
  52. data/lib/doing/item.rb +94 -8
  53. data/lib/doing/items.rb +6 -0
  54. data/lib/doing/phrase_parser.rb +124 -0
  55. data/lib/doing/prompt.rb +8 -0
  56. data/lib/doing/string.rb +16 -2
  57. data/lib/doing/string_chronify.rb +5 -1
  58. data/lib/doing/time.rb +32 -0
  59. data/lib/doing/util.rb +2 -5
  60. data/lib/doing/util_backup.rb +235 -0
  61. data/lib/doing/version.rb +1 -1
  62. data/lib/doing/wwid.rb +81 -26
  63. data/lib/doing.rb +6 -0
  64. metadata +47 -4
data/lib/doing/item.rb CHANGED
@@ -172,6 +172,13 @@ module Doing
172
172
  ## @return [Boolean] true if tag/bool combination passes
173
173
  ##
174
174
  def tags?(tags, bool = :and, negate: false)
175
+ if bool == :pattern
176
+ tags = tags.join(' ') if tags.is_a?(Array)
177
+ matches = tag_pattern?(tags.gsub(/ *, */, ' '))
178
+
179
+ return negate ? !matches : matches
180
+ end
181
+
175
182
  tags = split_tags(tags)
176
183
  bool = bool.normalize_bool
177
184
 
@@ -186,6 +193,10 @@ module Doing
186
193
  negate ? !matches : matches
187
194
  end
188
195
 
196
+ def ignore_case(search, case_type)
197
+ (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
198
+ end
199
+
189
200
  ##
190
201
  ## Test if item matches search string
191
202
  ##
@@ -197,9 +208,25 @@ module Doing
197
208
  ##
198
209
  ## @return [Boolean] matches search criteria
199
210
  ##
200
- def search(search, distance: 3, negate: false, case_type: :smart, fuzzy: false)
201
- text = @title + @note.to_s
202
- matches = text =~ search.to_rx(distance: distance, case_type: case_type)
211
+ def search(search, distance: 3, negate: false, case_type: :smart)
212
+ if search.is_rx?
213
+ matches = @title + @note.to_s =~ search.to_rx(distance: distance, case_type: case_type)
214
+ else
215
+ query = to_phrase_query(search.strip)
216
+
217
+ if query[:must].nil? && query[:must_not].nil?
218
+ query[:must] = query[:should]
219
+ query[:should] = []
220
+ end
221
+ matches = no_searches?(query[:must_not], case_type: case_type)
222
+ matches &&= all_searches?(query[:must], case_type: case_type)
223
+ matches &&= any_searches?(query[:should], case_type: case_type)
224
+ end
225
+ # if search =~ /(?<=\A| )[+-]\S/
226
+ # else
227
+ # text = @title + @note.to_s
228
+ # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
229
+ # end
203
230
 
204
231
  # if search.is_rx? || !fuzzy
205
232
  # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
@@ -286,33 +313,92 @@ module Doing
286
313
  start = @date
287
314
 
288
315
  t = (done - start).to_i
289
- t > 0 ? t : nil
316
+ t.positive? ? t : nil
317
+ end
318
+
319
+ def all_searches?(searches, case_type: :smart)
320
+ return true if searches.nil? || searches.empty?
321
+
322
+ text = @title + @note.to_s
323
+ searches.each do |s|
324
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
325
+ return false unless text =~ rx
326
+ end
327
+ true
328
+ end
329
+
330
+ def no_searches?(searches, case_type: :smart)
331
+ return true if searches.nil? || searches.empty?
332
+
333
+ text = @title + @note.to_s
334
+ searches.each do |s|
335
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
336
+ return false if text =~ rx
337
+ end
338
+ true
339
+ end
340
+
341
+ def any_searches?(searches, case_type: :smart)
342
+ return true if searches.nil? || searches.empty?
343
+
344
+ text = @title + @note.to_s
345
+ searches.each do |s|
346
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
347
+ return true if text =~ rx
348
+ end
349
+ false
290
350
  end
291
351
 
292
352
  def all_tags?(tags)
353
+ return true if tags.nil? || tags.empty?
354
+
293
355
  tags.each do |tag|
294
- return false unless @title =~ /@#{tag}/
356
+ return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
295
357
  end
296
358
  true
297
359
  end
298
360
 
299
361
  def no_tags?(tags)
362
+ return true if tags.nil? || tags.empty?
363
+
300
364
  tags.each do |tag|
301
- return false if @title =~ /@#{tag}/
365
+ return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
302
366
  end
303
367
  true
304
368
  end
305
369
 
306
370
  def any_tags?(tags)
371
+ return true if tags.nil? || tags.empty?
372
+
307
373
  tags.each do |tag|
308
- return true if @title =~ /@#{tag}/
374
+ return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
309
375
  end
310
376
  false
311
377
  end
312
378
 
379
+ def to_query(query)
380
+ parser = BooleanTermParser::QueryParser.new
381
+ transformer = BooleanTermParser::QueryTransformer.new
382
+ parse_tree = parser.parse(query)
383
+ transformer.apply(parse_tree).to_elasticsearch
384
+ end
385
+
386
+ def to_phrase_query(query)
387
+ parser = PhraseParser::QueryParser.new
388
+ transformer = PhraseParser::QueryTransformer.new
389
+ parse_tree = parser.parse(query)
390
+ transformer.apply(parse_tree).to_elasticsearch
391
+ end
392
+
393
+ def tag_pattern?(tags)
394
+ query = to_query(tags)
395
+
396
+ no_tags?(query[:must_not]) && all_tags?(query[:must]) && any_tags?(query[:should])
397
+ end
398
+
313
399
  def split_tags(tags)
314
400
  tags = tags.split(/ *, */) if tags.is_a? String
315
- tags.map { |t| t.strip.sub(/^@/, '') }
401
+ tags.map { |t| t.strip.add_at }
316
402
  end
317
403
  end
318
404
  end
data/lib/doing/items.rb CHANGED
@@ -105,6 +105,12 @@ module Doing
105
105
  new_item
106
106
  end
107
107
 
108
+ def all_tags
109
+ each_with_object([]) do |entry, tags|
110
+ tags.concat(entry.tags).sort!.uniq!
111
+ end
112
+ end
113
+
108
114
  # Output sections and items in Doing file format
109
115
  def to_s
110
116
  out = []
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module PhraseParser
6
+ # This parser adds quoted phrases (using matched double quotes) in addition to
7
+ # terms. This is done creating multiple types of clauses instead of just one.
8
+ # A phrase clause generates an Elasticsearch match_phrase query.
9
+ class QueryParser < Parslet::Parser
10
+ rule(:term) { match('[^\s"]').repeat(1).as(:term) }
11
+ rule(:quote) { str('"') }
12
+ rule(:operator) { (str('+') | str('-')).as(:operator) }
13
+ rule(:phrase) do
14
+ (quote >> (term >> space.maybe).repeat >> quote).as(:phrase)
15
+ end
16
+ rule(:clause) { (operator.maybe >> (phrase | term)).as(:clause) }
17
+ rule(:space) { match('\s').repeat(1) }
18
+ rule(:query) { (clause >> space.maybe).repeat.as(:query) }
19
+ root(:query)
20
+ end
21
+
22
+ class QueryTransformer < Parslet::Transform
23
+ rule(:clause => subtree(:clause)) do
24
+ if clause[:term]
25
+ TermClause.new(clause[:operator]&.to_s, clause[:term].to_s)
26
+ elsif clause[:phrase]
27
+ phrase = clause[:phrase].map { |p| p[:term].to_s }.join(' ')
28
+ PhraseClause.new(clause[:operator]&.to_s, phrase)
29
+ else
30
+ raise "Unexpected clause type: '#{clause}'"
31
+ end
32
+ end
33
+ rule(query: sequence(:clauses)) { Query.new(clauses) }
34
+ end
35
+
36
+ class Operator
37
+ def self.symbol(str)
38
+ case str
39
+ when '+'
40
+ :must
41
+ when '-'
42
+ :must_not
43
+ when nil
44
+ :should
45
+ else
46
+ raise "Unknown operator: #{str}"
47
+ end
48
+ end
49
+ end
50
+
51
+ class TermClause
52
+ attr_accessor :operator, :term
53
+
54
+ def initialize(operator, term)
55
+ self.operator = Operator.symbol(operator)
56
+ self.term = term
57
+ end
58
+ end
59
+
60
+ # Phrase
61
+ class PhraseClause
62
+ attr_accessor :operator, :phrase
63
+
64
+ def initialize(operator, phrase)
65
+ self.operator = Operator.symbol(operator)
66
+ self.phrase = phrase
67
+ end
68
+ end
69
+
70
+ ## Query object
71
+ class Query
72
+ attr_accessor :should_clauses, :must_not_clauses, :must_clauses
73
+
74
+ def initialize(clauses)
75
+ grouped = clauses.chunk(&:operator).to_h
76
+ self.should_clauses = grouped.fetch(:should, [])
77
+ self.must_not_clauses = grouped.fetch(:must_not, [])
78
+ self.must_clauses = grouped.fetch(:must, [])
79
+ end
80
+
81
+ def to_elasticsearch
82
+ query = {}
83
+
84
+ if should_clauses.any?
85
+ query[:should] = should_clauses.map do |clause|
86
+ clause_to_query(clause)
87
+ end
88
+ end
89
+
90
+ if must_clauses.any?
91
+ query[:must] = must_clauses.map do |clause|
92
+ clause_to_query(clause)
93
+ end
94
+ end
95
+
96
+ if must_not_clauses.any?
97
+ query[:must_not] = must_not_clauses.map do |clause|
98
+ clause_to_query(clause)
99
+ end
100
+ end
101
+
102
+ query
103
+ end
104
+
105
+ def clause_to_query(clause)
106
+ case clause
107
+ when TermClause
108
+ match(clause.term)
109
+ when PhraseClause
110
+ match_phrase(clause.phrase)
111
+ else
112
+ raise "Unknown clause type: #{clause}"
113
+ end
114
+ end
115
+
116
+ def match(term)
117
+ term
118
+ end
119
+
120
+ def match_phrase(phrase)
121
+ phrase
122
+ end
123
+ end
124
+ end
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
  ##
data/lib/doing/string.rb CHANGED
@@ -240,6 +240,10 @@ module Doing
240
240
  end
241
241
  end
242
242
 
243
+ def pluralize(number)
244
+ number == 1 ? self : "#{self}s"
245
+ end
246
+
243
247
  ##
244
248
  ## Convert a sort order string to a qualified type
245
249
  ##
@@ -299,6 +303,8 @@ module Doing
299
303
  :or
300
304
  when /(not|none)/i
301
305
  :not
306
+ when /^p/i
307
+ :pattern
302
308
  else
303
309
  default.is_a?(Symbol) ? default : default.normalize_bool
304
310
  end
@@ -312,8 +318,16 @@ module Doing
312
318
  gsub(/\((?!\?:)/, '(?:').downcase
313
319
  end
314
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
+
315
329
  def to_tags
316
- gsub(/ *, */, ' ').gsub(/ +/, ' ').split(/ /).sort.uniq.map { |t| t.strip.sub(/^@/, '') }
330
+ gsub(/ *, */, ' ').gsub(/ +/, ' ').split(/ /).sort.uniq.map(&:add_at)
317
331
  end
318
332
 
319
333
  def add_tags!(tags, remove: false)
@@ -533,7 +547,7 @@ module Doing
533
547
  end
534
548
  else
535
549
  case self
536
- when / *, */
550
+ when /(^\[.*?\]$| *, *)/
537
551
  gsub(/^\[ *| *\]$/, '').split(/ *, */)
538
552
  when /^[0-9]+$/
539
553
  to_i
@@ -41,7 +41,11 @@ module Doing
41
41
  if secs_ago
42
42
  now - secs_ago
43
43
  else
44
- Chronic.parse(self, { guess: options.fetch(:guess, :begin), context: options.fetch(:future, false) ? :future : :past, ambiguous_time_range: 8 })
44
+ Chronic.parse(self, {
45
+ guess: options.fetch(:guess, :begin),
46
+ context: options.fetch(:future, false) ? :future : :past,
47
+ ambiguous_time_range: 8
48
+ })
45
49
  end
46
50
  end
47
51
 
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.3'
2
+ VERSION = '2.1.4pre'
3
3
  end