#!/usr/bin/ruby # frozen_string_literal: true require 'deep_merge' require 'open3' require 'pp' require 'shellwords' require 'erb' module Doing ## ## Main "What Was I Doing" methods ## class WWID attr_reader :additional_configs, :current_section, :doing_file, :content attr_accessor :config, :config_file, :auto_tag, :default_option include Color # include Util ## ## Initializes the object. ## def initialize @timers = {} @recorded_items = [] @content = Items.new @auto_tag = true end ## ## Logger ## ## Responds to :debug, :info, :warn, and :error ## ## Each method takes a topic, and a message or block ## ## Example: debug('Hooks', 'Hook 1 triggered') ## def logger @logger ||= Doing.logger end ## ## Initializes the doing file. ## ## @param path [String] Override path to a doing file, optional ## def init_doing_file(path = nil) @doing_file = File.expand_path(@config['doing_file']) if path.nil? create(@doing_file) unless File.exist?(@doing_file) input = IO.read(@doing_file) input = input.force_encoding('utf-8') if input.respond_to? :force_encoding logger.debug('Read:', "read file #{@doing_file}") elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive? @doing_file = File.expand_path(path) input = IO.read(File.expand_path(path)) input = input.force_encoding('utf-8') if input.respond_to? :force_encoding logger.debug('Read:', "read file #{File.expand_path(path)}") elsif path.length < 256 @doing_file = File.expand_path(path) create(path) input = IO.read(File.expand_path(path)) input = input.force_encoding('utf-8') if input.respond_to? :force_encoding logger.debug('Read:', "read file #{File.expand_path(path)}") end @other_content_top = [] @other_content_bottom = [] section = nil lines = input.split(/[\n\r]/) lines.each do |line| next if line =~ /^\s*$/ if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/ section = Regexp.last_match(1) @content.add_section(Section.new(section, original: line), log: false) elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/ if section.nil? section = 'Uncategorized' @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false) end date = Regexp.last_match(1).strip title = Regexp.last_match(2).strip item = Item.new(date, title, section) @content.push(item) elsif @content.count.zero? # if content[section].items.length - 1 == current @other_content_top.push(line) elsif line =~ /^\S/ @other_content_bottom.push(line) else prev_item = @content.last prev_item.note = Note.new unless prev_item.note prev_item.note.add(line) # end end end Hooks.trigger :post_read, self end ## ## Create a new doing file ## def create(filename = nil) filename = @doing_file if filename.nil? return if File.exist?(filename) && File.stat(filename).size.positive? FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename)) File.open(filename, 'w+') do |f| f.puts "#{@config['current_section']}:" end end ## ## Create a process for an editor and wait for the file handle to return ## ## @param input [String] Text input for editor ## def fork_editor(input = '', message: :default) # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST'] raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil? tmpfile = Tempfile.new(['doing', '.md']) File.open(tmpfile.path, 'w+') do |f| f.puts input unless message.nil? f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message end end pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") } trap('INT') do begin Process.kill(9, pid) rescue StandardError Errno::ESRCH end tmpfile.unlink tmpfile.close! exit 0 end Process.wait(pid) begin if $?.exitstatus == 0 input = IO.read(tmpfile.path) else exit_now! 'Cancelled' end ensure tmpfile.close tmpfile.unlink end input.split(/\n/).delete_if(&:ignore?).join("\n") end ## ## Takes a multi-line string and formats it as an entry ## ## @param input [String] The string to parse ## ## @return [Array] [[String]title, [Note]note] ## def format_input(input) raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty? input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?) title = input_lines[0]&.strip raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty? date = nil iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/ watch_tags = [ 'start(?:ed)?', 'beg[ia]n', 'done', 'finished', 'completed?', 'waiting', 'defer(?:red)?' ] if @config['date_tags'] date_tags = @config['date_tags'] date_tags = date_tags.split(/ *, */) if date_tags.is_a?(String) date_tags.map! do |tag| tag.sub(/^@/, '').gsub(/\((?!\?:)(.*?)\)/, '(?:\1)').strip end watch_tags.concat(date_tags).uniq! end done_rx = /(?<=^| )@(?#{watch_tags.join('|')})\((?.*?)\)/i date_rx = /^(?:\s*- )?(?.*?) \| (?=\S)/ title.gsub!(done_rx) do m = Regexp.last_match t = m['tag'] d = m['date'] parsed_date = d =~ date_rx ? Time.parse(d) : d.chronify(guess: :begin) parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})" end if title =~ date_rx m = title.match(date_rx) d = m['date'] date = if d =~ iso_rx Time.parse(d) else d.chronify(guess: :begin) end title.sub!(date_rx, '').strip! end note = Note.new note.add(input_lines[1..-1]) if input_lines.length > 1 # If title line ends in a parenthetical, use that as the note if note.empty? && title =~ /\s+\(.*?\)$/ title.sub!(/\s+\((?.*?)\)$/) do m = Regexp.last_match note.add(m['note']) '' end end note.strip_lines! note.compress [date, title, note] end ## ## List sections ## ## @return [Array] section titles ## def sections @content.section_titles end ## ## Attempt to match a string with an existing section ## ## @param frag [String] The user-provided string ## @param guessed [Boolean] already guessed and failed ## def guess_section(frag, guessed: false, suggest: false) return 'All' if frag =~ /^all$/i frag ||= @config['current_section'] return frag.cap_first if @content.section?(frag) section = nil re = frag.to_rx(distance: 2, case_type: :ignore) sections.each do |sect| next unless sect =~ /#{re}/i logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}")) section = sect break end return section if suggest unless section || guessed alt = guess_view(frag, guessed: true, suggest: true) if alt meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n') raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view end res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n') if res @content.add_section(frag.cap_first, log: true) write(@doing_file) return frag.cap_first end raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:') end section ? section.cap_first : guessed end ## ## Attempt to match a string with an existing view ## ## @param frag [String] The user-provided string ## @param guessed [Boolean] already guessed ## def guess_view(frag, guessed: false, suggest: false) views.each { |view| return view if frag.downcase == view.downcase } view = false re = frag.to_rx(distance: 2, case_type: :ignore) views.each do |v| next unless v =~ /#{re}/i logger.debug('Match:', %(Assuming "#{v}" from "#{frag}")) view = v break end unless view || guessed alt = guess_section(frag, guessed: true, suggest: true) raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n') raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:') end view end ## ## Adds an entry ## ## @param title [String] The entry title ## @param section [String] The section to add to ## @param opt [Hash] Additional Options ## ## @option opt :date [Date] item start date ## @option opt :note [Array] item note (will be converted if value is String) ## @option opt :back [Date] backdate ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done ## def add_item(title, section = nil, opt) opt ||= {} section ||= @config['current_section'] @content.add_section(section, log: false) opt[:back] ||= opt[:date] ? opt[:date] : Time.now opt[:date] ||= Time.now note = Note.new opt[:timed] ||= false note.add(opt[:note]) if opt[:note] title = [title.strip.cap_first] title = title.join(' ') if @auto_tag title = autotag(title) title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty? end title.compress! entry = Item.new(opt[:back], title.strip, section) entry.note = note items = @content.dup if opt[:timed] items.reverse! items.each_with_index do |i, x| next if i.title =~ / @done/ items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})" break end end Hooks.trigger :pre_entry_add, self, entry @content.push(entry) # logger.count(:added, level: :debug) logger.info('New entry:', %(added "#{entry.title}" to #{section})) Hooks.trigger :post_entry_added, self, entry.dup end ## ## Remove items from a list that already exist in @content ## ## @param items [Array] The items to deduplicate ## @param no_overlap [Boolean] Remove items with overlapping time spans ## def dedup(items, no_overlap: false) items.delete_if do |item| duped = false @content.each do |comp| duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp) break if duped end logger.count(:skipped, level: :debug, message: '%count overlapping %items') if duped # logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped duped end end ## ## Imports external entries ## ## @param paths [String] Path to JSON report file ## @param opt [Hash] Additional Options ## def import(paths, opt) opt ||= {} Plugins.plugins[:import].each do |_, options| next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i if paths.count.positive? paths.each do |path| options[:class].import(self, path, options: opt) end else options[:class].import(self, nil, options: opt) end break end end ## ## Return the content of the last note for a given section ## ## @param section [String] The section to retrieve from, default ## All ## def last_note(section = 'All') section = guess_section(section) last_item = last_entry({ section: section }) raise NoEntryError, 'No entry found' unless last_item logger.log_now(:info, 'Edit note:', last_item.title) note = last_item.note&.to_s || '' "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}" end # Reset start date to current time, optionally remove # done tag (resume) # # @param item [Item] the item to reset/resume # @param resume [Boolean] removing @done tag if true # def reset_item(item, date: nil, resume: false) date ||= Time.now item.date = date item.tag('done', remove: true) if resume logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section})) item end # Duplicate an item and add it as a new item # # @param item [Item] the item to duplicate # @param opt [Hash] additional options # # @option opt :editor [Boolean] open new item in editor # @option opt :date [String] set start date # @option opt :in [String] add new item to section :in # @option opt :note [Note] add note to new item # # @return nothing # def repeat_item(item, opt) opt ||= {} if item.should_finish? if item.should_time? item.title.tag!('done', value: Time.now.strftime('%F %R')) else item.title.tag!('done') end Hooks.trigger :post_entry_updated, self, item end # Remove @done tag title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp section = opt[:in].nil? ? item.section : guess_section(opt[:in]) @auto_tag = false note = opt[:note] || Note.new if opt[:editor] start = opt[:date] ? opt[:date] : Time.now to_edit = "#{start.strftime('%F %R')} | #{title}" to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty? new_item = fork_editor(to_edit) date, title, note = format_input(new_item) opt[:date] = date unless date.nil? if title.nil? || title.empty? logger.warn('Skipped:', 'No content provided') return end end # @content.update_item(original, item) add_item(title, section, { note: note, back: opt[:date], timed: true }) end ## ## Restart the last entry ## ## @param opt [Hash] Additional Options ## def repeat_last(opt) opt ||= {} opt[:section] ||= 'all' opt[:section] = guess_section(opt[:section]) opt[:note] ||= [] opt[:tag] ||= [] opt[:tag_bool] ||= :and last = last_entry(opt) if last.nil? logger.warn('Skipped:', 'No previous entry found') return end repeat_item(last, opt) write(@doing_file) end ## ## Get the last entry ## ## @param opt [Hash] Additional Options ## def last_entry(opt) opt ||= {} opt[:tag_bool] ||= :and opt[:section] ||= @config['current_section'] items = filter_items(Items.new, opt: opt) logger.debug('Filtered:', "Parameters matched #{items.count} entries") if opt[:interactive] last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true, header: '', prompt: 'Select an entry > ', multiple: false, sort: false, show_if_single: true ) else last_entry = items.max_by { |item| item.date } end last_entry end def all_tags(items, opt: {}, counts: false) if counts all_tags = {} items.each do |item| item.tags.each do |tag| if all_tags.key?(tag.downcase) all_tags[tag.downcase] += 1 else all_tags[tag.downcase] = 1 end end end all_tags.sort_by { |tag, count| count } else all_tags = [] items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! } all_tags.sort end end def tag_groups(items, opt: {}) all_items = filter_items(items, opt: opt) tags = all_tags(all_items, opt: {}) tag_groups = {} tags.each do |tag| tag_groups[tag] ||= [] tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or }) end tag_groups end def fuzzy_filter_items(items, opt: {}) scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n") fzf_args = [ '--multi', %(--filter="#{opt[:search].sub(/^'?/, "'")}"), '--no-sort', '-d "\|"', '--nth=1' ] if opt[:case] fzf_args << case opt[:case].normalize_case when :sensitive '+i' when :ignore '-i' end end # fzf_args << '-e' if opt[:exact] # puts fzf_args.join(' ') res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}` selected = Items.new res.split(/\n/).each do |item| idx = item.match(/\|(\d+)$/)[1].to_i selected.push(items[idx]) end selected end ## ## Filter items based on search criteria ## ## @param items [Array] The items to filter (if empty, filters all items) ## @param opt [Hash] The filter parameters ## ## @option opt [String] :section ('all') ## @option opt [Boolean] :unfinished (false) ## @option opt [Array or String] :tag ([]) Array or comma-separated string ## @option opt [Symbol] :tag_bool (:and) :and, :or, :not ## @option opt [String] :search ('') string, optional regex with `/string/` ## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end] ## @option opt [Boolean] :only_timed (false) ## @option opt [String] :before (nil) Date/Time string, unparsed ## @option opt [String] :after (nil) Date/Time string, unparsed ## @option opt [Boolean] :today (false) limit to entries from today ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday ## @option opt [Number] :count (0) max entries to return ## @option opt [String] :age (new) 'old' or 'new' ## @option opt [Array] :val (nil) Array of tag value queries ## def filter_items(items = Items.new, opt: {}) time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/ if items.nil? || items.empty? section = opt[:section] ? guess_section(opt[:section]) : 'All' items = section =~ /^all$/i ? @content.dup : @content.in_section(section) end opt[:time_filter] = [nil, nil] if opt[:from] && !opt[:date_filter] date_string = opt[:from] case date_string when / (to|through|thru|(un)?til|-+) / dates = date_string.split(/ (?:to|through|thru|(?:un)?til|-+) /) if dates[0].strip =~ time_rx && dates[-1].strip =~ time_rx time_start = dates[0].strip time_end = dates[-1].strip else start = dates[0].chronify(guess: :begin) finish = dates[-1].chronify(guess: :end) end when time_rx time_start = date_string time_end = nil else start = date_string.chronify(guess: :begin) finish = false end if time_start opt[:time_filter] = [time_start, time_end] Doing.logger.debug('Parser:', "--from string interpreted as time span, from #{time_start ? time_start : '12am'} to #{time_end ? time_end : '11:59pm'}") else raise InvalidTimeExpression, 'Unrecognized date string' unless start opt[:date_filter] = [start, finish] Doing.logger.debug('Parser:', "--from string interpreted as #{start.strftime('%F %R')} -- #{finish ? finish.strftime('%F %R') : 'now'}") end end if opt[:before] =~ time_rx opt[:time_filter][1] = opt[:before] opt[:before] = nil end if opt[:after] =~ time_rx opt[:time_filter][0] = opt[:after] opt[:after] = nil end items.sort_by! { |item| [item.date, item.title.downcase] }.reverse filtered_items = items.select do |item| keep = true if opt[:unfinished] finished = item.tags?('done', :and) finished = opt[:not] ? !finished : finished keep = false if finished end if keep && opt[:val]&.count&.positive? bool = opt[:bool].normalize_bool if opt[:bool] bool ||= :and bool = :and if bool == :pattern val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool) keep = false unless val_match keep = opt[:not] ? !keep : keep end if keep && opt[:tag] opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool] opt[:tag_bool] ||= :and tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool]) keep = false unless tag_match keep = opt[:not] ? !keep : keep end if keep && opt[:search] search_match = if opt[:search].nil? || opt[:search].empty? true else item.search(opt[:search], case_type: opt[:case].normalize_case) end keep = false unless search_match keep = opt[:not] ? !keep : keep end if keep && opt[:date_filter]&.length == 2 start_date = opt[:date_filter][0] end_date = opt[:date_filter][1] in_date_range = if end_date item.date >= start_date && item.date <= end_date else item.date.strftime('%F') == start_date.strftime('%F') end keep = false unless in_date_range keep = opt[:not] ? !keep : keep end if keep && opt[:time_filter][0] || opt[:time_filter][1] start_string = if opt[:time_filter][0].nil? "#{item.date.strftime('%Y-%m-%d')} 12am" else "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}" end start_time = start_string.chronify(guess: :begin) end_string = if opt[:time_filter][1].nil? "#{item.date.next_day.strftime('%Y-%m-%d')} 12am" else "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}" end end_time = end_string.chronify(guess: :end) in_time_range = item.date >= start_time && item.date <= end_time keep = false unless in_time_range keep = opt[:not] ? !keep : keep end keep = false if keep && opt[:only_timed] && !item.interval if keep && opt[:tag_filter] keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool']) keep = opt[:not] ? !keep : keep end if keep && opt[:before] time_string = opt[:before] if time_string =~ time_rx cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :begin) else cutoff = time_string.chronify(guess: :begin) end keep = cutoff && item.date <= cutoff keep = opt[:not] ? !keep : keep end if keep && opt[:after] time_string = opt[:after] if time_string =~ time_rx cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :end) else cutoff = time_string.chronify(guess: :end) end keep = cutoff && item.date >= cutoff keep = opt[:not] ? !keep : keep end if keep && opt[:today] keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time keep = opt[:not] ? !keep : keep elsif keep && opt[:yesterday] keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time keep = opt[:not] ? !keep : keep end keep end count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count output = Items.new if opt[:age] && opt[:age].normalize_age == :oldest output.concat(filtered_items.slice(0, count).reverse) else output.concat(filtered_items.reverse.slice(0, count)) end output end def delete_items(items, force: false) res = force ? true : Prompt.yn("Delete #{items.size} #{items.size == 1 ? 'item' : 'items'}?", default_response: 'y') if res items.each do |i| deleted = @content.delete_item(i, single: items.count == 1) Hooks.trigger :post_entry_removed, self, deleted end write(@doing_file) end end def edit_items(items) editable_items = [] items.each do |i| editable = "#{i.date.strftime('%F %R')} | #{i.title}" old_note = i.note ? i.note.strip_lines.join("\n") : nil editable += "\n#{old_note}" unless old_note.nil? editable_items << editable end divider = "\n-----------\n" notice =<<~EONOTICE # - You may delete entries, but leave all divider lines (---) in place. # - Start and @done dates replaced with a time string (yesterday 3pm) will # be parsed automatically. Do not delete the pipe (|) between start date # and entry title. EONOTICE input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}" new_items = fork_editor(input).split(/#{divider}/) new_items.each_with_index do |new_item, i| input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?) first_line = input_lines[0]&.strip if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty? deleted = @content.delete_item(items[i], single: new_items.count == 1) Hooks.trigger :post_entry_removed, self, deleted Doing.logger.count(:deleted) else date, title, note = format_input(new_item) note.map!(&:strip) note.delete_if(&:ignore?) item = items[i] old_item = item.dup item.date = date || items[i].date item.title = title item.note = note if (item.equal?(old_item)) Doing.logger.count(:skipped, level: :debug) else Doing.logger.count(:updated) Hooks.trigger :post_entry_updated, self, item end end end end ## ## Display an interactive menu of entries ## ## @param opt [Hash] Additional options ## ## Options hash is shared with #filter_items and #act_on ## def interactive(opt) opt ||= {} opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All' search = nil if opt[:search] search = opt[:search] search.sub!(/^'?/, "'") if opt[:exact] opt[:search] = search end opt[:query] = opt[:search] if opt[:search] && !opt[:query] opt[:query] = "!#{opt[:query]}" if opt[:not] opt[:multiple] = true opt[:show_if_single] = true filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) { |k, hsh| hsh[k] = opt[k] } items = filter_items(Items.new, opt: filter_options) menu_options = %i[search query exact multiple show_if_single menu sort case].each_with_object({}) { |k, hsh| hsh[k] = opt[k] } selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **menu_options) raise NoResults, 'no items selected' if selection.nil? || selection.empty? act_on(selection, opt) end ## ## Perform actions on a set of entries. If ## no valid action is included in the opt ## hash and the terminal is a TTY, a menu ## will be presented ## ## @param items [Array] Array of Items to affect ## @param opt [Hash] Options and actions to perform ## ## @option opt [Boolean] :editor ## @option opt [Boolean] :delete ## @option opt [String] :tag ## @option opt [Boolean] :flag ## @option opt [Boolean] :finish ## @option opt [Boolean] :cancel ## @option opt [Boolean] :archive ## @option opt [String] :output ## @option opt [String] :save_to ## @option opt [Boolean] :again ## @option opt [Boolean] :resume ## def act_on(items, opt) opt ||= {} actions = %i[editor delete tag flag finish cancel archive output save_to again resume] has_action = false single = items.count == 1 actions.each do |a| if opt[a] has_action = true break end end unless has_action actions = [ 'add tag', 'remove tag', 'cancel', 'delete', 'finish', 'flag', 'archive', 'move', 'edit', 'output formatted' ] actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1 choice = Prompt.choose_from(actions, prompt: 'What do you want to do with the selected items? > ', multiple: true, sorted: false, fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden']) return unless choice to_do = choice.strip.split(/\n/) to_do.each do |action| case action when /resume/ opt[:resume] = true when /reset/ opt[:reset] = true when /(add|remove) tag/ type = action =~ /^add/ ? 'add' : 'remove' raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag] print "#{yellow("Tag to #{type}: ")}#{reset}" tag = $stdin.gets next if tag =~ /^ *$/ opt[:tag] = tag.strip.sub(/^@/, '') opt[:remove] = true if type == 'remove' when /output formatted/ plugins = Plugins.available_plugins(type: :export).sort output_format = Prompt.choose_from(plugins, prompt: 'Which output format? > ', fzf_args: [ "--height=#{plugins.count + 3}", '--tac', '--no-sort', '--info=hidden' ]) next if tag =~ /^ *$/ raise UserCancelled unless output_format opt[:output] = output_format.strip res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n') if res print "#{yellow('File path/name: ')}#{reset}" filename = $stdin.gets.strip next if filename.empty? opt[:save_to] = filename end when /archive/ opt[:archive] = true when /delete/ opt[:delete] = true when /edit/ opt[:editor] = true when /finish/ opt[:finish] = true when /cancel/ opt[:cancel] = true when /move/ section = choose_section.strip opt[:move] = section.strip unless section =~ /^ *$/ when /flag/ opt[:flag] = true end end end if opt[:resume] || opt[:reset] raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1 item = items[0] if opt[:resume] && !opt[:reset] repeat_item(item, { editor: opt[:editor] }) # hooked elsif opt[:reset] res = Prompt.enter_text('Start date (blank for current time)', default_response: '') if res =~ /^ *$/ date = Time.now else date = res.chronify(guess: :begin) end res = if item.tags?('done', :and) && !opt[:resume] opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y') else opt[:resume] end new_entry = reset_item(item, date: date, resume: res) @content.update_item(item, new_entry) Hooks.trigger :post_entry_updated, self, new_entry end write(@doing_file) return end if opt[:delete] delete_items(items, force: opt[:force]) # hooked return end if opt[:flag] tag = @config['marker_tag'] || 'flagged' items.map! do |i| i.tag(tag, date: false, remove: opt[:remove], single: single) Hooks.trigger :post_entry_updated, self, i end end if opt[:finish] || opt[:cancel] tag = 'done' items.map! do |i| if i.should_finish? should_date = !opt[:cancel] && i.should_time? i.tag(tag, date: should_date, remove: opt[:remove], single: single) Hooks.trigger :post_entry_updated, self, i end end end if opt[:tag] tag = opt[:tag] items.map! do |i| i.tag(tag, date: false, remove: opt[:remove], single: single) Hooks.trigger :post_entry_updated, self, i end end if opt[:archive] || opt[:move] section = opt[:archive] ? 'Archive' : guess_section(opt[:move]) items.map! do |i| i.move_to(section, label: true) Hooks.trigger :post_entry_updated, self, i end end write(@doing_file) if opt[:editor] edit_items(items) # hooked write(@doing_file) end return unless opt[:output] items.map! do |i| i.title = "#{i.title} @project(#{i.section})" i end @content = Items.new @content.concat(items) @content.add_section(Section.new('Export'), log: false) options = { section: 'Export' } if opt[:output] =~ /doing/ options[:output] = 'template' options[:template] = '- %date | %title%note' else options[:output] = opt[:output] options[:template] = opt[:template] || nil end output = list_section(options) # hooked if opt[:save_to] file = File.expand_path(opt[:save_to]) if File.exist?(file) # Create a backup copy for the undo command FileUtils.cp(file, "#{file}~") end File.open(file, 'w+') do |f| f.puts output end logger.warn('File written:', file) else Doing::Pager.page output end end ## ## Tag the last entry or X entries ## ## @param opt [Hash] Additional Options (see ## #filter_items for filtering ## options) ## ## @see #filter_items ## def tag_last(opt) # hooked opt ||= {} opt[:count] ||= 1 opt[:archive] ||= false opt[:tags] ||= ['done'] opt[:sequential] ||= false opt[:date] ||= false opt[:remove] ||= false opt[:autotag] ||= false opt[:back] ||= false opt[:unfinished] ||= false opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All' items = filter_items(Items.new, opt: opt) if opt[:interactive] items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true, header: '', prompt: 'Select entries to tag > ', multiple: true, sort: true, show_if_single: true) raise NoResults, 'no items selected' if items.empty? end raise NoResults, 'no items matched your search' if items.empty? items.each do |item| added = [] removed = [] if opt[:autotag] new_title = autotag(item.title) if @auto_tag if new_title == item.title logger.count(:skipped, level: :debug, message: '%count unchaged %items') # logger.debug('Autotag:', 'No changes') else logger.count(:added_tags) logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title) item.title = new_title end else if opt[:sequential] next_entry = next_item(item) done_date = if next_entry.nil? Time.now else next_entry.date - 60 end elsif opt[:took] if item.date + opt[:took] > Time.now item.date = Time.now - opt[:took] done_date = Time.now else done_date = item.date + opt[:took] end elsif opt[:back] done_date = if opt[:back].is_a? Integer item.date + opt[:back] else item.date + (opt[:back] - item.date) end else done_date = Time.now end opt[:tags].each do |tag| if tag == 'done' && !item.should_finish? Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}") logger.count(:skipped, level: :debug) next end tag = tag.strip if opt[:remove] || opt[:rename] || opt[:value] rename_to = nil if opt[:value] rename_to = tag elsif opt[:rename] rename_to = tag tag = opt[:rename] end old_title = item.title.dup force = opt[:value].nil? ? false : true item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force) if old_title != item.title removed << tag added << rename_to if rename_to else logger.count(:skipped, level: :debug) end else old_title = item.title.dup should_date = opt[:date] && item.should_time? item.title.tag!('done', remove: true) if tag =~ /done/ && !should_date item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil) added << tag if old_title != item.title end end end logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1) item.note.add(opt[:note]) if opt[:note] if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive? item.move_to('Archive', label: true) elsif opt[:archive] && opt[:count].zero? logger.warn('Skipped:', 'Archiving is skipped when operating on all entries') end Hooks.trigger :post_entry_updated, self, item end write(@doing_file) end ## ## Get next item in the index ## ## @param item [Item] target item ## @param options [Hash] additional options ## @see #filter_items ## ## @return [Item] the next chronological item in the index ## def next_item(item, options) options ||= {} items = filter_items(Items.new, opt: options) idx = items.index(item) idx.positive? ? items[idx - 1] : nil end ## ## Edit the last entry ## ## @param section [String] The section, default "All" ## def edit_last(section: 'All', options: {}) options[:section] = guess_section(section) item = last_entry(options) if item.nil? logger.debug('Skipped:', 'No entries found') return end content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"] content << item.note.strip_lines.join("\n") unless item.note.empty? new_item = fork_editor(content.join("\n")) date, title, note = format_input(new_item) date ||= item.date if title.nil? || title.empty? logger.debug('Skipped:', 'No content provided') elsif title == item.title && note.equal?(item.note) && date.equal?(item.date) logger.debug('Skipped:', 'No change in content') else item.date = date unless date.nil? item.title = title item.note.add(note, replace: true) logger.info('Edited:', item.title) Hooks.trigger :post_entry_updated, self, item.dup write(@doing_file) end end ## ## Accepts one tag and the raw text of a new item if the ## passed tag is on any item, it's replaced with @done. ## if new_item is not nil, it's tagged with the passed ## tag and inserted. This is for use where only one ## instance of a given tag should exist (@meanwhile) ## ## @param target_tag [String] Tag to replace ## @param opt [Hash] Additional Options ## ## @option opt :section [String] target section ## @option opt :archive [Boolean] archive old item ## @option opt :back [Date] backdate new item ## @option opt :new_item [String] content to use for new item ## @option opt :note [Array] note content for new item def stop_start(target_tag, opt) opt ||= {} tag = target_tag.dup opt[:section] ||= @config['current_section'] opt[:archive] ||= false opt[:back] ||= Time.now opt[:new_item] ||= false opt[:note] ||= false opt[:section] = guess_section(opt[:section]) tag.sub!(/^@/, '') found_items = 0 @content.each_with_index do |item, i| next unless item.section == opt[:section] || opt[:section] =~ /all/i next unless item.title =~ /@#{tag}/ item.title.add_tags!([tag, 'done'], remove: true) item.tag('done', value: opt[:back].strftime('%F %R')) found_items += 1 if opt[:archive] && opt[:section] != 'Archive' item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})") item.move_to('Archive', label: false, log: false) logger.count(:completed_archived) logger.info('Completed/archived:', item.title) else logger.count(:completed) logger.info('Completed:', item.title) end Hooks.trigger :post_entry_updated, self, item end logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero? if opt[:new_item] date, title, note = format_input(opt[:new_item]) opt[:back] = date unless date.nil? note.add(opt[:note]) if opt[:note] title.tag!(tag) add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] }) end write(@doing_file) end ## ## Write content to file or STDOUT ## ## @param file [String] The filepath to write to ## def write(file = nil, backup: true) Hooks.trigger :pre_write, self, file output = combined_content if file.nil? $stdout.puts output else Util.write_to_file(file, output, backup: backup) run_after if @config.key?('run_after') end end ## ## Rename doing file with date and start fresh one ## def rotate(opt) opt ||= {} keep = opt[:keep] || 0 tags = [] tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag] bool = opt[:bool] || :and sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all' section = guess_section(sect) section_items = @content.in_section(section) max = section_items.count - keep.to_i counter = 0 new_content = Items.new section_items.each do |item| break if counter >= max if opt[:before] time_string = opt[:before] cutoff = time_string.chronify(guess: :begin) end unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff)) new_item = @content.delete(item) Hooks.trigger :post_entry_removed, self, item.dup raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil? new_content.add_section(new_item.section, log: false) new_content.push(new_item) counter += 1 end end if counter.positive? logger.count(:rotated, level: :info, count: counter, message: "Rotated %count %items") else logger.info('Skipped:', 'No items were rotated') end write(@doing_file) file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1") if File.exist?(file) init_doing_file(file) @content.concat(new_content).uniq! logger.warn('File update:', "added entries to existing file: #{file}") else @content = new_content logger.warn('File update:', "created new file: #{file}") end write(file, backup: false) end ## ## Generate a menu of sections and allow user selection ## ## @return [String] The selected section name ## def choose_section(include_all: false) options = @content.section_titles.sort options.unshift('All') if include_all choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%']) choice ? choice.strip : choice end ## ## Generate a menu of tags and allow user selection ## ## @return [String] The selected tag name ## def choose_tag(section = 'All', items: nil, include_all: false) items ||= @content.in_section(section) tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" } tags.unshift('No tag filter') if include_all choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%']) choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice end ## ## Generate a menu of sections and tags and allow user selection ## ## @return [String] The selected section or tag name ## def choose_section_tag options = @content.section_titles.sort options.concat(@content.all_tags.sort.map { |t| "@#{t}" }) choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%']) choice ? choice.strip : choice end ## ## List available views ## ## @return [Array] View names ## def views @config.has_key?('views') ? @config['views'].keys : [] end ## ## Generate a menu of views and allow user selection ## ## @return [String] The selected view name ## def choose_view choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%']) choice ? choice.strip : choice end ## ## Gets a view from configuration ## ## @param title [String] The title of the view to retrieve ## def get_view(title) return @config['views'][title] if @config['views'].has_key?(title) false end ## ## Display contents of a section based on options ## ## @param opt [Hash] Additional Options ## def list_section(opt, items: Items.new) opt[:config_template] ||= 'default' tpl_cfg = @config.dig('templates', opt[:config_template]) cfg = if opt[:view_template] @config.dig('views', opt[:view_template]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true }) else tpl_cfg end cfg.deep_merge({ 'wrap_width' => @config['wrap_width'] || 0, 'date_format' => @config['default_date_format'], 'order' => @config['order'] || 'asc', 'tags_color' => @config['tags_color'], 'duration' => @config['duration'], 'interval_format' => @config['interval_format'] }, { extend_existing_arrays: true, sort_merged_arrays: true }) opt[:duration] ||= cfg['duration'] || false opt[:interval_format] ||= cfg['interval_format'] || 'text' opt[:count] ||= 0 opt[:age] ||= :newest opt[:age] = opt[:age].normalize_age opt[:format] ||= cfg['date_format'] opt[:order] ||= cfg['order'] || 'asc' opt[:tag_order] ||= 'asc' opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil? opt[:template] ||= cfg['template'] # opt[:highlight] ||= true title = '' is_single = true if opt[:section].nil? opt[:section] = choose_section title = opt[:section] elsif opt[:section].instance_of?(String) title = if opt[:section] =~ /^all$/i if opt[:page_title] opt[:page_title] elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ') else 'doing' end else guess_section(opt[:section]) end end items = filter_items(items, opt: opt) items.reverse! unless opt[:order] =~ /^d/i if opt[:delete] delete_items(items, force: opt[:force]) return elsif opt[:editor] edit_items(items) return elsif opt[:interactive] opt[:menu] = !opt[:force] opt[:query] = '' # opt[:search] opt[:multiple] = true selected = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt) raise NoResults, 'no items selected' if selected.empty? act_on(selected, opt) return end opt[:output] ||= 'template' opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0 output(items, title, is_single, opt) end ## ## Move entries from a section to Archive or other specified ## section ## ## @param section [String] The source section ## @param options [Hash] Options ## def archive(section = @config['current_section'], options) options ||= {} count = options[:keep] || 0 destination = options[:destination] || 'Archive' tags = options[:tags] || [] bool = options[:bool] || :and section = choose_section if section.nil? || section =~ /choose/i archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?) section = guess_section(section) unless archive_all @content.add_section(destination, log: true) # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive') destination = guess_section(destination) if @content.section?(destination) && (@content.section?(section) || archive_all) do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] }) write(doing_file) else raise InvalidArgument, 'Either source or destination does not exist' end end ## ## Show all entries from the current day ## ## @param times [Boolean] show times ## @param output [String] output format ## @param opt [Hash] Options ## def today(times = true, output = nil, opt) opt ||= {} opt[:totals] ||= false opt[:sort_tags] ||= false cfg = @config['templates']['today'].deep_merge(@config['templates']['default'], { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({ 'wrap_width' => @config['wrap_width'] || 0, 'date_format' => @config['default_date_format'], 'order' => @config['order'] || 'asc', 'tags_color' => @config['tags_color'], 'duration' => @config['duration'], 'interval_format' => @config['interval_format'] }, { extend_existing_arrays: true, sort_merged_arrays: true }) opt[:duration] ||= cfg['duration'] || false opt[:interval_format] ||= cfg['interval_format'] || 'text' options = { after: opt[:after], before: opt[:before], count: 0, duration: opt[:duration], from: opt[:from], format: cfg['date_format'], interval_format: opt[:interval_format], order: cfg['order'] || 'asc', output: output, section: opt[:section], sort_tags: opt[:sort_tags], template: cfg['template'], times: times, today: true, totals: opt[:totals], wrap_width: cfg['wrap_width'], tags_color: cfg['tags_color'], config_template: 'today' } list_section(options) end ## ## Display entries within a date range ## ## @param dates [Array] [start, end] ## @param section [String] The section ## @param times (Bool) Show times ## @param output [String] Output format ## @param opt [Hash] Additional Options ## def list_date(dates, section, times = nil, output = nil, opt) opt ||= {} opt[:totals] ||= false opt[:sort_tags] ||= false section = guess_section(section) # :date_filter expects an array with start and end date dates = [dates, dates] if dates.instance_of?(String) list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times, output: output, totals: opt[:totals], duration: opt[:duration], sort_tags: opt[:sort_tags], config_template: 'default' }) end ## ## Show entries from the previous day ## ## @param section [String] The section ## @param times (Bool) Show times ## @param output [String] Output format ## @param opt [Hash] Additional Options ## def yesterday(section, times = nil, output = nil, opt) opt ||= {} opt[:totals] ||= false opt[:sort_tags] ||= false section = guess_section(section) y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d') opt[:after] = "#{y} #{opt[:after]}" if opt[:after] opt[:before] = "#{y} #{opt[:before]}" if opt[:before] options = { after: opt[:after], before: opt[:before], count: 0, duration: opt[:duration], from: opt[:from], order: opt[:order], output: output, section: section, sort_tags: opt[:sort_tags], tag_order: opt[:tag_order], times: times, totals: opt[:totals], yesterday: true, config_template: 'today' } list_section(options) end ## ## Show recent entries ## ## @param count [Integer] The number to show ## @param section [String] The section to show from, default Currently ## @param opt [Hash] Additional Options ## def recent(count = 10, section = nil, opt) opt ||= {} times = opt[:t] || true opt[:totals] ||= false opt[:sort_tags] ||= false cfg = @config['templates']['recent'].deep_merge(@config['templates']['default'], { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({ 'wrap_width' => @config['wrap_width'] || 0, 'date_format' => @config['default_date_format'], 'order' => @config['order'] || 'asc', 'tags_color' => @config['tags_color'], 'duration' => @config['duration'], 'interval_format' => @config['interval_format'] }, { extend_existing_arrays: true, sort_merged_arrays: true }) opt[:duration] ||= cfg['duration'] || false opt[:interval_format] ||= cfg['interval_format'] || 'text' section ||= @config['current_section'] section = guess_section(section) opt[:section] = section opt[:wrap_width] = cfg['wrap_width'] opt[:count] = count opt[:format] = cfg['date_format'] opt[:template] = cfg['template'] opt[:order] = 'asc' opt[:times] = times opt[:config_template] = 'recent' list_section(opt) end ## ## Show the last entry ## ## @param times (Bool) Show times ## @param section [String] Section to pull from, default Currently ## def last(times: true, section: nil, options: {}) section = section.nil? || section =~ /all/i ? 'All' : guess_section(section) cfg = @config['templates']['last'].deep_merge(@config['templates']['default'], { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({ 'wrap_width' => @config['wrap_width'] || 0, 'date_format' => @config['default_date_format'], 'order' => @config['order'] || 'asc', 'tags_color' => @config['tags_color'], 'duration' => @config['duration'], 'interval_format' => @config['interval_format'] }, { extend_existing_arrays: true, sort_merged_arrays: true }) options[:duration] ||= cfg['duration'] || false options[:interval_format] ||= cfg['interval_format'] || 'text' opts = { section: section, wrap_width: cfg['wrap_width'], count: 1, format: cfg['date_format'], template: cfg['template'], times: times, duration: options[:duration], interval_format: options[:interval_format], case: options[:case], not: options[:negate], config_template: 'last', delete: options[:delete], val: options[:val] } if options[:tag] opts[:tag_filter] = { 'tags' => options[:tag], 'bool' => options[:tag_bool] } end opts[:search] = options[:search] if options[:search] list_section(opts) end ## ## Uses 'autotag' configuration to turn keywords into tags for time tracking. ## Does not repeat tags in a title, and only converts the first instance of an ## untagged keyword ## ## @param string [String] The text to tag ## def autotag(string) return unless string return string unless @auto_tag original = string.dup text = string.dup current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') } tagged = { whitelisted: [], synonyms: [], transformed: [], replaced: [] } @config['autotag']['whitelist'].each do |tag| next if text =~ /@#{tag}\b/i text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m| m.downcase! unless tag =~ /[A-Z]/ tagged[:whitelisted].push(m) "@#{m}" end end @config['autotag']['synonyms'].each do |tag, v| v.each do |word| word = word.wildcard_to_rx next unless text =~ /\b#{word}\b/i unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag) tagged[:synonyms].push(tag) tagged[:synonyms] = tagged[:synonyms].uniq end end end if @config['autotag'].key? 'transform' @config['autotag']['transform'].each do |tag| next unless tag =~ /\S+:\S+/ if tag =~ /::/ rx, r = tag.split(/::/) else rx, r = tag.split(/:/) end flag_rx = %r{/([r]+)$} if r =~ flag_rx flags = r.match(flag_rx)[1].split(//) r.sub!(flag_rx, '') end r.gsub!(/\$/, '\\') rx.sub!(/^@?/, '@') regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)") text.sub!(regex) do m = Regexp.last_match new_tag = r m.to_a.slice(1, m.length - 1).each_with_index do |v, idx| new_tag.gsub!("\\#{idx + 1}", v) end # Replace original tag if /r if flags&.include?('r') tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') }) new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ') else tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') }) tagged[:transformed] = tagged[:transformed].uniq m[0] end end end end logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty? logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty? logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty? logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty? tail_tags = tagged[:synonyms].concat(tagged[:transformed]) tail_tags.sort! tail_tags.uniq! text.add_tags!(tail_tags) unless tail_tags.empty? if text == original logger.debug('Autotag:', "no change to \"#{text.strip}\"") else new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced]) logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"") logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items') end text.dedup_tags end ## ## Get total elapsed time for all tags in ## selection ## ## @param format [String] return format (html, ## json, or text) ## @param sort_by_name [Boolean] Sort by name if true, otherwise by time ## @param sort_order [String] The sort order (asc or desc) ## def tag_times(format: :text, sort_by_name: false, sort_order: 'asc') return '' if @timers.empty? max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1 total = @timers.delete('All') tags_data = @timers.delete_if { |_k, v| v == 0 } sorted_tags_data = if sort_by_name tags_data.sort_by { |k, _v| k } else tags_data.sort_by { |_k, v| v } end sorted_tags_data.reverse! if sort_order =~ /^asc/i case format when :html output = < Tag Totals project time EOS sorted_tags_data.reverse.each do |k, v| if v > 0 output += "#{k}#{'%02d:%02d:%02d' % format_time(v)}\n" end end tail = < Total #{'%02d:%02d:%02d' % format_time(total)} EOS output + tail when :markdown pad = sorted_tags_data.map {|k, v| k }.group_by(&:size).max.last[0].length pad = 7 if pad < 7 output = <<~EOS | #{' ' * (pad - 7) }project | time | | #{'-' * (pad - 1)}: | :------- | EOS sorted_tags_data.reverse.each do |k, v| if v > 0 output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % format_time(v)} |\n" end end tail = "[Tag Totals]" output + tail when :json output = [] sorted_tags_data.reverse.each do |k, v| d, h, m = format_time(v) output << { 'tag' => k, 'seconds' => v, 'formatted' => format('%02d:%02d:%02d', d: d, h: h, m: m) } end output when :human output = [] sorted_tags_data.reverse.each do |k, v| spacer = '' (max - k.length).times do spacer += ' ' end _d, h, m = format_time(v, human: true) output.push("┃ #{spacer}#{k}:#{format('% 4dh %02dm', h: h, m: m)} ┃") end header = '┏━━ Tag Totals ' (max - 2).times { header += '━' } header += '┓' footer = '┗' (max + 12).times { footer += '━' } footer += '┛' divider = '┣' (max + 12).times { divider += '━' } divider += '┫' output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}" d, h, m = format_time(total, human: true) output += "\n#{divider}" spacer = '' (max - 6).times do spacer += ' ' end total = "┃ #{spacer}total: " total += format('% 4dh %02dm', h: h, m: m) total += ' ┃' output += "\n#{total}" output += "\n#{footer}" output else output = [] sorted_tags_data.reverse.each do |k, v| spacer = '' (max - k.length).times do spacer += ' ' end d, h, m = format_time(v) output.push("#{k}:#{spacer}#{format('%02d:%02d:%02d', d: d, h: h, m: m)}") end output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}" d, h, m = format_time(total) output += "\n\nTotal tracked: #{format('%02d:%02d:%02d', d: d, h: h, m: m)}\n" output end end ## ## Gets the interval between entry's start ## date and @done date ## ## @param item [Item] The entry ## @param formatted [Boolean] Return human readable ## time (default seconds) ## @param record [Boolean] Add the interval to the ## total for each tag ## ## @return Interval in seconds, or [d, h, m] array if ## formatted is true. False if no end date or ## interval is 0 ## def get_interval(item, formatted: true, record: true) if item.interval seconds = item.interval record_tag_times(item, seconds) if record return seconds.positive? ? seconds : false unless formatted return seconds.positive? ? format('%02d:%02d:%02d', *format_time(seconds)) : false end false end ## ## Format human readable time from seconds ## ## @param seconds [Integer] Seconds ## def format_time(seconds, human: false) return [0, 0, 0] if seconds.nil? if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/ h = Regexp.last_match(1) m = Regexp.last_match(2) s = Regexp.last_match(3) seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i end minutes = (seconds / 60).to_i hours = (minutes / 60).to_i if human minutes = (minutes % 60).to_i [0, hours, minutes] else days = (hours / 24).to_i hours = (hours % 24).to_i minutes = (minutes % 60).to_i [days, hours, minutes] end end def configure(filename = nil) if filename Doing.config_with(filename, { ignore_local: true }) elsif ENV['DOING_CONFIG'] Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true }) end Doing.logger.benchmark(:configure, :start) config = Doing.config Doing.logger.benchmark(:configure, :finish) config.settings['backup_dir'] = ENV['DOING_BACKUP_DIR'] if ENV['DOING_BACKUP_DIR'] @config = config.settings end def get_diff(filename = nil) configure if @config.nil? filename ||= @config['doing_file'] init_doing_file(filename) current_content = @content.dup backup_file = Util::Backup.last_backup(filename, count: 1) raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil? backup = WWID.new backup.config = @config backup.init_doing_file(backup_file) current_content.diff(backup.content) end private ## ## Wraps doing file content with additional ## header/footer content ## ## @return [String] concatenated content ## def combined_content output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : '' was_color = Color.coloring? Color.coloring = false output += @content.to_s output += @other_content_bottom.join("\n") unless @other_content_bottom.nil? # Just strip all ANSI colors from the content before writing to doing file Color.coloring = was_color output.uncolor end ## ## Generate output using available export plugins ## ## @param items [Array] The items ## @param title [String] Page title ## @param is_single [Boolean] Indicates if single ## section ## @param opt [Hash] Additional options ## ## @return [String] formatted output based on opt[:output] ## template trigger ## def output(items, title, is_single, opt) opt ||= {} out = nil raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export) export_options = { page_title: title, is_single: is_single, options: opt } Hooks.trigger :pre_export, self, opt[:output], items Plugins.plugins[:export].each do |_, options| next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i out = options[:class].render(self, items, variables: export_options) break end out end ## ## Record times for item tags ## ## @param item [Item] The item to record ## def record_tag_times(item, seconds) item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}" return if @recorded_items.include?(item_hash) item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m| k = m[0] == 'done' ? 'All' : m[0].downcase if @timers.key?(k) @timers[k] += seconds else @timers[k] = seconds end @recorded_items.push(item_hash) end end ## ## Helper function, performs the actual archiving ## ## @param section [String] The source section ## @param destination [String] The destination ## section ## @param opt [Hash] Additional Options ## def do_archive(section, destination, opt) opt ||= {} count = opt[:count] || 0 tags = opt[:tags] || [] bool = opt[:bool] || :and label = opt[:label] || true section = guess_section(section) destination = guess_section(destination) section_items = @content.in_section(section) max = section_items.count - count.to_i moved_items = [] counter = 0 @content.map do |item| break if counter >= max if opt[:before] time_string = opt[:before] cutoff = time_string.chronify(guess: :begin) end if (item.section.downcase != section.downcase && section != /^all$/i) || item.section.downcase == destination.downcase item elsif ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff)) item else counter += 1 item.move_to(destination, label: label, log: false) Hooks.trigger :post_entry_updated, self, item.dup item end end if counter.positive? logger.count(destination == 'Archive' ? :archived : :moved, level: :info, count: counter, message: "%count %items from #{section} to #{destination}") else logger.info('Skipped:', 'No items were moved') end end def run_after return unless @config.key?('run_after') _, stderr, status = Open3.capture3(@config['run_after']) return unless status.exitstatus.positive? logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}") logger.log_now(:error, 'STDERR output:', stderr) end end end