Skip to content
Brett Terpstra edited this page Jul 21, 2022 · 19 revisions

Doing has a plugin architecture that allows you to create custom import and export features. Import plugins take data from external sources/files (like Calendar or Timing.app reports) and create new doing entries from them. Export plugins format selected doing entries in specific ways, such as HTML or CSV output. Or anything you want.

If you want to perform actions based on doing activity, see Hooks.

If you want to add new subcommands to Doing, see Adding Commands.

A plugin is a single ruby file, or a directory containing the ruby file, located in a plugins directory. The directory is ~/.config/doing/plugins by default, but you can add a plugins_path key to ~/.doingrc to point doing to wherever you want to keep your plugins.

If your plugin has external templates or other dependencies, create a subdirectory to contain it as a "bundle". All subdirectories inside of the plugins folder will be traversed for plugins, so each plugin can be self-contained in its own folder. No naming convention for directories or files is enforced, but a descriptive name that indicates the use and type (import/export) of the plugin is recommended (e.g. plugins/wiki_export/wiki_export.rb).

Import and export plugins are similar, but have different required methods. Following is a breakdown of an export plugin. You can also view the full source code for the example plugin.

For a more detailed export plugin example complete with templates, take a look at the wiki_export plugin in the plugin examples directory.

Accessing Config Settings: Each plugin receives a WWID object which has a @config attribute that contains a Hash of all current configuration options (after reading user config and any local (per-directory) configurations). This can be accessed as a hash, e.g. wwid.config['search']['case']. There's a shortcut method available that takes a dot-separated key path, though, which is a nice convenience. Use Doing.setting('search.case'). This method also accepts an array, which is an alternative if you want to use a variable in the key path, e.g. Doing.setting(['template', options[:template]]). You can also include a second argument which is the default value to return if the path provided returns nil (doesn't exist or isn't set).

Include some meta at the top of the plugin. It's optional, but helpful.

# frozen_string_literal: true
#
# title: Export plugin example
# description: Speak the most recent entry (macOS)
# author: Brett Terpstra
# url: https://brettterpstra.com

As well as any info a user would need to use/configure it

# Example
#
# doing show -o sayit
#
# ## Configuration
#
# Change what the plugin says by generating a template with
# `doing template --type say`, saving it to a file, and
# putting the path to that file in `export_templates->say` in
# .doingrc.
#
# export_templates:
#   say: /path/to/template.txt
#
# Use a different voice by adding a `say_voice` key to your
# .doingrc. Use `say -v ?` to see available voices.
#
# plugins:
#   say:
#     say_voice: Zarvox

Use the example plugin as a skeleton to define required methods:

module Doing
  ##
  ## @brief      Plugin class
  ##
  class SayExport
    include Doing::Util

    def self.settings
    end

    def self.template(trigger) # Optional
    end

    def self.render(wwid, items, variables: {})
    end

    Doing::Plugins.register 'say', :export, self
  end
end

The Required methods

self.settings

This method provides doing with options and configuration for your plugin. It just needs to return a Hash object with the proper keys.

Note that when defining a regular expression for the trigger, all parenthetical groups should be non-capturing, i.e. (?:...).

Once your plugin is installed, you can run doing config update to add any keys the plugin registers to your config file.

#-------------------------------------------------------
## Plugin Settings. A plugin must have a self.settings
## method that returns a hash with plugin settings.
##
## trigger:   (required) Regular expression to match
## FORMAT when used with `--output FORMAT`. Registered
## name of plugin must be able to match the trigger, but
## alternatives can be included
##
## templates: (optional) Array of templates this plugin
## can export (plugin must have :template method)
##
##   Each template is a hash containing:
##               - name: display name for template
##               - trigger: regular expression for
##                 `template --type FORMAT`
##               - format: a descriptor of the file format (erb, haml, stylus, etc.)
##               - filename: a default filename used when the template is written to disk
##
##   If a template is included, a config key will
##   automatically be added for the user to override
##   The config key will be available at:
##
##       Doing.settings['export_templates'][PLUGIN_NAME]
##
## config:    (optional) A Hash which will be
## added to the main configuration in the plugins section.
## Options defined here are included when config file is
## created or updated with `config update`. Use this to
## add new configuration keys, not to override existing
## ones.
##
##   The configuration keys will be available at:
##
##      Doing.settings['plugins'][PLUGIN_NAME][KEY]
##
## @brief      Method to return plugin settings (required)
##
## @return     Hash of settings for this plugin
##
def self.settings
  {
    trigger: 'say(?:it)?',
    templates: [
      { name: 'say', trigger: 'say(?:it)?', format: 'text', filename: 'say.txt' }
    ],
    config: {
      'say_voice' => 'Fiona'
    }
  }
