things-rb 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ pkg
2
+ doc
3
+ Manifest
4
+ test/fixures/Backups
data/CHANGELOG ADDED
@@ -0,0 +1,8 @@
1
+ *0.2.0* (August 29, 2009)
2
+
3
+ * Added support for due dates, scheduled items & notes
4
+
5
+
6
+ *0.1.0* (March 23, 2009)
7
+
8
+ * Initial public release.
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,110 @@
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
+ $ sudo gem install haraldmartin-things-rb --source http://gems.github.com
19
+
20
+ If you're on default Mac OS X Leopard and haven't upgraded your RubyGem installation you'll need to to this first:
21
+
22
+ $ sudo gem update --system
23
+
24
+ When the upgrade is done, just runt the `gem install ...` command above and you're set.
25
+
26
+
27
+ ## Usage
28
+
29
+ things-rb can be used either as a Ruby library or with the included command line tool.
30
+
31
+ ### Ruby Library
32
+
33
+ Example usage:
34
+
35
+ things = Things.new # will use Things' default database location.
36
+ # things = Things.new(:database => '/path/to/Database.xml')
37
+
38
+ tasks = things.today.map do |task|
39
+ tags = "(#{task.tags.join(' ')})" if task.tags?
40
+ project = "[#{task.parent}]" if task.parent?
41
+ bullet = task.completed? ? "✓" : task.canceled? ? "×" : "-"
42
+ [bullet, task.title, tags, project].compact.join(" ")
43
+ end
44
+
45
+ puts tasks.compact.sort.join("\n")
46
+
47
+ ### Command Line Use
48
+
49
+ $ things
50
+ $ things --help
51
+
52
+ Shows all the options available.
53
+
54
+ The most common use I assume would be:
55
+
56
+ $ things today
57
+
58
+ which lists all tasks in "Today" which are not already completed.
59
+
60
+ If you like to show completed and canceled tasks, just pass the `--all` option
61
+
62
+ $ things --all today
63
+
64
+ Be default, things-rb will use the default location of the Things' database but if you keep it somewhere else you can
65
+ set a custom path using the `-d` or `--database` switch
66
+
67
+ $ things --database /path/to/Database.xml
68
+
69
+ Replace `today` with other focus to list the task
70
+
71
+ $ things --all next
72
+ $ things logbook
73
+
74
+
75
+ ## Testing
76
+
77
+ 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`.
78
+ Be sure to disable automatic logging of completed tasks in the Things.app preferences so they won't be moved around in the document.
79
+
80
+
81
+ ## TODO
82
+ - Support "Projects" focus
83
+ - Optimize test and XML queries
84
+ - Add tag support to binary
85
+ - Organize the classes, make internal methods private
86
+
87
+
88
+ ## Credits and license
89
+
90
+ By [Martin Ström](http://my-domain.se) under the MIT license:
91
+
92
+ > Copyright (c) 2009 Martin Ström
93
+ >
94
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
95
+ > of this software and associated documentation files (the "Software"), to deal
96
+ > in the Software without restriction, including without limitation the rights
97
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
98
+ > copies of the Software, and to permit persons to whom the Software is
99
+ > furnished to do so, subject to the following conditions:
100
+ >
101
+ > The above copyright notice and this permission notice shall be included in
102
+ > all copies or substantial portions of the Software.
103
+ >
104
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
110
+ > THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ require "rubygems"
2
+ require "rake/testtask"
3
+
4
+ task :default => :test
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << "test"
8
+ t.test_files = FileList["test/test_*.rb"]
9
+ t.verbose = true
10
+ end
11
+
12
+ begin
13
+ require 'jeweler'
14
+ Jeweler::Tasks.new do |gemspec|
15
+ gemspec.name = "things-rb"
16
+ gemspec.summary = "Library and command-line tool for accessing Things.app databases"
17
+ gemspec.description = "Library and command-line tool for accessing Things.app databases"
18
+ gemspec.email = "name@my-domain.se"
19
+ gemspec.homepage = "http://github.com/haraldmartin/things-rb"
20
+ gemspec.authors = ["Martin Ström"]
21
+ gemspec.add_dependency 'hpricot'
22
+ end
23
+ Jeweler::GemcutterTasks.new
24
+ rescue LoadError
25
+ puts "Jeweler not available. Install it with: gem install jeweler"
26
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.3.0
data/bin/things ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require 'things'
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
+ [task.bullet, task.title, tags, project].compact.join(" ")
49
+ end
50
+
51
+ puts tasks.compact.sort.join("\n")
52
+
@@ -0,0 +1,39 @@
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 = {}, &block)
8
+ @database_file = options[:database] || DEFAULT_DATABASE_PATH
9
+ @focus_cache = {}
10
+ parse!
11
+ yield self if block_given?
12
+ end
13
+
14
+ def database
15
+ @doc
16
+ end
17
+
18
+ def focus(name)
19
+ @focus_cache[name] ||= Focus.new(name, @doc)
20
+ end
21
+
22
+ [:today, :inbox, :trash, :logbook, :next, :scheduled].each do |name|
23
+ class_eval <<-EOF
24
+ def #{name}(options = {}) # def inbox(options = {})
25
+ focus(:#{name}).tasks(options) # focus(:inbox).tasks(options)
26
+ end # end
27
+ EOF
28
+ end
29
+
30
+ alias_method :nextactions, :next
31
+ alias_method :next_actions, :next
32
+
33
+ private
34
+
35
+ def parse!
36
+ @doc = Hpricot(IO.read(database_file))
37
+ end
38
+ end
39
+ end
@@ -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
@@ -0,0 +1,162 @@
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 notes
80
+ @notes ||= (node = @xml_node.at("attribute[@name='content']")) &&
81
+ Hpricot.parse(node.inner_text.gsub(/\\u3c00/, "<").gsub(/\\u3e00/, ">")).inner_text
82
+ end
83
+
84
+ def notes?
85
+ !!notes
86
+ end
87
+
88
+ def status
89
+ @status ||= (node = @xml_node.at("attribute[@name='status']")) && node.inner_text.to_i
90
+ end
91
+
92
+ def position
93
+ @position ||= @xml_node.at("attribute[@name='index']").inner_text.to_i
94
+ end
95
+
96
+ alias_method :index, :position
97
+ alias_method :order, :position
98
+
99
+ def due_date
100
+ @due_date ||= date_attribute("datedue")
101
+ end
102
+
103
+ def due?
104
+ due_date && Time.now > due_date
105
+ end
106
+
107
+ def scheduled_date
108
+ @scheduled_date ||= date_attribute('tickledate')
109
+ end
110
+
111
+ def scheduled?
112
+ !!scheduled_date
113
+ end
114
+
115
+ def bullet
116
+ completed? ? "✓" : canceled? ? "×" : "-"
117
+ end
118
+
119
+ def children_ids
120
+ ids_from_relationship('children')
121
+ end
122
+
123
+ def children
124
+ @children ||= tasks_from_ids(children_ids)
125
+ end
126
+
127
+ def children?
128
+ children.any?
129
+ end
130
+
131
+ private
132
+
133
+ def tasks_from_ids(ids)
134
+ ids.map { |id| task_from_id(id) }.compact
135
+ end
136
+
137
+ def task_from_id(id)
138
+ if (node = @doc.at("##{id}")) && node.inner_text.strip != ''
139
+ Task.new(node, @doc)
140
+ else
141
+ nil
142
+ end
143
+ end
144
+
145
+ # TODO rename id_from_relationship
146
+ def id_from_relationship(name)
147
+ ids_from_relationship(name)[0]
148
+ end
149
+
150
+ def ids_from_relationship(name)
151
+ if node = @xml_node.at("relationship[@name='#{name}'][@idrefs]")
152
+ node.attributes['idrefs'].split
153
+ else
154
+ []
155
+ end
156
+ end
157
+
158
+ def date_attribute(name)
159
+ (node = @xml_node.at("attribute[@name='#{name}']")) && node.inner_text.to_f.to_cocoa_date
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,9 @@
1
+ module Things
2
+ module Version
3
+ MAJOR = 0
4
+ MINOR = 3
5
+ TINY = 0
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join(".")
8
+ end
9
+ end
data/lib/things.rb ADDED
@@ -0,0 +1,34 @@
1
+ begin
2
+ require 'hpricot'
3
+ rescue LoadError => e
4
+ puts "Hpricot is missing. Run `gem install hpricot` to install"
5
+ end
6
+ require "time"
7
+
8
+ class Symbol
9
+ def to_proc
10
+ Proc.new { |obj, *args| obj.send(self, *args) }
11
+ end
12
+ end
13
+
14
+ class Float
15
+ EPOCH = Time.at(978307200.0)
16
+
17
+ def to_cocoa_date
18
+ EPOCH + self
19
+ end
20
+ end
21
+
22
+ require 'things/version'
23
+ require 'things/document'
24
+ require 'things/focus'
25
+ require 'things/task'
26
+
27
+ module Things
28
+ class FocusNotFound < StandardError; end
29
+ class InvalidFocus < StandardError; end
30
+
31
+ def Things.new(*options, &block)
32
+ Document.new(*options, &block)
33
+ end
34
+ end