doing 2.1.0pre → 2.1.4pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.yardoc/checksums +13 -9
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/CHANGELOG.md +42 -10
- data/Gemfile.lock +23 -1
- data/README.md +1 -1
- data/Rakefile +2 -0
- data/bin/doing +421 -156
- data/doc/Array.html +1 -1
- data/doc/Doing/Color.html +1 -1
- data/doc/Doing/Completion.html +1 -1
- data/doc/Doing/Configuration.html +81 -90
- data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
- data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
- data/doc/Doing/Errors/DoingStandardError.html +1 -1
- data/doc/Doing/Errors/EmptyInput.html +1 -1
- data/doc/Doing/Errors/NoResults.html +1 -1
- data/doc/Doing/Errors/PluginException.html +1 -1
- data/doc/Doing/Errors/UserCancelled.html +1 -1
- data/doc/Doing/Errors/WrongCommand.html +1 -1
- data/doc/Doing/Errors.html +1 -1
- data/doc/Doing/Hooks.html +1 -1
- data/doc/Doing/Item.html +84 -20
- data/doc/Doing/Items.html +35 -1
- data/doc/Doing/LogAdapter.html +1 -1
- data/doc/Doing/Note.html +1 -1
- data/doc/Doing/Pager.html +1 -1
- data/doc/Doing/Plugins.html +1 -1
- data/doc/Doing/Prompt.html +70 -18
- data/doc/Doing/Section.html +1 -1
- data/doc/Doing/Util.html +16 -4
- data/doc/Doing/WWID.html +27 -147
- data/doc/Doing.html +3 -3
- data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
- data/doc/GLI/Commands.html +1 -1
- data/doc/GLI.html +1 -1
- data/doc/Hash.html +1 -1
- data/doc/Status.html +1 -1
- data/doc/String.html +344 -4
- data/doc/Symbol.html +1 -1
- data/doc/Time.html +70 -2
- data/doc/_index.html +125 -4
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +2 -2
- data/doc/index.html +2 -2
- data/doc/method_list.html +537 -193
- data/doc/top-level-namespace.html +2 -2
- data/doing.gemspec +2 -0
- data/doing.rdoc +276 -75
- data/lib/completion/doing.bash +20 -20
- data/lib/doing/boolean_term_parser.rb +86 -0
- data/lib/doing/configuration.rb +36 -19
- data/lib/doing/item.rb +102 -9
- data/lib/doing/items.rb +6 -0
- data/lib/doing/phrase_parser.rb +124 -0
- data/lib/doing/plugins/export/template_export.rb +29 -2
- data/lib/doing/prompt.rb +21 -11
- data/lib/doing/string.rb +47 -3
- data/lib/doing/string_chronify.rb +85 -0
- data/lib/doing/time.rb +32 -0
- data/lib/doing/util.rb +2 -5
- data/lib/doing/util_backup.rb +235 -0
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid.rb +224 -124
- data/lib/doing.rb +7 -0
- metadata +46 -2
data/lib/completion/doing.bash
CHANGED
@@ -101,9 +101,9 @@ _doing_finish() {
|
|
101
101
|
_doing_grep() {
|
102
102
|
|
103
103
|
if [[ "$token" == --* ]]; then
|
104
|
-
COMPREPLY=( $( compgen -W '--after --before --case --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
|
104
|
+
COMPREPLY=( $( compgen -W '--after --before --case --duration --from --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
|
105
105
|
elif [[ "$token" == -* ]]; then
|
106
|
-
COMPREPLY=( $( compgen -W '-i -o -s -t -x --after --before --case --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
|
106
|
+
COMPREPLY=( $( compgen -W '-i -o -s -t -x --after --before --case --duration --from --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
|
107
107
|
|
108
108
|
fi
|
109
109
|
}
|
@@ -131,9 +131,9 @@ _doing_import() {
|
|
131
131
|
_doing_last() {
|
132
132
|
|
133
133
|
if [[ "$token" == --* ]]; then
|
134
|
-
COMPREPLY=( $( compgen -W '--bool --case --editor --not --section --search --tag --exact' -- $token ) )
|
134
|
+
COMPREPLY=( $( compgen -W '--bool --case --duration --editor --not --section --search --tag --exact' -- $token ) )
|
135
135
|
elif [[ "$token" == -* ]]; then
|
136
|
-
COMPREPLY=( $( compgen -W '-e -s -x --bool --case --editor --not --section --search --tag --exact' -- $token ) )
|
136
|
+
COMPREPLY=( $( compgen -W '-e -s -x --bool --case --duration --editor --not --section --search --tag --exact' -- $token ) )
|
137
137
|
|
138
138
|
fi
|
139
139
|
}
|
@@ -191,9 +191,9 @@ _doing_now() {
|
|
191
191
|
_doing_on() {
|
192
192
|
|
193
193
|
if [[ "$token" == --* ]]; then
|
194
|
-
COMPREPLY=( $( compgen -W '--output --section --times --tag_sort --totals' -- $token ) )
|
194
|
+
COMPREPLY=( $( compgen -W '--duration --output --section --times --tag_sort --totals' -- $token ) )
|
195
195
|
elif [[ "$token" == -* ]]; then
|
196
|
-
COMPREPLY=( $( compgen -W '-o -s -t --output --section --times --tag_sort --totals' -- $token ) )
|
196
|
+
COMPREPLY=( $( compgen -W '-o -s -t --duration --output --section --times --tag_sort --totals' -- $token ) )
|
197
197
|
|
198
198
|
fi
|
199
199
|
}
|
@@ -221,9 +221,9 @@ _doing_plugins() {
|
|
221
221
|
_doing_recent() {
|
222
222
|
|
223
223
|
if [[ "$token" == --* ]]; then
|
224
|
-
COMPREPLY=( $( compgen -W '--interactive --section --times --tag_sort --totals' -- $token ) )
|
224
|
+
COMPREPLY=( $( compgen -W '--duration --interactive --section --times --tag_sort --totals' -- $token ) )
|
225
225
|
elif [[ "$token" == -* ]]; then
|
226
|
-
COMPREPLY=( $( compgen -W '-i -s -t --interactive --section --times --tag_sort --totals' -- $token ) )
|
226
|
+
COMPREPLY=( $( compgen -W '-i -s -t --duration --interactive --section --times --tag_sort --totals' -- $token ) )
|
227
227
|
|
228
228
|
fi
|
229
229
|
}
|
@@ -261,9 +261,9 @@ _doing_sections() {
|
|
261
261
|
_doing_select() {
|
262
262
|
|
263
263
|
if [[ "$token" == --* ]]; then
|
264
|
-
COMPREPLY=( $( compgen -W '--archive --resume --cancel --case --delete --editor --finish --flag --force --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
|
264
|
+
COMPREPLY=( $( compgen -W '--archive --after --resume --before --cancel --case --delete --editor --finish --flag --force --from --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
|
265
265
|
elif [[ "$token" == -* ]]; then
|
266
|
-
COMPREPLY=( $( compgen -W '-a -c -d -e -f -m -o -r -s -t -x --archive --resume --cancel --case --delete --editor --finish --flag --force --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
|
266
|
+
COMPREPLY=( $( compgen -W '-a -c -d -e -f -m -o -r -s -t -x --archive --after --resume --before --cancel --case --delete --editor --finish --flag --force --from --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
|
267
267
|
|
268
268
|
fi
|
269
269
|
}
|
@@ -276,9 +276,9 @@ local words=$(doing sections)
|
|
276
276
|
IFS="$OLD_IFS"
|
277
277
|
|
278
278
|
if [[ "$token" == --* ]]; then
|
279
|
-
COMPREPLY=( $( compgen -W '--age --after --bool --before --count --case --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
279
|
+
COMPREPLY=( $( compgen -W '--age --after --bool --before --count --case --duration --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
280
280
|
elif [[ "$token" == -* ]]; then
|
281
|
-
COMPREPLY=( $( compgen -W '-a -b -c -
|
281
|
+
COMPREPLY=( $( compgen -W '-a -b -c -i -o -s -t -x --age --after --bool --before --count --case --duration --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
282
282
|
else
|
283
283
|
local nocasematchWasOff=0
|
284
284
|
shopt nocasematch >/dev/null || nocasematchWasOff=1
|
@@ -301,9 +301,9 @@ IFS="$OLD_IFS"
|
|
301
301
|
_doing_since() {
|
302
302
|
|
303
303
|
if [[ "$token" == --* ]]; then
|
304
|
-
COMPREPLY=( $( compgen -W '--output --section --times --tag_sort --totals' -- $token ) )
|
304
|
+
COMPREPLY=( $( compgen -W '--duration --output --section --times --tag_sort --totals' -- $token ) )
|
305
305
|
elif [[ "$token" == -* ]]; then
|
306
|
-
COMPREPLY=( $( compgen -W '-o -s -t --output --section --times --tag_sort --totals' -- $token ) )
|
306
|
+
COMPREPLY=( $( compgen -W '-o -s -t --duration --output --section --times --tag_sort --totals' -- $token ) )
|
307
307
|
|
308
308
|
fi
|
309
309
|
}
|
@@ -331,9 +331,9 @@ _doing_template() {
|
|
331
331
|
_doing_today() {
|
332
332
|
|
333
333
|
if [[ "$token" == --* ]]; then
|
334
|
-
COMPREPLY=( $( compgen -W '--after --before --output --section --times --tag_sort --totals' -- $token ) )
|
334
|
+
COMPREPLY=( $( compgen -W '--after --before --duration --from --output --section --times --tag_sort --totals' -- $token ) )
|
335
335
|
elif [[ "$token" == -* ]]; then
|
336
|
-
COMPREPLY=( $( compgen -W '-o -s -t --after --before --output --section --times --tag_sort --totals' -- $token ) )
|
336
|
+
COMPREPLY=( $( compgen -W '-o -s -t --after --before --duration --from --output --section --times --tag_sort --totals' -- $token ) )
|
337
337
|
|
338
338
|
fi
|
339
339
|
}
|
@@ -356,9 +356,9 @@ local words=$(doing views)
|
|
356
356
|
IFS="$OLD_IFS"
|
357
357
|
|
358
358
|
if [[ "$token" == --* ]]; then
|
359
|
-
COMPREPLY=( $( compgen -W '--after --bool --before --count --case --color --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
359
|
+
COMPREPLY=( $( compgen -W '--after --bool --before --count --case --color --duration --from --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
360
360
|
elif [[ "$token" == -* ]]; then
|
361
|
-
COMPREPLY=( $( compgen -W '-b -c -i -o -s -t -x --after --bool --before --count --case --color --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
361
|
+
COMPREPLY=( $( compgen -W '-b -c -i -o -s -t -x --after --bool --before --count --case --color --duration --from --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
|
362
362
|
else
|
363
363
|
local nocasematchWasOff=0
|
364
364
|
shopt nocasematch >/dev/null || nocasematchWasOff=1
|
@@ -401,9 +401,9 @@ _doing_wiki() {
|
|
401
401
|
_doing_yesterday() {
|
402
402
|
|
403
403
|
if [[ "$token" == --* ]]; then
|
404
|
-
COMPREPLY=( $( compgen -W '--after --before --output --section --times --tag_order --tag_sort --totals' -- $token ) )
|
404
|
+
COMPREPLY=( $( compgen -W '--after --before --duration --from --output --section --times --tag_order --tag_sort --totals' -- $token ) )
|
405
405
|
elif [[ "$token" == -* ]]; then
|
406
|
-
COMPREPLY=( $( compgen -W '-o -s -t --after --before --output --section --times --tag_order --tag_sort --totals' -- $token ) )
|
406
|
+
COMPREPLY=( $( compgen -W '-o -s -t --after --before --duration --from --output --section --times --tag_order --tag_sort --totals' -- $token ) )
|
407
407
|
|
408
408
|
fi
|
409
409
|
}
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parslet'
|
4
|
+
|
5
|
+
module BooleanTermParser
|
6
|
+
# This query parser adds an optional operator ("+" or "-") to the simple term
|
7
|
+
# parser. In order to do that, a new "clause" node is added to the parse tree.
|
8
|
+
class QueryParser < Parslet::Parser
|
9
|
+
rule(:term) { match('[^\s]').repeat(1).as(:term) }
|
10
|
+
rule(:operator) { (str('+') | str('-')).as(:operator) }
|
11
|
+
rule(:clause) { (operator.maybe >> term).as(:clause) }
|
12
|
+
rule(:space) { match('\s').repeat(1) }
|
13
|
+
rule(:query) { (clause >> space.maybe).repeat.as(:query) }
|
14
|
+
root(:query)
|
15
|
+
end
|
16
|
+
|
17
|
+
class QueryTransformer < Parslet::Transform
|
18
|
+
rule(:clause => subtree(:clause)) do
|
19
|
+
Clause.new(clause[:operator]&.to_s, clause[:term].to_s)
|
20
|
+
end
|
21
|
+
rule(:query => sequence(:clauses)) { Query.new(clauses) }
|
22
|
+
end
|
23
|
+
|
24
|
+
class Operator
|
25
|
+
def self.symbol(str)
|
26
|
+
case str
|
27
|
+
when '+'
|
28
|
+
:must
|
29
|
+
when '-'
|
30
|
+
:must_not
|
31
|
+
when nil
|
32
|
+
:should
|
33
|
+
else
|
34
|
+
raise "Unknown operator: #{str}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class Clause
|
40
|
+
attr_accessor :operator, :term
|
41
|
+
|
42
|
+
def initialize(operator, term)
|
43
|
+
self.operator = Operator.symbol(operator)
|
44
|
+
self.term = term
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class Query
|
49
|
+
attr_accessor :should_terms, :must_not_terms, :must_terms
|
50
|
+
|
51
|
+
def initialize(clauses)
|
52
|
+
grouped = clauses.chunk { |c| c.operator }.to_h
|
53
|
+
self.should_terms = grouped.fetch(:should, []).map(&:term)
|
54
|
+
self.must_not_terms = grouped.fetch(:must_not, []).map(&:term)
|
55
|
+
self.must_terms = grouped.fetch(:must, []).map(&:term)
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_elasticsearch
|
59
|
+
query = {}
|
60
|
+
|
61
|
+
if should_terms.any?
|
62
|
+
query[:should] = should_terms.map do |term|
|
63
|
+
match(term)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
if must_terms.any?
|
68
|
+
query[:must] = must_terms.map do |term|
|
69
|
+
match(term)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
if must_not_terms.any?
|
74
|
+
query[:must_not] = must_not_terms.map do |term|
|
75
|
+
match(term)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
query
|
80
|
+
end
|
81
|
+
|
82
|
+
def match(term)
|
83
|
+
term
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
data/lib/doing/configuration.rb
CHANGED
@@ -7,7 +7,11 @@ module Doing
|
|
7
7
|
class Configuration
|
8
8
|
attr_reader :settings
|
9
9
|
|
10
|
-
attr_writer :ignore_local
|
10
|
+
attr_writer :ignore_local, :config_file, :force_answer
|
11
|
+
|
12
|
+
def force_answer
|
13
|
+
@force_answer ||= false
|
14
|
+
end
|
11
15
|
|
12
16
|
MissingConfigFile = Class.new(RuntimeError)
|
13
17
|
|
@@ -26,34 +30,37 @@ module Doing
|
|
26
30
|
'command_path' => File.join(Util.user_home, '.config', 'doing', 'commands')
|
27
31
|
},
|
28
32
|
'doing_file' => '~/what_was_i_doing.md',
|
33
|
+
'backup_dir' => '~/.doing_backup',
|
29
34
|
'current_section' => 'Currently',
|
30
35
|
'paginate' => false,
|
31
36
|
'never_time' => [],
|
32
37
|
'never_finish' => [],
|
38
|
+
'date_tags' => ['done', 'defer(?:red)?', 'waiting'],
|
33
39
|
|
34
40
|
'timer_format' => 'text',
|
41
|
+
'interval_format' => 'text',
|
35
42
|
|
36
43
|
'templates' => {
|
37
44
|
'default' => {
|
38
45
|
'date_format' => '%Y-%m-%d %H:%M',
|
39
|
-
'template' => '%date | %title%note',
|
46
|
+
'template' => '%date | %title %interval%duration%note',
|
40
47
|
'wrap_width' => 0,
|
41
48
|
'order' => 'asc'
|
42
49
|
},
|
43
50
|
'today' => {
|
44
51
|
'date_format' => '%_I:%M%P',
|
45
|
-
'template' => '%date: %title %interval%note',
|
52
|
+
'template' => '%date: %title %interval%duration%note',
|
46
53
|
'wrap_width' => 0,
|
47
54
|
'order' => 'asc'
|
48
55
|
},
|
49
56
|
'last' => {
|
50
57
|
'date_format' => '%-I:%M%P on %a',
|
51
|
-
'template' => '%title (at %date)%odnote',
|
58
|
+
'template' => '%title (at %date) %interval%duration%odnote',
|
52
59
|
'wrap_width' => 88
|
53
60
|
},
|
54
61
|
'recent' => {
|
55
62
|
'date_format' => '%_I:%M%P',
|
56
|
-
'template' => '%shortdate: %title (%section)',
|
63
|
+
'template' => '%shortdate: %title (%section) %interval%duration%note',
|
57
64
|
'wrap_width' => 88,
|
58
65
|
'count' => 10,
|
59
66
|
'order' => 'asc'
|
@@ -99,24 +106,21 @@ module Doing
|
|
99
106
|
@config_file ||= default_config_file
|
100
107
|
end
|
101
108
|
|
102
|
-
def config_file=(file)
|
103
|
-
@config_file = file
|
104
|
-
end
|
105
|
-
|
106
109
|
def config_dir
|
107
110
|
@config_dir ||= File.join(Util.user_home, '.config', 'doing')
|
108
|
-
# @config_dir ||= Util.user_home
|
109
111
|
end
|
110
112
|
|
111
113
|
def default_config_file
|
112
|
-
|
114
|
+
if File.exist?(config_dir) && !File.directory?(config_dir)
|
115
|
+
raise DoingRuntimeError, "#{config_dir} exists but is not a directory"
|
116
|
+
|
117
|
+
end
|
113
118
|
|
114
119
|
unless File.exist?(config_dir)
|
115
120
|
FileUtils.mkdir_p(config_dir)
|
116
121
|
Doing.logger.log_now(:warn, "Config directory created at #{config_dir}")
|
117
122
|
end
|
118
123
|
|
119
|
-
# File.join(config_dir, 'config.yml')
|
120
124
|
File.join(config_dir, 'config.yml')
|
121
125
|
end
|
122
126
|
|
@@ -130,10 +134,13 @@ module Doing
|
|
130
134
|
## @return [String] file path
|
131
135
|
##
|
132
136
|
def choose_config
|
137
|
+
return @config_file if @force_answer
|
138
|
+
|
133
139
|
if @additional_configs.count.positive?
|
134
|
-
choices = [@config_file]
|
135
|
-
choices.
|
136
|
-
|
140
|
+
choices = [@config_file].concat(@additional_configs)
|
141
|
+
res = Doing::Prompt.choose_from(choices.uniq.sort.reverse,
|
142
|
+
sorted: false,
|
143
|
+
prompt: 'Local configs found, select which to update > ')
|
137
144
|
|
138
145
|
raise UserCancelled, 'Cancelled' unless res
|
139
146
|
|
@@ -153,7 +160,7 @@ module Doing
|
|
153
160
|
## matched, first match wins)
|
154
161
|
## @return [Array] ordered array of resolved keys
|
155
162
|
##
|
156
|
-
def resolve_key_path(keypath)
|
163
|
+
def resolve_key_path(keypath, create: false)
|
157
164
|
cfg = @settings
|
158
165
|
real_path = []
|
159
166
|
unless keypath =~ /^[.*]?$/
|
@@ -170,8 +177,18 @@ module Doing
|
|
170
177
|
end
|
171
178
|
|
172
179
|
if new_cfg.nil?
|
173
|
-
|
174
|
-
|
180
|
+
return nil unless create
|
181
|
+
|
182
|
+
resolved = real_path.count.positive? ? "Resolved #{real_path.join('->')}, but " : ''
|
183
|
+
Doing.logger.log_now(:warn, "#{resolved}#{path} is unknown")
|
184
|
+
new_path = [*real_path, path, *paths].join('->')
|
185
|
+
Doing.logger.log_now(:warn, "Continuing will create the path #{new_path}")
|
186
|
+
res = Prompt.yn('Key path not found, create it?', default_response: true)
|
187
|
+
raise InvalidArgument, 'Invalid key path' unless res
|
188
|
+
|
189
|
+
real_path.push(path).concat(paths)
|
190
|
+
Doing.logger.debug('Config:', "translated key path #{keypath} to #{real_path.join('.')}")
|
191
|
+
return real_path
|
175
192
|
end
|
176
193
|
cfg = new_cfg
|
177
194
|
end
|
@@ -194,7 +211,7 @@ module Doing
|
|
194
211
|
cfg = @settings
|
195
212
|
real_path = ['config']
|
196
213
|
unless keypath =~ /^[.*]?$/
|
197
|
-
real_path = resolve_key_path(keypath)
|
214
|
+
real_path = resolve_key_path(keypath, create: false)
|
198
215
|
return nil unless real_path&.count&.positive?
|
199
216
|
|
200
217
|
cfg = cfg.dig(*real_path)
|
data/lib/doing/item.rb
CHANGED
@@ -31,6 +31,13 @@ module Doing
|
|
31
31
|
# @date = new_date.is_a?(Time) ? new_date : Time.parse(new_date)
|
32
32
|
# end
|
33
33
|
|
34
|
+
## If the entry doesn't have a @done date, return the elapsed time
|
35
|
+
def duration
|
36
|
+
return nil if @title =~ /(?<=^| )@done\b/
|
37
|
+
|
38
|
+
return Time.now - @date
|
39
|
+
end
|
40
|
+
|
34
41
|
##
|
35
42
|
## Get the difference between the item's start date and
|
36
43
|
## the value of its @done tag (if present)
|
@@ -109,7 +116,7 @@ module Doing
|
|
109
116
|
## Add (or remove) tags from the title of the item
|
110
117
|
##
|
111
118
|
## @param tags [Array] The tags to apply
|
112
|
-
## @param
|
119
|
+
## @param options Additional options
|
113
120
|
##
|
114
121
|
## @option options :date [Boolean] Include timestamp?
|
115
122
|
## @option options :single [Boolean] Log as a single change?
|
@@ -165,6 +172,13 @@ module Doing
|
|
165
172
|
## @return [Boolean] true if tag/bool combination passes
|
166
173
|
##
|
167
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
|
+
|
168
182
|
tags = split_tags(tags)
|
169
183
|
bool = bool.normalize_bool
|
170
184
|
|
@@ -179,6 +193,10 @@ module Doing
|
|
179
193
|
negate ? !matches : matches
|
180
194
|
end
|
181
195
|
|
196
|
+
def ignore_case(search, case_type)
|
197
|
+
(case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
|
198
|
+
end
|
199
|
+
|
182
200
|
##
|
183
201
|
## Test if item matches search string
|
184
202
|
##
|
@@ -190,9 +208,25 @@ module Doing
|
|
190
208
|
##
|
191
209
|
## @return [Boolean] matches search criteria
|
192
210
|
##
|
193
|
-
def search(search, distance: 3, negate: false, case_type: :smart
|
194
|
-
|
195
|
-
|
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
|
196
230
|
|
197
231
|
# if search.is_rx? || !fuzzy
|
198
232
|
# matches = text =~ search.to_rx(distance: distance, case_type: case_type)
|
@@ -279,33 +313,92 @@ module Doing
|
|
279
313
|
start = @date
|
280
314
|
|
281
315
|
t = (done - start).to_i
|
282
|
-
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
|
283
350
|
end
|
284
351
|
|
285
352
|
def all_tags?(tags)
|
353
|
+
return true if tags.nil? || tags.empty?
|
354
|
+
|
286
355
|
tags.each do |tag|
|
287
|
-
return false unless @title =~ /@#{tag}/
|
356
|
+
return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
|
288
357
|
end
|
289
358
|
true
|
290
359
|
end
|
291
360
|
|
292
361
|
def no_tags?(tags)
|
362
|
+
return true if tags.nil? || tags.empty?
|
363
|
+
|
293
364
|
tags.each do |tag|
|
294
|
-
return false if @title =~ /@#{tag}/
|
365
|
+
return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
|
295
366
|
end
|
296
367
|
true
|
297
368
|
end
|
298
369
|
|
299
370
|
def any_tags?(tags)
|
371
|
+
return true if tags.nil? || tags.empty?
|
372
|
+
|
300
373
|
tags.each do |tag|
|
301
|
-
return true if @title =~ /@#{tag}/
|
374
|
+
return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
|
302
375
|
end
|
303
376
|
false
|
304
377
|
end
|
305
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
|
+
|
306
399
|
def split_tags(tags)
|
307
400
|
tags = tags.split(/ *, */) if tags.is_a? String
|
308
|
-
tags.map { |t| t.strip.
|
401
|
+
tags.map { |t| t.strip.add_at }
|
309
402
|
end
|
310
403
|
end
|
311
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
|