end

self.template

If your plugin allows a user-configured template, include a template method

#-------------------------------------------------------
## Output a template. Only required if template(s) are
## included in settings. The method should return a
## string (not output it to the STDOUT).
##
## @brief      Method to return template (optional)
##
## @param      trigger  The trigger passed to the
##                      template function. When this
##                      method defines multiple
##                      templates, the trigger can be
##                      used to determine which one is
##                      output.
##
## @return     (String) template contents
##
def self.template(trigger)
  return unless trigger =~ /^say(it)?$/

  'On %date, you were %title, recorded in section %section%took'
end

self.render

Lastly, provide a render method that accepts a WWID object, an array of items, and additional options. This is where import and export plugins differ. An import plugin requires an import method, an export plugin requires a render method.

##
## @brief      Render data received from an output
##             command
##
## @param      wwid       The wwid object with config
##                        and public methods
## @param      items      An array of items to be output
##                        { <Date>date, <String>title,
##                        <String>section, <Array>note }
## @param      variables  Additional variables including
##                        flags passed to command
##                        (variables[:options])
##
## @return     (String) Rendered output
##
def self.render(wwid, items, variables: {})
  return if items.nil? || items.empty?

  # the :options key includes the flags passed to the
  # command that called the plugin use `puts
  # variables.inspect` to see properties and methods
  # when run
  opt = variables[:options]

  # This plugin just grabs the last item in the `items`
  # list (which could be the oldest or newest, depending
  # on the sort order of the command that called the
  # plugin). Most of the time you'll want to use :each
  # or :map to generate output.
  i = items[-1]

  # Format the item. Items are objects with 4 methods: :date,
  # :title, :section (parent section), and :note. Start
  # time is in item.date. The wwid object has some
  # methods for calculation and formatting, including
  # wwid.end_date to convert the @done timestamp to
  # a Date object.
  if opt[:times]
    interval = i.interval

    if interval
      d, h, m = wwid.fmt_time(interval)
      took = ' and it took'
      took += " #{d.to_i} days" if d.to_i.positive?
      took += " #{h.to_i} hours" if h.to_i.positive?
      took += " #{m.to_i} minutes" if m.to_i.positive?
    end
  end

  date = i.date.strftime('%A %B %e at %I:%M%p')
  title = i.title.gsub(/@/, 'hashtag ')
  tpl = template('say')
 
  if Doing.setting('export_templates.say')
    cfg_tpl = Doing.setting('export_templates.say')
    tpl = cfg_tpl if cfg_tpl.good?
  end

  output = tpl.dup
  output.gsub!(/%date/, date)
  output.gsub!(/%title/, title)
  output.gsub!(/%section/, i.section)
  output.gsub!(/%took/, took || '')

  # Debugging output
  # warn "Saying: #{output}"

  # To provide results on the command line after the command
  # runs, use the logger methods debug, info, warn, and
  # error. Results are provided on STDERR unless doing is
  # run with `--stdout`
  Doing.logger.info('Say:', 'Spoke the last entry. Did you hear it?')

  # This export runs a command for fun, most plugins won't
  `say -v #{Doing.setting('plugins.say.say_voice')} "#{output}"`

  # Return the result (don't output to terminal with puts or print)
  output
end

For a list of public methods available on the WWID object and other utilities, check the API docs.

Register the plugin with Doing

Now you're ready to register the plugin.

# Register the plugin with doing.
# Doing::Plugins.register 'NAME', TYPE, Class
#
# Name should be lowercase, no spaces
#
# TYPE is :import or :export
#
# Class is the plugin class (e.g. Doing::SayExport), or
# self if called within the class
Doing::Plugins.register 'say', :export, SayExport

Place the plugin in the plugins directory (defined in ~/.doingrc under 'plugin_path', default ~/.config/doing/plugins). Now you can test it. If it's an export plugin, you should be able to use the name/trigger you registerd as an argument to doing show -o NAME.

To see all registered plugins, run doing plugins.

