Proyecto para el Trabajo Práctico Integrador de la cursada 2020-2021 de la materia Taller de Tecnologías de Producción de Software - Opción Ruby, de la Facultad de Informática de la Universidad Nacional de La Plata.
Ruby Notes, o simplemente rn
, es un gestor de notas concebido como un clon simplificado
de la excelente herramienta TomBoy.
Luego de instalar las dependencias descriptas más abajo, acceder a localhost:3000
y crear un usuario.
Para ejecutar el comando principal de la herramienta se utiliza el script
bin/rn
, el cual puede correrse de las siguientes manera:$ ruby bin/rn [args]O bien:
$ bundle exec bin/rn [args]
O simplemente:
$ bin/rn [args]Si se agrega el directorio
bin/
del proyecto a la variable de ambientePATH
de la shell, el comando puede utilizarse sin prefijarbin/
:# Esto debe ejecutarse estando ubicad@ en el directorio raiz del proyecto, una única vez # por sesión de la shell $ export PATH="$(pwd)/bin:$PATH" $ rn [args]
$ git clone https://github.com/pibytes/rn
$ cd rn
$ bundle install
Nota: Bundler debería estar disponible en tu instalación de Ruby, pero si por algún motivo al intentar ejecutar el comando
bundle
obtenés un error indicando que no se encuentra el comando, podés instalarlo mediante el siguiente comando:$ gem install bundler
Luego hay dependencias de node
$ yarn install
Por último inicializar la base de datos:
$ rails db:create
$ rails db:migrate
Y así ya se puede correr el servidor local:
$ rails server
lib/
: directorio que contiene todas las clases del modelo y de soporte para la ejecución del programabin/rn
.lib/rn.rb
es la declaración del namespaceRN
, y las directivas de carga de clases o módulos que estén contenidos directamente por éste (autoload
).lib/rn/
es el directorio que representa el namespaceRN
. Notá la convención de que el uso de un módulo como namespace se refleja en la estructura de archivos del proyecto como un directorio con el mismo nombre que el archivo.rb
que define el módulo, pero sin la terminación.rb
. Dentro de este directorio se ubicarán los elementos del proyecto que estén bajo el namespaceRN
- que, también por convención y para facilitar la organización, deberían ser todos. Es en este directorio donde deberías ubicar tus clases de modelo, módulos, clases de soporte, etc. Tené en cuenta que para que todo funcione correctamente, seguramente debas agregar nuevas directivas de carga en la definición del namespaceRN
(o dónde corresponda, según tus decisiones de diseño).lib/rn/commands.rb
ylib/rn/commands/*.rb
son las definiciones de comandos dedry-cli
que se utilizarán. En estos archivos es donde comenzarás a realizar la implementación de las operaciones en sí, que en esta plantilla están provistas como simples disparadores.lib/rn/version.rb
define la versión de la herramienta, utilizando SemVer.
bin/
: directorio donde reside cualquier archivo ejecutable, siendo el más notoriorn
que se utiliza como punto de entrada para el uso de la herramienta.
-
Armado de ejemplos en las mismas llamadas a los comandos
-
https://stackoverflow.com/questions/36350321/errnoenoent-no-such-file-or-directory-rb-sysopen
-
Editor de notas "a mano"
prompt = "RN>> " eof = "EON" eof_feedback = " [End Of Note]\n" File.open(file, File::RDWR|File::CREAT, 0644) {|f| f.flock(File::LOCK_EX) f.rewind print "\nWrite the contents of the note below.\nYou can write multiple lines.\nEnd the note with '#{eof}' + [Enter].\n\n#{prompt}" content = "" input_line = STDIN.gets while input_line.chomp != eof content << input_line print "#{prompt}" input_line = STDIN.gets end print eof_feedback f.write content f.truncate(f.pos) }
-
Separar Modelo de Comandos
-
Separar manejo de archivos de Modelo
-
Elegir formato de texto plano:
-
Elegir formato rico:
- HTML
-
Para convertir de texto Markdown a HTML estuve probando dos librerías redcarpet y github-markdown. El problema que tenia este último es que necesita que los archivos sean
.markdown
y tuve que hacer archivos temporales porque no quería modificar demasiado como se guardaban los archivos desde antes. Entonces a la hora de exportar un archivo se hace una copia a a una carpeta temporal con la extensión correspondiente para que pueda leerlo ese modulo. No me gustaba esta estrategia por eso termine decidiéndome a último momento por redcarpet que además tiene mejor documentación y me fué mas sencillo de configurar. -
Un problema que tuve para agregar el comando
--export
fue que no sabía si ponerlo en la parte de notas, o en la parte de libros (n
yb
son los argumentos principales). El un principio pensé ponerlo del lado de libro y que se use de la siguiente manera:rn b export --note unaNota --book unLibro # para exportar las notas "unaNota" de "unLibro" rn b export --global # para exportar las notas globales rn b export --book unLibro # para exportar las notas de unLibro rn b export # para exportar todas las notas
El problema era que se usaba
--book
para indicar el libro con la intención de exportar todas las notas de ese libro se podía pisar con cuando se usa esa misma opción para indicar el libro al que perteneces una nota. Por ello decidí ponerlo bajo el comando de notas (aunque pensándolo ahora podría accederse de ambos "lados"). Asi:rn n export unaNota --book unLibro # para exportar las notas "unaNota" de "unLibro" rn n export --global # para exportar las notas globales rn n export --book unLibro # para exportar las notas de unLibro rn n export # para exportar todas las notas
Por lo tanto cuando el argumento de la nota es nulo, se toma que se exportaran todas las notas de el libro indicado por la opción
--book
. De todas maneras las notas no conoces a los libros y a las otras notas, por lo que el manejo interno igualmente lo hace la claseBook
, que arma las colecciones pertinentes de notas, pidiéndole los datos a la claseNote
, para luego llamar a la nueva claseExporter
que con ayuda de la claseFileManager
mueve y crea los nuevos archivos. -
Se ofrecen algunas opciones para facilitar el uso, aunque esto fue pensado con el optimismo de tener varios sistemas de marcado y varios formatos de salida, pero ya queda el cli por si en algún momento se imprementa. Para ello uso TTY-prompt
-
Al HTML que genera redcarpet se le agregan unos simples tags para indicar el título y libro de la nota. Se intento embellecerlos con unas librerías sin éxito (codeprettify parece que ya no anda mas, y rouge (tutorial) parece que hay que usarlo con rails, asi que para más adelante).
-
Para intentar la conversión a PDF se estuvo leyendo esto, esto y esto
Esta entrega consiste en portear la aplicación CLI a una aplicación web usando rails. También se agregaran funcionalidades y modificaciones al modelo de datos como la incorporación de usuarios tendrán cierta relación con las notas y libros ya existentes.
A partir del tag: v1.0.1
fuí haciendo commits
luego de cada comando que utilicé para generar código, comentando brevemente el comando utilizado y para que sirve, por lo tanto en el historial de este repositorio se puede ver toda la evolución del desarrollo.
Lo primero que hice fue familiarizarme un poco con el framework leyendo y haciendo algunos ejercicios:
-
Instale rails y todas las dependencias necesarias y hice casi todo el curso, hasta que llegue a la autenticación. Aquí empezaron los primeros problemas:
LoadError: cannot load such file -- bcrypt
Había un problema con esa gema que no se pudo solucionar con
gem install bcrypt --platform=ruby
como sugerían -
Ya comenzando con el trabajo, sabía que necesitaría un editor de texto para editar las notas en la web. Viendo que el más recomendado era
ActionText
me dispuse a instalarlo. Para mi desgracia había un bug que no me permitía instalarlo:ruby bin\rails action_text:install rails app:binstub:yarn Installing JavaScript dependencies run bin/yarn add trix@^1.2.0 @rails/actiontext@^6.1.1 from "." rails aborted! Errno::ENOEXEC: Exec format error - bin/yarn add trix@^1.2.0 @rails/actiontext@^6.1.1 Tasks: TOP => action_text:install
El problema estaba relacionado con utilizar Windows, pero siendo verano y estando en la casa de mis padres, no tenia otra opción. Así que en una maquina virtual cloné el repositorio, instalé todas las dependencias y ahi surgió otro problema de permisos:
rails app:binstub:yarn Installing JavaScript dependencies run bin/yarn add trix@^1.2.0 @rails/actiontext@^6.1.1 from "." rails aborted! Errno::EACCES: Permission denied - bin/yarn Tasks: TOP => action_text:install
La solución que primero se me ocurrió fue simplemente cambiar los permisos de todo el proyecto
sudo chmod -R 777 path/to//project
Pero no surtió efecto. Leyendo un poco mas parecía que están mal los binarios para linux al haberlos clonado de windows. Se arregla con
$ rake app:update:bin
Y así
rails action_text:install
crea los archivos por fin. -
Luego otro problema muy raro con unas credenciales que sinceramente no entendí
ActiveSupport::MessageEncryptor::InvalidMessage
Para arreglarlo hubo que setear un editor de texto
set EDITOR="notepad.exe"
yrails credentials:edit
Comentamos las lineas# aws: # access_key_id: 123 # secret_access_key: 345 # Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. secret_key_base: 89320682a93db53e46cb82a71ee0b8d0dbd9a18d90b1f90a7f377e7d216cfaf9255bc0415be85acb68343a4ea 4680e08507812bdd7d040f03d032a8bf733801d
Sigue pasando. borrar lineas comentadas de config/storage.yml
-
Un truco aprendido cuando se están editando los estilos css, para no tener que hacer refresh todo el tiempo, además de correr el
rails server
se puede correrruby .\bin\webpack-dev-server
Entonces se actualiza al instante.
-
Muchas veces paso que había que modificar alguna tabla para agregar algún atributo y se rompían las migraciones, no salía de "table already exists", entonces con
rails console
y luegoActiveRecord::Migration.drop_table(:users)
Si eso no funcionaba había que borrar directamente los archivos
development.sql
ytest.sql
y volver a crear la bd conrails db:create
/migrate
. -
Para la autenticación y todo el manejo de usuarios decidí usar la gema Devise. Para instalarla había que correr
rails generate devise:install
y luego seguir unos pasos para instalarla1. Ensure you have defined default url options in your environments files. Here is an example of default_url_options appropriate for a development environment in config/environments/development.rb: config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } In production, :host should be set to the actual host of your application. * Required for all applications. * 2. Ensure you have defined root_url to *something* in your config/routes.rb. For example: root to: "home#index" * Not required for API-only Applications * 3. Ensure you have flash messages in app/views/layouts/application.html.erb. For example: <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> * Not required for API-only Applications * 4. You can copy Devise views (for customization) to your app by running: rails g devise:views
Esto último lo use para generar las vistas de Login y Register que luego modifique para que se adapten al diseño de mi aplicación.
-
Llego un momento que tenía que ver como cumplir con el requerimiento de que el Libro por defecto de un usuario no se debe borrar. Primero pensé que podría ser suficiente asegurarme que el usuario no se quede sin ningún Libro en ningún momento, pero luego releyendo la consigna, vi que el usuario no debe tener la capacidad de cambiar de libro por defecto. Asi que decidí que ni bien un usuario se registre debía crear este libro. Por eso:
class User < ApplicationRecord ... def create_default_notebook @user = User.last @book = @user.books.create( name: "#{@user.email.split(/@/)[0]}'s notebook", is_default: true) @user.default_book_id = @book.id @user.save end end
Y luego me aseguro que la variable para cualquier otro libro que se cree sea siempre
false
# POST /books or /books.json def create @book = current_user.books.create(book_params) @book.is_default = false
Ademas protejo que no me puedan "mandar" el formulario trucado por otro lado con ese nombre, con los llamados parámetros fuertes:
# Only allow a list of trusted parameters through. def book_params params.require(:book).permit(:name) end
-
Para evitar que el usuario borre el directorio por defecto entonces lo que hago es tirar una excepción cuando esto sucede, abortando así la destrucción del libro y sus notas. Idea Primero se levanta un error si el libro que se intenta borrar tiene atributo
is_default
class Book < ApplicationRecord ... after_destroy :ensure_default_book_remains class Error < StandardError end protected #or private whatever you need #Raise an error that you trap in your controller to prevent your record being deleted. def ensure_default_book_remains if is_default raise Error.new "Can't delete default book" end end end
Este error se agarra en el controlador, para manejarlo, y poder informarle al usuario.
# en books_controller.rb # DELETE /books/1 or /books/1.json def destroy @book.destroy respond_to do |format| format.html { redirect_to books_url, notice: "Book was successfully destroyed." } format.json { head :no_content } end end #Note, the rescue block is outside the destroy method rescue_from 'Book::Error' do |exception| redirect_to books_path, alert: exception.message end
-
Luego para darle un poco de estilo al proyecto integre AdminLTE
-
Intenté implementar un borrado lógico Pero terminé decidiendo que no valía la pena, ya que para 'backup' está la funcionalidad de bajar los datos en pdf, y además se generan problemas con los borrados (y restauraciones) en cascada por culpa de las relaciones uno a muchos que se dan en el modelo. Como no está especificado en los requerimientos, decidí hacer un borrado físico en cascada. Es decir, cuando se elimina un usuario, se eliminan todos sus libros, y a la vez cada libro que se elimine provocará que se eliminen todas las notas que a él pertenecen. Esto se encuentra debidamente advertido al usuario.
class Book < ApplicationRecord has_many :notes, :dependent => :destroy
-
Para agregar la funcionalidad de bajar los pdf necesitaba una librería que interpretase el html del texto rico de las notas y generase un PDF, para eso use la gema wkhtmltopdf. Obviamente tuvo sus problemas pero finalemente funcionó.
-
También modifiqué algunas cosas en el cuerpo del texto rico de las notas, para que pueda contener diversos tipos de archivos. Sin embargo no logré que estos atributos se embebieran en el pdf, si bien se muestran correctamente en la web.
-
Validaciones pude prescindir de algunas validaciones que tenia en la version anterior debido a que los nombres de las notas eran nombres de archivos físicos y por ende tenian muchas restricciones. Aquí lo único que me interesaba es que no estén en blanco el nombre de los libros y el título. Tambien que no se repitan, aunque solo con alcance de cada usuario. Y ademas quiero que no diferencie entre mayúscula y minúsculas para estas comparaciones. Asi que el
:title
de las notas tiene unicidad con scope a:book
y el:name
de estos tiene unicidad con scope a:user
. Este último ademas tiene un email que si es unico en todo el sistema, y debe ser un email valido, y su contraseña debe tener mas de 6 caracteres.class Book < ApplicationRecord ... validates :name, presence: true, uniqueness: { :case_sensitive => false, scope: :user, message: "of book already exists in your collection" }
-
Para el tema de los permisos pensé en utilizar una gema que se llama CanCanCan, pero decidí que era demasiado, ya que no tenia tantos permisos para setear. Simplemente un usuario no logueado solo puede loguearse o registrarse. Para ello puedo usar un helper de Devise:
class BooksController < ApplicationController before_action :authenticate_user!
Entonces todas las acciones de ese controlador estan protegidas por esa función, que si el usuario no se encuentra logueado lo redirige a
root_path
y le muestra una advertencia. Lo mismo hago en el controlador de notas. Y luego me tengo que asegurar que solo pueda acceder a sus notas y libros sin nunca ver los de otros. Para ello lo que hago es nunca buscar en los controladores cosas del estilo@book = Book.all
, aprovecho la existencia de la variablecurrent_user
la cual permite acceder a las colecciones y busco a partir de ellas. Por ejemplo una búsqueda, haciendola así seguro que no obtengo datos de otros usuarios, porque parto de esta colección acotada.@notes = current_user.notes if params[:search] && params[:search] != "" @notes = @notes.joins(:action_text_rich_text) .where(...
Pero puede ser que aunque yo no otorgue esos resultados directamente, puedan intentar acceder por la url a un dato que no les corresponda. De esta forma si alguien accede a una url de una nota que existe pero no le pertence, va a ver el mismo error que si intenta acceder a una inexistente, o de hecho a cualquier url inexistente, le da error 404. Esto es bueno porque no brindamos información acerca si la nota a la que intentó acceder sin permiso existe o no. Si quiero brindar esa información, por algún motivo, es restringiendo activamente el acceso a un dato que pertence a otro usuario. Por ejemplo
set_book
es una funcion que uso siempre antes de todas las acciones en el controlador de libros (con unbefore_action
):def set_book @book = Book.find(params[:id]) restrict_access if @book.user_id != current_user.id end def restrict_access redirect_to root_path, :alert => "Access denied" end
En este si busco entre todos los libros del sistema, pero si no coincide el id con el usuario logueado, entonces llamo a
restrict_access
que activamente patea al usuario, y le informa que no puede acceder allí. Son dos opciones para hacer lo mismo, esta otorga mas información, y es más agresiva. Otra opción sería restringiendo lasroutes
directamente, pero aquí se explica muy bien por qué se desaconseja. Es mejor restringir controladores. -
Luego de tantos errores quise agregar alguna forma de visualizarlos. Para eso hice esto Aunque luego quite las excepciones, porque rails te continua mandando a una pagina de redirección. quizás hay que probarlo en production de verdad.
-
Algo que no se pedía pero quise agregar fue una búsqueda simple. Como los libros se pueden visualizar en una barra lateral, decidí hacer la busqueda por titulo y contenido de la nota, para lo que tuve que investigar un poco como acceder al contenido del texto rico. Luego agrego en
Notas#index
el siguiente condicional, que si recibe un parametro no nulo:search
filtra la coleccion de notas (ya esta filtrado por usuario) con una consulta que armé a mano. Hace un join de la tabla de notas con la de texto rico (se guarda en una tabla aparte) y me quedo con las filas que cumplan que elbody
de ese texto, ó eltitle
de la nota, coincidan en parte (LIKE
) con el parámetro recibido:if params[:search] && params[:search] != "" @notes = @notes.joins(:action_text_rich_text) .where("action_text_rich_texts.body LIKE ? OR title LIKE ?", "%#{params[:search]}%", "%#{params[:search]}%") end
-
Quise implementar la funcionalidad que tenía en la versión anterior que era poder bajar todas las notas, pero en archivos pdfs en archivos separados, pero no fue posible. Si de a uno, yendo a cada nota y descargándolo individualmente, hacer un botón que baje baje todas las notas del usuario en archivos separados no, ya que solo se puede llamar una vez a
wickedpdf
en una misma acción, por lo que al lo sumo se puede bajar un archivo por vez. -
Por ultimo algo que me quedó pendiente, aunque no era un requerimiento, fue establecer un criterio de paginación y el ordenamiento en las listas, para lo que vi que hay una librería pero no la instalé.
-
P.D: Estoy intentando deployar la app en Heroku para poder probarla allí, pero no se si va a poder ser, hay problemas con el storage y algunas librerías.
-
P.D. 2: No se pudo, y además intenté instalar la aplicación en un linux y había problemas de liberías. Para windows la librería de los pdf había que instalarla externamente, y eso generaba problemas, por eso lo voy a arreglar formateando windows de una vez por todas, y con la gema
'wkhtmltopdf-binary'
me genera los binarios de la librería en el directorio de la aplicación, pero funciona solo para linux. Así que a partir de la versión2.0.1
se pierde soporte para windows xd. -
P.D. 3: Anduvo Heroku!! se puede ver la app en producción aquí :)