mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aacd98460 | |||
| 840162c9ca | |||
| 3519b0e009 | |||
| 53fae56c51 | |||
| f4552dbaa8 | |||
| dfab4c331c | |||
| d2a0937038 | |||
| fa1ef44b22 | |||
| c1a5c91299 | |||
| e6280e8f5a | |||
| fd2ba57325 | |||
| dc178ef03f | |||
| 68936e10c5 | |||
| 41f16e7d39 | |||
| 453ebbf90a | |||
| 7ca290c080 | |||
| dd5b6a6aa6 | |||
| bf2ebb995b | |||
| 3e929758fb | |||
| 4a53116586 | |||
| 2d1981acfa | |||
| c610df269f | |||
| 96dac635f5 | |||
| abd579103d | |||
| 7ed27bb413 | |||
| 180c542a9f | |||
| 89613113de | |||
| 3102900776 | |||
| 1a68053f4e | |||
| 5d4a032763 | |||
| 7481d27822 | |||
| 22afd01fd2 | |||
| fd9eb09dd6 | |||
| c53ed7f984 | |||
| 1d87e210be | |||
| 5fec952b8c | |||
| c4fbcc103a | |||
| 9333a1ba45 | |||
| de5cd92dba | |||
| 83e32bec9f | |||
| 4603efc04e | |||
| 6c73c82a27 | |||
| 8e71d9f9e9 | |||
| 80f60a5d88 | |||
| 2c33ec382d | |||
| 884865cffc | |||
| 9537fe8c91 | |||
| c8fe36a2b6 | |||
| 7ec3282b56 | |||
| ab8c41a520 | |||
| 4c1c70ff04 | |||
| d7a6e80bb1 | |||
| f87ef670d1 | |||
| daba2505bc | |||
| 71528eda45 | |||
| 269e2beeaa | |||
| 72dd2c6bc5 | |||
| 9758f7a15f | |||
| 2521eb5a4a | |||
| bf6afc5e61 | |||
| 291d869843 | |||
| cdfecb2a8f | |||
| c0df8e0ec3 | |||
| a21f7d1653 | |||
| 79e2514416 | |||
| 80a7385183 | |||
| 1c8a7b6a7c | |||
| b46ced2b6f | |||
| 5ae479644e | |||
| cb66eb1293 | |||
| b4d5a2cad4 | |||
| 32691b0c0d | |||
| 9163f744a3 | |||
| 23ecce51a3 | |||
| 747ffa8cf1 |
@@ -37,3 +37,4 @@ yarn-debug.log*
|
||||
/docuseal
|
||||
/ee
|
||||
dump.rdb
|
||||
*.onnx
|
||||
|
||||
@@ -45,6 +45,9 @@ Metrics/PerceivedComplexity:
|
||||
Style/MultipleComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/NumericPredicate:
|
||||
Enabled: false
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
|
||||
+6
-2
@@ -9,6 +9,7 @@ RUN apk --no-cache add fontforge wget && \
|
||||
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
|
||||
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
@@ -50,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
|
||||
\n\
|
||||
@@ -66,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
|
||||
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -83,6 +86,7 @@ COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
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 /model.onnx /app/tmp/model.onnx
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -24,7 +24,9 @@ gem 'image_processing'
|
||||
gem 'jwt'
|
||||
gem 'lograge'
|
||||
gem 'mysql2', require: false
|
||||
gem 'numo-narray'
|
||||
gem 'oj'
|
||||
gem 'onnxruntime'
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
|
||||
@@ -357,9 +357,18 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1)
|
||||
ffi
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (3.3.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -638,7 +647,9 @@ DEPENDENCIES
|
||||
letter_opener_web
|
||||
lograge
|
||||
mysql2
|
||||
numo-narray
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
|
||||
@@ -46,7 +46,9 @@ 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)
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
|
||||
@@ -34,6 +34,10 @@ module Api
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
|
||||
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
|
||||
scope: :console,
|
||||
exp: 1.minute.from_now.to_i)
|
||||
|
||||
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
redir_uri = Addressable::URI.parse(params[:redir])
|
||||
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
|
||||
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
|
||||
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
|
||||
allow_other_host: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ class PersonalizationSettingsController < ApplicationController
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
|
||||
AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
|
||||
respond_to do |f|
|
||||
f.html { render :success }
|
||||
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def already_sent?(submitter)
|
||||
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
|
||||
|
||||
@template = @submission.template
|
||||
|
||||
render :completed, layout: 'form'
|
||||
|
||||
@@ -75,7 +75,9 @@ class SubmitFormController < ApplicationController
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def completed; end
|
||||
def completed
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
|
||||
end
|
||||
|
||||
def success; end
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
class TemplateDocumentsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
if params[:blobs].blank? && params[:files].blank?
|
||||
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
||||
|
||||
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
|
||||
DEBUG_FILE = ''
|
||||
|
||||
def show
|
||||
attachment = @template.documents.first
|
||||
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
|
||||
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
|
||||
|
||||
data = attachment.download
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
end
|
||||
|
||||
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['fields'] = fields
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesDetectFieldsController < ApplicationController
|
||||
include ActionController::Live
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
response.headers['Content-Type'] = 'text/event-stream'
|
||||
|
||||
sse = SSE.new(response.stream)
|
||||
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
end
|
||||
end
|
||||
|
||||
sse.write({ completed: true })
|
||||
ensure
|
||||
response.stream.close
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,15 @@
|
||||
class TemplatesPreferencesController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY => %w[completed_notification_email_subject
|
||||
completed_notification_email_body]
|
||||
}.freeze
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@@ -15,19 +24,38 @@ class TemplatesPreferencesController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize!(:update, @template)
|
||||
|
||||
config_key = params[:config_key]
|
||||
preferences_to_delete = RESETTABLE_PREFERENCE_KEYS[config_key]
|
||||
|
||||
return head :ok if preferences_to_delete.blank?
|
||||
|
||||
preferences_to_delete.each do |key|
|
||||
@template.preferences.delete(key)
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render turbo_stream: turbo_stream.replace("#{config_key}_form",
|
||||
partial: "templates_preferences/#{config_key}_form"),
|
||||
status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa
|
||||
default_expire_at_duration shared_link_2fa
|
||||
default_expire_at request_email_enabled
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
|
||||
@@ -24,10 +24,19 @@ class UsersController < ApplicationController
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
if User.accessible_by(current_ability).exists?(email: @user.email)
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
if existing_user
|
||||
if existing_user.archived_at? &&
|
||||
current_ability.can?(:manage, existing_user) && current_ability.can?(:manage, @user.account)
|
||||
existing_user.assign_attributes(@user.slice(:first_name, :last_name, :role, :account_id))
|
||||
existing_user.archived_at = nil
|
||||
@user = existing_user
|
||||
else
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
@user.password = SecureRandom.hex if @user.password.blank?
|
||||
@@ -56,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
|
||||
|
||||
@@ -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 () {
|
||||
@@ -156,6 +158,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
@@ -166,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,10 @@ export default {
|
||||
style.color = this.field.preferences.color
|
||||
}
|
||||
|
||||
if (this.field.preferences?.background) {
|
||||
style.background = this.field.preferences.background
|
||||
}
|
||||
|
||||
return style
|
||||
},
|
||||
isNarrow () {
|
||||
|
||||
@@ -163,12 +163,20 @@ export default {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json()
|
||||
|
||||
if (resp.status === 422) {
|
||||
alert(data.error)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
})
|
||||
}
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
if (result && result[0]) {
|
||||
this.$emit('upload', result)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
v-if="!isDrawInitials"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<label
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
for="initials_text_input"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
@input="updateWrittenSignature"
|
||||
>
|
||||
<select
|
||||
v-if="requireSigningReason && !isOtherReason"
|
||||
v-if="withSigningReason && !isOtherReason"
|
||||
class="select base-input !text-2xl w-full mt-6 text-center"
|
||||
:class="{ 'text-gray-300': !reason }"
|
||||
required
|
||||
@@ -226,24 +226,37 @@
|
||||
>
|
||||
{{ t('select_a_reason') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
<template v-if="field.preferences?.reasons">
|
||||
<option
|
||||
v-for="option in field.preferences.reasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
v-if="requireSigningReason && isOtherReason"
|
||||
v-if="withSigningReason && isOtherReason"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
required
|
||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||
@@ -253,7 +266,7 @@
|
||||
@input="$emit('update:reason', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
v-if="requireSigningReason"
|
||||
v-if="withSigningReason"
|
||||
hidden
|
||||
name="with_reason"
|
||||
:value="field.preferences.reason_field_uuid"
|
||||
@@ -406,6 +419,9 @@ export default {
|
||||
format () {
|
||||
return this.field.preferences?.format
|
||||
},
|
||||
withSigningReason () {
|
||||
return this.requireSigningReason || this.field.preferences?.reasons?.length
|
||||
},
|
||||
defaultReasons () {
|
||||
return {
|
||||
[this.t('approved_by')]: this.t('approved'),
|
||||
@@ -424,10 +440,11 @@ export default {
|
||||
created () {
|
||||
this.isSignatureStarted = !!this.computedPreviousValue
|
||||
|
||||
if (this.requireSigningReason) {
|
||||
if (this.withSigningReason) {
|
||||
this.field.preferences ||= {}
|
||||
this.field.preferences.reason_field_uuid ||= v4()
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason] &&
|
||||
(!this.field.preferences?.reasons?.length || !this.field.preferences.reasons.includes(this.reason))
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
|
||||
@@ -175,7 +175,10 @@
|
||||
{{ t('save') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': isDownloading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"
|
||||
@@ -209,6 +212,30 @@
|
||||
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="withDownload">
|
||||
<button
|
||||
class="flex space-x-2"
|
||||
:disabled="isDownloading"
|
||||
@click.stop.prevent="download"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isDownloading"
|
||||
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<IconDownload
|
||||
v-else
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="isDownloading"
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('downloading_') }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('download') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
@@ -449,6 +476,7 @@
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
@@ -456,6 +484,7 @@
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@select-submitter="selectedSubmitter = $event"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-drag="dragField = $event"
|
||||
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
|
||||
@@ -510,7 +539,7 @@ import DocumentPreview from './preview'
|
||||
import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
@@ -536,6 +565,7 @@ export default {
|
||||
Contenteditable,
|
||||
IconUsersPlus,
|
||||
IconChevronDown,
|
||||
IconDownload,
|
||||
IconAdjustments,
|
||||
IconEye,
|
||||
IconDeviceFloppy
|
||||
@@ -583,6 +613,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withDownload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -618,6 +653,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withAddPageButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -799,6 +839,7 @@ export default {
|
||||
return {
|
||||
documentRefs: [],
|
||||
isBreakpointLg: false,
|
||||
isDownloading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
selectedSubmitter: null,
|
||||
@@ -957,6 +998,75 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => {
|
||||
if (response.ok) {
|
||||
const urls = await response.json()
|
||||
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
|
||||
const isSafariIos = isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
|
||||
if (isSafariIos && urls.length > 1) {
|
||||
this.downloadSafariIos(urls)
|
||||
} else {
|
||||
this.downloadUrls(urls)
|
||||
}
|
||||
} else {
|
||||
alert(this.t('failed_to_download_files'))
|
||||
}
|
||||
})
|
||||
},
|
||||
downloadUrls (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
return link
|
||||
})
|
||||
})
|
||||
|
||||
Promise.all(fileRequests).then((links) => {
|
||||
links.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href)
|
||||
}, index * 50)
|
||||
})
|
||||
}).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
onDragover (e) {
|
||||
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
|
||||
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
|
||||
|
||||
@@ -208,6 +208,43 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsDetection && editable && fields.length < 2"
|
||||
class="my-2"
|
||||
>
|
||||
<button
|
||||
class="btn w-full"
|
||||
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
|
||||
@click="fieldPagesLoaded !== null ? null : detectFields()"
|
||||
>
|
||||
<template v-if="fieldPagesLoaded !== null">
|
||||
<IconInnerShadowTop
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="analyzingProgress"
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconSparkles width="22" />
|
||||
<span
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ t('autodetect_fields') }}
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
|
||||
@@ -231,7 +268,7 @@
|
||||
import Field from './field'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
@@ -240,11 +277,13 @@ export default {
|
||||
Field,
|
||||
FieldType,
|
||||
IconCirclePlus,
|
||||
IconSparkles,
|
||||
IconInnerShadowTop,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
@@ -255,6 +294,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -328,15 +372,22 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
analyzingProgress: 0,
|
||||
defaultFieldsSearch: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
numberOfPages () {
|
||||
return this.template.documents.reduce((acc, doc) => {
|
||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
||||
}, 0)
|
||||
},
|
||||
isShowFieldSearch () {
|
||||
if (this.withFieldsSearch === false) {
|
||||
return false
|
||||
@@ -389,6 +440,84 @@ export default {
|
||||
|
||||
this.$emit('set-drag', field)
|
||||
},
|
||||
detectFields () {
|
||||
const fields = []
|
||||
|
||||
this.fieldPagesLoaded = 0
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n\n')
|
||||
|
||||
buffer = lines.pop()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.replace(/^data: /, '')
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
if (data.error) {
|
||||
if ((data.fields || fields).length) {
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
alert(data.error)
|
||||
}
|
||||
|
||||
break
|
||||
} else if (data.analyzing) {
|
||||
this.analyzingProgress = data.progress
|
||||
} else if (data.completed) {
|
||||
this.fieldPagesLoaded = null
|
||||
|
||||
if (data.submitters) {
|
||||
this.template.submitters = data.submitters
|
||||
this.$emit('select-submitter', this.template.submitters[0])
|
||||
}
|
||||
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
|
||||
break
|
||||
} else if (data.fields) {
|
||||
data.fields.forEach((f) => {
|
||||
if (!f.submitter_uuid) {
|
||||
f.submitter_uuid = this.template.submitters[0].uuid
|
||||
}
|
||||
})
|
||||
|
||||
this.fieldPagesLoaded += 1
|
||||
|
||||
fields.push(...data.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error in streaming message: ', error)
|
||||
}).finally(() => {
|
||||
this.fieldPagesLoaded = null
|
||||
this.analyzingProgress = null
|
||||
this.isFieldsLoading = false
|
||||
})
|
||||
},
|
||||
setDragPlaceholder (event) {
|
||||
this.$emit('set-drag-placeholder', {
|
||||
offsetX: event.offsetX,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const en = {
|
||||
analyzing_: 'Analyzing...',
|
||||
download: 'Download',
|
||||
downloading_: 'Downloading...',
|
||||
view: 'View',
|
||||
autodetect_fields: 'Autodetect fields',
|
||||
payment_link: 'Payment link',
|
||||
strikeout: 'Strikeout',
|
||||
draw_strikethrough_the_document: 'Draw strikethrough the document',
|
||||
@@ -184,6 +188,10 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
autodetect_fields: 'Autodetectar campos',
|
||||
analyzing_: 'Analizando...',
|
||||
download: 'Descargar',
|
||||
downloading_: 'Descargando...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Enlace de pago',
|
||||
strikeout: 'Tachar',
|
||||
@@ -369,6 +377,10 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
autodetect_fields: 'Rileva campi',
|
||||
analyzing_: 'Analisi...',
|
||||
download: 'Scarica',
|
||||
downloading_: 'Download in corso...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Link di pagamento',
|
||||
strikeout: 'Barrato',
|
||||
@@ -554,6 +566,10 @@ const it = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
autodetect_fields: 'Detectar campos',
|
||||
analyzing_: 'Analisando...',
|
||||
download: 'Baixar',
|
||||
downloading_: 'Baixando...',
|
||||
view: 'Visualizar',
|
||||
payment_link: 'Link de pagamento',
|
||||
strikeout: 'Tachado',
|
||||
@@ -739,6 +755,10 @@ const pt = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
autodetect_fields: 'Détecter les champs',
|
||||
analyzing_: 'Analyse...',
|
||||
download: 'Télécharger',
|
||||
downloading_: 'Téléchargement...',
|
||||
view: 'Voir',
|
||||
payment_link: 'Lien de paiement',
|
||||
strikeout: 'Rature',
|
||||
@@ -924,6 +944,10 @@ const fr = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
autodetect_fields: 'Felder erkennen',
|
||||
analyzing_: 'Analysiere...',
|
||||
download: 'Download',
|
||||
downloading_: 'Download...',
|
||||
view: 'Anzeigen',
|
||||
payment_link: 'Zahlungslink',
|
||||
strikeout: 'Durchstreichen',
|
||||
@@ -1109,6 +1133,10 @@ const de = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
autodetect_fields: 'Velden detecteren',
|
||||
analyzing_: 'Analyseren...',
|
||||
download: 'Downloaden',
|
||||
downloading_: 'Downloaden...',
|
||||
view: 'Bekijken',
|
||||
payment_link: 'Betaallink',
|
||||
strikeout: 'Doorhalen',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,8 @@ class SendSubmitterInvitationEmailJob
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
return if submitter.completed_at?
|
||||
return if submitter.submission.archived_at?
|
||||
return if submitter.template&.archived_at?
|
||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||
|
||||
unless Accounts.can_send_invitation_emails?(submitter.account)
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
@@ -59,6 +60,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:template_name_has_been_completed_by_submitters),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10z" />
|
||||
<path d="M7 20h10" /><path d="M9 16v4" />
|
||||
<path d="M15 16v4" /></svg>
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,6 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14z" />
|
||||
<path d="M11 4h2" />
|
||||
<path d="M12 17v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1,5 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16z" />
|
||||
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -3,7 +3,7 @@
|
||||
<%= svg_icon('waving_hand', class: 'h-10 w-10') %>
|
||||
<span><%= t('welcome_to_product_name', product_name: Docuseal.product_name) %></span>
|
||||
</h1>
|
||||
<%= 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| %>
|
||||
<div class="space-y-2">
|
||||
<%= render 'devise/shared/error_messages', resource: %>
|
||||
<%= f.hidden_field :reset_password_token %>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
<div class="form-control">
|
||||
<%= f.label :email, t('email'), class: 'label' %>
|
||||
<%= f.email_field :email, autocomplete: 'off', class: 'base-input' %>
|
||||
<% if current_user.try(:pending_reconfirmation?) %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
|
||||
<li>
|
||||
<%= 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') %>
|
||||
<span class="badge badge-warning"><%= t('pro') %></span>
|
||||
<% end %>
|
||||
@@ -74,13 +74,13 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
</li>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
</li>
|
||||
|
||||
@@ -28,9 +28,16 @@
|
||||
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
|
||||
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<p class="text-sm leading-none text-base-content/60 pt-1.5">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</p>
|
||||
<div class="flex items-center space-x-1 pt-1.5">
|
||||
<span class="text-sm leading-none text-base-content/60">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</span>
|
||||
<% if (device = DetectBrowserDevice.call(event.data['ua'])) %>
|
||||
<span class="tooltip tooltip-top" data-tip="<%= t(device) %>">
|
||||
<%= svg_icon("device_#{device}", class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-base-content/80 mt-1">
|
||||
<% if event.event_type == 'complete_verification' %>
|
||||
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
|
||||
|
||||
@@ -1,116 +1 @@
|
||||
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %>
|
||||
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
|
||||
<% message_field_id = "message_field_#{SecureRandom.hex(3)}" %>
|
||||
<div class="form-control">
|
||||
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
|
||||
<div class="flex justify-between items-center">
|
||||
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
|
||||
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %>
|
||||
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
|
||||
<% end %>
|
||||
<div>
|
||||
<% if can_send_emails %>
|
||||
<%= render 'submissions/email_stats' %>
|
||||
<%= content_for(:edit_button) || capture do %>
|
||||
<toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex">
|
||||
<label>
|
||||
<%= f.check_box :is_custom_message, checked: false, class: 'hidden peer', data: { action: 'change:toggle-visible#trigger', type: 'checkbox' } %>
|
||||
<span class="link peer-checked:hidden"><%= t('edit_message') %></span>
|
||||
</label>
|
||||
</toggle-visible>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? && !can_send_emails %>
|
||||
<div class="alert my-4">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold"><%= t('smtp_not_configured') %></p>
|
||||
<p class="text-gray-700">
|
||||
<%= t('configure_smtp_settings_in_order_to_send_emails_') %>
|
||||
<br>
|
||||
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">
|
||||
<%= t('go_to_smtp_settings') %>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
||||
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
||||
<div class="card-body">
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden form-control space-y-2">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= f.label :subject, t('subject'), class: 'label' %>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<label for="<%= toggle_uuid %>" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'submissions/message_fields' %>
|
||||
</div>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<div class="hidden peer-checked:block form-control space-y-2">
|
||||
<% uuid = SecureRandom.uuid %>
|
||||
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<% template_submitters.each_with_index do |submitter, index| %>
|
||||
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
|
||||
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
</div>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render partial: 'submissions/send_email_base', locals: local_assigns %>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %>
|
||||
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
|
||||
<% message_field_id = "message_field_#{SecureRandom.hex(3)}" %>
|
||||
<div class="form-control">
|
||||
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
|
||||
<div class="flex justify-between items-center">
|
||||
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
|
||||
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %>
|
||||
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
|
||||
<% end %>
|
||||
<div>
|
||||
<% if can_send_emails %>
|
||||
<%= render 'submissions/email_stats' %>
|
||||
<%= content_for(:edit_button) || capture do %>
|
||||
<toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex">
|
||||
<label>
|
||||
<%= f.check_box :is_custom_message, checked: false, class: 'hidden peer', data: { action: 'change:toggle-visible#trigger', type: 'checkbox' } %>
|
||||
<span class="link peer-checked:hidden"><%= t('edit_message') %></span>
|
||||
</label>
|
||||
</toggle-visible>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? && !can_send_emails %>
|
||||
<div class="alert my-4">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold"><%= t('smtp_not_configured') %></p>
|
||||
<p class="text-gray-700">
|
||||
<%= t('configure_smtp_settings_in_order_to_send_emails_') %>
|
||||
<br>
|
||||
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">
|
||||
<%= t('go_to_smtp_settings') %>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
||||
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
||||
<div class="card-body">
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden form-control space-y-2">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= f.label :subject, t('subject'), class: 'label' %>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<label for="<%= toggle_uuid %>" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'submissions/message_fields' %>
|
||||
</div>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<div class="hidden peer-checked:block form-control space-y-2">
|
||||
<% uuid = SecureRandom.uuid %>
|
||||
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<% template_submitters.each_with_index do |submitter, index| %>
|
||||
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
|
||||
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
</div>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +1,11 @@
|
||||
<% align = field.dig('preferences', 'align') %>
|
||||
<% valign = field.dig('preferences', 'valign') %>
|
||||
<% color = field.dig('preferences', 'color') %>
|
||||
<% bg_color = field.dig('preferences', 'background') %>
|
||||
<% font = field.dig('preferences', 'font') %>
|
||||
<% font_type = field.dig('preferences', 'font_type') %>
|
||||
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
|
||||
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<% if field['type'] == 'signature' %>
|
||||
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
|
||||
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
|
||||
<% if document.preview_images.first %>
|
||||
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer">
|
||||
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<div class="pb-2 pt-1.5 text-center" dir="auto">
|
||||
<%= item['name'].presence || document.filename.base %>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@
|
||||
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
|
||||
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= width %>" class="rounded" height="<%= height %>">
|
||||
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
|
||||
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
||||
<% document_annots_index[index]&.each do |annot| %>
|
||||
<%= render 'submissions/annotation', annot: %>
|
||||
|
||||
@@ -38,14 +38,6 @@
|
||||
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div>
|
||||
<%= link_to t('create_free_account'), registration_path, class: 'white-button w-full' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('completed_notification_email_subject', 'completed_notification_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_completed_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_completed_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_subject, :completed_notification_email_body).new(@template.preferences['completed_notification_email_subject'].presence || configs['subject'], @template.preferences['completed_notification_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_completed_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :completed_notification_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :completed_notification_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_enabled, :completed_notification_email_attach_audit, :completed_notification_email_attach_documents).new(@template.preferences['completed_notification_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['completed_notification_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['completed_notification_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_documents, { checked: ff.object.completed_notification_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_audit, { checked: ff.object.completed_notification_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_enabled, { checked: ff.object.completed_notification_email_enabled != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_completed_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert3" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('documents_copy_email_subject', 'documents_copy_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_documents_copy_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_documents_copy_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert2"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_reply_to, :documents_copy_email_subject, :documents_copy_email_body).new(@template.preferences['documents_copy_email_reply_to'].presence || configs['reply_to'], @template.preferences['documents_copy_email_subject'].presence || configs['subject'], @template.preferences['documents_copy_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_documents_copy_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :documents_copy_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :documents_copy_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% if can?(:manage, :reply_to) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_reply_to, t('reply_to'), class: 'label' %>
|
||||
<%= ff.email_field :documents_copy_email_reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_documents_copy_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_enabled, :documents_copy_email_attach_audit, :documents_copy_email_attach_documents).new(@template.preferences['documents_copy_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['documents_copy_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['documents_copy_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_documents, { checked: ff.object.documents_copy_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_audit, { checked: ff.object.documents_copy_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_documents_copy_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert2" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,110 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY %>_form">
|
||||
<% template_email_preferences_values = @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank %>
|
||||
<% default_template_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% multiple_submitters = @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<% if is_custom_template_email || @template.preferences['submitters'].to_a.any? %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_invitation_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_invitation_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= tag.input id: 'request_email_per_submitter', value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: @template.preferences['submitters'].to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden">
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(template_email_preferences_values.presence || default_template_email_preferences_values)) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_invitation_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% elsif multiple_submitters %>
|
||||
<label for="request_email_per_submitter" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if multiple_submitters %>
|
||||
<div class="hidden peer-checked:block">
|
||||
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap mb-2">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<%= f.fields_for :preferences do |ff| %>
|
||||
<% @template.submitters.each_with_index do |submitter, index| %>
|
||||
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
|
||||
<% submitter_email_preferences_values = submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence %>
|
||||
<%= ff.fields_for :submitters, Struct.new(:request_email_subject, :request_email_body).new(*(submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || template_email_preferences_values.presence || default_template_email_preferences_values)), index: nil do |fff| %>
|
||||
<%= fff.hidden_field :uuid, value: submitter['uuid'] %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= fff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if submitter_email_preferences_values.present? %>
|
||||
<label for="submitter_invitation_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= fff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= fff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= fff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_enabled).new(@template.preferences['request_email_enabled']) do |ff| %>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_signature_request_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :request_email_enabled, { checked: ff.object.request_email_enabled != false, class: 'toggle' }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_invitation_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert1" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,157 +98,17 @@
|
||||
<%= t('signature_request_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= tag.input id: 'request_email_per_submitter', value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: @template.preferences['submitters'].to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden">
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(@template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<label for="request_email_per_submitter" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<div class="hidden peer-checked:block">
|
||||
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap mb-2">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<%= f.fields_for :preferences do |ff| %>
|
||||
<% @template.submitters.each_with_index do |submitter, index| %>
|
||||
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
|
||||
<%= ff.fields_for :submitters, Struct.new(:request_email_subject, :request_email_body).new(*(submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))), index: nil do |fff| %>
|
||||
<%= fff.hidden_field :uuid, value: submitter['uuid'] %>
|
||||
<div class="form-control">
|
||||
<%= fff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= fff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= fff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= fff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_enabled).new(@template.preferences['request_email_enabled']) do |ff| %>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= 'Send signature request email' %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :request_email_enabled, { checked: ff.object.request_email_enabled != false, class: 'toggle' }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert1" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_invitation_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'templates_preferences/submitter_invitation_reminder_email_collapse' %>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
<input type="checkbox" name="accordion">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<%= t('documents_copy_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert2"></toggle-on-submit>
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY).value %>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_reply_to, :documents_copy_email_subject, :documents_copy_email_body, :documents_copy_email_enabled, :documents_copy_email_attach_audit, :documents_copy_email_attach_documents).new(@template.preferences['documents_copy_email_reply_to'].presence || configs['reply_to'], @template.preferences['documents_copy_email_subject'].presence || configs['subject'], @template.preferences['documents_copy_email_body'].presence || configs['body'], @template.preferences['documents_copy_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['documents_copy_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['documents_copy_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= ff.text_field :documents_copy_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :documents_copy_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% if can?(:manage, :reply_to) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_reply_to, t('reply_to'), class: 'label' %>
|
||||
<%= ff.email_field :documents_copy_email_reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_documents, { checked: ff.object.documents_copy_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_audit, { checked: ff.object.documents_copy_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert2" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_documents_copy_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
@@ -257,57 +117,7 @@
|
||||
<%= t('completed_notification_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_subject, :completed_notification_email_body, :completed_notification_email_enabled, :completed_notification_email_attach_audit, :completed_notification_email_attach_documents).new(@template.preferences['completed_notification_email_subject'].presence || configs['subject'], @template.preferences['completed_notification_email_body'].presence || configs['body'], @template.preferences['completed_notification_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['completed_notification_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['completed_notification_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= ff.text_field :completed_notification_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :completed_notification_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_documents, { checked: ff.object.completed_notification_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_audit, { checked: ff.object.completed_notification_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_enabled, { checked: ff.object.completed_notification_email_enabled != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert3" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_completed_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,9 +169,9 @@
|
||||
<span>
|
||||
<%= t('share_template_with_test_mode') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
|
||||
</submit-form>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
<div class="form-control">
|
||||
<%= f.label :email, t('email'), class: 'label' %>
|
||||
<%= f.email_field :email, required: true, class: 'base-input' %>
|
||||
<% if user.try(:pending_reconfirmation?) %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
<% if user.persisted? && Accounts.can_send_emails?(current_account) %>
|
||||
<span class="label-text-alt mt-2 mx-1">
|
||||
<%= t('click_here_to_send_a_reset_password_email_html') %>
|
||||
|
||||
@@ -52,7 +52,13 @@
|
||||
<%= user.full_name %>
|
||||
</td>
|
||||
<td>
|
||||
<%= user.email %>
|
||||
<% if user.try(:pending_reconfirmation?) %>
|
||||
<%= user.unconfirmed_email %>
|
||||
<br>
|
||||
<span class="label-text-alt">(<%= t('unconfirmed') %>)</span>
|
||||
<% else %>
|
||||
<%= user.email %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info badge-outline whitespace-nowrap">
|
||||
|
||||
@@ -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
|
||||
|
||||
+265
-1
@@ -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 <a href="%{product_url}">%{product_name}</a> in testing mode'
|
||||
@@ -290,6 +291,7 @@ en: &en
|
||||
invalid_timeserver: Invalid Timeserver
|
||||
email_templates: Email Templates
|
||||
signature_request_email: Signature request email
|
||||
signature_request_reminder_email: Signature request reminder email
|
||||
signature_request_sms: Signature Request SMS
|
||||
verification_code_sms: Verification Code SMS
|
||||
completed_notification_email: Completed notification email
|
||||
@@ -837,6 +839,41 @@ en: &en
|
||||
connect_google_drive: Connect Google Drive
|
||||
google_drive_has_been_connected: Google Drive has been connected
|
||||
unable_to_identify_reset_your_password_to_sign_in: Unable to identify. Reset your password to sign in.
|
||||
desktop: Desktop
|
||||
mobile: Mobile
|
||||
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
|
||||
@@ -943,10 +980,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: '<label class="link" for="resend_password_button">Haz clic aquí</label> 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
|
||||
@@ -1212,6 +1251,7 @@ es: &es
|
||||
invalid_timeserver: Servidor de tiempo inválido
|
||||
email_templates: Plantillas de correo electrónico
|
||||
signature_request_email: Correo de solicitud de firma
|
||||
signature_request_reminder_email: Correo de recordatorio de solicitud de firma
|
||||
signature_request_sms: SMS de solicitud de firma
|
||||
verification_code_sms: SMS de código de verificación
|
||||
completed_notification_email: Correo de notificación de formulario completado
|
||||
@@ -1587,6 +1627,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
|
||||
@@ -1758,6 +1799,41 @@ es: &es
|
||||
connect_google_drive: Conectar Google Drive
|
||||
google_drive_has_been_connected: Google Drive se ha conectado
|
||||
unable_to_identify_reset_your_password_to_sign_in: No se pudo identificar. Restablece tu contraseña para iniciar sesión.
|
||||
desktop: Escritorio
|
||||
mobile: Móvil
|
||||
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
|
||||
@@ -1864,6 +1940,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
|
||||
@@ -1897,6 +1974,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
|
||||
@@ -2133,6 +2211,7 @@ it: &it
|
||||
invalid_timeserver: Server di timestamp non valido
|
||||
email_templates: Modelli email
|
||||
signature_request_email: Email di richiesta di firma
|
||||
signature_request_reminder_email: Email di promemoria di richiesta di firma
|
||||
signature_request_sms: SMS di richiesta di firma
|
||||
verification_code_sms: SMS con codice di verifica
|
||||
completed_notification_email: Email di notifica di completamento
|
||||
@@ -2341,6 +2420,7 @@ it: &it
|
||||
upload_signature: Carica firma
|
||||
integration: Integrazione
|
||||
admin: Amministratore
|
||||
tenant_admin: Amministratore tenant
|
||||
editor: Editor
|
||||
viewer: Visualizzatore
|
||||
member: Membro
|
||||
@@ -2680,6 +2760,41 @@ it: &it
|
||||
connect_google_drive: Connetti Google Drive
|
||||
google_drive_has_been_connected: Google Drive è stato connesso
|
||||
unable_to_identify_reset_your_password_to_sign_in: Impossibile identificare. Reimposta la password per accedere.
|
||||
desktop: Desktop
|
||||
mobile: Mobile
|
||||
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
|
||||
@@ -2786,6 +2901,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
|
||||
@@ -3056,6 +3172,7 @@ fr: &fr
|
||||
invalid_timeserver: Serveur d’horodatage invalide
|
||||
email_templates: Modèles d’e‑mail
|
||||
signature_request_email: E‑mail de demande de signature
|
||||
signature_request_reminder_email: E‑mail de rappel de demande de signature
|
||||
signature_request_sms: SMS de demande de signature
|
||||
verification_code_sms: SMS de code de vérification
|
||||
completed_notification_email: E‑mail de notification de finalisation
|
||||
@@ -3599,6 +3716,42 @@ fr: &fr
|
||||
connect_google_drive: Connecter Google Drive
|
||||
google_drive_has_been_connected: Google Drive a été connecté
|
||||
unable_to_identify_reset_your_password_to_sign_in: Impossible d’identifier. Réinitialisez votre mot de passe pour vous connecter.
|
||||
desktop: Bureau
|
||||
mobile: Mobile
|
||||
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
|
||||
@@ -3705,6 +3858,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
|
||||
@@ -3975,6 +4129,7 @@ pt: &pt
|
||||
invalid_timeserver: Servidor de carimbo de tempo inválido
|
||||
email_templates: Modelos de e-mail
|
||||
signature_request_email: E-mail de solicitação de assinatura
|
||||
signature_request_reminder_email: E-mail de lembrete de solicitação de assinatura
|
||||
signature_request_sms: SMS de solicitação de assinatura
|
||||
verification_code_sms: SMS com código de verificação
|
||||
completed_notification_email: E-mail de notificação de submissão concluída
|
||||
@@ -4522,6 +4677,41 @@ pt: &pt
|
||||
connect_google_drive: Conectar Google Drive
|
||||
google_drive_has_been_connected: O Google Drive foi conectado
|
||||
unable_to_identify_reset_your_password_to_sign_in: Não foi possível identificar. Redefina sua senha para fazer login.
|
||||
desktop: Computador
|
||||
mobile: Celular
|
||||
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
|
||||
@@ -4628,6 +4818,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
|
||||
@@ -4898,6 +5089,7 @@ de: &de
|
||||
invalid_timeserver: Ungültiger Zeitstempelserver
|
||||
email_templates: E-Mail-Vorlagen
|
||||
signature_request_email: E-Mail für Signaturanfrage
|
||||
signature_request_reminder_email: E-Mail-Erinnerung für Signaturanfrage
|
||||
signature_request_sms: SMS für Signaturanfrage
|
||||
verification_code_sms: SMS mit Verifizierungscode
|
||||
completed_notification_email: E-Mail-Benachrichtigung bei Abschluss
|
||||
@@ -5445,6 +5637,41 @@ de: &de
|
||||
connect_google_drive: Google Drive verbinden
|
||||
google_drive_has_been_connected: Google Drive wurde verbunden
|
||||
unable_to_identify_reset_your_password_to_sign_in: Identifizierung nicht möglich. Setzen Sie Ihr Passwort zurück, um sich anzumelden.
|
||||
desktop: Desktop
|
||||
mobile: Mobil
|
||||
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
|
||||
@@ -5915,6 +6142,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
|
||||
@@ -6186,6 +6414,7 @@ nl: &nl
|
||||
invalid_timeserver: Ongeldige tijdserver
|
||||
email_templates: E-mailsjablonen
|
||||
signature_request_email: E-mail voor handtekeningverzoek
|
||||
signature_request_reminder_email: E-mailherinnering voor handtekeningverzoek
|
||||
signature_request_sms: SMS voor handtekeningverzoek
|
||||
verification_code_sms: Verificatiecode-SMS
|
||||
completed_notification_email: E-mailmelding voltooid
|
||||
@@ -6729,6 +6958,41 @@ nl: &nl
|
||||
connect_google_drive: Verbind Google Drive
|
||||
google_drive_has_been_connected: Google Drive is verbonden
|
||||
unable_to_identify_reset_your_password_to_sign_in: Kan niet worden geïdentificeerd. Stel je wachtwoord opnieuw in om in te loggen.
|
||||
desktop: Desktop
|
||||
mobile: Mobiel
|
||||
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
|
||||
|
||||
+5
-9
@@ -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
|
||||
@@ -98,8 +93,9 @@ Rails.application.routes.draw do
|
||||
resources :submissions_filters, only: %i[show], param: 'name'
|
||||
resources :templates, only: %i[new create edit update show destroy] do
|
||||
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
|
||||
resources :documents, only: %i[create], controller: 'template_documents'
|
||||
resources :documents, only: %i[index create], controller: 'template_documents'
|
||||
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
|
||||
resources :detect_fields, only: %i[create], controller: 'templates_detect_fields' unless Docuseal.multitenant?
|
||||
resources :restore, only: %i[create], controller: 'templates_restore'
|
||||
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
|
||||
resources :submissions, only: %i[new create]
|
||||
@@ -107,7 +103,7 @@ Rails.application.routes.draw do
|
||||
resource :preview, only: %i[show], controller: 'templates_preview'
|
||||
resource :form, only: %i[show], controller: 'templates_form_preview'
|
||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||
resource :preferences, only: %i[show create], controller: 'templates_preferences'
|
||||
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
|
||||
resource :share_link, only: %i[show create], controller: 'templates_share_link'
|
||||
resources :recipients, only: %i[create], controller: 'templates_recipients'
|
||||
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+13
-2
@@ -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"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DetectBrowserDevice
|
||||
module_function
|
||||
|
||||
MOBILE_USER_AGENT_REGEXP = /
|
||||
iPhone |
|
||||
iPod |
|
||||
Android.*Mobile|
|
||||
Opera\ Mini |
|
||||
Opera\ Mobi |
|
||||
webOS |
|
||||
IEMobile |
|
||||
Windows\ Phone |
|
||||
BlackBerry |
|
||||
BB10 |
|
||||
Mobile
|
||||
/ix
|
||||
|
||||
TABLET_USER_AGENT_REGEXP = /
|
||||
iPad |
|
||||
Android(?!.*Mobile)|
|
||||
Tablet |
|
||||
Kindle |
|
||||
PlayBook |
|
||||
Silk
|
||||
/ix
|
||||
|
||||
def call(user_agent)
|
||||
return if user_agent.blank?
|
||||
|
||||
return 'mobile' if MOBILE_USER_AGENT_REGEXP.match?(user_agent)
|
||||
return 'tablet' if TABLET_USER_AGENT_REGEXP.match?(user_agent)
|
||||
|
||||
'desktop'
|
||||
end
|
||||
end
|
||||
+199
@@ -25,6 +25,8 @@ class Pdfium
|
||||
typedef :pointer, :FPDF_BITMAP
|
||||
typedef :pointer, :FPDF_FORMHANDLE
|
||||
typedef :pointer, :FPDF_TEXTPAGE
|
||||
typedef :pointer, :FPDF_PAGEOBJECT
|
||||
typedef :pointer, :FPDF_PATHSEGMENT
|
||||
|
||||
MAX_SIZE = 32_767
|
||||
|
||||
@@ -37,6 +39,26 @@ class Pdfium
|
||||
FPDF_RENDER_FORCEHALFTONE = 0x400
|
||||
FPDF_PRINTING = 0x800
|
||||
|
||||
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
|
||||
layout :version, :int,
|
||||
@@ -77,6 +99,37 @@ class Pdfium
|
||||
attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void
|
||||
attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int
|
||||
attach_function :FPDFText_GetText, %i[FPDF_TEXTPAGE int int pointer], :int
|
||||
attach_function :FPDFText_GetUnicode, %i[FPDF_TEXTPAGE int], :uint
|
||||
attach_function :FPDFText_GetCharBox, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFText_GetCharOrigin, %i[FPDF_TEXTPAGE int pointer pointer], :int
|
||||
attach_function :FPDFText_GetCharIndexAtPos, %i[FPDF_TEXTPAGE double double double double], :int
|
||||
attach_function :FPDFText_CountRects, %i[FPDF_TEXTPAGE int int], :int
|
||||
attach_function :FPDFText_GetRect, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFText_GetFontSize, %i[FPDF_TEXTPAGE int], :double
|
||||
|
||||
# Page object functions for extracting paths/lines
|
||||
attach_function :FPDFPage_CountObjects, [:FPDF_PAGE], :int
|
||||
attach_function :FPDFPage_GetObject, %i[FPDF_PAGE int], :FPDF_PAGEOBJECT
|
||||
attach_function :FPDFPageObj_GetType, [:FPDF_PAGEOBJECT], :int
|
||||
attach_function :FPDFPageObj_GetBounds, %i[FPDF_PAGEOBJECT pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFPath_CountSegments, [:FPDF_PAGEOBJECT], :int
|
||||
attach_function :FPDFPath_GetPathSegment, %i[FPDF_PAGEOBJECT int], :FPDF_PATHSEGMENT
|
||||
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
|
||||
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
|
||||
|
||||
# Page object types
|
||||
FPDF_PAGEOBJ_UNKNOWN = 0
|
||||
FPDF_PAGEOBJ_TEXT = 1
|
||||
FPDF_PAGEOBJ_PATH = 2
|
||||
FPDF_PAGEOBJ_IMAGE = 3
|
||||
FPDF_PAGEOBJ_SHADING = 4
|
||||
FPDF_PAGEOBJ_FORM = 5
|
||||
|
||||
# Path segment types
|
||||
FPDF_SEGMENT_UNKNOWN = -1
|
||||
FPDF_SEGMENT_LINETO = 0
|
||||
FPDF_SEGMENT_BEZIERTO = 1
|
||||
FPDF_SEGMENT_MOVETO = 2
|
||||
|
||||
typedef :int, :FPDF_BOOL
|
||||
typedef :pointer, :IPDF_JSPLATFORM
|
||||
@@ -157,6 +210,7 @@ class Pdfium
|
||||
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics
|
||||
class Document
|
||||
attr_reader :document_ptr, :form_handle
|
||||
|
||||
@@ -386,6 +440,150 @@ class Pdfium
|
||||
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
|
||||
end
|
||||
|
||||
def text_nodes
|
||||
return @text_nodes if @text_nodes
|
||||
|
||||
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
|
||||
char_count = Pdfium.FPDFText_CountChars(text_page)
|
||||
|
||||
@text_nodes = []
|
||||
|
||||
return @text_nodes if char_count.zero?
|
||||
|
||||
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
|
||||
|
||||
loop do
|
||||
break unless i < char_count
|
||||
|
||||
box_index = i
|
||||
|
||||
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
|
||||
|
||||
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, box_index)
|
||||
font_size = 8 if font_size == 1
|
||||
|
||||
abs_x = left
|
||||
abs_y = height - origin_y - (font_size * 0.8)
|
||||
abs_width = right - left
|
||||
abs_height = font_size
|
||||
|
||||
x = origin_x / width
|
||||
y = abs_y / height
|
||||
node_width = (abs_width + ((abs_x - origin_x).abs * 2)) / width
|
||||
node_height = abs_height / height
|
||||
|
||||
@text_nodes << TextNode.new(char, x, y, node_width, node_height)
|
||||
ensure
|
||||
i += 1
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def line_nodes
|
||||
return @line_nodes if @line_nodes
|
||||
|
||||
ensure_not_closed!
|
||||
|
||||
@line_nodes = []
|
||||
|
||||
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
|
||||
|
||||
return @line_nodes if object_count.zero?
|
||||
|
||||
object_count.times do |i|
|
||||
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
|
||||
|
||||
next if page_object.null?
|
||||
|
||||
obj_type = Pdfium.FPDFPageObj_GetType(page_object)
|
||||
|
||||
next unless obj_type == Pdfium::FPDF_PAGEOBJ_PATH
|
||||
|
||||
left_ptr = FFI::MemoryPointer.new(:float)
|
||||
bottom_ptr = FFI::MemoryPointer.new(:float)
|
||||
right_ptr = FFI::MemoryPointer.new(:float)
|
||||
top_ptr = FFI::MemoryPointer.new(:float)
|
||||
|
||||
Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr)
|
||||
|
||||
obj_left = left_ptr.read_float
|
||||
obj_bottom = bottom_ptr.read_float
|
||||
obj_right = right_ptr.read_float
|
||||
obj_top = top_ptr.read_float
|
||||
|
||||
obj_width = obj_right - obj_left
|
||||
obj_height = obj_top - obj_bottom
|
||||
|
||||
next if obj_width < 1 && obj_height < 1
|
||||
|
||||
segment_count = Pdfium.FPDFPath_CountSegments(page_object)
|
||||
|
||||
next if segment_count < 2
|
||||
|
||||
next unless segment_count <= 10 && (obj_height < 10 || obj_width < 10)
|
||||
|
||||
if obj_width > obj_height && obj_height < 10
|
||||
tilt = 0
|
||||
elsif obj_height > obj_width && obj_width < 10
|
||||
tilt = 90
|
||||
else
|
||||
next
|
||||
end
|
||||
|
||||
x = obj_left
|
||||
y = obj_bottom
|
||||
w = obj_width
|
||||
h = obj_height
|
||||
|
||||
norm_x = x / width
|
||||
norm_y = (height - y - h) / height
|
||||
norm_w = w / width
|
||||
norm_h = h / height
|
||||
|
||||
@line_nodes << LineNode.new(norm_x, norm_y, norm_w, norm_h, tilt)
|
||||
end
|
||||
|
||||
@line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy }
|
||||
end
|
||||
|
||||
def close
|
||||
return if closed?
|
||||
|
||||
@@ -445,4 +643,5 @@ class Pdfium
|
||||
at_exit do
|
||||
cleanup_library
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
|
||||
@@ -39,6 +39,7 @@ Puma::Plugin.create do
|
||||
configs = Sidekiq.configure_embed do |config|
|
||||
config.logger.level = Logger::INFO
|
||||
sidekiq_config = YAML.load_file('config/sidekiq.yml')
|
||||
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
|
||||
config.queues = sidekiq_config['queues']
|
||||
config.concurrency = ENV.fetch('SIDEKIQ_THREADS', 5).to_i
|
||||
config.merge!(sidekiq_config)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,8 @@ module Submissions
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
MAX_IMAGE_HEIGHT = 100
|
||||
|
||||
CHECKSUM_LIMIT = 30
|
||||
|
||||
US_TIMEZONES = TimeUtils::US_TIMEZONES
|
||||
|
||||
module_function
|
||||
@@ -216,7 +218,7 @@ module Submissions
|
||||
composer.document.layout.formatted_text_box(
|
||||
[
|
||||
{ text: "#{I18n.t('original_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] },
|
||||
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"),
|
||||
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.first(CHECKSUM_LIMIT).join("\n"),
|
||||
"\n",
|
||||
{ text: "#{I18n.t('result_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] },
|
||||
document.metadata['sha256'] || document.checksum,
|
||||
|
||||
@@ -244,6 +244,7 @@ module Submissions
|
||||
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
|
||||
|
||||
fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
|
||||
bg_color = field.dig('preferences', 'background').to_s.delete_prefix('#').presence
|
||||
|
||||
font_name = field.dig('preferences', 'font')
|
||||
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
|
||||
@@ -289,6 +290,13 @@ module Submissions
|
||||
with_signature_id = field['preferences']['with_signature_id']
|
||||
end
|
||||
|
||||
if bg_color.present?
|
||||
canvas.fill_color(bg_color)
|
||||
.rectangle(area['x'] * width, height - (area['y'] * height) - (area['h'] * height),
|
||||
area['w'] * width, area['h'] * height)
|
||||
.fill
|
||||
end
|
||||
|
||||
case field_type
|
||||
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
@@ -321,9 +329,9 @@ module Submissions
|
||||
end
|
||||
end
|
||||
|
||||
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
base_font_size = (font_size / 1.8).to_i
|
||||
|
||||
result = nil
|
||||
|
||||
if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
|
||||
area_x = area['x'] * width
|
||||
@@ -344,12 +352,10 @@ module Submissions
|
||||
|
||||
id_string = "ID: #{attachment.uuid}".upcase
|
||||
|
||||
while true
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
|
||||
result = layouter.fit([text], half_width, base_font_size / 0.65)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
@@ -358,25 +364,39 @@ module Submissions
|
||||
break if id_string.length < 8
|
||||
end
|
||||
|
||||
string = [id_string, reason_string].join("\n")
|
||||
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], half_width, area_h)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
base_font_size *= 0.9
|
||||
|
||||
break if base_font_size < 2
|
||||
end
|
||||
|
||||
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
|
||||
|
||||
text_x = area_x + half_width
|
||||
text_y = height - area_y
|
||||
|
||||
reason_result = layouter.fit([reason_text], half_width, height)
|
||||
|
||||
layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
|
||||
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
|
||||
|
||||
layouter.fit([reason_text], half_width, reason_result.lines.sum(&:height))
|
||||
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y - TEXT_TOP_MARGIN - result.lines.sum(&:height))
|
||||
layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
|
||||
else
|
||||
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
|
||||
font:,
|
||||
font_size: base_font_size)
|
||||
|
||||
id_string = "ID: #{attachment.uuid}".upcase
|
||||
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
|
||||
result = layouter.fit([text], area['w'] * width, base_font_size / 0.65)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
@@ -395,7 +415,7 @@ module Submissions
|
||||
|
||||
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
|
||||
|
||||
layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
|
||||
layouter.fit([text], area['w'] * width, base_font_size / 0.65)
|
||||
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
|
||||
height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
|
||||
|
||||
@@ -699,20 +719,30 @@ module Submissions
|
||||
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false }, **sign_params)
|
||||
rescue HexaPDF::MalformedPDFError, NoMethodError => e
|
||||
rescue HexaPDF::Error, NoMethodError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
rescue HexaPDF::Error
|
||||
pdf.validate(auto_correct: true)
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
end
|
||||
end
|
||||
|
||||
maybe_enable_ltv(io, sign_params)
|
||||
else
|
||||
begin
|
||||
pdf.write(io, incremental: true, validate: false)
|
||||
rescue HexaPDF::MalformedPDFError, NoMethodError => e
|
||||
rescue HexaPDF::Error, NoMethodError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
begin
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
rescue HexaPDF::Error
|
||||
pdf.validate(auto_correct: true)
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ module Submitters
|
||||
|
||||
UnableToSendCode = Class.new(StandardError)
|
||||
InvalidOtp = Class.new(StandardError)
|
||||
MaliciousFileExtension = Class.new(StandardError)
|
||||
|
||||
DANGEROUS_EXTENSIONS = Set.new(%w[
|
||||
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
|
||||
hta cpl jar app deb rpm dmg pkg mpkg dll so dylib sys
|
||||
inf reg ps1 psm1 psd1 ps1xml psc1 pssc bat cmd vb vba
|
||||
sh bash zsh fish run out bin elf gadget workflow lnk scf
|
||||
url desktop application action workflow apk ipa xap appx
|
||||
appxbundle msix msixbundle diagcab diagpkg cpl msc ocx
|
||||
drv scr ins isp mst paf prf shb shs slk ws wsc inf1 inf2
|
||||
].freeze)
|
||||
|
||||
module_function
|
||||
|
||||
@@ -111,6 +122,12 @@ module Submitters
|
||||
def create_attachment!(submitter, params)
|
||||
blob =
|
||||
if (file = params[:file])
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
|
||||
if DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
|
||||
ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
@@ -234,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
|
||||
|
||||
@@ -20,6 +20,8 @@ module Submitters
|
||||
def call(text, font: nil)
|
||||
font = FONT_ALIASES[font] || font
|
||||
|
||||
text = ERB::Util.html_escape(text)
|
||||
|
||||
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
|
||||
width: WIDTH, height: HEIGHT, wrap: :none)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ module Submitters
|
||||
end
|
||||
|
||||
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
|
||||
if submitter_name
|
||||
if submitter_name && !for_submitter
|
||||
submitter =
|
||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
||||
raise(UnknownSubmitterName,
|
||||
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module DetectFields
|
||||
module_function
|
||||
|
||||
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 = /
|
||||
(?:
|
||||
date
|
||||
| signed\sat
|
||||
| datum
|
||||
)[:_\s-]*\z
|
||||
/ix
|
||||
|
||||
NUMBER_REGEXP = /
|
||||
(?:
|
||||
price
|
||||
| \$
|
||||
| €
|
||||
| total
|
||||
| quantity
|
||||
| prix
|
||||
| quantité
|
||||
| preis
|
||||
| summe
|
||||
| gesamt(?:betrag)?
|
||||
| menge
|
||||
| anzahl
|
||||
| stückzahl
|
||||
)[:_\s-]*\z
|
||||
/ix
|
||||
|
||||
SIGNATURE_REGEXP = /
|
||||
(?:
|
||||
signature
|
||||
| sign\shere
|
||||
| sign
|
||||
| signez\sici
|
||||
| signer\sici
|
||||
| unterschrift
|
||||
| unterschreiben
|
||||
| unterzeichnen
|
||||
)[:_\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, &)
|
||||
fields, head_node =
|
||||
if attachment&.image?
|
||||
process_image_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
|
||||
temperature:, aspect_ratio:, padding:, &)
|
||||
else
|
||||
process_pdf_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
|
||||
temperature:, aspect_ratio:, regexp_type:, padding:, &)
|
||||
end
|
||||
|
||||
[fields, head_node]
|
||||
end
|
||||
|
||||
def process_image_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
|
||||
split_page: false, aspect_ratio: false, padding: nil)
|
||||
image = Vips::Image.new_from_buffer(io.read, '')
|
||||
|
||||
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 == 'signature',
|
||||
preferences: {},
|
||||
areas: [{
|
||||
x: f.x,
|
||||
y: f.y,
|
||||
w: f.w,
|
||||
h: f.h,
|
||||
page: 0,
|
||||
attachment_uuid: attachment&.uuid
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
yield [attachment&.uuid, 0, fields] if block_given?
|
||||
|
||||
[fields, nil]
|
||||
end
|
||||
|
||||
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
|
||||
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false)
|
||||
doc = Pdfium::Document.open_bytes(io.read)
|
||||
|
||||
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
|
||||
tail_node = head_node
|
||||
|
||||
fields = doc.page_count.times.flat_map do |page_number|
|
||||
page = doc.get_page(page_number)
|
||||
|
||||
data, width, height = page.render_to_bitmap(width: inference::RESOLUTION * 1.5)
|
||||
|
||||
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
|
||||
|
||||
fields = inference.call(image, confidence: confidence / 4.0, nms:, split_page:,
|
||||
temperature:, aspect_ratio:, padding:)
|
||||
|
||||
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)
|
||||
|
||||
fields = fields.reject { |f| f.confidence < confidence }
|
||||
|
||||
field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid)
|
||||
|
||||
fields = field_nodes.map do |node|
|
||||
field = node.elem
|
||||
|
||||
type = regexp_type ? type_from_page_node(node) : field.type
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
type:,
|
||||
required: type == 'signature',
|
||||
preferences: {},
|
||||
areas: [{
|
||||
x: field.x, y: field.y,
|
||||
w: field.w, h: field.h,
|
||||
page: page_number,
|
||||
attachment_uuid: attachment&.uuid
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
yield [attachment&.uuid, page_number, fields] if block_given?
|
||||
|
||||
fields
|
||||
ensure
|
||||
page.close
|
||||
end
|
||||
|
||||
print_debug(head_node) if Rails.env.development?
|
||||
|
||||
[fields, head_node]
|
||||
ensure
|
||||
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
|
||||
string = ''.b
|
||||
|
||||
loop do
|
||||
string <<
|
||||
if current_node.elem.is_a?(String)
|
||||
current_node.elem
|
||||
else
|
||||
"[#{current_node.elem.type == 'checkbox' ? 'Checkbox' : 'Field'}_#{index += 1}]"
|
||||
end
|
||||
|
||||
current_node = current_node.next
|
||||
|
||||
break unless current_node
|
||||
end
|
||||
|
||||
Rails.logger.info(string)
|
||||
end
|
||||
|
||||
def type_from_page_node(node)
|
||||
return node.elem.type unless node.prev.elem.is_a?(String)
|
||||
return node.elem.type unless node.elem.type == 'text'
|
||||
|
||||
string = node.prev.elem
|
||||
|
||||
return 'date' if string.match?(DATE_REGEXP)
|
||||
return 'signature' if string.match?(SIGNATURE_REGEXP)
|
||||
return 'number' if string.match?(NUMBER_REGEXP)
|
||||
|
||||
return 'text'
|
||||
end
|
||||
|
||||
def build_page_nodes(page, fields, tail_node, attachment_uuid: nil)
|
||||
field_nodes = []
|
||||
|
||||
y_threshold = 4.0 / page.height
|
||||
x_threshold = 30.0 / page.width
|
||||
|
||||
text_nodes = page.text_nodes
|
||||
|
||||
current_field = fields.shift
|
||||
|
||||
index = 0
|
||||
|
||||
prev_node = nil
|
||||
|
||||
loop do
|
||||
node = text_nodes[index]
|
||||
|
||||
break unless node
|
||||
|
||||
if node.content.in?(LINEBREAK)
|
||||
next_node = text_nodes[index]
|
||||
|
||||
if next_node && (next_node.endy - node.endy) < y_threshold
|
||||
index += 1
|
||||
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if tail_node.elem.is_a?(Templates::ImageToFields::Field)
|
||||
prev_field = tail_node.elem
|
||||
|
||||
text_node = PageNode.new(prev: tail_node, elem: ''.b, page: page.page_index, attachment_uuid:)
|
||||
tail_node.next = text_node
|
||||
|
||||
tail_node = text_node
|
||||
|
||||
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
|
||||
|
||||
if node.content != '_' || !tail_node.elem.ends_with?('___')
|
||||
tail_node.elem << node.content unless CHECKBOXES.include?(node.content)
|
||||
end
|
||||
|
||||
prev_node = node
|
||||
|
||||
index += 1
|
||||
end
|
||||
|
||||
loop do
|
||||
break unless current_field
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
end
|
||||
|
||||
def extract_line_fields_from_page(page)
|
||||
line_thickness = 5.0 / page.height
|
||||
|
||||
vertical_lines, all_horizontal_lines = page.line_nodes.partition { |line| line.tilt == 90 }
|
||||
|
||||
horizontal_lines = all_horizontal_lines.reject do |h_line|
|
||||
next true if h_line.w > 0.7 && (h_line.h < 0.1 || h_line.h < 0.9)
|
||||
|
||||
next false if vertical_lines.blank?
|
||||
|
||||
h_x_min = h_line.x
|
||||
h_x_max = h_line.x + h_line.w
|
||||
h_y_avg = h_line.y + (h_line.h / 2)
|
||||
|
||||
vertical_lines.any? do |v_line|
|
||||
v_x_avg = v_line.x + (v_line.w / 2)
|
||||
v_y_min = v_line.y
|
||||
v_y_max = v_line.y + v_line.h
|
||||
|
||||
h_x_min_expanded = h_x_min - line_thickness
|
||||
h_x_max_expanded = h_x_max + line_thickness
|
||||
h_y_min_expanded = h_y_avg - line_thickness
|
||||
h_y_max_expanded = h_y_avg + line_thickness
|
||||
|
||||
v_x_min_expanded = v_x_avg - line_thickness
|
||||
v_x_max_expanded = v_x_avg + line_thickness
|
||||
v_y_min_expanded = v_y_min - line_thickness
|
||||
v_y_max_expanded = v_y_max + line_thickness
|
||||
|
||||
x_overlap = v_x_min_expanded <= h_x_max_expanded && v_x_max_expanded >= h_x_min_expanded
|
||||
y_overlap = h_y_min_expanded <= v_y_max_expanded && h_y_max_expanded >= v_y_min_expanded
|
||||
|
||||
x_overlap && y_overlap
|
||||
end
|
||||
end
|
||||
|
||||
node_index = 0
|
||||
|
||||
horizontal_lines = horizontal_lines.reject do |line|
|
||||
nodes = []
|
||||
|
||||
loop do
|
||||
node = page.text_nodes[node_index += 1]
|
||||
|
||||
break unless node
|
||||
|
||||
break if node.y > line.y
|
||||
|
||||
next if node.x + node.w < line.x || line.x + line.w < node.x ||
|
||||
node.y + node.h < line.y - node.h || line.y < node.y
|
||||
|
||||
nodes << node
|
||||
|
||||
next if nodes.blank?
|
||||
|
||||
next_node = page.text_nodes[node_index + 1]
|
||||
|
||||
break unless next_node
|
||||
|
||||
break if next_node.x + next_node.w < line.x || line.x + line.w < next_node.x ||
|
||||
next_node.y + next_node.h < line.y - next_node.h || line.y < next_node.y
|
||||
end
|
||||
|
||||
next if nodes.blank?
|
||||
|
||||
width = nodes.last.x + nodes.last.w - nodes.first.x
|
||||
|
||||
next true if width > line.w / 2.0
|
||||
end
|
||||
|
||||
horizontal_lines.each do |line|
|
||||
line.h += 4 * line_thickness
|
||||
line.y -= 4 * line_thickness
|
||||
end
|
||||
end
|
||||
|
||||
def extract_text_fields_from_page(page)
|
||||
text_nodes = page.text_nodes
|
||||
|
||||
field_boxes = []
|
||||
|
||||
i = 0
|
||||
|
||||
while i < text_nodes.length
|
||||
node = text_nodes[i]
|
||||
|
||||
next i += 1 if node.content != '_'
|
||||
|
||||
x1 = node.x
|
||||
y1 = node.y
|
||||
x2 = node.endx
|
||||
y2 = node.endy
|
||||
|
||||
underscore_count = 1
|
||||
|
||||
j = i + 1
|
||||
|
||||
while j < text_nodes.length
|
||||
next_node = text_nodes[j]
|
||||
|
||||
break unless next_node.content == '_'
|
||||
|
||||
distance = next_node.x - x2
|
||||
height_diff = (next_node.y - y1).abs
|
||||
|
||||
break if distance > 0.02 || height_diff > node.h * 0.5
|
||||
|
||||
underscore_count += 1
|
||||
|
||||
next_x2 = next_node.endx
|
||||
next_y2 = next_node.endy
|
||||
|
||||
x2 = next_x2
|
||||
y2 = [y2, next_y2].max
|
||||
y1 = [y1, next_node.y].min
|
||||
|
||||
j += 1
|
||||
end
|
||||
|
||||
field_boxes << TextFieldBox.new(x: x1, y: y1, w: x2 - x1, h: y2 - y1) if underscore_count >= 2
|
||||
|
||||
i = j
|
||||
end
|
||||
|
||||
field_boxes
|
||||
end
|
||||
|
||||
def calculate_iou(box1, box2)
|
||||
x1 = [box1.x, box2.x].max
|
||||
y1 = [box1.y, box2.y].max
|
||||
x2 = [box1.endx, box2.endx].min
|
||||
y2 = [box1.endy, box2.endy].min
|
||||
|
||||
intersection_width = [0, x2 - x1].max
|
||||
intersection_height = [0, y2 - y1].max
|
||||
intersection_area = intersection_width * intersection_height
|
||||
|
||||
return 0.0 if intersection_area.zero?
|
||||
|
||||
box1_area = box1.w * box1.h
|
||||
box2_area = box2.w * box2.h
|
||||
union_area = box1_area + box2_area - intersection_area
|
||||
|
||||
intersection_area / union_area
|
||||
end
|
||||
|
||||
def boxes_overlap?(box1, box2)
|
||||
!(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)
|
||||
return image_fields if text_fields.blank?
|
||||
|
||||
image_fields.map do |image_field|
|
||||
next if image_field.type != 'text'
|
||||
|
||||
text_fields.each do |text_field|
|
||||
break if text_field.y > image_field.endy
|
||||
|
||||
next if text_field.endy < image_field.y
|
||||
|
||||
next unless boxes_overlap?(image_field, text_field)
|
||||
next if calculate_iou(image_field, text_field) < 0.4
|
||||
|
||||
break image_field.confidence += by
|
||||
end
|
||||
end
|
||||
|
||||
image_fields
|
||||
end
|
||||
# rubocop:enable Metrics, Style
|
||||
end
|
||||
end
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module ImageToFields
|
||||
module_function
|
||||
|
||||
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')
|
||||
|
||||
RESOLUTION = 704
|
||||
|
||||
ID_TO_CLASS = %w[text checkbox].freeze
|
||||
|
||||
MEAN = [0.485, 0.456, 0.406].freeze
|
||||
STD = [0.229, 0.224, 0.225].freeze
|
||||
|
||||
CPU_THREADS = Etc.nprocessors
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(image, confidence: 0.3, nms: 0.1, temperature: 1,
|
||||
split_page: false, aspect_ratio: true, padding: nil, resolution: RESOLUTION)
|
||||
base_image = image.extract_band(0, n: 3)
|
||||
|
||||
trimmed_base, base_offset_x, base_offset_y = trim_image_with_padding(base_image, padding)
|
||||
|
||||
if split_page && image.height > image.width
|
||||
regions = build_split_image_regions(trimmed_base)
|
||||
|
||||
detections = { xyxy: Numo::SFloat[], confidence: Numo::SFloat[], class_id: Numo::Int32[] }
|
||||
|
||||
detections = regions.reduce(detections) do |acc, r|
|
||||
next detections if r[:img].height <= 0 || r[:img].width <= 0
|
||||
|
||||
input_tensor, transform_info = preprocess_image(r[:img], resolution, aspect_ratio:)
|
||||
|
||||
transform_info[:trim_offset_x] = base_offset_x
|
||||
transform_info[:trim_offset_y] = base_offset_y + r[:offset_y]
|
||||
|
||||
outputs = model.predict({ 'input' => input_tensor }, output_type: :numo)
|
||||
|
||||
boxes = outputs['dets'][0, true, true]
|
||||
logits = outputs['labels'][0, true, true]
|
||||
|
||||
postprocess_outputs(boxes, logits, transform_info, acc, confidence:, temperature:, resolution:)
|
||||
end
|
||||
else
|
||||
input_tensor, transform_info = preprocess_image(trimmed_base, resolution, aspect_ratio:)
|
||||
|
||||
transform_info[:trim_offset_x] = base_offset_x
|
||||
transform_info[:trim_offset_y] = base_offset_y
|
||||
|
||||
outputs = model.predict({ 'input' => input_tensor }, output_type: :numo)
|
||||
|
||||
boxes = outputs['dets'][0, true, true]
|
||||
logits = outputs['labels'][0, true, true]
|
||||
|
||||
detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:)
|
||||
end
|
||||
|
||||
detections = apply_nms(detections, nms)
|
||||
|
||||
build_fields_from_detections(detections, image)
|
||||
end
|
||||
|
||||
def build_split_image_regions(image)
|
||||
half_h = image.height / 2
|
||||
top_h = half_h
|
||||
bottom_h = image.height - half_h
|
||||
|
||||
[
|
||||
{ img: image.crop(0, 0, image.width, top_h), offset_y: 0 },
|
||||
{ img: image.crop(0, top_h, image.width, bottom_h), offset_y: top_h }
|
||||
]
|
||||
end
|
||||
|
||||
def build_fields_from_detections(detections, image)
|
||||
detections[:xyxy].shape[0].times.filter_map do |i|
|
||||
x1 = detections[:xyxy][i, 0]
|
||||
y1 = detections[:xyxy][i, 1]
|
||||
x2 = detections[:xyxy][i, 2]
|
||||
y2 = detections[:xyxy][i, 3]
|
||||
|
||||
class_id = detections[:class_id][i].to_i
|
||||
|
||||
confidence = detections[:confidence][i]
|
||||
|
||||
x0_norm = x1 / image.width.to_f
|
||||
y0_norm = y1 / image.height.to_f
|
||||
x1_norm = x2 / image.width.to_f
|
||||
y1_norm = y2 / image.height.to_f
|
||||
|
||||
x1_norm = 1 if x1_norm > 1
|
||||
y1_norm = 1 if y1_norm > 1
|
||||
|
||||
next if x0_norm < 0 || x0_norm > 1
|
||||
next if y0_norm < 0 || y0_norm > 1
|
||||
|
||||
type_name = ID_TO_CLASS[class_id]
|
||||
|
||||
Field.new(
|
||||
type: type_name,
|
||||
x: x0_norm,
|
||||
y: y0_norm,
|
||||
w: (x1_norm - x0_norm),
|
||||
h: (y1_norm - y0_norm),
|
||||
confidence:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def trim_image_with_padding(image, padding = 0)
|
||||
return [image, 0, 0] if padding.nil?
|
||||
|
||||
left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255])
|
||||
|
||||
padded_left = [left - padding, 0].max
|
||||
padded_top = [top - padding, 0].max
|
||||
padded_right = [left + trim_width + padding, image.width].min
|
||||
padded_bottom = [top + trim_height + padding, image.height].min
|
||||
|
||||
width = padded_right - padded_left
|
||||
height = padded_bottom - padded_top
|
||||
|
||||
trimmed_image = image.crop(padded_left, padded_top, width, height)
|
||||
|
||||
[trimmed_image, padded_left, padded_top]
|
||||
end
|
||||
|
||||
def preprocess_image(image, resolution, aspect_ratio: false)
|
||||
scale_x = resolution.to_f / image.width
|
||||
scale_y = resolution.to_f / image.height
|
||||
|
||||
if aspect_ratio
|
||||
scale = [scale_x, scale_y].min
|
||||
|
||||
new_width = (image.width * scale).round
|
||||
new_height = (image.height * scale).round
|
||||
|
||||
resized = image.resize(scale, vscale: scale, kernel: :lanczos3)
|
||||
|
||||
pad_x = ((resolution - new_width) / 2.0).round
|
||||
pad_y = ((resolution - new_height) / 2.0).round
|
||||
|
||||
image = resized.embed(pad_x, pad_y, resolution, resolution, background: [255, 255, 255])
|
||||
|
||||
transform_info = { scale_x: scale, scale_y: scale, pad_x: pad_x, pad_y: pad_y }
|
||||
else
|
||||
image = image.resize(scale_x, vscale: scale_y, kernel: :lanczos3)
|
||||
|
||||
transform_info = { scale_x: scale_x, scale_y: scale_y, pad_x: 0, pad_y: 0 }
|
||||
end
|
||||
|
||||
image /= 255.0
|
||||
|
||||
image = (image - MEAN) / STD
|
||||
|
||||
pixel_data = image.write_to_memory
|
||||
|
||||
img_array = Numo::SFloat.from_binary(pixel_data, [resolution, resolution, 3])
|
||||
|
||||
img_array = img_array.transpose(2, 0, 1)
|
||||
|
||||
[img_array.reshape(1, 3, resolution, resolution), transform_info]
|
||||
end
|
||||
|
||||
def nms(boxes, scores, iou_threshold = 0.5)
|
||||
return Numo::Int32[] if boxes.shape[0].zero?
|
||||
|
||||
x1 = boxes[true, 0]
|
||||
y1 = boxes[true, 1]
|
||||
x2 = boxes[true, 2]
|
||||
y2 = boxes[true, 3]
|
||||
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
order = scores.sort_index.reverse
|
||||
|
||||
keep = []
|
||||
|
||||
while order.size.positive?
|
||||
i = order[0]
|
||||
keep << i
|
||||
|
||||
break if order.size == 1
|
||||
|
||||
xx1 = Numo::SFloat.maximum(x1[i], x1[order[1..]])
|
||||
yy1 = Numo::SFloat.maximum(y1[i], y1[order[1..]])
|
||||
xx2 = Numo::SFloat.minimum(x2[i], x2[order[1..]])
|
||||
yy2 = Numo::SFloat.minimum(y2[i], y2[order[1..]])
|
||||
|
||||
w = Numo::SFloat.maximum(0.0, xx2 - xx1)
|
||||
h = Numo::SFloat.maximum(0.0, yy2 - yy1)
|
||||
|
||||
intersection = w * h
|
||||
|
||||
iou = intersection / (areas[i] + areas[order[1..]] - intersection)
|
||||
|
||||
inds = iou.le(iou_threshold).where
|
||||
|
||||
order = order[inds + 1]
|
||||
end
|
||||
|
||||
Numo::Int32.cast(keep)
|
||||
end
|
||||
|
||||
def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1,
|
||||
resolution: RESOLUTION)
|
||||
scaled_logits = logits / temperature
|
||||
|
||||
probs = 1.0 / (1.0 + Numo::NMath.exp(-scaled_logits))
|
||||
|
||||
scores = probs.max(axis: 1)
|
||||
labels = probs.argmax(axis: 1)
|
||||
|
||||
cx = boxes[true, 0]
|
||||
cy = boxes[true, 1]
|
||||
w = boxes[true, 2]
|
||||
h = boxes[true, 3]
|
||||
|
||||
x1 = cx - (w / 2.0)
|
||||
y1 = cy - (h / 2.0)
|
||||
x2 = cx + (w / 2.0)
|
||||
y2 = cy + (h / 2.0)
|
||||
|
||||
boxes_xyxy = Numo::SFloat.zeros(boxes.shape[0], 4)
|
||||
boxes_xyxy[true, 0] = x1
|
||||
boxes_xyxy[true, 1] = y1
|
||||
boxes_xyxy[true, 2] = x2
|
||||
boxes_xyxy[true, 3] = y2
|
||||
|
||||
boxes_xyxy *= resolution
|
||||
|
||||
pad_x = transform_info[:pad_x]
|
||||
pad_y = transform_info[:pad_y]
|
||||
boxes_xyxy[true, 0] -= pad_x
|
||||
boxes_xyxy[true, 1] -= pad_y
|
||||
boxes_xyxy[true, 2] -= pad_x
|
||||
boxes_xyxy[true, 3] -= pad_y
|
||||
|
||||
scale_x = transform_info[:scale_x]
|
||||
scale_y = transform_info[:scale_y]
|
||||
boxes_xyxy[true, 0] /= scale_x
|
||||
boxes_xyxy[true, 1] /= scale_y
|
||||
boxes_xyxy[true, 2] /= scale_x
|
||||
boxes_xyxy[true, 3] /= scale_y
|
||||
|
||||
trim_offset_x = transform_info[:trim_offset_x]
|
||||
trim_offset_y = transform_info[:trim_offset_y]
|
||||
boxes_xyxy[true, 0] += trim_offset_x
|
||||
boxes_xyxy[true, 1] += trim_offset_y
|
||||
boxes_xyxy[true, 2] += trim_offset_x
|
||||
boxes_xyxy[true, 3] += trim_offset_y
|
||||
|
||||
keep_mask = scores.gt(confidence)
|
||||
|
||||
keep_indices = keep_mask.where
|
||||
|
||||
if keep_indices.empty?
|
||||
detections || {
|
||||
xyxy: Numo::SFloat[],
|
||||
confidence: Numo::SFloat[],
|
||||
class_id: Numo::Int32[]
|
||||
}
|
||||
else
|
||||
scores = scores[keep_indices]
|
||||
labels = labels[keep_indices]
|
||||
boxes_xyxy = boxes_xyxy[keep_indices, true]
|
||||
|
||||
if detections
|
||||
existing_n = detections[:xyxy].shape[0]
|
||||
new_n = boxes_xyxy.shape[0]
|
||||
total = existing_n + new_n
|
||||
|
||||
xyxy = Numo::SFloat.zeros(total, 4)
|
||||
conf = Numo::SFloat.zeros(total)
|
||||
cls = Numo::Int32.zeros(total)
|
||||
|
||||
if existing_n.positive?
|
||||
xyxy[0...existing_n, true] = detections[:xyxy]
|
||||
conf[0...existing_n] = detections[:confidence]
|
||||
cls[0...existing_n] = detections[:class_id]
|
||||
end
|
||||
|
||||
xyxy[existing_n...(existing_n + new_n), true] = boxes_xyxy
|
||||
conf[existing_n...(existing_n + new_n)] = scores
|
||||
cls[existing_n...(existing_n + new_n)] = Numo::Int32.cast(labels)
|
||||
|
||||
{ xyxy: xyxy, confidence: conf, class_id: cls }
|
||||
else
|
||||
{
|
||||
xyxy: boxes_xyxy,
|
||||
confidence: scores,
|
||||
class_id: Numo::Int32.cast(labels)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def apply_nms(detections, threshold = 0.5)
|
||||
return detections if detections[:xyxy].shape[0].zero?
|
||||
|
||||
keep_indices = nms(detections[:xyxy], detections[:confidence], threshold)
|
||||
|
||||
{
|
||||
xyxy: detections[:xyxy][keep_indices, true],
|
||||
confidence: detections[:confidence][keep_indices],
|
||||
class_id: detections[:class_id][keep_indices]
|
||||
}
|
||||
end
|
||||
|
||||
def model
|
||||
@model ||= OnnxRuntime::Model.new(
|
||||
MODEL_PATH.to_s,
|
||||
inter_op_num_threads: CPU_THREADS,
|
||||
intra_op_num_threads: CPU_THREADS,
|
||||
enable_mem_pattern: false,
|
||||
enable_cpu_mem_arena: false,
|
||||
providers: ['CPUExecutionProvider']
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user