rubyfocus 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ba866c7a553641c90dd8804d86c198a7cd720c3e
4
+ data.tar.gz: 58c47389f885a131eb300e18dd57aa5aed6eb197
5
+ SHA512:
6
+ metadata.gz: ba9aac7051986e1aa922a7077d388aae5202866a2342c3b64554debddf133da49cf3fc3042caec5e506a9e886aed41f66c90e53c4c9a5c5bd15d13cadaa8f403
7
+ data.tar.gz: 870cc1ccc452e2deecf1418a49500a3f966dbc88521161c96647f7ff7f19002720e504d4ad90ae8e2ce365864e385d63ae4cad9fc7b39ba45a66ce26c2fb2c36
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ .rarity
2
+ pkg
3
+ stage_ofocus.rb
4
+ spec/files/ofocus_staging
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ -c
data/Manifest ADDED
@@ -0,0 +1,36 @@
1
+ # INCLUDE bin/**/*
2
+ # INCLUDE lib/**/*
3
+ # INCLUDE *
4
+ # EXCLUDE **/.DS_Store
5
+ # EXCLUDE .rarity
6
+ # EXCLUDE stage_ofocus.rb
7
+ # EXCLUDE spec/files/ofocus_staging
8
+
9
+ .gitignore
10
+ .rspec
11
+ lib/rubyfocus.rb
12
+ lib/rubyfocus/document.rb
13
+ lib/rubyfocus/errors.rb
14
+ lib/rubyfocus/fetchers/fetcher.rb
15
+ lib/rubyfocus/fetchers/local_fetcher.rb
16
+ lib/rubyfocus/fetchers/oss_fetcher.rb
17
+ lib/rubyfocus/includes/conditional_exec.rb
18
+ lib/rubyfocus/includes/idref.rb
19
+ lib/rubyfocus/includes/parser.rb
20
+ lib/rubyfocus/includes/searchable.rb
21
+ lib/rubyfocus/items/context.rb
22
+ lib/rubyfocus/items/folder.rb
23
+ lib/rubyfocus/items/item.rb
24
+ lib/rubyfocus/items/named_item.rb
25
+ lib/rubyfocus/items/project.rb
26
+ lib/rubyfocus/items/ranked_item.rb
27
+ lib/rubyfocus/items/setting.rb
28
+ lib/rubyfocus/items/task.rb
29
+ lib/rubyfocus/location.rb
30
+ lib/rubyfocus/patch.rb
31
+ lib/rubyfocus/review_period.rb
32
+ lib/rubyfocus/xml_translator.rb
33
+ Manifest
34
+ README.md
35
+ rubyfocus.gemspec
36
+ version.txt
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ Rubyfocus is a one-way (read-only) ruby bridge to OmniFocus. Analyse, store, inspect, or play with your projects and tasks in OmniFocus from the comfort and flexibility of ruby!
2
+
3
+ # Installation
4
+
5
+ Rubyfocus is a ruby gem. It's not currently hosted on rubygems, though, so you'll have to download and install locally.
6
+
7
+ ## Via git
8
+
9
+ First download rubyfocus to your computer:
10
+
11
+ ```
12
+ git clone https://github.com/jyruzicka/rubyfocus.git
13
+ ```
14
+
15
+ Now build and install it!
16
+
17
+ ```
18
+ gem build rubyfocus.gemspec
19
+ gem install rubyfocus-0.1.0.gem
20
+ ```
21
+
22
+ # Usage
23
+
24
+ ## Getting set up
25
+
26
+ To create a new database from your local OmniFocus install:
27
+
28
+ ```ruby
29
+ require "rubyfocus"
30
+
31
+ f = Rubyfocus::LocalFetcher.new # This class lets you access a local OmniFocus install
32
+ d = Rubyfocus::Document.new(f) # This is how we create a document linked up to a local fetcher
33
+
34
+ d.update # This is how we get the document to update using its built-in fetcher
35
+
36
+ d.save("ofocus.yml") # Save the whole database to yaml!
37
+ ```
38
+
39
+ To open it up again, it's even easier:
40
+
41
+ ```ruby
42
+ require "rubyfocus"
43
+
44
+ d = Rubyfocus::Document.load_from_file("ofocus.yml") # Your document will remember everything
45
+ d.update # Updates it against the local cache, in case you made changes
46
+ ```
47
+
48
+ ## Grabbing data
49
+
50
+ How do you access all that lovely data? Easy!
51
+
52
+ ```ruby
53
+ d.projects
54
+ d.tasks
55
+ d.contexts
56
+ d.folders
57
+ ```
58
+
59
+ And if you want to select a certain project's tasks:
60
+
61
+ ```ruby
62
+ d.projects.first.tasks
63
+ ```
64
+
65
+ What if you want to select only certain projects? Sure, you can use standard `Array#find` or `Array#select` methods, but you can also make use of a hash of values:
66
+
67
+ ```
68
+ d.projects.select(name: "Sample project")
69
+ ```
70
+
71
+ Once you have your objects, you can query them for more information:
72
+
73
+ ```
74
+ t = d.tasks.first
75
+ t.name # => "Sample task"
76
+ t.project.name # => "Sample project"
77
+ t.deferred? # => true/false
78
+ t.start # => Time or nil
79
+ ```
80
+
81
+ ## SyncServer-ing
82
+
83
+ To access an instance of the [Omni Sync Server](http://sync.omnigroup.com), use an `OSSFetcher` object:
84
+
85
+ ```ruby
86
+ f = Rubyfocus::OSSFetcher.new(my_username, my_password)
87
+ d = Rubyfocus::Document.new(f)
88
+ d.update
89
+ ```
90
+
91
+ If you use the Omni Sync Server, you'll definitely want to save your data locally and just update what's necessary - it takes a while to download and apply all those files.
92
+
93
+ # Behind the scenes
94
+
95
+ OmniFocus stores its data as a "base" XML file plus a series of "patches". These are all stored as zip files inside an ".ofocus" package. This means that you can have several different devices, all storing the OmniFocus task database at different states, and each one can easily update its database by comparing the various patches against its own database and downloading/applying only what's needed.
96
+
97
+ Rubyfocus makes use of this by fetching and reading OmniFocus' local store on your machine. As long as your local OmniFocus is up to date, `rubyfocus` will be able to fetch the database in its latest state.
98
+
99
+ # Further work
100
+
101
+ `rubyfocus` is a work in progress. In the near future I hope to release a more comprehensive document detailing exactly which details of OmniFocus' projects and tasks are available to the user, and what you can do with them.
102
+
103
+ Other goals include:
104
+
105
+ * Updating via the [Omni Sync Server](https://manage.sync.omnigroup.com/) rather than relying on local files.
106
+ * A couple of example projects using rubyfocus
107
+
108
+ # History
109
+
110
+ ## 0.3.0 // 2015-10-17
111
+
112
+ * [New] Now supports remote syncing with the Omni Sync Server!
113
+
114
+ ## 0.2.0 // 2015-10-11
115
+
116
+ * [Bugfix] Will now turn tasks into projects and projects into tasks if the user has done this in OmniFocus.
117
+ * [Bugfix] Rubyfocus::Patch now does patch application, rather than delegating to the Fetcher.
118
+
119
+ ## 0.1.0 // 2015-10-10
120
+
121
+ * Hello, world!
@@ -0,0 +1,146 @@
1
+ # The Document is how rubyfocus stores an OmniFocus document, both locally and otherwise.
2
+ # A Document contains a number of arrays of contexts, settings, folders, projects, and tasks,
3
+ # and is also able to keep track of what patch it's up to, for updating.
4
+ #
5
+ # You can initialize a document through +Document.new(doc)+, where +doc+ is either an XML string, or
6
+ # a Nokogiri XML document (or +nil+). Alternatively, you can initialize through +Document.from_file(file)+,
7
+ # which reads the file and parses it as XML
8
+ #
9
+ # You add XML to the document by running +Documet::apply_xml(doc)+, which takes all children of the root
10
+ # XML node, tries to turn each child into a relevant object, and adds it to the document. This is done using
11
+ # the private +ivar_for+ method, as well as +add_element(e)+, which you can use to add individual objects.
12
+ class Rubyfocus::Document
13
+ include Rubyfocus::Searchable
14
+ # A number of arrays into which elements may fit
15
+ attr_reader :contexts, :settings, :folders, :projects, :tasks
16
+
17
+ # This is the identifier of the current patch level. This also determines
18
+ # which patches can be applied to the current document.
19
+ attr_accessor :patch_id
20
+
21
+ # This is the fetcher object, used to fetch new data
22
+ attr_accessor :fetcher
23
+
24
+ # Initalise with one of:
25
+ # * a Nokogiri document
26
+ # * a string
27
+ # * a fetcher subclass
28
+ def initialize(doc=nil)
29
+ %w(contexts settings projects folders tasks).each{ |s| instance_variable_set("@#{s}", Rubyfocus::SearchableArray.new) }
30
+
31
+ if doc
32
+ if doc.is_a?(String)
33
+ apply_xml(Nokogiri::XML(doc))
34
+ elsif doc.is_a?(Nokogiri::XML)
35
+ apply_xml(doc)
36
+ elsif doc.kind_of?(Rubyfocus::Fetcher)
37
+ self.fetcher = doc
38
+ base = Nokogiri::XML(doc.base)
39
+ self.apply_xml(base)
40
+ self.patch_id = doc.base_id
41
+ end
42
+ end
43
+ end
44
+
45
+ #...or from file! If you provide it with an XML file, it'll load up without a fetcher.
46
+ def self.from_xml(file)
47
+ new(File.read(file))
48
+ end
49
+
50
+ # Initialize from the local repo
51
+ def self.from_local
52
+ new(Rubyfocus::LocalFetcher.new)
53
+ end
54
+
55
+ # Initialize with a URL, for remote fetching.
56
+ # Not implemented yet
57
+ def self.from_url(url)
58
+ raise RuntimeError, "Rubyfocus::Document.from_url not yet implemented."
59
+ # new(Rubyfocus::RemoteFetcher.new(url))
60
+ end
61
+
62
+ # Load from a a hash
63
+ def self.load_from_file(file_location)
64
+ d = YAML::load_file(file_location)
65
+ d.fetcher.reset
66
+ d
67
+ end
68
+
69
+ #---------------------------------------
70
+ # Use the linked fetcher to update the document
71
+ def update
72
+ if fetcher
73
+ fetcher.update_full(self)
74
+ else
75
+ raise RuntimeError, "Tried to update a document with no fetcher."
76
+ end
77
+ end
78
+
79
+ #-------------------------------------------------------------------------------
80
+ # Apply XML!
81
+ def apply_xml(doc)
82
+ doc.root.children.select{ |e| !e.text? }.each do |node|
83
+ elem = Rubyfocus::Parser.parse(self, node)
84
+ end
85
+ end
86
+
87
+ # Given an object, work out the correct instance variable for it to go into
88
+ def ivar_for(obj)
89
+ {
90
+ Rubyfocus::Project => @projects,
91
+ Rubyfocus::Context => @contexts,
92
+ Rubyfocus::Task => @tasks,
93
+ Rubyfocus::Folder => @folders,
94
+ Rubyfocus::Setting => @settings
95
+ }[obj.class]
96
+ end
97
+ private :ivar_for
98
+
99
+ # Add an element. Element should be a Project, Task, Context, Folder, or Setting
100
+ # We assume whoever does this will set document appropriately on the element
101
+ def add_element(e)
102
+ dest = ivar_for(e)
103
+ if dest
104
+ dest << e
105
+ else
106
+ raise ArgumentError, "You passed a #{e.class} to Document#add_element - I don't know what to do with this."
107
+ end
108
+ end
109
+
110
+ # Remove an element from the document.
111
+ # We assume whoever does this is smart enough to also set the element's #document value
112
+ # to nil
113
+ def remove_element(e)
114
+ e = self[e] if e.is_a?(String)
115
+ return if e.nil?
116
+
117
+ dest = ivar_for(e)
118
+ if dest
119
+ dest.delete(e)
120
+ else
121
+ raise ArgumentError, "You passed a #{e.class} to Document#remove_element - I don't know what to do with this."
122
+ end
123
+ end
124
+
125
+ #-------------------------------------------------------------------------------
126
+ # Searchable stuff
127
+ def elements
128
+ @tasks + @projects + @contexts + @folders + @settings
129
+ end
130
+
131
+ # For Searchable include
132
+ alias_method :array, :elements
133
+
134
+ #-------------------------------------------------------------------------------
135
+ # Find elements from id
136
+ def [] search_id
137
+ self.elements.find{ |elem| elem.id == search_id }
138
+ end
139
+
140
+ #---------------------------------------
141
+ # YAML export
142
+
143
+ def save(file)
144
+ File.open(file, "w"){ |io| io.puts YAML::dump(self) }
145
+ end
146
+ end
@@ -0,0 +1,2 @@
1
+ class Rubyfocus::RubyfocusError < Exception; end
2
+ class Rubyfocus::OSSFetcherError < Rubyfocus::RubyfocusError; end
@@ -0,0 +1,83 @@
1
+ # The Fetcher class is a class that fetches data from a place - generally either a file location or a web address.
2
+ # The Fetcher is initialized with (by default) no options, although subclasses of Fetcher may change this.
3
+ #
4
+ # Each Fetcher has two important methods:
5
+ #
6
+ # * Fetcher#base returns the text of the main xml file, upon which all patch are applied.
7
+ # * Fetcher#patch returns the text of each update file as an array.
8
+ #
9
+ # You may also use the Fetcher#each_patch to iterate through all updates.
10
+ #
11
+ # To patch a document, simply call +Fetcher#update_full()+, passing it the document to be patched.
12
+ # +Document#update+ will automatically send the fetcher the document.
13
+
14
+ class Rubyfocus::Fetcher
15
+
16
+ # This method is called when loading a fetcher from YAML
17
+ def init_with(coder)
18
+ raise RuntimeError, "Method Fetcher#init_with called for abstract class Fetcher"
19
+ end
20
+
21
+ # Returns the content of the base file
22
+ def base
23
+ raise RuntimeError, "Method Fetcher#base called for abstract class Fetcher."
24
+ end
25
+
26
+ # Returns the id of the base
27
+ def base_id
28
+ raise RuntimeError, "Method Fetcher#base_id called for abstract class Fetcher."
29
+ end
30
+
31
+ # Returns an array of patch paths - either files or urls
32
+ def patches
33
+ raise RuntimeError, "Method Fetcher#patches called for abstract class Fetcher."
34
+ end
35
+
36
+ # Returns the contents of a given patch
37
+ def patch(filename)
38
+ raise RuntimeError, "Method Fetcher#patch called for abstract class Fetcher."
39
+ end
40
+
41
+ #---------------------------------------
42
+ # Patching methods
43
+
44
+ # Update the document as far as we can
45
+ def update_full(document)
46
+ update_once(document) while can_patch?(document)
47
+ end
48
+
49
+ # Update the document one step. Raises an error if the document cannot be updated.
50
+ def update_once(document)
51
+ np = self.next_patch(document)
52
+ if np
53
+ np.apply_to(document)
54
+ else
55
+ raise RuntimeError, "Patcher#update_once called, but I can't find a patch to apply!"
56
+ end
57
+ end
58
+
59
+ # Can we patch this document? You should call this before update_onceing
60
+ def can_patch?(document)
61
+ !next_patch(document).nil?
62
+ end
63
+
64
+ # Collect the next patch to the document. If more than one patch can be applied,
65
+ # apply the latest one.
66
+ def next_patch(document)
67
+ all_possible_patches = self.patches.select{ |patch| patch.can_patch?(document) }
68
+ return all_possible_patches.sort_by(&:time).last
69
+ end
70
+
71
+
72
+ #---------------------------------------
73
+ # Serialisation info
74
+ def encode_with(coder)
75
+ raise RuntimeError, "Fetcher#encode_with called on abstract class."
76
+ end
77
+
78
+ # Remove all cached information
79
+ def reset
80
+ @patches = nil
81
+ @base = nil
82
+ end
83
+ end
@@ -0,0 +1,66 @@
1
+ class Rubyfocus::LocalFetcher < Rubyfocus::Fetcher
2
+ # This is where the files are usually stored
3
+ LOCATION = File.join(ENV["HOME"], "/Library/Containers/com.omnigroup.OmniFocus2/Data/Library/Application Support/OmniFocus/OmniFocus.ofocus")
4
+
5
+ #---------------------------------------
6
+ # Parent method overrides
7
+
8
+ # Init from yaml
9
+ def init_with(coder)
10
+ if coder["location"]
11
+ @location = coder["location"]
12
+ end
13
+ end
14
+
15
+ # Fetches the contents of the base file
16
+ def base
17
+ @base ||= begin
18
+ zip_file = Dir[File.join(self.location,"*.zip")].first
19
+ if zip_file
20
+ Zip::File.open(zip_file){ |z| z.get_entry("contents.xml").get_input_stream.read }
21
+ else
22
+ raise RuntimeError, "Rubyfocs::LocalFetcher looking for zip files at #{self.location}: none found."
23
+ end
24
+ end
25
+ end
26
+
27
+ # Fetches the ID Of the base file
28
+ def base_id
29
+ base_file = File.basename(Dir[File.join(self.location,"*.zip")].first)
30
+ if base_file =~ /^\d+\=.*\+(.*)\.zip$/
31
+ $1
32
+ else
33
+ raise RuntimeError, "Malformed patch file #{base_file}."
34
+ end
35
+ end
36
+
37
+ # Fetches a list of every patch file
38
+ def patches
39
+ @patches ||= Dir[File.join(self.location, "*.zip")][1..-1].map{ |f| Rubyfocus::Patch.new(self, File.basename(f)) }
40
+ end
41
+
42
+ # Fetches the contents of a given patch file
43
+ def patch(file)
44
+ filename = File.join(self.location, file)
45
+ if File.exists?(filename)
46
+ Zip::File.open(filename){ |z| z.get_entry("contents.xml").get_input_stream.read }
47
+ else
48
+ raise ArgumentError, "Trying to fetch patch #{file}, but file does not exist."
49
+ end
50
+ end
51
+
52
+ # Save to disk
53
+ def encode_with(coder)
54
+ coder.map = {"location" => @location}
55
+ end
56
+
57
+ #---------------------------------------
58
+ # Location file setters and getters
59
+ def location
60
+ @location || LOCATION
61
+ end
62
+
63
+ def location= l
64
+ @location = l
65
+ end
66
+ end
@@ -0,0 +1,102 @@
1
+ # This fetcher fetches data from the OmniSyncServer.
2
+ # NOTE: This does *not* register a client with the OmniSyncServer. As such, if you don't
3
+ # sync with the database regularly, you will likely lose track of the head and have to re-sync.
4
+ class Rubyfocus::OSSFetcher < Rubyfocus::Fetcher
5
+ # Used to log in to the Omni Sync Server. Also determines the URL of your file
6
+ attr_accessor :username
7
+ attr_accessor :password
8
+
9
+ # The engine used to fetch data. Defaults to HTTParty
10
+ attr_accessor :fetcher
11
+
12
+ #---------------------------------------
13
+ # Parent method overrides
14
+
15
+ # Initialise with username and password
16
+ def initialize(u,p)
17
+ @username = u
18
+ @password = p
19
+ @fetcher = HTTParty
20
+ end
21
+
22
+ # Init from yaml
23
+ def init_with(coder)
24
+ @username = coder["username"]
25
+ @password = coder["password"]
26
+ @fetcher = HTTParty
27
+ end
28
+
29
+ # Fetches the contents of the base file
30
+ def base
31
+ @base ||= if self.patches.size > 0
32
+ fetch_file(self.patches.first.file)
33
+ else
34
+ raise Rubyfocus::OSSFetcherError, "Looking for zip files at #{url}: none found."
35
+ end
36
+ end
37
+
38
+ # Fetches the ID Of the base file
39
+ def base_id
40
+ if self.patches.size > 0
41
+ base_file = self.patches.first
42
+ if base_file.file =~ /^\d+\=.*\+(.*)\.zip$/
43
+ $1
44
+ else
45
+ raise Rubyfocus::OSSFetcherError, "Malformed patch file #{base_file}."
46
+ end
47
+ else
48
+ raise Rubyfocus::OSSFetcherError, "Looking for zip files at #{url}: none found."
49
+ end
50
+ end
51
+
52
+ # Fetches a list of every patch file
53
+ def patches
54
+ @patches ||= begin
55
+ response = self.fetcher.get(url, digest_auth: auth).body
56
+ # Text is in first table, let's assume
57
+ table = response[/<table>(.*?)<\/table>/m,1]
58
+ if table
59
+ links = table.scan(/<a href="([^"]+)"/).flatten.select{ |f| f.end_with?(".zip") }
60
+ links.map{ |u| Rubyfocus::Patch.new(self,u) }
61
+ else
62
+ []
63
+ end
64
+ end
65
+ end
66
+
67
+ # Fetches the contents of a given patch file
68
+ def patch(file)
69
+ fetch_file(file)
70
+ end
71
+
72
+ # Save to disk
73
+ def encode_with(coder)
74
+ coder.map = {
75
+ "username" => @username,
76
+ "password" => @password
77
+ }
78
+ end
79
+
80
+ #---------------------------------------
81
+ # Private aux methods
82
+ private
83
+ def auth
84
+ {username: @username, password: @password}
85
+ end
86
+
87
+ def url
88
+ "https://sync.omnigroup.com/#{@username}/OmniFocus.ofocus"
89
+ end
90
+
91
+ def fetch_file(f)
92
+ f = File.join(url,f)
93
+ data = self.fetcher.get(f, digest_auth: auth).body
94
+ io = StringIO.new(data)
95
+ Zip::InputStream.open(io) do |io|
96
+ while (entry = io.get_next_entry)
97
+ return io.read if entry.name == "contents.xml"
98
+ end
99
+ raise Rubyfocus::OSSFetcherError, "Malformed OmniFocus zip file #{zipfile}."
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,7 @@
1
+ module Rubyfocus
2
+ module ConditionalExec
3
+ def conditional_set(key, object, &blck)
4
+ send("#{key}=", blck[object]) if object
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ module Rubyfocus
2
+ module IDRef
3
+ module ClassMethods
4
+ def idref *names
5
+ names.each do |name|
6
+ name_id = "#{name}_id".to_sym
7
+ attr_accessor name_id
8
+ define_method(name) do
9
+ return document && document.find(send(name_id))
10
+ end
11
+
12
+ define_method("#{name}=") do |o|
13
+ if o.nil?
14
+ self.send("#{name}_id=", nil)
15
+ elsif o.respond_to?(:id)
16
+ self.send("#{name}_id=", o.id)
17
+ else
18
+ raise ArgumentError, "#{self.class}##{name}= called with argument #{o}, which does not respond to :id."
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.included(mod)
26
+ mod.extend ClassMethods
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ module Rubyfocus
2
+ module Parser
3
+ @subclasses = []
4
+
5
+ def self.parse(document, node)
6
+ matching_classes = @subclasses.select{ |klass| klass.matches_node?(node) }
7
+
8
+ # More than one matches? Take the most specific
9
+ if matching_classes.size > 1
10
+ classes_with_subclasses = matching_classes.select{ |c| matching_classes.any?{ |sc| sc < c } }
11
+ matching_classes = matching_classes - classes_with_subclasses
12
+ end
13
+
14
+ case matching_classes.size
15
+ when 0
16
+ nil
17
+ when 1
18
+ return matching_classes.first.new(document, node)
19
+ else
20
+ raise RuntimeError, "Node #{node.inspect} matches more than one Rubyfocus::Item subclass."
21
+ end
22
+ end
23
+
24
+ def self.included(mod)
25
+ @subclasses << mod
26
+ mod.extend ClassMethods
27
+ end
28
+
29
+ module ClassMethods
30
+ # Filler method
31
+ def matches_node?(node)
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end