rubyfocus 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/Manifest +36 -0
- data/README.md +121 -0
- data/lib/rubyfocus/document.rb +146 -0
- data/lib/rubyfocus/errors.rb +2 -0
- data/lib/rubyfocus/fetchers/fetcher.rb +83 -0
- data/lib/rubyfocus/fetchers/local_fetcher.rb +66 -0
- data/lib/rubyfocus/fetchers/oss_fetcher.rb +102 -0
- data/lib/rubyfocus/includes/conditional_exec.rb +7 -0
- data/lib/rubyfocus/includes/idref.rb +29 -0
- data/lib/rubyfocus/includes/parser.rb +36 -0
- data/lib/rubyfocus/includes/searchable.rb +82 -0
- data/lib/rubyfocus/items/context.rb +15 -0
- data/lib/rubyfocus/items/folder.rb +18 -0
- data/lib/rubyfocus/items/item.rb +63 -0
- data/lib/rubyfocus/items/named_item.rb +13 -0
- data/lib/rubyfocus/items/project.rb +76 -0
- data/lib/rubyfocus/items/ranked_item.rb +13 -0
- data/lib/rubyfocus/items/setting.rb +17 -0
- data/lib/rubyfocus/items/task.rb +133 -0
- data/lib/rubyfocus/location.rb +17 -0
- data/lib/rubyfocus/patch.rb +142 -0
- data/lib/rubyfocus/review_period.rb +43 -0
- data/lib/rubyfocus/xml_translator.rb +36 -0
- data/lib/rubyfocus.rb +16 -0
- data/rubyfocus.gemspec +22 -0
- data/version.txt +1 -0
- metadata +113 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
module Rubyfocus
|
2
|
+
# The Searchable module allows you to easily search arrays of items by
|
3
|
+
# effectively supplying find and select methods. Passing a hash to either
|
4
|
+
# of these methods allows you to search by given properties - for example:
|
5
|
+
#
|
6
|
+
# data_store.find(name: "Foobar")
|
7
|
+
#
|
8
|
+
# Is basically the same as:
|
9
|
+
#
|
10
|
+
# data_store.find{ |o| o.name == "Foobard" }
|
11
|
+
#
|
12
|
+
# If you pass +find+ or +select+ a String or Fixnum, it will look for objects
|
13
|
+
# whose +id+ equals this value.
|
14
|
+
#
|
15
|
+
# You should ensure that your class' +array+ method is overwritten to point
|
16
|
+
# to the appriopriate array.
|
17
|
+
module Searchable
|
18
|
+
# This method will return only the *first* object that matches the given
|
19
|
+
# criteria, or +nil+ if no object is found.
|
20
|
+
def find(arg=nil, &blck)
|
21
|
+
fs_block = find_select_block(arg) || blck
|
22
|
+
if fs_block
|
23
|
+
return array.find(&fs_block)
|
24
|
+
else
|
25
|
+
raise ArgumentError, "ItemArray#find called with #{arg.class} argument."
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# This method will return an array of all objects that match the given
|
30
|
+
# criteria, or +[]+ if no object is found.
|
31
|
+
def select(arg=nil, &blck)
|
32
|
+
fs_block = find_select_block(arg) || blck
|
33
|
+
if fs_block
|
34
|
+
return array.select(&fs_block)
|
35
|
+
else
|
36
|
+
raise ArgumentError, "ItemArray#select called with #{arg.class} argument."
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# This method should be overriden
|
41
|
+
def array
|
42
|
+
raise RuntimeError, "Method #{self.class}#array has not been implemented!"
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
# This method determines the correct block to use in find or select operations
|
47
|
+
def find_select_block(arg)
|
48
|
+
case arg
|
49
|
+
when Fixnum # ID
|
50
|
+
string_id = arg.to_s
|
51
|
+
Proc.new{ |item| item.id == string_id }
|
52
|
+
when String
|
53
|
+
Proc.new{ |item| item.id == arg }
|
54
|
+
when Hash
|
55
|
+
Proc.new{ |item| arg.all?{ |k,v| item.send(k) == v } }
|
56
|
+
else
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# A sample class, SearchableArray, is basically a wrapper around a standard array
|
64
|
+
class SearchableArray
|
65
|
+
include Searchable
|
66
|
+
attr_reader :array
|
67
|
+
|
68
|
+
# Takes the same arguments as the array it wraps
|
69
|
+
def initialize(*args, &blck)
|
70
|
+
@array = Array.new(*args, &blck)
|
71
|
+
end
|
72
|
+
|
73
|
+
# In all other respects, it's an array - handily governed by this method
|
74
|
+
def method_missing meth, *args, &blck
|
75
|
+
if @array.respond_to?(meth)
|
76
|
+
@array.send(meth, *args, &blck)
|
77
|
+
else
|
78
|
+
super(meth, *args, &blck)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class Rubyfocus::Context < Rubyfocus::RankedItem
|
2
|
+
include Rubyfocus::Parser
|
3
|
+
def self.matches_node?(node)
|
4
|
+
return (node.name == "context")
|
5
|
+
end
|
6
|
+
|
7
|
+
idref :container
|
8
|
+
attr_accessor :location
|
9
|
+
|
10
|
+
def apply_xml(n)
|
11
|
+
super(n)
|
12
|
+
conditional_set(:container_id,n.at_xpath("xmlns:context")) { |e| e["idref"] }
|
13
|
+
conditional_set(:location, n.at_xpath("xmlns:location")){ |e| Rubyfocus::Location.new(e) }
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Rubyfocus::Folder < Rubyfocus::RankedItem
|
2
|
+
include Rubyfocus::Parser
|
3
|
+
def self.matches_node?(node)
|
4
|
+
return (node.name == "folder")
|
5
|
+
end
|
6
|
+
|
7
|
+
idref :container
|
8
|
+
|
9
|
+
def apply_xml(n)
|
10
|
+
super(n)
|
11
|
+
conditional_set(:container_id, n.at_xpath("xmlns:folder")){ |e| e["idref"] }
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
def inspect_properties
|
16
|
+
super + %w(container_id)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# The Rubyfocus Item represents an item found in an Omnifocus XML file, and thus also
|
2
|
+
# any Omnifocus entity.
|
3
|
+
#
|
4
|
+
# The Rubyfocus Item has a parent "document" as well as a series of properties determined
|
5
|
+
# by the XML file proper. You can pass an XML document at creation or leave it blank. You
|
6
|
+
# can always apply XML later using Item#apply_xml or Item#<<
|
7
|
+
#
|
8
|
+
# By separating these two methods, we can also patch the object with another XML node, e.g.
|
9
|
+
# through an update. This is important!
|
10
|
+
class Rubyfocus::Item
|
11
|
+
include Rubyfocus::IDRef
|
12
|
+
include Rubyfocus::ConditionalExec
|
13
|
+
attr_accessor :id, :added, :modified, :document
|
14
|
+
|
15
|
+
def initialize(document=nil, n=nil)
|
16
|
+
self.document = document
|
17
|
+
|
18
|
+
case n
|
19
|
+
when Nokogiri::XML::Element
|
20
|
+
apply_xml(n)
|
21
|
+
when Hash
|
22
|
+
n.each do |k,v|
|
23
|
+
setter = "#{k}="
|
24
|
+
send(setter,v) if respond_to?(setter)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def apply_xml(n)
|
30
|
+
self.id ||= n["id"] # This should not change once set!
|
31
|
+
conditional_set(:added, n.at_xpath("xmlns:added")) { |e| Time.parse(e) }
|
32
|
+
conditional_set(:modified, n.at_xpath("xmlns:modified")) { |e| Time.parse(e) }
|
33
|
+
end
|
34
|
+
alias_method :<<, :apply_xml
|
35
|
+
|
36
|
+
#---------------------------------------
|
37
|
+
# Inspection method
|
38
|
+
|
39
|
+
def inspect
|
40
|
+
msgs = inspect_properties.select{ |sym| self.respond_to?(sym) && !self.send(sym).nil? }
|
41
|
+
"#<#{self.class} " + msgs.map{ |e| %|#{e}=#{self.send(e).inspect}| }.join(" ") + ">"
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_serial
|
45
|
+
inspect_properties.each_with_object({}){ |s,hsh| hsh[s] = self.send(s) }
|
46
|
+
end
|
47
|
+
|
48
|
+
#---------------------------------------
|
49
|
+
# Document set/get methods
|
50
|
+
def document= d
|
51
|
+
@document.remove_element(self) if @document
|
52
|
+
@document = d
|
53
|
+
@document.add_element(self) if @document
|
54
|
+
end
|
55
|
+
|
56
|
+
#-------------------------------------------------------------------------------
|
57
|
+
# Private inspect methods
|
58
|
+
|
59
|
+
private
|
60
|
+
def inspect_properties
|
61
|
+
%w(id added modified)
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# The project represents an OmniFocus project object.
|
2
|
+
class Rubyfocus::Project < Rubyfocus::Task
|
3
|
+
# It's parseable
|
4
|
+
include Rubyfocus::Parser
|
5
|
+
|
6
|
+
#-------------------------------------------------------------------------------
|
7
|
+
# Parsing stuff
|
8
|
+
|
9
|
+
# Projects are <task>s with an interior <project> node
|
10
|
+
def self.matches_node?(node)
|
11
|
+
return (node.name == "task" && (node/"project").size > 0)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Singleton: contains one-off tasks
|
15
|
+
attr_accessor :singleton
|
16
|
+
|
17
|
+
# How often to we review this project?
|
18
|
+
attr_accessor :review_interval
|
19
|
+
|
20
|
+
# When did we last review the project?
|
21
|
+
attr_accessor :last_review
|
22
|
+
|
23
|
+
# What's the status of the project? Valid options: :active, :inactive, :done. Default
|
24
|
+
# is :active
|
25
|
+
attr_accessor :status
|
26
|
+
|
27
|
+
# Initialize the project with a document and optional node to base it off of.
|
28
|
+
# Also sets default values
|
29
|
+
def initialize(document=nil, n=nil)
|
30
|
+
@singleton = false
|
31
|
+
@order = :sequential
|
32
|
+
@status = :active
|
33
|
+
super(document, n)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Apply XML node to project
|
37
|
+
def apply_xml(n)
|
38
|
+
super(n)
|
39
|
+
|
40
|
+
#First, set project
|
41
|
+
p = n.at_xpath("xmlns:project")
|
42
|
+
conditional_set(:singleton, p.at_xpath("xmlns:singleton")) { |e| e.inner_html == "true" }
|
43
|
+
conditional_set(:review_interval, p.at_xpath("xmlns:review-interval") ) { |e| Rubyfocus::ReviewPeriod.from_string(e.inner_html) }
|
44
|
+
conditional_set(:last_review, p.at_xpath("xmlns:last-review")) { |e| Time.parse e.inner_html }
|
45
|
+
conditional_set(:status, p.at_xpath("xmlns:status")) { |e| e.inner_html.to_sym }
|
46
|
+
end
|
47
|
+
|
48
|
+
alias_method :singleton?, :singleton
|
49
|
+
|
50
|
+
private
|
51
|
+
def inspect_properties
|
52
|
+
super + %w(singleton review_interval last_review)
|
53
|
+
end
|
54
|
+
|
55
|
+
public
|
56
|
+
|
57
|
+
#-------------------------------------------------------------------------------
|
58
|
+
# Convenience methods
|
59
|
+
|
60
|
+
# Status methods
|
61
|
+
def active?; status == :active; end
|
62
|
+
def on_hold?; status == :inactive; end
|
63
|
+
def completed?; status == :done; end
|
64
|
+
def dropped?; status == :dropped; end
|
65
|
+
|
66
|
+
#---------------------------------------
|
67
|
+
# Convert to a task
|
68
|
+
def to_task
|
69
|
+
t = Rubyfocus::Task.new(self.document)
|
70
|
+
instance_variables.each do |ivar|
|
71
|
+
setter = ivar.to_s.gsub(/^@/,"") + "="
|
72
|
+
t.send(setter, self.instance_variable_get(ivar)) if t.respond_to?(setter)
|
73
|
+
end
|
74
|
+
t
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class Rubyfocus::RankedItem < Rubyfocus::NamedItem
|
2
|
+
attr_accessor :rank
|
3
|
+
|
4
|
+
def apply_xml(n)
|
5
|
+
super(n)
|
6
|
+
conditional_set(:rank, n.at_xpath("xmlns:rank")){ |e| e.inner_html.to_i }
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
def inspect_properties
|
11
|
+
super + %w(rank)
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class Rubyfocus::Setting < Rubyfocus::Item
|
2
|
+
include Rubyfocus::Parser
|
3
|
+
def self.matches_node?(node)
|
4
|
+
return (node.name == "xmlns:plist")
|
5
|
+
end
|
6
|
+
|
7
|
+
attr_accessor :value
|
8
|
+
|
9
|
+
def apply_xml(n)
|
10
|
+
super(n)
|
11
|
+
conditional_set(:value, n.at_xpath("xmlns:plist").children.first){ |e| Rubyfocus::XMLTranslator.parse(e) }
|
12
|
+
end
|
13
|
+
|
14
|
+
def inspect
|
15
|
+
form_inspector(:id, :name, :value, :added, :modified)
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
class Rubyfocus::Task < Rubyfocus::RankedItem
|
2
|
+
include Rubyfocus::Parser
|
3
|
+
def self.matches_node?(node)
|
4
|
+
return (node.name == "task")
|
5
|
+
end
|
6
|
+
|
7
|
+
# Inherits from RankedItem:
|
8
|
+
# * rank
|
9
|
+
# Inherits from NamedItem:
|
10
|
+
# * name
|
11
|
+
# Inherits from Item:
|
12
|
+
# * id
|
13
|
+
# * added
|
14
|
+
# * modified
|
15
|
+
# * document
|
16
|
+
|
17
|
+
attr_accessor :note, :flagged, :order, :start, :due, :completed
|
18
|
+
idref :container, :context
|
19
|
+
|
20
|
+
def initialize(document, n=nil)
|
21
|
+
@order = :sequential
|
22
|
+
@flagged = false
|
23
|
+
super(document,n)
|
24
|
+
end
|
25
|
+
|
26
|
+
def apply_xml(n)
|
27
|
+
super(n)
|
28
|
+
conditional_set(:container_id, n.at_xpath("xmlns:task") || n.at_xpath("xmlns:project/xmlns:folder")){ |e| e["idref"] }
|
29
|
+
|
30
|
+
conditional_set(:context_id, n.at_xpath("xmlns:context")) { |e| e["idref"] }
|
31
|
+
conditional_set(:note, n.at_xpath("xmlns:note")) { |e| e.inner_html.strip }
|
32
|
+
conditional_set(:order, n.at_xpath("xmlns:order")) { |e| e.inner_html.to_sym }
|
33
|
+
conditional_set(:flagged, n.at_xpath("xmlns:flagged")) { |e| e.inner_html == "true" }
|
34
|
+
conditional_set(:start, n.at_xpath("xmlns:start")) { |e| Time.parse(e.inner_html) }
|
35
|
+
conditional_set(:due, n.at_xpath("xmlns:due")) { |e| Time.parse(e.inner_html) }
|
36
|
+
conditional_set(:completed, n.at_xpath("xmlns:completed")){ |e| Time.parse(e.inner_html) }
|
37
|
+
end
|
38
|
+
|
39
|
+
# Convenience methods
|
40
|
+
def completed?; !self.completed.nil?; end
|
41
|
+
alias_method :flagged?, :flagged
|
42
|
+
|
43
|
+
# Collect all child tasks. If child tasks have their own subtasks, will instead fetch those.
|
44
|
+
def tasks
|
45
|
+
@tasks ||= if self.id.nil?
|
46
|
+
[]
|
47
|
+
else
|
48
|
+
t_arr = document.tasks.select(container_id: self.id)
|
49
|
+
i = 0
|
50
|
+
while i < t_arr.size
|
51
|
+
task = t_arr[i]
|
52
|
+
if task.has_subtasks?
|
53
|
+
t_arr += t_arr.delete_at(i).tasks
|
54
|
+
else
|
55
|
+
i += 1
|
56
|
+
end
|
57
|
+
end
|
58
|
+
t_arr
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Collect only immediate tasks: I don't care about this subtasks malarky
|
63
|
+
def immediate_tasks
|
64
|
+
document.tasks.select(container_id: self.id)
|
65
|
+
end
|
66
|
+
|
67
|
+
# The first non-completed task, determined by order
|
68
|
+
def next_available_task
|
69
|
+
nat_candidate = immediate_tasks.select{ |t| !t.completed? }.sort_by(&:rank).first
|
70
|
+
if nat_candidate.has_subtasks?
|
71
|
+
nat_candidate.next_available_task
|
72
|
+
else
|
73
|
+
nat_candidate
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# A list of all tasks that you can take action on. Actionable tasks
|
78
|
+
# are tasks that are:
|
79
|
+
# * not completed
|
80
|
+
# * not blocked (as part of a sequential project or task group)
|
81
|
+
# * not due to start in the future
|
82
|
+
def actionable_tasks
|
83
|
+
@actionable_tasks ||= next_tasks.select{ |t| !t.deferred? }
|
84
|
+
end
|
85
|
+
|
86
|
+
# A list of all tasks that are not blocked.
|
87
|
+
def next_tasks
|
88
|
+
@next_tasks ||= incomplete_tasks.select{ |t| !t.blocked? }
|
89
|
+
end
|
90
|
+
|
91
|
+
# A list of all tasks that aren't complete
|
92
|
+
def incomplete_tasks
|
93
|
+
@incomplete_tasks ||= tasks.select{ |t| !t.completed? }
|
94
|
+
end
|
95
|
+
|
96
|
+
# Are there any tasks on this project which aren't completed?
|
97
|
+
def tasks_remain?
|
98
|
+
tasks.any?{ |t| t.completed.nil? }
|
99
|
+
end
|
100
|
+
|
101
|
+
# Does this task have any subtasks?
|
102
|
+
def has_subtasks?
|
103
|
+
tasks.size > 0
|
104
|
+
end
|
105
|
+
|
106
|
+
# Can we only start this task at some point in the future?
|
107
|
+
def deferred?
|
108
|
+
start && start > Time.now
|
109
|
+
end
|
110
|
+
|
111
|
+
# Can we attack this task, or does its container stop that happening?
|
112
|
+
def blocked?
|
113
|
+
container && (container.order == :sequential) && (container.next_available_task != self)
|
114
|
+
end
|
115
|
+
|
116
|
+
#---------------------------------------
|
117
|
+
# Conversion methods
|
118
|
+
|
119
|
+
# Convert the task to a project
|
120
|
+
def to_project
|
121
|
+
p = Rubyfocus::Project.new(self.document)
|
122
|
+
instance_variables.each do |ivar|
|
123
|
+
setter = ivar.to_s.gsub(/^@/,"") + "="
|
124
|
+
p.send(setter, self.instance_variable_get(ivar)) if p.respond_to?(setter)
|
125
|
+
end
|
126
|
+
p
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
def inspect_properties
|
131
|
+
super + %w(note container_id context_id order flagged start due completed)
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# A location file. Really a collection of properties and initializer.
|
2
|
+
class Rubyfocus::Location
|
3
|
+
attr_accessor :name, :latitude, :longitude, :radius, :notification_flags
|
4
|
+
|
5
|
+
def initialize(n)
|
6
|
+
@notification_flags = 0
|
7
|
+
self.name = n["name"]
|
8
|
+
self.latitude = n["latitude"].to_f
|
9
|
+
self.longitude = n["longitude"].to_f
|
10
|
+
self.radius = n["radius"].to_i
|
11
|
+
self.notification_flags = n["notificationFlags"].to_i
|
12
|
+
end
|
13
|
+
|
14
|
+
def inspect
|
15
|
+
%|#<Rubyfocus::Location latitude="#{self.latitude}" longitude="#{self.longitude}">|
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# The patch class represents a text-file patch, storing update, delete, and creation operations.
|
2
|
+
# It should also be able to apply itself to an existing document.
|
3
|
+
class Rubyfocus::Patch
|
4
|
+
# The fetcher this patch belongs to. We mainly use this to work out how to fetch content for the patch proper
|
5
|
+
attr_accessor :fetcher
|
6
|
+
|
7
|
+
# Operations to be performed on a document
|
8
|
+
attr_accessor :update, :delete, :create
|
9
|
+
|
10
|
+
# The file the patch loads from
|
11
|
+
attr_accessor :file
|
12
|
+
|
13
|
+
# These record the transformation in terms of patch ID values.
|
14
|
+
attr_accessor :from_ids, :to_id
|
15
|
+
|
16
|
+
# The time the file was submitted
|
17
|
+
attr_accessor :time
|
18
|
+
|
19
|
+
# By default we initialize patches from a file. To initialize from a string, use the .from_string method.
|
20
|
+
# This class will lazily load data from the file proper
|
21
|
+
def initialize(fetcher=nil, file=nil)
|
22
|
+
@fetcher = fetcher
|
23
|
+
@file = file
|
24
|
+
@update = []
|
25
|
+
@create = []
|
26
|
+
@delete = []
|
27
|
+
|
28
|
+
if file
|
29
|
+
if File.basename(file) =~ /^(\d+)=(.*)\./
|
30
|
+
time_string = $1
|
31
|
+
self.time = if (time_string == "00000000000000")
|
32
|
+
Time.at(0)
|
33
|
+
else
|
34
|
+
Time.parse(time_string)
|
35
|
+
end
|
36
|
+
|
37
|
+
ids = $2.split("+")
|
38
|
+
self.to_id = ids.pop
|
39
|
+
self.from_ids = ids
|
40
|
+
else
|
41
|
+
raise ArgumentError, "Constructed patch from a malformed patch file: #{file}."
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Load from a string.
|
47
|
+
def self.from_string(fetcher, str)
|
48
|
+
n = new(fetcher)
|
49
|
+
n.load_data(str)
|
50
|
+
n
|
51
|
+
end
|
52
|
+
|
53
|
+
# Loads data from the file. Optional argument +str+ if you want to supply your own data,
|
54
|
+
# otherwise will load file data
|
55
|
+
def load_data(str=nil)
|
56
|
+
return if @data_loaded
|
57
|
+
@data_loaded = true
|
58
|
+
|
59
|
+
str ||= fetcher.patch(self.file)
|
60
|
+
doc = Nokogiri::XML(str)
|
61
|
+
doc.root.children.select{ |n| !n.text?}.each do |child|
|
62
|
+
case child["op"]
|
63
|
+
when "update"
|
64
|
+
@update << child
|
65
|
+
when "delete"
|
66
|
+
@delete << child
|
67
|
+
when "reference" # Ignore!
|
68
|
+
when nil
|
69
|
+
@create << child
|
70
|
+
else
|
71
|
+
raise RuntimeError, "Rubyfocus::Patch encountered unknown operation type #{child["op"]}."
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Update, delete and create methods
|
77
|
+
def update; load_data; @update; end
|
78
|
+
def delete; load_data; @delete; end
|
79
|
+
def create; load_data; @create; end
|
80
|
+
|
81
|
+
# Can we apply this patch to a given document?
|
82
|
+
def can_patch?(document)
|
83
|
+
self.from_ids.include?(document.patch_id)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Apply this patch to a document. Check to make sure ids match
|
87
|
+
def apply_to(document)
|
88
|
+
if can_patch?(document)
|
89
|
+
apply_to!(document)
|
90
|
+
else
|
91
|
+
raise RuntimeError, "Patch ID mismatch (patch from_ids: [#{self.from_ids.join(", ")}], document.patch_id: #{document.patch_id}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Apply this patch to a document. Who needs error checking amirite?
|
96
|
+
def apply_to!(document)
|
97
|
+
# Updates modify elements
|
98
|
+
self.update.each do |node|
|
99
|
+
elem = document[node["id"]]
|
100
|
+
|
101
|
+
# Tasks can become projects and v.v.: check this.
|
102
|
+
if [Rubyfocus::Task, Rubyfocus::Project].include?(elem.class)
|
103
|
+
should_be_project = (node.at_xpath("xmlns:project") != nil)
|
104
|
+
if (elem.class == Rubyfocus::Project) && !should_be_project
|
105
|
+
elem.document = nil # Remove this from current document
|
106
|
+
elem = elem.to_task # Convert to task
|
107
|
+
elem.document = document # Insert again!
|
108
|
+
elsif (elem.class == Rubyfocus::Task) && should_be_project
|
109
|
+
elem.document = nil # Remove this from current document
|
110
|
+
elem = elem.to_project # Convert to task
|
111
|
+
elem.document = document # Insert again!
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
elem.apply_xml(node) if elem
|
116
|
+
end
|
117
|
+
|
118
|
+
# Deletes remove elements
|
119
|
+
self.delete.each do |node|
|
120
|
+
document.remove_element(node["id"])
|
121
|
+
end
|
122
|
+
|
123
|
+
# Creates make new elements
|
124
|
+
self.create.each do |node|
|
125
|
+
if Rubyfocus::Parser.parse(document, node).nil?
|
126
|
+
raise RuntimeError, "Encountered unparsable XML during patch reading: #{node}."
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Modify current patch_id to show new value
|
131
|
+
document.patch_id = self.to_id
|
132
|
+
end
|
133
|
+
|
134
|
+
# String representation
|
135
|
+
def to_s
|
136
|
+
if from_ids.size == 1
|
137
|
+
"(#{from_ids.first} -> #{to_id})"
|
138
|
+
else
|
139
|
+
"([#{from_ids.join(", ")}] -> #{to_id})"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# The ReviewPeriod represents a review period used with Projects in OmniFocus
|
2
|
+
# The ReviewPeriod is made up of three sections:
|
3
|
+
# * The precursor symbol (~ or @, use unknown)
|
4
|
+
# * A numerical "size"
|
5
|
+
# * A unit ([d]ays, [w]eeks, [m]onths or [y]ears)
|
6
|
+
|
7
|
+
class Rubyfocus::ReviewPeriod
|
8
|
+
attr_accessor :size, :unit
|
9
|
+
|
10
|
+
ALLOWED_UNITS = %i(days weeks months years)
|
11
|
+
|
12
|
+
def self.from_string(str)
|
13
|
+
if str =~ /^[@~]?(\d+)([a-z])$/
|
14
|
+
size = $1.to_i
|
15
|
+
unit = {"d" => :days, "w" => :weeks, "m" => :months, "y" => :years}[$2]
|
16
|
+
new(size: size, unit: unit)
|
17
|
+
else
|
18
|
+
raise ArgumentError, "Unrecognised review period format: \"#{str}\"."
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(size:0, unit: :months)
|
23
|
+
self.size = size
|
24
|
+
self.unit = unit
|
25
|
+
end
|
26
|
+
|
27
|
+
def unit= value
|
28
|
+
raise ArgumentError, "Tried to set ReviewPeriod.unit to invalid value \"#{value}\"." unless ALLOWED_UNITS.include?(value)
|
29
|
+
@unit = value
|
30
|
+
@short_unit = nil
|
31
|
+
end
|
32
|
+
|
33
|
+
def short_unit
|
34
|
+
@short_unit ||= @unit.to_s[0]
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
"#{size}#{short_unit}"
|
39
|
+
end
|
40
|
+
|
41
|
+
alias_method :inspect, :to_s
|
42
|
+
alias_method :to_serial, :to_s
|
43
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Rubyfocus::XMLTranslator
|
2
|
+
class << self
|
3
|
+
VALID_NODE_NAMES = %w(string true false integer array)
|
4
|
+
|
5
|
+
# Actual parsing method
|
6
|
+
def parse(node)
|
7
|
+
method_name = node.name
|
8
|
+
if VALID_NODE_NAMES.include?(method_name)
|
9
|
+
self.send(method_name, node)
|
10
|
+
else
|
11
|
+
raise RuntimeError, "Does not recognise node type: #{method_name}."
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Individual parsing methods
|
16
|
+
def string(node)
|
17
|
+
node.inner_html
|
18
|
+
end
|
19
|
+
|
20
|
+
def true(node)
|
21
|
+
true
|
22
|
+
end
|
23
|
+
|
24
|
+
def false(node)
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def integer(node)
|
29
|
+
node.inner_html.to_i
|
30
|
+
end
|
31
|
+
|
32
|
+
def array(node)
|
33
|
+
node.children.map{ |child| parse(child) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/rubyfocus.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require "time"
|
2
|
+
require "nokogiri"
|
3
|
+
require "zip"
|
4
|
+
require "yaml"
|
5
|
+
require "httparty"
|
6
|
+
|
7
|
+
module Rubyfocus; end
|
8
|
+
# Require library files
|
9
|
+
Dir[File.join(__dir__, "rubyfocus/includes/*")].each{ |f| require f }
|
10
|
+
%w(fetcher local_fetcher oss_fetcher).each{ |f| require File.join(__dir__, "rubyfocus/fetchers", f) }
|
11
|
+
Dir[File.join(__dir__, "rubyfocus/*.rb")].each{ |f| require f }
|
12
|
+
|
13
|
+
# Need to load items in a specific order
|
14
|
+
%w(item named_item ranked_item task project context folder setting).each do |f|
|
15
|
+
require File.join(__dir__, "rubyfocus/items", f)
|
16
|
+
end
|