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 ADDED
@@ -0,0 +1,3 @@
1
+ *0.1.0* (March 23, 2009)
2
+
3
+ * 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,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
@@ -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,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