acts_as_filesystem.rb 5.12 KB
module ActsAsFileSystem

  module ClassMethods

    # Declares the ActiveRecord model to acts like a filesystem: objects are
    # arranged in a tree (liks acts_as_tree), and . The underlying table must
    # have the following fields:
    #
    # * name (+:string+) - the title of the object
    # * slug (+:string+)- the title turned in a URL-friendly string (downcased,
    #   non-ascii chars transliterated into ascii, all sequences of
    #   non-alphanumericd characters changed into dashed)
    # * path (+:text+)- stores the full path of the object (the full path of
    #   the parent, a "/" and the slug of the object)
    # * children_count - a cache of the number of children elements.
    def acts_as_filesystem

      include ActsAsFileSystem::InstanceMethods

      # a filesystem is a tree
      acts_as_tree :order => 'name', :counter_cache => :children_count

      # calculate the right path
      before_create do |record|
        if record.path == record.slug && (! record.top_level?)
          record.path = record.calculate_path
        end
        true
      end

      # when renaming a category, all children categories must have their paths
      # recalculated
      after_update do |record|
        if record.recalculate_path
          record.children.each do |item|
            item.path = item.calculate_path
            item.recalculate_path = true
            item.save!
          end
        end
        record.recalculate_path = false
        true
      end

    end
  end

  module InstanceMethods
    # used to know when to trigger batch renaming
    attr_accessor :recalculate_path

    # calculates the full name of a category by accessing the name of all its
    # ancestors.
    #
    # If you have this category hierarchy:
    #   Category "A"
    #     Category "B"
    #       Category "C"
    #
    # Then Category "C" will have "A/B/C" as its full name.
    def full_name(sep = '/')
      self.hierarchy.map {|item| item.name || '?' }.join(sep)
    end

    # gets the name without leading parents. Usefull when dividing categories
    # in top-level groups and full names must not include the top-level
    # category which is already a emphasized label
    def full_name_without_leading(count, sep = '/')
      parts = self.full_name(sep).split(sep)
      count.times { parts.shift }
      parts.join(sep)
    end

    # calculates the level of the category in the category hierarchy. Top-level
    # categories have level 0; the children of the top-level categories have
    # level 1; the children of categories with level 1 have level 2, and so on.
    #
    #      A    level 0
    #     / \
    #    B   C  level 1
    #   / \ / \
    #   E F G H level 2
    #     ...
    def level
      self.parent ? (self.parent.level + 1) : 0
    end

    # Is this category a top-level category?
    def top_level?
      self.parent.nil?
    end

    # Is this category a leaf in the hierarchy tree of categories?
    #
    # Being a leaf means that this category has no subcategories.
    def leaf?
      self.children.empty?
    end

    def set_name(value)
      if self.name != value
        self.recalculate_path = true
      end
      self[:name] = value
    end

    # sets the name of the category. Also sets #slug accordingly.
    def name=(value)
      self.set_name(value)
      unless self.name.blank?
        self.slug = self.name.to_slug
      end
    end

    # sets the slug of the category. Also sets the path with the new slug value.
    def slug=(value)
      self[:slug] = value
      unless self.slug.blank?
        self.path = self.calculate_path
      end
    end

    # calculates the full path to this category using parent's path.
    def calculate_path
      if self.top_level?
        self.slug
      else
        self.parent.calculate_path + "/" + self.slug
      end
    end

    def top_ancestor
      self.top_level? ? self : self.parent.top_ancestor
    end

    def explode_path
      path.split(/\//)
    end

    # returns the full hierarchy from the top-level item to this one. For
    # example, if item1 has a children item2 and item2 has a children item3,
    # then item3's hierarchy would be [item1, item2, item3].
    #
    # If +reload+ is passed as +true+, then the hierarchy is reload (usefull
    # when the ActiveRecord object was modified in some way, or just after
    # changing parent)
    def hierarchy(reload = false)
      if reload
        @hierarchy = nil
      end

      unless @hierarchy
        @hierarchy = []
        item = self
        while item
          @hierarchy.unshift(item)
          item = item.parent
        end
      end

      @hierarchy
    end

    def map_traversal(&block)
      result = []
      current_level = [self]

      while !current_level.empty?
        result += current_level
        ids = current_level.select {|item| item.children_count > 0}.map(&:id)
        break if ids.empty?
        current_level = self.class.base_class.find(:all, :conditions => { :parent_id => ids})
      end
      block ||= (lambda { |x| x })
      result.map(&block)
    end

    def all_children
      res = map_traversal
      res.shift
      res
    end

  end
end

ActiveRecord::Base.extend ActsAsFileSystem::ClassMethods