diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index dea9dc9..89d3d10 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -10,6 +10,22 @@ class PostsController < ApplicationController @post = find_post(@site) end + def new + @site = find_site + @post = Post.new(site: @site, front_matter: { date: Time.now }) + end + + def create + @site = find_site + @post = Post.new(site: @site, front_matter: post_params.to_hash) + + if @post.save + redirect_to site_posts_path(@site) + else + render 'posts/new' + end + end + def edit @site = find_site @post = find_post(@site) @@ -20,12 +36,8 @@ class PostsController < ApplicationController @site = find_site @post = find_post(@site) - # crear un array a partir de una cadena separada por comas - [:tags,:categories].each do |comma| - p[comma] = p.dig(comma).split(',').map(&:strip) - end - @post.update_attributes(p) + if @post.save redirect_to site_post_path(@site, @post) else diff --git a/app/models/post.rb b/app/models/post.rb index d2f46c2..4c15b7b 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -3,119 +3,180 @@ require 'jekyll/utils' # Esta clase representa un post en un sitio jekyll e incluye métodos # para modificarlos y crear nuevos +# +# Cuando estamos editando un post, instanciamos este modelo y le +# asociamos el Jekyll::Document correspondiente. +# +# Cuando estamos creando un post, no creamos su Jekyll::Document +# hasta que se guardan los datos, porque para poder guardarlo +# necesitamos su front_matter completo. +# +# El front matter está duplicado. El Post mantiene una copia de los +# datos y los sincroniza al momento de leer y de escribir el Document. class Post attr_accessor :content, :front_matter - attr_reader :post, :site, :errors + attr_reader :post, :site, :errors, :old_post - REJECT_FROM_DATA = %w[excerpt slug draft date ext].freeze + REJECT_FROM_DATA = %w[excerpt].freeze + # datos que no tienen que terminar en el front matter + REJECT_FROM_FRONT_MATTER = %w[date slug draft ext].freeze # Trabajar con posts. Si estamos creando uno nuevo, el **site** y # el **front_matter** son necesarios, sino, **site** y **post**. # XXX chequear que se den las condiciones def initialize(site:, post: nil, front_matter: {}) - raise ArgumentError, I18n.t('posts.errors.site') unless site.is_a?(Site) - raise ArgumentError, I18n.t('posts.errors.post') unless post.is_a?(Jekyll::Document) + unless site.is_a?(Site) + raise ArgumentError, + I18n.t('errors.argument_error', argument: :site, class: Site) + end + + unless post.nil? || post.is_a?(Jekyll::Document) + raise ArgumentError, + I18n.t('errors.argument_error', argument: :post, + class: Jekyll::Document) + end @site = site @post = post # los errores tienen que ser un hash para que # ActiveModel pueda traer los errores normalmente @errors = {} - @front_matter = front_matter - # Crea un post nuevo si no especificamos el post - new_post unless @post - load_data! unless new? + # sincronizar los datos del document + if new? + @front_matter = {} + update_attributes front_matter + else + load_front_matter! + merge_with_front_matter! front_matter.stringify_keys + end end - # Si el archivo destino no existe, el post es nuevo + # Limpiar los errores + def reset_errors! + @errors = {} + end + + # El post es nuevo si no hay un documento asociado def new? - !File.exist? @post.path + @post.nil? || !File.exist?(@post.try(:path)) end - # Guarda los cambios + # Guarda los cambios. + # + # Recién cuando vamos a guardar creamos el Post, porque ya tenemos + # todos los datos para escribir el archivo, que es la condición + # necesaria para poder crearlo :P def save - merge_data_with_front_matter! - clean_content! + cleanup! + + return false unless valid? + + new_post if new? return unless write return unless detect_file_rename! # Vuelve a leer el post para tomar los cambios @post.read + add_post_to_site! true end alias :save! :save def title - get_metadata 'title' + get_front_matter 'title' end def date - get_metadata 'date' + get_front_matter 'date' end def tags - get_metadata 'tags' + get_front_matter 'tags' end def categories - get_metadata 'categories' + get_front_matter 'categories' end alias :category :categories + # Devuelve la ruta del post, si se cambió alguno de los datos, + # generamos una ruta nueva para tener siempre la ruta actualizada. def path basename_changed? ? File.join(@site.path, '_posts', basename_from_front_matter) : @post.try(:path) end + # TODO los slugs se pueden repetir, el identificador real sería + # fecha+slug, pero se ve feo en las urls? def id - get_metadata 'slug' + get_front_matter 'slug' end alias :slug :id alias :to_s :id def basename_changed? - @post.basename != basename_from_front_matter - end - - def data - @post.data + @post.try(:basename) != basename_from_front_matter end def content - @content ||= @post.content + @content ||= @post.try :content end # imita Model.update_attributes de ActiveRecord def update_attributes(attrs) - attrs.each_pair do |k,i| - # el contenido no es metadata - if k == 'content' - @content = i - else - set_metadata k.to_sym, i - end - end + # el cuerpo se maneja por separado + @content = attrs.delete('content') if attrs.key? 'content' + merge_with_front_matter! attrs.stringify_keys + end + + # Requisitos para que el post sea válido + def validate + add_error validate: I18n.t('posts.errors.date') unless get_front_matter('date').is_a? Time + add_error validate: I18n.t('posts.errors.title') if get_front_matter('title').blank? + # TODO verificar que el id sea único + end + + def valid? + reset_errors! + validate + + @errors.empty? + end + + # Permite ordenar los posts + def <=>(other) + @post <=> other.post end private + # Genera un post nuevo y lo agrega a la colección del sitio. def new_post - opts = { site: @site, collection: @site.jekyll.posts } + opts = { site: @site.jekyll, collection: @site.jekyll.posts } @post = Jekyll::Document.new(path, opts) + end + + # Solo agregar el post al sitio una vez que lo guardamos + # + # TODO no sería la forma correcta de hacerlo en Rails + def add_post_to_site! @site.jekyll.posts.docs << @post @site.jekyll.posts.docs.sort! - @post + @site.posts << self unless @site.posts.include? self + @site.posts.sort! end - # Obtiene metadatos - def get_metadata(name) - @front_matter.key?(name) ? @front_matter.dig(name) : @post.data[name] + # Obtiene metadatos asegurándose que siempre trabajamos con strings + def get_front_matter(name) + @front_matter.dig(name.to_s) end - def set_metadata(name, value) - @front_matter[name] = value + # Los define, asegurandose que las llaves siempre son strings, para no + # tener incompatibilidades con jekyll + def set_front_matter(name, value) + @front_matter[name.to_s] = value end # Cambiar el nombre del archivo si cambió el título o la fecha. @@ -123,18 +184,17 @@ class Post # engañamos eliminando la instancia de @post y recargando otra. def detect_file_rename! return true unless basename_changed? + # No eliminamos el archivo a menos que ya exista el reemplazo! + return false unless File.exist? path - if File.exist? path - add_error path: I18n.t('posts.errors.path') - return - end - - FileUtils.mv @post.path, path - replace_post! path + Rails.logger.info I18n.t('posts.logger.rm', path: path) + FileUtils.rm @post.path + replace_post! end - def replace_post!(path) - @site.jekyll.posts.docs.delete @post + # Reemplaza el post en el sitio por uno nuevo + def replace_post! + @old_post = @site.jekyll.posts.docs.delete @post new_post end @@ -142,40 +202,47 @@ class Post # Obtiene el nombre del archivo a partir de los datos que le # pasemos def basename_from_front_matter - _date = @front_matter[:date] - date = _date.respond_to?(:strftime) ? _date.strftime('%F') : _date - title = get_metadata 'slug' || Jekyll::Utils.slugify(@front_matter[:title]) - ext = get_metadata 'ext' || '.markdown' + date = get_front_matter('date').strftime('%F') + slug = get_front_matter('slug') + slug = + ext = get_front_matter('ext') || '.markdown' - "#{date}-#{title}#{ext}" + "#{date}-#{slug}#{ext}" end # Toma los datos del front matter local y los mueve a los datos # que van a ir al post. Si hay símbolos se convierten a cadenas, # porque Jekyll trabaja con cadenas. Se excluyen otros datos que no # van en el frontmatter - def merge_data_with_front_matter! - @data.merge! Hash[@front_matter.map do |k, v| - [k.to_s, v] unless REJECT_FROM_DATA.include? k + def merge_with_front_matter!(params) + @front_matter.merge! Hash[params.to_hash.map do |k, v| + [k, v] unless REJECT_FROM_DATA.include? k end.compact] end # Carga una copia de los datos del post original excluyendo datos # que no nos interesan - def load_data! - @data ||= @post.data.reject do |key, _| + def load_front_matter! + @front_matter = @post.data.reject do |key, _| REJECT_FROM_DATA.include? key end end + def cleanup! + things_to_arrays! + date_to_time! + clean_content! + slugify_title! + end + # Aplica limpiezas básicas del contenido def clean_content! - @content = @content.delete("\r") + @content.try(:delete!, "\r") end # Guarda los cambios en el archivo destino def write - r = File.open(@post.path, File::RDWR | File::CREAT, 0o640) do |f| + r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f| # Bloquear el archivo para que no sea accedido por otro # proceso u otra editora f.flock(File::LOCK_EX) @@ -197,8 +264,14 @@ class Post false end + # Genera el post con front matter, menos los campos que no necesitamos + # que estén en el front matter def full_content - "#{@data.to_yaml}---\n\n#{@content}" + yaml = @front_matter.reject do |k,_| + REJECT_FROM_FRONT_MATTER.include? k + end + + "#{yaml.to_yaml}---\n\n#{@content}" end def add_error(hash) @@ -212,4 +285,23 @@ class Post @errors end + + def date_to_time! + unless @front_matter.dig(:date).is_a? Time + @front_matter['date'] = @front_matter.dig('date').try(:to_time) || Time.now + end + end + + def things_to_arrays! + [:tags,:categories].each do |c| + @front_matter[c.to_s] = @front_matter.dig(c.to_s).split(',').map(&:strip) + end + end + + def slugify_title! + if get_front_matter('slug').blank? + title = get_front_matter('title') + @front_matter['slug'] = Jekyll::Utils.slugify(title) + end + end end diff --git a/app/models/site.rb b/app/models/site.rb index 8071214..cbbfadd 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -26,6 +26,8 @@ class Site def posts return @posts if @posts + Rails.logger.info 'Procesando posts' + if @jekyll.posts.docs.empty? @jekyll.read # Queremos saber cuantas veces releemos los articulos diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml index 3d644df..9a801cc 100644 --- a/app/views/posts/_form.haml +++ b/app/views/posts/_form.haml @@ -1,10 +1,17 @@ - unless @post.errors.empty? .alert.alert-danger %ul - - @post.errors.each do |error| + - @post.errors.each do |_,error| %li= error -= form_tag site_post_path(@site, @post), method: :patch, class: 'form' do +-# TODO habilitar form_for +- if @post.new? + - url = site_posts_path(@site) + - method = :post +- else + - url = site_post_path(@site, @post) + - method = :patch += form_tag url, method: method, class: 'form' do .form-group = submit_tag t('posts.save'), class: 'btn btn-success' .form-group @@ -12,20 +19,21 @@ placeholder: t('posts.title') .form-group = text_area_tag 'post[content]', @post.content, autofocus: true, - class: 'form-control post-content', data: { provide: 'markdown' } + class: 'form-control post-content', data: { provide: 'markdown' }, + cols: 72, wrap: 'hard' .form-group = label_tag 'post_date', t('posts.date') - = date_field 'post', 'date', value: @post.date.strftime('%F'), + = date_field 'post', 'date', value: @post.date.try(:strftime, '%F'), class: 'form-control' %small.text-muted.form-text= t('posts.date_help') .form-group = label_tag 'post_categories', t('posts.categories') - = text_field 'post', 'categories', value: @post.categories.join(', '), + = text_field 'post', 'categories', value: @post.categories.try(:join, ', '), class: 'form-control' %small.text-muted.form-text= t('posts.tags_help') .form-group = label_tag 'post_tags', t('posts.tags') - = text_field 'post', 'tags', value: @post.tags.join(', '), + = text_field 'post', 'tags', value: @post.tags.try(:join, ', '), class: 'form-control' %small.text-muted.form-text= t('posts.tags_help') .form-group diff --git a/app/views/posts/edit.haml b/app/views/posts/edit.haml index cc29197..f327f1b 100644 --- a/app/views/posts/edit.haml +++ b/app/views/posts/edit.haml @@ -1,9 +1,3 @@ -.container-fluid - .row - %h1 - = t('posts.editing') - = @post.title - - .row - .col - = render 'posts/form' +.row + .col + = render 'posts/form' diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index 48492cc..98a2d9b 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -1,8 +1,17 @@ -%h1= @site.name +.row + .col + %h1= @site.name -%table.table.table-condensed.table-striped - %tbody - - @site.posts.each do |post| - %tr - %td= link_to post.title, site_post_path(@site, post) - %td= post.date.strftime('%F') +.row + .col + = link_to t('posts.new'), new_site_post_path(@site), + class: 'btn btn-success' + +.row + .col + %table.table.table-condensed.table-striped + %tbody + - @site.posts.each do |post| + %tr + %td= link_to post.title, site_post_path(@site, post) + %td= post.date.strftime('%F') diff --git a/app/views/posts/new.haml b/app/views/posts/new.haml new file mode 100644 index 0000000..f327f1b --- /dev/null +++ b/app/views/posts/new.haml @@ -0,0 +1,3 @@ +.row + .col + = render 'posts/form' diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index ce7bcf2..174cbee 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -1,27 +1,31 @@ .row + .col + %h1= @post.title - %h1= @post.title +.row + .col + = link_to t('posts.edit'), edit_site_post_path(@site, @post), + class: 'btn btn-info' - = link_to t('posts.edit'), edit_site_post_path(@site, @post), - class: 'btn btn-info' +.row + .col + .content + :markdown + #{@post.content} - .content - :markdown - #{@post.content} - - %table.table.table-condensed.table-striped - %tbody - - @post.data.each do |key, data| - %tr - %th= key - %td - - if data.respond_to? :each - - data.each do |d| - %span.badge.badge-success= d - - elsif data.respond_to? :content - :markdown - #{data.content} - - elsif data.respond_to? :strftime - = data.strftime('%F') - - else - = data + %table.table.table-condensed.table-striped + %tbody + - @post.front_matter.each do |key, data| + %tr + %th= key + %td + - if data.respond_to? :each + - data.each do |d| + %span.badge.badge-success= d + - elsif data.respond_to? :content + :markdown + #{data.content} + - elsif data.respond_to? :strftime + = data.strftime('%F') + - else + = data diff --git a/config/locales/en.yml b/config/locales/en.yml index 1e7c254..1de8119 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,6 @@ en: + errors: + argument_error: 'Argument `%{argument}` must be an instance of %{class}' login: email: 'E-mail' password: 'Password' @@ -19,8 +21,11 @@ en: categories_help: 'Comma separated!' slug: 'Slug' slug_help: 'This is the name of the article on the URL, ie. /title/. You can leave it empty.' + logger: + rm: 'Removed %{path}' errors: - site: 'Argument `site` must be of class Site' - post: 'Argument `post` must be of class Post' path: 'File already exist' file: "Couldn't write the file" + title: 'Post needs a title' + date: 'Post needs a valid date' + diff --git a/config/locales/es.yml b/config/locales/es.yml index d7a1339..7f19b02 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,6 @@ es: + errors: + argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}' login: email: 'Dirección de correo' password: 'Contraseña' @@ -17,6 +19,10 @@ es: categories_help: '¡Separadas por comas!' slug: 'Nombre la URL' slug_help: 'Esto es el nombre del artículo en la URL, por ejemplo /título/. Puedes dejarlo vacío.' + logger: + rm: 'Eliminado %{path}' errors: path: 'El archivo destino ya existe' file: 'No se pudo escribir el archivo' + title: 'Necesita un título' + date: 'Necesita una fecha' diff --git a/yarn.lock b/yarn.lock index f11192f..b0cbb71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8,7 +8,7 @@ abbrev@1: "bootstrap-markdown@https://0xacab.org/itacate-kefir/bootstrap-markdown.git": version "2.10.0" - resolved "https://0xacab.org/itacate-kefir/bootstrap-markdown.git#449141351caa8d96e99fe09f02772ab91e578ab4" + resolved "https://0xacab.org/itacate-kefir/bootstrap-markdown.git#580184dc214ea2364fca0fdcb70e3c5e08bd5605" markdown@^0.5.0: version "0.5.0"