zzamboni-things2thl 0.2.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/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
+