# An example use of the /delta API call. Maintains a local cache of # the App Folder's contents. Use the 'update' sub-command to update # the local cache. Use the 'find' sub-command to search the local # cache. # # Example usage: # # 1. Link to your Dropbox account # > ruby search_cache.rb link # # 2. Go to Dropbox and make changes to the contents. # # 3. Update the local cache to match what's on Dropbox. # > ruby search_cache.rb update # # 4. Search the local cache. # > ruby search_cache.rb find 'txt' # Repeat steps 2-4 any number of times. require File.expand_path('../../lib/dropbox_sdk', __FILE__) require 'json' # You must use your Dropbox App key and secret to use the API. # Find this at https://www.dropbox.com/developers APP_KEY = '' APP_SECRET = '' STATE_FILE = 'search_cache.json' def main() if APP_KEY == '' or APP_SECRET == '' warn "ERROR: Set your APP_KEY and APP_SECRET at the top of search_cache.rb" exit end prog_name = __FILE__ args = ARGV if args.size == 0 warn("Usage:\n") warn(" #{prog_name} link Link to a user's account.") warn(" #{prog_name} update Update cache to the latest on Dropbox.") warn(" #{prog_name} update Update cache, limit to pages of /delta.") warn(" #{prog_name} find Search cache for .") warn(" #{prog_name} find Display entire cache contents") warn(" #{prog_name} reset Delete the cache.") exit end command = args[0] if command == 'link' command_link(args) elsif command == 'update' command_update(args) elsif command == 'find' command_find(args) elsif command == 'reset' command_reset(args) else warn "ERROR: Unknown command: #{command}" warn "Run with no arguments for help." exit(1) end end def command_link(args) if args.size != 1 warn "ERROR: \"link\" doesn't take any arguments" exit end web_auth = DropboxOAuth2FlowNoRedirect.new(APP_KEY, APP_SECRET) authorize_url = web_auth.start() puts "1. Go to: #{authorize_url}" puts "2. Click \"Allow\" (you might have to log in first)." puts "3. Copy the authorization code." print "Enter the authorization code here: " STDOUT.flush auth_code = STDIN.gets.strip access_token, user_id = web_auth.finish(auth_code) puts "Link successful." save_state({ 'access_token' => access_token, 'tree' => {} }) end def command_update(args) if args.size == 1 page_limit = nil elsif args.size == 2 page_limit = Integer(args[1]) else warn "ERROR: \"update\" takes either zero or one argument." exit end # Load state state = load_state() access_token = state['access_token'] cursor = state['cursor'] tree = state['tree'] # Connect to Dropbox c = DropboxClient.new(access_token) page = 0 changed = false while (page_limit == nil) or (page < page_limit) # Get /delta results from Dropbox result = c.delta(cursor) page += 1 if result['reset'] == true puts 'reset' changed = true tree = {} end cursor = result['cursor'] # Apply the entries one by one to our cached tree. for delta_entry in result['entries'] changed = true apply_delta(tree, delta_entry) end cursor = result['cursor'] if not result['has_more'] break end end # Save state if changed state['cursor'] = cursor state['tree'] = tree save_state(state) else puts "No updates." end end # We track folder state as a tree of Node objects. class Node attr_accessor :path, :content def initialize(path, content) # The "original" page (i.e. not the lower-case path) @path = path # For files, content is a pair (size, modified) # For folders, content is a hash of children Nodes, keyed by lower-case file names. @content = content end def folder?() @content.is_a? Hash end def to_json() [@path, Node.to_json_content(@content)] end def self.from_json(jnode) path, jcontent = jnode Node.new(path, Node.from_json_content(jcontent)) end def self.to_json_content(content) if content.is_a? Hash map_hash_values(content) { |child| child.to_json } else content end end def self.from_json_content(jcontent) if jcontent.is_a? Hash map_hash_values(jcontent) { |jchild| Node.from_json jchild } else jcontent end end end # Run a mapping function over every value in a Hash, returning a new Hash. def map_hash_values(h) new = {} h.each { |k,v| new[k] = yield v } new end def apply_delta(root, e) path, metadata = e branch, leaf = split_path(path) if metadata != nil puts "+ #{path}" # Traverse down the tree until we find the parent folder of the entry # we want to add. Create any missing folders along the way. children = root branch.each do |part| node = get_or_create_child(children, part) # If there's no folder here, make an empty one. if not node.folder? node.content = {} end children = node.content end # Create the file/folder. node = get_or_create_child(children, leaf) node.path = metadata['path'] # Save the un-lower-cased path. if metadata['is_dir'] # Only create a folder if there isn't one there already. node.content = {} if not node.folder? else node.content = metadata['size'], metadata['modified'] end else puts "- #{path}" # Traverse down the tree until we find the parent of the entry we # want to delete. children = root missing_parent = false branch.each do |part| node = children[part] # If one of the parent folders is missing, then we're done. if node == nil or not node.folder? missing_parent = true break end children = node.content end # If we made it all the way, delete the file/folder. if not missing_parent children.delete(leaf) end end end def get_or_create_child(children, name) child = children[name] if child == nil children[name] = child = Node.new(nil, nil) end child end def split_path(path) bad, *parts = path.split '/' [parts, parts.pop] end def command_find(args) if args.size == 1 term = '' elsif args.size == 2 term = args[1] else warn("ERROR: \"find\" takes either zero or one arguments.") exit end state = load_state() results = [] search_tree(results, state['tree'], term) for r in results puts("#{r}") end puts("[Matches: #{results.size}]") end def command_reset(args) if args.size != 1 warn("ERROR: \"reset\" takes no arguments.") exit end # Delete cursor, empty tree. state = load_state() if state.has_key?('cursor') state.delete('cursor') end state['tree'] = {} save_state(state) end # Recursively search 'tree' for files that contain the string in 'term'. # Print out any matches. def search_tree(results, tree, term) tree.each do |name_lc, node| path = node.path if (path != nil) and path.include?(term) if node.folder? results.push("#{path}") else size, modified = node.content results.push("#{path} (#{size}, #{modified})") end end if node.folder? search_tree(results, node.content, term) end end end def save_state(state) state['tree'] = Node.to_json_content(state['tree']) File.open(STATE_FILE,"w") do |f| f.write(JSON.pretty_generate(state, :max_nesting => false)) end end def load_state() state = JSON.parse(File.read(STATE_FILE), :max_nesting => false) state['tree'] = Node.from_json_content(state['tree']) state end main()