diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0cd12d52..b24c6b45 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -46,6 +46,7 @@ class AccountsController < ApplicationController def destroy authorize!(:manage, current_account) + true_user.skip_reconfirmation! true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@')) true_user.account.update!(archived_at: Time.current) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 7030e40f..c3b7e88e 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -1,4 +1,11 @@ # frozen_string_literal: true class InvitationsController < Devise::PasswordsController + def update + super do |resource| + resource.confirmed_at ||= Time.current if resource.errors.empty? + + PasswordsController::Current.user = resource + end + end end diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb index a1ee71bf..1bbdb14a 100644 --- a/app/controllers/profile_controller.rb +++ b/app/controllers/profile_controller.rb @@ -9,7 +9,14 @@ class ProfileController < ApplicationController def update_contact if current_user.update(contact_params) - redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update') + if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email) + SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id) + + redirect_to settings_profile_index_path, + notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address') + else + redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update') + end else render :index, status: :unprocessable_content end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 08778a3e..98e5e0c3 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -81,7 +81,7 @@ class StartFormController < ApplicationController @submitter = Submitter.where(submission: @template.submissions) .where.not(completed_at: nil) - .find_by!(required_params) + .find_by!(required_params.except('name')) end private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 39555b59..51025dd1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -65,7 +65,14 @@ class UsersController < ApplicationController end if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password]))) - redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated') + if @user.try(:pending_reconfirmation?) && @user.previous_changes.key?(:unconfirmed_email) + SendConfirmationInstructionsJob.perform_async('user_id' => @user.id) + + redirect_back fallback_location: settings_users_path, + notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address') + else + redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated') + end else render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content end diff --git a/app/javascript/application.js b/app/javascript/application.js index b839039d..1320946e 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -51,6 +51,7 @@ import ToggleClasses from './elements/toggle_classes' import AutosizeField from './elements/autosize_field' import GoogleDriveFilePicker from './elements/google_drive_file_picker' import OpenModal from './elements/open_modal' +import BarChart from './elements/bar_chart' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -140,6 +141,7 @@ safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('open-modal', OpenModal) +safeRegisterElement('bar-chart', BarChart) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/elements/bar_chart.js b/app/javascript/elements/bar_chart.js new file mode 100644 index 00000000..32fdfa0f --- /dev/null +++ b/app/javascript/elements/bar_chart.js @@ -0,0 +1,50 @@ +export default class extends HTMLElement { + connectedCallback () { + this.chartLabels = JSON.parse(this.dataset.labels || '[]') + this.chartDatasets = JSON.parse(this.dataset.datasets || '[]') + + this.initChart() + } + + disconnectedCallback () { + if (this.chartInstance) { + this.chartInstance.destroy() + this.chartInstance = null + } + } + + async initChart () { + const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto') + + const canvas = this.querySelector('canvas') + + const ctx = canvas.getContext('2d') + + this.chartInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: this.chartLabels, + datasets: this.chartDatasets + }, + options: { + responsive: true, + maintainAspectRatio: true, + animation: false, + scales: { + y: { + beginAtZero: true, + grace: '20%', + ticks: { + precision: 0 + } + } + }, + plugins: { + legend: { + display: false + } + } + } + }) + } +} diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index cd9888d5..a3cdf6f2 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -47,6 +47,7 @@ class ProcessSubmitterCompletionJob completed_submitter.assign_attributes( submission_id: submitter.submission_id, account_id: submission.account_id, + is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true), template_id: submission.template_id, source: submission.source, sms_count: sms_events.sum { |e| e.data['segments'] || 1 }, diff --git a/app/jobs/send_confirmation_instructions_job.rb b/app/jobs/send_confirmation_instructions_job.rb new file mode 100644 index 00000000..7abae742 --- /dev/null +++ b/app/jobs/send_confirmation_instructions_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SendConfirmationInstructionsJob + include Sidekiq::Job + + def perform(params = {}) + user = User.find(params['user_id']) + + user.send_confirmation_instructions + end +end diff --git a/app/models/account.rb b/app/models/account.rb index cbf157e5..bc6471bf 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -60,6 +60,10 @@ class Account < ApplicationRecord linked_account_account&.testing? end + def tz_info + @tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone) + end + def default_template_folder super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME, author_id: users.minimum(:id)).tap(&:save!) diff --git a/app/models/completed_submitter.rb b/app/models/completed_submitter.rb index 25c36e3a..8c5f2f64 100644 --- a/app/models/completed_submitter.rb +++ b/app/models/completed_submitter.rb @@ -6,6 +6,7 @@ # # id :bigint not null, primary key # completed_at :datetime not null +# is_first :boolean # sms_count :integer not null # source :string not null # verification_method :string @@ -18,8 +19,10 @@ # # Indexes # -# index_completed_submitters_on_account_id (account_id) -# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE +# index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true) +# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at) +# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true) +# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE # class CompletedSubmitter < ApplicationRecord belongs_to :submitter diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb index d85536b3..11a6e995 100644 --- a/app/models/submission_event.rb +++ b/app/models/submission_event.rb @@ -10,17 +10,21 @@ # event_type :string not null # created_at :datetime not null # updated_at :datetime not null +# account_id :bigint # submission_id :bigint not null # submitter_id :bigint # # Indexes # -# index_submission_events_on_created_at (created_at) -# index_submission_events_on_submission_id (submission_id) -# index_submission_events_on_submitter_id (submitter_id) +# index_submission_events_on_account_id (account_id) +# index_submission_events_on_created_at (created_at) +# index_submission_events_on_submission_id (submission_id) +# index_submission_events_on_submitter_id (submitter_id) +# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[])) # # Foreign Keys # +# fk_rails_... (account_id => accounts.id) # fk_rails_... (submission_id => submissions.id) # fk_rails_... (submitter_id => submitters.id) # @@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord serialize :data, coder: JSON before_validation :set_submission_id, on: :create + before_validation :set_account_id, on: :create enum :event_type, { send_email: 'send_email', @@ -63,4 +68,8 @@ class SubmissionEvent < ApplicationRecord def set_submission_id self.submission_id = submitter&.submission_id end + + def set_account_id + self.account_id = submitter&.account_id + end end diff --git a/app/models/user.rb b/app/models/user.rb index 83e88f08..7eabb059 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,9 @@ # # id :bigint not null, primary key # archived_at :datetime +# confirmation_sent_at :datetime +# confirmation_token :string +# confirmed_at :datetime # consumed_timestep :integer # current_sign_in_at :datetime # current_sign_in_ip :string @@ -24,8 +27,9 @@ # reset_password_token :string # role :string not null # sign_in_count :integer default(0), not null +# unconfirmed_email :string # unlock_token :string -# uuid :text not null +# uuid :string not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null diff --git a/app/views/invitations/edit.html.erb b/app/views/invitations/edit.html.erb index 966b487f..1513d612 100644 --- a/app/views/invitations/edit.html.erb +++ b/app/views/invitations/edit.html.erb @@ -3,7 +3,7 @@ <%= svg_icon('waving_hand', class: 'h-10 w-10') %> <%= t('welcome_to_product_name', product_name: Docuseal.product_name) %> - <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %> + <%= form_for(resource, as: resource_name, url: invitation_path, html: { method: :put, class: 'space-y-6' }) do |f| %>
<%= render 'devise/shared/error_messages', resource: %> <%= f.hidden_field :reset_password_token %> diff --git a/app/views/profile/index.html.erb b/app/views/profile/index.html.erb index c5422b00..9f1ec645 100644 --- a/app/views/profile/index.html.erb +++ b/app/views/profile/index.html.erb @@ -18,6 +18,11 @@
<%= f.label :email, t('email'), class: 'label' %> <%= f.email_field :email, autocomplete: 'off', class: 'base-input' %> + <% if current_user.try(:pending_reconfirmation?) %> + + <% end %>
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %> diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 1b06a08a..8e26a8fb 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -66,7 +66,7 @@ <% end %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
  • - <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> + <%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %> <%= t('plans') %> <%= t('pro') %> <% end %> @@ -74,13 +74,13 @@ <% end %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
  • - <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> + <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %> <% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %> <% end %>
  • <% if Docuseal.multitenant? %>
  • - <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> + <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %> <%= t('embedding') %> <% end %>
  • diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 5ceaeabc..c7652916 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -13,6 +13,11 @@
    <%= f.label :email, t('email'), class: 'label' %> <%= f.email_field :email, required: true, class: 'base-input' %> + <% if user.try(:pending_reconfirmation?) %> + + <% end %> <% if user.persisted? && Accounts.can_send_emails?(current_account) %> <%= t('click_here_to_send_a_reset_password_email_html') %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index a0849cec..e49a8563 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -52,7 +52,13 @@ <%= user.full_name %> - <%= user.email %> + <% if user.try(:pending_reconfirmation?) %> + <%= user.unconfirmed_email %> +
    + (<%= t('unconfirmed') %>) + <% else %> + <%= user.email %> + <% end %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d7b80f1a..1329ce87 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -31,6 +31,7 @@ end # # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. +# rubocop:disable Metrics/BlockLength Devise.setup do |config| config.warden do |manager| manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable) @@ -166,7 +167,7 @@ Devise.setup do |config| # without confirming their account. # Default is 0.days, meaning the user cannot access the website without # confirming their account. - # config.allow_unconfirmed_access_for = 2.days + config.allow_unconfirmed_access_for = nil # A period that the user is allowed to confirm their account before their # token becomes invalid. For example, if set to 3.days, the user can confirm @@ -332,3 +333,4 @@ Devise.setup do |config| ActiveSupport.run_load_hooks(:devise_config, config) end +# rubocop:enable Metrics/BlockLength diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index cb7de198..f7f60106 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -23,6 +23,7 @@ en: &en add_from_google_drive: Add from Google Drive or_add_from: Or add from upload_a_new_document: Upload a New Document + billing: Billing hi_there: Hi there pro: Pro thanks: Thanks @@ -70,7 +71,7 @@ en: &en team_access: Team access document_download_filename_format: Document download filename format docuseal_trusted_signature: DocuSeal Trusted Signature - hello_name: Hello %{name} + hello_name: Hi %{name} you_are_invited_to_product_name: You are invited to %{product_name} you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:' sent_using_product_name_in_testing_mode_html: 'Sent using %{product_name} in testing mode' @@ -844,6 +845,36 @@ en: &en tablet: Tablet reset_default: Reset default send_signature_request_email: Send signature request email + last_3_months: Last 3 months + last_6_months: Last 6 months + last_year: Last year + all_time: All time + everyone: Everyone + daily: Daily + weekly: Weekly + monthly: Monthly + api: API + embed: Embed + bulk: Bulk + invite: Invite + api_and_embed: API and Embed + period: Period + reports: Reports + completed_submissions: Completed submissions + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: A confirmation email has been sent to the new email address. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm." + please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:' + confirm_email: Confirm email + unconfirmed: Unconfirmed + devise: + confirmations: + confirmed: Your email address has been successfully confirmed. + failure: + unconfirmed: You have to confirm your email address before continuing. + mailer: + confirmation_instructions: + subject: Confirm your email address submission_sources: api: API bulk: Bulk Send @@ -950,10 +981,12 @@ en: &en range_without_total: "%{from}-%{to} events" es: &es + billing: Facturación add_from_google_drive: Agregar desde Google Drive or_add_from: O agregar desde upload_a_new_document: Subir nuevo documento use_direct_file_attachment_links_in_the_documents: Usar enlaces directos de archivos adjuntos en los documentos + click_here_to_send_a_reset_password_email_html: ' para enviar un correo electrónico de restablecimiento de contraseña.' enabled: Habilitado disabled: Deshabilitado expirable_file_download_links: Enlaces de descarga de archivos con vencimiento @@ -1596,6 +1629,7 @@ es: &es image_field: Campo de Imagen file_field: Campo de Archivo select_field: Campo de Selección + checkbox_field: Campo de Casilla multiple_field: Campo Múltiple radio_field: Campo de Grupo Radio cells_field: Campo de Celdas @@ -1772,6 +1806,36 @@ es: &es tablet: Tableta reset_default: Restablecer por defecto send_signature_request_email: Enviar correo de solicitud de firma + last_3_months: Últimos 3 meses + last_6_months: Últimos 6 meses + last_year: Último año + all_time: Todo el tiempo + everyone: Todos + daily: Diario + weekly: Semanal + monthly: Mensual + api: API + embed: Integrar + bulk: Masivo + invite: Invitación + api_and_embed: API e Integrar + period: Período + reports: Informes + completed_submissions: Envíos completados + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: Se ha enviado un correo electrónico de confirmación a la nueva dirección de correo electrónico. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla." + please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:' + confirm_email: Confirmar correo + unconfirmed: No confirmado + devise: + confirmations: + confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente. + failure: + unconfirmed: Debes confirmar tu dirección de correo electrónico antes de continuar. + mailer: + confirmation_instructions: + subject: Confirma tu dirección de correo electrónico submission_sources: api: API bulk: Envío masivo @@ -1878,6 +1942,7 @@ es: &es range_without_total: "%{from}-%{to} eventos" it: &it + billing: Fatturazione add_from_google_drive: Aggiungi da Google Drive or_add_from: Oppure aggiungi da upload_a_new_document: Carica nuovo documento @@ -1911,6 +1976,7 @@ it: &it edit_per_party: Modifica per partito signed: Firmato reply_to: Rispondi a + partially_completed: Parzialmente completato pending_by_me: In sospeso da me add: Aggiungi adding: Aggiungendo @@ -2357,6 +2423,7 @@ it: &it upload_signature: Carica firma integration: Integrazione admin: Amministratore + tenant_admin: Amministratore tenant editor: Editor viewer: Visualizzatore member: Membro @@ -2701,6 +2768,36 @@ it: &it tablet: Tablet reset_default: Reimposta predefinito send_signature_request_email: Invia email di richiesta firma + last_3_months: Ultimi 3 mesi + last_6_months: Ultimi 6 mesi + last_year: Ultimo anno + all_time: Tutto il tempo + everyone: Tutti + daily: Giornaliero + weekly: Settimanale + monthly: Mensile + api: API + embed: Incorporare + bulk: Massivo + invite: Invito + api_and_embed: API e Incorporare + period: Periodo + reports: Rapporti + completed_submissions: Invii completati + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: È stata inviata un'email di conferma al nuovo indirizzo email. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare." + please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:' + confirm_email: Conferma email + unconfirmed: Non confermato + devise: + confirmations: + confirmed: Il tuo indirizzo email è stato confermato con successo. + failure: + unconfirmed: Devi confermare il tuo indirizzo email prima di continuare. + mailer: + confirmation_instructions: + subject: Conferma il tuo indirizzo email submission_sources: api: API bulk: Invio massivo @@ -2807,6 +2904,7 @@ it: &it range_without_total: "%{from}-%{to} eventi" fr: &fr + billing: Facturation add_from_google_drive: Ajouter depuis Google Drive or_add_from: Ou ajouter depuis upload_a_new_document: Téléverser un nouveau document @@ -3627,6 +3725,37 @@ fr: &fr tablet: Tablette reset_default: Réinitialiser par défaut send_signature_request_email: Envoyer un e-mail de demande de signature + last_month: Mois dernier + last_3_months: 3 derniers mois + last_6_months: 6 derniers mois + last_year: Année dernière + all_time: Tout le temps + everyone: Tout le monde + daily: Quotidien + weekly: Hebdomadaire + monthly: Mensuel + api: API + embed: Intégrer + bulk: En masse + invite: Invitation + api_and_embed: API et Intégrer + period: Période + reports: Rapports + completed_submissions: Soumissions terminées + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: Un e-mail de confirmation a été envoyé à la nouvelle adresse e-mail. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer." + please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :' + confirm_email: "Confirmer l'e-mail" + unconfirmed: Non confirmé + devise: + confirmations: + confirmed: Votre adresse e-mail a été confirmée avec succès. + failure: + unconfirmed: Vous devez confirmer votre adresse e-mail avant de continuer. + mailer: + confirmation_instructions: + subject: Confirmez votre adresse e-mail submission_sources: api: API bulk: Envoi en masse @@ -3733,6 +3862,7 @@ fr: &fr range_without_total: "%{from}-%{to} événements" pt: &pt + billing: Pagamentos add_from_google_drive: Adicionar do Google Drive or_add_from: Ou adicionar de upload_a_new_document: Enviar novo documento @@ -4557,6 +4687,36 @@ pt: &pt tablet: Tablet reset_default: Redefinir para padrão send_signature_request_email: Enviar e-mail de solicitação de assinatura + last_3_months: Últimos 3 meses + last_6_months: Últimos 6 meses + last_year: Último ano + all_time: Todo o tempo + everyone: Todos + daily: Diário + weekly: Semanal + monthly: Mensal + api: API + embed: Incorporar + bulk: Em massa + invite: Convite + api_and_embed: API e Incorporar + period: Período + reports: Relatórios + completed_submissions: Envios concluídos + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: Um e-mail de confirmação foi enviado para o novo endereço de e-mail. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar." + please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:' + confirm_email: Confirmar e-mail + unconfirmed: Não confirmado + devise: + confirmations: + confirmed: Seu endereço de e-mail foi confirmado com sucesso. + failure: + unconfirmed: Você deve confirmar seu endereço de e-mail antes de continuar. + mailer: + confirmation_instructions: + subject: Confirme seu endereço de e-mail submission_sources: api: API bulk: Envio em massa @@ -4663,6 +4823,7 @@ pt: &pt range_without_total: "%{from}-%{to} eventos" de: &de + billing: Abrechnung add_from_google_drive: Aus Google Drive hinzufügen or_add_from: Oder hinzufügen aus upload_a_new_document: Neues Dokument hochladen @@ -5487,6 +5648,36 @@ de: &de tablet: Tablet reset_default: Standard zurücksetzen send_signature_request_email: Signaturanfrage-E-Mail senden + last_3_months: Letzte 3 Monate + last_6_months: Letzte 6 Monate + last_year: Letztes Jahr + all_time: Gesamte Zeit + everyone: Alle + daily: Täglich + weekly: Wöchentlich + monthly: Monatlich + api: API + embed: Einbetten + bulk: Massenversand + invite: Einladung + api_and_embed: API und Einbetten + period: Zeitraum + reports: Berichte + completed_submissions: Abgeschlossene Übermittlungen + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: Eine Bestätigungs-E-Mail wurde an die neue E-Mail-Adresse gesendet. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen." + please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:' + confirm_email: E-Mail bestätigen + unconfirmed: Unbestätigt + devise: + confirmations: + confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt. + failure: + unconfirmed: Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie fortfahren. + mailer: + confirmation_instructions: + subject: Bestätigen Sie Ihre E-Mail-Adresse submission_sources: api: API bulk: Massenversand @@ -5957,6 +6148,7 @@ he: your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: לא ניתן היה לגשת לדוא"ל שלך. ייתכן שזה קרה עקב שגיאת כתיב בכתובת או אם תיבת הדואר אינה זמינה. אנא פנה ל־support@docuseal.com כדי להתחבר. nl: &nl + billing: Facturatie add_from_google_drive: Toevoegen vanuit Google Drive or_add_from: Of toevoegen vanuit upload_a_new_document: Nieuw document uploaden @@ -6777,6 +6969,36 @@ nl: &nl tablet: Tablet reset_default: Standaard herstellen send_signature_request_email: E-mail met handtekeningaanvraag verzenden + last_3_months: Afgelopen 3 maanden + last_6_months: Afgelopen 6 maanden + last_year: Afgelopen jaar + all_time: Altijd + everyone: Iedereen + daily: Dagelijks + weekly: Wekelijks + monthly: Maandelijks + api: API + embed: Insluiten + bulk: Bulk + invite: Uitnodiging + api_and_embed: API en Insluiten + period: Periode + reports: Rapporten + completed_submissions: Voltooide inzendingen + sms: SMS + a_confirmation_email_has_been_sent_to_the_new_email_address: Er is een bevestigingsmail verzonden naar het nieuwe e-mailadres. + email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen." + please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:' + confirm_email: E-mailadres bevestigen + unconfirmed: Onbevestigd + devise: + confirmations: + confirmed: Je e-mailadres is succesvol bevestigd. + failure: + unconfirmed: Je moet je e-mailadres bevestigen voordat je verdergaat. + mailer: + confirmation_instructions: + subject: Bevestig je e-mailadres submission_sources: api: API bulk: Bulkverzending diff --git a/config/routes.rb b/config/routes.rb index 13c33fb5..5a9281cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,13 +14,8 @@ Rails.application.routes.draw do get 'up' => 'rails/health#show' get 'manifest' => 'pwa#manifest' - devise_for :users, - path: '/', only: %i[sessions passwords omniauth_callbacks], - controllers: begin - options = { sessions: 'sessions', passwords: 'passwords' } - options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable) - options - end + devise_for :users, path: '/', only: %i[sessions passwords], + controllers: { sessions: 'sessions', passwords: 'passwords' } devise_scope :user do resource :invitation, only: %i[update] do diff --git a/db/migrate/20251121090556_add_account_id_to_submission_events.rb b/db/migrate/20251121090556_add_account_id_to_submission_events.rb new file mode 100644 index 00000000..6e88c16a --- /dev/null +++ b/db/migrate/20251121090556_add_account_id_to_submission_events.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAccountIdToSubmissionEvents < ActiveRecord::Migration[8.0] + def change + add_reference :submission_events, :account, null: true, foreign_key: true, index: true + end +end diff --git a/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb b/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb new file mode 100644 index 00000000..baa59d33 --- /dev/null +++ b/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddIsFirstToCompletedSubmitters < ActiveRecord::Migration[8.0] + def change + # rubocop:disable Rails/ThreeStateBooleanColumn + add_column :completed_submitters, :is_first, :boolean + # rubocop:enable Rails/ThreeStateBooleanColumn + + add_index :completed_submitters, %i[account_id completed_at], + where: 'is_first = TRUE', + name: 'index_completed_submitters_account_id_completed_at_is_first' + add_index :completed_submitters, :submission_id, unique: true, where: 'is_first = TRUE' + end +end diff --git a/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb b/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb new file mode 100644 index 00000000..86880645 --- /dev/null +++ b/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddCompletedAtIndexToCompletedSubmitters < ActiveRecord::Migration[8.0] + def change + add_index :completed_submitters, %i[account_id completed_at], + name: 'index_completed_submitters_on_account_id_and_completed_at' + remove_index :completed_submitters, :account_id + end +end diff --git a/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb b/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb new file mode 100644 index 00000000..f6f666a8 --- /dev/null +++ b/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSendSmsSend2faSmsIndex < ActiveRecord::Migration[8.0] + def change + add_index :submission_events, %i[account_id created_at], + where: "event_type IN ('send_sms', 'send_2fa_sms')", + name: 'index_submissions_events_on_sms_event_types' + end +end diff --git a/db/migrate/20251125194305_add_confirmable_to_users.rb b/db/migrate/20251125194305_add_confirmable_to_users.rb new file mode 100644 index 00000000..ca946854 --- /dev/null +++ b/db/migrate/20251125194305_add_confirmable_to_users.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddConfirmableToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :confirmation_token, :string + add_column :users, :confirmed_at, :datetime + add_column :users, :confirmation_sent_at, :datetime + add_column :users, :unconfirmed_email, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index c14f705b..ea115d78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do +ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "plpgsql" @@ -118,7 +118,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "verification_method" - t.index ["account_id"], name: "index_completed_submitters_on_account_id" + t.boolean "is_first" + t.index ["account_id", "completed_at"], name: "index_completed_submitters_account_id_completed_at_is_first", where: "(is_first = true)" + t.index ["account_id", "completed_at"], name: "index_completed_submitters_on_account_id_and_completed_at" + t.index ["submission_id"], name: "index_completed_submitters_on_submission_id", unique: true, where: "(is_first = true)" t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true end @@ -293,6 +296,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do t.datetime "event_timestamp", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "account_id" + t.index ["account_id", "created_at"], name: "index_submissions_events_on_sms_event_types", where: "((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))" + t.index ["account_id"], name: "index_submission_events_on_account_id" t.index ["created_at"], name: "index_submission_events_on_created_at" t.index ["submission_id"], name: "index_submission_events_on_submission_id" t.index ["submitter_id"], name: "index_submission_events_on_submitter_id" @@ -445,6 +451,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do t.string "otp_secret" t.integer "consumed_timestep" t.boolean "otp_required_for_login", default: false, null: false + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" t.index ["account_id"], name: "index_users_on_account_id" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true @@ -506,6 +516,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" + add_foreign_key "submission_events", "accounts" add_foreign_key "submission_events", "submissions" add_foreign_key "submission_events", "submitters" add_foreign_key "submissions", "templates" diff --git a/lib/pdfium.rb b/lib/pdfium.rb index 464f95e2..a20e41c7 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -39,8 +39,25 @@ class Pdfium FPDF_RENDER_FORCEHALFTONE = 0x400 FPDF_PRINTING = 0x800 - TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true) - LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true) + TextNode = Struct.new(:content, :x, :y, :w, :h) do + def endx + @endx ||= x + w + end + + def endy + @endy ||= y + h + end + end + + LineNode = Struct.new(:x, :y, :w, :h, :tilt) do + def endy + @endy ||= y + h + end + + def endx + @endx ||= x + w + end + end # rubocop:disable Naming/ClassAndModuleCamelCase class FPDF_LIBRARY_CONFIG < FFI::Struct @@ -433,31 +450,47 @@ class Pdfium return @text_nodes if char_count.zero? - char_count.times do |i| - unicode = Pdfium.FPDFText_GetUnicode(text_page, i) + left_ptr = FFI::MemoryPointer.new(:double) + right_ptr = FFI::MemoryPointer.new(:double) + bottom_ptr = FFI::MemoryPointer.new(:double) + top_ptr = FFI::MemoryPointer.new(:double) + origin_x_ptr = FFI::MemoryPointer.new(:double) + origin_y_ptr = FFI::MemoryPointer.new(:double) + + i = 0 - char = [unicode].pack('U*') + loop do + break unless i < char_count - left_ptr = FFI::MemoryPointer.new(:double) - right_ptr = FFI::MemoryPointer.new(:double) - bottom_ptr = FFI::MemoryPointer.new(:double) - top_ptr = FFI::MemoryPointer.new(:double) + box_index = i - result = Pdfium.FPDFText_GetCharBox(text_page, i, left_ptr, right_ptr, bottom_ptr, top_ptr) + codepoint = Pdfium.FPDFText_GetUnicode(text_page, i) + + if codepoint.between?(0xD800, 0xDBFF) && (i + 1 < char_count) + codepoint2 = Pdfium.FPDFText_GetUnicode(text_page, i + 1) + + if codepoint2.between?(0xDC00, 0xDFFF) + codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (codepoint2 - 0xDC00) + + i += 1 + end + end + + char = codepoint.chr(Encoding::UTF_8) + + result = Pdfium.FPDFText_GetCharBox(text_page, box_index, left_ptr, right_ptr, bottom_ptr, top_ptr) next if result.zero? left = left_ptr.read_double right = right_ptr.read_double - origin_x_ptr = FFI::MemoryPointer.new(:double) - origin_y_ptr = FFI::MemoryPointer.new(:double) - - Pdfium.FPDFText_GetCharOrigin(text_page, i, origin_x_ptr, origin_y_ptr) + Pdfium.FPDFText_GetCharOrigin(text_page, box_index, origin_x_ptr, origin_y_ptr) origin_y = origin_y_ptr.read_double + origin_x = origin_x_ptr.read_double - font_size = Pdfium.FPDFText_GetFontSize(text_page, i) + font_size = Pdfium.FPDFText_GetFontSize(text_page, box_index) font_size = 8 if font_size == 1 abs_x = left @@ -465,15 +498,21 @@ class Pdfium abs_width = right - left abs_height = font_size - x = abs_x / width + x = origin_x / width y = abs_y / height - node_width = abs_width / width + node_width = (abs_width + ((abs_x - origin_x).abs * 2)) / width node_height = abs_height / height - @text_nodes << TextNode.new(content: char, x: x, y: y, w: node_width, h: node_height) + @text_nodes << TextNode.new(char, x, y, node_width, node_height) + ensure + i += 1 end - @text_nodes = @text_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y } + y_threshold = 4.0 / width + + @text_nodes = @text_nodes.sort do |a, b| + (a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy + end ensure Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null? end @@ -539,10 +578,10 @@ class Pdfium norm_w = w / width norm_h = h / height - @line_nodes << LineNode.new(x: norm_x, y: norm_y, w: norm_w, h: norm_h, tilt: tilt) + @line_nodes << LineNode.new(norm_x, norm_y, norm_w, norm_h, tilt) end - @line_nodes = @line_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y } + @line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy } end def close diff --git a/lib/submission_events.rb b/lib/submission_events.rb index 1705ba41..570652e1 100644 --- a/lib/submission_events.rb +++ b/lib/submission_events.rb @@ -20,4 +20,14 @@ module SubmissionEvents **data }.compact_blank) end + + def populate_account_id + Account.find_each do |account| + ids = account.submissions.pluck(:id) + + ids.each_slice(10_000).each do |batch| + SubmissionEvent.where(submission_id: batch).update_all(account_id: account.id) + end + end + end end diff --git a/lib/submissions/timestamp_handler.rb b/lib/submissions/timestamp_handler.rb index a152a97f..8dd3179c 100644 --- a/lib/submissions/timestamp_handler.rb +++ b/lib/submissions/timestamp_handler.rb @@ -3,6 +3,7 @@ module Submissions class TimestampHandler HASH_ALGORITHM = 'SHA256' + TIMEOUT = 10 TimestampError = Class.new(StandardError) @@ -32,6 +33,8 @@ module Submissions uri = Addressable::URI.parse(tsa_url) conn = Faraday.new(uri.origin) do |c| + c.options.read_timeout = TIMEOUT + c.options.open_timeout = TIMEOUT c.basic_auth(uri.user, uri.password) if uri.password.present? end diff --git a/lib/submitters.rb b/lib/submitters.rb index 2738f773..af8e0c69 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -251,4 +251,20 @@ module Submitters true end + + def populate_completed_is_first + Account.find_each do |account| + submissions_index = {} + + CompletedSubmitter.where(account_id: account.id).order(:account_id, :completed_at).each do |cs| + submissions_index[cs.submission_id] ||= cs.submitter_id + + cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id) + rescue ActiveRecord::RecordNotUnique + CompletedSubmitter.where(submission_id: cs.submission_id).update_all(is_first: false) + + cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id) + end + end + end end diff --git a/lib/templates/detect_fields.rb b/lib/templates/detect_fields.rb index 1f3b5c0c..2ade0efd 100755 --- a/lib/templates/detect_fields.rb +++ b/lib/templates/detect_fields.rb @@ -4,7 +4,16 @@ module Templates module DetectFields module_function - TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true) + TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true) do + def endy + @endy ||= y + h + end + + def endx + @endx ||= x + w + end + end + PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true) DATE_REGEXP = / @@ -12,8 +21,7 @@ module Templates date | signed\sat | datum - ) - \s*[:-]?\s*\z + )[:_\s-]*\z /ix NUMBER_REGEXP = / @@ -31,8 +39,7 @@ module Templates | menge | anzahl | stückzahl - ) - \s*[:-]?\s*\z + )[:_\s-]*\z /ix SIGNATURE_REGEXP = / @@ -45,10 +52,12 @@ module Templates | unterschrift | unterschreiben | unterzeichnen - ) - \s*[:-]?\s*\z + )[:_\s-]*\z /ix + LINEBREAK = ["\n", "\r"].freeze + CHECKBOXES = ['☐', '□'].freeze + # rubocop:disable Metrics, Style def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &) @@ -71,11 +80,13 @@ module Templates fields = inference.call(image, confidence:, nms:, split_page:, temperature:, aspect_ratio:, padding:) + fields = sort_fields(fields, y_threshold: 10.0 / image.height) + fields = fields.map do |f| { uuid: SecureRandom.uuid, type: f.type, - required: f.type != 'checkbox', + required: f.type == 'signature', preferences: {}, areas: [{ x: f.x, @@ -113,6 +124,8 @@ module Templates text_fields = extract_text_fields_from_page(page) line_fields = extract_line_fields_from_page(page) + fields = sort_fields(fields, y_threshold: 10.0 / image.height) + fields = increase_confidence_for_overlapping_fields(fields, text_fields) fields = increase_confidence_for_overlapping_fields(fields, line_fields) @@ -128,7 +141,7 @@ module Templates { uuid: SecureRandom.uuid, type:, - required: type != 'checkbox', + required: type == 'signature', preferences: {}, areas: [{ x: field.x, y: field.y, @@ -153,6 +166,12 @@ module Templates doc.close end + def sort_fields(fields, y_threshold: 0.01) + fields.sort do |a, b| + (a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy + end + end + def print_debug(head_node) current_node = head_node index = 0 @@ -189,121 +208,120 @@ module Templates def build_page_nodes(page, fields, tail_node, attachment_uuid: nil) field_nodes = [] - current_text = ''.b - - text_nodes = page.text_nodes - text_idx = 0 - field_idx = 0 + y_threshold = 4.0 / page.height + x_threshold = 30.0 / page.width - while text_idx < text_nodes.length || field_idx < fields.length - text_node = text_nodes[text_idx] - field = fields[field_idx] + text_nodes = page.text_nodes - process_text_node = false - process_field_node = false + current_field = fields.shift - if text_node && field - text_y_center = text_node.y + (text_node.h / 2.0) - field_y_center = field.y + (field.h / 2.0) - y_threshold = text_node.h / 2.0 - vertical_distance = (text_y_center - field_y_center).abs + index = 0 - if vertical_distance < y_threshold - is_underscore = text_node.content == '_' - is_left_of_field = text_node.x < field.x + prev_node = nil - if is_underscore && is_left_of_field - text_x_end = text_node.x + text_node.w + loop do + node = text_nodes[index] - distance = field.x - text_x_end - proximity_threshold = text_node.w * 3.0 + break unless node - if distance < proximity_threshold - process_field_node = true - else - process_text_node = true - end + if node.content.in?(LINEBREAK) + next_node = text_nodes[index] - elsif is_left_of_field - process_text_node = true - else - process_field_node = true - end + if next_node && (next_node.endy - node.endy) < y_threshold + index += 1 - elsif text_node.y < field.y - process_text_node = true - else - process_field_node = true + next end - - elsif text_node - process_text_node = true - elsif field - process_field_node = true end - if process_field_node - unless current_text.empty? - new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:) - tail_node.next = new_text_node - tail_node = new_text_node - current_text = ''.b - end - - new_field_node = PageNode.new(prev: tail_node, elem: field, page: page.page_index, attachment_uuid:) - tail_node.next = new_field_node - tail_node = new_field_node - - field_nodes << tail_node + loop do + break unless current_field + + if ((current_field.endy - node.endy).abs < y_threshold && + (current_field.x <= node.x || node.content.in?(LINEBREAK))) || + current_field.endy < node.y + if tail_node.elem.is_a?(Templates::ImageToFields::Field) + divider = + if (tail_node.elem.endy - current_field.endy).abs > y_threshold + "\n".b + elsif tail_node.elem.endx - current_field.x > x_threshold + "\t".b + else + ' '.b + end + + text_node = PageNode.new(prev: tail_node, elem: divider, page: page.page_index, attachment_uuid:) + tail_node.next = text_node + + tail_node = text_node + elsif prev_node && (prev_node.endy - current_field.endy).abs > y_threshold + text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:) + tail_node.next = text_node + + tail_node = text_node + end - while text_idx < text_nodes.length - text_node_to_check = text_nodes[text_idx] + field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:) - is_part_of_field = false + tail_node.next = field_node + tail_node = field_node + field_nodes << tail_node - if text_node_to_check.content == '_' - check_y_center = text_node_to_check.y + (text_node_to_check.h / 2.0) - check_y_dist = (check_y_center - field_y_center).abs - check_y_thresh = text_node_to_check.h / 2.0 + current_field = fields.shift + else + break + end + end - if check_y_dist < check_y_thresh - padding = text_node_to_check.w * 3.0 - field_x_start = field.x - padding - field_x_end = field.x + field.w + padding - text_x_start = text_node_to_check.x - text_x_end = text_node_to_check.x + text_node_to_check.w + if tail_node.elem.is_a?(Templates::ImageToFields::Field) + prev_field = tail_node.elem - is_part_of_field = true if text_x_start <= field_x_end && field_x_start <= text_x_end - end - end + text_node = PageNode.new(prev: tail_node, elem: ''.b, page: page.page_index, attachment_uuid:) + tail_node.next = text_node - break unless is_part_of_field + tail_node = text_node - text_idx += 1 + if (node.endy - prev_field.endy).abs > y_threshold + tail_node.elem << "\n" + elsif (node.x - prev_field.endx) > x_threshold + tail_node.elem << "\t" end + elsif prev_node + if (node.endy - prev_node.endy) > y_threshold && LINEBREAK.exclude?(prev_node.content) + tail_node.elem << "\n" + elsif (node.x - prev_node.endx) > x_threshold && !tail_node.elem.ends_with?("\t") + tail_node.elem << "\t" + end + end - field_idx += 1 - elsif process_text_node - if text_idx > 0 - prev_text_node = text_nodes[text_idx - 1] + if node.content != '_' || !tail_node.elem.ends_with?('___') + tail_node.elem << node.content unless CHECKBOXES.include?(node.content) + end - x_gap = text_node.x - (prev_text_node.x + prev_text_node.w) + prev_node = node - gap_w = text_node.w > prev_text_node.w ? text_node.w : prev_text_node.w + index += 1 + end - current_text << ' ' if x_gap > gap_w * 2 - end + loop do + break unless current_field - current_text << text_node.content - text_idx += 1 - end + field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:) + tail_node.next = field_node + tail_node = field_node + field_nodes << tail_node + + current_field = fields.shift end - unless current_text.empty? - new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:) - tail_node.next = new_text_node - tail_node = new_text_node + if tail_node.elem.is_a?(Templates::ImageToFields::Field) + text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:) + tail_node.next = text_node + + tail_node = text_node + else + tail_node.elem << "\n" end [field_nodes, tail_node] @@ -399,8 +417,8 @@ module Templates x1 = node.x y1 = node.y - x2 = node.x + node.w - y2 = node.y + node.h + x2 = node.endx + y2 = node.endy underscore_count = 1 @@ -417,8 +435,9 @@ module Templates break if distance > 0.02 || height_diff > node.h * 0.5 underscore_count += 1 - next_x2 = next_node.x + next_node.w - next_y2 = next_node.y + next_node.h + + next_x2 = next_node.endx + next_y2 = next_node.endy x2 = next_x2 y2 = [y2, next_y2].max @@ -438,8 +457,8 @@ module Templates def calculate_iou(box1, box2) x1 = [box1.x, box2.x].max y1 = [box1.y, box2.y].max - x2 = [box1.x + box1.w, box2.x + box2.w].min - y2 = [box1.y + box1.h, box2.y + box2.h].min + x2 = [box1.endx, box2.endx].min + y2 = [box1.endy, box2.endy].min intersection_width = [0, x2 - x1].max intersection_height = [0, y2 - y1].max @@ -455,8 +474,7 @@ module Templates end def boxes_overlap?(box1, box2) - !(box1.x + box1.w < box2.x || box2.x + box2.w < box1.x || - box1.y + box1.h < box2.y || box2.y + box2.h < box1.y) + !(box1.endx < box2.x || box2.endx < box1.x || box1.endy < box2.y || box2.endy < box1.y) end def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0) @@ -465,14 +483,13 @@ module Templates image_fields.map do |image_field| next if image_field.type != 'text' - field_bottom = image_field.y + image_field.h - text_fields.each do |text_field| - break if text_field.y > field_bottom + break if text_field.y > image_field.endy - next if text_field.y + text_field.h < image_field.y + next if text_field.endy < image_field.y - next unless boxes_overlap?(image_field, text_field) && calculate_iou(image_field, text_field) > 0.5 + next unless boxes_overlap?(image_field, text_field) + next if calculate_iou(image_field, text_field) < 0.4 break image_field.confidence += by end diff --git a/lib/templates/image_to_fields.rb b/lib/templates/image_to_fields.rb index 786e9785..d9c04221 100755 --- a/lib/templates/image_to_fields.rb +++ b/lib/templates/image_to_fields.rb @@ -4,7 +4,15 @@ module Templates module ImageToFields module_function - Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true) + Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true) do + def endy + @endy ||= y + h + end + + def endx + @endx ||= x + w + end + end MODEL_PATH = Rails.root.join('tmp/model.onnx') @@ -60,9 +68,7 @@ module Templates detections = apply_nms(detections, nms) - fields = build_fields_from_detections(detections, image) - - sort_fields(fields, y_threshold: 10.0 / image.height) + build_fields_from_detections(detections, image) end def build_split_image_regions(image) @@ -298,27 +304,6 @@ module Templates end end - def sort_fields(fields, y_threshold: 0.01) - sorted_fields = fields.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y } - - lines = [] - current_line = [] - - sorted_fields.each do |field| - if current_line.blank? || (field.y - current_line.first.y).abs < y_threshold - current_line << field - else - lines << current_line.sort_by(&:x) - - current_line = [field] - end - end - - lines << current_line.sort_by(&:x) if current_line.present? - - lines.flatten - end - def apply_nms(detections, threshold = 0.5) return detections if detections[:xyxy].shape[0].zero? diff --git a/lib/time_utils.rb b/lib/time_utils.rb index c1c0d07e..0af5a928 100644 --- a/lib/time_utils.rb +++ b/lib/time_utils.rb @@ -22,7 +22,7 @@ module TimeUtils DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY' DEFAULT_DATE_FORMAT = 'DD/MM/YYYY' - US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze + US_TIMEZONES = %w[EST EDT CST CDT MST MDT PST PDT HST HDT AKST AKDT].freeze module_function diff --git a/package.json b/package.json index 4b664b19..af04328b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-macros": "^3.1.0", "canvas-confetti": "^1.6.0", + "chart.js": "^4.5.1", "codemirror": "^6.0.2", "compression-webpack-plugin": "10.0.0", "css-loader": "^6.7.3", diff --git a/yarn.lock b/yarn.lock index 2faf9648..4b3b5bf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1355,6 +1355,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -2341,6 +2346,13 @@ chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chart.js@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35" + integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw== + dependencies: + "@kurkle/color" "^0.3.0" + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"