-
-
Notifications
You must be signed in to change notification settings - Fork 91
Creating Plugins
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
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
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
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.
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 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
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.
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.