#!/usr/bin/ruby # frozen_string_literal: true require 'deep_merge' require 'open3' require 'pp' require 'shellwords' require 'erb' module Doing ## ## @brief 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 Util ## ## @brief Initializes the object. ## def initialize @timers = {} @recorded_items = [] @content = {} @doingrc_needs_update = false @default_config_file = '.doingrc' @auto_tag = true @user_home = Util.user_home end ## ## @brief 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 ## ## @brief 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 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 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 end @other_content_top = [] @other_content_bottom = [] section = 'Uncategorized' lines = input.split(/[\n\r]/) current = 0 lines.each do |line| next if line =~ /^\s*$/ if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/ section = Regexp.last_match(1) @content[section] = {} @content[section][:original] = line @content[section][:items] = [] current = 0 elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/ date = Regexp.last_match(1).strip title = Regexp.last_match(2).strip item = Item.new(date, title, section) @content[section][:items].push(item) current += 1 elsif current.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[section][:items][current - 1] prev_item.note = Note.new unless prev_item.note prev_item.note.add(line) # end end end Hooks.trigger :post_read, self end ## ## @brief 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? File.open(filename, 'w+') do |f| f.puts "#{@config['current_section']}:" end end ## ## @brief 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 = '') # 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 f.puts "\n# The first line is the entry title, any lines after that are added as a note" 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 ## ## @brief Takes a multi-line string and formats it as an entry ## ## @return (Array) [(String)title, (Array)note] ## ## @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? 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[1]) '' end end note.strip_lines! note.compress [title, note] end ## ## @brief Converts input string into a Time object when input takes on the ## following formats: ## - interval format e.g. '1d2h30m', '45m' etc. ## - a semantic phrase e.g. 'yesterday 5:30pm' ## - a strftime e.g. '2016-03-15 15:32:04 PDT' ## ## @param input (String) String to chronify ## ## @return (DateTime) result ## def chronify(input, future: false, guess: :begin) now = Time.now raise InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == '' secs_ago = if input.match(/^(\d+)$/) # plain number, assume minutes Regexp.last_match(1).to_i * 60 elsif (m = input.match(/^(?:(?\d+)d)?(?:(?\d+)h)?(?:(?\d+)m)?$/i)) # day/hour/minute format e.g. 1d2h30m [[m['day'], 24 * 3600], [m['hour'], 3600], [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+) end if secs_ago now - secs_ago else Chronic.parse(input, { guess: guess, context: future ? :future : :past, ambiguous_time_range: 8 }) end end ## ## @brief Converts simple strings into seconds that can be added to a Time ## object ## ## @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m, ## 1.5d, 1h20m, etc.) ## ## @return (Integer) seconds ## def chronify_qty(qty) minutes = 0 case qty.strip when /^(\d+):(\d\d)$/ minutes += Regexp.last_match(1).to_i * 60 minutes += Regexp.last_match(2).to_i when /^(\d+(?:\.\d+)?)([hmd])?$/ amt = Regexp.last_match(1) type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2) minutes = case type.downcase when 'm' amt.to_i when 'h' (amt.to_f * 60).round when 'd' (amt.to_f * 60 * 24).round else minutes end end minutes * 60 end ## ## @brief List sections ## ## @return (Array) section titles ## def sections @content.keys end ## ## @brief Adds a section. ## ## @param title (String) The new section title ## def add_section(title) if @content.key?(title.cap_first) raise InvalidSection, %(section "#{title.cap_first}" already exists) end @content[title.cap_first] = { :original => "#{title}:", :items => [] } logger.info('New section:', %("#{title.cap_first}")) end ## ## @brief 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'] sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase } section = false re = frag.split('').join('.*?') 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 = yn("#{Color.boldwhite}Did you mean `#{Color.yellow}doing view #{alt}#{Color.boldwhite}`?", default_response: 'n') raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view end res = yn("#{Color.boldwhite}Section #{frag.yellow}#{Color.boldwhite} not found, create it", default_response: 'n') if res add_section(frag.cap_first) write(@doing_file) return frag.cap_first end raise InvalidSection.new("unknown section #{frag.yellow}", topic: 'Missing:') end section ? section.cap_first : guessed end ## ## @brief Ask a yes or no question in the terminal ## ## @param question (String) The question to ask ## @param default (Bool) default response if no input ## ## @return (Bool) yes or no ## def yn(question, default_response: false) if default_response.is_a?(String) default = default_response =~ /y/i ? true : false else default = default_response end # if global --default is set, answer default return default if @default_option # if this isn't an interactive shell, answer default return default unless $stdout.isatty # clear the buffer if ARGV&.length ARGV.length.times do ARGV.shift end end system 'stty cbreak' cw = Color.white cbw = Color.boldwhite cbg = Color.boldgreen cd = Color.default options = unless default.nil? "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}" else "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}" end $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} " res = $stdin.sysread 1 puts system 'stty cooked' res.chomp! res.downcase! return default if res.empty? res =~ /y/i ? true : false end ## ## @brief 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.split('').join('.*?') 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) meant_view = 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(%(unkown view #{alt.yellow}), topic: 'Missing:') end view end ## ## @brief Adds an entry ## ## @param title (String) The entry title ## @param section (String) The section to add to ## @param opt (Hash) Additional Options {:date, :note, :back, :timed} ## def add_item(title, section = nil, opt = {}) section ||= @config['current_section'] add_section(section) unless @content.key?(section) opt[:date] ||= Time.now opt[:note] ||= [] opt[:back] ||= Time.now opt[:timed] ||= false opt[:note] = opt[:note].lines if opt[:note].is_a?(String) 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.gsub!(/ +/, ' ') entry = Item.new(opt[:back], title.strip, section) entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == '' items = @content[section][:items] 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 items.reverse! end items.push(entry) # logger.count(:added, level: :debug) logger.info('New entry:', %(added "#{entry.title}" to #{section})) end ## ## @brief 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) combined = [] @content.each do |_k, v| combined += v[:items] end items.delete_if do |item| duped = false combined.each do |comp| duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp) break if duped end logger.count(:skipped, level: :debug, message: 'overlapping %item') if duped # logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped duped end end ## ## @brief Imports external entries ## ## @param path (String) Path to JSON report file ## @param opt (Hash) Additional Options ## def import(paths, 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 ## ## @brief 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 def reset_item(item, resume: false) item.date = Time.now if resume item.tag('done', remove: true) end logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section})) item end def repeat_item(item, opt = {}) original = item.dup if item.should_finish? if item.should_time? item.title.tag!('done', value: Time.now.strftime('%F %R')) else item.title.tag!('done') end 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] to_edit = title to_edit += "\n#{note.to_s}" unless note.empty? new_item = fork_editor(to_edit) title, note = format_input(new_item) if title.nil? || title.empty? logger.warn('Skipped:', 'No content provided') return end end update_item(original, item) add_item(title, section, { note: note, back: opt[:date], timed: true }) write(@doing_file) end ## ## @brief Restart the last entry ## ## @param opt (Hash) Additional Options ## def repeat_last(opt = {}) opt[:section] ||= 'all' 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) end ## ## @brief Get the last entry ## ## @param opt (Hash) Additional Options ## def last_entry(opt = {}) opt[:tag_bool] ||= :and opt[:section] ||= @config['current_section'] items = filter_items([], opt: opt) logger.debug('Filtered:', "Parameters matched #{items.count} entries") if opt[:interactive] last_entry = choose_from_items(items, { menu: true, header: '', prompt: 'Select an entry > ', multiple: false, sort: false, show_if_single: true }, include_section: opt[:section] =~ /^all$/i ) else last_entry = items.max_by { |item| item.date } end last_entry end ## ## @brief Generate a menu of options and allow user selection ## ## @return (String) The selected option ## def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: []) return nil unless $stdout.isatty fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder') # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation fzf_args << %(--prompt "#{prompt}") fzf_args << '--multi' if multiple header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm" fzf_args << %(--header "#{header}") options.sort! if sorted res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}` return false if res.strip.size.zero? res end def all_tags(items, opt: {}) all_tags = [] items.each { |item| all_tags.concat(item.tags).uniq! } all_tags.sort 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 ## ## @brief Filter items based on search criteria ## ## @param items (Array) The items to filter (if empty, filters all items) ## @param opt (Hash) The filter parameters ## ## Available filter options in opt object ## ## - +:section+ (String) ## - +:unfinished+ (Boolean) ## - +:tag+ (Array or comma-separated string) ## - +:tag_bool+ (:and, :or, :not) ## - +:search+ (string, optional regex with //) ## - +:date_filter+ (Array[(Time)start, (Time)end]) ## - +:only_timed+ (Boolean) ## - +:before+ (Date/Time string, unparsed) ## - +:after+ (Date/Time string, unparsed) ## - +:today+ (Boolean) ## - +:yesterday+ (Boolean) ## - +:count+ (Number to return) ## - +:age+ (String, 'old' or 'new') ## def filter_items(items = [], opt: {}) if items.nil? || items.empty? section = opt[:section] ? guess_section(opt[:section]) : 'All' items = if section =~ /^all$/i @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) } else @content[section][:items].dup end end items.sort_by! { |item| [item.date, item.title.downcase] }.reverse filtered_items = items.select do |item| keep = true finished = opt[:unfinished] && item.tags?('done', :and) keep = false if finished if keep && opt[:tag] 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 end if keep && opt[:search] search_match = opt[:search].nil? || opt[:search].empty? ? true : item.search(opt[:search]) keep = false unless search_match 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 end keep = false if keep && opt[:only_timed] && !item.interval if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty? keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool']) end if keep && opt[:before] time_string = opt[:before] cutoff = chronify(time_string, guess: :begin) keep = cutoff && item.date <= cutoff end if keep && opt[:after] time_string = opt[:after] cutoff = chronify(time_string, guess: :end) keep = cutoff && item.date >= cutoff end if keep && opt[:today] keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time elsif keep && opt[:yesterday] keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time end keep end count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length if opt[:age] =~ /^o/i filtered_items.slice(0, count).reverse else filtered_items.reverse.slice(0, count) end end ## ## @brief Display an interactive menu of entries ## ## @param opt (Hash) Additional options ## def interactive(opt = {}) section = opt[:section] ? guess_section(opt[:section]) : 'All' opt[:query] = opt[:search] if opt[:search] && !opt[:query] opt[:multiple] = true items = filter_items([], opt: { section: section, search: opt[:search] }) selection = choose_from_items(items, opt, include_section: section =~ /^all$/i) raise NoResults, 'no items selected' if selection.empty? act_on(selection, opt) end def choose_from_items(items, opt = {}, include_section: false) return nil unless $stdout.isatty return nil unless items.count.positive? opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit" opt[:prompt] ||= "Select entries to act on > " pad = items.length.to_s.length options = items.map.with_index do |item, i| out = [ format("%#{pad}d", i), ') ', format('%13s', item.date.relative_date), ' | ', item.title ] if include_section out.concat([ ' (', item.section, ') ' ]) end out.join('') end fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder') fzf_args = [ %(--header="#{opt[:header]}"), %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"), opt[:multiple] ? '--multi' : '--no-multi', '-0', '--bind ctrl-a:select-all', %(-q "#{opt[:query]}") ] fzf_args.push('-1') unless opt[:show_if_single] unless opt[:menu] raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty? fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort']) end res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}` selected = [] res.split(/\n/).each do |item| idx = item.match(/^ *(\d+)\)/)[1].to_i selected.push(items[idx]) end opt[:multiple] ? selected : selected[0] end def act_on(items, 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 = choose_from(actions, prompt: 'What do you want to do with the selected items? > ', multiple: true, sorted: false, fzf_args: ['--height=60%', '--tac', '--no-sort']) 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 "#{Color.yellow}Tag to #{type}: #{Color.reset}" tag = $stdin.gets next if tag =~ /^ *$/ opt[:tag] = tag.strip.sub(/^@/, '') opt[:remove] = true if type == 'remove' when /output formatted/ output_format = choose_from(Plugins.available_plugins(type: :export).sort, prompt: 'Which output format? > ', fzf_args: ['--height=60%', '--tac', '--no-sort']) next if tag =~ /^ *$/ unless output_format raise UserCancelled, 'Cancelled' end opt[:output] = output_format.strip res = opt[:force] ? false : yn('Save to file?', default_response: 'n') if res print "#{Color.yellow}File path/name: #{Color.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] if items.count > 1 raise InvalidArgument, 'resume and restart can only be used on a single entry' else item = items[0] if opt[:resume] && !opt[:reset] repeat_item(item, { editor: opt[:editor] }) elsif opt[:reset] if item.tags?('done', :and) && !opt[:resume] res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y') else res = opt[:resume] end update_item(item, reset_item(item, resume: res)) end write(@doing_file) end return end if opt[:delete] res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y') if res items.each { |item| delete_item(item) } write(@doing_file) end return end if opt[:flag] tag = @config['marker_tag'] || 'flagged' items.map! do |item| tag_item(item, tag, date: false, remove: opt[:remove], single: single) end end if opt[:finish] || opt[:cancel] tag = 'done' items.map! do |item| if item.should_finish? should_date = !opt[:cancel] && item.should_time? tag_item(item, tag, date: should_date, remove: opt[:remove], single: single) end end end if opt[:tag] tag = opt[:tag] items.map! do |item| tag_item(item, tag, date: false, remove: opt[:remove], single: single) end end if opt[:archive] || opt[:move] section = opt[:archive] ? 'Archive' : guess_section(opt[:move]) items.map! {|item| move_item(item, section) } end write(@doing_file) if opt[:editor] editable_items = [] items.each do |item| editable = "#{item.date} | #{item.title}" old_note = item.note ? item.note.to_s : nil editable += "\n#{old_note}" unless old_note.nil? editable_items << editable end divider = "\n-----------\n" input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place" 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?) title = input_lines[0]&.strip if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty? delete_item(items[i]) else note = input_lines.length > 1 ? input_lines[1..-1] : [] note.map!(&:strip) note.delete_if(&:ignore?) date = title.match(/^([\d\-: ]+) \| /)[1] title.sub!(/^([\d\-: ]+) \| /, '') item = items[i] item.title = title item.note = note item.date = Time.parse(date) || items[i].date end end write(@doing_file) end if opt[:output] items.map! do |item| item.title = "#{item.title} @project(#{item.section})" item end @content = { 'Export' => { :original => 'Export:', :items => items } } 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) 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 end ## ## @brief Tag an item from the index ## ## @param item (Item) The item to tag ## @param tags (string) The tag to apply ## @param remove (Boolean) remove tags ## @param date (Boolean) Include timestamp? ## def tag_item(item, tags, remove: false, date: false, single: false) added = [] removed = [] tags = tags.to_tags if tags.is_a? ::String done_date = Time.now tags.each do |tag| bool = remove ? :and : :not if item.tags?(tag, bool) item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil) remove ? removed.push(tag) : added.push(tag) end end log_change(tags_added: added, tags_removed: removed, count: 1, item: item, single: single) item end ## ## @brief Tag the last entry or X entries ## ## @param opt (Hash) Additional Options ## def tag_last(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([], opt: opt) if opt[:interactive] items = choose_from_items(items, { menu: true, header: '', prompt: 'Select entries to tag > ', multiple: true, sort: true, show_if_single: true }, include_section: opt[:section] =~ /^all$/i) 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] rename_to = nil if opt[:rename] rename_to = tag tag = opt[:rename] end old_title = item.title.dup item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex]) 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 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? move_item(item, 'Archive', label: true) elsif opt[:archive] && opt[:count].zero? logger.warn('Skipped:', 'Archiving is skipped when operating on all entries') end end write(@doing_file) end ## ## @brief Move item from current section to ## destination section ## ## @param item The item ## @param section The destination section ## ## @return Updated item ## def move_item(item, section, label: true) from = item.section new_item = @content[item.section][:items].delete(item) new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label new_item.section = section @content[section][:items].concat([new_item]) logger.count(section == 'Archive' ? :archived : :moved) logger.debug("#{section == 'Archive' ? 'Archived' : 'Moved'}:", "#{new_item.title.truncate(60)} from #{from} to #{section}") new_item end ## ## @brief Get next item in the index ## ## @param item ## def next_item(item, options = {}) items = filter_items([], opt: options) idx = items.index(item) idx.positive? ? items[idx - 1] : nil end ## ## @brief Delete an item from the index ## ## @param item The item ## def delete_item(item) section = item.section section_items = @content[section][:items] deleted = section_items.delete(item) logger.count(:deleted) logger.info('Entry deleted:', deleted.title) end ## ## @brief Update an item in the index with a modified item ## ## @param old_item The old item ## @param new_item The new item ## def update_item(old_item, new_item) section = old_item.section section_items = @content[section][:items] s_idx = section_items.index { |item| item.equal?(old_item) } raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx return if section_items[s_idx].equal?(new_item) section_items[s_idx] = new_item logger.count(:updated) logger.info('Entry updated:', section_items[s_idx].title.truncate(60)) new_item end ## ## @brief 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.title.dup] content << item.note.to_s unless item.note.empty? new_item = fork_editor(content.join("\n")) title, note = format_input(new_item) if title.nil? || title.empty? logger.debug('Skipped:', 'No content provided') elsif title == item.title && note.equal?(item.note) logger.debug('Skipped:', 'No change in content') else item.title = title item.note.add(note, replace: true) logger.info('Edited:', item.title) write(@doing_file) end end ## ## @brief 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 tag (String) Tag to replace ## @param opt (Hash) Additional Options ## def stop_start(target_tag, 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[opt[:section]][:items].each_with_index do |item, 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})") move_item(item, 'Archive', label: false) logger.count(:completed_archived) logger.info('Completed/archived:', item.title) else logger.count(:completed) logger.info('Completed:', item.title) end end logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero? if opt[:new_item] title, note = format_input(opt[:new_item]) 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 ## ## @brief 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 = wrapped_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 def wrapped_content output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : '' @content.each do |title, section| output += "#{section[:original]}\n" output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 }) end output + @other_content_bottom.join("\n") unless @other_content_bottom.nil? end ## ## @brief Restore a backed up version of a file ## ## @param file (String) The filepath to restore ## def restore_backup(file) if File.exist?("#{file}~") FileUtils.cp("#{file}~", file) logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}") else logger.error('Restore error:', 'No backup file found') end end ## ## @brief Rename doing file with date and start fresh one ## def rotate(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' if sect =~ /^all$/i all_sections = sections.dup else all_sections = [sect] end counter = 0 new_content = {} all_sections.each do |section| items = @content[section][:items].dup new_content[section] = {} new_content[section][:original] = @content[section][:original] new_content[section][:items] = [] moved_items = [] if !tags.empty? || opt[:search] || opt[:before] if opt[:before] time_string = opt[:before] cutoff = chronify(time_string, guess: :begin) end items.delete_if do |item| if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff)) moved_items.push(item) counter += 1 true else false end end @content[section][:items] = items new_content[section][:items] = moved_items logger.warn('Rotated:', "#{moved_items.length} items from #{section}") else new_content[section][:items] = [] moved_items = [] count = items.length < keep ? items.length : keep if items.count > count moved_items.concat(items[count..-1]) else moved_items.concat(items) end @content[section][:items] = if count.zero? [] else items[0..count - 1] end new_content[section][:items] = moved_items logger.warn('Rotated:', "#{items.length - count} items from #{section}") end 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.deep_merge(new_content) 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 ## ## @brief Generate a menu of sections and allow user selection ## ## @return (String) The selected section name ## def choose_section choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%']) choice ? choice.strip : choice end ## ## @brief List available views ## ## @return (Array) View names ## def views @config.has_key?('views') ? @config['views'].keys : [] end ## ## @brief Generate a menu of views and allow user selection ## ## @return (String) The selected view name ## def choose_view choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%']) choice ? choice.strip : choice end ## ## @brief 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 ## ## @brief Display contents of a section based on options ## ## @param opt (Hash) Additional Options ## def list_section(opt = {}) opt[:count] ||= 0 opt[:age] ||= 'newest' opt[:format] ||= @config.dig('templates', 'default', 'date_format') opt[:order] ||= @config.dig('templates', 'default', 'order') || 'asc' opt[:tag_order] ||= 'asc' opt[:tags_color] ||= false opt[:template] ||= @config.dig('templates', 'default', '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) if opt[:section] =~ /^all$/i title = 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 title = guess_section(opt[:section]) end end items = filter_items([], opt: opt).reverse items.reverse! if opt[:order] =~ /^d/i if opt[:interactive] opt[:menu] = !opt[:force] opt[:query] = '' # opt[:search] opt[:multiple] = true selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i ) 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'] output(items, title, is_single, opt) end def output(items, title, is_single, 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 } 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 def load_plugins if @config.key?('plugins') && @config['plugins']['plugin_path'] add_dir = @config['plugins']['plugin_path'] else add_dir = File.join(@user_home, '.config', 'doing', 'plugins') begin FileUtils.mkdir_p(add_dir) if add_dir rescue nil end end Plugins.load_plugins(add_dir) end ## ## @brief 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 = {}) 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 add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive') destination = guess_section(destination) if sections.include?(destination) && (sections.include?(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 ## ## @brief 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(sect, destination, opt = {}) count = opt[:count] || 0 tags = opt[:tags] || [] bool = opt[:bool] || :and label = opt[:label] || true if sect =~ /^all$/i all_sections = sections.dup all_sections.delete(destination) else all_sections = [sect] end counter = 0 all_sections.each do |section| items = @content[section][:items].dup moved_items = [] if !tags.empty? || opt[:search] || opt[:before] if opt[:before] time_string = opt[:before] cutoff = chronify(time_string, guess: :begin) end items.delete_if do |item| if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff)) moved_items.push(item) counter += 1 true else false end end moved_items.each do |item| if label item.title = if section == @config['current_section'] item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1') else item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})") end logger.debug('Moved:', "#{item.title} from #{section} to #{destination}") end end @content[section][:items] = items @content[destination][:items].concat(moved_items) if moved_items.length.positive? logger.count(destination == 'Archive' ? :archived : :moved, level: :info, count: moved_items.length, message: "%count %items from #{section} to #{destination}") else logger.info('Skipped:', 'No items were moved') end else count = items.length if items.length < count items.map! do |item| if label item.title = if section == @config['current_section'] item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1') else item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})") end logger.debug('Moved:', "#{item.title} from #{section} to #{destination}") end item end if items.count > count @content[destination][:items].concat(items[count..-1]) else @content[destination][:items].concat(items) end @content[section][:items] = if count.zero? [] else items[0..count - 1] end logger.count(destination == 'Archive' ? :archived : :moved, level: :info, count: items.length - count, message: "%count %items from #{section} to #{destination}") end end end ## ## @brief 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[:totals] ||= false opt[:sort_tags] ||= false cfg = @config['templates']['today'] options = { after: opt[:after], before: opt[:before], count: 0, format: cfg['date_format'], 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'] } list_section(options) end ## ## @brief 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[: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], sort_tags: opt[:sort_tags] }) end ## ## @brief 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[: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, order: opt[:order], output: output, section: section, sort_tags: opt[:sort_tags], tag_order: opt[:tag_order], times: times, totals: opt[:totals], yesterday: true } list_section(options) end ## ## @brief 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 = {}) times = opt[:t] || true opt[:totals] ||= false opt[:sort_tags] ||= false cfg = @config['templates']['recent'] section ||= @config['current_section'] section = guess_section(section) list_section({ section: section, wrap_width: cfg['wrap_width'], count: count, format: cfg['date_format'], template: cfg['template'], order: 'asc', times: times, totals: opt[:totals], sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] }) end ## ## @brief 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'] opts = { section: section, wrap_width: cfg['wrap_width'], count: 1, format: cfg['date_format'], template: cfg['template'], times: times } 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 ## ## @brief 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 text (String) The text to tag ## def autotag(text) return unless text return text unless @auto_tag original = text.dup current_tags = text.scan(/@\w+/) whitelisted = [] @config['autotag']['whitelist'].each do |tag| next if text =~ /@#{tag}\b/i text.sub!(/(? 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 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 ## ## @brief Gets the interval between entry's start ## date and @done date ## ## @param item (Hash) The entry ## @param formatted (Bool) Return human readable ## time (default seconds) ## @param record (Bool) 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 ## ## @brief Record times for item tags ## ## @param item The item ## 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 ## ## @brief Format human readable time from seconds ## ## @param seconds The seconds ## def format_time(seconds, human: false) return [0, 0, 0] if seconds.nil? if seconds.class == 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 private 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 def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false) if tags_added.empty? && tags_removed.empty? logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count) else if tags_added.empty? logger.count(:skipped, level: :debug, message: 'no tags added to %count %items') else if single && item logger.info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} to #{item.title})) else logger.count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items') end end if tags_removed.empty? logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items') else if single && item logger.info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} from #{item.title})) else logger.count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items') end end end end end end