diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77d912e4..ae923e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: bundle install --jobs 4 --retry 4 yarn install sudo apt-get update - sudo apt-get install libvips + sudo apt-get install libvips liblept5 - name: Run Brakeman run: bundle exec brakeman -q --exit-on-warn @@ -162,7 +162,7 @@ jobs: bundle install --jobs 4 --retry 4 yarn install sudo apt-get update - sudo apt-get install -y libvips + sudo apt-get install -y libvips liblept5 wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so rm -f pdfium-linux.tgz diff --git a/Dockerfile b/Dockerfile index a4896f63..e3346423 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,16 +2,20 @@ FROM ruby:4.0.5-alpine AS download WORKDIR /fonts -RUN apk --no-cache add wget && \ +RUN apk --no-cache add wget unzip && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \ wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ - wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ + wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \ + case "$(uname -m)" in \ + x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \ + aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \ + esac | sha256sum -c - && \ mkdir -p /pdfium-linux && \ - tar -xzf pdfium-linux.tgz -C /pdfium-linux + unzip -q pdfium-linux.zip -d /pdfium-linux FROM ruby:4.0.5-alpine AS webpack @@ -48,7 +52,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf WORKDIR /app -RUN apk add --no-cache libpq vips redis onnxruntime && \ +RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \ rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal @@ -82,7 +86,7 @@ COPY --chown=docuseal:docuseal .version ./public/version COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/ COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so -COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt +COPY --from=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs diff --git a/Gemfile.lock b/Gemfile.lock index d72a8bd7..6260f938 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -313,7 +313,7 @@ GEM multi_json (1.19.1) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.4) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -373,7 +373,7 @@ GEM date stringio public_suffix (7.0.5) - puma (7.2.0) + puma (8.0.2) nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index b8ffa5ff..edd769df 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -54,6 +54,12 @@ module Api return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil? + if @template.archived_at? + Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar) + + return render json: { error: 'Template has been archived' }, status: :unprocessable_content + end + if @template.fields.blank? Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 2e1bba67..fd598309 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -38,6 +38,8 @@ class SubmissionsController < ApplicationController end def create + return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at? + save_template_message(@template, params) if params[:save_message] == '1' [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' diff --git a/app/controllers/template_documents_crop_controller.rb b/app/controllers/template_documents_crop_controller.rb new file mode 100644 index 00000000..c09b084c --- /dev/null +++ b/app/controllers/template_documents_crop_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class TemplateDocumentsCropController < ApplicationController + load_and_authorize_resource :template + before_action :load_attachment + + rescue_from Leptonica::LeptonicaError do + render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content + end + + def index + render json: { corners: Leptonica.detect_document_corners(@attachment.download) } + end + + def create + authorize!(:update, @template) + + document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params) + + render json: { + document: document.as_json( + methods: %i[metadata signed_key], + include: { + preview_images: { methods: %i[url metadata filename] } + } + ) + } + end + + private + + def load_attachment + @attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid]) + end + + def crop_params + params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]]) + end +end diff --git a/app/controllers/template_documents_modify_controller.rb b/app/controllers/template_documents_modify_controller.rb new file mode 100644 index 00000000..30db1fd9 --- /dev/null +++ b/app/controllers/template_documents_modify_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class TemplateDocumentsModifyController < ApplicationController + load_and_authorize_resource :template + + def create + authorize!(:update, @template) + + documents_layout = + params.require(:documents).map do |item| + item.permit(:attachment_uuid, + pages: [:attachment_uuid, :page, :rotate, + { redact: [%i[x y w h]], replaced_page: %i[attachment_uuid page] }]).to_h + end + + Templates::ModifyDocuments.call(@template, documents_layout) + + render json: { + schema: @template.schema, + fields: @template.fields, + submitters: @template.submitters, + documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json( + methods: %i[metadata signed_key], + include: { + preview_images: { methods: %i[url metadata filename] } + } + ) + } + rescue Templates::ModifyDocuments::InvalidLayout + render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content + end +end diff --git a/app/controllers/template_documents_page_objects_controller.rb b/app/controllers/template_documents_page_objects_controller.rb new file mode 100644 index 00000000..de9aa0b3 --- /dev/null +++ b/app/controllers/template_documents_page_objects_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TemplateDocumentsPageObjectsController < ApplicationController + load_and_authorize_resource :template + + def index + attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid]) + + render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i) + end +end diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 9cfa44a1..d73aee07 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -528,7 +528,7 @@ export default { return this.formatDate( this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'), - { withTimePlaceholders: this.modelValue === '{{date}}' } + { withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' } ) } catch { return this.modelValue @@ -651,7 +651,7 @@ export default { return number } }, - formatDate (date, format, { withTimePlaceholders = false } = {}) { + formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const dayFormats = { D: 'numeric', DD: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } @@ -673,7 +673,7 @@ export default { if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] if (/z/.test(format)) opts.timeZoneName = 'short' - if (!hasTime) opts.timeZone = 'UTC' + if (!hasTime && utc) opts.timeZone = 'UTC' const partTypes = { M: 'month', diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 4fe13b4d..098fcfb5 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -320,6 +320,7 @@ @replace="onDocumentReplace" @up="moveDocument(item, -1)" @reorder="reorderFields" + @edit="editModalDocumentUuid = item.attachment_uuid" @down="moveDocument(item, 1)" @change="save" /> @@ -363,6 +364,7 @@ isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto', zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden' ]" + @wheel="onPagesWheel" >
+
@@ -703,6 +716,7 @@ import DocumentControls from './controls' import MobileFields from './mobile_fields' import FieldSubmitter from './field_submitter' import RevisionsModal from './revisions_modal' +import DocumentsEditorModal from './documents_editor_modal' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue' import { v4 } from 'uuid' import { ref, computed, toRaw, defineAsyncComponent } from 'vue' @@ -746,7 +760,8 @@ export default { IconHistory, IconDeviceFloppy, IconX, - RevisionsModal + RevisionsModal, + DocumentsEditorModal }, provide () { return { @@ -1102,6 +1117,7 @@ export default { isDragFile: false, isMathLoaded: false, isRevisionsModalOpen: false, + editModalDocumentUuid: null, revisions: [], beforeRevisionSnapshot: null, zoomLevel: 1 @@ -1354,8 +1370,6 @@ export default { this.pendingFieldAttachmentUuids.push(item.attachment_uuid) } }) - - this.$refs.pagesContainer.addEventListener('wheel', this.onPagesWheel, { passive: false }) }, unmounted () { document.removeEventListener('keyup', this.onKeyUp) @@ -1363,8 +1377,6 @@ export default { window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('dragleave', this.onWindowDragLeave) - - this.$refs.pagesContainer.removeEventListener('wheel', this.onPagesWheel) }, beforeUpdate () { this.documentRefs = [] @@ -1681,7 +1693,7 @@ export default { ref.x = e.clientX - ref.offsetX ref.y = e.clientY - ref.offsetY - } else if (e.dataTransfer?.types?.includes('Files')) { + } else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) { this.isDragFile = true } }, @@ -2124,6 +2136,10 @@ export default { } }, onKeyDown (event) { + if (this.editModalDocumentUuid) { + return + } + if (event.key === 'Tab' && document.activeElement === document.body) { event.stopImmediatePropagation() event.preventDefault() @@ -3142,6 +3158,22 @@ export default { onDocumentsReplaceAndTemplateClone (template) { window.Turbo.visit(`/templates/${template.id}/edit`) }, + onDocumentsModified (data) { + this.template.schema = data.schema + this.template.fields = data.fields + this.template.submitters = data.submitters + this.template.documents = data.documents + + this.selectedAreasRef.value = [] + + if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) { + this.selectedSubmitter = this.template.submitters[0] + } + + this.editModalDocumentUuid = null + + this.save() + }, moveDocument (item, direction) { const currentIndex = this.template.schema.indexOf(item) diff --git a/app/javascript/template_builder/documents_editor_crop.vue b/app/javascript/template_builder/documents_editor_crop.vue new file mode 100644 index 00000000..2916ef91 --- /dev/null +++ b/app/javascript/template_builder/documents_editor_crop.vue @@ -0,0 +1,328 @@ + + + diff --git a/app/javascript/template_builder/documents_editor_modal.vue b/app/javascript/template_builder/documents_editor_modal.vue new file mode 100644 index 00000000..1d25c8c3 --- /dev/null +++ b/app/javascript/template_builder/documents_editor_modal.vue @@ -0,0 +1,1166 @@ + + + diff --git a/app/javascript/template_builder/documents_editor_page.vue b/app/javascript/template_builder/documents_editor_page.vue new file mode 100644 index 00000000..1bdc93e8 --- /dev/null +++ b/app/javascript/template_builder/documents_editor_page.vue @@ -0,0 +1,224 @@ + + + diff --git a/app/javascript/template_builder/documents_editor_redact.vue b/app/javascript/template_builder/documents_editor_redact.vue new file mode 100644 index 00000000..7e3a792c --- /dev/null +++ b/app/javascript/template_builder/documents_editor_redact.vue @@ -0,0 +1,413 @@ + + + diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index aa3c69a7..8f5c02b6 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -96,6 +96,23 @@ const en = { add_pdf_documents_or_images: 'Add PDF documents or images', add_documents_or_images: 'Add documents or images', add_a_new_document: 'Add a new document', + edit_documents: 'Edit documents', + move_forward: 'Move forward', + move_backward: 'Move backward', + remove_page: 'Remove page', + merge_with_previous: 'Merge with previous', + merge_with_next: 'Merge with next', + move_up: 'Move up', + move_down: 'Move down', + rotate: 'Rotate', + redact: 'Redact', + crop: 'Crop', + crop_and_scan: 'Crop & Scan', + flip_horizontal: 'Flip horizontal', + flip_vertical: 'Flip vertical', + there_is_no_text_to_redact_on_this_page: 'This page contains only images. Redact tool can be used only with text pages', + reset: 'Reset', + upload_to_document: 'Upload to "{document}"', replace_existing_document: 'Replace existing document', clone_and_replace_documents: 'Clone and replace documents', required: 'Required', @@ -319,6 +336,23 @@ const es = { add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes', add_documents_or_images: 'Agregar documentos o imágenes', add_a_new_document: 'Agregar un nuevo documento', + edit_documents: 'Editar documentos', + move_forward: 'Mover adelante', + move_backward: 'Mover atrás', + remove_page: 'Eliminar página', + merge_with_previous: 'Combinar con el anterior', + merge_with_next: 'Combinar con el siguiente', + move_up: 'Mover arriba', + move_down: 'Mover abajo', + rotate: 'Rotar', + redact: 'Censurar', + crop: 'Recortar', + crop_and_scan: 'Recortar y escanear', + flip_horizontal: 'Voltear horizontal', + flip_vertical: 'Voltear vertical', + there_is_no_text_to_redact_on_this_page: 'Esta página contiene solo imágenes. La herramienta de censura solo puede usarse con páginas de texto', + reset: 'Restablecer', + upload_to_document: 'Subir a "{document}"', replace_existing_document: 'Reemplazar documento existente', clone_and_replace_documents: 'Clonar y reemplazar documentos', required: 'Requerido', @@ -548,6 +582,23 @@ const it = { add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini', add_documents_or_images: 'Aggiungi documenti o immagini', add_a_new_document: 'Aggiungi un nuovo documento', + edit_documents: 'Modifica documenti', + move_forward: 'Sposta avanti', + move_backward: 'Sposta indietro', + remove_page: 'Rimuovi pagina', + merge_with_previous: 'Unisci al precedente', + merge_with_next: 'Unisci al successivo', + move_up: 'Sposta su', + move_down: 'Sposta giù', + rotate: 'Ruota', + redact: 'Oscura', + crop: 'Ritaglia', + crop_and_scan: 'Ritaglia e scansiona', + flip_horizontal: 'Rifletti orizzontale', + flip_vertical: 'Rifletti verticale', + there_is_no_text_to_redact_on_this_page: 'Questa pagina contiene solo immagini. Lo strumento di oscuramento può essere usato solo con pagine di testo', + reset: 'Reimposta', + upload_to_document: 'Carica in "{document}"', replace_existing_document: 'Sostituisci documento esistente', clone_and_replace_documents: 'Clona e sostituisci documenti', required: 'Obbligatorio', @@ -771,6 +822,23 @@ const pt = { add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens', add_documents_or_images: 'Adicionar documentos ou imagens', add_a_new_document: 'Adicionar um novo documento', + edit_documents: 'Editar documentos', + move_forward: 'Mover para frente', + move_backward: 'Mover para trás', + remove_page: 'Remover página', + merge_with_previous: 'Mesclar com o anterior', + merge_with_next: 'Mesclar com o próximo', + move_up: 'Mover para cima', + move_down: 'Mover para baixo', + rotate: 'Girar', + redact: 'Censurar', + crop: 'Cortar', + crop_and_scan: 'Cortar e digitalizar', + flip_horizontal: 'Inverter horizontal', + flip_vertical: 'Inverter vertical', + there_is_no_text_to_redact_on_this_page: 'Esta página contém apenas imagens. A ferramenta de censura só pode ser usada com páginas de texto', + reset: 'Redefinir', + upload_to_document: 'Enviar para "{document}"', replace_existing_document: 'Substituir documento existente', clone_and_replace_documents: 'Clonar e substituir documentos', required: 'Obrigatório', @@ -1000,6 +1068,23 @@ const fr = { add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images', add_documents_or_images: 'Ajouter des documents ou des images', add_a_new_document: 'Ajouter un nouveau document', + edit_documents: 'Modifier les documents', + move_forward: 'Déplacer en avant', + move_backward: 'Déplacer en arrière', + remove_page: 'Supprimer la page', + merge_with_previous: 'Fusionner avec le précédent', + merge_with_next: 'Fusionner avec le suivant', + move_up: 'Déplacer vers le haut', + move_down: 'Déplacer vers le bas', + rotate: 'Pivoter', + redact: 'Caviarder', + crop: 'Rogner', + crop_and_scan: 'Rogner et numériser', + flip_horizontal: 'Miroir horizontal', + flip_vertical: 'Miroir vertical', + there_is_no_text_to_redact_on_this_page: 'Cette page ne contient que des images. L\'outil de caviardage ne peut être utilisé qu\'avec des pages de texte', + reset: 'Réinitialiser', + upload_to_document: 'Téléverser dans "{document}"', replace_existing_document: 'Remplacer le document existant', clone_and_replace_documents: 'Cloner et remplacer des documents', required: 'Obligatoire', @@ -1226,6 +1311,23 @@ const de = { add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen', add_documents_or_images: 'Dokumente oder Bilder hinzufügen', add_a_new_document: 'Neues Dokument hinzufügen', + edit_documents: 'Dokumente bearbeiten', + move_forward: 'Nach vorne', + move_backward: 'Nach hinten', + remove_page: 'Seite entfernen', + merge_with_previous: 'Mit vorherigem zusammenführen', + merge_with_next: 'Mit nächstem zusammenführen', + move_up: 'Nach oben verschieben', + move_down: 'Nach unten verschieben', + rotate: 'Drehen', + redact: 'Schwärzen', + crop: 'Zuschneiden', + crop_and_scan: 'Zuschneiden & Scan', + flip_horizontal: 'Horizontal spiegeln', + flip_vertical: 'Vertikal spiegeln', + there_is_no_text_to_redact_on_this_page: 'Diese Seite enthält nur Bilder. Das Schwärzungswerkzeug kann nur mit Textseiten verwendet werden', + reset: 'Zurücksetzen', + upload_to_document: 'In "{document}" hochladen', replace_existing_document: 'Vorhandenes Dokument ersetzen', clone_and_replace_documents: 'Dokumente klonen und ersetzen', required: 'Erforderlich', @@ -1452,6 +1554,23 @@ const nl = { add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen', add_documents_or_images: 'Documenten of afbeeldingen toevoegen', add_a_new_document: 'Nieuw document toevoegen', + edit_documents: 'Documenten bewerken', + move_forward: 'Naar voren', + move_backward: 'Naar achteren', + remove_page: 'Pagina verwijderen', + merge_with_previous: 'Samenvoegen met vorige', + merge_with_next: 'Samenvoegen met volgende', + move_up: 'Omhoog verplaatsen', + move_down: 'Omlaag verplaatsen', + rotate: 'Draaien', + redact: 'Redigeren', + crop: 'Bijsnijden', + crop_and_scan: 'Bijsnijden en scannen', + flip_horizontal: 'Horizontaal spiegelen', + flip_vertical: 'Verticaal spiegelen', + there_is_no_text_to_redact_on_this_page: 'Deze pagina bevat alleen afbeeldingen. De redactietool kan alleen worden gebruikt met tekstpagina\'s', + reset: 'Opnieuw instellen', + upload_to_document: 'Uploaden naar "{document}"', replace_existing_document: 'Bestaand document vervangen', clone_and_replace_documents: 'Documenten klonen en vervangen', required: 'Vereist', diff --git a/app/javascript/template_builder/preview.vue b/app/javascript/template_builder/preview.vue index efd21777..58b49c9d 100644 --- a/app/javascript/template_builder/preview.vue +++ b/app/javascript/template_builder/preview.vue @@ -72,6 +72,15 @@ style="min-width: 170px" @click="closeDropdown" > +
  • + +