# frozen_string_literal: true module MetadataTemplateConstants ALLOWED_ATTRIBUTES = %w[style href src alt controls data-align data-multimedia data-multimedia-inner id name].freeze ALLOWED_TAGS = %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote figcaption a sub sup small].freeze end # Representa la plantilla de un campo en los metadatos del artículo # # TODO: Validar el tipo de valor pasado a value= según el :type # @todo ¿Es necesario pasar :type, :label, :help, :required si se pueden # obtener del layout a través de :name? MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, :value, :help, :required, :errors, :post, :layout, keyword_init: true) do include MetadataTemplateConstants include Metadata::FrontMatterConcern # @return [String] def inspect "#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>" end # Queremos que los artículos nuevos siempre cacheen, si usamos el UUID # siempre vamos a obtener un item nuevo. # # @return [String] def cache_key @cache_key ||= if post.new? "#{layout.value}/#{name}" else "post/#{post.uuid.value}/#{name}" end end # Genera una versión de caché en base a la fecha de modificación del # Post, el valor actual y los valores posibles, de forma que cualquier # cambio permita renovar la caché. # # @todo no será demasiado traer #values acá # @return [String] def cache_version post.cache_version + value.hash.to_s + values.hash.to_s end # @return [String] def cache_key_with_version "#{cache_key}-#{cache_version}" end # @todo: Deberíamos sanitizar durante la asignación? def value=(new_value) @value_was = value self[:value] = new_value end # Siempre obtener el valor actual y solo obtenerlo del documento una # vez. # # @return [any] def value_was return @value_was if instance_variable_defined? '@value_was' @value_was = document_value end # ¿Cambió? # # @return [Boolean] def changed? value_was != value end # Obtiene el valor del JekyllDocument # # @return [any] def document_value document.data[name.to_s] end # Trae el idioma actual del sitio o del panel # @return [String] def lang @lang ||= post&.lang&.value || I18n.locale end # El valor por defecto def default_value layout.metadata.dig(name, 'default', lang.to_s) end # Valores posibles, busca todos los valores actuales en otros # artículos del mismo sitio # # @return [Array] def values site.indexed_posts.everything_of(name) end # Valor actual o por defecto. Al memoizarlo podemos modificarlo # usando otros métodos que el de asignación. def value self[:value] ||= if (data = document_value).present? if private? decrypt(data) else data end else default_value end end # Detecta si el valor está vacío # # @return [Boolean] def empty? value.blank? end # Comprueba si el metadato es válido # # @return [Boolean] def valid? validate end # Ejecuta validaciones # # @return [Boolean] def validate self.errors = [] errors << I18n.t('metadata.cant_be_empty') unless can_be_empty? errors.empty? end # Usa el valor por defecto para generar el formato de StrongParam # necesario. # # @return [Symbol,Hash] def to_param case default_value when Hash then { name => default_value.keys.map(&:to_sym) } when Array then { name => [] } else name end end # Convierte el valor a String # # @return [String] def to_s value.to_s end # En caso de que algún campo necesite realizar acciones antes de ser # guardado # # @return [Boolean] def save unless changed? self[:value] = document_value if private? return true end self[:value] = sanitize value self[:value] = encrypt(value) if private? true end # Métodos relacionados # # @return [Array] def related_methods @related_methods ||= [].freeze end # Determina si el campo es privado y debería ser cifrado # # @return [Boolean] def private? layout.metadata.dig(name, 'private').present? end # Determina si el campo debería estar deshabilitado # # @return [Boolean] def disabled? layout.metadata.dig(name, 'disabled') || !writable? end # Determina si el campo es de solo lectura # # once => el campo solo se puede modificar si estaba vacío # # @return [Boolean] def writable? case layout.metadata.dig(name, 'writable') when 'once' then value.blank? else true end end private # Si es obligatorio no puede estar vacío # # @return [Boolean] def can_be_empty? true unless required && empty? end # No usamos sanitize_action_text_content porque espera un ActionText # # Ver ActionText::ContentHelper#sanitize_action_text_content # # @param :string [String] # @return [String,nil] def sanitize(string) return if string.nil? return string unless string.is_a? String sanitizer .sanitize(string.tr("\r", '').unicode_normalize, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) .strip .html_safe end # @return [Rails::Html::Sanitizer] def sanitizer @sanitizer ||= Rails::Html::Sanitizer.safe_list_sanitizer.new end # Decifra el valor # # @todo Agregar el error de decifrado en self.errors # @todo Mover a su propio Concern # @param :value [String] # @return [String,nil] def decrypt(value) return value if value.blank? box.decrypt_str value.to_s rescue Lockbox::DecryptionError => e if value.to_s.include? ' ' value else ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name }) I18n.t('lockbox.help.decryption_error') end end # Cifra el valor. # # @todo serializar como JSON # @param :value [String] # @return [String] def encrypt(value) box.encrypt value.to_s end # Genera una lockbox a partir de la llave privada del sitio # # @return [Lockbox] def box @box ||= Lockbox.new key: site.private_key, padding: true, encode: true end end