#!/usr/bin/env ruby # frozen_string_literal: true $LOAD_PATH.unshift File.join(__dir__, '..', 'lib') require 'gli' require 'doing/help_monkey_patch' require 'doing' require 'tempfile' require 'pp' def class_exists?(class_name) klass = Module.const_get(class_name) klass.is_a?(Class) rescue NameError false end if class_exists? 'Encoding' Encoding.default_external = Encoding::UTF_8 if Encoding.respond_to?('default_external') Encoding.default_internal = Encoding::UTF_8 if Encoding.respond_to?('default_internal') end include GLI::App include Doing::Errors version Doing::VERSION hide_commands_without_desc true autocomplete_commands true include Doing::Types @colors = Doing::Color @wwid = Doing::WWID.new Doing.logger.log_level = :info env_log_level = nil if ENV['DOING_LOG_LEVEL'] || ENV['DOING_DEBUG'] || ENV['DOING_QUIET'] || ENV['DOING_VERBOSE'] || ENV['DOING_PLUGIN_DEBUG'] env_log_level = true # Quiet always wins if ENV['DOING_QUIET']&.truthy? Doing.logger.log_level = :error elsif ENV['DOING_PLUGIN_DEBUG']&.truthy? Doing.logger.log_level = :debug elsif ENV['DOING_DEBUG']&.truthy? Doing.logger.log_level = :debug elsif ENV['DOING_LOG_LEVEL'] Doing.logger.log_level = ENV['DOING_LOG_LEVEL'] end end Doing.logger.benchmark(:total, :start) Doing.logger.benchmark(:configure, :start) Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true }) if ENV['DOING_CONFIG'] @config = Doing.config Doing.logger.benchmark(:configure, :finish) @config.settings['backup_dir'] = ENV['DOING_BACKUP_DIR'] if ENV['DOING_BACKUP_DIR'] @settings = @config.settings @wwid.config = @settings commands_from File.expand_path(@settings.dig('plugins', 'command_path')) if @settings.dig('plugins', 'command_path') accept BooleanSymbol do |value| value.normalize_bool(:pattern) end accept CaseSymbol do |value| value.normalize_case(@config.fetch('search', 'case', :smart)) end accept AgeSymbol do |value| value.normalize_age(:newest) end accept OrderSymbol do |value| value.normalize_order(:asc) end accept MatchingSymbol do |value| value.normalize_matching(:pattern) end accept TagSortSymbol do |value| value.normalize_tag_sort(@config.fetch('tag_sort', :name)) end accept TemplateName do |value| res = @settings['templates'].keys.select { |k| k =~ value.to_rx(distance: 2) } raise InvalidArgument, "Unknown template: #{value}" unless res.good? res.group_by(&:length).min.last[0] end accept DateBeginString do |value| if value =~ REGEX_TIME res = value else res = value.chronify(guess: :begin, future: false) end raise InvalidTimeExpression, 'Invalid start date' unless res res end accept DateEndString do |value| if value =~ REGEX_TIME res = value else res = value.chronify(guess: :end, future: false) end raise InvalidTimeExpression, 'Invalid end date' unless res res end accept DateRangeString do |value| start, finish = value.split_date_range raise InvalidTimeExpression, 'Invalid range' unless start finish ||= Time.now [start, finish] end accept DateRangeOptionalString do |value| start, finish = value.split_date_range raise InvalidTimeExpression, 'Invalid range' unless start [start, finish] end accept DateIntervalString do |value| res = value.chronify_qty raise InvalidTimeExpression, 'Invalid time quantity' unless res res end accept TagArray do |value| value.gsub(/[, ]+/, ' ').split(' ').map { |tag| tag.sub(/^@/, '') }.map(&:strip) end ## ## Add presets of flags and switches to a command. ## ## :add_entry => --noauto, --note, --ask, --editor, --back ## ## :search => --search, --case, --exact ## ## :tag_filter => --tag, --bool, --not, --val ## ## :date_filter => --before, --after, --from ## ## @param type [Symbol] The type ## @param cmd The GLI command to which the options will be added ## def add_options(type, cmd) cmd_name = cmd.name.to_s action = case cmd_name when /again/ 'Repeat' when /grep/ 'Search' when /mark/ 'Flag' when /(last|tags|view)/ 'Show' else cmd_name.capitalize end case type when :add_entry cmd.desc 'Exclude auto tags and default tags' cmd.switch %i[X noauto], default_value: false, negatable: false cmd.desc 'Include a note' cmd.arg_name 'TEXT' cmd.flag %i[n note] cmd.desc 'Prompt for note via multi-line input' cmd.switch %i[ask], negatable: false, default_value: false cmd.desc "Edit entry with #{Doing::Util.default_editor}" cmd.switch %i[e editor], negatable: false, default_value: false cmd.desc 'Backdate start date for new entry to date string [4pm|20m|2h|yesterday noon]' cmd.arg_name 'DATE_STRING' cmd.flag %i[b back started], type: DateBeginString when :search cmd.desc 'Filter entries using a search query, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")' cmd.arg_name 'QUERY' cmd.flag [:search] cmd.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]' cmd.arg_name 'TYPE' cmd.flag [:case], must_match: REGEX_CASE, default_value: @settings.dig('search', 'case').normalize_case, type: CaseSymbol cmd.desc 'Force exact search string matching (case sensitive)' cmd.switch %i[x exact], default_value: @config.exact_match?, negatable: @config.exact_match? when :tag_filter cmd.desc 'Filter entries by tag. Combine multiple tags with a comma. Wildcards allowed (*, ?)' cmd.arg_name 'TAG' cmd.flag [:tag], type: TagArray cmd.desc 'Perform a tag value query ("@done > two hours ago" or "@progress < 50"). May be used multiple times, combined with --bool' cmd.arg_name 'QUERY' cmd.flag [:val], multiple: true, must_match: REGEX_VALUE_QUERY cmd.desc "#{action} items that *don't* match search/tag filters" cmd.switch [:not], default_value: false, negatable: false cmd.desc 'Boolean used to combine multiple tags. Use PATTERN to parse + and - as booleans' cmd.arg_name 'BOOLEAN' cmd.flag [:bool], must_match: REGEX_BOOL, default_value: :pattern, type: BooleanSymbol when :date_filter if action =~ /Archive/ cmd.desc 'Archive entries older than date (natural language).' else cmd.desc "#{action} entries older than date (natural language). If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day" end cmd.arg_name 'DATE_STRING' cmd.flag [:before], type: DateBeginString if action =~ /Archive/ cmd.desc 'Archive entries newer than date (natural language).' else cmd.desc "#{action} entries newer than date (natural language). If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day" end cmd.arg_name 'DATE_STRING' cmd.flag [:after], type: DateEndString if action =~ /Archive/ cmd.desc %( Date range (natural language) to archive: `doing archive --from "1/1/21 to 12/31/21"`. ) else cmd.desc %( Date range (natural language) to #{action.downcase}, or a single day to filter on. To specify a range, use "to": `doing #{cmd_name} --from "monday 8am to friday 5pm"`. If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered by time of day. ) end cmd.arg_name 'DATE_OR_RANGE' cmd.flag [:from], type: DateRangeString end end program_desc 'A CLI for a What Was I Doing system' program_long_desc %(Doing uses a TaskPaper-like formatting to keep a plain text record of what you've been doing, complete with tag-based time tracking. The command line tool allows you to add entries, annotate with tags and notes, and view your entries with myriad options, with a focus on a "natural" language syntax.) default_command :recent # sort_help :manually ## Global options desc 'Output notes if included in the template' switch [:notes], default_value: true, negatable: true desc 'Send results report to STDOUT instead of STDERR' switch [:stdout], default_value: false, negatable: false desc 'Use a pager when output is longer than screen' switch %i[p pager], default_value: @settings['paginate'] desc 'Answer yes/no menus with default option' switch [:default], default_value: false, negatable: false desc 'Answer all yes/no menus with yes' switch [:yes], negatable: false desc 'Answer all yes/no menus with no' switch [:no], negatable: false desc 'Exclude auto tags and default tags' switch %i[x noauto], default_value: false, negatable: false desc 'Colored output' switch %i[color], default_value: true desc 'Silence info messages' switch %i[q quiet], default_value: false, negatable: false desc 'Verbose output' switch %i[debug], default_value: false, negatable: false desc 'Use a specific configuration file. Deprecated, set $DOING_CONFIG instead' flag [:config_file], default_value: @config.config_file desc 'Specify a different doing_file' flag %i[f doing_file] def add_commands(commands) commands = [commands] unless commands.is_a?(Array) hidden = @settings['disabled_commands'] hidden = hidden.set_type('array') if hidden.good? && !hidden.is_a?(Array) commands.delete_if { |c| hidden.include?(c) } commands.each { |cmd| require_relative "commands/#{cmd}" } end ## Add/modify commands add_commands(%w[now done finish note select tag]) ## View commands add_commands(%w[grep last recent show on view]) ## Utility commands add_commands(%w[config open commands]) ## File handling/batch modification commands add_commands(%w[archive import rotate]) ## History commands add_commands(%w[undo redo]) ## Hidden commands add_commands(%w[commands_accepting install_fzf]) ## Optional commands add_commands(%w[again cancel flag meanwhile reset tags today yesterday since add_section tag_dir colors completion plugins sections template views changes]) pre do |global, _command, _options, _args| # global[:pager] ||= @settings['paginate'] Doing::Pager.paginate = global[:pager] $stdout.puts "doing v#{Doing::VERSION}" if global[:version] unless $stdout.isatty Doing::Color.coloring = global[:pager] ? global[:color] : false else Doing::Color.coloring = global[:color] end # Return true to proceed; false to abort and not call the # chosen command # Use skips_pre before a command to skip this block # on that command only true end on_error do |exception| if exception.kind_of?(GLI::UnknownCommand) exit run(['now'].concat(ARGV)) elsif exception.kind_of?(SystemExit) false else # Doing.logger.error('Fatal:', exception) Doing.logger.output_results true end end post do |global, _command, _options, _args| # Use skips_post before a command to skip this # block on that command only Doing.logger.output_results Doing.logger.benchmark(:total, :finish) Doing.logger.log_benchmarks end around do |global, command, options, arguments, code| Doing.logger.benchmark("command_#{command.name.to_s}".to_sym, :start) # Doing.logger.debug('Pager:', "Global: #{global[:pager]}, Config: #{@settings['paginate']}, Pager: #{Doing::Pager.paginate}") if env_log_level.nil? Doing.logger.adjust_verbosity(global) end if global[:stdout] Doing.logger.logdev = $stdout end if global[:yes] Doing::Prompt.force_answer = :yes Doing.config.force_answer = true elsif global[:no] Doing::Prompt.force_answer = :no Doing.config.force_answer = false else Doing::Prompt.default_answer = if $stdout.isatty global[:default] else true end Doing.config.force_answer = global[:default] ? true : false end if global[:config_file] && global[:config_file] != @config.config_file Doing.logger.warn(format('%sWARNING:%s %sThe use of --config_file is deprecated, please set the environment variable DOING_CONFIG instead.', @colors.flamingo, @colors.default, @colors.boldred)) Doing.logger.warn(format('%sTo set it just for the current command, use: %sDOING_CONFIG=/path/to/doingrc doing [command]%s', @colors.red, @colors.boldwhite, @colors.default)) cf = File.expand_path(global[:config_file]) raise MissingConfigFile, "Config file not found (#{global[:config_file]})" unless File.exist?(cf) @config.config_file = cf @settings = @config.configure({ ignore_local: true }) end Doing.logger.benchmark(:init, :start) if global[:doing_file] @wwid.init_doing_file(global[:doing_file]) else @wwid.init_doing_file end Doing.logger.benchmark(:init, :finish) @wwid.auto_tag = !global[:noauto] @settings[:include_notes] = false unless global[:notes] global[:wwid] = @wwid code.call Doing.logger.benchmark("command_#{command.name.to_s}".to_sym, :finish) end exit run(ARGV)