zzamboni-things2thl 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Manifest ADDED
@@ -0,0 +1,5 @@
1
+ README
2
+ Rakefile
3
+ bin/things2thl
4
+ lib/Things2THL.rb
5
+ Manifest
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
@@ -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
+