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.
- checksums.yaml +4 -4
- data/.yardoc/checksums +13 -10
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile.lock +23 -1
- data/README.md +1 -1
- data/bin/doing +253 -63
- 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 +42 -1
- 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 +37 -3
- 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 +35 -1
- data/doc/Doing/Section.html +1 -1
- data/doc/Doing/Util.html +16 -4
- data/doc/Doing/WWID.html +131 -71
- 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 +104 -2
- 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 +480 -144
- data/doc/top-level-namespace.html +2 -2
- data/doing.gemspec +2 -0
- data/doing.rdoc +155 -66
- data/lib/doing/boolean_term_parser.rb +86 -0
- data/lib/doing/configuration.rb +13 -4
- data/lib/doing/item.rb +94 -8
- data/lib/doing/items.rb +6 -0
- data/lib/doing/phrase_parser.rb +124 -0
- data/lib/doing/prompt.rb +8 -0
- data/lib/doing/string.rb +16 -2
- data/lib/doing/string_chronify.rb +5 -1
- 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 +81 -26
- data/lib/doing.rb +6 -0
- 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
|
201
|
-
|
202
|
-
|
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
|
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.
|
401
|
+
tags.map { |t| t.strip.add_at }
|
316
402
|
end
|
317
403
|
end
|
318
404
|
end
|
data/lib/doing/items.rb
CHANGED
@@ -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
|
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, {
|
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
|
-
|
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