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.
- 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
|