benjaminoakes/maid

View on GitHub
lib/maid/tools.rb

Summary

Maintainability
D
1 day
Test Coverage
require 'digest/sha1'
require 'find'
require 'fileutils'
require 'time'

require 'exifr/jpeg'
require 'geocoder'
require 'mime/types'
require 'dimensions'
require 'zip'

require 'pathname'

# These "tools" are methods available in the Maid DSL.
#
# In general, methods are expected to:
#
# * Automatically expand paths (that is, `'~/Downloads/foo.zip'` becomes `'/home/username/Downloads/foo.zip'`)
# * Respect the `noop` (`dry-run`) option if it is set
#
# Some methods are not available on all platforms.  An `ArgumentError` is raised when a command is not available.  See
# tags such as: [Mac OS X]
module Maid::Tools
  # For showing deprecation notices
  include Deprecated

  # Moves `sources` file(s) to a `destination` directory.
  #
  # Movement is only allowed to directories that already exist.  If your
  # intention is to rename, see the `rename` method.
  #
  # @example Single source file
  #   move('~/Downloads/foo.zip', '~/Archive/Software/Mac OS X/')
  #
  # @example Multiple source files
  #   move(['~/Downloads/foo.zip', '~/Downloads/bar.zip'],
  #   '~/Archive/Software/Mac OS X/')
  #   move(dir('~/Downloads/*.zip'), '~/Archive/Software/Mac OS X/')
  #
  # @example Overwrite destination file if it already exists
  #   move('~/Downloads/foo.zip', '~/Archive/Software/Mac OS X/')
  #   move('~/Downloads/foo.zip', '~/Archive/Software/Mac OS X/', clobber:
  #   true)
  #
  # @example Skip file if it already exists at destination
  #   move('~/Downloads/foo.zip', '~/Archive/Software/Mac OS X/', clobber:
  #   false)
  #
  # @param sources [String, Array<String>] the paths to the source files to
  # move
  # @param destination_dir [String] path of the directory where to move
  # `sources` to
  # @param [Hash] kwargs the arguments to modify behaviour
  # @option kwargs [Boolean] :clobber (true) `true` to overwrite destination
  # file if it exists, `false` to skip moving file if it exists
  def move(sources, destination_dir, clobber: true)
    expanded_destination_dir = expand(destination_dir)

    if File.directory?(expanded_destination_dir)
      expand_all(sources).each do |source|
        log("move #{sh_escape(source)} #{sh_escape(expanded_destination_dir)}")

        unless skip_move?(source, expanded_destination_dir, clobber)
          FileUtils.mv(source, expanded_destination_dir, **@file_options)
        end
      end
    else
      # Unix `mv` warns about the target not being a directory with multiple sources.  Maid checks the same.
      warn("skipping move because #{sh_escape(expanded_destination_dir)} " \
           "is not a directory (use 'mkdir' to create first, or use 'rename')")
    end
  end

  # Rename a single file.
  #
  # Any directories needed in order to complete the rename are made automatically.
  #
  # Overwriting is not allowed; it logs a warning.  If overwriting is desired,
  # use `remove` to delete the file first, then use `rename`.
  #
  # ## Examples
  #
  # Simple rename:
  #
  #     rename('foo.zip', 'baz.zip') # "foo.zip" becomes "baz.zip"
  #
  # Rename needing directories:
  #
  #     rename('foo.zip', 'bar/baz.zip') # "bar" is created, "foo.zip" becomes "baz.zip" within "bar"
  #
  # Attempting to overwrite:
  #
  #     rename('foo.zip', 'existing.zip') # "skipping move of..."
  def rename(source, destination)
    source = expand(source)
    destination = expand(destination)

    mkdir(File.dirname(destination))

    if File.exist?(destination)
      warn("skipping rename of #{sh_escape(source)} to #{sh_escape(destination)} because it would overwrite")
    else
      log("rename #{sh_escape(source)} #{sh_escape(destination)}")
      FileUtils.mv(source, destination, **@file_options)
    end
  end

  # Move the given paths to the user's trash.
  #
  # The path is still moved if a file already exists in the trash with the same name.  However, the current date and
  # time is appended to the filename.
  #
  # **Note:** the OS-native "restore" or "put back" functionality for trashed files is not currently supported.  (See
  # [issue #63](https://github.com/benjaminoakes/maid/issues/63).)  However, they can be restored manually, and the Maid
  # log can help assist with this.
  #
  # ## Options
  #
  # `:remove_over => Fixnum` (e.g. `1.gigabyte`, `1024.megabytes`)
  #
  # Delete files over the given size rather than moving to the trash.
  #
  # See also `Maid::NumericExtensions::SizeToKb`
  #
  # ## Examples
  #
  # Single path:
  #
  #     trash('~/Downloads/foo.zip')
  #
  # Multiple paths:
  #
  #     trash(['~/Downloads/foo.zip', '~/Downloads/bar.zip'])
  #     trash(dir('~/Downloads/*.zip'))
  def trash(paths, options = {})
    # ## Implementation Notes
    #
    # Trashing files correctly is surprisingly hard.  What Maid ends up doing
    # is one the easiest, most foolproof solutions:  moving the file.
    #
    # Unfortunately, that means it's not possile to restore files automatically
    # in OSX or Ubuntu.  The previous location of the file is lost.
    #
    # OSX support depends on AppleScript or would require a not-yet-written C
    # extension to interface with the OS.  The AppleScript solution is less
    # than ideal: the user has to be logged in, Finder has to be running, and
    # it makes the "trash can sound" every time a file is moved.
    #
    # Ubuntu makes it easy to implement, and there's a Python library for doing
    # so (see `trash-cli`).  However, there's not a Ruby equivalent yet.

    expand_all(paths).each do |path|
      target = File.join(@trash_path, File.basename(path))
      safe_trash_path = File.join(@trash_path, "#{File.basename(path)} #{Time.now.strftime('%Y-%m-%d-%H-%M-%S')}")

      if options[:remove_over] &&
         File.exist?(path) &&
         disk_usage(path) > options[:remove_over]
        remove(path)
      end

      if File.exist?(path)
        if File.exist?(target)
          rename(path, safe_trash_path)
        else
          move(path, @trash_path)
        end
      end
    end
  end

  # Copy from `sources` to `destination`
  #
  # The path is not copied if a file already exists at the destination with the
  # same name.  A warning is logged instead. Note: Similar functionality is
  # provided by the sync tool, but this requires installation of the `rsync`
  # binary
  # ## Examples
  #
  # Single path:
  #
  #     copy('~/Downloads/foo.zip', '~/Archive/Software/Mac OS X/')
  #
  # Multiple paths:
  #
  #     copy(['~/Downloads/foo.zip', '~/Downloads/bar.zip'], '~/Archive/Software/Mac OS X/')
  #     copy(dir('~/Downloads/*.zip'), '~/Archive/Software/Mac OS X/')
  def copy(sources, destination)
    destination = expand(destination)

    expand_all(sources).each do |source|
      target = File.join(destination, File.basename(source))

      if File.exist?(target)
        warn("skipping copy because #{sh_escape(source)} because #{sh_escape(target)} already exists")
      else
        log("cp #{sh_escape(source)} #{sh_escape(destination)}")
        FileUtils.cp(source, destination, **@file_options)
      end
    end
  end

  # Delete the files at the given path recursively.
  #
  # **NOTE**: In most cases, `trash` is a safer choice, since the files will be
  # recoverable by retreiving them from the trash.  Once you delete a file
  # using `remove`, it's gone!  Please use `trash` whenever possible and only
  # use `remove` when necessary.
  #
  # ## Options
  #
  # `:force => boolean`
  #
  # Force deletion (no error is raised if the file does not exist).
  #
  # `:secure => boolean`
  #
  # Infrequently needed. See [`FileUtils.remove_entry_secure`][fures]
  #
  # ## Examples
  #
  # Single path:
  #
  #     remove('~/Downloads/foo.zip')
  #
  # Multiple path:
  #
  #     remove(['~/Downloads/foo.zip', '~/Downloads/bar.zip'])
  #     remove(dir('~/Downloads/*.zip'))
  #
  #   [fures]: http://www.ruby-doc.org/stdlib-1.9.3/libdoc/fileutils/rdoc/FileUtils.html#method-c-remove_entry_secure
  def remove(paths, options = {})
    expand_all(paths).each do |path|
      options = @file_options.merge(options)

      log("Removing #{sh_escape(path)}")
      FileUtils.rm_r(path, **options)
    end
  end

  # Give all files matching the given glob.
  #
  # Note that the globs are *not* regexps (they're closer to shell globs).  However, some regexp-like notation can be
  # used, e.g. `?`, `[a-z]`, `{tgz,zip}`.  For more details, see Ruby's documentation on `Dir.glob`.
  #
  # The matches are sorted lexically to aid in readability when using `--dry-run`.
  #
  # ## Examples
  #
  # Single glob:
  #
  #     dir('~/Downloads/*.zip')
  #
  # Specifying multiple extensions succinctly:
  #
  #     dir('~/Downloads/*.{exe,deb,dmg,pkg,rpm}')
  #
  # Multiple glob (all are equivalent):
  #
  #     dir(['~/Downloads/*.zip', '~/Dropbox/*.zip'])
  #     dir(%w(~/Downloads/*.zip ~/Dropbox/*.zip))
  #     dir('~/{Downloads,Dropbox}/*.zip')
  #
  # Recursing into subdirectories (see also: `find`):
  #
  #     dir('~/Music/**/*.m4a')
  #
  def dir(globs)
    expand_all(globs)
      .map { |glob| Dir.glob(glob) }
      .flatten
      .sort
  end

  # Same as `dir`, but excludes files that are (possibly) being
  # downloaded.
  #
  # ## Example
  #
  # Move Debian/Ubuntu packages that are finished downloading into a software directory.
  #
  #     move dir_safe('~/Downloads/*.deb'), '~/Archive/Software'
  #
  def dir_safe(globs)
    dir(globs)
      .reject { |path| downloading?(path) }
  end

  # Give only files matching the given glob.
  #
  # This is the same as `dir` but only includes actual files (no directories or symlinks).
  #
  def files(globs)
    dir(globs)
      .select { |f| File.file?(f) }
  end

  # Escape characters that have special meaning as a part of path global patterns.
  #
  # Useful when using `dir` with file names that may contain `{ } [ ]` characters.
  #
  # ## Example
  #
  #     escape_glob('test [tmp]') # => 'test \\[tmp\\]'
  def escape_glob(glob)
    glob.gsub(/[{}\[\]]/) { |s| '\\' + s }
  end

  # Create a directory and all of its parent directories.
  #
  # The path of the created directory is returned, which allows for chaining (see examples).
  #
  # ## Options
  #
  # `:mode`
  #
  # The symbolic and absolute mode can both be used, for example: `0700`, `'u=wr,go=rr'`
  #
  # ## Examples
  #
  # Creating a directory with a specific mode:
  #
  #     mkdir('~/Music/Pink Floyd/', :mode => 0644)
  #
  # Ensuring a directory exists when moving:
  #
  #     move('~/Downloads/Pink Floyd*.mp3', mkdir('~/Music/Pink Floyd/'))
  def mkdir(path, options = {})
    path = expand(path)
    log("mkdir -p #{sh_escape(path)}")
    FileUtils.mkdir_p(path, **@file_options.merge(options))
    path
  end

  # Find matching files, akin to the Unix utility `find`.
  #
  # If no block is given, it will return an array.  Otherwise, it acts like `Find.find`.
  #
  # ## Examples
  #
  # Without a block:
  #
  #     find('~/Downloads/') # => [...]
  #
  # Recursing and filtering using a regular expression:
  #
  #     find('~/Downloads/').grep(/\.pdf$/)
  #
  # (**Note:** It's just Ruby, so any methods in `Array` and `Enumerable` can be used.)
  #
  # Recursing with a block:
  #
  #     find('~/Downloads/') do |path|
  #       # ...
  #     end
  #
  def find(path, &block)
    expanded_path = expand(path)

    if block.nil?
      Find.find(expanded_path).to_a
    else
      Find.find(expanded_path, &block)
    end
  end

  # [Mac OS X] Use Spotlight to locate all files matching the given filename.
  #
  # [Ubuntu] Use `locate` to locate all files matching the given filename.
  #
  # ## Examples
  #
  #     locate('foo.zip') # => ['/a/foo.zip', '/b/foo.zip']
  def locate(name)
    cmd("#{Maid::Platform::Commands.locate} #{sh_escape(name)}").split("\n")
  end

  # [Mac OS X] Use Spotlight metadata to determine the site from which a file was downloaded.
  #
  # ## Examples
  #
  #     downloaded_from('foo.zip') # => ['http://www.site.com/foo.zip', 'http://www.site.com/']
  def downloaded_from(path)
    mdls_to_array(path, 'kMDItemWhereFroms')
  end

  # Detect whether the path is currently being downloaded in Chrome, Firefox or Safari.
  #
  # See also: `dir_safe`
  def downloading?(path)
    Maid::Downloading.downloading?(path)
  end

  # Find all duplicate files in the given globs.
  #
  # More often than not, you'll want to use `newest_dupes_in` or
  # `verbose_dupes_in` instead of using this method directly.
  #
  # Globs are expanded as in `dir`, then all non-files are filtered out. The
  # remaining files are compared by size, and non-dupes are filtered out. The
  # remaining candidates are then compared by checksum. Dupes are returned as
  # an array of arrays.
  #
  # ## Examples
  #
  #     dupes_in('~/{Downloads,Desktop}/*') # => [
  #                                                ['~/Downloads/foo.zip', '~/Downloads/foo (1).zip'],
  #                                                ['~/Desktop/bar.txt', '~/Desktop/bar copy.txt']
  #                                              ]
  #
  # Keep the newest dupe:
  #
  #     dupes_in('~/Desktop/*', '~/Downloads/*').each do |dupes|
  #       trash dupes.sort_by { |p| File.mtime(p) }[0..-2]
  #     end
  #
  def dupes_in(globs)
    dupes = []
    files(globs) # Start by filtering out non-files
      .group_by { |f| size_of(f) } # ... then grouping by size, since that's fast
      .reject { |_s, p| p.length < 2 } # ... and filter out any non-dupes
      .map do |_size, candidates|
        dupes += candidates
                 .group_by { |p| checksum_of(p) } # Now group our candidates by a slower checksum calculation
                 .reject { |_c, p| p.length < 2 } # ... and filter out any non-dupes
                 .values
      end
    dupes
  end

  # Convenience method that is like `dupes_in` but excludes the oldest dupe.
  #
  # ## Example
  #
  # Keep the oldest dupe (trash the others):
  #
  #     trash newest_dupes_in('~/Downloads/*')
  #
  def newest_dupes_in(globs)
    dupes_in(globs)
      .map { |dupes| dupes.sort_by { |p| File.mtime(p) }[1..-1] }
      .flatten
  end

  # Convenience method for `dupes_in` that excludes the dupe with the shortest name.
  #
  # This is ideal for dupes like `foo.zip`, `foo (1).zip`, `foo copy.zip`.
  #
  # ## Example
  #
  # Keep the dupe with the shortest name (trash the others):
  #
  #     trash verbose_dupes_in('~/Downloads/*')
  #
  def verbose_dupes_in(globs)
    dupes_in(globs)
      .map { |dupes| dupes.sort_by { |p| File.basename(p).length }[1..-1] }
      .flatten
  end

  # Determine the dimensions of GIF, PNG, JPEG, or TIFF images.
  #
  # Value returned is [width, height].
  #
  # ## Examples
  #
  #     dimensions_px('image.jpg') # => [1024, 768]
  #     width, height = dimensions_px('image.jpg')
  #     dimensions_px('image.jpg').join('x') # => "1024x768"
  def dimensions_px(path)
    Dimensions.dimensions(path)
  end

  # Determine the city of the given JPEG image.
  #
  # ## Examples
  #
  #     loation_city('old_capitol.jpg') # => "Iowa City, IA, US"
  def location_city(path)
    case mime_type(path)
    when 'image/jpeg'
      gps = EXIFR::JPEG.new(path).gps
      coordinates_string = [gps.latitude, gps.longitude]
      location = Geocoder.search(coordinates_string).first
      [location.city, location.province, location.country_code.upcase].join(', ')
    end
  end

  # [Mac OS X] Use Spotlight metadata to determine audio length.
  #
  # ## Examples
  #
  #     duration_s('foo.mp3') # => 235.705
  def duration_s(path)
    cmd("mdls -raw -name kMDItemDurationSeconds #{sh_escape(path)}").to_f
  end

  # List the contents of a zip file.
  #
  # ## Examples
  #
  #     zipfile_contents('foo.zip') # => ['foo.exe', 'README.txt', 'subdir/anything.txt']
  def zipfile_contents(path)
    # It might be nice to use `glob` from `Zip::FileSystem`, but it seems buggy.  (Subdirectories aren't included.)
    Zip::File.open(path) do |zip_file|
      zip_file.entries.map { |entry| entry.name }.sort
    end
  end

  # Calculate disk usage of a given path in kilobytes.
  #
  # See also: `Maid::NumericExtensions::SizeToKb`.
  #
  # ## Examples
  #
  #     disk_usage('foo.zip') # => 136
  def disk_usage(path)
    raw = cmd("du -s #{sh_escape(path)}")
    # FIXME: This reports in kilobytes, but should probably report in bytes.
    usage_kb = raw.split(/\s+/).first.to_i

    raise "Stopping pessimistically because of unexpected value from du (#{raw.inspect})" if usage_kb.zero?

    usage_kb
  end

  # Get the creation time of a file.
  #
  # In Unix speak, `ctime`.
  #
  # ## Examples
  #
  #     created_at('foo.zip') # => Sat Apr 09 10:50:01 -0400 2011
  def created_at(path)
    File.ctime(expand(path))
  end

  # Get the time that a file was last accessed.
  #
  # In Unix speak, `atime`.
  #
  # ## Examples
  #
  #     accessed_at('foo.zip') # => Sat Apr 09 10:50:01 -0400 2011
  def accessed_at(path)
    File.atime(expand(path))
  end

  # @deprecated
  #
  # Alias of `accessed_at`.
  def last_accessed(path)
    # Not a normal `alias` so the deprecation notice shows in the docs.
    accessed_at(path)
  end
  deprecated :last_accessed, :accessed_at

  # Get the modification time of a file.
  #
  # In Unix speak, `mtime`.
  #
  # ## Examples
  #
  #     modified_at('foo.zip') # => Sat Apr 09 10:50:01 -0400 2011
  def modified_at(path)
    File.mtime(expand(path))
  end

  # Get the size of a file.
  #
  # ## Examples
  #
  #     size_of('foo.zip') # => 2193
  def size_of(path)
    File.size(path)
  end

  # Get a checksum for a file.
  #
  # ## Examples
  #
  #     checksum_of('foo.zip') # => "67258d750ca654d5d3c7b06bd2a1c792ced2003e"
  def checksum_of(path)
    Digest::SHA1.hexdigest(File.read(path))
  end

  # @deprecated
  #
  # Pull and push the `git` repository at the given path.
  #
  # Since this is deprecated, you might also be interested in [SparkleShare](http://sparkleshare.org/), a great
  # `git`-based file syncronization project.
  #
  # ## Examples
  #
  #     git_piston('~/code/projectname')
  def git_piston(path)
    full_path = expand(path)
    stdout = cmd("cd #{sh_escape(full_path)} && git pull && git push 2>&1")
    log("Fired git piston on #{sh_escape(full_path)}.  STDOUT:\n\n#{stdout}")
  end

  deprecated :git_piston, 'SparkleShare (http://sparkleshare.org/)'

  # Simple sync two files/folders using `rsync`.
  #
  # The host OS must provide `rsync`.  See the `rsync` man page for a detailed description.
  #
  #     man rsync
  #
  # ## Options
  #
  # `:delete      => boolean`
  # `:verbose     => boolean`
  # `:archive     => boolean` (default `true`)
  # `:update      => boolean` (default `true`)
  # `:exclude     => string`
  # `:prune_empty => boolean`
  #
  # ## Examples
  #
  # Syncing a directory to a backup:
  #
  #     sync('~/music', '/backup/music')
  #
  # Excluding a path:
  #
  #     sync('~/code', '/backup/code', :exclude => '.git')
  #
  # Excluding multiple paths:
  #
  #     sync('~/code', '/backup/code', :exclude => ['.git', '.rvmrc'])
  def sync(from, to, options = {})
    # expand removes trailing slash
    # cannot use str[-1] due to ruby 1.8.7 restriction
    from = expand(from) + (from.end_with?('/') ? '/' : '')
    to = expand(to) + (to.end_with?('/') ? '/' : '')
    # default options
    options = { archive: true, update: true }.merge(options)
    ops = []
    ops << '-a' if options[:archive]
    ops << '-v' if options[:verbose]
    ops << '-u' if options[:update]
    ops << '-m' if options[:prune_empty]
    ops << '-n' if @file_options[:noop]

    Array(options[:exclude]).each do |path|
      ops << "--exclude=#{sh_escape(path)}"
    end

    ops << '--delete' if options[:delete]
    stdout = cmd("rsync #{ops.join(' ')} #{sh_escape(from)} #{sh_escape(to)} 2>&1")
    log("Fired sync from #{sh_escape(from)} to #{sh_escape(to)}.  STDOUT:\n\n#{stdout}")
  end

  # [Mac OS X] Use Spotlight metadata to determine which content types a file has.
  #
  # ## Examples
  #
  #     spotlight_content_types('foo.zip') # => ['public.zip-archive', 'public.archive']
  def spotlight_content_types(path)
    mdls_to_array(path, 'kMDItemContentTypeTree')
  end

  # Get the content types of a path.
  #
  # Content types can be MIME types, Internet media types or Spotlight content types (OS X only).
  #
  # ## Examples
  #
  #     content_types('foo.zip') # => ["public.zip-archive", "com.pkware.zip-archive",
  #                                    "public.archive", "application/zip", "application"]
  #     content_types('bar.jpg') # => ["public.jpeg", "public.image", "image/jpeg", "image"]
  def content_types(path)
    [spotlight_content_types(path), mime_type(path), media_type(path)].flatten
  end

  # Get the MIME type of the file.
  #
  # ## Examples
  #
  #     mime_type('bar.jpg') # => "image/jpeg"
  def mime_type(path)
    type = MIME::Types.type_for(path)[0]

    return unless type

    [type.media_type, type.sub_type].join('/')
  end

  # Get the Internet media type of the file.
  #
  # In other words, the first part of `mime_type`.
  #
  # ## Examples
  #
  #     media_type('bar.jpg') # => "image"
  def media_type(path)
    type = MIME::Types.type_for(path)[0]

    return unless type

    type.media_type
  end

  # Filter an array by content types.
  #
  # Content types can be MIME types, internet media types or Spotlight content types (OS X only).
  #
  # If you need your rules to work on multiple platforms, it's recommended to avoid using Spotlight content types.
  #
  # ## Examples
  #
  # ### Using media types
  #
  #     where_content_type(dir('~/Downloads/*'), 'video')
  #     where_content_type(dir('~/Downloads/*'), ['image', 'audio'])
  #
  # ### Using MIME types
  #
  #     where_content_type(dir('~/Downloads/*'), 'image/jpeg')
  #
  # ### Using Spotlight content types
  #
  # Less portable, but richer data in some cases.
  #
  #     where_content_type(dir('~/Downloads/*'), 'public.image')
  def where_content_type(paths, filter_types)
    filter_types = Array(filter_types)
    Array(paths).select { |p| !(filter_types & content_types(p)).empty? }
  end

  # Test whether a directory is either empty, or contains only empty
  # directories/subdirectories.
  #
  # ## Example
  #
  #     if tree_empty?(dir('~/Downloads/foo'))
  #       trash('~/Downloads/foo')
  #     end
  def tree_empty?(root)
    return nil if File.file?(root)
    return true if Dir.glob(root + '/*').length == 0

    ignore = []

    # Look for files.
    return false if Dir.glob(root + '/*').select { |f| File.file?(f) }.length > 0

    empty_dirs = Dir.glob(root + '/**/*').select do |d|
      File.directory?(d)
    end.reverse.select do |d|
      # `.reverse` sorts deeper directories first.

      # If the directory is empty, its parent should ignore it.
      should_ignore = Dir.glob(d + '/*').select do |n|
        !ignore.include?(n)
      end.length == 0

      ignore << d if should_ignore

      should_ignore
    end

    Dir.glob(root + '/*').select do |n|
      !empty_dirs.include?(n)
    end.length == 0
  end

  # Given an array of directories, return a new array without any child
  # directories whose parent is already present in that array.
  #
  # ## Example
  #
  #     ignore_child_dirs(["foo", "foo/a", "foo/b", "bar"]) # => ["foo", "bar"]
  def ignore_child_dirs(arr)
    arr.sort do |x, y|
      y.count('/') - x.count('/')
    end.select do |d|
      !arr.include?(File.dirname(d))
    end
  end

  # Get a list of Finder labels of a file or directory. Only available on OS X when you have tag installed.
  #
  # ## Example
  #
  #     tags("~/Downloads/a.dmg.download") # => ["Unfinished"]
  def tags(path)
    if has_tag_available_and_warn?
      path = expand(path)
      raw = cmd("tag -lN #{sh_escape(path)}")
      raw.strip.split(',')
    else
      []
    end
  end

  # Tell if a file or directory has any Finder labels. Only available on OS X
  # when you have tag installed.
  #
  # ## Example
  #
  #     has_tags?("~/Downloads/a.dmg.download") # => true
  def has_tags?(path)
    if has_tag_available_and_warn?
      ts = tags(path)
      ts && ts.count > 0
    else
      false
    end
  end

  # Tell if a file or directory has a certain Finder labels. Only available on
  # OS X when you have tag installed.
  #
  # ## Example
  #
  #     contains_tag?("~/Downloads/a.dmg.download", "Unfinished") # => true
  def contains_tag?(path, tag)
    if has_tag_available_and_warn?
      path = expand(path)
      ts = tags(path)
      ts.include?(tag)
    else
      false
    end
  end

  # Add a Finder label or a list of labels to a file or directory. Only
  # available on OS X when you have tag installed.
  #
  # ## Example
  #
  #     add_tag("~/Downloads/a.dmg.download", "Unfinished")
  def add_tag(path, tag)
    return unless has_tag_available_and_warn?

    path = expand(path)
    ts = Array(tag).join(',')
    log "add tags #{ts} to #{path}"
    return if @file_options[:noop]

    cmd("tag -a #{sh_escape(ts)} #{sh_escape(path)}")
  end

  # Remove a Finder label or a list of labels from a file or directory. Only
  # available on OS X when you have tag installed.
  #
  # ## Example
  #
  #     remove_tag("~/Downloads/a.dmg", "Unfinished")
  def remove_tag(path, tag)
    return unless has_tag_available_and_warn?

    path = expand(path)
    ts = Array(tag).join(',')
    log "remove tags #{ts} from #{path}"
    return if @file_options[:noop]

    cmd("tag -r #{sh_escape(ts)} #{sh_escape(path)}")
  end

  # Set Finder label of a file or directory to a label or a list of labels.
  # Only available on OS X when you have tag installed.
  #
  # ## Example
  #
  #     set_tag("~/Downloads/a.dmg.download", "Unfinished")
  def set_tag(path, tag)
    return unless has_tag_available_and_warn?

    path = expand(path)
    ts = Array(tag).join(',')
    log "set tags #{ts} to #{path}"
    return if @file_options[:noop]

    cmd("tag -s #{sh_escape(ts)} #{sh_escape(path)}")
  end

  # Tell if a file is hidden
  #
  # ## Example
  #
  #     hidden?("~/.maid") # => true
  def hidden?(path)
    if Maid::Platform.osx?
      raw = cmd("mdls -raw -name kMDItemFSInvisible #{sh_escape(path)}")
      raw == '1'
    else
      p = Pathname.new(expand(path))
      p.basename =~ /^\./
    end
  end

  # Tell if a file has been used since added
  #
  # ## Example
  #
  #     has_been_used?("~/Downloads/downloading.download") # => false
  def has_been_used?(path)
    if Maid::Platform.osx?
      path = expand(path)
      raw = cmd("mdls -raw -name kMDItemLastUsedDate #{sh_escape(path)}")

      if raw == '(null)'
        false
      else
        begin
          DateTime.parse(raw).to_time
          true
        rescue ArgumentError => e
          false
        end
      end
    else
      used_at(path) <=> added_at(path) > 0
    end
  end

  # The last used time of a file on OS X, or atime on Linux.
  #
  # ## Example
  #
  #     used_at("foo.zip") # => Sat Apr 09 10:50:01 -0400 2011
  def used_at(path)
    if Maid::Platform.osx?
      path = expand(path)
      raw = cmd("mdls -raw -name kMDItemLastUsedDate #{sh_escape(path)}")

      if raw == '(null)'
        nil
      else
        begin
          DateTime.parse(raw).to_time
        rescue ArgumentError => e
          accessed_at(path)
        end
      end
    else
      accessed_at(path)
    end
  end

  # The added time of a file on OS X, or ctime on Linux.
  #
  # ## Example
  #
  #     added_at("foo.zip") # => Sat Apr 09 10:50:01 -0400 2011
  def added_at(path)
    if Maid::Platform.osx?
      path = expand(path)
      raw = cmd("mdls -raw -name kMDItemDateAdded #{sh_escape(path)}")

      if raw == '(null)'
        1.second.ago
      else
        begin
          DateTime.parse(raw).to_time
        rescue ArgumentError => e
          created_at(path)
        end
      end
    else
      created_at(path)
    end
  end

  private

  def has_tag_available?
    Maid::Platform.has_tag_available?
  end

  def has_tag_available_and_warn?
    if has_tag_available?
      true
    else
      if Maid::Platform.osx?
        warn('To use this feature, you need `tag` installed.  Run `brew install tag`')
      else
        warn('sorry, tagging is unavailable on your platform')
      end

      false
    end
  end

  def sh_escape(array)
    Escape.shell_command(Array(array))
  end

  def log(message)
    @logger.info(message)
  end

  def warn(message)
    @logger.warn(message)
  end

  def expand(path)
    File.expand_path(path)
  end

  def expand_all(paths)
    Array(paths).map { |path| expand(path) }
  end

  def mdls_to_array(path, attribute)
    if Maid::Platform.osx?
      raw = cmd("mdls -raw -name #{sh_escape(attribute)} #{sh_escape(path)}")

      if raw.empty?
        []
      else
        clean = raw[1, raw.length - 2]
        clean.split(/,\s+/).map do |s|
          t = s.strip
          t[1, t.length - 2]
        end
      end
    else
      []
    end
  end

  # Predicate to tell whether the file should be skipped when moving.
  # @param source_path [String] the path to the source file
  # @param destination_dir [String] the path to the destination directory
  # @param clobber [Boolean] `true` to overwrite existing destination file,
  # `false` otherwise
  # @return [Boolean] whether to skip the move
  def skip_move?(source_path, destination_dir, clobber)
    destination_path = File.join(destination_dir, File.basename(source_path))

    # if the destination file doesn't exist, we can move.
    return false unless File.exist?(destination_path)

    log("#{destination_path} already exists")

    # figure out whether to overwrite the existing destination file.
    if clobber
      log("clobbering enabled, moving #{File.basename(source_path)} anyway")

      return false
    end
    log("clobbering disabled, skipping move for #{File.basename(source_path)}")

    true
  end
end