├── .gitignore ├── README.md └── nvremind.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.taskpaper 2 | *.sublime-* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nvremind 2 | ======== 3 | 4 | A scheduled background task to scan nvALT notes for @remind() tags and trigger notifications based on dates. It's grown to work with any folder of text or Markdown files, TaskPaper files and [Day One](http://dayoneapp.com/) entries. 5 | 6 | Yes, it's pronounced "never mind." 7 | 8 | ## Synopsis 9 | 10 | 11 | This tool will search for @remind() tags in the specified notes folder. 12 | 13 | It searches ".md", ".txt", ".ft", ".taskpaper" and Day One entry files. 14 | 15 | It expects an ISO 8601 format date (2013-05-01) with optional 24-hour time (2013-05-01 15:30). Put `@remind(2013-05-01 06:00)` anywhere in a note to have a reminder go off on the first run following that time. 16 | 17 | This script is intended to be run on a schedule. Check for reminders every 30-60 minutes using cron or launchd. 18 | 19 | By default the script will replace found @remind tags with @reminded tags containing the date the reminder was sent. Use the `-z` option to prevent any changes from being made to your file, although this can result in reminders being sent multiple times. You'd need to manually update the files after handling the reminder. 20 | 21 | The script also preserves the original modification time of the file. To instead update the modification time to the date when it was matched, use `--no-preserve-time`. 22 | 23 | A document can contain multiple reminders with different dates. The script will check all of them and only modify the ones that are triggered. Future reminders in the same document will still be active after the run. 24 | 25 | Reminders on their own line with no other text will send the entire note as the reminder with the filename being the subject line. If a @remind tag is on a line with other text, only that line will be used as the title and the content. 26 | 27 | If you include a double-quoted string at the end of the remind tag value, it will override the default reminder title. `@remind(2013-05-24 "This is the override")` would create a reminder called "This is the override", ignoring any other text on the line or the name of the file. Additional text on the line or the entire note (in the case of a @remind tag on its own line) will still be included in the note, if the notification method supports that. 28 | 29 | Use the `-n` option to send Mountain Lion notifications instead of terminal output. Clicking a notification will open the related file in nvALT. Notifications require that the 'terminal-notifier' gem be installed (falls back to [growlnotify](http://growl.info/downloads#generaldownloads) if it exists): 30 | 31 | sudo gem install 'terminal-notifier' 32 | 33 | Use the `-e ADDRESS` option to send an email with the title of the note as the subject and the contents of the note as the body to the specified address. Separate multiple emails with commas. The contents of the note will be rendered with MultiMarkdown, which needs to exist at `/usr/local/bin/multimarkdown`. 34 | 35 | If the file to be emailed has a ".taskpaper" extension, it will be converted to Markdown for formatting before processing with MultiMarkdown. [[Links]] and @tags will be linked and can be clicked from Mail.app. 36 | 37 | The `-m` option will add a reminder to Reminders.app in Mountain Lion, due immediately, that will show up on iCloud-synced iOS devices as well. Specify a list (default "Reminders" or the first list available) using `--reminder-list "List name"`. 38 | 39 | The `-f FOLDER` option allows you to specify a directory where a file named with the reminder title will be saved. The note for the reminder will be the file contents. This is useful, for example, with IFTTT.com. You can save a file to a public Dropbox folder, have IFTTT notice it and take any number of actions on it. 40 | 41 | ## Examples 42 | 43 | 44 | nvremind.rb ~/Dropbox/nvALT 45 | 46 | Other examples: 47 | 48 | nvremind.rb ~/Dropbox/nvALT 49 | nvremind.rb -n ~/Dropbox/nvALT 50 | nvremind.rb -e me@gmail.com ~/Dropbox/nvALT 51 | nvremind.rb -mn -e me@gmail.com ~/Dropbox/nvALT 52 | nvremind.rb -f ~/Dropbox/Public/ifttt ~/Dropbox/nvALT 53 | 54 | Testing/debugging example: 55 | 56 | nvremind.rb -Vz ~/Dropbox/nvALT 57 | 58 | ## Usage 59 | 60 | 61 | nvremind.rb [options] notes_folder 62 | 63 | For help use `nvremind.rb -h`. For even more help, use `nvremind.rb -H`. 64 | 65 | 66 | ## Options 67 | 68 | 69 | -h, --help Displays help message 70 | -H No, really help 71 | -v, --version Display the version, then exit 72 | -V, --verbose Verbose output 73 | -z, --no-replace Don't updated @remind() tags with @reminded() after notification 74 | -n, --notify Use terminal-notifier to post Mountain Lion notifications 75 | -m, --reminders Add an item to the Reminders list in Reminders.app (due immediately) 76 | --reminder-list LIST List to use in Reminders.app (default "Reminders") 77 | -f folder Save a file to FOLDER named with the task title, note as contents 78 | -e EMAIL[,EMAIL], --email EMAIL[,EMAIL] Send an email with note contents to the specified address 79 | 80 | ## Changelog 81 | 82 | ### 1.0.6 83 | 84 | * Fixed UTF-8 and Ruby 2.0 issues 85 | * Added File notification method for use with IFTTT, etc. 86 | 87 | ### 1.0 88 | 89 | * Works with any prefix, not just "@". To allow use in apps like Day One that have different uses for @tags. Any character will work (!remind, $remind), there just has to be something immediately before "remind" 90 | * Works with multiple paths, just separate with commas (no space) 91 | * Works with Day One, just pass it the path to the entries folder within your Journal 92 | * In Day One, if a reminder is on its own line and has no override title, the first 30 characters of the first line of the entry will be used as the reminder title. 93 | 94 | This is necessary because Day One entries don't have titles and the filenames are just UUID strings. 95 | * If the tag is inside of quotes or brackets, those will be stripped from the reminder title 96 | * If you include a double-quoted string at the end of the remind tag value, it will override the default reminder title. @reminded(2013-06-06 09:52 "This is the override") would create a reminder called "This is the override", ignoring any other text on the line or the name of the file. 97 | 98 | Additional text on the line or the entire note (in the case of a @remind tag on its own line) will still be included in the note, if the notification method supports that. 99 | * You can specify a list for Reminders.app (default "Reminders" or the first list available) using '--reminder-list "List name"' 100 | * Won't schedule the reminder if the same line contains @done or @canceled (also recognizes @cancelled) 101 | * Remove leading -, * or + so you can use it within Markdown lists and still get nicely-formatted reminder messages 102 | * Don't include line number in file link (that just breaks it for 90% of the population) 103 | * Use a remind date 1 minute in the future to allow iOS notifications when using Reminders.app 104 | * Allows multiple target folders in the last argument, separated by commas (no spaces) 105 | 106 | ### 0.2.2 107 | 108 | - Allow title override with double quoted string at end of tag 109 | - Allow specification of an alternate Reminders.app list (`--reminder-list LIST`) 110 | - Remove list markers from captured line notes 111 | - Remove line number from file link 112 | 113 | ### 0.2.1 114 | 115 | - Add FoldingText extension 116 | - Handle multiple reminders per file 117 | 118 | ### 0.2.0 119 | 120 | - Reminders.app integration 121 | 122 | ## Author 123 | 124 | 125 | Brett Terpstra 126 | 127 | 128 | ## Copyright 129 | 130 | Copyright (c) 2013 Brett Terpstra. Licensed under the MIT License: 131 | 132 | -------------------------------------------------------------------------------- /nvremind.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # == Synopsis 3 | # This tool will search for @remind() tags in the specified notes folder. 4 | # 5 | # It searches ".md", ".txt", ".ft" and ".taskpaper" files. It also works with Day One journal folders. 6 | # 7 | # It expects an ISO 8601 format date (2013-05-01) with optional 24-hour time (2013-05-01 15:30). 8 | # Put `@remind(2013-05-01 06:00)` anywhere in a note to have a reminder go off on the first run after that time. 9 | # 10 | # 11 | # Reminders on their own line with no other text will send the entire note as the reminder with the filename being the subject line. If a @reminder tag is on a line with other text, only that line will be used as the title and the content. 12 | # 13 | # If you include a double-quoted string at the end of the remind tag value, it will override the default reminder title. `@remind(2013-05-24 "This is the override")` would create a reminder called "This is the override", ignoring any other text on the line or the name of the file. Additional text on the line or the entire note (in the case of a @remind tag on its own line) will still be included in the note, if the notification method supports that. 14 | # 15 | # This script is intended to be run on a schedule. Check for reminders every 30-60 minutes using cron or launchd. 16 | # 17 | # Use the -n option to send Mountain Lion notifications instead of terminal output. Clicking a notification will open the related file in nvALT. 18 | # Notifications require that the 'terminal-notifier' gem be installed: 19 | # 20 | # sudo gem install 'terminal-notifier' 21 | # 22 | # Use the -e ADDRESS option to send an email with the title of the note as the subject and the contents of the note as the body to the specified address. Separate multiple emails with commas. The contents of the note will be rendered with MultiMarkdown, which needs to exist at /usr/local/bin/multimarkdown. 23 | # 24 | # If the file has a ".taskpaper" extension, it will be converted to Markdown for formatting before processing with MultiMarkdown. 25 | # 26 | # The `-m` option will add a reminder to Reminders.app in Mountain Lion, due immediately, that will show up on iCloud-synced iOS devices as well. 27 | # 28 | # The `-f FOLDER` option allows you to specify a directory where a file named with the reminder title will be saved. The note for the reminder will be the file contents. This is useful, for example, with IFTTT.com. You can save a file to a public Dropbox folder, have IFTTT notice it and take any number of actions on it. 29 | # == Examples 30 | # 31 | # nvremind.rb ~/Dropbox/nvALT 32 | # 33 | # Other examples: 34 | # nvremind.rb ~/Dropbox/nvALT 35 | # nvremind.rb -n ~/Dropbox/nvALT 36 | # nvremind.rb -e me@gmail.com ~/Dropbox/nvALT 37 | # nvremind.rb -mn -e me@gmail.com ~/Dropbox/nvALT 38 | # == Usage 39 | # nvremind.rb [options] notes_folder 40 | # 41 | # For help use: nvremind.rb -h 42 | # 43 | # See for more information 44 | # 45 | # == Options 46 | # -h, --help Displays help message 47 | # -v, --version Display the version, then exit 48 | # -V, --verbose Verbose output 49 | # -z, --no-replace Don't updated @remind() tags with @reminded() after notification 50 | # -n, --notify Use terminal-notifier to post Mountain Lion notifications 51 | # -m, --reminders Add an item to the Reminders list in Reminders.app (due immediately) 52 | # --reminder-list LIST List to use in Reminders.app (default "Reminders") 53 | # -f folder Save a file to FOLDER named with the task title, note as contents 54 | # -e EMAIL[,EMAIL], --email EMAIL[,EMAIL] Send an email with note contents to the specified address 55 | # 56 | # == Author 57 | # Brett Terpstra 58 | # 59 | # == Copyright 60 | # Copyright (c) 2013 Brett Terpstra. Licensed under the MIT License: 61 | # http://www.opensource.org/licenses/mit-license.php 62 | 63 | require 'date' 64 | require 'cgi' 65 | require 'time' 66 | require 'optparse' 67 | require 'ostruct' 68 | require 'shellwords' 69 | 70 | NVR_VERSION = '1.0.6' 71 | 72 | if RUBY_VERSION.to_f > 1.9 73 | Encoding.default_external = Encoding::UTF_8 74 | Encoding.default_internal = Encoding::UTF_8 75 | end 76 | 77 | class TaskPaper 78 | def tp2md(input) 79 | header = input.scan(/Format\: .*$/) 80 | output = "" 81 | prevlevel = 0 82 | begin 83 | input.split("\n").each {|line| 84 | if line =~ /^(\t+)?(.*?):(\s(.*?))?$/ 85 | tabs = $1 86 | project = $2 87 | if tabs.nil? 88 | output += "\n## #{project} ##\n\n" 89 | prevlevel = 0 90 | else 91 | output += "#{tabs.gsub(/^\t/,"")}* **#{project.gsub(/^\s*-\s*/,'')}**\n" 92 | prevlevel = tabs.length 93 | end 94 | elsif line =~ /^(\t+)?\- (.*)$/ 95 | task = $2 96 | tabs = $1.nil? ? '' : $1 97 | task = "*#{task}*" if task =~ /@done/ 98 | if tabs.length - prevlevel > 1 99 | tabs = "\t" 100 | prevlevel.times {|i| tabs += "\t"} 101 | end 102 | tabs = '' if prevlevel == 0 && tabs.length > 1 103 | output += "#{tabs.gsub(/^\t/,'')}* #{task.strip}\n" 104 | prevlevel = tabs.length 105 | else 106 | next if line =~ /^\s*$/ 107 | tabs = "" 108 | (prevlevel - 1).times {|i| tabs += "\t"} 109 | output += "\n#{tabs}*#{line.strip}*\n" 110 | end 111 | } 112 | rescue => err 113 | puts "Exception: #{err}" 114 | err 115 | end 116 | o = "" 117 | o += header.join("\n") + "\n" unless header.nil? 118 | o += "" 119 | o += output.gsub(/\[\[(.*?)\]\]/,"\\1").gsub(/(@[^ \n\r\(]+)((\()([^\)]+)(\)))?/,"\\1\\3\\4\\5") 120 | o 121 | end 122 | end 123 | 124 | class Reminder 125 | attr_reader :options 126 | 127 | def initialize(arguments) 128 | @arguments = arguments 129 | 130 | @options = OpenStruct.new 131 | @options.remove = true 132 | @options.preserve_time = true 133 | @options.verbose = false 134 | @options.notify = false 135 | @options.email = false 136 | @options.file = false 137 | @options.stdout = true 138 | @options.reminders = false 139 | @options.reminder_list = "Reminders" 140 | end 141 | 142 | def run 143 | if parsed_options? && arguments_valid? 144 | 145 | puts "Start at #{DateTime.now}\n\n" if @options.verbose 146 | 147 | output_options if @options.verbose # [Optional] 148 | 149 | process_arguments 150 | process_command 151 | 152 | puts "\nFinished at #{DateTime.now}" if @options.verbose 153 | 154 | else 155 | output_usage 156 | end 157 | 158 | end 159 | 160 | def e_as(str) 161 | str.to_s.gsub(/(?=["\\])/, '\\') 162 | end 163 | 164 | protected 165 | 166 | def parsed_options? 167 | 168 | opts = OptionParser.new 169 | opts.on('-v', '--version', 'Display version information') { output_version ; exit 0 } 170 | opts.on('-V', '--verbose', 'Verbose output') { @options.verbose = true } 171 | opts.on('-z', '--no-replace', "Don't updated @remind() tags with @reminded() after notification") { @options.remove = false } 172 | opts.on('--no-preserve-time', "Allow file modification time to change") { @options.preserve_time = false } 173 | opts.on('-n', '--notify', "Use terminal-notifier to post Mountain Lion notifications") { @options.notify = true } 174 | opts.on('-r', '--replace', 'Deprecated, no effect') { } # deprecated, backward compatibility only 175 | opts.on('-m', '--reminders', "Add an item to the Reminders list in Reminders.app (due immediately)") { @options.reminders = true } 176 | opts.on('--reminder-list LIST', "List to use in Reminders.app (default 'Reminders')" ) { |list| @options.reminder_list = list } 177 | opts.on('-f FOLDER', '--file FOLDER', "Add a file to the specified folder") { |folder| 178 | if File.exists?(File.expand_path(folder)) 179 | @options.file = File.expand_path(folder) 180 | else 181 | puts "Invalid folder specified for -f (#{folder} does not exist)" 182 | Process.exit 1 183 | end 184 | } 185 | opts.on('-e EMAIL[,EMAIL]', '--email EMAIL[,EMAIL]', "Send an email with note contents to the specified address") { |emails| 186 | @options.email = [] 187 | emails.split(/,/).each {|email| 188 | @options.email.push(email.strip) 189 | } 190 | } 191 | opts.on('-h', '--help', 'Display this screen') { 192 | puts opts 193 | puts 194 | output_usage 195 | } 196 | opts.parse!(@arguments) rescue return false 197 | 198 | true 199 | end 200 | 201 | def output_options 202 | puts "Options:\n" 203 | 204 | @options.marshal_dump.each do |name, val| 205 | puts " #{name} = #{val}" 206 | end 207 | end 208 | 209 | def arguments_valid? 210 | @notes_dir = [] 211 | unless @arguments[0].nil? 212 | @arguments[0].split(",").each {|path| 213 | @notes_dir.push(File.expand_path(path)) if File.exists?(File.expand_path(path)) 214 | } 215 | true unless @notes_dir.empty? 216 | else 217 | false 218 | end 219 | end 220 | 221 | def process_arguments 222 | 223 | if @options.notify 224 | begin 225 | require 'rubygems' 226 | gem 'terminal-notifier', '>=1.4' 227 | require 'terminal-notifier' 228 | @options.notify = "terminal-notifier" 229 | rescue Gem::LoadError 230 | @options.notify = %x{growlnotify &>/dev/null && echo $? || echo false}.strip == "0" ? "growlnotify" : false 231 | $stderr.puts "Either terminal-notifier gem or growlnotify must be installed to use Notifications" unless @options.notify 232 | end 233 | end 234 | if (@options.notify || @options.email || @options.reminders) && !@options.verbose 235 | @options.stdout = false 236 | end 237 | end 238 | 239 | def output_usage 240 | output_version 241 | puts 242 | usage =< for more information 248 | ENDUSAGE 249 | puts usage 250 | Process.exit 251 | end 252 | 253 | def output_version 254 | puts "#{File.basename(__FILE__)} version #{NVR_VERSION}" 255 | end 256 | 257 | def process_command 258 | @notes_dir.each {|notes_dir| 259 | 260 | Dir.chdir(notes_dir) 261 | 262 | %x{grep -El "[^\\s]remind\\(.*?\\)" *.{md,txt,taskpaper,ft,doentry} 2>/dev/null}.split("\n").each {|file| 263 | mod_time = File.mtime(file) 264 | if RUBY_VERSION.to_f > 1.9 265 | input = IO.read(file).force_encoding('utf-8') 266 | else 267 | input = IO.read(file) 268 | end 269 | lines = input.split(/\n/) 270 | counter = 0 271 | lines.map! {|contents| 272 | counter += 1 273 | # don't remind if the line contains @done or @canceled 274 | unless contents =~ /\s@(done|cancell?ed)/ 275 | date_match = contents.match(/([^\s"`'\(\[])remind\((.*?)(\s"(.*?)")?\)/) 276 | unless date_match.nil? 277 | remind_date = Time.parse(date_match[2]) 278 | 279 | if remind_date <= Time.now 280 | stripped_line = contents.gsub(/["`'\(\[]?#{Regexp.escape(date_match[0])}["`'\)\]]?\s*/,'').gsub(/<\/?string>/,'').strip 281 | # remove leading - or * in case it's in a TaskPaper or Markdown list 282 | stripped_line.sub!(/^[\-\*\+] /,"") 283 | filename = "#{notes_dir}/#{file}".gsub(/\+/,"%20") 284 | is_day_one = File.extname(file) =~ /doentry$/ 285 | if is_day_one 286 | xml = IO.read(file) 287 | dayone_content = xml.match(/Entry Text<\/key>\s*(.*?)<\/string>/m)[1] 288 | if dayone_content 289 | note_title = dayone_content.split(/\n/)[0].gsub(/[#<>\-\*\+]/,"")[0..30].strip 290 | else 291 | note_title = "From Day One" 292 | end 293 | else 294 | note_title = File.basename(file).gsub(/\.(txt|md|taskpaper|ft|doentry)$/,'') 295 | end 296 | if stripped_line == "" 297 | @title = date_match[4] || note_title 298 | @extension = File.extname(file) 299 | @message = "#{@title} [#{remind_date.strftime('%F')}]" 300 | @note = is_day_one ? dayone_content : IO.read(file) 301 | if @extension =~ /(md|txt)$/ 302 | @note += "\n\n- \n" 303 | end 304 | else 305 | @title = date_match[4] || stripped_line 306 | @extension = "" 307 | @message = "#{@title} [#{remind_date.strftime('%F')}]" 308 | # add :#{counter} after #{filename} to include line number below 309 | if is_day_one 310 | @note = stripped_line 311 | else 312 | @note = "#{stripped_line}\n\n- file://#{filename}\n- nvalt://find/#{CGI.escape(note_title).gsub(/\+/,"%20")}\n" 313 | end 314 | end 315 | if @options.verbose 316 | puts "Title: #{@title}" 317 | puts "Extension: #{@extension}" 318 | puts "Message: #{@message}" 319 | puts "Note: #{@note}" 320 | end 321 | notify 322 | if @options.remove 323 | contents.gsub!(/([^\s"`'\(\[])remind\((.*?)(\s"(.*?)")?\)/) do |match| 324 | if Time.parse($2) < Time.now 325 | "#{$1}reminded(#{Time.now.strftime('%Y-%m-%d %H:%M')}#{$3})" 326 | else 327 | match 328 | end 329 | end 330 | end 331 | end 332 | end 333 | end 334 | contents 335 | } 336 | File.open(file,'w+') do |f| 337 | f.puts lines.join("\n") 338 | end 339 | if @options.preserve_time 340 | %x{touch -m -t '#{mod_time.strftime('%Y%m%d%H%M')}' "#{file}"} 341 | end 342 | } 343 | } 344 | end 345 | 346 | def notify 347 | if @options.stdout 348 | puts @message 349 | end 350 | if @options.notify == "terminal-notifier" 351 | TerminalNotifier.notify(@message, :title => "Reminder", :open => "nvalt://find/#{CGI.escape(@title).gsub(/\+/,"%20")}") 352 | elsif @options.notify == "growlnotify" 353 | %x{growlnotify -m "#{@message}" -t "Reminder" -s} 354 | end 355 | if @options.reminders 356 | %x{osascript <<'APPLESCRIPT' 357 | tell application "Reminders" 358 | if name of lists does not contain "#{@options.reminder_list}" then 359 | set _reminders to item 1 of lists 360 | else 361 | set _reminders to list "#{@options.reminder_list}" 362 | end if 363 | set d to ((current date) + 300) 364 | make new reminder at end of _reminders with properties {name:"#{@title}", remind me date:d, body:"#{e_as(@note)}"} 365 | end tell 366 | APPLESCRIPT} 367 | end 368 | unless @options.file == false 369 | filename = File.join(@options.file, @title) 370 | File.open(filename,'w+') do |f| 371 | f.puts @note 372 | end 373 | end 374 | unless @options.email == false 375 | subject = @title 376 | content = @note 377 | if @extension == ".taskpaper" 378 | if File.exists?("/usr/local/bin/multimarkdown") 379 | md = "format: complete\n\n#{TaskPaper.new.tp2md(@note)}" 380 | content = %x{echo #{Shellwords.escape(md)}|/usr/local/bin/multimarkdown} 381 | end 382 | else 383 | if File.exists?("/usr/local/bin/multimarkdown") 384 | content = %x{echo #{Shellwords.escape("format: complete\n\n" + @note)}|/usr/local/bin/multimarkdown} 385 | end 386 | end 387 | template =<