things-fetcher 0.1.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/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .rvmrc
2
+ *.gem
3
+ .bundle
4
+ Gemfile.lock
5
+ pkg/*
data/CHANGELOG ADDED
@@ -0,0 +1 @@
1
+ v0.1.0. Initial release.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in babelyoda.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Andrey Subbotin
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,51 @@
1
+ = things-fetcher
2
+
3
+ A simple tool to fetch messages from an IMAP folder and create a new to-do item in the Things app for each.
4
+
5
+ == How It Works
6
+
7
+ Say, you use Gmail. Say, you want all the mail with +[todo]+ in the subject field to be automatically routed to your Things' Inbox.
8
+
9
+ 1. Create a filter so that all subject mails end up in the same folder/label.
10
+ 2. <tt>gem install things-fetcher</tt>
11
+ 3. Run <tt>things-fetcher</tt> in Terminal to generate a default config file at <tt>~/.things_fetcher</tt>
12
+ 4. Edit the config file at <tt>~/.things_fetcher</tt> to specify your email and so on.
13
+ 5. Run <tt>things-fetcher</tt> in Terminal.
14
+
15
+ Set up a cron task to run <tt>things-fetcher</tt> periodically.
16
+
17
+ == Configuration
18
+
19
+ The following options can be specified in the <tt>~/.things_fetcher</tt> file:
20
+
21
+ [<tt>username</tt>] The username to authenticate with. You must specify one.
22
+ [<tt>password</tt>] The password to authenticate with. Default is to use the Keychain.
23
+ [<tt>server</tt>] The IP address or domain name of the IMAP server. Defaults to +imap.gmail.com+.
24
+ [<tt>port</tt>] The port to connect to. Defaults to +993+.
25
+ [<tt>ssl</tt>] Set to any value to use SSL encryption. Enabled by default.
26
+ [<tt>use_login</tt>] Set to any value to use the LOGIN command instead of AUTHENTICATE. Some servers, like GMail, do not support AUTHENTICATE. Enabled by default.
27
+ [<tt>in_folder</tt>] The name of the folder from which to read incoming mail. Defaults to +Things+.
28
+ [<tt>error_folder</tt>] The name a folder to move mail that causes an error during processing. Defaults to +Things+.
29
+ [<tt>list</tt>] The list in Things to create TODOs in. Defaults to +Inbox+.
30
+ [<tt>tag_names</tt>] The tags to apply to the newly created TODOs. Defaults to +Texted+.
31
+
32
+ == Note on Patches/Pull Requests
33
+
34
+ * Fork the project.
35
+ * Make your feature addition or bug fix.
36
+ * Add tests for it. This is important so I don't break it in a
37
+ future version unintentionally.
38
+ * Commit, do not mess with rakefile, version, or history.
39
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
40
+ * Send me a pull request. Bonus points for topic branches.
41
+
42
+ == Copyright
43
+
44
+ Copyright (c) 2012 Andrey Subbotin. See LICENSE for details.
45
+
46
+ == The IMAP fetching code
47
+
48
+ Created by Dan Weinand and Luke Francl. Development supported by {Slantwise Design}[http://slantwisedesign.com].
49
+ Licensed under the terms of the MIT License. Be excellent to each other.
50
+
51
+ The original repo can be found at: https://github.com/look/fetcher
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'things_fetcher'
5
+
6
+ # Main block
7
+
8
+ def main
9
+ config = ThingsFetcher::Config.new("~/.things_fetcher")
10
+ fetcher = ThingsFetcher::Fetcher.new(config)
11
+ fetcher.run
12
+ end
13
+
14
+ # Aux methods
15
+
16
+ main
@@ -0,0 +1,63 @@
1
+ module Fetcher
2
+ class Base
3
+ # Options:
4
+ # * <tt>:server</tt> - Server to connect to.
5
+ # * <tt>:username</tt> - Username to use when connecting to server.
6
+ # * <tt>:password</tt> - Password to use when connecting to server.
7
+ # * <tt>:receiver</tt> - Receiver object to pass messages to. Assumes the
8
+ # receiver object has a receive method that takes a message as it's argument
9
+ #
10
+ # Additional protocol-specific options implimented by sub-classes
11
+ #
12
+ # Example:
13
+ # Fetcher::Base.new(:server => 'mail.example.com',
14
+ # :username => 'pam',
15
+ # :password => 'test',
16
+ # :receiver => IncomingMailHandler)
17
+ def initialize(options={})
18
+ %w(server username password receiver).each do |opt|
19
+ raise ArgumentError, "#{opt} is required" unless options[opt.to_sym]
20
+ # convert receiver to a Class if it isn't already.
21
+ if opt == "receiver" && options[:receiver].is_a?(String)
22
+ options[:receiver] = Kernel.const_get(options[:receiver])
23
+ end
24
+
25
+ instance_eval("@#{opt} = options[:#{opt}]")
26
+ end
27
+ end
28
+
29
+ # Run the fetching process
30
+ def fetch
31
+ establish_connection
32
+ get_messages
33
+ close_connection
34
+ end
35
+
36
+ protected
37
+
38
+ # Stub. Should be overridden by subclass.
39
+ def establish_connection #:nodoc:
40
+ raise NotImplementedError, "This method should be overridden by subclass"
41
+ end
42
+
43
+ # Stub. Should be overridden by subclass.
44
+ def get_messages #:nodoc:
45
+ raise NotImplementedError, "This method should be overridden by subclass"
46
+ end
47
+
48
+ # Stub. Should be overridden by subclass.
49
+ def close_connection #:nodoc:
50
+ raise NotImplementedError, "This method should be overridden by subclass"
51
+ end
52
+
53
+ # Send message to receiver object
54
+ def process_message(message)
55
+ @receiver.receive(message)
56
+ end
57
+
58
+ # Stub. Should be overridden by subclass.
59
+ def handle_bogus_message(message) #:nodoc:
60
+ raise NotImplementedError, "This method should be overridden by subclass"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,88 @@
1
+ (RUBY_VERSION < '1.9.0') ? require('system_timer') : require('timeout')
2
+ require_relative 'plain_imap'
3
+
4
+ module Fetcher
5
+ class Imap < Base
6
+
7
+ PORT = 143
8
+
9
+ protected
10
+
11
+ # Additional Options:
12
+ # * <tt>:authentication</tt> - authentication type to use, defaults to PLAIN
13
+ # * <tt>:port</tt> - port to use (defaults to 143)
14
+ # * <tt>:ssl</tt> - use SSL to connect
15
+ # * <tt>:use_login</tt> - use LOGIN instead of AUTHENTICATE to connect (some IMAP servers, like GMail, do not support AUTHENTICATE)
16
+ # * <tt>:processed_folder</tt> - if set to the name of a mailbox, messages will be moved to that mailbox instead of deleted after processing. The mailbox will be created if it does not exist.
17
+ # * <tt>:error_folder:</tt> - the name of a mailbox where messages that cannot be processed (i.e., your receiver throws an exception) will be moved. Defaults to "bogus". The mailbox will be created if it does not exist.
18
+ def initialize(options={})
19
+ @authentication = options.delete(:authentication) || 'PLAIN'
20
+ @port = options.delete(:port) || PORT
21
+ @ssl = options.delete(:ssl)
22
+ @use_login = options.delete(:use_login)
23
+ @in_folder = options.delete(:in_folder) || 'INBOX'
24
+ @processed_folder = options.delete(:processed_folder)
25
+ @error_folder = options.delete(:error_folder) || 'bogus'
26
+ super(options)
27
+ end
28
+
29
+ # Open connection and login to server
30
+ def establish_connection
31
+ timeout_call = (RUBY_VERSION < '1.9.0') ? "SystemTimer.timeout_after(15.seconds) do" : "Timeout::timeout(15) do"
32
+
33
+ eval("#{timeout_call}
34
+ @connection = Net::IMAP.new(@server, @port, @ssl)
35
+ if @use_login
36
+ @connection.login(@username, @password)
37
+ else
38
+ @connection.authenticate(@authentication, @username, @password)
39
+ end
40
+ end")
41
+ end
42
+
43
+ # Retrieve messages from server
44
+ def get_messages
45
+ @connection.select(@in_folder)
46
+ @connection.uid_search(['ALL']).each do |uid|
47
+ msg = @connection.uid_fetch(uid,'RFC822').first.attr['RFC822']
48
+ begin
49
+ process_message(msg)
50
+ add_to_processed_folder(uid) if @processed_folder
51
+ rescue
52
+ handle_bogus_message(msg)
53
+ end
54
+ # Mark message as deleted
55
+ @connection.uid_store(uid, "+FLAGS", [:Seen, :Deleted])
56
+ end
57
+ end
58
+
59
+ # Store the message for inspection if the receiver errors
60
+ def handle_bogus_message(message)
61
+ create_mailbox(@error_folder)
62
+ @connection.append(@error_folder, message)
63
+ end
64
+
65
+ # Delete messages and log out
66
+ def close_connection
67
+ @connection.expunge
68
+ @connection.logout
69
+ begin
70
+ @connection.disconnect unless @connection.disconnected?
71
+ rescue
72
+ Rails.logger.info("Fetcher: Remote closed connection before I could disconnect.")
73
+ end
74
+ end
75
+
76
+ def add_to_processed_folder(uid)
77
+ create_mailbox(@processed_folder)
78
+ @connection.uid_copy(uid, @processed_folder)
79
+ end
80
+
81
+ def create_mailbox(mailbox)
82
+ unless @connection.list("", mailbox)
83
+ @connection.create(mailbox)
84
+ end
85
+ end
86
+
87
+ end
88
+ end
@@ -0,0 +1,21 @@
1
+ require 'net/imap'
2
+ # add plain as an authentication type...
3
+ # This is taken from:
4
+ # http://svn.ruby-lang.org/cgi-bin/viewvc.cgi/trunk/lib/net/imap.rb?revision=7657&view=markup&pathrev=10966
5
+
6
+ # Authenticator for the "PLAIN" authentication type. See
7
+ # #authenticate().
8
+ class PlainAuthenticator
9
+ def process(data)
10
+ return "\0#{@user}\0#{@password}"
11
+ end
12
+
13
+ private
14
+
15
+ def initialize(user, password)
16
+ @user = user
17
+ @password = password
18
+ end
19
+ end
20
+
21
+ Net::IMAP.add_authenticator "PLAIN", PlainAuthenticator
data/lib/fetcher.rb ADDED
@@ -0,0 +1,21 @@
1
+ module Fetcher
2
+ # Use factory-style initialization or insantiate directly from a subclass
3
+ #
4
+ # Options:
5
+ # * <tt>:type</tt> - Name of class as a symbol to instantiate
6
+ #
7
+ # Other options are the same as Fetcher::Base.new
8
+ #
9
+ # Example:
10
+ #
11
+ # Fetcher.create(:type => :pop) is equivalent to
12
+ # Fetcher::Pop.new()
13
+ def self.create(options={})
14
+ klass = options.delete(:type)
15
+ raise ArgumentError, 'Must supply a type' unless klass
16
+ module_eval "#{klass.to_s.capitalize}.new(options)"
17
+ end
18
+ end
19
+
20
+ require_relative 'fetcher/base'
21
+ require_relative 'fetcher/imap'
data/lib/keychain.rb ADDED
@@ -0,0 +1,23 @@
1
+ # blog post: http://blog.slashpoundbang.com/post/1521530410/accessing-the-os-x-keychain-from-ruby
2
+
3
+ class KeyChain
4
+ def self.method_missing(meth, *args)
5
+ run args.unshift(meth)
6
+ end
7
+
8
+ def self.find_internet_password(*args)
9
+ # -g: Display the password for the item found
10
+ output = quiet args.unshift('find-internet-password', '-g')
11
+ output[/^password: "(.*)"$/, 1]
12
+ end
13
+
14
+ private
15
+
16
+ def self.run(*args)
17
+ `security #{args.join(' ')}`
18
+ end
19
+
20
+ def self.quiet(*args)
21
+ run args.unshift('2>&1 >/dev/null')
22
+ end
23
+ end
@@ -0,0 +1,122 @@
1
+ require 'yaml'
2
+
3
+ require_relative '../keychain'
4
+
5
+ module ThingsFetcher
6
+ class Config
7
+ def initialize(path)
8
+ super
9
+
10
+ @data = default_config
11
+ @data.merge! load_from_file(path)
12
+ end
13
+
14
+ def [](key)
15
+ data.has_key?(key) ? data[key] : send(key.to_s)
16
+ end
17
+
18
+ def password
19
+ KeyChain.find_internet_password '-s', data[:server], '-a', data[:username]
20
+ end
21
+
22
+ private
23
+ attr_accessor :data
24
+
25
+ def default_config
26
+ {
27
+ :server => 'imap.gmail.com',
28
+ :port => 993,
29
+ :ssl => true,
30
+ :use_login => true,
31
+ :in_folder => 'Things',
32
+ :error_folder => 'Things',
33
+ :tag_names => 'Texted',
34
+ :list => 'Inbox'
35
+ }
36
+ end
37
+
38
+ def load_from_file(path)
39
+ expanded_path = File.expand_path(path)
40
+ if File.exists?(expanded_path)
41
+ loaded_config = YAML::load(File.open(expanded_path))
42
+ result = {}
43
+ config_keys.each do |k|
44
+ if loaded_config.has_key?(k.to_s)
45
+ result[k] = loaded_config[k.to_s]
46
+ end
47
+ end
48
+ if result.has_key?(:username)
49
+ result
50
+ else
51
+ puts "ERROR: No username specified in the config."
52
+ puts " Please edit the config file at: #{expanded_path}"
53
+ exit 0
54
+ end
55
+ else
56
+ generate_config_file(expanded_path)
57
+ puts "ERROR: No config file found at: #{expanded_path}"
58
+ puts " A new config has been generated."
59
+ puts " Please edit."
60
+ exit 0
61
+ end
62
+ end
63
+
64
+ def config_keys
65
+ [
66
+ :server,
67
+ :port,
68
+ :ssl,
69
+ :use_login,
70
+ :in_folder,
71
+ :error_folder,
72
+ :tag_names,
73
+ :list,
74
+ :username,
75
+ :password
76
+ ]
77
+ end
78
+
79
+ def generate_config_file(path)
80
+ File.open(path, "w") do |f|
81
+ f << <<-EOS
82
+ # Any line that starts with a # is a comment.
83
+ # Leave any of the configuration options commented out to use the default value.
84
+
85
+ # The username to authenticate with.
86
+ # Uncoment and edit the following line.
87
+ # username: email@domain.com
88
+
89
+ # The password to authenticate with.
90
+ # Leave the following line commented out to you the password stored in the Keychain.
91
+ # Uncomment and edit otherwise.
92
+ # password: somepassword
93
+
94
+ # The IP address or domain name of the IMAP server
95
+ # server: imap.gmail.com
96
+
97
+ # The port to connect to (defaults to the standard port for the type of server)
98
+ # port: 993
99
+
100
+ # Set to any value to use SSL encryption
101
+ # ssl: true
102
+
103
+ # Set to any value to use the LOGIN command instead of AUTHENTICATE.
104
+ # Some servers, like GMail, do not support AUTHENTICATE (IMAP only).
105
+ # use_login: true
106
+
107
+ # The name of the folder from which to read incoming mail (IMAP only). Defaults to INBOX.
108
+ # in_folder: Things
109
+
110
+ # The name a folder to move mail that causes an error during processing (IMAP only).
111
+ # error_folder: Things
112
+
113
+ # The tags to apply to the newly created TODOs.
114
+ # tag_names: Texted
115
+
116
+ # The list in Things to create TODOs in.
117
+ # list: Inbox
118
+ EOS
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,38 @@
1
+ require_relative '../fetcher'
2
+ require_relative 'mail_handler'
3
+
4
+ module ThingsFetcher
5
+ class Fetcher
6
+ def initialize(config)
7
+ super
8
+ @config = config
9
+ end
10
+
11
+ def run
12
+ fetcher = ::Fetcher.create(fetcher_options)
13
+ fetcher.fetch
14
+ end
15
+
16
+ private
17
+ attr_accessor :config
18
+
19
+ def fetcher_options
20
+ {
21
+ :type => :imap,
22
+ :receiver => handler,
23
+ :server => config[:server],
24
+ :port => config[:port],
25
+ :ssl => config[:ssl],
26
+ :use_login => config[:use_login],
27
+ :username => config[:username],
28
+ :password => config[:password],
29
+ :in_folder => config[:in_folder],
30
+ :error_folder => config[:error_folder]
31
+ }
32
+ end
33
+
34
+ def handler
35
+ ThingsFetcher::MailHandler.new(config)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ require 'appscript'
2
+ require 'mail'
3
+ require 'osax'
4
+
5
+ module ThingsFetcher
6
+ class MailHandler
7
+ def initialize(config)
8
+ super
9
+ @config = config
10
+ end
11
+
12
+ def receive(data)
13
+ msg = Mail.read_from_string(data)
14
+ make_todo msg.subject
15
+ end
16
+
17
+ private
18
+ include Appscript
19
+ include OSAX
20
+
21
+ attr_accessor :config
22
+
23
+ def make_todo(name)
24
+ things = app("Things")
25
+ todo = things.make(:new => :to_do, :at => things.lists[config[:list]], :with_properties => { :name => name })
26
+ todo.tag_names.set config[:tag_names]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module ThingsFetcher
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'things_fetcher/config'
2
+ require_relative 'things_fetcher/fetcher'
@@ -0,0 +1,30 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "things_fetcher/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "things-fetcher"
7
+ s.version = ThingsFetcher::VERSION
8
+ s.authors = ["Andrey Subbotin"]
9
+ s.email = ["andrey@subbotin.me"]
10
+ s.homepage = "http://github.com/eploko/things-fetcher"
11
+ s.summary = "Imports to-dos from IMAP to Things.app"
12
+ s.description = "A simple daemon to periodically check an IMAP folder and create a new to-dos in the Things app for each new message."
13
+
14
+ s.rubyforge_project = "things-fetcher"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.require_paths = ["lib"]
18
+
19
+ s.executables = ["things-fetcher"]
20
+ s.default_executable = ["things-fetcher"]
21
+ s.extra_rdoc_files = [
22
+ "LICENSE",
23
+ "README.rdoc"
24
+ ]
25
+
26
+ s.add_development_dependency "rake"
27
+
28
+ s.add_runtime_dependency "mail"
29
+ s.add_runtime_dependency "rb-appscript"
30
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: things-fetcher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Andrey Subbotin
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-05-19 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: &70233798421180 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70233798421180
25
+ - !ruby/object:Gem::Dependency
26
+ name: mail
27
+ requirement: &70233798420760 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *70233798420760
36
+ - !ruby/object:Gem::Dependency
37
+ name: rb-appscript
38
+ requirement: &70233798420340 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *70233798420340
47
+ description: A simple daemon to periodically check an IMAP folder and create a new
48
+ to-dos in the Things app for each new message.
49
+ email:
50
+ - andrey@subbotin.me
51
+ executables:
52
+ - things-fetcher
53
+ extensions: []
54
+ extra_rdoc_files:
55
+ - LICENSE
56
+ - README.rdoc
57
+ files:
58
+ - .gitignore
59
+ - CHANGELOG
60
+ - Gemfile
61
+ - LICENSE
62
+ - README.rdoc
63
+ - Rakefile
64
+ - bin/things-fetcher
65
+ - lib/fetcher.rb
66
+ - lib/fetcher/base.rb
67
+ - lib/fetcher/imap.rb
68
+ - lib/fetcher/plain_imap.rb
69
+ - lib/keychain.rb
70
+ - lib/things_fetcher.rb
71
+ - lib/things_fetcher/config.rb
72
+ - lib/things_fetcher/fetcher.rb
73
+ - lib/things_fetcher/mail_handler.rb
74
+ - lib/things_fetcher/version.rb
75
+ - things-fetcher.gemspec
76
+ homepage: http://github.com/eploko/things-fetcher
77
+ licenses: []
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ! '>='
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubyforge_project: things-fetcher
96
+ rubygems_version: 1.8.10
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: Imports to-dos from IMAP to Things.app
100
+ test_files: []