# frozen_string_literal: true require 'active_support/concern' # Adds the necessary functionality for an ActiveRecord object to have a slug # # See the README for usage instructions and details. module Schnecke extend ActiveSupport::Concern SCHNECKE_DEFAULT_SLUG_COLUMN = :slug SCHNECKE_DEFAULT_SLUG_SEPARATOR = '-' SCHNECKE_DEFAULT_MAX_LENGTH = 32 SCHNECKE_DEFAULT_REQUIRED_FORMAT = /\A[a-z0-9\-_]+\z/ class_methods do # rubocop:disable Metrics/AbcSize def slug(source, opts = {}) class_attribute :schnecke_config # Save the configuration self.schnecke_config = { slug_source: source, slug_column: opts.fetch(:column, SCHNECKE_DEFAULT_SLUG_COLUMN), slug_separator: opts.fetch(:separator, SCHNECKE_DEFAULT_SLUG_SEPARATOR), limit_length: opts.fetch(:limit_length, SCHNECKE_DEFAULT_MAX_LENGTH), required: opts.fetch(:required, true), generate_on_blank: opts.fetch(:generate_on_blank, true), require_format: opts.fetch( :require_format, SCHNECKE_DEFAULT_REQUIRED_FORMAT ), uniqueness: opts.fetch(:uniqueness, {}) } # Setup the validations for the slug validates_uniqueness_of schnecke_config[:slug_column], schnecke_config[:uniqueness] if schnecke_config[:required] validates schnecke_config[:slug_column], presence: true end if schnecke_config[:require_format] validates \ schnecke_config[:slug_column], format: { with: schnecke_config[:require_format], message: 'contains invalid characters. Only ' \ "#{schnecke_config[:require_format]} are allowed" } end # Ensure the slug gets created automatically before_validation :assign_slug, on: :create include InstanceMethods end # rubocop:enable Metrics/AbcSize end # Instance methods to include module InstanceMethods # Assign the slug # # This is automatically called before model validation. # # Note, a slug will not be assigned if one already exists. If one needs to # force the assignment of a slug, pass `force: true` def assign_slug(opts = {}) before_assign_slug(opts) perform_slug_assign(opts) after_assign_slug(opts) end # Reassign the slug # # Unlike assign_slug, this will cause a slug to be created even if one # already exists. def reassign_slug(opts = {}) opts[:force] = true assign_slug(opts) end # Callback that is handled before the slug assignment process. # # When this is called, no validations, or decisions about whether or not # a slug should be created have been made. As such, this will always run # regardless of whether or not the slug assignment process proceeds or not. def before_assign_slug(opts = {}) # Left blank, but can be implemented by user end # Callback that is handled after the slug is assigned # # Unless an error is raised during the slug assignment process, this method # will always be called regardless of whether or not the slug was assigned def after_assign_slug(opts = {}) # Left blank, but can be implemented by user end protected # Assign the slug # # This is automatically called before model validation. # # Note, a slug will not be assigned if one already exists. If one needs to # force the assignment of a slug, pass `force: true` def perform_slug_assign(opts = {}) validate_slug_source validate_slug_column return if !should_create_slug? && !opts[:force] # Generate the slug candidate_slug = slugify_source(schnecke_config[:slug_source]) # If slugify returned a blank string, create one not based on the # source if candidate_slug.blank? && schnecke_config[:generate_on_blank] candidate_slug = slugify_blank end # Make sure it is not too long candidate_slug = truncate_slug(candidate_slug) # If there is a duplicate, create a unique one if slug_exists?(candidate_slug) candidate_slug = slugify_duplicate(candidate_slug) end self[schnecke_config[:slug_column]] = candidate_slug end # Slugify a string # # This will take a string and convert it to a slug by removing punctuation # and then ensuring it is downcased and has the special characters removed # # This can be overriden if a different slug generation method is needed def slugify(str) return str if str.blank? str = str.gsub(/[\p{Pc}\p{Ps}\p{Pe}\p{Pi}\p{Pf}\p{Po}]/, '') str.parameterize end # Default slug for blank strings. # # The slug to use if the string we were to use to slugify returns a blank # slug. # # This can be overriden if a different slug generation method is needed def slugify_blank self.class.to_s.demodulize.underscore.dasherize end # Handle the creation of a unique slug # # This assumes that the slug has already been generated with `slugify` but # that this value is non-unique. As such we will append '-n' to the end of # the slug to make it unique. The second instance gets a '-2' suffix, the # third will get a '-3', and so forth. # # This can be overriden if a different behavior is desired def slugify_duplicate(slug) return slug if slug.blank? seq = 2 new_slug = slug_concat([slug, seq]) while slug_exists?(new_slug) seq += 1 new_slug = slug_concat([slug, seq]) end new_slug end # Concatenate multiple slugified parts together # # This is used in, if the slug is to be generated from multiple # attributes of the model (e.g. [:first_name, :last_name]) and when we # append a number to the end of a slug if the initial slug wsa non-unique. # # This can be overriden if a different behavior is desired def slug_concat(parts) parts.join(schnecke_config[:slug_separator]) end private def validate_slug_source source = arrayify(schnecke_config[:slug_source]) source.each do |attr| unless respond_to?(attr, true) raise ArgumentError, "Source '#{attr}' does not exist." end end end def validate_slug_column return if respond_to?("#{schnecke_config[:slug_column]}=") raise ArgumentError, "Slug column '#{schnecke_config[:slug_column]}' does not " \ 'exist.' end def should_create_slug? self[schnecke_config[:slug_column]].blank? end def slugify_source(source) parts = arrayify(source).map do |part| slugify(send(part)) end parts.join(schnecke_config[:slug_separator]) end def truncate_slug(slug) return slug if slug.blank? return slug if schnecke_config[:limit_length].blank? slug[0, schnecke_config[:limit_length]] end def slug_exists?(slug) slug_scope.exists?(schnecke_config[:slug_column] => slug) end def slug_scope query = self.class.base_class if schnecke_config[:uniqueness].present? if schnecke_config[:uniqueness][:scope].present? scopes = arrayify(schnecke_config[:uniqueness][:scope]) scopes.each do |scope| query = query.where(scope => send(scope)) end elsif schnecke_config[:uniqueness][:conditions].present? raise 'Cannot handle uniqueness constraint parameter `:conditions`' end end query end def arrayify(obj) return obj if obj.is_a?(Array) [obj] end end end