If properly registered, you should see your plugin listed in the output of doing help show under the --output flag description or, if it's an import plugin, with doing help import under the --type flag description.

Import Plugin Options

Import plugins receive optional date range and search filters which must be implemented on a per-plugin basis. If your plugin generates a new Item object, you can use the built-in search method and the Item's date object for comparisons. The search query is found in options[:search] (nil if not provided), and the date range (via the --from flag) is in options[:date_filter], which is a 2-element array with start and end Dates. The options hash also include the values of --not and --case. In the import method:

def self.import(wwid, path, options: {})
	# ...items.each
		# code to generate new item, probably in a loop
		new_item = Item.new(start_time, title, section)
		new_item.note.add(entry['notes']) if entry.key?('notes')
		
		is_match = true
		
		if options[:search]
		  is_match = new_item.search(options[:search], case_type: options[:case], negate: options[:not])
		end
		
		if is_match && options[:date_filter]
		  is_match = start_time > options[:date_filter][0] && start_time < options[:date_filter][1]
		  is_match = options[:not] ? !is_match : is_match
		end
		
		new_items.push(new_item) if is_match
	# end
end

The --no_overlap flag can be honored by using the WWID.dedup method once an array of new items is created. The dedup method will remove items from the array that overlap existing entries (start time between an existing entry's start and end time) in the doing file. If no_overlap is false, only items with exact duplicate times will be skipped.

new_items = wwid.dedup(new_items, no_overlap: options[:no_overlap])

Import plugins also receive the --autotag flag, which should be handled by calling WWID.autotag if the option is set:

title = wwid.autotag(title) if options[:autotag]

The --tag option defines a tag that should be added to all imported entries, and the --prefix flag indicates a string that should be prefixed to imported entries. These need to be handled by your plugin:

add_tags = options[:tag] ? options[:tag].split(/[ ,]+/).map { |t| t.sub(/^@?/, '') } : []
prefix = options[:prefix] || '[Timing.app]'
tags = [] # this array may already be populated
tags.concat(add_tags)
title = "#{prefix} " # Append the title after this
tags.each do |tag|
  if title =~ /\b#{tag}\b/i
    title.sub!(/\b#{tag}\b/i, "@#{tag}")
  else
    title += " @#{tag}"
  end
end

Debugging

To enable verbose output and traces for debugging, run GLI_DEBUG=true doing show -o NAME.

# Register the plugin with doing.
# Doing::Plugins.register 'NAME', TYPE, Class
#
# Name should be lowercase, no spaces
#
# TYPE is :import or :export
#
# Class is the plugin class (e.g. Doing::SayExport), or
# self if called within the class
Doing::Plugins.register 'say', :export, SayExport

Place the plugin in the plugins directory (defined in ~/.doingrc under 'plugin_path', default ~/.config/doing/plugins). Now you can test it. If it's an export plugin, you should be able to use the name/trigger you registerd as an argument to doing show -o NAME.

To see all registered plugins, run doing plugins.

If properly registered, you should see your plugin listed in the output of doing help show under the --output flag description or, if it's an import plugin, with doing help import under the --type flag description.

Distributing

You can package a plugin as a gem and make it available to users to install with gem install xxx. Just name the gem with the prefix doing-plugin-, e.g. doing-plugin-twitter-import. Be sure that the Gemspec and lib/*.rb are all named the same.

Only two files are required:

doing-plugin-myplugin.gemspec
lib/
  doing-plugin-myplugin.rb

The Gemspec file should contain the basic metadata, as well as any gem dependencies your plugin has:

Gem::Specification.new do |s|
  s.name        = "doing-plugin-twitter-import"
  s.version     = "0.0.3"
  s.summary     = "Twitter timeline import for Doing"
  s.description = "Imports entries from the Twitter timeline to Doing"
  s.authors     = ["Brett Terpstra"]
  s.email       = "[email protected]"
  s.files       = ["lib/doing-plugin-twitter-import.rb"]
  s.homepage    = "https://brettterpstra.com"
  s.license     = "MIT"
  s.add_runtime_dependency('twitter', '~> 7.0')
end

Run gem build doing-plugin-myplugin.gemspec to build the gem, then use gem push doing-plugin-myplugin-x.x.x.gem to publish it. Once published, anyone can install it with [sudo] gem install and Doing will automatically pick it up next time it runs.