# frozen_string_literal: true module Doing class WWID ## ## 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[:query] && 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', 'autotag', 'cancel', 'delete', 'finish', 'flag', 'archive', 'move', 'edit', 'output formatted' ] actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1 choice = Prompt.choose_from(actions.map(&:titlecase), 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/).map(&:downcase) to_do.each do |action| action = 'resume' if action =~ /^resume/i action = 'reset' if action =~ /^begin/i case action when /(resume|reset|autotag|archive|delete|finish|cancel|flag)/ opt[action.to_sym] = true when /edit/ opt[:editor] = 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] tags = type == 'add' ? all_tags(@content) : all_tags(items) add_msg = type == 'add' ? ', include values with tag(value)' : '' puts "#{yellow}Separate multiple tags with spaces, hit tab to complete known tags#{add_msg}" puts "#{boldgreen}Available tags: #{boldwhite}#{tags.sort.map(&:add_at).join(', ')}" if type == 'remove' tag = Prompt.read_line(prompt: "Tags to #{type}", completions: tags) # 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 output_format =~ /^ *$/ 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 filename = Prompt.read_line(prompt: 'File path/name') next if filename.empty? opt[:save_to] = filename end when /move/ section = choose_section.strip opt[:move] = section.strip unless section =~ /^ *$/ 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: '') date = if res =~ /^ *$/ Time.now else 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 old_item = item.clone new_entry = reset_item(item, date: date, resume: res) @content.update_item(item, new_entry) Hooks.trigger :post_entry_updated, self, new_entry, old_item end write(@doing_file) return end if opt[:delete] delete_items(items, force: opt[:force]) # hooked write(@doing_file) return end if opt[:flag] tag = Doing.setting('marker_tag', 'flagged') items.map! do |i| old_item = i.clone i.tag(tag, date: false, remove: opt[:remove], single: single) Hooks.trigger :post_entry_updated, self, i, old_item end end if opt[:finish] || opt[:cancel] tag = 'done' items.map! do |i| next unless i.should_finish? old_item = i.clone 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, old_item end end if opt[:autotag] items.map! do |i| new_title = autotag(i.title) if new_title == i.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) old_item = i.clone i.title = new_title Hooks.trigger :post_entry_updated, self, i, old_item end end end if opt[:tag] tag = opt[:tag] items.map! do |i| old_item = i.clone i.tag(tag, date: false, remove: opt[:remove], single: single) i.expand_date_tags(Doing.setting('date_tags')) Hooks.trigger :post_entry_updated, self, i, old_item end end if opt[:archive] || opt[:move] section = opt[:archive] ? 'Archive' : guess_section(opt[:move]) items.map! do |i| old_item = i.clone i.move_to(section, label: true) Hooks.trigger :post_entry_updated, self, i, old_item end end write(@doing_file) if opt[:editor] sleep 2 # This seems to be necessary between running fzf # and forking the editor, otherwise vim gets all # screwy and I can't figure out why edit_items(items) # hooked write(@doing_file) end return unless opt[:output] items.each { |i| i.title = "#{i.title} @section(#{i.section})" } export_items = Items.new export_items.concat(items) export_items.add_section(Section.new('Export'), log: false) options = { section: 'All' } 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, items: export_items) # 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 ## ## 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 ## ## 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 ## ## Interactively verify an item modification if elapsed time is greater than configured threshold ## ## @param date [String] Item date ## @param finish_date [String] The finish date ## @param title [String] The Item title ## def verify_duration(date, finish_date, title: nil) max_elapsed = Doing.setting('interaction.confirm_longer_than', 0) max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String) date = date.chronify(guess: :end, context: :today) if date.is_a?(String) finish_date = finish_date.chronify(guess: :end, context: :today) if finish_date.is_a?(String) elapsed = finish_date - date if max_elapsed.positive? && (elapsed > max_elapsed) puts boldwhite(title) if title human = elapsed.time_string(format: :natural) res = Prompt.yn(yellow("Did this entry actually take #{human}"), default_response: true) unless res new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed.positive? finish_date = date + new_elapsed if new_elapsed end end finish_date end end end