Updating using external program

I wanted to update an outline with information gathered from various web sites and some calculations. I don’t like applescript and my language of choice is Ruby, so this short note gives some code and guidance as to how I did this.

I will use the budget template as an example.

The heavy lifting is done using the Ruby script (update_oo.rb):

require "nokogiri"  
require 'date'

class OmniOutliner
    OO = {'oo' => "http://www.omnigroup.com/namespace/OmniOutliner/v3"}     # shorthand

    def initialize (filename)
        @filename = filename
        @xml_file = File.join(filename, "contents.xml")
        @doc = Nokogiri::XML(File.open(@xml_file))

        @column_names = @doc.xpath("//oo:column", OO).map do |node|
            title =  node.xpath("./oo:title/oo:text/oo:p", OO)
            title.text.strip
        end

        # First column has a blank name and seems extra to the values stored per line.
        @column_names.shift
    end

    def update_file (&block)
        update_lines(block)
        File.write(@xml_file, @doc.to_xml)
    end

    # Convert the the node into Ruby types.
    def value_from_node (node)
        case node.name
            when 'text'
                node.text.strip
            when 'null'
                nil
            when 'date'
                DateTime.parse(node.text)
            when 'number'
                node.text.to_f
        else
            raise "unknown node type: #{node.name}"
        end
    end

    def node_from_value (value)
        case value
            when nil
                new_node = Nokogiri::XML::Node.new('null', @doc)
            when String
                lit_node = Nokogiri::XML::Node.new('lit', @doc)
                lit_node.content = value
        
                new_node = Nokogiri::XML::Node.new('text', @doc) <<
                            Nokogiri::XML::Node.new('p', @doc)
                new_node.child << Nokogiri::XML::Node.new('run', @doc)
                new_node.child.child << lit_node                    
            when Integer, Float
                new_node = Nokogiri::XML::Node.new('number', @doc)
                new_node.content = value.to_s
            when DateTime
                new_node = Nokogiri::XML::Node.new('date', @doc)
                new_node.content = value.strftime("%Y-%m-%d %H:%M:%S %z")
        end
        new_node
    end

    def scan_lines (&block)
        line_values = {}
        @doc.xpath("//oo:values", OO).each do |line_nodes|
            # Convert a line's nodes into something more useful.
            line_nodes.xpath("*").each_with_index  do |node, i|
                line_values[@column_names[i]] = value_from_node(node)
            end
    
            block.call(line_values)
        end
    end

    def update_lines (block)
        line_values = {}
        @doc.xpath("//oo:values", OO).each do |line_nodes|
            # Convert a line's nodes into something more useful.
            line_nodes.xpath("*").each_with_index  do |node, i|
                line_values[@column_names[i]] = value_from_node(node)
            end
    
            block.call(line_values)
    
            # Update the node with any changed values.
            line_nodes.xpath("*").each_with_index  do |node, i|
                col_name = @column_names[i]
                if line_values[col_name] != value_from_node(node)
                    new_node = node_from_value(line_values[col_name])
                    node.replace(new_node)
                end
            end
        end
    end
end

oo = OmniOutliner.new(ARGV[0])

# This is a trivial examples showing how the budget.oo template file can be
# manipulated.  You shouldn't have to make any changes to the code above 
# and your updates/calculations should just be in the lines below.

oo.update_file do |values|
    # values is a hash holding the columns for a line.  The entries in the 
    # hash are named after the columns.  The column names must match exactly.
    # If a column doesn't have a value it will be retuned as a nil and most
    # expressions using a nil value will cause an exception so it is best
    # to test that all the columns for your calculation have values before
    # you try to use them.

    # Update number values.
    if values['Amount']
        values['Amount'] *= 2
    end

    # Update date value.
    if values['Date']
        values['Date'] = DateTime.now
    end

    # Update string value.
    case values['Topic']
        when 'Coffee' then values['Topic'] = 'Tea'
        when 'Tea'    then values['Topic'] = 'Coffee'
    end 
end

The first part is a class that parses the omnioutliner file format. It expects the file to be uncompresses so select this in the document inspector. The second part is where the calculations and updating of the data is done. This block of code is called once for every row in the file (at any level) and passes the column values in as a hash (or dictionary). Any values that are updated in the hash will be inserted into the file.

Text, dates and numbers can all be changed and in the example here the Amount field is doubled, the Date field is set to today’s date and the Coffee/Tea text is toggled, but you would probably want to do something more useful :-)

Parsing and manipulating the XML file used by omnioutliner is done using a library (gem in Ruby parlance) called nokogiri. This needs to be installed before this script can be used and unfortunately this has got a lot harder with El Capitan. There are many references on the web to guide you with this.

From the command line you can run this script using:
ruby update_oo.rb path-to-your-oo3-file

It would be convenient to be able to run this from within omnioutliner and this can be done using the following applescript (Update):

tell application id "OOut"

    try
        set doc_file to file of front document
        set filename to POSIX path of file doc_file
        set scriptpath to quoted form of POSIX path of ((path to me as text) & "::" & "update_oo.rb")
        set thedoc to front document
        save thedoc
    
        set have_file_coord to "no"
        tell application "Finder" to if exists "/usr/local/bin/file-coord" as POSIX file then set have_file_coord to "yes"
        if have_file_coord contains "yes" then
            -- Use file update events to let oo know the file has changed on disk.
            do shell script "/usr/local/bin/file-coord " & filename & " -- /bin/bash -c \" ruby " & scriptpath & " " & filename & "\""
        else
            close thedoc
            do shell script "/bin/bash -c \" ruby " & scriptpath & " " & filename & "\""
            open doc_file
        end if
    on error errStr number errorNumber
        activate
        display dialog errStr
    end try
end tell

Store this applescript and the Ruby script in ~/Library/Application\ Scripts/com.omnigroup.OmniOutliner4/ and add Update to your toolbar. Clicking on the script will now run it.

Running the script will update the file as already described (if run with budget.003) and the window will close and reopen.

An alternative behaviour is possible where the window doesn’t close and the code and instructions to do this can be found here courtesy of Ken Case. If you compile and move the executable to /usr/local/bin then this will be used to keep the window open and show the updates when ready.

Enjoy!

Dave.