haraldmartin-things-rb 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +3 -0
- data/LICENSE +19 -0
- data/README.markdown +115 -0
- data/Rakefile +13 -0
- data/bin/things +53 -0
- data/lib/things.rb +24 -0
- data/lib/things/document.rb +38 -0
- data/lib/things/focus.rb +63 -0
- data/lib/things/task.rb +129 -0
- data/lib/things/version.rb +9 -0
- data/test/fixtures/Database.xml +1318 -0
- data/test/test_document.rb +48 -0
- data/test/test_focus.rb +95 -0
- data/test/test_helper.rb +26 -0
- data/test/test_task.rb +118 -0
- data/things-rb.gemspec +34 -0
- metadata +85 -0
data/CHANGELOG
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2008 Martin Str�m
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
things-rb
|
2
|
+
=========
|
3
|
+
|
4
|
+
things-rb is a Ruby library and a command line tool for accessing the backend of the GTD app [Things.app](http://culturedcode.com/things/)
|
5
|
+
|
6
|
+
## Why?
|
7
|
+
There are many reasons why you would use a command line version of Things, including:
|
8
|
+
|
9
|
+
![GeekTools](http://img.skitch.com/20090323-xacfsghrsi7p6yjt1x2fnse8ek.jpg)
|
10
|
+
|
11
|
+
- Keep a list of your ToDos on the desktop using an app like [GeekTool](http://projects.tynsoe.org/en/geektool/)
|
12
|
+
- Access your ToDos remotely by SSH:ing into your machine (even from a Windows or Linux box)
|
13
|
+
- You don't need to have Things.app running all the time
|
14
|
+
- You like the Terminal and command line
|
15
|
+
|
16
|
+
## Install
|
17
|
+
|
18
|
+
Install using RubyGems
|
19
|
+
|
20
|
+
gem install haraldmartin-things-rb --source http://gems.github.com
|
21
|
+
|
22
|
+
Install via git:
|
23
|
+
|
24
|
+
$ git clone git://github.com/haraldmartin/things-rb.git
|
25
|
+
$ cd things-rb
|
26
|
+
$ rake manifest
|
27
|
+
$ sudo rake install
|
28
|
+
$ things --version # should work
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
|
32
|
+
things-rb can be used either as a Ruby library or with the included command line tool.
|
33
|
+
|
34
|
+
### Ruby Library
|
35
|
+
|
36
|
+
Example usage:
|
37
|
+
|
38
|
+
things = Things.new # will use Things' default database location.
|
39
|
+
# things = Things.new(:database => '/path/to/Database.xml')
|
40
|
+
|
41
|
+
tasks = things.today.map do |task|
|
42
|
+
tags = "(#{task.tags.join(' ')})" if task.tags?
|
43
|
+
project = "[#{task.parent}]" if task.parent?
|
44
|
+
bullet = task.completed? ? "✓" : task.canceled? ? "×" : "-"
|
45
|
+
[bullet, task.title, tags, project].compact.join(" ")
|
46
|
+
end
|
47
|
+
|
48
|
+
puts tasks.compact.sort.join("\n")
|
49
|
+
|
50
|
+
### Command Line Use
|
51
|
+
|
52
|
+
$ things
|
53
|
+
$ things --help
|
54
|
+
|
55
|
+
Shows all the options available.
|
56
|
+
|
57
|
+
The most common use I assume would be:
|
58
|
+
|
59
|
+
$ things today
|
60
|
+
|
61
|
+
which lists all tasks in "Today" which are not already completed.
|
62
|
+
|
63
|
+
If you like to show completed and canceled tasks, just pass the `--all` option
|
64
|
+
|
65
|
+
$ things --all today
|
66
|
+
|
67
|
+
Be default, things-rb will use the default location of the Things' database but if you keep it somewhere else you can
|
68
|
+
set a custom path using the `-d` or `--database` switch
|
69
|
+
|
70
|
+
$ things --database /path/to/Database.xml
|
71
|
+
|
72
|
+
Replace `today` with other focus to list the task
|
73
|
+
|
74
|
+
$ things --all next
|
75
|
+
$ things logbook
|
76
|
+
|
77
|
+
|
78
|
+
## Testing
|
79
|
+
|
80
|
+
To view test document (`test/fixtures/Database.xml`) in Things, just launch Things.app with ⌥ (option/alt) down and click "Choose Library" and point it to `things-rb/test/fixtures`.
|
81
|
+
Be sure to disable automatic logging of completed tasks in the Things.app preferences so they won't be moved around in the document.
|
82
|
+
|
83
|
+
|
84
|
+
## TODO
|
85
|
+
- Write support (add tasks via the library)
|
86
|
+
- Support "Projects" focus
|
87
|
+
- Support due dates, notes etc
|
88
|
+
- Optimize test and XML queries
|
89
|
+
- Add tag support to binary
|
90
|
+
- Organize the clases, make internal methods private
|
91
|
+
|
92
|
+
|
93
|
+
## Credits and license
|
94
|
+
|
95
|
+
By [Martin Ström](http://my-domain.se) under the MIT license:
|
96
|
+
|
97
|
+
> Copyright (c) 2008 Martin Ström
|
98
|
+
>
|
99
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
100
|
+
> of this software and associated documentation files (the "Software"), to deal
|
101
|
+
> in the Software without restriction, including without limitation the rights
|
102
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
103
|
+
> copies of the Software, and to permit persons to whom the Software is
|
104
|
+
> furnished to do so, subject to the following conditions:
|
105
|
+
>
|
106
|
+
> The above copyright notice and this permission notice shall be included in
|
107
|
+
> all copies or substantial portions of the Software.
|
108
|
+
>
|
109
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
110
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
111
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
112
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
113
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
114
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
115
|
+
> THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'echoe'
|
4
|
+
|
5
|
+
Echoe.new('things-rb', '0.1.1') do |p|
|
6
|
+
p.description = "Library and command-line tool for accessing Things.app databases"
|
7
|
+
p.url = "http://github.com/haraldmartin/things-rb"
|
8
|
+
p.author = "Martin Ström"
|
9
|
+
p.email = "martin.strom@gmail.com"
|
10
|
+
p.development_dependencies = []
|
11
|
+
end
|
12
|
+
|
13
|
+
Dir["#{File.dirname(__FILE__)}/tasks/*.rake"].sort.each { |ext| load ext }
|
data/bin/things
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.join(File.dirname(__FILE__), *%w".. lib things")
|
4
|
+
require "optparse"
|
5
|
+
|
6
|
+
options = { :tasks => { :completed => false } }
|
7
|
+
opts = OptionParser.new do |opts|
|
8
|
+
opts.separator ''
|
9
|
+
opts.separator 'Options:'
|
10
|
+
|
11
|
+
opts.banner = "Usage: things [options] today|next|inbox|logbook|trash"
|
12
|
+
|
13
|
+
def opts.show_usage
|
14
|
+
puts self
|
15
|
+
exit
|
16
|
+
end
|
17
|
+
|
18
|
+
opts.on("-d FILENAME", "--database FILENAME", "Use the specified Things database") do |database|
|
19
|
+
options[:database] = database
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on("-c", "--completed", 'Shows only completed tasks') { options[:tasks] = { :completed => true } }
|
23
|
+
opts.on("-a", "--all", 'Shows all tasks in the focus') { |f| options[:tasks] = { } }
|
24
|
+
|
25
|
+
opts.on_tail("-h", "--help", "Shows this help message") { opts.show_usage }
|
26
|
+
opts.on_tail("-v", "--version", "Shows version") do
|
27
|
+
puts Things::Version::STRING
|
28
|
+
exit
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.show_usage if ARGV.empty?
|
32
|
+
|
33
|
+
begin
|
34
|
+
opts.order(ARGV) { |focus| options[:focus] = focus }
|
35
|
+
rescue OptionParser::ParseError => e
|
36
|
+
opts.warn e.message
|
37
|
+
opts.show_usage
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
opts.parse!
|
42
|
+
opts.show_usage unless options.key?(:focus)
|
43
|
+
|
44
|
+
things = Things.new(:database => options.delete(:database))
|
45
|
+
tasks = things.focus(options[:focus]).tasks(options[:tasks]).map do |task|
|
46
|
+
tags = "(#{task.tags.join(' ')})" if task.tags?
|
47
|
+
project = "[#{task.parent}]" if task.parent?
|
48
|
+
bullet = task.completed? ? "✓" : task.canceled? ? "×" : "-"
|
49
|
+
[bullet, task.title, tags, project].compact.join(" ")
|
50
|
+
end
|
51
|
+
|
52
|
+
puts tasks.compact.sort.join("\n")
|
53
|
+
|
data/lib/things.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
$:.unshift File.dirname(__FILE__)
|
2
|
+
|
3
|
+
require "rubygems"
|
4
|
+
require "hpricot"
|
5
|
+
|
6
|
+
class Symbol
|
7
|
+
def to_proc
|
8
|
+
Proc.new { |obj, *args| obj.send(self, *args) }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
require 'things/version'
|
13
|
+
require 'things/document'
|
14
|
+
require 'things/focus'
|
15
|
+
require 'things/task'
|
16
|
+
|
17
|
+
module Things
|
18
|
+
class FocusNotFound < StandardError; end
|
19
|
+
class InvalidFocus < StandardError; end
|
20
|
+
|
21
|
+
def Things.new(*options)
|
22
|
+
Document.new(*options)
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Things
|
2
|
+
class Document
|
3
|
+
DEFAULT_DATABASE_PATH = ENV['HOME'] + '/Library/Application Support/Cultured Code/Things/Database.xml' unless defined?(DEFAULT_DATABASE_PATH)
|
4
|
+
|
5
|
+
attr_reader :database_file
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@database_file = options[:database] || DEFAULT_DATABASE_PATH
|
9
|
+
@focus_cache = {}
|
10
|
+
parse!
|
11
|
+
end
|
12
|
+
|
13
|
+
def database
|
14
|
+
@doc
|
15
|
+
end
|
16
|
+
|
17
|
+
def focus(name)
|
18
|
+
@focus_cache[name] ||= Focus.new(name, @doc)
|
19
|
+
end
|
20
|
+
|
21
|
+
[:today, :inbox, :trash, :logbook, :next].each do |name|
|
22
|
+
class_eval <<-EOF
|
23
|
+
def #{name}(options = {}) # def inbox(options = {})
|
24
|
+
focus(:#{name}).tasks(options) # focus(:inbox).tasks(options)
|
25
|
+
end # end
|
26
|
+
EOF
|
27
|
+
end
|
28
|
+
|
29
|
+
alias_method :nextactions, :next
|
30
|
+
alias_method :next_actions, :next
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def parse!
|
35
|
+
@doc = Hpricot(IO.read(database_file))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/lib/things/focus.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
module Things
|
2
|
+
class Focus
|
3
|
+
FOCUS_TYPES = %w[FocusInbox FocusLogbook FocusMaybe FocusNextActions FocusTickler FocusToday FocusTrash].freeze
|
4
|
+
|
5
|
+
def initialize(name, doc)
|
6
|
+
@name = name
|
7
|
+
@doc = doc
|
8
|
+
@xml_node = @doc.at("//object[@type='FOCUS']/attribute[@name='identifier'][text()='#{type_name}']/..")
|
9
|
+
end
|
10
|
+
|
11
|
+
def type_name
|
12
|
+
name = case @name.to_s
|
13
|
+
when /next/i then "FocusNextActions"
|
14
|
+
when "someday", "later" then "FocusMaybe"
|
15
|
+
when "scheduled" then "FocusTickler"
|
16
|
+
else "Focus" + @name.to_s.capitalize
|
17
|
+
end
|
18
|
+
raise Things::InvalidFocus, name unless FOCUS_TYPES.member?(name)
|
19
|
+
name
|
20
|
+
end
|
21
|
+
|
22
|
+
def id
|
23
|
+
@id ||= @xml_node.attributes['id']
|
24
|
+
end
|
25
|
+
|
26
|
+
def type_id
|
27
|
+
@type_id ||= @xml_node.at("/attribute[@name='focustype']").inner_text
|
28
|
+
end
|
29
|
+
|
30
|
+
def tasks(options = {})
|
31
|
+
options ||= {} # when options == nil
|
32
|
+
|
33
|
+
selector = "//object[@type='TODO']/attribute[@name='focustype'][text()='#{type_id}']/.."
|
34
|
+
@all_tasks ||= @doc.search(selector).map do |task_xml|
|
35
|
+
Task.new(task_xml, @doc)
|
36
|
+
end
|
37
|
+
|
38
|
+
filter_tasks!(options)
|
39
|
+
|
40
|
+
@tasks.sort_by &:position
|
41
|
+
end
|
42
|
+
|
43
|
+
alias_method :todos, :tasks
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# TODO: Smarter task filtering
|
48
|
+
def filter_tasks!(options)
|
49
|
+
@tasks = @all_tasks.reject(&:children?)
|
50
|
+
|
51
|
+
[:completed, :canceled].each do |filter|
|
52
|
+
proc = Proc.new { |e| e.send("#{filter}?") }
|
53
|
+
if options.key?(filter)
|
54
|
+
if options[filter]
|
55
|
+
@tasks = @tasks.select(&proc)
|
56
|
+
else
|
57
|
+
@tasks = @tasks.reject(&proc)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
data/lib/things/task.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
module Things
|
2
|
+
class Task
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
INCOMPLETED = 0
|
6
|
+
CANCELED = 2
|
7
|
+
COMPLETED = 3
|
8
|
+
|
9
|
+
def initialize(task_xml, doc)
|
10
|
+
@doc = doc
|
11
|
+
@xml_node = task_xml
|
12
|
+
end
|
13
|
+
|
14
|
+
def title
|
15
|
+
@xml_node.at("attribute[@name='title']").inner_text
|
16
|
+
end
|
17
|
+
|
18
|
+
alias_method :to_s, :title
|
19
|
+
|
20
|
+
def <=>(another)
|
21
|
+
if parent? && another.parent?
|
22
|
+
parent <=> another.parent
|
23
|
+
else
|
24
|
+
title <=> another.title
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_xml
|
29
|
+
@xml_node.to_s
|
30
|
+
end
|
31
|
+
|
32
|
+
def tag_ids
|
33
|
+
ids_from_relationship("tags")
|
34
|
+
end
|
35
|
+
|
36
|
+
def tags
|
37
|
+
@tags ||= tag_ids.map do |tag_id|
|
38
|
+
@doc.at("##{tag_id} attribute[@name=title]").inner_text
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def tags?
|
43
|
+
tags.any?
|
44
|
+
end
|
45
|
+
|
46
|
+
def tag?(name)
|
47
|
+
tags.include?(name)
|
48
|
+
end
|
49
|
+
|
50
|
+
def parent_id
|
51
|
+
id_from_relationship('parent')
|
52
|
+
end
|
53
|
+
|
54
|
+
def parent
|
55
|
+
@parent ||= task_from_id(parent_id)
|
56
|
+
end
|
57
|
+
|
58
|
+
def parent?
|
59
|
+
!!parent
|
60
|
+
end
|
61
|
+
|
62
|
+
def completed?
|
63
|
+
status == COMPLETED
|
64
|
+
end
|
65
|
+
|
66
|
+
alias_method :complete?, :completed?
|
67
|
+
alias_method :done?, :completed?
|
68
|
+
|
69
|
+
def incompleted?
|
70
|
+
status == INCOMPLETED
|
71
|
+
end
|
72
|
+
|
73
|
+
alias_method :incomplete?, :incompleted?
|
74
|
+
|
75
|
+
def canceled?
|
76
|
+
status == CANCELED
|
77
|
+
end
|
78
|
+
|
79
|
+
def status
|
80
|
+
@status ||= (node = @xml_node.at("attribute[@name='status']")) && node.inner_text.to_i
|
81
|
+
end
|
82
|
+
|
83
|
+
def position
|
84
|
+
@position ||= @xml_node.at("attribute[@name='index']").inner_text.to_i
|
85
|
+
end
|
86
|
+
|
87
|
+
alias_method :index, :position
|
88
|
+
alias_method :order, :position
|
89
|
+
|
90
|
+
def children_ids
|
91
|
+
ids_from_relationship('children')
|
92
|
+
end
|
93
|
+
|
94
|
+
def children
|
95
|
+
@children ||= tasks_from_ids(children_ids)
|
96
|
+
end
|
97
|
+
|
98
|
+
def children?
|
99
|
+
children.any?
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def tasks_from_ids(ids)
|
105
|
+
ids.map { |id| task_from_id(id) }.compact
|
106
|
+
end
|
107
|
+
|
108
|
+
def task_from_id(id)
|
109
|
+
if (node = @doc.at("##{id}")) && node.inner_text.strip != ''
|
110
|
+
Task.new(node, @doc)
|
111
|
+
else
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# TODO rename id_from_relationship
|
117
|
+
def id_from_relationship(name)
|
118
|
+
ids_from_relationship(name)[0]
|
119
|
+
end
|
120
|
+
|
121
|
+
def ids_from_relationship(name)
|
122
|
+
if node = @xml_node.at("relationship[@name='#{name}'][@idrefs]")
|
123
|
+
node.attributes['idrefs'].split
|
124
|
+
else
|
125
|
+
[]
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|