zzamboni-things2thl 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Manifest +5 -0
- data/README +60 -0
- data/Rakefile +18 -0
- data/VERSION +1 -0
- data/bin/things2thl +123 -0
- data/lib/Things2THL.rb +698 -0
- data/things2thl.gemspec +42 -0
- metadata +59 -0
data/Manifest
ADDED
data/README
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
Things2THL
|
2
|
+
|
3
|
+
Conversion program to transfer all tasks from Things
|
4
|
+
(http://culturedcode.com/things/ ) to The Hit List
|
5
|
+
(http://www.potionfactory.com/thehitlist/ ).
|
6
|
+
|
7
|
+
Written by Diego Zamboni <diego@zzamboni.org>
|
8
|
+
|
9
|
+
INSTALL:
|
10
|
+
--------
|
11
|
+
|
12
|
+
You need Things 1.1.1 or later, since things2thl requires Applescript
|
13
|
+
support.
|
14
|
+
|
15
|
+
You need to install rb-appscript from
|
16
|
+
http://appscript.sourceforge.net/rb-appscript/install.html
|
17
|
+
|
18
|
+
Run things2thl by changing into the base directory of this
|
19
|
+
distribution and running:
|
20
|
+
./bin/things2thl [options]
|
21
|
+
|
22
|
+
|
23
|
+
USAGE:
|
24
|
+
-----
|
25
|
+
|
26
|
+
To see a usage message:
|
27
|
+
|
28
|
+
./bin/things2thl -h
|
29
|
+
|
30
|
+
|
31
|
+
|
32
|
+
Functionality still missing:
|
33
|
+
---------------------------
|
34
|
+
|
35
|
+
- Handling contexts vs tags
|
36
|
+
|
37
|
+
Plan: make it a user option which Things tasks should be considered
|
38
|
+
as contexts.
|
39
|
+
|
40
|
+
- Handle rich-text notes (with attachments, links, etc.) properly
|
41
|
+
|
42
|
+
Plan: not sure yet. Need to investigate how notes are stored in
|
43
|
+
THL (Things uses an XML format)
|
44
|
+
|
45
|
+
- Handling delegation ("People" feature in Things)
|
46
|
+
|
47
|
+
Not sure how to transfer this to THL. Ideas are welcome.
|
48
|
+
|
49
|
+
Known issues:
|
50
|
+
-------------
|
51
|
+
|
52
|
+
- Cancellation/completion dates are not transferred, because THL
|
53
|
+
handles those attributes as read-only. So if you choose to transfer
|
54
|
+
completed/canceled tasks, they will all appear in your "completed
|
55
|
+
today" view.
|
56
|
+
|
57
|
+
- Tasks in the Things "Scheduled" focus are transferred, but the
|
58
|
+
scheduling itself is not transferred, because this information is
|
59
|
+
not accessible through AS from either Things not THL.
|
60
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gemspec|
|
7
|
+
gemspec.name = "things2thl"
|
8
|
+
gemspec.summary = "Library and command-line tool for migrating Things data to The Hit List"
|
9
|
+
gemspec.email = "diego@zzamboni.org"
|
10
|
+
gemspec.homepage = "http://zzamboni.github.com/things2thl/"
|
11
|
+
gemspec.description = "Library and command-line tool for migrating Things data to The Hit List"
|
12
|
+
gemspec.authors = ["Diego Zamboni"]
|
13
|
+
end
|
14
|
+
rescue LoadError
|
15
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
16
|
+
end
|
17
|
+
|
18
|
+
Dir["#{File.dirname(__FILE__)}/tasks/*.rake"].sort.each { |ext| load ext }
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.2.0
|
data/bin/things2thl
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
|
4
|
+
require File.join(File.dirname(__FILE__), *%w".. lib Things2THL")
|
5
|
+
require "optparse"
|
6
|
+
require "ostruct"
|
7
|
+
|
8
|
+
# Command line options and defaults
|
9
|
+
options=OpenStruct.new
|
10
|
+
options.completed = false
|
11
|
+
options.database = nil
|
12
|
+
options.structure = :projects_as_lists
|
13
|
+
options.areas = true
|
14
|
+
options.quiet = false
|
15
|
+
options.archivecompleted = true
|
16
|
+
options.projectsfolder = nil
|
17
|
+
opts = OptionParser.new do |opts|
|
18
|
+
opts.separator ''
|
19
|
+
opts.separator 'Options:'
|
20
|
+
|
21
|
+
opts.banner = "Usage: things2thl [options]"
|
22
|
+
|
23
|
+
def opts.show_usage
|
24
|
+
puts self
|
25
|
+
exit
|
26
|
+
end
|
27
|
+
|
28
|
+
opts.on("--projects-as-lists", "Convert projects in Things to lists in THL (default)") { options.structure = :projects_as_lists }
|
29
|
+
opts.on("--projects-as-tasks", "Convert projects in Things to tasks in THL") { options.structure = :projects_as_tasks }
|
30
|
+
opts.on("--no-areas", "Ignore areas in Things") { options.areas = false }
|
31
|
+
|
32
|
+
opts.on("--top-level-folder FOLDER", "If specified, do the import inside the named folders, instead of the top level",
|
33
|
+
" (Inbox, etc. will also be created there instead of their corresponding places)") do |toplevel|
|
34
|
+
options.toplevel = toplevel
|
35
|
+
end
|
36
|
+
opts.on("--projects-folder FOLDER", "If specified, the named folder will be created to contain all projects when",
|
37
|
+
" --projects-as-lists is used (otherwise they will be put in the top folders group).",
|
38
|
+
" If --projects-as-tasks is used, a 'Projects' list is always created, but this option",
|
39
|
+
" can be used to specify its name.") do |projfolder|
|
40
|
+
options.projectsfolder = projfolder
|
41
|
+
end
|
42
|
+
|
43
|
+
opts.on("-c", "--completed", 'Transfer also completed/canceled tasks and projects (default: no)') { options.completed = true }
|
44
|
+
opts.on("--no-archive-completed", 'If transferring completed/canceled tasks, also mark them as archived (default: yes)') {options.archivecompleted = false }
|
45
|
+
opts.on("-q", "--quiet", "Do not print items as they are processed") { options.quiet = true }
|
46
|
+
# opts.on("-n", "--dry-run", "Do not create anything in THL, just print the items that would be created") { options.dryrun = true }
|
47
|
+
# opts.on("-n", "--notes", "Shows only tasks with notes") { options[:tasks] = { :onlynotes => true } }
|
48
|
+
# opts.on("-a", "--all", 'Shows all tasks in the focus') { |f| options[:tasks] = { :children => false } }
|
49
|
+
|
50
|
+
opts.on("-h", "--help", "Shows this help message") { opts.show_usage }
|
51
|
+
opts.on("-v", "--version", "Shows version") do
|
52
|
+
puts Things2THL::Version::STRING
|
53
|
+
exit
|
54
|
+
end
|
55
|
+
|
56
|
+
opts.separator("")
|
57
|
+
opts.separator("Options you should seldom need:")
|
58
|
+
opts.on("--things THINGSAPP", "Location of the Things application (default: /Applications/Things.app)") do |things|
|
59
|
+
options.thingsapp = things
|
60
|
+
end
|
61
|
+
opts.on("--thl THLAPP", "Location of the The Hit List application (default: /Applications/The Hit List.app)") do |thl|
|
62
|
+
options.thlapp = thl
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
|
67
|
+
######################################################################
|
68
|
+
# Main program
|
69
|
+
######################################################################
|
70
|
+
|
71
|
+
opts.parse!
|
72
|
+
#opts.show_usage unless options.key?(:focus)
|
73
|
+
|
74
|
+
converter = Things2THL.new(options, options.thingsapp, options.thlapp)
|
75
|
+
things = converter.things
|
76
|
+
thl = converter.thl
|
77
|
+
|
78
|
+
# Create top-level containers if needed
|
79
|
+
|
80
|
+
# First, traverse all areas
|
81
|
+
things.areas.get.each { |area| converter.process(Things2THL::ThingsNode.new(area)) } if options.areas
|
82
|
+
|
83
|
+
# Next, traverse all projects, putting each one inside its corresponding area
|
84
|
+
things.projects.get.each { |project| converter.process(Things2THL::ThingsNode.new(project)) }
|
85
|
+
|
86
|
+
# Now do the tasks
|
87
|
+
# This is more complicated because:
|
88
|
+
# - to_dos returns not only tasks, also projects (not areas)
|
89
|
+
# - to-dos returns tasks from all the views: Inbox, Scheduled, Someday, and Next, so we have
|
90
|
+
# to separate them and create the appropriate containers as needed
|
91
|
+
things.to_dos.get.each do |t|
|
92
|
+
task=Things2THL::ThingsNode.new(t)
|
93
|
+
next if task.type != :selected_to_do
|
94
|
+
converter.process(task)
|
95
|
+
end
|
96
|
+
|
97
|
+
# # First traverse areas
|
98
|
+
# puts "Areas:"
|
99
|
+
# things.areas.each { |n| converter.traverse(n, 0, thl.folders_group.get) }
|
100
|
+
# # Then traverse area-less projects
|
101
|
+
# puts "\nProjects without an area:"
|
102
|
+
# parent = thl.folders_group.get
|
103
|
+
# if (options.structure == :projects_as_tasks)
|
104
|
+
# parent = converter.find_or_create_list(parent, "Projects")
|
105
|
+
# end
|
106
|
+
# things.projects.each { |n| converter.traverse(n, 0, parent) unless n.parent_area? }
|
107
|
+
# Then tasks without a project
|
108
|
+
#puts "\nTasks without a project:"
|
109
|
+
#things.focus(:next).children.each { |n| traverse(n, 0, options) unless n.parent? }
|
110
|
+
|
111
|
+
#############
|
112
|
+
|
113
|
+
# thl.folders_group.end.make(:new => :folder, :with_properties => {:name => 'test folder'})
|
114
|
+
# app("/Applications/The Hit List.app").folders_group.folders.ID("1B98A3855A3C79DE").end.make(:new => :list, :with_properties => {:name => 'test list'})
|
115
|
+
# task=app("/Applications/The Hit List.app").folders_group.folders.ID("1B98A3855A3C79DE").lists.ID("43B3187E6D90AB8D").end.make(:new => :task, :with_properties => {:title => 'test
|
116
|
+
# task', :notes => 'test notes' })
|
117
|
+
# task.end.make(:new => :task, :with_properties => {:title => 'test subtask'})
|
118
|
+
# task.set(:title => 'new title')
|
119
|
+
# task.set(title => 'new title')
|
120
|
+
# task.title = 'new title'
|
121
|
+
# thl.set(task.title, :to => 'new title')
|
122
|
+
# task.title.set('another title')
|
123
|
+
# task.title.get
|
data/lib/Things2THL.rb
ADDED
@@ -0,0 +1,698 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
#require File.join(File.dirname(__FILE__), *%w".. .. things-rb lib things")
|
4
|
+
require "ostruct"
|
5
|
+
require 'time'
|
6
|
+
begin; require 'rubygems'; rescue LoadError; end
|
7
|
+
require 'appscript'; include Appscript
|
8
|
+
|
9
|
+
######################################################################
|
10
|
+
|
11
|
+
module Things2THL
|
12
|
+
module Version
|
13
|
+
MAJOR = 0
|
14
|
+
MINOR = 1
|
15
|
+
|
16
|
+
STRING = [MAJOR, MINOR].join(".")
|
17
|
+
end
|
18
|
+
|
19
|
+
####################################################################
|
20
|
+
|
21
|
+
module Constants
|
22
|
+
# Ways in which the items can be structured on the THL side.
|
23
|
+
# Elements are: <Things item> => [<THL item>, {prop mapping}, postblock],
|
24
|
+
# where {prop mapping} is a map containing <Things prop> => <THL prop>.
|
25
|
+
# <Things prop> is a symbol representing the name of a Node property.
|
26
|
+
# <THL prop> can be any of the following
|
27
|
+
# :symbol => value of <Things prop> is assigned to this prop
|
28
|
+
# [:symbol, {hash}] => value of <Things prop> is looked up in {hash}
|
29
|
+
# and the corresponding value is assigned to :symbol
|
30
|
+
# {value1 => :symbol1, value2 => :symbol2} => if value of <Things prop>
|
31
|
+
# is value1, then :symbol1 prop in THL is set to true, etc.
|
32
|
+
# postblock, if given, should be a code block that will receive
|
33
|
+
# the original node, the produced new properties hash and the
|
34
|
+
# Things2THL object as parameters, and do any necessary
|
35
|
+
# postprocessing on the new properties
|
36
|
+
# If postblock inserts in the properties hash an item with the key
|
37
|
+
# :__newnodes__, it should be an array with items of the form
|
38
|
+
# { :new => :what,
|
39
|
+
# :with_properties => { properties }
|
40
|
+
# }
|
41
|
+
# Those items will be added to the new THL node immediately
|
42
|
+
# after its creation.
|
43
|
+
STRUCTURES = {
|
44
|
+
:projects_as_lists => {
|
45
|
+
:area => [:folder,
|
46
|
+
{
|
47
|
+
:name => :name,
|
48
|
+
}],
|
49
|
+
:project => [:list,
|
50
|
+
{
|
51
|
+
:name => :name,
|
52
|
+
:creation_date => :created_date,
|
53
|
+
},
|
54
|
+
Proc.new {|node,prop,obj|
|
55
|
+
obj.add_list_notes(node,prop)
|
56
|
+
obj.add_project_duedate(node,prop)
|
57
|
+
}
|
58
|
+
],
|
59
|
+
:selected_to_do => [:task,
|
60
|
+
{
|
61
|
+
:name => :title,
|
62
|
+
:creation_date => :created_date,
|
63
|
+
:due_date => :due_date,
|
64
|
+
:completion_date => :completed_date,
|
65
|
+
:cancellation_date => :canceled_date,
|
66
|
+
:status => {
|
67
|
+
:completed => :completed,
|
68
|
+
:canceled => :canceled,
|
69
|
+
},
|
70
|
+
:notes => :notes,
|
71
|
+
},
|
72
|
+
Proc.new {|node,prop,obj|
|
73
|
+
obj.fix_completed_canceled(node, prop)
|
74
|
+
obj.archive_completed(prop)
|
75
|
+
obj.add_tags(node, prop, true, true)
|
76
|
+
obj.check_today(node, prop)
|
77
|
+
}
|
78
|
+
]
|
79
|
+
},
|
80
|
+
:projects_as_tasks => {
|
81
|
+
:area => [:list,
|
82
|
+
{
|
83
|
+
:name => :name,
|
84
|
+
}],
|
85
|
+
:project => [:task,
|
86
|
+
{
|
87
|
+
:name => :title,
|
88
|
+
:creation_date => :created_date,
|
89
|
+
:due_date => :due_date,
|
90
|
+
:completion_date => :completed_date,
|
91
|
+
:cancellation_date => :canceled_date,
|
92
|
+
:status => {
|
93
|
+
:completed => :completed,
|
94
|
+
:canceled => :canceled,
|
95
|
+
},
|
96
|
+
:notes => :notes,
|
97
|
+
},
|
98
|
+
Proc.new {|node,prop,obj|
|
99
|
+
obj.fix_completed_canceled(node, prop)
|
100
|
+
obj.archive_completed(prop)
|
101
|
+
obj.add_tags(node, prop, false, true)
|
102
|
+
}
|
103
|
+
],
|
104
|
+
:selected_to_do => [:task,
|
105
|
+
{
|
106
|
+
:name => :title,
|
107
|
+
:creation_date => :created_date,
|
108
|
+
:due_date => :due_date,
|
109
|
+
:completion_date => :completed_date,
|
110
|
+
:cancellation_date => :canceled_date,
|
111
|
+
:status => {
|
112
|
+
:completed => :completed,
|
113
|
+
:canceled => :canceled,
|
114
|
+
},
|
115
|
+
:notes => :notes,
|
116
|
+
},
|
117
|
+
Proc.new {|node,prop,obj|
|
118
|
+
obj.fix_completed_canceled(node, prop)
|
119
|
+
obj.archive_completed(prop)
|
120
|
+
obj.add_tags(node, prop, false, true)
|
121
|
+
obj.check_today(node, prop)
|
122
|
+
}
|
123
|
+
]
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
# For some reason some entities in THL use "title", others use "name"
|
128
|
+
TITLEPROP = {
|
129
|
+
:folder => :name,
|
130
|
+
:list => :name,
|
131
|
+
:task => :title
|
132
|
+
}
|
133
|
+
|
134
|
+
end ### module Constants
|
135
|
+
|
136
|
+
####################################################################
|
137
|
+
|
138
|
+
# Wrapper around an AS Things node to provide easier access to its props
|
139
|
+
# For each property 'prop' of the node, it defines two methods:
|
140
|
+
# prop() returns the value, converting :missing_value to nil
|
141
|
+
# prop?() returns true/false to indicate if the value is set and is not :missing_value
|
142
|
+
# Not all the AS properties respond properly to get(), so not all of the
|
143
|
+
# auto-defined methods will work properly.
|
144
|
+
class ThingsNode
|
145
|
+
attr_accessor :node
|
146
|
+
|
147
|
+
@@defined={}
|
148
|
+
|
149
|
+
def initialize(node)
|
150
|
+
@node = node
|
151
|
+
@values = {}
|
152
|
+
return unless @@defined.empty?
|
153
|
+
(node.properties + node.elements).each do |prop|
|
154
|
+
next if @@defined.has_key?(prop)
|
155
|
+
puts "Defining ThingsNode.#{prop}" if $DEBUG
|
156
|
+
@@defined[prop]=true
|
157
|
+
case prop
|
158
|
+
# For area, project and some others, convert the result to a ThingsNode as well
|
159
|
+
when 'area', 'project', 'areas', 'projects', 'parent_tag', 'delegate'
|
160
|
+
ThingsNode.class_eval <<-EOF
|
161
|
+
def #{prop}
|
162
|
+
return @values['#{prop}'] if @values.has_key?('#{prop}')
|
163
|
+
value=node.#{prop}.get
|
164
|
+
@values['#{prop}']=case value
|
165
|
+
when nil, :missing_value
|
166
|
+
nil
|
167
|
+
else
|
168
|
+
ThingsNode.new(value)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
def #{prop}?
|
172
|
+
!!#{prop}
|
173
|
+
end
|
174
|
+
EOF
|
175
|
+
when 'to_dos', 'tags'
|
176
|
+
# For to_dos and tags, map the returned array to ThingsNode as well
|
177
|
+
ThingsNode.class_eval <<-EOF
|
178
|
+
def #{prop}
|
179
|
+
return @values['#{prop}'] if @values.has_key?('#{prop}')
|
180
|
+
value=node.#{prop}.get
|
181
|
+
@values['#{prop}']=case value
|
182
|
+
when nil, :missing_value
|
183
|
+
nil
|
184
|
+
else
|
185
|
+
value.map { |n| ThingsNode.new(n) }
|
186
|
+
end
|
187
|
+
end
|
188
|
+
def #{prop}?
|
189
|
+
!!#{prop}
|
190
|
+
end
|
191
|
+
EOF
|
192
|
+
else
|
193
|
+
ThingsNode.class_eval <<-EOF
|
194
|
+
def #{prop}
|
195
|
+
return @values['#{prop}'] if @values.has_key?('#{prop}')
|
196
|
+
value=node.#{prop}.get
|
197
|
+
value=nil if value==:missing_value
|
198
|
+
@values['#{prop}']=value
|
199
|
+
end
|
200
|
+
def #{prop}?
|
201
|
+
!!#{prop}
|
202
|
+
end
|
203
|
+
EOF
|
204
|
+
# Some smarts for specific attributes
|
205
|
+
if prop == 'class_'
|
206
|
+
ThingsNode.class_eval 'alias_method :type, :class_'
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end ### ThingsNode
|
212
|
+
|
213
|
+
####################################################################
|
214
|
+
|
215
|
+
class Converter
|
216
|
+
attr_accessor :options, :things, :thl
|
217
|
+
|
218
|
+
def initialize(opt_struct = nil, things_location = nil, thl_location = nil)
|
219
|
+
@options=opt_struct || OpenStruct.new
|
220
|
+
thingsappname=things_location || 'Things'
|
221
|
+
thlappname=thl_location || 'The Hit List'
|
222
|
+
begin
|
223
|
+
@things = Appscript.app(thingsappname)
|
224
|
+
@thl = Appscript.app(thlappname)
|
225
|
+
rescue ApplicationNotFoundError => e
|
226
|
+
puts "I could not open one of the needed applications: #{e.message}"
|
227
|
+
exit(1)
|
228
|
+
end
|
229
|
+
|
230
|
+
# Structure to keep track of already create items
|
231
|
+
# These hashes are indexed by Things node ID (node.id_). Each element
|
232
|
+
# contains a hash with two elements:
|
233
|
+
# :things_node - pointer to the corresponding AS node in Things
|
234
|
+
# :thl_node - pointer to the corresponding AS node in THL
|
235
|
+
@cache_nodes = {}
|
236
|
+
|
237
|
+
# Cache of which items are contained in each focus (Inbox, etc.)
|
238
|
+
# Indexed by focus name, value is a hash with elements keyed by the
|
239
|
+
# id_ of each node that belongs to that focus. Existence of the key
|
240
|
+
# indicates existence in the focus.
|
241
|
+
@cache_focus = {}
|
242
|
+
end
|
243
|
+
|
244
|
+
# Get the type of the THL node that corresponds to the given Things node,
|
245
|
+
# depending on the options specified
|
246
|
+
def thl_node_type(node)
|
247
|
+
Constants::STRUCTURES[options.structure][node.type][0]
|
248
|
+
end
|
249
|
+
|
250
|
+
# Get the name/title for a THL node.
|
251
|
+
def thl_node_name(node)
|
252
|
+
node.properties_.get[Constants::TITLEPROP[node.class_.get]]
|
253
|
+
end
|
254
|
+
|
255
|
+
def props_from_node(node)
|
256
|
+
newprops={}
|
257
|
+
# Process the properties, according to how the mapping is specified in STRUCTURES
|
258
|
+
pm=Constants::STRUCTURES[options.structure][node.type][1]
|
259
|
+
proptoset=nil
|
260
|
+
pm.keys.each do |k|
|
261
|
+
case pm[k]
|
262
|
+
when Symbol
|
263
|
+
proptoset=pm[k]
|
264
|
+
value=node.node.properties_.get[k]
|
265
|
+
newprops[proptoset] = value if value && value != :missing_value
|
266
|
+
when Array
|
267
|
+
proptoset=pm[k][0]
|
268
|
+
value=pm[k][1][node.node.properties_.get[k]]
|
269
|
+
newprops[proptoset] = value if value && value != :missing_value
|
270
|
+
when Hash
|
271
|
+
value = node.node.properties_.get[k]
|
272
|
+
if value && value != :missing_value
|
273
|
+
proptoset = pm[k][value]
|
274
|
+
if proptoset
|
275
|
+
newprops[proptoset] = true
|
276
|
+
end
|
277
|
+
end
|
278
|
+
else
|
279
|
+
puts "Invalid class for pm[k]=#{pm[k]} (#{pm[k].class})"
|
280
|
+
end
|
281
|
+
puts "Mapping node.#{k} (#{node.node.properties_.get[k]}) to newprops[#{proptoset}]=#{newprops[proptoset]}" if $DEBUG
|
282
|
+
end
|
283
|
+
# Do any necesary postprocessing
|
284
|
+
postproc=Constants::STRUCTURES[options.structure][node.type][2]
|
285
|
+
if postproc
|
286
|
+
puts "Calling post-processor #{postproc.to_s} with newprops=#{newprops.inspect}" if $DEBUG
|
287
|
+
postproc.call(node, newprops, self)
|
288
|
+
puts "After post-processor: #{newprops.inspect}" if $DEBUG
|
289
|
+
end
|
290
|
+
return newprops
|
291
|
+
end
|
292
|
+
|
293
|
+
def loose_tasks_name(parent)
|
294
|
+
if parent == top_level_node
|
295
|
+
"Loose tasks"
|
296
|
+
else
|
297
|
+
thl_node_name(parent) + " - loose tasks"
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# Find or create a list or a folder inside the given parent (or the top-level folders group if not given)
|
302
|
+
def find_or_create(what, name, parent = @thl.folders_group.get)
|
303
|
+
unless what == :list || what == :folder
|
304
|
+
raise "find_or_create: 'what' parameter has to be :list or :folder"
|
305
|
+
end
|
306
|
+
puts "parent of #{name} = #{parent}" if $DEBUG
|
307
|
+
if parent.class_.get != :folder
|
308
|
+
raise "find_or_create: parent is not a folder, it's a #{parent.class_.get}"
|
309
|
+
else
|
310
|
+
if parent.groups[name].exists
|
311
|
+
parent.groups[name].get
|
312
|
+
else
|
313
|
+
parent.end.make(:new => what, :with_properties => {:name => name})
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def new_folder(name, parent = @thl.folders_group.get)
|
319
|
+
parent.end.make(:new => :folder,
|
320
|
+
:with_properties => { :name => name })
|
321
|
+
end
|
322
|
+
|
323
|
+
# Return the provided top level node, or the folders group if the option is not specified
|
324
|
+
def top_level_node
|
325
|
+
return @thl.folders_group.get unless options.toplevel
|
326
|
+
|
327
|
+
unless @top_level_node
|
328
|
+
# Create the top-level node if we don't have it cached yet
|
329
|
+
@top_level_node=new_folder(options.toplevel)
|
330
|
+
end
|
331
|
+
@top_level_node
|
332
|
+
end
|
333
|
+
|
334
|
+
# Create (if necessary) and return an appropriate THL container
|
335
|
+
# for a given top-level Things focus.
|
336
|
+
# If --top-level-folder is specified, all of them are simply
|
337
|
+
# folders inside that folder.
|
338
|
+
# Otherwise:
|
339
|
+
# Inbox => Inbox
|
340
|
+
# Next => default top level node
|
341
|
+
# Scheduled, Someday => correspondingly-named top-level folders
|
342
|
+
# Logbook => 'Completed' top-level folder
|
343
|
+
# Projects => 'Projects' list if --projects-as-tasks
|
344
|
+
# => 'Projects' folder if --projects-folder was specified
|
345
|
+
# => default top level node otherwise
|
346
|
+
# Trash => ignore
|
347
|
+
# Today => ignore
|
348
|
+
def top_level_for_focus(focusnames)
|
349
|
+
# We loop through all the focus names given, and return the first
|
350
|
+
# one that produces a non-nil container
|
351
|
+
focusnames.each do |focusname|
|
352
|
+
result = if options.toplevel
|
353
|
+
case focusname
|
354
|
+
when 'Trash', 'Today'
|
355
|
+
nil
|
356
|
+
when 'Inbox', 'Next'
|
357
|
+
find_or_create(:list, focusname, top_level_node)
|
358
|
+
when 'Scheduled', 'Someday', 'Logbook'
|
359
|
+
find_or_create(:folder, focusname, top_level_node)
|
360
|
+
when 'Projects'
|
361
|
+
if options.structure == :projects_as_tasks
|
362
|
+
find_or_create(:list, options.projectsfolder || 'Projects', top_level_node)
|
363
|
+
else
|
364
|
+
if options.projectsfolder
|
365
|
+
find_or_create(:folder, options.projectsfolder, top_level_node)
|
366
|
+
else
|
367
|
+
top_level_node
|
368
|
+
end
|
369
|
+
end
|
370
|
+
else
|
371
|
+
puts "Invalid focus name: #{focusname}"
|
372
|
+
top_level_node
|
373
|
+
end
|
374
|
+
else
|
375
|
+
# That was easy. Now for the more complicated part.
|
376
|
+
case focusname
|
377
|
+
when 'Inbox'
|
378
|
+
thl.inbox.get
|
379
|
+
when 'Next'
|
380
|
+
top_level_node
|
381
|
+
when 'Scheduled', 'Someday', 'Logbook'
|
382
|
+
find_or_create(:folder, focusname, top_level_node)
|
383
|
+
when 'Projects'
|
384
|
+
if options.structure == :projects_as_tasks
|
385
|
+
find_or_create(:list, options.projectsfolder || 'Projects', top_level_node)
|
386
|
+
else
|
387
|
+
if options.projectsfolder
|
388
|
+
find_or_create(:folder, options.projectsfolder, top_level_node)
|
389
|
+
else
|
390
|
+
top_level_node
|
391
|
+
end
|
392
|
+
end
|
393
|
+
when 'Trash', 'Today'
|
394
|
+
nil
|
395
|
+
else
|
396
|
+
puts "Invalid focus name: #{focusname}"
|
397
|
+
top_level_node
|
398
|
+
end
|
399
|
+
end
|
400
|
+
return result if result
|
401
|
+
end
|
402
|
+
nil
|
403
|
+
end
|
404
|
+
|
405
|
+
# Get the top-level focus for a node. If it's not directly contained
|
406
|
+
# in a focus, check its project and area, if any.
|
407
|
+
def top_level_for_node(node)
|
408
|
+
# Areas are always contained at the top level, unless they
|
409
|
+
# are suspended
|
410
|
+
if node.type == :area
|
411
|
+
if node.suspended
|
412
|
+
return top_level_for_focus('Someday')
|
413
|
+
else
|
414
|
+
return top_level_node
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
# Else, find if the node is contained in a top-level focus...
|
419
|
+
foci=focus_of(node)
|
420
|
+
# ...if not, look at its project and area
|
421
|
+
if foci.empty?
|
422
|
+
if node.project?
|
423
|
+
tl=top_level_for_node(node.project)
|
424
|
+
if !tl && node.area?
|
425
|
+
tl=top_level_for_node(node.area)
|
426
|
+
end
|
427
|
+
elsif node.area?
|
428
|
+
tl=top_level_for_node(node.area)
|
429
|
+
end
|
430
|
+
return tl
|
431
|
+
end
|
432
|
+
top_level_for_focus(foci)
|
433
|
+
end
|
434
|
+
|
435
|
+
# See if we have processed a node already - in that case return
|
436
|
+
# the cached THL node. Otherwise, invoke the process() function
|
437
|
+
# on it, which will put it in the cache.
|
438
|
+
def get_cached_or_process(node)
|
439
|
+
node_id=node.id_
|
440
|
+
if @cache_nodes.has_key?(node_id)
|
441
|
+
@cache_nodes[node_id][:thl_node]
|
442
|
+
else
|
443
|
+
# If we don't have the corresponding node cached yet, do it now
|
444
|
+
process(node)
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
# Create if necessary and return an appropriate THL container
|
449
|
+
# object for the new node, according to the node's class and
|
450
|
+
# options selected. A task in THL can only be contained in a list
|
451
|
+
# or in another task. So if parent is a folder and note is a task,
|
452
|
+
# we need to find or create an auxiliary list to contain it.
|
453
|
+
def container_for(node)
|
454
|
+
# If its top-level container is nil, it means we need to skip this node
|
455
|
+
# unless it's an area, areas don't have a focus
|
456
|
+
tlcontainer=top_level_for_node(node)
|
457
|
+
return nil unless tlcontainer
|
458
|
+
|
459
|
+
# Otherwise, run through the process
|
460
|
+
container = case node.type
|
461
|
+
when :area
|
462
|
+
tlcontainer
|
463
|
+
when :project
|
464
|
+
if options.areas && node.area?
|
465
|
+
get_cached_or_process(node.area)
|
466
|
+
else
|
467
|
+
tlcontainer
|
468
|
+
end
|
469
|
+
when :selected_to_do
|
470
|
+
if node.project?
|
471
|
+
get_cached_or_process(node.project)
|
472
|
+
elsif node.area? && options.areas
|
473
|
+
get_cached_or_process(node.area)
|
474
|
+
else
|
475
|
+
# It's a loose task
|
476
|
+
tlcontainer
|
477
|
+
end
|
478
|
+
else
|
479
|
+
raise "Invalid Things node type: #{node.type}"
|
480
|
+
end
|
481
|
+
|
482
|
+
# Now we check the container type. Tasks can only be contained in lists,
|
483
|
+
# so if the container is a folder, we have to create a list to hold the task
|
484
|
+
if container && (container.class_.get == :folder) && (thl_node_type(node) == :task)
|
485
|
+
if node.type == :project
|
486
|
+
find_or_create(:list, options.projectsfolder || 'Projects', container)
|
487
|
+
else
|
488
|
+
find_or_create(:list, loose_tasks_name(container), container)
|
489
|
+
end
|
490
|
+
else
|
491
|
+
container
|
492
|
+
end
|
493
|
+
end
|
494
|
+
|
495
|
+
def create_in_thl(node, parent)
|
496
|
+
if (parent)
|
497
|
+
new_node_type = thl_node_type(node)
|
498
|
+
new_node_props = props_from_node(node)
|
499
|
+
additional_nodes = new_node_props.delete(:__newnodes__)
|
500
|
+
result=parent.end.make(:new => new_node_type,
|
501
|
+
:with_properties => new_node_props )
|
502
|
+
if node.type == :area || node.type == :project
|
503
|
+
@cache_nodes[node.id_]={}
|
504
|
+
@cache_nodes[node.id_][:things_node] = node
|
505
|
+
@cache_nodes[node.id_][:thl_node] = result
|
506
|
+
end
|
507
|
+
# Add new nodes
|
508
|
+
if additional_nodes
|
509
|
+
additional_nodes.each do |n|
|
510
|
+
result.end.make(n)
|
511
|
+
end
|
512
|
+
end
|
513
|
+
return result
|
514
|
+
else
|
515
|
+
parent
|
516
|
+
end
|
517
|
+
end
|
518
|
+
|
519
|
+
# Process a single node. Returns the new THL node.
|
520
|
+
def process(node)
|
521
|
+
return unless node
|
522
|
+
# Skip if we have already processed it
|
523
|
+
return if @cache_nodes.has_key?(node.id_)
|
524
|
+
# Areas don't have status, so we skip the check
|
525
|
+
unless node.type == :area
|
526
|
+
return if ( node.status == :completed || node.status == :canceled ) && !(options.completed)
|
527
|
+
end
|
528
|
+
|
529
|
+
container=container_for(node)
|
530
|
+
puts "Container for #{node.name}: #{container}" if $DEBUG
|
531
|
+
unless container
|
532
|
+
puts "Skipping trashed task '#{node.name}'" unless options.quiet
|
533
|
+
return
|
534
|
+
end
|
535
|
+
|
536
|
+
unless (options.quiet)
|
537
|
+
bullet = (node.type == :area) ? "*" : ((node.status == :completed) ? "✓" : (node.status == :canceled) ? "×" : "-")
|
538
|
+
puts bullet + " " + node.name + " (#{node.type})"
|
539
|
+
end
|
540
|
+
|
541
|
+
newnode=create_in_thl(node, container)
|
542
|
+
end
|
543
|
+
|
544
|
+
# Get all the focus names
|
545
|
+
def get_focusnames(all=false)
|
546
|
+
# Get only top-level items of type :list (areas are also there, but with type :area) unless all==true
|
547
|
+
@cached_focusnames||=things.lists.get.select {|l| all || l.class_.get == :list }.map { |focus| focus.name.get }
|
548
|
+
end
|
549
|
+
|
550
|
+
# Create the focus caches
|
551
|
+
def create_focuscaches
|
552
|
+
get_focusnames.each { |focus|
|
553
|
+
puts "Creating focus cache for #{focus}..." if $DEBUG
|
554
|
+
@cache_focus[focus] = {}
|
555
|
+
next if focus == "Logbook" && !options.completed
|
556
|
+
things.lists[focus].to_dos.get.each { |t|
|
557
|
+
@cache_focus[focus][t.id_.get] = true
|
558
|
+
}
|
559
|
+
puts " Cache: #{@cache_focus[focus].inspect}" if $DEBUG
|
560
|
+
}
|
561
|
+
end
|
562
|
+
|
563
|
+
# Get the focuses in which a task is visible
|
564
|
+
def focus_of(node)
|
565
|
+
result=[]
|
566
|
+
get_focusnames.each { |focus|
|
567
|
+
if in_focus?(focus, node)
|
568
|
+
result.push(focus)
|
569
|
+
end
|
570
|
+
}
|
571
|
+
if node.type == :area && node.suspended
|
572
|
+
result.push('Someday')
|
573
|
+
end
|
574
|
+
result
|
575
|
+
end
|
576
|
+
|
577
|
+
# Check if a node is in a certain focus
|
578
|
+
# Node can be a ThingsNode, and AS node, or a node ID
|
579
|
+
def in_focus?(focus, node)
|
580
|
+
unless @cache_focus[focus]
|
581
|
+
create_focuscaches
|
582
|
+
end
|
583
|
+
case node
|
584
|
+
when ThingsNode
|
585
|
+
key = node.id_
|
586
|
+
when Appscript::Reference
|
587
|
+
key = node.id_.get
|
588
|
+
when String
|
589
|
+
key = node
|
590
|
+
else
|
591
|
+
puts "Unknown node object type: #{node.class}"
|
592
|
+
return nil
|
593
|
+
end
|
594
|
+
return @cache_focus[focus].has_key?(key)
|
595
|
+
end
|
596
|
+
|
597
|
+
###-------------------------------------------------------------------
|
598
|
+
### Methods to fix new nodes - called from the postproc block in STRUCTURES
|
599
|
+
|
600
|
+
# Things sets both 'completion_date' and 'cancellation_date'
|
601
|
+
# for both completed and canceled tasks, which confuses THL,
|
602
|
+
# so we delete the one that should not be there.
|
603
|
+
def fix_completed_canceled(node,prop)
|
604
|
+
if prop[:completed_date] && prop[:canceled_date]
|
605
|
+
if prop[:canceled]
|
606
|
+
prop.delete(:completed)
|
607
|
+
prop.delete(:completed_date)
|
608
|
+
else
|
609
|
+
prop.delete(:canceled)
|
610
|
+
prop.delete(:canceled_date)
|
611
|
+
end
|
612
|
+
end
|
613
|
+
end
|
614
|
+
|
615
|
+
# Archive completed/canceled if requested
|
616
|
+
def archive_completed(prop)
|
617
|
+
prop[:archived] = true if options.archivecompleted && (prop[:completed] || prop[:canceled])
|
618
|
+
end
|
619
|
+
|
620
|
+
# Add tags to title
|
621
|
+
def add_tags(node, prop, inherit_project_tags, inherit_area_tags)
|
622
|
+
tasktags = node.tags.map {|t| t.name }
|
623
|
+
if inherit_project_tags
|
624
|
+
# Merge project and area tags
|
625
|
+
if node.project?
|
626
|
+
tasktags |= node.project.tags.map {|t| t.name }
|
627
|
+
if options.areas && node.project.area?
|
628
|
+
tasktags |= node.project.area.tags.map {|t| t.name }
|
629
|
+
end
|
630
|
+
end
|
631
|
+
end
|
632
|
+
if options.areas && node.area? && inherit_area_tags
|
633
|
+
tasktags |= node.area.tags.map {|t| t.name }
|
634
|
+
end
|
635
|
+
unless tasktags.empty?
|
636
|
+
prop[:title] = [prop[:title], tasktags.map {|t| "/" + t + (t.index(" ")?"/":"") }].join(' ')
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
# Check if node is in the Today list
|
641
|
+
def check_today(node, prop)
|
642
|
+
if in_focus?('Today', node)
|
643
|
+
prop[:start_date] = Time.parse('today at 00:00')
|
644
|
+
end
|
645
|
+
end
|
646
|
+
|
647
|
+
def add_extra_node(prop, newnode)
|
648
|
+
prop[:__newnodes__] = [] unless prop.has_key?(:__newnodes__)
|
649
|
+
prop[:__newnodes__].push(newnode)
|
650
|
+
end
|
651
|
+
|
652
|
+
# Add a new task containing project notes when the project is a THL list,
|
653
|
+
# since THL lists cannot have notes
|
654
|
+
def add_list_notes(node, prop)
|
655
|
+
new_node_type = thl_node_type(node)
|
656
|
+
# Process notes only for non-areas
|
657
|
+
if (node.type != :area)
|
658
|
+
# If the node has notes but the THL node is a list, add the notes as a task in there
|
659
|
+
# If the node has notes but the THL node is a folder (this shouldn't happen), print a warning
|
660
|
+
if node.notes? && new_node_type == :list
|
661
|
+
newnode = {
|
662
|
+
:new => :task,
|
663
|
+
:with_properties => { :title => "Notes for '#{prop[:name]}'", :notes => node.notes }}
|
664
|
+
# Mark as completed if the project is completed
|
665
|
+
if node.status == :completed || node.status == :canceled
|
666
|
+
newnode[:with_properties][:completed] = true
|
667
|
+
archive_completed(newnode[:with_properties])
|
668
|
+
end
|
669
|
+
add_extra_node(prop, newnode)
|
670
|
+
end
|
671
|
+
if node.notes? && new_node_type == :folder
|
672
|
+
$stderr.puts "Error: cannot transfer notes into new folder: #{node.notes}"
|
673
|
+
end
|
674
|
+
end
|
675
|
+
end
|
676
|
+
|
677
|
+
# When projects are lists, if the project has a due date, we add a bogus task to it
|
678
|
+
# to represent its due date, since lists in THL cannot have due dates.
|
679
|
+
def add_project_duedate(node, prop)
|
680
|
+
new_node_type = thl_node_type(node)
|
681
|
+
return unless node.type == :project && new_node_type == :list && node.due_date?
|
682
|
+
|
683
|
+
# Create the new node
|
684
|
+
newnode = {
|
685
|
+
:new => :task,
|
686
|
+
:with_properties => { :title => "Due date for '#{prop[:name]}'", :due_date => node.due_date }
|
687
|
+
}
|
688
|
+
add_extra_node(prop, newnode)
|
689
|
+
end
|
690
|
+
|
691
|
+
end #### class Converter
|
692
|
+
|
693
|
+
####################################################################
|
694
|
+
|
695
|
+
def Things2THL.new(opt_struct = nil, things_db = nil, thl_location = nil)
|
696
|
+
Converter.new(opt_struct, things_db, thl_location)
|
697
|
+
end
|
698
|
+
end
|
data/things2thl.gemspec
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{things2thl}
|
5
|
+
s.version = "0.2.0"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Diego Zamboni"]
|
9
|
+
s.date = %q{2009-05-18}
|
10
|
+
s.default_executable = %q{things2thl}
|
11
|
+
s.description = %q{Library and command-line tool for migrating Things data to The Hit List}
|
12
|
+
s.email = %q{diego@zzamboni.org}
|
13
|
+
s.executables = ["things2thl"]
|
14
|
+
s.extra_rdoc_files = [
|
15
|
+
"README"
|
16
|
+
]
|
17
|
+
s.files = [
|
18
|
+
"Manifest",
|
19
|
+
"README",
|
20
|
+
"Rakefile",
|
21
|
+
"VERSION",
|
22
|
+
"bin/things2thl",
|
23
|
+
"lib/Things2THL.rb",
|
24
|
+
"things2thl.gemspec"
|
25
|
+
]
|
26
|
+
s.has_rdoc = true
|
27
|
+
s.homepage = %q{http://zzamboni.github.com/things2thl/}
|
28
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
29
|
+
s.require_paths = ["lib"]
|
30
|
+
s.rubygems_version = %q{1.3.1}
|
31
|
+
s.summary = %q{Library and command-line tool for migrating Things data to The Hit List}
|
32
|
+
|
33
|
+
if s.respond_to? :specification_version then
|
34
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
35
|
+
s.specification_version = 2
|
36
|
+
|
37
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
38
|
+
else
|
39
|
+
end
|
40
|
+
else
|
41
|
+
end
|
42
|
+
end
|
metadata
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: zzamboni-things2thl
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Diego Zamboni
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-05-18 00:00:00 -07:00
|
13
|
+
default_executable: things2thl
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: Library and command-line tool for migrating Things data to The Hit List
|
17
|
+
email: diego@zzamboni.org
|
18
|
+
executables:
|
19
|
+
- things2thl
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- README
|
24
|
+
files:
|
25
|
+
- Manifest
|
26
|
+
- README
|
27
|
+
- Rakefile
|
28
|
+
- VERSION
|
29
|
+
- bin/things2thl
|
30
|
+
- lib/Things2THL.rb
|
31
|
+
- things2thl.gemspec
|
32
|
+
has_rdoc: true
|
33
|
+
homepage: http://zzamboni.github.com/things2thl/
|
34
|
+
post_install_message:
|
35
|
+
rdoc_options:
|
36
|
+
- --charset=UTF-8
|
37
|
+
require_paths:
|
38
|
+
- lib
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: "0"
|
44
|
+
version:
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: "0"
|
50
|
+
version:
|
51
|
+
requirements: []
|
52
|
+
|
53
|
+
rubyforge_project:
|
54
|
+
rubygems_version: 1.2.0
|
55
|
+
signing_key:
|
56
|
+
specification_version: 2
|
57
|
+
summary: Library and command-line tool for migrating Things data to The Hit List
|
58
|
+
test_files: []
|
59
|
+
|