├── bin ├── omnifocus ├── omnifocus_new └── of ├── lib ├── omnifocus │ ├── review.rb │ ├── repl.rb │ ├── schedule.rb │ ├── time.rb │ ├── version.rb │ ├── help.rb │ ├── reschedule.rb │ ├── set.rb │ ├── sync.rb │ ├── fix_review_dates.rb │ ├── top.rb │ ├── neww.rb │ ├── projects.rb │ ├── unfuck.rb │ └── triage.rb └── omnifocus.rb ├── Manifest.txt ├── .autotest ├── Rakefile ├── README.rdoc └── History.rdoc /bin/omnifocus: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby -w 2 | 3 | require 'rubygems' 4 | require 'omnifocus' 5 | 6 | warn "omnifocus is going away. Use `of sync` instead." 7 | OmniFocus.sync ARGV 8 | -------------------------------------------------------------------------------- /lib/omnifocus/review.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out an aggregate report for all live projects" 3 | def cmd_review args 4 | print_aggregate_report live_projects 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/omnifocus/repl.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Start a pry repl" 3 | def cmd_repl args 4 | puts "starting pry..." 5 | require "pry" 6 | binding.pry 7 | p :DONE 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/omnifocus_new: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby -w 2 | 3 | ENV['GEM_HOME'] = ENV['GEM_PATH'] = "/Users/ryan/.gem/sandboxes/omnifocus" 4 | 5 | require 'rubygems' 6 | require 'omnifocus' 7 | require 'time' 8 | 9 | include Appscript 10 | 11 | warn "omnifocus_new is going away. Use `of new` instead" 12 | OmniFocus.neww ARGV 13 | -------------------------------------------------------------------------------- /lib/omnifocus/schedule.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out a schedule for a project or context" 3 | def cmd_schedule args 4 | name = args.shift or abort "need a context or project name" 5 | 6 | cp = context(name) || project(name) 7 | 8 | abort "Context/Project not found: #{name}" unless cp 9 | 10 | print_aggregate_report cp.tasks, :long 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/omnifocus/time.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Calculate the amount of estimated time across all tasks. Depressing" 3 | def cmd_time args 4 | m = 0 5 | 6 | all_tasks.map { |task| 7 | task.estimated_minutes.get 8 | }.grep(Numeric).each { |t| 9 | m += t 10 | } 11 | 12 | puts "all tasks = #{m} minutes" 13 | puts " = %.2f hours" % (m / 60.0) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/omnifocus/version.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out versions for omnifocus and plugins" 3 | def cmd_version args 4 | plugins = self.class._plugins 5 | 6 | width = plugins.map(&:name).map(&:length).max 7 | fmt = " %-#{width}s = v%s" 8 | 9 | puts "Versions:" 10 | puts 11 | 12 | puts fmt % ["Omnifocus", VERSION] 13 | plugins.each do |klass| 14 | puts fmt % [klass, klass::VERSION] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/of: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby -w 2 | 3 | require 'rubygems' 4 | require 'omnifocus' 5 | require 'abbrev' 6 | 7 | OmniFocus._load_plugins nil 8 | methods = OmniFocus.public_instance_methods(false).grep(/^cmd_/) 9 | methods.map! { |s| s[4..-1] } 10 | 11 | tbl = Abbrev::abbrev methods 12 | 13 | cmd = ARGV.shift or abort "need a subcommand: sync, schedule, etc" 14 | msg = tbl[cmd] 15 | 16 | abort "unknown command: #{cmd}" unless msg 17 | 18 | OmniFocus.new.send "cmd_#{msg}", ARGV 19 | -------------------------------------------------------------------------------- /lib/omnifocus/help.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out descriptions for all known subcommands" 3 | def cmd_help args 4 | methods = OmniFocus.public_instance_methods(false).grep(/^cmd_/) 5 | methods.map! { |s| s[4..-1] } 6 | width = methods.map(&:length).max 7 | 8 | puts "Available subcommands:" 9 | 10 | methods.sort.each do |m| 11 | desc = self.class.description["cmd_#{m}".to_sym] 12 | puts " %-#{width}s : %s." % [m, desc] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | History.rdoc 3 | Manifest.txt 4 | README.rdoc 5 | Rakefile 6 | bin/of 7 | bin/omnifocus 8 | bin/omnifocus_new 9 | lib/omnifocus.rb 10 | lib/omnifocus/fix_review_dates.rb 11 | lib/omnifocus/help.rb 12 | lib/omnifocus/neww.rb 13 | lib/omnifocus/projects.rb 14 | lib/omnifocus/repl.rb 15 | lib/omnifocus/reschedule.rb 16 | lib/omnifocus/review.rb 17 | lib/omnifocus/schedule.rb 18 | lib/omnifocus/set.rb 19 | lib/omnifocus/sync.rb 20 | lib/omnifocus/time.rb 21 | lib/omnifocus/top.rb 22 | lib/omnifocus/triage.rb 23 | lib/omnifocus/unfuck.rb 24 | lib/omnifocus/version.rb 25 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require 'autotest/restart' 4 | 5 | Autotest.add_hook :initialize do |at| 6 | at.testlib = "minitest/autorun" 7 | # at.extra_files << "../some/external/dependency.rb" 8 | # 9 | # at.libs << ":../some/external" 10 | # 11 | # at.add_exception 'vendor' 12 | # 13 | # at.add_mapping(/dependency.rb/) do |f, _| 14 | # at.files_matching(/test_.*rb$/) 15 | # end 16 | # 17 | # %w(TestA TestB).each do |klass| 18 | # at.extra_class_map[klass] = "test/test_misc.rb" 19 | # end 20 | end 21 | 22 | # Autotest.add_hook :run_command do |at| 23 | # system "rake build" 24 | # end 25 | -------------------------------------------------------------------------------- /lib/omnifocus/reschedule.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Reschedule reviews & releases, and fix missing tasks. -n to no-op" 3 | def cmd_reschedule args 4 | skip = ARGV.first == "-n" 5 | 6 | rels, tasks, projs = aggregate_releases 7 | 8 | no_autosave_during do 9 | warn "Checking project review intervals..." 10 | fix_project_review_intervals rels, skip 11 | 12 | warn "Checking releasing task numeric prefixes (if any)" 13 | fix_release_task_names projs, tasks, skip 14 | 15 | warn "Checking releasing task schedules" 16 | fix_release_task_schedule projs, tasks, skip 17 | 18 | warn "Repairing any missing release or triage tasks" 19 | fix_missing_tasks skip 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/omnifocus/set.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Set the schedule for a project (incl release and triage tasks) to N weeks" 3 | def cmd_set args 4 | name, interval = args 5 | 6 | abort "NAH" unless name && interval 7 | 8 | interval = interval.to_i 9 | 10 | proj = project name 11 | 12 | if proj.review_interval[:steps] != interval then 13 | warn "Setting project review to #{interval} weeks" 14 | proj.review_interval = weekly(interval) 15 | end 16 | 17 | rel = proj.thing.tasks[q_release].first.get rescue nil # TODO: this sucks 18 | tri = proj.thing.tasks[q_triage].first.get rescue nil 19 | 20 | fix_project_review_interval Task.new(omnifocus, rel) if rel 21 | fix_project_review_interval Task.new(omnifocus, tri) if tri 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/omnifocus/sync.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Synchronize tasks with all known BTS plugins" 3 | def cmd_sync args 4 | self.debug = args.delete("-d") 5 | plugins = self.class._plugins 6 | 7 | # do this all up front so we can REALLY fuck shit up with plugins 8 | plugins.each do |plugin| 9 | extend plugin 10 | end 11 | 12 | prepopulate_existing_tasks 13 | 14 | plugins.each do |plugin| 15 | name = plugin.name.split(/::/).last.downcase 16 | warn "scanning #{name}" 17 | send "populate_#{name}_tasks" 18 | end 19 | 20 | if debug then 21 | require 'pp' 22 | p :existing 23 | pp existing 24 | p :bug_db 25 | pp bug_db 26 | end 27 | 28 | create_missing_projects 29 | update_tasks 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/omnifocus/fix_review_dates.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Fix review dates. Use -n to no-op" 3 | def cmd_fix_review_dates args # TODO: merge into reschedule 4 | skip = ARGV.first == "-n" 5 | 6 | projs = all_projects.group_by { |proj| proj.review_interval[:steps] } 7 | 8 | projs.each do |k, a| 9 | # helps stabilize and prevent random shuffling 10 | projs[k] = a.sort_by { |p| [p.next_review_date, p.name] } 11 | end 12 | 13 | now = hour 0 14 | fri = if now.wday == 5 then 15 | now 16 | else 17 | now - 86400 * (now.wday-5) 18 | end 19 | 20 | no_autosave_during do 21 | projs.each do |unit, a| 22 | day = fri 23 | 24 | steps = (a.size.to_f / unit).ceil 25 | 26 | a.each_with_index do |proj, i| 27 | if proj.next_review_date != day then 28 | warn "Fixing #{unit} #{proj.name} review date to #{day}" 29 | proj.thing.next_review_date.set day unless skip 30 | end 31 | 32 | day += 86400 * 7 if (i+1) % steps == 0 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/omnifocus/top.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out top 10 projects+contexts, contexts, and projects" 3 | def cmd_top args 4 | # available non-repeating tasks per project & context 5 | h1 = Hash.new 0 6 | _flattened_contexts.each do |context| 7 | context_name = context.name.get 8 | context.tasks[q_active_unique].get.each do |task| 9 | h1[[task.containing_project.name.get, context_name].join(": ")] += 1 10 | end 11 | end 12 | 13 | # available non-repeating tasks per context 14 | h2 = Hash.new 0 15 | _flattened_contexts.each do |context| 16 | h2[context.name.get] += context.tasks[q_active_unique].count 17 | end 18 | 19 | # available non-repeating tasks per project 20 | h3 = Hash.new 0 21 | self.omnifocus.flattened_projects.get.each do |project| 22 | h3[project.name.get] += project.flattened_tasks[q_active_unique].count 23 | end 24 | 25 | puts "%-26s%-26s%-26s" % ["#### Proj+Context", "#### Context", "#### Project"] 26 | puts "-" * 26 * 3 27 | top(h1).zip(top(h2), top(h3)).each do |a| 28 | puts "%-26s%-26s%-26s" % a 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/omnifocus/neww.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Create a new project or task" 3 | def cmd_neww args 4 | project_name = args.shift 5 | title = ($stdin.tty? ? args.join(" ") : $stdin.read).strip 6 | 7 | unless project_name && ! title.empty? then 8 | cmd = File.basename $0 9 | projects = self.omnifocus.flattened_projects.name.get.sort_by(&:downcase) 10 | 11 | warn "usage: #{cmd} new project_name title - create a project task" 12 | warn " #{cmd} new nil title - create an inbox task" 13 | warn " #{cmd} new project project_name - create a new project" 14 | warn "" 15 | warn "project_names = #{projects.join ", "}" 16 | exit 1 17 | end 18 | 19 | case project_name.downcase 20 | when "nil" then 21 | self.omnifocus.make :new => :inbox_task, :with_properties => {:name => title} 22 | when "project" then 23 | new_or_repair_project title 24 | else 25 | project = self.omnifocus.flattened_projects[q_named(project_name)].first.get 26 | make project, :task, title 27 | puts "created task in #{project_name}: #{title}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require 'rubygems' 4 | require 'hoe' 5 | 6 | Hoe.plugin :seattlerb 7 | Hoe.plugin :isolate 8 | 9 | Hoe.spec "omnifocus" do 10 | developer "Ryan Davis", "ryand-ruby@zenspider.com" 11 | 12 | license "MIT" 13 | 14 | dependency "rb-scpt", "~> 1.0" 15 | dependency "octokit", "~> 4.14", :development if ENV["TEST"] || ENV["USER"] == "ryan" 16 | 17 | self.isolate_multiruby = true 18 | 19 | pluggable! 20 | end 21 | 22 | def omnifocus cmd, options = nil 23 | inc = "-Ilib:../../omnifocus-github/dev/lib" 24 | 25 | ruby "#{inc} -rpry-byebug bin/of #{cmd} #{options}" 26 | end 27 | 28 | desc "Run fix and reschedule tasks" 29 | t = task "of:fix" => :isolate do 30 | omnifocus "fix" 31 | omnifocus "resch" 32 | end 33 | t.plugin = "omnifocus" 34 | 35 | desc "Run any command (via $CMD) with -d if $D" 36 | t = task "of:debug" => :isolate do 37 | cmd = ENV["CMD"] || "sync github" 38 | d = ENV["D"] ? "-d" : nil 39 | omnifocus cmd, d 40 | end 41 | t.plugin = "omnifocus" 42 | 43 | Dir["lib/omnifocus/*.rb"] 44 | .map { |f| File.basename f, ".rb" } 45 | .each do |cmd| 46 | desc "Run the #{cmd} command" 47 | task("of:#{cmd}" => :isolate) { omnifocus cmd } 48 | .plugin = "omnifocus" 49 | end 50 | 51 | # vim: syntax=ruby 52 | -------------------------------------------------------------------------------- /lib/omnifocus/projects.rb: -------------------------------------------------------------------------------- 1 | class OmniFocus 2 | desc "Print out all active projects" 3 | def cmd_projects args 4 | h = Hash.new 0 5 | n = 0 6 | 7 | self.active_nerd_projects.each do |project| 8 | name = project.name 9 | count = project.flattened_tasks.count 10 | ri = project.review_interval 11 | time = "#{ri[:steps]}#{ri[:unit].to_s[0,1]}" 12 | 13 | next unless count > 0 14 | 15 | n += count 16 | h["#{name} (#{time})"] = count 17 | end 18 | 19 | puts "%5d: %3d%%: %s" % [n, 100, "Total"] 20 | puts 21 | h.sort_by { |name, count| -count }.each do |name, count| 22 | puts "%5d: %3d%%: %s" % [count, 100 * count / n, name] 23 | end 24 | 25 | t = Hash.new { |h,k| h[k] = [] } 26 | 27 | self.active_nerd_projects.each do |project| 28 | ri = project.review_interval 29 | time = "#{ri[:steps]}#{ri[:unit].to_s[0,1]}" 30 | 31 | t[time] << project.name 32 | end 33 | 34 | puts 35 | t.sort.each do |k, vs| 36 | wrapped = vs.sort 37 | .join(" ") # make one string 38 | .scan(/.{,70}(?: |$)/) # break into 70 char lines 39 | .join("\n ") # wrap w/ whitespace to indent 40 | .strip 41 | 42 | puts "%s: %s" % [k, wrapped] 43 | puts 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = omnifocus 2 | 3 | home :: https://github.com/seattlerb/omnifocus 4 | rdoc :: http://docs.seattlerb.org/omnifocus 5 | 6 | == DESCRIPTION: 7 | 8 | Synchronizes bug tracking systems to omnifocus. 9 | 10 | == FEATURES/PROBLEMS: 11 | 12 | * Pluggable to work with multiple bug tracking systems (BTS). 13 | * Creates projects in omnifocus if needed. 14 | * Creates tasks for multiple projects in omnifocus. 15 | * Marks tasks complete if they've been closed in the BTS. 16 | 17 | == SYNOPSIS: 18 | 19 | % of sync 20 | scanning ticket RF#3802 21 | removing parsetree # 314159 22 | creating parsetree # 3802 23 | ... 24 | 25 | == Known Plugins: 26 | 27 | + omnifocus-bugzilla by kushali 28 | + omnifocus-github by zenspider 29 | + omnifocus-pivotaltracker by vesan 30 | + omnifocus-redmine by kushali 31 | + omnifocus-rt by kushali 32 | + omnifocus-rubyforge by zenspider 33 | + omnifocus-lighthouse by juliengrimault 34 | + omnifocus-trello by vesan 35 | 36 | == REQUIREMENTS: 37 | 38 | * mechanize 39 | * rb-appscript 40 | 41 | == INSTALL: 42 | 43 | * sudo gem install omnifocus 44 | 45 | == LICENSE: 46 | 47 | (The MIT License) 48 | 49 | Copyright (c) Ryan Davis, seattle.rb 50 | 51 | Permission is hereby granted, free of charge, to any person obtaining 52 | a copy of this software and associated documentation files (the 53 | 'Software'), to deal in the Software without restriction, including 54 | without limitation the rights to use, copy, modify, merge, publish, 55 | distribute, sublicense, and/or sell copies of the Software, and to 56 | permit persons to whom the Software is furnished to do so, subject to 57 | the following conditions: 58 | 59 | The above copyright notice and this permission notice shall be 60 | included in all copies or substantial portions of the Software. 61 | 62 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 63 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 64 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 65 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 66 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 67 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 68 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 69 | -------------------------------------------------------------------------------- /lib/omnifocus/unfuck.rb: -------------------------------------------------------------------------------- 1 | require "nEt/http" # because open-uri is thread ugly 2 | require "net/https" # ... 3 | require "tempfile" # ... 4 | 5 | require "open-uri" 6 | 7 | require "omnifocus/github" 8 | 9 | class OmniFocus 10 | include OmniFocus::Github 11 | 12 | desc "unfuck triage tasks by ensuring they have URLs" 13 | def cmd_unfuck args 14 | gh = github_clients.values.first # hack 15 | 16 | q_tri = its.completed.eq(false).and(its.name.begins_with("Triage")) 17 | 18 | nerd_projects.projects.get.sort_by { |p| p.name.get }.each do |proj| 19 | name = proj.name.get 20 | tri = proj.tasks[q_tri].first.get rescue nil 21 | 22 | next if ENV["PROJ"] and name !~ /#{ENV["PROJ"]}/ 23 | 24 | next unless tri 25 | 26 | warn "#{name}:" 27 | 28 | if proj.note.get.end_with? "/issues" then 29 | warn " repairing project note: #{proj.note.get}" 30 | proj.note.set proj.note.get.delete_suffix "/issues" 31 | end 32 | 33 | repair_note proj, "https://github.com/seattlerb/#{name}" 34 | repair_note tri, "https://github.com/seattlerb/#{name}/issues" 35 | 36 | source = URI.parse(proj.note.get).path.delete_prefix("/") 37 | 38 | unless github_project? gh, source then 39 | warn " unknown project source #{source}... searching" 40 | path = nil 41 | path = "seattlerb/#{name}" if github_project?(gh, "seattlerb/#{name}") 42 | path ||= "zenspider/#{name}" if github_project?(gh, "zenspider/#{name}") 43 | 44 | if path then 45 | warn " repairing notes to #{path}" 46 | proj.note.set "https://github.com/#{path}" 47 | tri.note.set "https://github.com/#{path}/issues" 48 | else 49 | warn " #{name} is NOT a github project? removing notes" 50 | proj.note.set "" 51 | tri.note.set "" 52 | next 53 | end 54 | end 55 | end 56 | end 57 | 58 | def github_project? gh, proj 59 | gh.list_issues proj 60 | rescue ::Octokit::NotFound, ::OpenURI::HTTPError 61 | warn " #{proj} is NOT a github project" 62 | false 63 | end 64 | 65 | def repair_note obj, url 66 | note = obj.note.get 67 | if !note or note.empty? then 68 | warn " repairing note: #{url}" 69 | obj.note.set url 70 | end 71 | 72 | note = obj.note.get 73 | if note != url and not valid_url?(note) then 74 | warn " note on #{obj.class_.get} differs from url" 75 | warn " have: #{note}" 76 | warn " want: #{url}" 77 | if valid_url?(url) then 78 | warn " (NOT) repairing note: #{url}" 79 | # obj.note.set url 80 | end 81 | end 82 | end 83 | 84 | def valid_url? url 85 | uri = URI.parse url 86 | !!uri.read 87 | rescue 88 | false 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/omnifocus/triage.rb: -------------------------------------------------------------------------------- 1 | require "net/http" # because open-uri is thread ugly 2 | require "net/https" # ... 3 | require "tempfile" # ... 4 | 5 | require "open-uri" 6 | require "uri" 7 | require "json" 8 | 9 | $: << File.expand_path("~/Work/p4/zss/src/worker_bee/dev/lib/") 10 | require "worker_bee" 11 | 12 | class OmniFocus 13 | # def window 14 | # omnifocus.documents.first.document_windows.first 15 | # end 16 | 17 | desc "Triage nerd projects with open issues and/or PRs" 18 | def cmd_triage args 19 | active = its.completed.eq(false) 20 | is_due = its.due_date.lt(Time.now + (86400/3)) 21 | http_note = its.note.begins_with("http") 22 | 23 | # note of tasks of flattened tag "Triaging" whose 24 | # completed is false and due date < today and note starts with "HTTP" 25 | 26 | tasks = self._context("Triaging").tasks[active.and(is_due).and(http_note)] 27 | urls = tasks.note.get 28 | 29 | # triage = Triage.new 30 | 31 | u2t = urls.zip(tasks.get).map { |u, t| # TODO: use api to get redirs 32 | d = File.dirname u 33 | [["#{d}/pulls", t], 34 | ["#{d}/issues", t]] 35 | }.flatten(1).to_h 36 | 37 | t0 = Time.now 38 | td = x = nil 39 | urls_to_triage = Triage.new.process u2t.keys 40 | td = Time.now - t0 41 | 42 | puts td 43 | 44 | tasks_to_triage = urls_to_triage.map { |u| u2t[u] }.uniq 45 | _tasks_to_skip = tasks.get - tasks_to_triage 46 | 47 | open_safari_tabs urls_to_triage unless urls_to_triage.empty? 48 | 49 | tasks.mark_complete 50 | 51 | # this seems to be deleting everything and breaking my repeats 52 | # tasks_to_skip.each do |task| # complete + delete == skip 53 | # task.delete 54 | # end 55 | end 56 | 57 | def open_safari_tabs urls 58 | safari = Appscript.app("Safari") 59 | 60 | safari.activate 61 | _document = safari.make new: :document # this seems so dumb 62 | window = safari.windows[1] 63 | 64 | dead = window.current_tab.get 65 | 66 | urls.each do |url| 67 | window.make(:new => :tab, :with_properties => {:URL => url}) 68 | end 69 | 70 | dead.delete 71 | end 72 | 73 | class Triage 74 | attr_reader :oauth 75 | 76 | def initialize 77 | @oauth = config_oauth_token 78 | end 79 | 80 | def process urls 81 | require "worker_bee" 82 | 83 | bee = WorkerBee.new 84 | 85 | bee.input(*urls) 86 | bee.work { |url| url_to_api url } # -> api_url 87 | bee.work(n:20) { |url| url_to_ary url } # -> [ issues_or_pulls ] 88 | bee.work { |ary| issues_to_url ary } # -> url_to_check 89 | bee.compact # -> url 90 | bee.results.sort 91 | end 92 | 93 | def url_to_api url 94 | url = "https://github.com/#{url}/issues" unless url.start_with? "http" 95 | uri = URI.parse url 96 | uri.host = "api.github.com" 97 | uri.path = "/repos#{uri.path}" 98 | uri.to_s 99 | end 100 | 101 | def url_to_ary url 102 | ary = get url 103 | ary.reject! { |h| h["pull_request"] } if url =~ /issues$/ 104 | ary 105 | end 106 | 107 | def issues_to_url ary 108 | payload_to_url ary.first 109 | end 110 | 111 | def payload_to_url payload, sub = "" 112 | payload && payload["html_url"].sub(/\/\d+$/, sub).sub(/pull$/, "pulls") 113 | end 114 | 115 | def get url 116 | $stderr.print "." 117 | uri = URI.parse "#{url.sub(/\{.*?\}$/, "")}?per_page=100" 118 | JSON.load uri.read("Authorization" => "token #{oauth}") 119 | rescue => e 120 | warn "ERROR processing %p: %s" % [url, e.message] 121 | raise 122 | end 123 | 124 | def config_oauth_token 125 | token = `gh auth token`.chomp 126 | abort "Please set git config github.oauth-token" if token.empty? 127 | token 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /History.rdoc: -------------------------------------------------------------------------------- 1 | === 2.7.1 / 2022-04-22 2 | 3 | * 1 bug fix: 4 | 5 | * Fixed (again) /\p{L}/ being _nothing_ like /\w/. 6 | 7 | === 2.7.0 / 2022-04-09 8 | 9 | * 4 minor enhancements: 10 | 11 | * Added #active qualifier 12 | * Added all_active_tasks 13 | * Added filtering to #all_subtasks. 14 | * Added some cleanup to remove duplicate tasks (by ticket_id, first one wins). 15 | 16 | * 5 bug fixes: 17 | 18 | * Fix for CJK name support. (inevity) 19 | * Fixed #non_dropped_project for newer omnifocus applescript changes. 20 | * Fixed creating release/triage tasks for an existing project. 21 | * Fixed filtering existing tasks, was ignoring '_' in project names and duplicating tasks. 22 | * Force load all plugins in bin/of on start 23 | 24 | === 2.6.0 / 2020-02-12 25 | 26 | * 4 minor enhancements: 27 | 28 | * Added `of version` subcommand to print out versions for omnifocus and plugins. 29 | * Added config file (~/.omnifocus.yml) to exclude syncing named projects. 30 | * Added debug ivar to OmniFocus instead of ruby's $DEBUG (noisy!). 31 | * Extended `of help` subcommand to print out all known subcommands with descriptions. 32 | 33 | === 2.5.0 / 2019-10-08 34 | 35 | * 5 minor enhancements: 36 | 37 | * Extended _context to try tags first. 38 | * Extended _flattened_contexts to try tags first. 39 | * Extended reschedule subcommand to create missing release/triage tasks where needed. 40 | * Refactored _context and _flattened_contexts methods. 41 | * Refactored neww project sub-command to new_or_repair_project. 42 | 43 | * 1 bug fix: 44 | 45 | * Fixed to work with omnifocus 3 (single context -> multiple tags) 46 | 47 | === 2.4.0 / 2019-01-02 48 | 49 | * 1 minor enhancement: 50 | 51 | * Switched to rb-scpt for OSX compatibility. (current version is noisy, hopefully this will be fixed soon) 52 | 53 | * 3 bug fixes: 54 | 55 | * Fixed marking tasks in/complete with latest version of omnifocus. 56 | * More fixes for defer_date changes in OF applescript dictionary. 57 | * Project field is defer_date (and alias doesn't seem to work) 58 | 59 | === 2.3.0 / 2015-12-15 60 | 61 | * 1 minor enhancement: 62 | 63 | * Added support for hash parameter to make. (andrewguy9) 64 | 65 | === 2.2.0 / 2015-02-02 66 | 67 | * 1 minor enhancement: 68 | 69 | * Customizable nerd folder via OF_FOLDER. (maxim) 70 | 71 | === 2.1.6 / 2015-01-09 72 | 73 | * 1 minor enhancement: 74 | 75 | * Review command filters out dropped projects. 76 | 77 | === 2.1.5 / 2014-08-08 78 | 79 | * 1 bug fix: 80 | 81 | * Fixed 'of reschedule' to gracefully deal with tasks w/o repeat. 82 | 83 | === 2.1.4 / 2014-05-15 84 | 85 | * 1 minor enhancement: 86 | 87 | * Popped mechanize dependency up to 2.x. 88 | 89 | === 2.1.3 / 2014-01-15 90 | 91 | * 1 bug fix: 92 | 93 | * Removed 2.0 warnings. (moujp) 94 | 95 | === 2.1.2 / 2013-02-22 96 | 97 | * 1 bug fix: 98 | 99 | * Fix task population for projects with whitespace in their names (thank you vesen) 100 | 101 | === 2.1.1 / 2012-06-14 102 | 103 | * 1 minor enhancement: 104 | 105 | * Make “of sync” aware of nested tasks (dohzya) 106 | 107 | === 2.1.0 / 2012-05-17 108 | 109 | * 4 minor enhancements: 110 | 111 | * Added "reschedule" command to evenly distribute review & release chaos. 112 | * Added Project#review_interval=. 113 | * Added Task#project. 114 | * of fix_review_dates now fully re-distributes projects to be evenly spread out. 115 | 116 | === 2.0.0 / 2012-02-02 117 | 118 | * 2 minor enhancements: 119 | 120 | * Added bin/of 121 | * Added deprecation notices to bin/omnifocus* 122 | 123 | * 1 bug fix: 124 | 125 | * _plugins should skip classes explicitly. 126 | 127 | === 1.5.2 / 2011-08-25 128 | 129 | * 1 bug fix: 130 | 131 | * Removed filtering on active project to avoid sync creating duplicates everywhere 132 | 133 | === 1.5.1 / 2011-08-12 134 | 135 | * 2 bug fixes: 136 | 137 | * Roll back mechanize from 2.x to 1.x... buggy mechanize is buggy 138 | * refactored and fixed existing task scanning 139 | 140 | === 1.5.0 / 2011-08-11 141 | 142 | * 2 minor enhancements: 143 | 144 | * Rewrote all the task accesses to be a single fetch. 145 | * Updated mechanize and rb-appscript deps. 146 | 147 | * 2 bug fixes: 148 | 149 | * Fixed a bug with rb-appscript caused by ruby 1.9. :( (turadg) 150 | * Project names with '.' in them were being ignored by my regexp 151 | 152 | === 1.4.0 / 2011-07-20 153 | 154 | * 1 minor enhancement: 155 | 156 | * Added plugin filtering from the commandline (eg omnifocus github). 157 | * Requires all plugins to provide a PREFIX constant. 158 | 159 | === 1.3.1 / 2011-02-18 160 | 161 | * 2 minor enhancements: 162 | 163 | * Refactored. Extracted into walk_queue_deep 164 | * Updated to mechanize 1.0.x! No more WWW! YAY! 165 | 166 | === 1.3.0 / 2009-10-10 167 | 168 | * 1 minor enhancement: 169 | 170 | * Added ability to re-open tasks. (aja) 171 | 172 | === 1.2.1 / 2009-08-14 173 | 174 | * 1 bug fix: 175 | 176 | * Rakefile should have declared pluggable! 177 | 178 | === 1.2.0 / 2009-07-30 179 | 180 | * 3 minor enhancements: 181 | 182 | * Added omnifocus_new so I can create tasks via shell. yay! 183 | * Cleaned up a fair amount of appscript calls, mostly negated by changes for OF's model. 184 | * Figured out the necessary appscript to navigate OmniFocus' byzantine hierarchical object model. 185 | * adding methods: all_{tasks,folders,projects} 186 | 187 | === 1.1.0 / 2009-07-28 188 | 189 | * 3 minor enhancements: 190 | 191 | * Changed bts_id to match /SYSTEM(-project)?#id/ to work with PITA BTSen. 192 | * Debug mode prints pseudo-actions as well as dumping its knowledge db. 193 | * run method now extends with plugins before hitting the backend. 194 | 195 | * 1 bug fix: 196 | 197 | * Fixed load_plugins from loading both gem and local plugins. 198 | 199 | === 1.0.0 / 2009-07-26 200 | 201 | * 1 major enhancement 202 | 203 | * Birthday! 204 | 205 | -------------------------------------------------------------------------------- /lib/omnifocus.rb: -------------------------------------------------------------------------------- 1 | old_w, $-w = $-w, nil 2 | require 'rb-scpt' 3 | $-w = old_w 4 | require "yaml" 5 | 6 | NERD_FOLDER = ENV["OF_FOLDER"] || "nerd" 7 | 8 | include Appscript 9 | 10 | ## 11 | # Synchronizes bug tracking systems to omnifocus. 12 | # 13 | # Some definitions: 14 | # 15 | # bts: bug tracking system 16 | # SYSTEM: a tag uniquely identifying the bts 17 | # bts_id: a string uniquely identifying a task: SYSTEM(-projectname)?#id 18 | 19 | class OmniFocus 20 | VERSION = "2.7.1" 21 | 22 | module Pluggable 23 | attr_accessor :description, :current_desc 24 | 25 | def self.extended obj 26 | obj.current_desc = nil 27 | obj.description = {} 28 | end 29 | 30 | def method_added name 31 | return unless name =~ /^cmd_/ 32 | description[name] = current_desc || "UNKNOWN" 33 | self.current_desc = nil 34 | end 35 | 36 | def desc str 37 | self.current_desc = str 38 | end 39 | 40 | ## 41 | # Load any file matching "omnifocus/*.rb" 42 | 43 | def _load_plugins filter = ARGV.shift 44 | @__loaded__ ||= 45 | begin 46 | loaded = {} 47 | Gem.find_files("omnifocus/*.rb").each do |path| 48 | name = File.basename path 49 | next if loaded[name] 50 | next unless path.index filter if filter 51 | require path 52 | loaded[name] = true 53 | end 54 | true 55 | end 56 | end 57 | 58 | ## 59 | # Return all the plugin modules that have been loaded. 60 | 61 | def _plugins 62 | _load_plugins 63 | 64 | constants. 65 | reject { |mod| mod =~ /^[A-Z_]+$/ }. 66 | map { |mod| const_get mod }. 67 | reject { |mod| Class === mod }. 68 | select { |mod| mod.const_defined? :PREFIX } 69 | end 70 | end 71 | 72 | extend Pluggable 73 | 74 | ## 75 | # bug_db = { 76 | # project => { 77 | # bts_id => [task_name, url, due, defer], # only on BTS = add to OF 78 | # bts_id => {field=>value, ...}, # only on BTS = OF and maybe BTS. Update fields 79 | # bts_id => true, # both BTS and OF = don't touch 80 | # } 81 | # } 82 | 83 | attr_reader :bug_db 84 | 85 | ## 86 | # existing = { 87 | # bts_id => project, 88 | # } 89 | 90 | attr_reader :existing 91 | 92 | attr_accessor :debug 93 | 94 | attr_accessor :config 95 | 96 | def initialize 97 | @bug_db = Hash.new { |h,k| h[k] = {} } 98 | @existing = {} 99 | self.debug = false 100 | self.config = load_or_create_config 101 | end 102 | 103 | def load_or_create_config 104 | path = File.expand_path "~/.omnifocus.yml" 105 | 106 | unless File.exist? path then 107 | config = { :exclude => %w[proj_a proj_b proj_c] } 108 | 109 | File.open path, "w" do |f| 110 | YAML.dump config, f 111 | end 112 | 113 | abort "Created default config in #{path}. Go fill it out." 114 | end 115 | 116 | YAML.load File.read path 117 | end 118 | 119 | def excluded_projects 120 | config[:exclude] 121 | end 122 | 123 | def omnifocus 124 | @omnifocus ||= Appscript.app('OmniFocus').default_document 125 | end 126 | 127 | def all_subtasks task, filter = nil # TOOD: retire 128 | if filter then 129 | [task] + task.tasks[filter].get.flatten.map{ |t| all_subtasks t, filter } 130 | else 131 | [task] + task.tasks.get.flatten.map{ |t| all_subtasks t } 132 | end 133 | end 134 | 135 | def _wrap klass, things 136 | things.map { |thing| klass.new self.omnifocus, thing } 137 | end 138 | 139 | def all_tasks 140 | _wrap Task, self.omnifocus.flattened_tasks.get 141 | end 142 | 143 | def all_active_tasks 144 | _wrap Task, self.omnifocus.flattened_tasks[q_not_completed].get 145 | end 146 | 147 | ## 148 | # Utility shortcut to make a new thing with a name via appscript. 149 | 150 | def make target, type, name, extra = {} 151 | target.make :new => type, :with_properties => { :name => name }.merge(extra) 152 | end 153 | 154 | ## 155 | # Get all projects under the nerd folder 156 | 157 | def nerd_projects 158 | return @nerd_projects if defined? @nerd_projects 159 | 160 | unless self.omnifocus.folders.name.get.include? NERD_FOLDER then 161 | make self.omnifocus, :folder, NERD_FOLDER 162 | end 163 | 164 | @nerd_projects = nerd_folder 165 | end 166 | 167 | ## 168 | # Walk all omnifocus tasks under the nerd folder and add them to the 169 | # bug_db hash if they match a bts_id. 170 | 171 | def prepopulate_existing_tasks 172 | prefixen = self.class._plugins.map { |klass| klass::PREFIX rescue nil } 173 | of_tasks = nil 174 | 175 | prefix_re = /^(#{Regexp.union prefixen}(?:-[\p{L}\d_\s.-]+)?\#\d+)/ 176 | 177 | if prefixen.all? then 178 | of_tasks = all_tasks.find_all { |task| 179 | task.name =~ prefix_re 180 | } 181 | else 182 | warn "WA"+"RN: Older plugins installed. Falling back to The Old Ways" 183 | 184 | of_tasks = all_tasks.find_all { |task| 185 | task.name =~ /^([A-Z]+(?:-[\w-]+)?\#\d+)/ 186 | } 187 | end 188 | 189 | of_tasks.each do |of_task| 190 | ticket_id = of_task.name[prefix_re, 1] 191 | project = of_task.project.name 192 | 193 | if existing.key? ticket_id 194 | warn "Duplicate task! #{ticket_id}" 195 | warn " deleting: #{of_task.id_.get}" 196 | self.omnifocus.flattened_projects.tasks.delete of_task 197 | end 198 | 199 | existing[ticket_id] = project 200 | bug_db[project][ticket_id] = false 201 | end 202 | end 203 | 204 | ## 205 | # Create any projects in bug_db that aren't in omnifocus, add under 206 | # the nerd folder. 207 | 208 | def create_missing_projects 209 | (bug_db.keys - nerd_projects.projects.name.get).each do |name| 210 | warn "creating project #{name}" 211 | next if debug 212 | make nerd_projects, :project, name 213 | end 214 | end 215 | 216 | ## 217 | # Synchronize the contents of bug_db with omnifocus, creating 218 | # missing tasks and marking tasks completed as needed. See the doco 219 | # for +bug_db+ for more info on how you should populate it. 220 | 221 | def update_tasks 222 | bug_db.each do |name, tickets| 223 | project = nerd_projects.projects[name] 224 | 225 | tickets.each do |bts_id, value| 226 | case value 227 | when true 228 | project.tasks[q_nameish(bts_id)].get.each do |task| 229 | if task.completed.get 230 | puts "Re-opening #{name} # #{bts_id}" 231 | next if debug 232 | 233 | begin 234 | task.completed.set false 235 | rescue 236 | task.mark_incomplete 237 | end 238 | end 239 | end 240 | when false 241 | project.tasks[q_nameish(bts_id)].get.each do |task| 242 | next if task.completed.get 243 | puts "Removing #{name} # #{bts_id}" 244 | next if debug 245 | 246 | begin 247 | task.completed.set true 248 | rescue 249 | task.mark_complete 250 | end 251 | end 252 | when Array 253 | puts "Adding #{name} # #{bts_id}" 254 | next if debug 255 | title, url = *value 256 | make project, :task, title, :note => url 257 | when Hash 258 | puts "Adding Detail #{name} # #{bts_id}" 259 | next if debug 260 | properties = value.clone 261 | title = properties.delete(:title) 262 | make project, :task, title, properties 263 | else 264 | abort "ERROR: Unknown value in bug_db #{bts_id}: #{value.inspect}" 265 | end 266 | end 267 | end 268 | end 269 | 270 | def weekly(n=1) 271 | { 272 | :unit => :week, 273 | :steps => n, 274 | :fixed_ => true, 275 | } 276 | end 277 | 278 | def add_hours t, n 279 | t + (n * 3600).to_i 280 | end 281 | 282 | def hour n 283 | t = Time.now 284 | midnight = Time.gm t.year, t.month, t.day 285 | midnight -= t.utc_offset 286 | midnight + (n * 3600).to_i 287 | end 288 | 289 | def top hash, n=10 290 | hash.sort_by { |k,v| [-v, k] }.first(n).map { |k,v| 291 | "%4d %s" % [v,k[0,21]] 292 | } 293 | end 294 | 295 | def distribute count, weeks 296 | count = count.to_f 297 | d = 5 * weeks 298 | hits = (1..d).step(d/count).map(&:round) 299 | (1..d).map { |n| hits.include?(n) ? weeks : nil } 300 | end 301 | 302 | def calculate_schedule projs 303 | all = [ 304 | distribute(projs[1].size, 1), 305 | distribute(projs[2].size, 2), 306 | distribute(projs[3].size, 3), 307 | distribute(projs[5].size, 5), 308 | distribute(projs[7].size, 7), 309 | ] 310 | 311 | # [[1, 1, 1, 1, 1], 312 | # [2, 2, 2, 2, 2, nil, 2, 2, 2, 2], 313 | # [3, nil, 3, 3, nil, 3, 3, nil, 3, 3, nil, 3, 3, nil, 3], 314 | # ... 315 | 316 | all.map! { |a| 317 | a.concat [nil] * (35-a.size) 318 | a.each_slice(5).to_a 319 | } 320 | 321 | # [[[1, 1, 1, 1, 1], [nil, nil, nil, nil, nil], ... 322 | # [[2, 2, 2, 2, 2], [nil, 2, 2, 2, 2], ... 323 | # [[3, nil, 3, 3, nil], [3, 3, nil, 3, 3], ... 324 | # ... 325 | 326 | weeks = all.transpose.map { |a, *r| 327 | a.zip(*r).map(&:compact) 328 | } 329 | 330 | # [[[1, 2, 3, 5, 7], [1, 2], [1, 2, 3], [1, 2, 3], [1, 2, 5]], 331 | # [[3], [2, 3, 7], [2], [2, 3, 5], [2, 3]], 332 | # ... 333 | 334 | weeks 335 | end 336 | 337 | def aggregate_releases 338 | rels = context "Releasing" 339 | tris = context "Triaging" 340 | 341 | tasks = Hash.new { |h,k| h[k] = [] } # name => tasks 342 | projs = Hash.new { |h,k| h[k] = [] } # step => projs 343 | 344 | rels.tasks.each do |task| 345 | proj = task.project 346 | tasks[proj.name] << task 347 | projs[proj.review_interval[:steps]] << proj 348 | end 349 | 350 | tris.tasks.each do |task| 351 | proj = task.project 352 | tasks[proj.name] << task 353 | end 354 | 355 | projs.each do |k, a| 356 | # helps stabilize and prevent random shuffling 357 | projs[k] = a.uniq_by { |p| p.name }.sort_by { |p| 358 | tasks[p.name].map(&:name).min 359 | } 360 | end 361 | 362 | return rels, tasks, projs 363 | end 364 | 365 | def new_or_repair_project name, n_weeks = 1 366 | warn "project #{name}" 367 | 368 | rep = weekly n_weeks 369 | start_date = hour 0 370 | rel_due_date = hour 16 371 | tri_due_date = hour 16.5 372 | props = { 373 | :repetition => rep, 374 | :defer_date => start_date, 375 | :estimated_minutes => 10, 376 | } 377 | 378 | min30 = 30 * 60 379 | 380 | rel_tag = context("Releasing").thing # TODO: remove? should have the methods 381 | tri_tag = context("Triaging").thing 382 | 383 | proj = nerd_projects.projects[name].get rescue nil 384 | 385 | unless proj then 386 | warn "creating #{name} project" 387 | proj = make nerd_projects, :project, name, :review_interval => rep 388 | end 389 | 390 | rel_task = proj.tasks[q_release].first.get rescue nil 391 | tri_task = proj.tasks[q_triage].first.get rescue nil 392 | 393 | if rel_task || tri_task then # repair 394 | new_task_from proj, tri_task, "Release #{name}", rel_tag, -min30 unless rel_task 395 | new_task_from proj, rel_task, "Triage #{name}", tri_tag, +min30 unless tri_task 396 | else 397 | make proj, :task, "Release #{name}", props.merge(:due_date => rel_due_date, 398 | :primary_tag => rel_tag) 399 | make proj, :task, "Triage #{name}", props.merge(:due_date => tri_due_date, 400 | :primary_tag => tri_tag) 401 | end 402 | end 403 | 404 | def new_task_from proj, task, name, tag, offset 405 | warn " + #{name} task" 406 | 407 | props = { 408 | :estimated_minutes => 10, 409 | :due_date => task.due_date.get + offset, 410 | :defer_date => task.defer_date.get, 411 | :repetition => task.repetition.get, 412 | :primary_tag => tag, 413 | } 414 | 415 | make proj, :task, name, props 416 | end 417 | 418 | def fix_project_review_intervals rels, skip 419 | rels.tasks.each do |task| 420 | fix_project_review_interval task unless skip 421 | end 422 | end 423 | 424 | def fix_project_review_interval task 425 | proj = task.project 426 | 427 | t_ri = task.repetition[:steps] 428 | p_ri = proj.review_interval[:steps] 429 | 430 | if t_ri != p_ri then 431 | warn "Fixing #{task.name} to #{p_ri} weeks" 432 | 433 | rep = { 434 | :recurrence => "FREQ=WEEKLY;INTERVAL=#{p_ri}", 435 | :repetition_method => :fixed_repetition, 436 | } 437 | 438 | task.thing.repetition_rule.set :to => rep 439 | end 440 | rescue => e 441 | warn "ERROR: skipping '#{task.name}' in '#{proj.name}': #{e.message}" 442 | end 443 | 444 | def fix_release_task_names projs, tasks, skip 445 | projs.each do |step, projects| 446 | projects.each do |project| 447 | tasks[project.name].each do |task| 448 | if task.name =~ /^(\d+(\.\d+)?)/ then 449 | if $1.to_i != step then 450 | new_name = task.name.sub(/^(\d+(\.\d+)?)/, step.to_s) 451 | puts "renaming to #{new_name}" 452 | task.thing.name.set new_name unless skip 453 | end 454 | end 455 | end 456 | end 457 | end 458 | end 459 | 460 | def fix_release_task_schedule projs, tasks, skip 461 | weeks = calculate_schedule projs 462 | 463 | now = hour 0 464 | mon = if now.wday == 1 then 465 | now 466 | else 467 | now - 86400 * (now.wday-1) 468 | end 469 | 470 | weeks.each_with_index do |week, wi| 471 | week.each_with_index do |day, di| 472 | next if day.empty? 473 | delta = wi*7 + di 474 | date = mon + 86400 * delta 475 | 476 | day.each do |rank| 477 | p = projs[rank].shift 478 | t = tasks[p.name] 479 | 480 | t.each do |task| 481 | if task.start_date != date then 482 | due_date1 = add_hours date, 16 483 | due_date2 = add_hours date, 16.5 484 | 485 | warn "Fixing #{p.name} to #{date.strftime "%Y-%m-%d"}" 486 | 487 | case task.name 488 | when /Release/ then 489 | warn " Fixing #{p.name} release to #{date.strftime "%Y-%m-%d"}" 490 | next if skip 491 | task.start_date = date 492 | task.due_date = due_date1 493 | when /Triage/ then 494 | warn " Fixing #{p.name} triage to #{date.strftime "%Y-%m-%d"}" 495 | next if skip 496 | task.start_date = date 497 | task.due_date = due_date2 498 | else 499 | warn "Unknown task name: #{task.name}" 500 | end 501 | end 502 | end 503 | 504 | rel = p.tasks.find { |t| t.name.start_with? "Release" } 505 | 506 | if rel && p.next_review_date.to_date != rel.due_date.to_date then 507 | pp NEEDS_FIXING:[p.name, 508 | p.next_review_date.to_date.to_s, 509 | rel.due_date.to_date.to_s] 510 | 511 | next if skip 512 | 513 | p.next_review_date = rel.due_date.to_date 514 | end 515 | end 516 | end 517 | end 518 | end 519 | 520 | def fix_missing_tasks skip 521 | nerd_projects.projects.get.each do |proj| 522 | name = proj.name.get 523 | 524 | rel = proj.tasks[q_release].first.get rescue nil 525 | tri = proj.tasks[q_triage].first.get rescue nil 526 | 527 | case [!!rel, !!tri] 528 | when [true, true] then 529 | # do nothing 530 | when [false, false] then 531 | # do nothing? 532 | when [true, false] then # create triage 533 | warn " Repairing triage for #{name}" 534 | new_or_repair_project name unless skip 535 | when [false, true] then # create release 536 | warn " Repairing release for #{name}" 537 | new_or_repair_project name unless skip 538 | end 539 | end 540 | end 541 | 542 | def print_aggregate_report collection, long = false 543 | h, p = self.aggregate collection 544 | 545 | self.print_occurrence_table h, p 546 | 547 | puts 548 | 549 | self.print_details h, long 550 | end 551 | 552 | def aggregate collection 553 | h = Hash.new { |h1,k1| h1[k1] = Hash.new { |h2,k2| h2[k2] = [] } } 554 | p = Hash.new 0 555 | 556 | collection.each do |thing| 557 | name = thing.name 558 | ri = case thing 559 | when Project then 560 | thing.review_interval 561 | when Task then 562 | thing.repetition 563 | else 564 | raise "unknown type: #{thing.class}" 565 | end 566 | date = case thing 567 | when Project then 568 | thing.next_review_date 569 | when Task then 570 | thing.due_date 571 | else 572 | raise "unknown type: #{thing.class}" 573 | end 574 | 575 | date = if date then 576 | date.strftime("%Y-%m-%d %a") 577 | else 578 | "unscheduled" 579 | end 580 | 581 | time = ri ? "#{ri[:steps]}#{ri[:unit].to_s[0,1]}" : "NR" 582 | 583 | p[time] += 1 584 | h[date][time] << name 585 | end 586 | 587 | return h, p 588 | end 589 | 590 | def print_occurrence_table h, p 591 | p = p.sort_by { |priority, _| 592 | case priority 593 | when /(\d+)(.)/ then 594 | n, u = $1.to_i, $2 595 | n *= {"d" => 1, "w" => 7, "m" => 28, "y" => 365}[u] 596 | when "NR" then 597 | 1/0.0 598 | else 599 | warn "unparsed: #{priority.inspect}" 600 | 0 601 | end 602 | } 603 | 604 | units = p.map(&:first) 605 | 606 | total = 0 607 | hdr = "%14s%s %3s " + "%2s " * units.size 608 | fmt = "%14s: %3d " + "%2s " * units.size 609 | puts hdr % ["date", "\\", "tot", *units] 610 | h.sort.each do |date, plan| 611 | counts = units.map { |n| plan[n].size } 612 | subtot = counts.inject(&:+) 613 | total += subtot 614 | puts fmt % [date, subtot, *counts] 615 | end 616 | puts hdr % ["total", ":", total, *p.map(&:last)] 617 | end 618 | 619 | def print_details h, long = false 620 | h.sort.each do |date, plan| 621 | puts date 622 | plan.sort.each do |period, things| 623 | next if things.empty? 624 | if long then 625 | things.sort.each do |thing| 626 | puts " #{period}: #{thing}" 627 | end 628 | else 629 | puts " #{period}: #{things.sort.join ', '}" 630 | end 631 | end 632 | end 633 | end 634 | 635 | def all_projects 636 | _wrap Project, self.omnifocus.flattened_projects.get 637 | end 638 | 639 | def nerd_folder 640 | self.omnifocus.folders[NERD_FOLDER] 641 | end 642 | 643 | def live_projects 644 | _wrap Project, self.omnifocus.flattened_projects[q_non_dropped_project].get 645 | end 646 | 647 | # TODO: globally rename context to tags 648 | def _flattened_contexts 649 | self.omnifocus.flattened_tags.get 650 | end 651 | 652 | def _context name 653 | self.omnifocus.flattened_tags[name].get 654 | end 655 | 656 | def all_contexts 657 | _wrap Context, _flattened_contexts 658 | end 659 | 660 | def context name 661 | context = _context name 662 | Context.new self.omnifocus, context if context 663 | end 664 | 665 | def project name 666 | project = self.omnifocus.flattened_projects[name].get 667 | Project.new self.omnifocus, project if project 668 | end 669 | 670 | def active_projects 671 | _wrap Project, self.omnifocus.flattened_projects[q_active_project].get 672 | end 673 | 674 | def active_nerd_projects 675 | _wrap Project, nerd_folder.flattened_projects[q_active_project].get 676 | end 677 | 678 | def window 679 | self.omnifocus.document_windows[1] 680 | end 681 | 682 | def selected_tasks 683 | _wrap Task, window.content.selected_trees[q_regular_tasks].value.get 684 | end 685 | 686 | def no_autosave_during 687 | self.omnifocus.will_autosave.set false 688 | yield 689 | ensure 690 | self.omnifocus.will_autosave.set true 691 | end 692 | 693 | class Thingy 694 | attr_accessor :omnifocus, :thing 695 | def initialize of, thing 696 | @omnifocus = of 697 | @thing = thing 698 | end 699 | 700 | def method_missing m, *a 701 | warn "%s#method_missing(%s) from %s" % [self.class.name, [m,*a].inspect[1..-2], caller.first] 702 | thing.send m, *a 703 | end 704 | 705 | def name 706 | thing.name.get 707 | end 708 | 709 | def id 710 | thing.id_.get 711 | end 712 | 713 | def inspect 714 | "#{self.class}[#{self.id}]" 715 | end 716 | 717 | def _wrap klass, things 718 | things.map { |thing| klass.new self.omnifocus, thing } 719 | end 720 | end 721 | 722 | class Project < Thingy 723 | def unscheduled_tasks 724 | _wrap Task, thing.tasks[q_not_completed.and(q_unscheduled)].get 725 | end 726 | 727 | def scheduled_tasks 728 | _wrap Task, thing.tasks[q_not_completed.and(q_scheduled)].get 729 | end 730 | 731 | def review_interval 732 | thing.review_interval.get 733 | end 734 | 735 | def review_interval= h 736 | thing.review_interval.set :to => h 737 | end 738 | 739 | def next_review_date 740 | thing.next_review_date.get 741 | end 742 | 743 | def next_review_date= date 744 | thing.next_review_date.set to: date 745 | end 746 | 747 | def tasks 748 | _wrap Task, thing.tasks[q_not_completed].get 749 | end 750 | 751 | def flattened_tasks 752 | _wrap Task, thing.flattened_tasks[q_not_completed].get 753 | end 754 | end 755 | 756 | class Task < Thingy 757 | def project 758 | Project.new self.omnifocus, thing.containing_project.get 759 | end 760 | 761 | def start_date= t 762 | thing.start_date.set t 763 | rescue 764 | thing.defer_date.set t 765 | end 766 | 767 | def start_date 768 | thing.start_date.get.nilify 769 | rescue 770 | thing.defer_date.get.nilify 771 | end 772 | 773 | def due_date= t 774 | thing.due_date.set t 775 | end 776 | 777 | def due_date 778 | thing.due_date.get.nilify 779 | end 780 | 781 | def repetition 782 | thing.repetition.get.nilify 783 | end 784 | 785 | def completed 786 | thing.completed.get.nilify 787 | end 788 | end 789 | 790 | class Context < Thingy 791 | def tasks 792 | _wrap Task, thing.tasks[q_not_completed].get 793 | end 794 | end 795 | 796 | module Queries 797 | def its # :nodoc: 798 | Appscript.its 799 | end 800 | 801 | def q_active_project 802 | its.status.eq :active_status 803 | end 804 | 805 | def q_named name 806 | its.name.eq name 807 | end 808 | 809 | def q_nameish name # TODO: better name 810 | its.name.begins_with name 811 | end 812 | 813 | def q_non_dropped_project 814 | its.status.eq(:dropped_status).not 815 | end 816 | 817 | def q_non_repeating 818 | its.repetition.eq :missing_value 819 | end 820 | 821 | def q_not_completed 822 | its.completed.eq false 823 | end 824 | 825 | def q_active_unique 826 | q_not_completed.and q_non_repeating 827 | end 828 | 829 | def q_release 830 | q_not_completed.and q_named "Release" 831 | end 832 | 833 | def q_triage 834 | q_not_completed.and q_named "Triage" 835 | end 836 | 837 | def q_regular_tasks 838 | its.value.class_.eq(:item).not 839 | .and its.value.class_.eq(:folder).not 840 | end 841 | 842 | def q_scheduled 843 | q_unscheduled.not 844 | end 845 | 846 | def q_unscheduled 847 | its.due_date.eq(:missing_value) 848 | end 849 | end 850 | 851 | include Queries 852 | Thingy.send :include, Queries 853 | end 854 | 855 | class Object 856 | def nilify 857 | self == :missing_value ? nil : self 858 | end 859 | end 860 | 861 | class Array 862 | def merge! o 863 | o.each_with_index do |x, i| 864 | self[i] << x 865 | end 866 | end 867 | end 868 | 869 | module Enumerable 870 | def uniq_by 871 | r, s = [], {} 872 | each do |e| 873 | v = yield(e) 874 | next if s[v] 875 | r << e 876 | s[v] = true 877 | end 878 | r 879 | end 880 | end unless [].respond_to?(:uniq_by) 881 | --------------------------------------------------------------------------------