#!/usr/bin/env ruby require 'rubygems' require 'net/ssh' require 'net/sftp' module BashNotes; module MobileNotes class Sync def self.process! BashNote.notes_dir = File.join(ENV['HOME'], '.notes') delta = { :new => {:local => [], :remote => []}, :changed => {:local => [], :remote => []} } local_notes = BashNote.all remote_notes = IphodNote.all # Find notes that have been added locally since last sync. rn_titles = remote_notes.collect {|n| n.title} delta[:new][:local] = local_notes.select do |n| !rn_titles.include?(n.title) end # Find notes that have been added on the remote device since last sync. ln_titles = local_notes.collect {|n| n.title} delta[:new][:remote] = remote_notes.select do |n| !ln_titles.include?(n.title) end # Find notes that have changed locally/remotely since last sync. local_notes.each do |ln| next unless rn = remote_notes.detect {|n| n.title == ln.title} ln.counterpart = rn rn.counterpart = ln if rn.mod_date > ln.mod_date delta[:changed][:remote] << rn elsif rn.mod_date < ln.mod_date delta[:changed][:local] << ln end end unless delta[:new][:local].empty? puts "Adding to MobileNotes.app:" delta[:new][:local].each do |ln| rn = IphodNote.clone(ln) rn.save! puts " #{rn.title}" end end unless delta[:new][:remote].empty? puts "Adding to bash_notes:" delta[:new][:remote].each do |rn| ln = BashNote.clone(rn) ln.save! puts " #{ln.title}" end end unless delta[:changed][:local].empty? puts "Updating in MobileNotes.app:" delta[:changed][:local].each do |ln| ln.counterpart.clone(ln) ln.counterpart.save! puts " #{ln.title}" end end unless delta[:changed][:remote].empty? puts "Updating in bash_notes:" delta[:changed][:remote].each do |rn| rn.counterpart.clone(rn) rn.counterpart.save! puts " #{rn.title}" end end ensure SQL_IFACE.close end end class Note attr_accessor :title, :body, :mod_date, :counterpart def initialize(atts) atts.each_pair { |key, value| self.send("#{key}=", value) } end def self.clone(other_note) n = new( 'title' => other_note.title, 'body' => other_note.body, 'mod_date' => other_note.mod_date ) n.counterpart = other_note n end def self.all # override in subclass end def save! # override in subclass end def clone(other_note) self.title = other_note.title self.body = other_note.body self.mod_date = other_note.mod_date end end class BashNote < Note def self.notes_dir=(val) @@notes_dir = val end def self.notes_dir @@notes_dir end def self.all files = Dir.glob(File.join(@@notes_dir, '*.note')) files.collect do |file| new( 'title' => File.basename(file, '.note'), 'body' => IO.read(file), 'mod_date' => File.new(file).mtime.to_i ) end end def save! filename = File.join(self.class.notes_dir, self.title + '.note') File.open(filename, 'w') { |f| f.write(self.body) } File.utime(Time.now, Time.at(self.mod_date), filename) end end class IphodNote < Note attr_accessor :row_id, :native_body, :native_mod_date def initialize(atts) self.title = atts['title'].strip if atts['native_body'] self.native_body = remove_iphod_title(atts['native_body']) elsif atts['body'] self.body = atts['body'] end if atts['native_mod_date'] self.native_mod_date = atts['native_mod_date'].to_i else self.mod_date = atts['mod_date'].to_i end self.row_id = atts['row_id'].to_i end def self.all results = SQL_IFACE.query( 'SELECT Note.rowid, title, creation_date, data ' + 'FROM Note JOIN note_bodies on Note.rowid = note_id' ) result_array = results.strip.split("\n\n") result_array.collect do |result| atts = {} { 'row_id' => 'rowid', 'title' => 'title', 'native_mod_date' => 'creation_date', 'native_body' => 'data' }.each_pair do |att, column| match = result.match(/^\s*#{column} = (.*?)$/) atts[att] = match[1] if match end new(atts) end end def save! return if !title || !body if self.row_id > 0 SQL_IFACE.query( "UPDATE Note SET creation_date = ?, title = ?, summary = ? " + "WHERE rowid = ?", self.native_mod_date, self.title, self.title, self.row_id ) SQL_IFACE.query( "UPDATE note_bodies SET data = ? WHERE note_id = ?", prepend_iphod_title(self.native_body), self.row_id ) else SQL_IFACE.query( "INSERT INTO Note VALUES (?, ?, ?)", self.native_mod_date, self.title, self.title ) result = SQL_IFACE.query( "SELECT rowid FROM Note WHERE title = ?", self.title ) self.row_id = result.match(/rowid = (\d+)/)[1].to_i SQL_IFACE.query( "INSERT INTO note_bodies VALUES (?, ?)", self.row_id, prepend_iphod_title(self.native_body) ) end rescue => e puts "Remote Save failed for: #{self.title}\n#{e.inspect}" end def body=(val) @native_body = self.class.iphod_format(val) @body = val end def native_body=(val) @body = self.class.text_format(val) @native_body = val end def mod_date=(val) @native_mod_date = val - self.class.iphone_epoch_offset @mod_date = val end def native_mod_date=(val) @mod_date = val + self.class.iphone_epoch_offset @native_mod_date = val end private def self.iphod_format(text) operations = [ [/&/, '&'], [//, '>'], [/^\s*[\n$]/, '
'], [/ /, "  "], [/^(.*?)\s*?[\n$]/, '
\1
'] ] operations.inject(text) { |o, cnv| o.gsub(*cnv) } end def self.text_format(markup) operations = [ [/
(.*?)<\/div>/, '\1'+"\n"], [/ /, ' '], [/
/, "\n"], [/>/, '>'], [/</, '<'], [/&/, '&'] ] operations.inject(markup) { |o, cnv| o.gsub(*cnv) } end def self.iphone_epoch_offset Time.gm(2001, 1, 1).to_i end def quote_string(s) s.gsub(/\\/, '\&\&').gsub(/'/, "''") end def remove_iphod_title(bdy) ipht = self.class.iphod_format(self.title + "\n") bdy.sub!(ipht, '') if bdy[(0..(ipht.size-1))] == ipht bdy end def prepend_iphod_title(bdy) self.class.iphod_format(self.title + "\n") + bdy end end class RemoteSqliteInterface # *Arguably* it's not the most efficient approach, but sqlite3 would not # play nice with Net::SSH's process manipulation options (process.open, # process.popen3). Writing to a file then passing it in is more robust # than trying for a command-line one-liner of indeterminable size # and contents. SQLITE = '/var/root/bin/sqlite3' TMP_FILE = '/tmp/sync_query' def initialize(host, file) if host.nil? || host.empty? puts "Error: you must specify the address or hostname of the device." exit end @file = file begin @session = Net::SSH.start(host, :username => 'root') rescue Net::SSH::AuthenticationFailed puts "Error: access to device denied. Have you set up your public key?" exit; end @sftp = @session.sftp.connect @shell = @session.shell.sync end def close @sftp.remove(TMP_FILE) @session.close end def query(query, *args) while !args.empty? query.sub!(/\?/, quote(args.shift)) end #puts "SQLITE: #{query}" @sftp.open_handle(TMP_FILE, 'w') do |handle| @sftp.write(handle, "#{query};\n") end out = @shell.send_command("#{SQLITE} -line #{@file} < #{TMP_FILE}") raise out.stderr if out.stderr && !out.stderr.empty? out.stdout end private def quote(value) case value when String "'#{value.gsub(/\\/, '\&\&').gsub(/'/, "''")}'" when NilClass "NULL" when TrueClass '1' when FalseClass '0' else value.to_s end end end end; end SQL_IFACE = BashNotes::MobileNotes::RemoteSqliteInterface.new( ARGV.shift, '/private/var/root/Library/Notes/notes.db' ) BashNotes::MobileNotes::Sync.process!