doing 2.1.39 → 2.1.42

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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -1
  3. data/CHANGELOG.md +67 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +1 -1
  6. data/Rakefile +4 -4
  7. data/bin/commands/again.rb +1 -3
  8. data/bin/commands/changes.rb +50 -34
  9. data/bin/commands/commands.rb +77 -52
  10. data/bin/commands/commands_accepting.rb +57 -53
  11. data/bin/commands/config.rb +45 -36
  12. data/bin/commands/done.rb +1 -18
  13. data/bin/commands/finish.rb +90 -59
  14. data/bin/commands/flag.rb +5 -1
  15. data/bin/commands/grep.rb +3 -14
  16. data/bin/commands/last.rb +2 -8
  17. data/bin/commands/meanwhile.rb +13 -6
  18. data/bin/commands/now.rb +151 -107
  19. data/bin/commands/on.rb +8 -18
  20. data/bin/commands/recent.rb +2 -8
  21. data/bin/commands/reset.rb +24 -1
  22. data/bin/commands/select.rb +1 -1
  23. data/bin/commands/show.rb +6 -17
  24. data/bin/commands/since.rb +1 -12
  25. data/bin/commands/tag_dir.rb +49 -15
  26. data/bin/commands/today.rb +2 -13
  27. data/bin/commands/undo.rb +4 -6
  28. data/bin/commands/view.rb +1 -1
  29. data/bin/commands/yesterday.rb +2 -13
  30. data/bin/doing +15 -8
  31. data/{Dockerfile → docker/Dockerfile} +3 -1
  32. data/{Dockerfile-2.6 → docker/Dockerfile-2.6} +2 -2
  33. data/{Dockerfile-2.7 → docker/Dockerfile-2.7} +2 -2
  34. data/{Dockerfile-3.0 → docker/Dockerfile-3.0} +2 -2
  35. data/{bash_profile → docker/bash_profile} +0 -0
  36. data/{inputrc → docker/inputrc} +0 -0
  37. data/docs/doc/Array.html +85 -2
  38. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  39. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  40. data/docs/doc/BooleanTermParser/Query.html +1 -1
  41. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  42. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  43. data/docs/doc/BooleanTermParser.html +1 -1
  44. data/docs/doc/Doing/ArrayNestedHash.html +198 -0
  45. data/docs/doc/Doing/ArrayTags.html +424 -0
  46. data/docs/doc/Doing/CSVExport.html +266 -0
  47. data/docs/doc/Doing/CalendarImport.html +232 -0
  48. data/docs/doc/Doing/Change.html +617 -0
  49. data/docs/doc/Doing/Changes.html +468 -0
  50. data/docs/doc/Doing/ChronifyArray.html +347 -0
  51. data/docs/doc/Doing/ChronifyNumeric.html +271 -0
  52. data/docs/doc/Doing/ChronifyString.html +682 -0
  53. data/docs/doc/Doing/Color.html +167 -21
  54. data/docs/doc/Doing/Completion/BashCompletions.html +445 -0
  55. data/docs/doc/Doing/Completion/FishCompletions.html +445 -0
  56. data/docs/doc/Doing/Completion/StringUtils.html +229 -0
  57. data/docs/doc/Doing/Completion/ZshCompletions.html +445 -0
  58. data/docs/doc/Doing/Completion.html +17 -3
  59. data/docs/doc/Doing/Configuration.html +3 -2
  60. data/docs/doc/Doing/DayOneRenderer.html +383 -0
  61. data/docs/doc/Doing/DayoneExport.html +290 -0
  62. data/docs/doc/Doing/DoingImport.html +391 -0
  63. data/docs/doc/Doing/Entry.html +381 -0
  64. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  65. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  66. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  67. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  68. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  69. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  70. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  71. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  72. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  73. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  74. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  75. data/docs/doc/Doing/Errors.html +9 -9
  76. data/docs/doc/Doing/HTMLExport.html +256 -0
  77. data/docs/doc/Doing/Hooks.html +1 -1
  78. data/docs/doc/Doing/Item.html +179 -1660
  79. data/docs/doc/Doing/ItemDates.html +564 -0
  80. data/docs/doc/Doing/ItemQuery.html +614 -0
  81. data/docs/doc/Doing/ItemState.html +387 -0
  82. data/docs/doc/Doing/ItemTags.html +498 -0
  83. data/docs/doc/Doing/Items.html +581 -15
  84. data/docs/doc/Doing/JSONExport.html +222 -0
  85. data/docs/doc/Doing/Logger.html +1 -1
  86. data/docs/doc/Doing/MarkdownExport.html +266 -0
  87. data/docs/doc/Doing/MarkdownRenderer.html +383 -0
  88. data/docs/doc/Doing/Note.html +18 -4
  89. data/docs/doc/Doing/Pager.html +1 -1
  90. data/docs/doc/Doing/Plugins.html +181 -76
  91. data/docs/doc/Doing/Prompt.html +32 -683
  92. data/docs/doc/Doing/PromptChoose.html +484 -0
  93. data/docs/doc/Doing/PromptFZF.html +391 -0
  94. data/docs/doc/Doing/PromptInput.html +572 -0
  95. data/docs/doc/Doing/PromptSTD.html +293 -0
  96. data/docs/doc/Doing/PromptYN.html +237 -0
  97. data/docs/doc/Doing/Section.html +58 -2
  98. data/docs/doc/Doing/StringHighlight.html +533 -0
  99. data/docs/doc/Doing/StringNormalize.html +929 -0
  100. data/docs/doc/Doing/StringQuery.html +725 -0
  101. data/docs/doc/Doing/StringTags.html +884 -0
  102. data/docs/doc/Doing/StringTransform.html +599 -0
  103. data/docs/doc/Doing/StringTruncate.html +448 -0
  104. data/docs/doc/Doing/StringURL.html +409 -0
  105. data/docs/doc/Doing/SymbolNormalize.html +341 -0
  106. data/docs/doc/Doing/TaskPaperExport.html +222 -0
  107. data/docs/doc/Doing/TemplateExport.html +249 -0
  108. data/docs/doc/Doing/TemplateString.html +102 -3
  109. data/docs/doc/Doing/TimingImport.html +285 -0
  110. data/docs/doc/Doing/Types.html +1 -1
  111. data/docs/doc/Doing/Util/Backup.html +11 -163
  112. data/docs/doc/Doing/Util.html +67 -10
  113. data/docs/doc/Doing/Version.html +523 -0
  114. data/docs/doc/Doing/WWID/WWIDUtil.html +510 -0
  115. data/docs/doc/Doing/WWID.html +476 -139
  116. data/docs/doc/Doing/WWIDDisplay.html +865 -0
  117. data/docs/doc/Doing/WWIDEditor.html +466 -0
  118. data/docs/doc/Doing/WWIDFileTools.html +359 -0
  119. data/docs/doc/Doing/WWIDFilter.html +466 -0
  120. data/docs/doc/Doing/WWIDGuess.html +299 -0
  121. data/docs/doc/Doing/WWIDInteractive.html +752 -0
  122. data/docs/doc/Doing/WWIDModify.html +1078 -0
  123. data/docs/doc/Doing/WWIDTags.html +302 -0
  124. data/docs/doc/Doing/WWIDTimers.html +359 -0
  125. data/docs/doc/Doing/WWIDUtil.html +510 -0
  126. data/docs/doc/Doing.html +9 -6
  127. data/docs/doc/FalseClass.html +1 -1
  128. data/docs/doc/GLI/Commands/Help.html +1 -1
  129. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  130. data/docs/doc/GLI/Commands.html +1 -1
  131. data/docs/doc/GLI.html +1 -1
  132. data/docs/doc/Hash.html +1 -1
  133. data/docs/doc/Numeric.html +23 -78
  134. data/docs/doc/Object.html +1 -1
  135. data/docs/doc/PhraseParser/Operator.html +1 -1
  136. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  137. data/docs/doc/PhraseParser/Query.html +1 -1
  138. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  139. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  140. data/docs/doc/PhraseParser/TermClause.html +1 -1
  141. data/docs/doc/PhraseParser.html +1 -1
  142. data/docs/doc/Status.html +1 -1
  143. data/docs/doc/String.html +58 -633
  144. data/docs/doc/Symbol.html +9 -224
  145. data/docs/doc/Time.html +119 -13
  146. data/docs/doc/TrueClass.html +1 -1
  147. data/docs/doc/_index.html +348 -4
  148. data/docs/doc/class_list.html +1 -1
  149. data/docs/doc/file.README.html +2 -2
  150. data/docs/doc/index.html +2 -2
  151. data/docs/doc/method_list.html +1904 -592
  152. data/docs/doc/top-level-namespace.html +12 -4
  153. data/docs/index.md +1 -1
  154. data/doing.rdoc +67 -15
  155. data/lib/completion/_doing.zsh +6 -6
  156. data/lib/completion/doing.bash +10 -10
  157. data/lib/completion/doing.fish +10 -3
  158. data/lib/doing/add_options.rb +39 -1
  159. data/lib/doing/array/array.rb +18 -12
  160. data/lib/doing/array/cleanup.rb +31 -0
  161. data/lib/doing/array/nested_hash.rb +1 -1
  162. data/lib/doing/array/tags.rb +6 -5
  163. data/lib/doing/changelog/changelog.rb +6 -0
  164. data/lib/doing/chronify/array.rb +65 -25
  165. data/lib/doing/chronify/chronify.rb +12 -0
  166. data/lib/doing/chronify/numeric.rb +3 -2
  167. data/lib/doing/chronify/string.rb +1 -1
  168. data/lib/doing/colors.rb +77 -30
  169. data/lib/doing/completion/completion_string.rb +25 -0
  170. data/lib/doing/completion.rb +4 -5
  171. data/lib/doing/configuration.rb +7 -3
  172. data/lib/doing/errors.rb +51 -35
  173. data/lib/doing/good.rb +8 -0
  174. data/lib/doing/hooks.rb +3 -3
  175. data/lib/doing/item/dates.rb +112 -0
  176. data/lib/doing/item/item.rb +128 -0
  177. data/lib/doing/{item.rb → item/query.rb} +2 -353
  178. data/lib/doing/item/state.rb +59 -0
  179. data/lib/doing/item/tags.rb +87 -0
  180. data/lib/doing/items/filter.rb +67 -0
  181. data/lib/doing/items/items.rb +57 -0
  182. data/lib/doing/items/modify.rb +36 -0
  183. data/lib/doing/items/sections.rb +83 -0
  184. data/lib/doing/items/util.rb +74 -0
  185. data/lib/doing/normalize.rb +10 -2
  186. data/lib/doing/note.rb +1 -1
  187. data/lib/doing/pager.rb +9 -3
  188. data/lib/doing/plugin_manager.rb +33 -8
  189. data/lib/doing/plugins/export/markdown_export.rb +4 -2
  190. data/lib/doing/plugins/export/template_export.rb +4 -4
  191. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  192. data/lib/doing/plugins/import/doing_import.rb +1 -1
  193. data/lib/doing/prompt/choose.rb +118 -0
  194. data/lib/doing/prompt/fzf.rb +84 -0
  195. data/lib/doing/prompt/input.rb +129 -0
  196. data/lib/doing/prompt/prompt.rb +41 -0
  197. data/lib/doing/prompt/std.rb +32 -0
  198. data/lib/doing/prompt/yn.rb +64 -0
  199. data/lib/doing/section.rb +4 -0
  200. data/lib/doing/string/highlight.rb +1 -1
  201. data/lib/doing/string/query.rb +1 -1
  202. data/lib/doing/string/string.rb +18 -7
  203. data/lib/doing/string/tags.rb +14 -3
  204. data/lib/doing/string/transform.rb +7 -1
  205. data/lib/doing/string/truncate.rb +1 -1
  206. data/lib/doing/string/url.rb +1 -1
  207. data/lib/doing/time.rb +19 -1
  208. data/lib/doing/util.rb +12 -6
  209. data/lib/doing/util_backup.rb +62 -57
  210. data/lib/doing/version.rb +1 -1
  211. data/lib/doing/wwid/display.rb +396 -0
  212. data/lib/doing/wwid/editor.rb +214 -0
  213. data/lib/doing/wwid/filetools.rb +183 -0
  214. data/lib/doing/wwid/filter.rb +226 -0
  215. data/lib/doing/wwid/guess.rb +85 -0
  216. data/lib/doing/wwid/interactive.rb +377 -0
  217. data/lib/doing/wwid/modify.rb +617 -0
  218. data/lib/doing/wwid/tags.rb +51 -0
  219. data/lib/doing/wwid/timers.rb +342 -0
  220. data/lib/doing/wwid/wwid.rb +121 -0
  221. data/lib/doing/wwid/wwidutil.rb +101 -0
  222. data/lib/doing.rb +7 -7
  223. data/lib/helpers/threaded_tests.rb +1 -0
  224. metadata +94 -14
  225. data/lib/doing/changelog.rb +0 -6
  226. data/lib/doing/completion/string.rb +0 -17
  227. data/lib/doing/items.rb +0 -196
  228. data/lib/doing/prompt.rb +0 -330
  229. data/lib/doing/wwid.rb +0 -2398
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ ##
6
+ ## Create a process for an editor and wait for the file handle to return
7
+ ##
8
+ ## @param input [String] Text input for editor
9
+ ##
10
+ def fork_editor(input = '', message: :default)
11
+ # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
12
+
13
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
14
+
15
+ tmpfile = Tempfile.new(['doing_temp', '.doing'])
16
+
17
+ File.open(tmpfile.path, 'w+') do |f|
18
+ f.puts input
19
+ unless message.nil?
20
+ f.puts message == :default ? '# First line is the entry title, lines after are added as a note' : message
21
+ end
22
+ end
23
+
24
+ pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
25
+
26
+ trap('INT') do
27
+ begin
28
+ Process.kill(9, pid)
29
+ rescue StandardError
30
+ Errno::ESRCH
31
+ end
32
+ tmpfile.unlink
33
+ tmpfile.close!
34
+ exit 0
35
+ end
36
+
37
+ Process.wait(pid)
38
+
39
+ begin
40
+ if $?.exitstatus == 0
41
+ input = IO.read(tmpfile.path)
42
+ else
43
+ exit_now! 'Cancelled'
44
+ end
45
+ ensure
46
+ tmpfile.close
47
+ tmpfile.unlink
48
+ end
49
+
50
+ input.split(/\n/).delete_if(&:ignore?).join("\n")
51
+ end
52
+
53
+ ##
54
+ ## Takes a multi-line string and formats it as an entry
55
+ ##
56
+ ## @param input [String] The string to parse
57
+ ##
58
+ ## @return [Array] [[String]title, [Note]note]
59
+ ##
60
+ def format_input(input)
61
+ raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
62
+
63
+ input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
64
+ title = input_lines[0]&.strip
65
+ raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
66
+
67
+ date = nil
68
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
69
+ date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
70
+
71
+ raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
72
+
73
+ title.expand_date_tags(Doing.setting('date_tags'))
74
+
75
+ if title =~ date_rx
76
+ m = title.match(date_rx)
77
+ d = m['date']
78
+ date = if d =~ iso_rx
79
+ Time.parse(d)
80
+ else
81
+ d.chronify(guess: :begin)
82
+ end
83
+ title.sub!(date_rx, '').strip!
84
+ end
85
+
86
+ note = Note.new
87
+ note.add(input_lines[1..-1]) if input_lines.length > 1
88
+ # If title line ends in a parenthetical, use that as the note
89
+ if note.empty? && title =~ /\s+\(.*?\)$/
90
+ title.sub!(/\s+\((?<note>.*?)\)$/) do
91
+ m = Regexp.last_match
92
+ note.add(m['note'])
93
+ ''
94
+ end
95
+ end
96
+
97
+ note.strip_lines!
98
+ note.compress
99
+
100
+ [date, title, note]
101
+ end
102
+
103
+ def add_with_editor(**options)
104
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
105
+
106
+ input = options[:date].strftime('%F %R | ')
107
+ input += options[:title]
108
+ input += "\n#{options[:note]}" if options[:note]
109
+ input = fork_editor(input).strip
110
+
111
+ d, title, note = format_input(input)
112
+ raise EmptyInput, 'No content' if title.empty?
113
+
114
+ if options[:ask]
115
+ ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
116
+ note.add(ask_note) unless ask_note.empty?
117
+ end
118
+
119
+ date = d.nil? ? options[:date] : d
120
+ finish = options[:finish_last] || false
121
+ add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
122
+ write(@doing_file)
123
+ end
124
+
125
+ def edit_items(items)
126
+ items.sort_by! { |i| i.date }
127
+ editable_items = []
128
+
129
+ items.each do |i|
130
+ editable = "#{i.date.strftime('%F %R')} | #{i.title}"
131
+ old_note = i.note ? i.note.strip_lines.join("\n") : nil
132
+ editable += "\n#{old_note}" unless old_note.nil?
133
+ editable_items << editable
134
+ end
135
+ divider = "-----------"
136
+ notice =<<~EONOTICE
137
+
138
+ # - You may delete entries, but leave all divider lines (---) in place.
139
+ # - Start and @done dates replaced with a time string (yesterday 3pm) will
140
+ # be parsed automatically. Do not delete the pipe (|) between start date
141
+ # and entry title.
142
+ EONOTICE
143
+ input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n"
144
+
145
+ new_items = fork_editor(input, message: notice).split(/^#{divider}/).map(&:strip)
146
+
147
+ new_items.each_with_index do |new_item, i|
148
+ input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
149
+ first_line = input_lines[0]&.strip
150
+
151
+ if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
152
+ deleted = @content.delete_item(items[i], single: new_items.count == 1)
153
+ Hooks.trigger :post_entry_removed, self, deleted
154
+ Doing.logger.info('Deleted:', deleted.title)
155
+ else
156
+ date, title, note = format_input(new_item)
157
+
158
+ note.map!(&:strip)
159
+ note.delete_if(&:ignore?)
160
+ item = items[i]
161
+ old_item = item.clone
162
+ item.date = date || items[i].date
163
+ item.title = title
164
+ item.note = note
165
+ if (item.equal?(old_item))
166
+ Doing.logger.count(:skipped, level: :debug)
167
+ else
168
+ Doing.logger.count(:updated)
169
+ Hooks.trigger :post_entry_updated, self, item, old_item
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ ##
176
+ ## Edit the last entry
177
+ ##
178
+ ## @param section [String] The section, default "All"
179
+ ##
180
+ def edit_last(section: 'All', options: {})
181
+ options[:section] = guess_section(section)
182
+
183
+ item = last_entry(options)
184
+
185
+ if item.nil?
186
+ logger.debug('Skipped:', 'No entries found')
187
+ return
188
+ end
189
+
190
+ old_item = item.clone
191
+ content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
192
+ content << item.note.strip_lines.join("\n") unless item.note.empty?
193
+ new_item = fork_editor(content.join("\n"))
194
+ raise UserCancelled, 'No change' if new_item.strip == content.join("\n").strip
195
+
196
+ date, title, note = format_input(new_item)
197
+ date ||= item.date
198
+
199
+ if title.nil? || title.empty?
200
+ logger.debug('Skipped:', 'No content provided')
201
+ elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
202
+ logger.debug('Skipped:', 'No change in content')
203
+ else
204
+ item.date = date unless date.nil?
205
+ item.title = title
206
+ item.note.add(note, replace: true)
207
+ logger.info('Edited:', item.title)
208
+ Hooks.trigger :post_entry_updated, self, item, old_item
209
+
210
+ write(@doing_file)
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ ##
6
+ ## Initializes the doing file.
7
+ ##
8
+ ## @param path [String] Override path to a doing file, optional
9
+ ##
10
+ def init_doing_file(path = nil)
11
+ @doing_file = File.expand_path(Doing.setting('doing_file'))
12
+
13
+ if path.nil?
14
+ create(@doing_file) unless File.exist?(@doing_file)
15
+ input = IO.read(@doing_file)
16
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
17
+ logger.debug('Read:', "read file #{@doing_file}")
18
+ elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
19
+ @doing_file = File.expand_path(path)
20
+ input = IO.read(File.expand_path(path))
21
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
22
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
23
+ elsif path.length < 256
24
+ @doing_file = File.expand_path(path)
25
+ create(path)
26
+ input = IO.read(File.expand_path(path))
27
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
28
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
29
+ end
30
+
31
+ @other_content_top = []
32
+ @other_content_bottom = []
33
+
34
+ section = nil
35
+ lines = input.split(/[\n\r]/)
36
+
37
+ lines.each do |line|
38
+ next if line =~ /^\s*$/
39
+
40
+ if line =~ /^(\S[\S ]+):\s*(@[\w\-_.]+\s*)*$/
41
+ section = Regexp.last_match(1)
42
+ @content.add_section(Section.new(section, original: line), log: false)
43
+ elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
44
+ if section.nil?
45
+ section = 'Uncategorized'
46
+ @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
47
+ end
48
+
49
+ date = Regexp.last_match(1).strip
50
+ title = Regexp.last_match(2).strip
51
+ item = Item.new(date, title, section)
52
+ @content.push(item)
53
+ elsif @content.count.zero?
54
+ # if content[section].items.length - 1 == current
55
+ @other_content_top.push(line)
56
+ elsif line =~ /^\S/
57
+ @other_content_bottom.push(line)
58
+ else
59
+ prev_item = @content.last
60
+ prev_item.note = Note.new unless prev_item.note
61
+
62
+ prev_item.note.add(line)
63
+ # end
64
+ end
65
+ end
66
+
67
+ Hooks.trigger :post_read, self
68
+ @initial_content = @content.clone
69
+ end
70
+
71
+ ##
72
+ ## Create a new doing file
73
+ ##
74
+ def create(filename = nil)
75
+ filename = @doing_file if filename.nil?
76
+ return if File.exist?(filename) && File.stat(filename).size.positive?
77
+
78
+ FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
79
+
80
+ File.open(filename, 'w+') do |f|
81
+ f.puts "#{Doing.setting('current_section')}:"
82
+ end
83
+ end
84
+
85
+ ##
86
+ ## Write content to file or STDOUT
87
+ ##
88
+ ## @param file [String] The filepath to write to
89
+ ##
90
+ def write(file = nil, backup: true)
91
+ Hooks.trigger :pre_write, self, file
92
+ output = combined_content
93
+ if file.nil?
94
+ $stdout.puts output
95
+ else
96
+ Util.write_to_file(file, output, backup: backup)
97
+ run_after if Doing.setting('run_after')
98
+ end
99
+ end
100
+
101
+ ##
102
+ ## Rename doing file with date and start fresh one
103
+ ##
104
+ def rotate(opt)
105
+ opt ||= {}
106
+ keep = opt[:keep] || 0
107
+ tags = []
108
+ tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
109
+ bool = opt[:bool] || :and
110
+ sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
111
+
112
+ section = guess_section(sect)
113
+
114
+ section_items = @content.in_section(section)
115
+ max = section_items.count - keep.to_i
116
+
117
+ counter = 0
118
+ new_content = Items.new
119
+
120
+ section_items.each do |item|
121
+ break if counter >= max
122
+ if opt[:before]
123
+ time_string = opt[:before]
124
+ cutoff = time_string.chronify(guess: :begin)
125
+ end
126
+
127
+ unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
128
+ new_item = @content.delete(item)
129
+ Hooks.trigger :post_entry_removed, self, item.clone
130
+ raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
131
+
132
+ new_content.add_section(new_item.section, log: false)
133
+ new_content.push(new_item)
134
+ counter += 1
135
+ end
136
+ end
137
+
138
+ if counter.positive?
139
+ logger.count(:rotated,
140
+ level: :info,
141
+ count: counter,
142
+ message: "Rotated %count %items")
143
+ else
144
+ logger.info('Skipped:', 'No items were rotated')
145
+ end
146
+
147
+ write(@doing_file)
148
+
149
+ file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
150
+ if File.exist?(file)
151
+ init_doing_file(file)
152
+ @content.concat(new_content).uniq!
153
+ logger.warn('File update:', "added entries to existing file: #{file}")
154
+ else
155
+ @content = new_content
156
+ logger.warn('File update:', "created new file: #{file}")
157
+ end
158
+
159
+ write(file, backup: false)
160
+ end
161
+
162
+ private
163
+
164
+ ##
165
+ ## Wraps doing file content with additional
166
+ ## header/footer content
167
+ ##
168
+ ## @return [String] concatenated content
169
+ ## @api private
170
+ def combined_content
171
+ output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
172
+ was_color = Color.coloring?
173
+ Color.coloring = false
174
+ @content.dedup!(match_section: true)
175
+ output += @content.to_s
176
+ output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
177
+ # Just strip all ANSI colors from the content before writing to doing file
178
+ Color.coloring = was_color
179
+
180
+ output.uncolor
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Use fzf to filter an Items object with a search query.
6
+ # Faster than {#filter_items} when all you need is a
7
+ # text search of the title and note
8
+ #
9
+ # @param items [Items] an Items object
10
+ # @param query [String] The search query
11
+ # @param case_type [Symbol] The case type (:smart, :sensitive, :ignore)
12
+ #
13
+ # @return [Items] Filtered Items array
14
+ #
15
+ def fuzzy_filter_items(items, query, case_type: :smart)
16
+ scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
17
+
18
+ fzf_args = [
19
+ '--multi',
20
+ %(--filter="#{query.sub(/^'?/, "'")}"),
21
+ '--no-sort',
22
+ '-d "\|"',
23
+ '--nth=1'
24
+ ]
25
+ fzf_args << case case_type.normalize_case
26
+ when :smart
27
+ query =~ /[A-Z]/ ? '+i' : '-i'
28
+ when :sensitive
29
+ '+i'
30
+ when :ignore
31
+ '-i'
32
+ end
33
+
34
+ # fzf_args << '-e' if opt[:exact]
35
+ # puts fzf_args.join(' ')
36
+ res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
37
+ selected = Items.new
38
+ res.split(/\n/).each do |item|
39
+ idx = item.match(/\|(\d+)$/)[1].to_i
40
+ selected.push(items[idx])
41
+ end
42
+ selected
43
+ end
44
+
45
+ ##
46
+ ## Filter items based on search criteria
47
+ ##
48
+ ## @param items [Array] The items to filter (if empty, filters all items)
49
+ ## @param opt [Hash] The filter parameters
50
+ ##
51
+ ## @option opt [String] :section ('all')
52
+ ## @option opt [Boolean] :unfinished (false)
53
+ ## @option opt [Array or String] :tag ([]) Array or comma-separated string
54
+ ## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
55
+ ## @option opt [String] :search ('') string, optional regex with `/string/`
56
+ ## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
57
+ ## @option opt [Boolean] :only_timed (false)
58
+ ## @option opt [String] :before (nil) Date/Time string, unparsed
59
+ ## @option opt [String] :after (nil) Date/Time string, unparsed
60
+ ## @option opt [Boolean] :today (false) limit to entries from today
61
+ ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
62
+ ## @option opt [Number] :count (0) max entries to return
63
+ ## @option opt [String] :age (new) 'old' or 'new'
64
+ ## @option opt [Array] :val (nil) Array of tag value queries
65
+ ##
66
+ def filter_items(items = Items.new, opt: {})
67
+ logger.benchmark(:filter_items, :start)
68
+ time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
69
+
70
+ if items.nil? || items.empty?
71
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
72
+ items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
73
+ end
74
+
75
+ unless opt[:time_filter]
76
+ opt[:time_filter] = [nil, nil]
77
+ if opt[:from] && !opt[:date_filter]
78
+ if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
79
+ opt[:time_filter] = opt[:from]
80
+ elsif opt[:from][0].is_a?(Time)
81
+ opt[:date_filter] = opt[:from]
82
+ end
83
+ end
84
+ end
85
+
86
+ if opt[:before].is_a?(String) && opt[:before] =~ time_rx
87
+ opt[:time_filter][1] = opt[:before]
88
+ opt[:before] = nil
89
+ end
90
+
91
+ if opt[:after].is_a?(String) && opt[:after] =~ time_rx
92
+ opt[:time_filter][0] = opt[:after]
93
+ opt[:after] = nil
94
+ end
95
+
96
+ items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
97
+
98
+ filtered_items = items.select do |item|
99
+ keep = true
100
+ if opt[:unfinished]
101
+ finished = item.tags?('done', :and)
102
+ finished = opt[:not] ? !finished : finished
103
+ keep = false if finished
104
+ end
105
+
106
+ if keep && opt[:val]&.count&.positive?
107
+ bool = opt[:bool].normalize_bool if opt[:bool]
108
+ bool ||= :and
109
+ bool = :and if bool == :pattern
110
+
111
+ val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
112
+ keep = false unless val_match
113
+ keep = opt[:not] ? !keep : keep
114
+ end
115
+
116
+ if keep && opt[:tag]
117
+ opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
118
+ opt[:tag_bool] ||= :and
119
+ tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
120
+ keep = false unless tag_match
121
+ keep = opt[:not] ? !keep : keep
122
+ end
123
+
124
+ if keep && opt[:search]
125
+ search_match = if opt[:search].nil? || opt[:search].empty?
126
+ true
127
+ else
128
+ item.search(opt[:search], case_type: opt[:case].normalize_case)
129
+ end
130
+
131
+ keep = false unless search_match
132
+ keep = opt[:not] ? !keep : keep
133
+ end
134
+
135
+ if keep && opt[:date_filter]&.length == 2
136
+ start_date = opt[:date_filter][0]
137
+ end_date = opt[:date_filter][1]
138
+
139
+ in_date_range = if end_date
140
+ item.date >= start_date && item.date <= end_date
141
+ else
142
+ item.date.strftime('%F') == start_date.strftime('%F')
143
+ end
144
+ keep = false unless in_date_range
145
+ keep = opt[:not] ? !keep : keep
146
+ end
147
+
148
+ if keep && opt[:time_filter][0] || opt[:time_filter][1]
149
+ start_string = if opt[:time_filter][0].nil?
150
+ "#{item.date.strftime('%Y-%m-%d')} 12am"
151
+ else
152
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
153
+ end
154
+ start_time = start_string.chronify(guess: :begin)
155
+
156
+ end_string = if opt[:time_filter][1].nil?
157
+ "#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
158
+ else
159
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
160
+ end
161
+ end_time = end_string.chronify(guess: :end)
162
+
163
+ in_time_range = item.date >= start_time && item.date <= end_time
164
+ keep = false unless in_time_range
165
+ keep = opt[:not] ? !keep : keep
166
+ end
167
+
168
+ keep = false if keep && opt[:only_timed] && !item.interval
169
+
170
+ if keep && opt[:tag_filter]
171
+ keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
172
+ keep = opt[:not] ? !keep : keep
173
+ end
174
+
175
+ if keep && opt[:before]
176
+ before = opt[:before]
177
+ cutoff = if before =~ time_rx
178
+ "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
179
+ elsif before.is_a?(String)
180
+ before.chronify(guess: :begin)
181
+ else
182
+ before
183
+ end
184
+ keep = cutoff && item.date <= cutoff
185
+ keep = opt[:not] ? !keep : keep
186
+ end
187
+
188
+ if keep && opt[:after]
189
+ after = opt[:after]
190
+ cutoff = if after =~ time_rx
191
+ "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
192
+ elsif after.is_a?(String)
193
+ after.chronify(guess: :end)
194
+ else
195
+ after
196
+ end
197
+ keep = cutoff && item.date >= cutoff
198
+ keep = opt[:not] ? !keep : keep
199
+ end
200
+
201
+ if keep && opt[:today]
202
+ keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
203
+ keep = opt[:not] ? !keep : keep
204
+ elsif keep && opt[:yesterday]
205
+ keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
206
+ keep = opt[:not] ? !keep : keep
207
+ end
208
+
209
+ keep
210
+ end
211
+ count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
212
+
213
+ output = Items.new
214
+
215
+ if opt[:age] && opt[:age].normalize_age == :oldest
216
+ output.concat(filtered_items.slice(0, count).reverse)
217
+ else
218
+ output.concat(filtered_items.reverse.slice(0, count))
219
+ end
220
+
221
+ logger.benchmark(:filter_items, :finish)
222
+
223
+ output
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ ##
6
+ ## Attempt to match a string with an existing section
7
+ ##
8
+ ## @param frag [String] The user-provided string
9
+ ## @param guessed [Boolean] already guessed and failed
10
+ ##
11
+ def guess_section(frag, guessed: false, suggest: false)
12
+ return 'All' if frag =~ /^all$/i
13
+
14
+ frag ||= Doing.setting('current_section')
15
+
16
+ return frag.cap_first if @content.section?(frag)
17
+
18
+ found = @content.guess_section(frag, distance: 2)
19
+
20
+ section = found ? found.title : nil
21
+
22
+ return section if suggest
23
+
24
+ unless section || guessed
25
+ alt = guess_view(frag, guessed: true, suggest: true)
26
+ if alt
27
+ prompt = Color.template("{bw}Did you mean `{xy}doing {by}view {xy}#{alt}`{bw}?{x}")
28
+ meant_view = Prompt.yn(prompt, default_response: 'n')
29
+
30
+ msg = format('%<y>srun with `%<w>sdoing view %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
31
+ raise Errors::WrongCommand.new(msg, topic: 'Try again:') if meant_view
32
+
33
+ end
34
+
35
+ res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
36
+
37
+ if res
38
+ @content.add_section(frag.cap_first, log: true)
39
+ write(@doing_file)
40
+ return frag.cap_first
41
+ end
42
+
43
+ raise Errors::InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
44
+ end
45
+ section ? section.cap_first : guessed
46
+ end
47
+
48
+ ##
49
+ ## Attempt to match a string with an existing view
50
+ ##
51
+ ## @param frag [String] The user-provided string
52
+ ## @param guessed [Boolean] already guessed
53
+ ##
54
+ def guess_view(frag, guessed: false, suggest: false)
55
+ views.each { |view| return view if frag.downcase == view.downcase }
56
+ view = nil
57
+ re = frag.to_rx(distance: 2, case_type: :ignore)
58
+ views.each do |v|
59
+ next unless v =~ /#{re}/i
60
+
61
+ logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
62
+ view = v
63
+ break
64
+ end
65
+ unless view || guessed
66
+ alt = guess_section(frag, guessed: true, suggest: true)
67
+
68
+ raise Errors::InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
69
+
70
+ prompt = Color.template("{bw}Did you mean `{xy}doing {by}show {xy}#{alt}`{bw}?{x}")
71
+ meant_view = Prompt.yn(prompt, default_response: 'n')
72
+
73
+ if meant_view
74
+ msg = format('%<y>srun with `%<w>sdoing show %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
75
+ raise Errors::WrongCommand.new(msg, topic: 'Try again:')
76
+
77
+ end
78
+
79
+ raise Errors::InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
80
+
81
+ end
82
+ view
83
+ end
84
+ end
85
+ end