doing 2.1.39 → 2.1.42

Sign up to get free protection for your applications and to get access to all the features.
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