Compare commits

...
65 Commits
Author SHA1 Message Date
Alex Turchyn 22afd01fd2 Merge from docusealco/wip 2025-11-17 13:20:44 +02:00
Pete Matsyburka fd9eb09dd6 use can? 2025-11-17 10:21:38 +02:00
Alex Turchyn c53ed7f984 add custom invitation reminder emails 2025-11-17 10:05:52 +02:00
Pete Matsyburka 1d87e210be optimize detect fields 2025-11-16 19:05:54 +02:00
Pete Matsyburka 5fec952b8c adjust detections 2025-11-15 19:48:42 +02:00
Pete Matsyburka c4fbcc103a autocorrect pdf 2025-11-15 10:46:54 +02:00
Pete Matsyburka 9333a1ba45 fix fields error alert 2025-11-14 16:35:02 +02:00
Pete Matsyburka de5cd92dba adjust send email 2025-11-13 19:36:06 +02:00
Pete Matsyburka 83e32bec9f fix preferences form 2025-11-13 10:34:52 +02:00
Pete Matsyburka 4603efc04e refactor toggles form 2025-11-13 09:50:26 +02:00
Alex Turchyn 6c73c82a27 add ability to reset custom template emails to default 2025-11-13 08:56:09 +02:00
Pete Matsyburka 8e71d9f9e9 skip email 2025-11-13 08:29:27 +02:00
Pete Matsyburka 80f60a5d88 re-invite archived user 2025-11-12 20:33:52 +02:00
Pete Matsyburka 2c33ec382d fix device icon 2025-11-12 16:48:33 +02:00
Pete Matsyburka 884865cffc adjust size 2025-11-12 16:39:29 +02:00
Alex Turchyn 9537fe8c91 update constants 2025-11-12 16:33:51 +02:00
Alex Turchyn c8fe36a2b6 add desktop/mobile/tablet badges to events log 2025-11-12 16:33:51 +02:00
Pete Matsyburka 7ec3282b56 i18n 2025-11-11 17:34:50 +02:00
Pete Matsyburka ab8c41a520 add signature reasons param 2025-11-11 13:42:01 +02:00
Pete Matsyburka 4c1c70ff04 fix audit generation 2025-11-11 12:08:16 +02:00
Pete Matsyburka d7a6e80bb1 html escape font signature 2025-11-11 11:11:07 +02:00
Alex Turchyn f87ef670d1 Merge from docusealco/wip 2025-11-10 13:08:28 +02:00
Pete Matsyburka daba2505bc fix detection 2025-11-09 21:33:47 +02:00
Pete Matsyburka 71528eda45 map page nodes 2025-11-09 08:51:00 +02:00
Pete Matsyburka 269e2beeaa fix preview 2025-11-07 10:20:41 +02:00
Pete Matsyburka 72dd2c6bc5 add template download 2025-11-05 12:07:11 +02:00
Pete Matsyburka 9758f7a15f adjust detection 2025-11-04 16:17:13 +02:00
Alex Turchyn 2521eb5a4a Merge from docusealco/wip 2025-11-03 20:16:45 +02:00
Pete Matsyburka bf6afc5e61 adjust detection 2025-11-03 19:24:09 +02:00
Pete Matsyburka 291d869843 adjust detect 2025-11-03 09:49:25 +02:00
Pete Matsyburka cdfecb2a8f fix role validation 2025-11-03 09:17:50 +02:00
Pete Matsyburka c0df8e0ec3 close doc 2025-11-02 08:57:58 +02:00
Pete Matsyburka a21f7d1653 fix padding 2025-11-02 08:13:40 +02:00
Pete Matsyburka 79e2514416 fix model name 2025-11-02 08:11:31 +02:00
Pete Matsyburka 80a7385183 fix redirect 2025-11-02 08:09:54 +02:00
Pete Matsyburka 1c8a7b6a7c detect fields 2025-11-02 08:09:54 +02:00
Pete Matsyburka b46ced2b6f add log 2025-10-31 10:26:10 +02:00
Pete Matsyburka 5ae479644e focus on canvas click 2025-10-31 10:18:39 +02:00
Pete Matsyburka cb66eb1293 fix redirect 2025-10-31 10:10:19 +02:00
Pete Matsyburka b4d5a2cad4 dangerous extension error 2025-10-30 08:47:01 +02:00
Pete Matsyburka 32691b0c0d add background 2025-10-29 10:10:04 +02:00
Pete Matsyburka 9163f744a3 remove sign up button 2025-10-27 20:03:33 +02:00
Pete Matsyburka 23ecce51a3 hide archived completed page 2025-10-22 22:03:39 +03:00
Pete Matsyburka 747ffa8cf1 adjust signature ID size 2025-10-15 13:38:53 +03:00
Alex Turchyn 4e230899ed Merge from docusealco/wip 2025-10-13 13:50:54 +03:00
Pete Matsyburka aff9f753c7 adjust style 2025-10-11 18:20:59 +03:00
Pete Matsyburka 1127fac500 update gem 2025-10-11 11:09:24 +03:00
Pete Matsyburka 7707e1cd2b adjust style 2025-10-11 10:58:09 +03:00
Pete Matsyburka 30274f7f1a fix sync button 2025-10-10 23:03:19 +03:00
Pete Matsyburka a5da6cc781 fix i18n 2025-10-10 14:04:27 +03:00
Pete Matsyburka 201e4010c5 fix german i18n 2025-10-10 13:15:41 +03:00
Pete Matsyburka b9bbdbffae fix french i18n 2025-10-10 11:01:01 +03:00
Alex Turchyn 9e8e1996e8 google drive file picker 2025-10-09 16:43:11 +03:00
Pete Matsyburka b99235397a update gem 2025-10-07 21:15:58 +03:00
Pete Matsyburka c0fd06daf3 adjust i18n 2025-10-07 13:57:36 +03:00
Pete Matsyburka 90536294df adjust i18n 2025-10-07 09:34:39 +03:00
Pete Matsyburka f8e9787675 fix edit email detailed 2025-10-07 08:59:01 +03:00
Pete Matsyburka bf9b785946 adjust i18n 2025-10-07 08:45:40 +03:00
Pete Matsyburka dab3a6f031 fix typo 2025-10-06 21:45:46 +03:00
Pete Matsyburka 88466193c5 fix i18n 2025-10-06 20:05:14 +03:00
Pete Matsyburka c2f7601dad update readme 2025-10-06 18:44:29 +03:00
Pete Matsyburka 189f7b2d7b add dutch langauge 2025-10-06 17:43:51 +03:00
Pete Matsyburka 5d87160ee9 fix rubocop 2025-10-04 18:33:37 +03:00
Pete Matsyburka 0cdcdb8898 update rubocop 2025-10-01 13:31:40 +03:00
Pete Matsyburka c20619b5c8 adjust completed email delivery 2025-09-30 13:49:45 +03:00
86 changed files with 4753 additions and 1573 deletions

No files matched your search

+1
View File
@@ -37,3 +37,4 @@ yarn-debug.log*
/docuseal /docuseal
/ee /ee
dump.rdb dump.rdb
*.onnx
+9
View File
@@ -42,6 +42,15 @@ Metrics/CyclomaticComplexity:
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 15 Max: 15
Style/MultipleComparison:
Enabled: false
Style/NumericPredicate:
Enabled: false
Naming/PredicateMethod:
Enabled: false
Layout/LineLength: Layout/LineLength:
AllowedPatterns: ['\A\s*#'] AllowedPatterns: ['\A\s*#']
+6 -2
View File
@@ -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://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/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \ 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" && \ 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 && \ mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -50,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app 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\ RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\ \n\
@@ -66,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./ 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 ./bin ./bin
COPY ./app ./app 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 /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
+2
View File
@@ -24,7 +24,9 @@ gem 'image_processing'
gem 'jwt' gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'numo-narray'
gem 'oj' gem 'oj'
gem 'onnxruntime'
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
+30 -15
View File
@@ -76,7 +76,7 @@ GEM
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0) annotaterb (4.14.0)
arabic-letter-connector (0.1.1) arabic-letter-connector (0.1.1)
ast (2.4.2) ast (2.4.3)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.1027.0) aws-partitions (1.1027.0)
aws-sdk-core (3.214.0) aws-sdk-core (3.214.0)
@@ -291,10 +291,10 @@ GEM
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.13.2) json (2.15.0)
jwt (2.9.3) jwt (2.9.3)
base64 base64
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.5)
launchy (3.0.1) launchy (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) childprocess (~> 5.0)
@@ -305,6 +305,7 @@ GEM
letter_opener (~> 1.9) letter_opener (~> 1.9)
railties (>= 6.1) railties (>= 6.1)
rexml rexml
lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
@@ -356,17 +357,26 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl) nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11) oj (3.16.11)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) 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) openssl (3.3.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.3)
package_json (0.1.0) package_json (0.1.0)
pagy (9.3.3) pagy (9.3.3)
parallel (1.26.3) parallel (1.27.0)
parser (3.3.6.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.9) pg (1.5.9)
@@ -383,6 +393,7 @@ GEM
pretender (0.5.0) pretender (0.5.0)
actionpack (>= 6.1) actionpack (>= 6.1)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0) pry (0.15.0)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
@@ -395,7 +406,7 @@ GEM
puma (6.5.0) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.0) rack (3.2.3)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.1) rack-session (2.1.1)
@@ -448,7 +459,7 @@ GEM
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.23.0) redis-client (0.23.0)
connection_pool connection_pool
regexp_parser (2.9.3) regexp_parser (2.11.3)
reline (0.6.2) reline (0.6.2)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
@@ -485,18 +496,20 @@ GEM
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.2) rspec-support (3.13.2)
rubocop (1.69.2) rubocop (1.81.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0) rubocop-ast (1.47.1)
parser (>= 3.3.1.0) parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0) rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
@@ -567,9 +580,9 @@ GEM
tzinfo-data (1.2024.2) tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
unicode-display_width (3.1.2) unicode-display_width (3.2.0)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.1)
unicode-emoji (4.0.4) unicode-emoji (4.1.0)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (1.0.3) uri (1.0.3)
useragent (0.16.11) useragent (0.16.11)
@@ -634,7 +647,9 @@ DEPENDENCIES
letter_opener_web letter_opener_web
lograge lograge
mysql2 mysql2
numo-narray
oj oj
onnxruntime
pagy pagy
pg pg
premailer-rails premailer-rails
+1 -1
View File
@@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- PDF signature verification - PDF signature verification
- Users management - Users management
- Mobile-optimized - Mobile-optimized
- 6 UI languages with signing available in 14 languages - 7 UI languages with signing available in 14 languages
- API and Webhooks for integrations - API and Webhooks for integrations
- Easy to deploy in minutes - Easy to deploy in minutes
+3 -1
View File
@@ -8,7 +8,8 @@ class AccountsController < ApplicationController
'es-ES' => 'Español', 'es-ES' => 'Español',
'pt-PT' => 'Português', 'pt-PT' => 'Português',
'de-DE' => 'Deutsch', 'de-DE' => 'Deutsch',
'it-IT' => 'Italiano' 'it-IT' => 'Italiano',
'nl-NL' => 'Nederlands'
}.freeze }.freeze
before_action :load_account before_action :load_account
@@ -46,6 +47,7 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account) authorize!(:manage, current_account)
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@')) 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 # rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
@@ -34,6 +34,10 @@ module Api
end end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) 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 end
def build_new_cookie_signatures_json(submitter, attachment) def build_new_cookie_signatures_json(submitter, attachment)
@@ -147,7 +147,7 @@ module Api
is_send_email = !params[:send_email].in?(['false', false]) is_send_email = !params[:send_email].in?(['false', false])
if (emails = (params[:emails] || params[:email]).presence) && if (emails = (params[:emails] || params[:email]).presence) &&
(params[:submission].blank? && params[:submitters].blank?) params[:submission].blank? && params[:submitters].blank?
Submissions.create_from_emails(template:, Submissions.create_from_emails(template:,
user: current_user, user: current_user,
source: :api, source: :api,
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
scope: :console, scope: :console,
exp: 1.minute.from_now.to_i) 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
end end
@@ -4,6 +4,7 @@ class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [ ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY, AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
AccountConfig::FORM_COMPLETED_MESSAGE_KEY, AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) 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| respond_to do |f|
f.html { render :success } f.html { render :success }
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
private private
def already_sent?(submitter) def can_send?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: 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) event_type: :send, created_at: SEND_DURATION.ago..Time.current)
true
end end
end end
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
def completed def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug]) @submission = Submission.find_by!(slug: params[:submissions_preview_slug])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
@template = @submission.template @template = @submission.template
render :completed, layout: 'form' render :completed, layout: 'form'
+3 -1
View File
@@ -75,7 +75,9 @@ class SubmitFormController < ApplicationController
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
def completed; end def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
end
def success; end def success; end
@@ -3,6 +3,12 @@
class TemplateDocumentsController < ApplicationController class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template 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 def create
if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
+2 -1
View File
@@ -118,7 +118,8 @@ class TemplatesController < ApplicationController
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(
:name, :name,
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]], { schema: [[:attachment_uuid, :google_drive_file_id, :name,
{ conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]], submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
DEBUG_FILE = '' DEBUG_FILE = ''
def show 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 data = attachment.download
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data)) pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, 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'] ||= {}
attachment.metadata['pdf']['fields'] = fields 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 class TemplatesPreferencesController < ApplicationController
load_and_authorize_resource :template 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 show; end
def create def create
@@ -15,19 +24,38 @@ class TemplatesPreferencesController < ApplicationController
head :ok head :ok
end 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 private
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body 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_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration shared_link_2fa default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] + completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body], [completed_message: %i[title body],
+10 -1
View File
@@ -24,11 +24,20 @@ class UsersController < ApplicationController
def edit; end def edit; end
def create def create
if User.accessible_by(current_ability).exists?(email: @user.email) existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
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')) @user.errors.add(:email, I18n.t('already_exists'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
end end
end
@user.password = SecureRandom.hex if @user.password.blank? @user.password = SecureRandom.hex if @user.password.blank?
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role) @user.role = User::ADMIN_ROLE unless role_valid?(@user.role)
+7
View File
@@ -49,6 +49,8 @@ import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation' import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes' import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field' import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -136,6 +138,8 @@ safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation) safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -152,6 +156,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null, withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
@@ -160,7 +165,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true, withReplaceAndCloneUpload: true,
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes, acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true' showTourStartForm: this.dataset.showTourStartForm === 'true'
@@ -0,0 +1,55 @@
export default class extends HTMLElement {
connectedCallback () {
const iframeTemplate = this.querySelector('template')
this.observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
iframeTemplate.parentElement.prepend(iframeTemplate.content)
this.observer.disconnect()
}
})
this.observer.observe(this)
window.addEventListener('message', this.messageHandler)
}
messageHandler = (event) => {
if (event.data.type === 'google-drive-files-picked') {
this.form.querySelectorAll('input[name="google_drive_file_ids[]"]').forEach(el => el.remove())
const files = event.data.files || []
files.forEach((file) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'google_drive_file_ids[]'
input.value = file.id
this.form.appendChild(input)
})
this.form.querySelector('button[type="submit"]').click()
this.loader.classList.remove('hidden')
} else if (event.data.type === 'google-drive-picker-loaded') {
this.loader.classList.add('hidden')
this.form.classList.remove('hidden')
} else if (event.data.type === 'google-drive-picker-request-oauth') {
document.getElementById(this.dataset.oauthButtonId).classList.remove('hidden')
this.classList.add('hidden')
}
}
disconnectedCallback () {
this.observer?.unobserve(this)
window.removeEventListener('message', this.messageHandler)
}
get form () {
return this.querySelector('form')
}
get loader () {
return document.getElementById('google_drive_loader')
}
}
+16
View File
@@ -0,0 +1,16 @@
export default class extends HTMLElement {
connectedCallback () {
const src = this.getAttribute('src')
const link = document.createElement('a')
link.href = src
link.setAttribute('data-turbo-frame', 'modal')
link.style.display = 'none'
this.appendChild(link)
link.click()
window.history.replaceState({}, document.title, window.location.pathname)
}
}
+4
View File
@@ -553,6 +553,10 @@ export default {
style.color = this.field.preferences.color style.color = this.field.preferences.color
} }
if (this.field.preferences?.background) {
style.background = this.field.preferences.background
}
return style return style
}, },
isNarrow () { isNarrow () {
+9 -1
View File
@@ -163,12 +163,20 @@ export default {
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + '/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(resp => resp.json()).then((data) => { }).then(async (resp) => {
const data = await resp.json()
if (resp.status === 422) {
alert(data.error)
} else {
return data return data
}
}) })
} }
})).then((result) => { })).then((result) => {
if (result && result[0]) {
this.$emit('upload', result) this.$emit('upload', result)
}
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
+109 -109
View File
@@ -91,7 +91,7 @@ const en = {
type_text: 'Type text', type_text: 'Type text',
email_has_been_sent: 'Email has been sent', email_has_been_sent: 'Email has been sent',
processing: 'Processing', processing: 'Processing',
pay_with_strip: 'Pay with Stripe', pay_with_stripe: 'Pay with Stripe',
reupload: 'Reupload', reupload: 'Reupload',
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
@@ -192,7 +192,7 @@ const es = {
type_text: 'Escribir texto', type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado', email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando', processing: 'Procesando',
pay_with_strip: 'Pagar con Stripe', pay_with_stripe: 'Pagar con Stripe',
reupload: 'Volver a subir', reupload: 'Volver a subir',
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
@@ -222,7 +222,7 @@ const it = {
value_is_invalid: 'Il valore non è valido', value_is_invalid: 'Il valore non è valido',
verification_code_is_invalid: 'Il codice di verifica non è valido', verification_code_is_invalid: 'Il codice di verifica non è valido',
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile', drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scansiona il codice QR con l\'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il', by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica', electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma', esignature_disclosure: 'Divulgazione della eFirma',
@@ -293,7 +293,7 @@ const it = {
toggle_multiline_text: 'Attiva Testo Multilinea', toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata", email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione', processing: 'Elaborazione',
pay_with_strip: 'Paga con Stripe', pay_with_stripe: 'Paga con Stripe',
reupload: 'Ricarica', reupload: 'Ricarica',
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
@@ -304,64 +304,64 @@ const it = {
const de = { const de = {
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein', must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung', identity_verification: 'Identitätsprüfung',
complete: 'Abschließen', complete: 'Abschließen',
fill_all_required_fields_to_complete: 'Alle erforderlichen Felder ausfüllen, um abzuschließen', fill_all_required_fields_to_complete: 'Füllen Sie alle Pflichtfelder aus, um abzuschließen',
sign_and_complete: 'Signieren und Abschließen', sign_and_complete: 'Unterzeichnen und abschließen',
text: 'Text',
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie dem',
electronic_signature_disclosure: 'Hinweis zur E-Signatur',
esignature_disclosure: 'Hinweis zur eSignatur',
signature: 'Unterschrift',
initials: 'Initialen',
drawn_signature_on_a_touchscreen_device: 'Auf einem Touchscreen-Gerät gezeichnete Unterschrift',
approved: 'Genehmigt',
reviewed: 'Geprüft',
other: 'Sonstiges',
authored_by_me: 'Von mir erstellt',
invite: 'Einladen', invite: 'Einladen',
email: 'E-Mail', email: 'E-Mail',
approved: 'Genehmigt',
reviewed: 'Überprüft',
other: 'Andere',
authored_by_me: 'Von mir verfasst',
approved_by: 'Genehmigt von', approved_by: 'Genehmigt von',
reviewed_by: 'Überprüft von', reviewed_by: 'Geprüft von',
authored_by: 'Verfasst von', authored_by: 'Erstellt von',
select_a_reason: 'Grund auswählen', select_a_reason: 'Grund auswählen',
value_is_invalid: 'Wert ist ungültig', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Mobilgerät zu öffnen und Ihre Unterschrift zu zeichnen',
verification_code_is_invalid: 'Bestätigungscode ist ungültig',
drawn_signature_on_a_touchscreen_device: 'Gezeichnete Unterschrift auf einem Touchscreen-Gerät',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Handy zu öffnen und Ihre Unterschrift zu zeichnen',
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie zu, dass Sie die',
electronic_signature_disclosure: 'Elektronische Unterschriftenoffenlegung',
esignature_disclosure: 'eSignatur Offenlegung',
already_paid: 'Bereits bezahlt',
minimize: 'Minimieren',
text: 'Text',
signature: 'Unterschrift',
digitally_signed_by: 'Digital unterschrieben von',
reason: 'Grund',
initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer', number: 'Zahl',
value_is_invalid: 'Wert ist ungültig',
verification_code_is_invalid: 'Verifizierungscode ist ungültig',
already_paid: 'Bereits bezahlt',
image: 'Bild', image: 'Bild',
pay: 'Bezahlen', pay: 'Bezahlen',
take_photo: 'Foto aufnehmen',
number_phone_is_invalid: 'Telefonnummer ({number}) ist ungültig',
file: 'Datei', file: 'Datei',
select: 'Auswählen', digitally_signed_by: 'Elektronisch signiert von',
reason: 'Grund',
select: 'Auswahl',
checkbox: 'Checkbox', checkbox: 'Checkbox',
multiple: 'Mehrere', multiple: 'Mehrfach',
radio: 'Radio', radio: 'Radio',
cells: 'Zellen', cells: 'Zellen',
stamp: 'Stempel', stamp: 'Stempel',
minimize: 'Minimieren',
payment: 'Zahlung', payment: 'Zahlung',
number_phone_is_invalid: '{number} Telefonnummer ist ungültig',
phone: 'Telefon', phone: 'Telefon',
start_now: 'Jetzt starten', start_now: 'Jetzt starten',
continue: 'Fortsetzen', continue: 'Weiter',
sign_now: 'Jetzt unterschreiben', sign_now: 'Jetzt unterzeichnen',
type_here_: 'Hier eingeben...', type_here_: 'Hier eingeben...',
optional: 'optional', optional: 'optional',
option: 'Option', option: 'Option',
appears_on: 'Erscheint auf', appears_on: 'Erscheint auf',
page: 'Seite', page: 'Seite',
take_photo: 'Foto aufnehmen', select_your_option: 'Option auswählen',
select_your_option: 'Wähle deine Option', complete_hightlighted_checkboxes_and_click: 'Füllen Sie die markierten Kontrollkästchen aus und klicken Sie auf',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken', submit: 'Einreichen',
submit: 'absenden', next: 'Weiter',
next: 'weiter', click_to_upload: 'Zum Hochladen klicken',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen', or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden', send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen', download: 'Herunterladen',
@@ -370,137 +370,137 @@ const de = {
draw_initials: 'Initialen zeichnen', draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben', type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben', type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!', form_has_been_completed: 'Formular wurde abgeschlossen!',
document_has_been_signed: 'Dokument wurde unterschrieben!', document_has_been_signed: 'Dokument wurde unterzeichnet!',
documents_have_been_signed: 'Dokumente wurden unterschrieben!', documents_have_been_signed: 'Dokumente wurden unterzeichnet!',
create_a_free_account: 'Kostenloses Konto erstellen', create_a_free_account: 'Kostenloses Konto erstellen',
powered_by: 'Bereitgestellt von', powered_by: 'Bereitgestellt von',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren.', please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
open_source_documents_software: 'Open-Source-Dokumentensoftware', open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen', verified_phone_number: 'Telefonnummer verifizieren',
use_international_format: 'Internationales Format verwenden: +1xxx', use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code', six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern', change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...', sending: 'Senden...',
resend_code: 'Code erneut senden', resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet', verification_code_has_been_resent: 'Der Verifizierungscode wurde per SMS erneut gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus', please_fill_all_required_fields: 'Bitte füllen Sie alle Pflichtfelder aus',
set_today: 'Heute einstellen', set_today: 'Heute',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
draw_signature: 'Unterschrift zeichnen', draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben', type_initial: 'Initialen eingeben',
draw: 'Zeichnen', draw: 'Zeichnen',
type: 'Eingeben', type: 'Eingeben',
type_text: 'Text eingeben', type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten', email_has_been_sent: 'E-Mail wurde gesendet',
email_has_been_sent: 'Die E-Mail wurde gesendet',
processing: 'Verarbeitung', processing: 'Verarbeitung',
pay_with_strip: 'Mit Stripe bezahlen', pay_with_stripe: 'Mit Stripe bezahlen',
reupload: 'Erneut hochladen', reupload: 'Erneut hochladen',
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden' wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
} }
const fr = { const fr = {
please_upload_an_image_file: 'Veuillez télécharger un fichier image', please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit contenir {number} caractères', must_be_characters_length: 'Doit comporter {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
verify_id: "Vérification de l'ID", verify_id: "Vérifier l'ID",
identity_verification: "Vérification de l'identité", identity_verification: "Vérification d'identité",
complete: 'Terminer', complete: 'Terminer',
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter', fill_all_required_fields_to_complete: 'Remplissez tous les champs obligatoires pour terminer',
sign_and_complete: 'Signer et Terminer', sign_and_complete: 'Signer et terminer',
invite: 'Inviter', text: 'Texte',
email: 'Courriel', by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
electronic_signature_disclosure: 'Déclaration relative à la signature électronique',
esignature_disclosure: 'Déclaration eSignature',
signature: 'Signature',
initials: 'Initiales',
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
approved: 'Approuvé', approved: 'Approuvé',
reviewed: 'Révisé', reviewed: 'Révisé',
other: 'Autre', other: 'Autre',
authored_by_me: 'Rédigé par moi', authored_by_me: 'Rédigé par moi',
invite: 'Inviter',
email: 'E-mail',
approved_by: 'Approuvé par', approved_by: 'Approuvé par',
reviewed_by: 'Révisé par', reviewed_by: 'Révisé par',
authored_by: 'Rédigé par', authored_by: 'Rédigé par',
select_a_reason: 'Sélectionnez une raison', select_a_reason: 'Sélectionnez une raison',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le QR avec l’appareil photo pour ouvrir le formulaire et signer sur mobile',
date: 'Date',
number: 'Nombre',
value_is_invalid: 'La valeur est invalide', value_is_invalid: 'La valeur est invalide',
verification_code_is_invalid: 'Le code de vérification est invalide', verification_code_is_invalid: 'Le code de vérification est invalide',
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire sur mobile et dessiner votre signature',
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
electronic_signature_disclosure: 'Divulgation de Signature Électronique',
esignature_disclosure: 'Divulgation de la eSignature',
minimize: 'Réduire',
text: 'Texte',
already_paid: 'Déjà payé', already_paid: 'Déjà payé',
signature: 'Signature',
initials: 'Initiales',
digitally_signed_by: 'Signé numériquement par',
reason: 'Raison',
pay: 'Payer',
date: 'Date',
number: 'Numéro',
image: 'Image', image: 'Image',
pay: 'Payer',
take_photo: 'Prendre une photo',
number_phone_is_invalid: "Le numéro de téléphone {number} n'est pas valide",
file: 'Fichier', file: 'Fichier',
select: 'Choisir', digitally_signed_by: 'Signé électroniquement par',
reason: 'Motif',
select: 'Sélectionner',
checkbox: 'Coche', checkbox: 'Coche',
multiple: 'Multiple', multiple: 'Multiple',
radio: 'Radio', radio: 'Radio',
cells: 'Cellules', cells: 'Cellules',
stamp: 'Tampon', stamp: 'Tampon',
minimize: 'Réduire',
payment: 'Paiement', payment: 'Paiement',
number_phone_is_invalid: '{number} téléphone est invalide',
phone: 'Téléphone', phone: 'Téléphone',
start_now: 'Commencer maintenant', start_now: 'Commencer maintenant',
continue: 'Continuer', continue: 'Continuer',
sign_now: 'Signer maintenant', sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...', type_here_: 'Saisissez ici...',
optional: 'facultatif', optional: 'facultatif',
option: 'Option', option: 'Option',
appears_on: 'Apparaît sur', appears_on: 'Apparaît sur',
page: 'Page', page: 'Page',
take_photo: 'Prendre une photo',
select_your_option: 'Sélectionnez votre option', select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez', complete_hightlighted_checkboxes_and_click: 'Cochez les cases mises en évidence puis cliquez',
submit: 'envoyer', submit: 'soumettre',
next: 'suivant', next: 'suivant',
click_to_upload: 'Cliquez pour télécharger', click_to_upload: 'Cliquez pour téléverser',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers', or_drag_and_drop_files: 'ou faites glisser et déposez des fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail', send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger', download: 'Télécharger',
clear: 'Effacer', clear: 'Effacer',
redraw: 'Redessiner', redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales', draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici', type_signature_here: 'Saisissez la signature ici',
type_initial_here: 'Tapez les initiales ici', type_initial_here: 'Saisissez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !', form_has_been_completed: 'Le formulaire a été rempli !',
document_has_been_signed: 'Le document a été signé!', document_has_been_signed: 'Le document a été signé !',
documents_have_been_signed: 'Les documents ont été signés!', documents_have_been_signed: 'Les documents ont été signés !',
create_a_free_account: 'Créer un Compte Gratuit', create_a_free_account: 'Créer un compte gratuit',
powered_by: 'Propulsé par', powered_by: 'Propulsé par',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.', please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
open_source_documents_software: 'logiciel de documents open source', open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone', verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx', use_international_format: 'Utilisez le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres', six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone', change_phone_number: 'Changer de numéro de téléphone',
sending: 'Envoi en cours...', sending: 'Envoi...',
resend_code: 'Renvoyer le code', resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS', verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires', please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui", set_today: "Définir à aujourd'hui",
draw_signature: 'Dessiner une signature', toggle_multiline_text: 'Basculer le texte multiligne',
draw_signature: 'Dessiner la signature',
type_initial: 'Saisir les initiales', type_initial: 'Saisir les initiales',
draw: 'Dessiner', draw: 'Dessiner',
type: 'Saisir', type: 'Saisir',
type_text: 'Saisir du texte', type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne', email_has_been_sent: "L'e-mail a été envoyé",
email_has_been_sent: "L'email a été envoyé", processing: 'Traitement en cours',
processing: 'Traitement', pay_with_stripe: 'Payer avec Stripe',
pay_with_strip: 'Paiement avec Stripe', reupload: 'Retéléverser',
reupload: 'Recharger', upload: 'Téléverser',
upload: 'Télécharger',
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes' wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
} }
const pl = { const pl = {
@@ -596,7 +596,7 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy', toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany', email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie', processing: 'Przetwarzanie',
pay_with_strip: 'Płatność za pomocą Stripe', pay_with_stripe: 'Płatność za pomocą Stripe',
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
@@ -798,7 +798,7 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text', toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán', email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování', processing: 'Zpracování',
pay_with_strip: 'Zaplacení přes Stripe', pay_with_stripe: 'Zaplacení přes Stripe',
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
@@ -899,7 +899,7 @@ const pt = {
toggle_multiline_text: 'Alternar Texto Multilinha', toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado', email_has_been_sent: 'Email enviado',
processing: 'Processamento', processing: 'Processamento',
pay_with_strip: 'Pagar com Stripe', pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar', reupload: 'Reenviar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
@@ -1000,7 +1000,7 @@ const he = {
type_text: 'הקלד טקסט', type_text: 'הקלד טקסט',
email_has_been_sent: 'האימייל נשלח', email_has_been_sent: 'האימייל נשלח',
processing: 'מעבד', processing: 'מעבד',
pay_with_strip: 'שלם עם סטרייפ', pay_with_stripe: 'שלם עם סטרייפ',
reupload: 'העלה שוב', reupload: 'העלה שוב',
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
@@ -1101,7 +1101,7 @@ const nl = {
type_text: 'Typ tekst', type_text: 'Typ tekst',
email_has_been_sent: 'E-mail is verzonden', email_has_been_sent: 'E-mail is verzonden',
processing: 'Verwerken', processing: 'Verwerken',
pay_with_strip: 'Betalen met Stripe', pay_with_stripe: 'Betalen met Stripe',
reupload: 'Opnieuw uploaden', reupload: 'Opnieuw uploaden',
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
@@ -1303,7 +1303,7 @@ const ko = {
type_text: '텍스트 입력', type_text: '텍스트 입력',
email_has_been_sent: '이메일이 전송되었습니다', email_has_been_sent: '이메일이 전송되었습니다',
processing: '처리 중', processing: '처리 중',
pay_with_strip: '스트라이프로 결제', pay_with_stripe: '스트라이프로 결제',
reupload: '다시 업로드', reupload: '다시 업로드',
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
@@ -1404,7 +1404,7 @@ const ja = {
type_text: 'テキストを入力', type_text: 'テキストを入力',
email_has_been_sent: 'メールが送信されました', email_has_been_sent: 'メールが送信されました',
processing: '処理中', processing: '処理中',
pay_with_strip: 'Stripeで支払う', pay_with_stripe: 'Stripeで支払う',
reupload: '再アップロード', reupload: '再アップロード',
upload: 'アップロード', upload: 'アップロード',
files: 'ファイル', files: 'ファイル',
@@ -123,6 +123,11 @@
v-if="!isDrawInitials" v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0" 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 <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
@@ -62,7 +62,7 @@
width="22" width="22"
/> />
<span> <span>
{{ t('pay_with_strip') }} {{ t('pay_with_stripe') }}
</span> </span>
</button> </button>
</div> </div>
@@ -211,7 +211,7 @@
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
<select <select
v-if="requireSigningReason && !isOtherReason" v-if="withSigningReason && !isOtherReason"
class="select base-input !text-2xl w-full mt-6 text-center" class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }" :class="{ 'text-gray-300': !reason }"
required required
@@ -226,6 +226,18 @@
> >
{{ t('select_a_reason') }} {{ t('select_a_reason') }}
</option> </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 <option
v-for="(label, option) in defaultReasons" v-for="(label, option) in defaultReasons"
:key="option" :key="option"
@@ -241,9 +253,10 @@
> >
{{ t('other') }} {{ t('other') }}
</option> </option>
</template>
</select> </select>
<input <input
v-if="requireSigningReason && isOtherReason" v-if="withSigningReason && isOtherReason"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
required required
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
@@ -253,7 +266,7 @@
@input="$emit('update:reason', $event.target.value)" @input="$emit('update:reason', $event.target.value)"
> >
<input <input
v-if="requireSigningReason" v-if="withSigningReason"
hidden hidden
name="with_reason" name="with_reason"
:value="field.preferences.reason_field_uuid" :value="field.preferences.reason_field_uuid"
@@ -406,6 +419,9 @@ export default {
format () { format () {
return this.field.preferences?.format return this.field.preferences?.format
}, },
withSigningReason () {
return this.requireSigningReason || this.field.preferences?.reasons?.length
},
defaultReasons () { defaultReasons () {
return { return {
[this.t('approved_by')]: this.t('approved'), [this.t('approved_by')]: this.t('approved'),
@@ -424,10 +440,11 @@ export default {
created () { created () {
this.isSignatureStarted = !!this.computedPreviousValue this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) { if (this.withSigningReason) {
this.field.preferences ||= {} this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4() 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 () { async mounted () {
+128 -4
View File
@@ -175,7 +175,10 @@
{{ t('save') }} {{ t('save') }}
</span> </span>
</button> </button>
<div class="dropdown dropdown-end"> <div
class="dropdown dropdown-end"
:class="{ 'dropdown-open': isDownloading }"
>
<label <label
tabindex="0" tabindex="0"
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500" 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> <span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a> </a>
</li> </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> </ul>
</div> </div>
</span> </span>
@@ -260,8 +287,12 @@
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
<Upload <Upload
v-if="sortedDocuments.length && editable && withUploadButton" v-if="editable && withUploadButton"
v-show="sortedDocuments.length"
ref="upload"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:template-id="template.id" :template-id="template.id"
@success="updateFromUpload" @success="updateFromUpload"
/> />
@@ -297,6 +328,8 @@
v-if="withUploadButton" v-if="withUploadButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:with-google-drive="withGoogleDrive"
@click-google-drive="$refs.upload.openGoogleDriveModal()"
@success="updateFromUpload" @success="updateFromUpload"
/> />
<button <button
@@ -368,6 +401,8 @@
v-if="withUploadButton" v-if="withUploadButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
@success="updateFromUpload" @success="updateFromUpload"
/> />
<button <button
@@ -441,6 +476,7 @@
:default-required-fields="defaultRequiredFields" :default-required-fields="defaultRequiredFields"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
@@ -448,6 +484,7 @@
:show-tour-start-form="showTourStartForm" :show-tour-start-form="showTourStartForm"
@add-field="addField" @add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]" @set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event" @set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event" @set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@@ -502,7 +539,7 @@ import DocumentPreview from './preview'
import DocumentControls from './controls' import DocumentControls from './controls'
import MobileFields from './mobile_fields' import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter' 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 { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue' import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
@@ -528,6 +565,7 @@ export default {
Contenteditable, Contenteditable,
IconUsersPlus, IconUsersPlus,
IconChevronDown, IconChevronDown,
IconDownload,
IconAdjustments, IconAdjustments,
IconEye, IconEye,
IconDeviceFloppy IconDeviceFloppy
@@ -575,6 +613,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withDownload: {
type: Boolean,
required: false,
default: false
},
backgroundColor: { backgroundColor: {
type: String, type: String,
required: false, required: false,
@@ -610,6 +653,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withAddPageButton: { withAddPageButton: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -766,6 +814,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: { onlyDefinedFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -786,6 +839,7 @@ export default {
return { return {
documentRefs: [], documentRefs: [],
isBreakpointLg: false, isBreakpointLg: false,
isDownloading: false,
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
@@ -944,6 +998,75 @@ export default {
}, },
methods: { methods: {
toRaw, 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) { onDragover (e) {
if (this.$refs.dragPlaceholder?.dragPlaceholder) { if (this.$refs.dragPlaceholder?.dragPlaceholder) {
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask' this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
@@ -1757,8 +1880,9 @@ export default {
}, },
onDocumentReplace (data) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data const { replaceSchemaItem, schema, documents } = data
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...replaceSchemaItem, ...schema[0] }) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents) this.template.documents.push(...documents)
if (data.fields) { if (data.fields) {
@@ -8,8 +8,8 @@
dir="auto" dir="auto"
:contenteditable="editable" :contenteditable="editable"
style="min-width: 2px" style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'" :class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
class="peer outline-none focus:block" class="peer outline-none"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable" @keydown.enter.prevent="blurContenteditable"
@focus="$emit('focus', $event)" @focus="$emit('focus', $event)"
@@ -18,22 +18,19 @@
{{ value }} {{ value }}
</span> </span>
<span <span
v-if="withRequired" class="relative inline"
title="Required" :class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
class="text-red-500 peer-focus:hidden"
@click="focusContenteditable"
> >
*
</span>
<IconPencil <IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden" class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle pl-1"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:title="t('edit')" :title="t('edit')"
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }" :class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth" :width="iconWidth + 4"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]" @click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/> />
</span>
</div> </div>
</template> </template>
@@ -62,7 +59,12 @@ export default {
required: false, required: false,
default: 30 default: 30
}, },
withRequired: { hideIcon: {
type: Boolean,
required: false,
default: true
},
floatIcon: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
+20 -5
View File
@@ -12,8 +12,8 @@
:for="inputId" :for="inputId"
:class="{ 'opacity-50': isLoading, 'hover:bg-base-200/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }" :class="{ 'opacity-50': isLoading, 'hover:bg-base-200/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }"
> >
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none"> <div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center pointer-events-none">
<IconInnerShadowTop <IconInnerShadowTop
v-if="isLoading" v-if="isLoading"
class="animate-spin" class="animate-spin"
@@ -40,6 +40,15 @@
> >
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }} <span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div> </div>
<button
v-if="withGoogleDrive"
class="flex items-center text-sm mt-2 pointer-events-auto"
@click.stop.prevent="$emit('click-google-drive')"
>
<span>{{ t('or_add_from') }}</span>
<IconBrandGoogleDrive class="w-4 h-4 inline-block ml-1" />
<span class="ml-1 font-medium hover:underline">Google Drive</span>
</button>
</div> </div>
</div> </div>
<form <form
@@ -62,7 +71,7 @@
<script> <script>
import Upload from './upload' import Upload from './upload'
import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop, IconBrandGoogleDrive } from '@tabler/icons-vue'
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
@@ -71,7 +80,8 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop, IconInnerShadowTop,
IconFileSymlink, IconFileSymlink,
IconFiles IconFiles,
IconBrandGoogleDrive
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't'],
props: { props: {
@@ -99,6 +109,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
title: { title: {
type: String, type: String,
required: false, required: false,
@@ -110,7 +125,7 @@ export default {
default: 'image/*, application/pdf, application/zip' default: 'image/*, application/pdf, application/zip'
} }
}, },
emits: ['success', 'error', 'loading'], emits: ['success', 'error', 'loading', 'click-google-drive'],
data () { data () {
return { return {
isLoading: false, isLoading: false,
@@ -52,7 +52,7 @@
> >
<span class="py-1 flex items-center"> <span class="py-1 flex items-center">
<span <span
class="rounded-full w-3 h-3 ml-1 mr-3" class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
:class="colors[index % colors.length]" :class="colors[index % colors.length]"
/> />
<span> <span>
@@ -100,7 +100,7 @@
class="cursor-pointer text-base-100 flex h-full items-center justify-center" class="cursor-pointer text-base-100 flex h-full items-center justify-center"
> >
<button <button
class="mx-1 w-3 h-3 rounded-full" class="mx-1 w-3 h-3 rounded-full flex-shrink-0"
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]" :class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
/> />
</label> </label>
@@ -108,11 +108,11 @@
v-else v-else
ref="label" ref="label"
tabindex="0" tabindex="0"
class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between" class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between items-center"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full flex-shrink-0"
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]" :class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
/> />
<Contenteditable <Contenteditable
@@ -125,7 +125,7 @@
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </div>
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded"> <span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0">
<component <component
:is="editable ? 'IconPlus' : 'IconChevronDown'" :is="editable ? 'IconPlus' : 'IconChevronDown'"
width="18" width="18"
@@ -153,7 +153,7 @@
> >
<span class="py-1 flex items-center"> <span class="py-1 flex items-center">
<span <span
class="rounded-full w-3 h-3 ml-1 mr-3" class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
:class="colors[index % colors.length]" :class="colors[index % colors.length]"
/> />
<span> <span>
@@ -164,7 +164,7 @@
v-if="!compact && submitters.length > 1 && editable" v-if="!compact && submitters.length > 1 && editable"
class="flex" class="flex"
> >
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0"> <div class="flex-col pr-1 flex invisible group-hover:visible -mt-1 h-0">
<button <button
:title="t('up')" :title="t('up')"
class="relative w-2" class="relative w-2"
@@ -184,7 +184,7 @@
</div> </div>
<button <button
v-if="!compact && submitters.length > 1 && editable" v-if="!compact && submitters.length > 1 && editable"
class="hidden group-hover:block px-2" class="invisible group-hover:visible px-2"
@click.prevent.stop="remove(submitter)" @click.prevent.stop="remove(submitter)"
> >
<IconTrashX :width="18" /> <IconTrashX :width="18" />
+132 -3
View File
@@ -208,6 +208,43 @@
</li> </li>
</ul> </ul>
</div> </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 <div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm" v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300" class="rounded py-2 px-4 w-full border border-dashed border-base-300"
@@ -231,7 +268,7 @@
import Field from './field' import Field from './field'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' 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' import IconDrag from './icon_drag'
export default { export default {
@@ -240,11 +277,13 @@ export default {
Field, Field,
FieldType, FieldType,
IconCirclePlus, IconCirclePlus,
IconSparkles,
IconInnerShadowTop,
FieldSubmitter, FieldSubmitter,
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'], inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
@@ -255,6 +294,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -328,15 +372,22 @@ export default {
default: false 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 () { data () {
return { return {
fieldPagesLoaded: null,
analyzingProgress: 0,
defaultFieldsSearch: '' defaultFieldsSearch: ''
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, 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 () { isShowFieldSearch () {
if (this.withFieldsSearch === false) { if (this.withFieldsSearch === false) {
return false return false
@@ -389,6 +440,84 @@ export default {
this.$emit('set-drag', field) 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) { setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', { this.$emit('set-drag-placeholder', {
offsetX: event.offsetX, offsetX: event.offsetX,
@@ -0,0 +1,105 @@
<template>
<div
class="dropdown"
:class="{ 'dropdown-open': isLoading }"
>
<label tabindex="0">
<IconBrandGoogleDrive
width="19"
class="inline-block mr-1 cursor-pointer"
/>
</label>
<ul
tabindex="0"
:style="{ backgroundColor }"
class="dropdown-content z-[1] shadow menu rounded-box"
>
<li>
<a
:href="`https://drive.google.com/file/d/${googleDriveFileId}/view?usp=sharing`"
data-turbo="false"
target="_blank"
class="flex items-center"
>
<IconExternalLink class="w-4 h-4 flex-shrink-0" />
<span>{{ t('view') }}</span>
</a>
</li>
<li>
<button
form="sync_form"
type="submit"
:disabled="isLoading"
>
<IconRefresh
class="w-4 h-4 flex-shrink-0"
:class="{ 'animate-spin': isLoading }"
/>
<span>{{ message }}</span>
</button>
</li>
</ul>
<form
id="sync_form"
ref="form"
class="hidden"
@submit.prevent="upload({ path: uploadUrl })"
>
<input
:id="inputId"
ref="input"
:value="googleDriveFileId"
name="google_drive_file_ids[]"
>
</form>
</div>
</template>
<script>
import Upload from './upload'
import { IconRefresh, IconBrandGoogleDrive, IconExternalLink } from '@tabler/icons-vue'
export default {
name: 'GoogleDriveDocumentSettings',
components: {
IconRefresh,
IconBrandGoogleDrive,
IconExternalLink
},
inject: ['baseFetch', 't', 'backgroundColor'],
props: {
templateId: {
type: [Number, String],
required: true
},
googleDriveFileId: {
type: String,
required: true
}
},
emits: ['success'],
data () {
return {
isLoading: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/google_drive_documents`
},
message () {
if (this.isLoading) {
return this.t('syncing')
} else {
return this.t('sync')
}
}
},
methods: {
upload: Upload.methods.upload
}
}
</script>
+390 -153
View File
@@ -1,4 +1,9 @@
const en = { const en = {
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
view: 'View',
autodetect_fields: 'Autodetect fields',
payment_link: 'Payment link', payment_link: 'Payment link',
strikeout: 'Strikeout', strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document', draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -176,10 +181,18 @@ const en = {
and: 'and', and: 'and',
or: 'or', or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
start_tour: 'Start Tour' start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
syncing: 'Syncing...'
} }
const es = { const es = {
autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...',
download: 'Descargar',
downloading_: 'Descargando...',
view: 'Vista',
payment_link: 'Enlace de pago', payment_link: 'Enlace de pago',
strikeout: 'Tachar', strikeout: 'Tachar',
draw_strikethrough_the_document: 'Dibujar una línea de tachado en el documento', draw_strikethrough_the_document: 'Dibujar una línea de tachado en el documento',
@@ -357,10 +370,18 @@ const es = {
and: 'y', and: 'y',
or: 'o', or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía' start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
} }
const it = { const it = {
autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...',
download: 'Scarica',
downloading_: 'Download in corso...',
view: 'Vista',
payment_link: 'Link di pagamento', payment_link: 'Link di pagamento',
strikeout: 'Barrato', strikeout: 'Barrato',
draw_strikethrough_the_document: 'Disegna una linea barrata sul documento', draw_strikethrough_the_document: 'Disegna una linea barrata sul documento',
@@ -538,10 +559,18 @@ const it = {
and: 'e', and: 'e',
or: 'o', or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour' start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
syncing: 'Sincronizzazione...'
} }
const pt = { const pt = {
autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...',
download: 'Baixar',
downloading_: 'Baixando...',
view: 'Visualizar',
payment_link: 'Link de pagamento', payment_link: 'Link de pagamento',
strikeout: 'Tachado', strikeout: 'Tachado',
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento', draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
@@ -719,94 +748,108 @@ const pt = {
and: 'e', and: 'e',
or: 'ou', or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour' start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
} }
const fr = { 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', payment_link: 'Lien de paiement',
strikeout: 'Barrer', strikeout: 'Rature',
draw_strikethrough_the_document: 'Tracer une ligne de suppression sur le document', draw_strikethrough_the_document: 'Tracer une rature sur le document',
quantity: 'Quantité', quantity: 'Quantité',
prefillable: 'Pré-remplissable', prefillable: 'Préremplissable',
signature_id: 'ID de signature', signature_id: 'Identifiant de signature',
error_message: 'Message d\'erreur', error_message: "Message d'erreur",
length: 'Longueur', length: 'Longueur',
min: 'Min', min: 'Min',
max: 'Max', max: 'Max',
date_signed: 'Date actuelle',
font: 'Police', font: 'Police',
party: 'Partie', party: 'Partie',
date_signed: 'Date de signature',
method: 'Méthode', method: 'Méthode',
reorder_fields: 'Réorganiser les champs', reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID", verify_id: "Vérifier l'ID",
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) auprès du fournisseur de confiance. Cliquez pour en savoir plus.',
editable: 'Modifiable',
recurrent: 'Récurrent', recurrent: 'Récurrent',
one_off: 'Ponctuel', one_off: 'Ponctuel',
editable: 'Éditable',
search_field: 'Champ de recherche', search_field: 'Champ de recherche',
field_not_found: 'Champ non trouvé', field_not_found: 'Champ introuvable',
clear: 'Effacer', clear: 'Effacer',
type_value: 'Tapez la valeur',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléchargé contient des champs. Les conserver ou les supprimer?',
keep: 'Conserver',
align: 'Aligner', align: 'Aligner',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
keep: 'Conserver',
left: 'Gauche', left: 'Gauche',
heading: 'En-tête', heading: 'Titre',
validation: 'Validation', validation: 'Validation',
add_blank_page: 'Ajouter une page blanche', add_blank_page: 'Ajouter une page blanche',
right: 'Droite', right: 'Droite',
add_condition: 'Ajouter une condition',
center: 'Centre', center: 'Centre',
with_logo: 'Avec logo',
description: 'Description', description: 'Description',
display_title: "Titre d'affichage", display_title: 'Afficher le titre',
unchecked: 'Non coché', with_logo: 'Avec logo',
unchecked: 'Décoché',
price: 'Prix', price: 'Prix',
type_value: 'Saisir une valeur',
equal: 'Égal', equal: 'Égal',
not_equal: 'Non égal', not_equal: 'Différent',
contains: 'Contient', contains: 'Contient',
signing_date: 'Date de signature',
does_not_contain: 'Ne contient pas', does_not_contain: 'Ne contient pas',
not_empty: 'Non vide', not_empty: 'Non vide',
empty: 'Vide', empty: 'Vide',
select_field_: 'Sélectionner un champ...', select_field_: 'Sélectionner un champ...',
select_value_: 'Sélectionner une valeur...', select_value_: 'Sélectionner une valeur...',
remove_condition: 'Supprimer la condition', remove_condition: 'Supprimer la condition',
condition: 'Condition', add_condition: 'Ajouter une condition',
formula: 'Formule', are_you_sure_: 'Êtes-vous sûr ?',
edit: 'Éditer', sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature',
send: 'Envoyer',
remove: 'Supprimer',
edit: 'Modifier',
settings: 'Paramètres', settings: 'Paramètres',
up: 'Haut', up: 'Haut',
down: 'Bas', down: 'Bas',
set_signing_date: 'Définir la date de signature',
are_you_sure_: 'Êtes-vous sûr?',
sign_yourself: 'Signez-vous',
checked: 'Coché', checked: 'Coché',
send: 'Envoyer',
remove: 'Supprimer',
save: 'Enregistrer', save: 'Enregistrer',
cancel: 'Annuler', cancel: 'Annuler',
draw_field_on_the_document: 'Dessiner un champ sur le document', any: "N'importe lequel",
click_to_upload: 'Cliquez pour télécharger', drawn: 'Dessiné',
drawn_or_typed: 'Dessiné ou tapé',
drawn_or_upload: 'Dessiné ou téléversé',
upload: 'Téléverser',
formula: 'Formule',
typed: 'Tapé',
draw_field_on_the_document: 'Dessinez un champ sur le document',
click_to_upload: 'Cliquez pour téléverser',
or_drag_and_drop_files: 'ou faites glisser-déposer des fichiers', or_drag_and_drop_files: 'ou faites glisser-déposer des fichiers',
uploading: 'Téléchargement en cours', uploading: 'Téléversement en cours',
processing_: 'Traitement en cours...', processing_: 'Traitement...',
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images', add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
add_documents_or_images: 'Ajoutez des documents ou des images', add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Adicionar um novo documento', add_a_new_document: 'Ajouter un nouveau document',
replace_existing_document: 'Remplacer le document existant', replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer les documents', clone_and_replace_documents: 'Cloner et remplacer des documents',
required: 'Requis', required: 'Obligatoire',
default_value: 'Valeur par défaut', default_value: 'Valeur par défaut',
format: 'Format', format: 'Format',
read_only: 'Lecture seule', read_only: 'Lecture seule',
page: 'Page', page: 'Page',
draw_new_area: 'Dessiner une nouvelle zone', draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages', copy_to_all_pages: 'Copier sur toutes les pages',
add_option: 'Ajouter une option', add_option: 'Ajouter une option',
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
condition: 'Condition',
first_party: 'Première partie', first_party: 'Première partie',
second_party: 'Deuxième partie', second_party: 'Deuxième partie',
third_party: 'Troisième partie', third_party: 'Troisième partie',
@@ -817,27 +860,27 @@ const fr = {
eighth_party: 'Huitième partie', eighth_party: 'Huitième partie',
ninth_party: 'Neuvième partie', ninth_party: 'Neuvième partie',
tenth_party: 'Dixième partie', tenth_party: 'Dixième partie',
eleventh_party: 'Onzième Parti', eleventh_party: 'Onzième partie',
twelfth_party: 'Douzième Parti', twelfth_party: 'Douzième partie',
thirteenth_party: 'Treizième Parti', thirteenth_party: 'Treizième partie',
fourteenth_party: 'Quatorzième Parti', fourteenth_party: 'Quatorzième partie',
fifteenth_party: 'Quinzième Parti', fifteenth_party: 'Quinzième partie',
sixteenth_party: 'Seizième Parti', sixteenth_party: 'Seizième partie',
seventeenth_party: 'Dix-septième Parti', seventeenth_party: 'Dix-septième partie',
eighteenth_party: 'Dix-huitième Parti', eighteenth_party: 'Dix-huitième partie',
nineteenth_party: 'Dix-Neuvième Fête', nineteenth_party: 'Dix-neuvième partie',
twentieth_party: 'Vingtième Fête', twentieth_party: 'Vingtième partie',
draw: 'Dessiner', draw: 'Dessiner',
add: 'Ajouter', add: 'Ajouter',
or_add_field_without_drawing: 'Ou ajoutez un champ sans dessiner', or_add_field_without_drawing: 'Ou ajouter un champ sans le dessiner',
text: 'Texte', text: 'Texte',
number: 'Nombre',
signature: 'Signature', signature: 'Signature',
initials: 'Initiales', initials: 'Initiales',
date: 'Date', date: 'Date',
number: 'Numéro',
image: 'Image', image: 'Image',
file: 'Fichier', file: 'Fichier',
select: 'Choisir', select: 'Sélection',
checkbox: 'Coche', checkbox: 'Coche',
multiple: 'Multiple', multiple: 'Multiple',
radio: 'Radio', radio: 'Radio',
@@ -845,40 +888,34 @@ const fr = {
stamp: 'Tampon', stamp: 'Tampon',
payment: 'Paiement', payment: 'Paiement',
phone: 'Téléphone', phone: 'Téléphone',
text_field: 'Champ de Texte', text_field: 'Champ de texte',
signature_field: 'Champ de Signature', signature_field: 'Champ de signature',
initials_field: "Champ d'Initiales", initials_field: "Champ d'initiales",
date_field: 'Champ de Date', date_field: 'Champ de date',
number_field: 'Champ de Numéro', number_field: 'Champ de numéro',
image_field: "Champ d'Image", image_field: "Champ d'image",
file_field: 'Champ de Fichier', file_field: 'Champ de fichier',
select_field: 'Champ de Choix', select_field: 'Champ de choix',
checkbox_field: 'Champ de Coche', checkbox_field: 'Champ de coche',
multiple_field: 'Champ de Choix Multiple', multiple_field: 'Champ de choix multiple',
radio_field: 'Champ de Groupe Radio', radio_field: 'Champ de groupe radio',
cells_field: 'Champ de Cellules', cells_field: 'Champ de cellules',
stamp_field: 'Champ de Tampon', stamp_field: 'Champ de tampon',
payment_field: 'Champ de Paiement', payment_field: 'Champ de paiement',
phone_field: 'Champ de Téléphone', phone_field: 'Champ de téléphone',
draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ texte sur la page avec une souris', draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ de texte sur la page avec la souris',
drag_and_drop_any_other_field_type_on_the_page: 'Glissez et déposez tout autre type de champ sur la page', drag_and_drop_any_other_field_type_on_the_page: 'Glissez-déposez tout autre type de champ sur la page',
click_on_the_field_type_above_to_start_drawing_it: 'Cliquez sur le type de champ ci-dessus pour commencer à le dessiner', click_on_the_field_type_above_to_start_drawing_it: 'Cliquez sur le type de champ ci-dessus pour commencer à le dessiner',
please_draw_fields_to_prepare_the_document: 'Veuillez dessiner les champs pour préparer le document.', please_draw_fields_to_prepare_the_document: 'Veuillez dessiner des champs pour préparer le document.',
only_pdf_and_images_are_supported: 'Seuls les PDF et les images sont pris en charge.', only_pdf_and_images_are_supported: 'Seuls les PDF et les images sont pris en charge.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un plan payant. Utilisez un champ texte pour les numéros de téléphone sans vérification.', unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un forfait payant. Utilisez un champ texte pour les numéros sans vérification.',
available_only_in_pro: 'Disponible uniquement en Pro', available_only_in_pro: 'Disponible uniquement dans la version Pro',
failed_to_download_files: 'Échec du téléchargement des fichiers', failed_to_download_files: 'Impossible de télécharger les fichiers',
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Veuillez ajouter des champs pour {submitter_name} ou retirer {submitter_name} si ce n\'est pas nécessaire.', please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: "Veuillez ajouter des champs pour {submitter_name}. Ou supprimez {submitter_name} si ce n'est pas nécessaire.",
draw_field: 'Dessiner le champ {field}', draw_field: 'Dessiner le champ {field}',
replace: 'Remplacer', replace: 'Remplacer',
uploading_: 'Téléchargement en cours...', uploading_: 'Téléversement...',
add_document: 'Télécharger', add_document: 'Téléverser',
any: 'Tout',
drawn: 'Dessiné',
drawn_or_typed: 'Dessiné ou Tapé',
drawn_or_upload: 'Dessiné ou Téléchargé',
upload: 'Téléverser',
typed: 'Tapé',
none: 'Aucun', none: 'Aucun',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -889,90 +926,103 @@ const fr = {
numbers_only: 'Chiffres uniquement', numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement', letters_only: 'Lettres uniquement',
regexp_validation: 'Validation par expression régulière', regexp_validation: 'Validation par expression régulière',
enter_pdf_password: 'Entrez le mot de passe PDF', enter_pdf_password: 'Saisir le mot de passe du PDF',
wrong_password: 'Mot de passe incorrect', wrong_password: 'Mot de passe incorrect',
currency: 'Devise', currency: 'Devise',
save_and_preview: 'Enregistrer et prévisualiser', save_and_preview: 'Enregistrer et prévisualiser',
preferences: 'Préférences', preferences: 'Préférences',
available_in_pro: 'Disponible en version Pro', available_in_pro: 'Disponible dans la version Pro',
some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.', some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.',
learn_more: 'En savoir plus', learn_more: 'En savoir plus',
and: 'et', and: 'et',
or: 'ou', or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Lancez une visite rapide pour apprendre à créer et envoyer votre premier document.', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_tour: 'Démarrer' start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
syncing: 'Synchronisation...'
} }
const de = { const de = {
autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...',
download: 'Download',
downloading_: 'Download...',
view: 'Anzeigen',
payment_link: 'Zahlungslink', payment_link: 'Zahlungslink',
strikeout: 'Streichung', strikeout: 'Durchstreichen',
draw_strikethrough_the_document: 'Ziehe eine Streichung auf das Dokument', draw_strikethrough_the_document: 'Durchstreichung im Dokument zeichnen',
quantity: 'Menge', quantity: 'Menge',
prefillable: 'Vorausfüllbar', prefillable: 'Vorausfüllbar',
signature_id: 'Signatur-ID', signature_id: 'Signatur-ID',
error_message: 'Fehlermeldung', error_message: 'Fehlermeldung',
length: 'Länge', length: 'Länge',
min: 'Min', min: 'Min.',
max: 'Max', max: 'Max.',
date_now: 'Akt. Datum',
font: 'Schriftart', font: 'Schriftart',
party: 'Partei', party: 'Partei',
method: 'Verfahren', date_signed: 'Unterzeichnungsdatum',
method: 'Methode',
reorder_fields: 'Felder neu anordnen', reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Qualifizierte elektronische Signatur (QES) beim vertrauenswürdigen Anbieter einholen. Klicken Sie, um mehr zu erfahren.',
wiederkehrend: 'Wiederkehrend',
einmalig: 'Einmalig',
editable: 'Bearbeitbar', editable: 'Bearbeitbar',
recurrent: 'Wiederkehrend',
one_off: 'Einmalig',
search_field: 'Suchfeld', search_field: 'Suchfeld',
field_not_found: 'Feld nicht gefunden', field_not_found: 'Feld nicht gefunden',
clear: 'Löschen', clear: 'Leeren',
type_value: 'Wert eingeben',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Behalten oder entfernen?',
keep: 'Behalten',
align: 'Ausrichten', align: 'Ausrichten',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
keep: 'Beibehalten',
left: 'Links', left: 'Links',
heading: 'Überschrift', heading: 'Überschrift',
validation: 'Validierung', validation: 'Validierung',
add_blank_page: 'Leere Seite hinzufügen', add_blank_page: 'Leere Seite hinzufügen',
right: 'Rechts', right: 'Rechts',
add_condition: 'Bedingung hinzufügen',
center: 'Zentriert', center: 'Zentriert',
with_logo: 'Mit Logo',
description: 'Beschreibung', description: 'Beschreibung',
display_title: 'Anzeigetitel', display_title: 'Anzeigetitel',
unchecked: 'Nicht überprüft', with_logo: 'Mit Logo',
unchecked: 'Nicht markiert',
price: 'Preis', price: 'Preis',
type_value: 'Wert eingeben',
equal: 'Gleich', equal: 'Gleich',
not_equal: 'Ungleich', not_equal: 'Ungleich',
contains: 'Enthält', contains: 'Enthält',
does_not_contain: 'Enthält nicht', does_not_contain: 'Enthält nicht',
not_empty: 'Nicht leer', not_empty: 'Nicht leer',
empty: 'Leer', empty: 'Leer',
signing_date: 'Signaturdatum',
select_field_: 'Feld auswählen...', select_field_: 'Feld auswählen...',
select_value_: 'Wert auswählen...', select_value_: 'Wert auswählen...',
remove_condition: 'Bedingung entfernen', remove_condition: 'Bedingung entfernen',
condition: 'Bedingung', add_condition: 'Bedingung hinzufügen',
formula: 'Formel', are_you_sure_: 'Sind Sie sicher?',
sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum',
send: 'Senden',
remove: 'Entfernen',
edit: 'Bearbeiten', edit: 'Bearbeiten',
settings: 'Einstellungen', settings: 'Einstellungen',
up: 'Nach oben', up: 'Nach oben',
down: 'Nach unten', down: 'Nach unten',
set_signing_date: 'Unterschriftdatum festlegen', checked: 'Markiert',
are_you_sure_: 'Bist du sicher?',
sign_yourself: 'Unterschreiben Sie selbst',
checked: 'Ausgewählt',
send: 'Senden',
remove: 'Entfernen',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
draw_field_on_the_document: 'Ein Feld auf dem Dokument zeichnen', any: 'Beliebig',
click_to_upload: 'Klicken Sie zum Hochladen', drawn: 'Gezeichnet',
or_drag_and_drop_files: 'oder Dateien hierher ziehen', drawn_or_typed: 'Gezeichnet oder getippt',
uploading: 'Hochladen', drawn_or_upload: 'Gezeichnet oder hochgeladen',
processing_: 'Verarbeitung...', upload: 'Hochladen',
formula: 'Formel',
typed: 'Getippt',
draw_field_on_the_document: 'Ein Feld im Dokument zeichnen',
click_to_upload: 'Zum Hochladen klicken',
or_drag_and_drop_files: 'oder Dateien per Drag & Drop ablegen',
uploading: 'Hochladen...',
processing_: 'Verarbeiten...',
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen', add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
add_documents_or_images: 'Dokumente oder Bilder hinzufügen', add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen', add_a_new_document: 'Neues Dokument hinzufügen',
@@ -983,11 +1033,12 @@ const de = {
format: 'Format', format: 'Format',
read_only: 'Nur lesen', read_only: 'Nur lesen',
page: 'Seite', page: 'Seite',
draw_new_area: 'Neuen Bereich zeichnen', draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren', copy_to_all_pages: 'Auf alle Seiten kopieren',
add_option: 'Option hinzufügen', add_option: 'Option hinzufügen',
option: 'Option', option: 'Option',
options: 'Options', options: 'Optionen',
condition: 'Bedingung',
first_party: 'Erste Partei', first_party: 'Erste Partei',
second_party: 'Zweite Partei', second_party: 'Zweite Partei',
third_party: 'Dritte Partei', third_party: 'Dritte Partei',
@@ -1006,21 +1057,21 @@ const de = {
sixteenth_party: 'Sechzehnte Partei', sixteenth_party: 'Sechzehnte Partei',
seventeenth_party: 'Siebzehnte Partei', seventeenth_party: 'Siebzehnte Partei',
eighteenth_party: 'Achtzehnte Partei', eighteenth_party: 'Achtzehnte Partei',
nineteenth_party: 'Neunzehnte Party', nineteenth_party: 'Neunzehnte Partei',
twentieth_party: 'Zwanzigste Party', twentieth_party: 'Zwanzigste Partei',
draw: 'Zeichnen', draw: 'Zeichnen',
add: 'Hinzufügen', add: 'Hinzufügen',
or_add_field_without_drawing: 'Oder Feld ohne Zeichnung hinzufügen', or_add_field_without_drawing: 'Oder Feld ohne Zeichnen hinzufügen',
text: 'Text', text: 'Text',
number: 'Zahl',
signature: 'Unterschrift', signature: 'Unterschrift',
initials: 'Initialen', initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer',
image: 'Bild', image: 'Bild',
file: 'Datei', file: 'Datei',
select: 'Auswählen', select: 'Auswahl',
checkbox: 'Checkbox', checkbox: 'Checkbox',
multiple: 'Mehrere', multiple: 'Mehrfach',
radio: 'Radio', radio: 'Radio',
cells: 'Zellen', cells: 'Zellen',
stamp: 'Stempel', stamp: 'Stempel',
@@ -1034,32 +1085,26 @@ const de = {
image_field: 'Bildfeld', image_field: 'Bildfeld',
file_field: 'Dateifeld', file_field: 'Dateifeld',
select_field: 'Auswahlfeld', select_field: 'Auswahlfeld',
checkbox_field: 'Checkbox-Feld', checkbox_field: 'Checkboxfeld',
multiple_field: 'Mehrfach-Auswahlfeld', multiple_field: 'Mehrfachauswahlfeld',
radio_field: 'Radio-Gruppenfeld', radio_field: 'Radiogruppe',
cells_field: 'Zellenfeld', cells_field: 'Zellenfeld',
stamp_field: 'Stempelfeld', stamp_field: 'Stempelfeld',
payment_field: 'Zahlungsfeld', payment_field: 'Zahlungsfeld',
phone_field: 'Telefonfeld', phone_field: 'Telefonfeld',
draw_a_text_field_on_the_page_with_a_mouse: 'Textfeld auf der Seite mit der Maus zeichnen', draw_a_text_field_on_the_page_with_a_mouse: 'Ein Textfeld mit der Maus auf der Seite zeichnen',
drag_and_drop_any_other_field_type_on_the_page: 'Ziehe und lasse einen anderen Feldtyp auf die Seite fallen', drag_and_drop_any_other_field_type_on_the_page: 'Jeden anderen Feldtyp auf die Seite ziehen und ablegen',
click_on_the_field_type_above_to_start_drawing_it: 'Klicke auf den Feldtyp oben, um mit dem Zeichnen zu beginnen', click_on_the_field_type_above_to_start_drawing_it: 'Klicken Sie oben auf den Feldtyp, um mit dem Zeichnen zu beginnen',
please_draw_fields_to_prepare_the_document: 'Bitte zeichne Felder, um das Dokument vorzubereiten.', please_draw_fields_to_prepare_the_document: 'Bitte zeichnen Sie Felder, um das Dokument vorzubereiten.',
only_pdf_and_images_are_supported: 'Nur PDFs und Bilder werden unterstützt.', only_pdf_and_images_are_supported: 'Nur PDF und Bilder werden unterstützt.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Schalte das SMS-verifizierte Telefonnummernfeld mit einem kostenpflichtigen Plan frei. Verwende das Textfeld für Telefonnummern ohne Verifizierung.', unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'SMS-verifiziertes Telefonnummernfeld mit kostenpflichtigem Plan freischalten. Verwenden Sie für Telefonnummern ohne Verifizierung das Textfeld.',
available_only_in_pro: 'Nur in Pro verfügbar', available_only_in_pro: 'Nur in Pro verfügbar',
failed_to_download_files: 'Fehler beim Herunterladen der Dateien', failed_to_download_files: 'Dateien konnten nicht heruntergeladen werden',
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Bitte füge Felder für {submitter_name} hinzu oder entferne {submitter_name}, falls nicht erforderlich.', please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Bitte fügen Sie Felder für {submitter_name} hinzu oder entfernen Sie {submitter_name}, falls nicht benötigt.',
draw_field: 'Feld {field} zeichnen', draw_field: '{field}-Feld zeichnen',
replace: 'Ersetzen', replace: 'Ersetzen',
uploading_: 'Hochladen...', uploading_: 'Hochladen...',
add_document: 'Hochladen', add_document: 'Hochladen',
any: 'Jede',
drawn: 'Gezeichnet',
drawn_or_typed: 'Gezeichnet oder getippt',
drawn_or_upload: 'Gezeichnet oder hochgeladen',
upload: 'Upload',
typed: 'Getippt',
none: 'Keine', none: 'Keine',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -1069,19 +1114,211 @@ const de = {
custom: 'Benutzerdefiniert', custom: 'Benutzerdefiniert',
numbers_only: 'Nur Zahlen', numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben', letters_only: 'Nur Buchstaben',
regexp_validation: 'Regulärer Ausdruck Überprüfung', regexp_validation: 'RegExp-Validierung',
enter_pdf_password: 'Geben Sie das PDF-Passwort ein', enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort', wrong_password: 'Falsches Passwort',
currency: 'Währung', currency: 'Währung',
save_and_preview: 'Speichern und Vorschau', save_and_preview: 'Speichern und Vorschau',
preferences: 'Einstellungen', preferences: 'Voreinstellungen',
available_in_pro: 'In Pro verfügbar', available_in_pro: 'In Pro verfügbar',
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.', some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
learn_more: 'Erfahren Sie mehr', learn_more: 'Mehr erfahren',
and: 'und', and: 'und',
or: 'oder', or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starte eine kurze Tour, um zu lernen, wie du dein erstes Dokument erstellst und versendest.', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_tour: 'Starten' start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
syncing: 'Synchronisiere...'
} }
export { en, es, it, pt, fr, de } const nl = {
autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...',
download: 'Downloaden',
downloading_: 'Downloaden...',
view: 'Bekijken',
payment_link: 'Betaallink',
strikeout: 'Doorhalen',
draw_strikethrough_the_document: 'Teken doorhaling in het document',
quantity: 'Aantal',
prefillable: 'Voorinvulbaar',
signature_id: 'Handtekening-ID',
error_message: 'Foutmelding',
length: 'Lengte',
min: 'Min',
max: 'Max',
font: 'Lettertype',
party: 'Partij',
date_signed: 'Datum ondertekend',
method: 'Methode',
reorder_fields: 'Velden herschikken',
verify_id: 'ID verifiëren',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Verkrijg een gekwalificeerde elektronische handtekening (QeS) via de vertrouwde provider. Klik voor meer informatie.',
editable: 'Bewerkbaar',
recurrent: 'Terugkerend',
one_off: 'Eenmalig',
search_field: 'Zoekveld',
field_not_found: 'Veld niet gevonden',
clear: 'Wissen',
align: 'Uitlijnen',
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
keep: 'Behouden',
left: 'Links',
heading: 'Koptekst',
validation: 'Validatie',
add_blank_page: 'Lege pagina toevoegen',
right: 'Rechts',
center: 'Centreren',
description: 'Beschrijving',
display_title: 'Titel weergeven',
with_logo: 'Met logo',
unchecked: 'Niet aangevinkt',
price: 'Prijs',
type_value: 'Typ waarde',
equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan',
contains: 'Bevat',
does_not_contain: 'Bevat niet',
not_empty: 'Niet leeg',
empty: 'Leeg',
select_field_: 'Veld selecteren...',
select_value_: 'Waarde selecteren...',
remove_condition: 'Voorwaarde verwijderen',
add_condition: 'Voorwaarde toevoegen',
are_you_sure_: 'Weet u het zeker?',
sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum',
send: 'Verzenden',
remove: 'Verwijderen',
edit: 'Bewerken',
settings: 'Instellingen',
up: 'Omhoog',
down: 'Omlaag',
checked: 'Aangevinkt',
save: 'Opslaan',
cancel: 'Annuleren',
any: 'Elke',
drawn: 'Getekend',
drawn_or_typed: 'Getekend of getypt',
drawn_or_upload: 'Getekend of uploaden',
upload: 'Uploaden',
formula: 'Formule',
typed: 'Getypt',
draw_field_on_the_document: 'Teken een veld op het document',
click_to_upload: 'Klik om te uploaden',
or_drag_and_drop_files: 'of sleep bestanden hierheen',
uploading: 'Uploaden',
processing_: 'Bezig met verwerken...',
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen',
replace_existing_document: 'Bestaand document vervangen',
clone_and_replace_documents: 'Documenten klonen en vervangen',
required: 'Vereist',
default_value: 'Standaardwaarde',
format: 'Formaat',
read_only: 'Alleen-lezen',
page: 'Pagina',
draw_new_area: 'Nieuw gebied tekenen',
copy_to_all_pages: 'Kopieer naar alle pag.',
add_option: 'Optie toevoegen',
option: 'Optie',
options: 'Opties',
condition: 'Voorwaarde',
first_party: 'Eerste partij',
second_party: 'Tweede partij',
third_party: 'Derde partij',
fourth_party: 'Vierde partij',
fifth_party: 'Vijfde partij',
sixth_party: 'Zesde partij',
seventh_party: 'Zevende partij',
eighth_party: 'Achtste partij',
ninth_party: 'Negende partij',
tenth_party: 'Tiende partij',
eleventh_party: 'Elfde partij',
twelfth_party: 'Twaalfde partij',
thirteenth_party: 'Dertiende partij',
fourteenth_party: 'Veertiende partij',
fifteenth_party: 'Vijftiende partij',
sixteenth_party: 'Zestiende partij',
seventeenth_party: 'Zeventiende partij',
eighteenth_party: 'Achttiende partij',
nineteenth_party: 'Negentiende partij',
twentieth_party: 'Twintigste partij',
draw: 'Tekenen',
add: 'Toevoegen',
or_add_field_without_drawing: 'Of voeg een veld toe zonder te tekenen',
text: 'Tekst',
number: 'Getal',
signature: 'Signatuur',
initials: 'Initialen',
date: 'Datum',
image: 'Afbeelding',
file: 'Bestand',
select: 'Selecteren',
checkbox: 'Checkbox',
multiple: 'Meerdere',
radio: 'Radio',
cells: 'Cellen',
stamp: 'Stempel',
payment: 'Betaling',
phone: 'Telefoon',
text_field: 'Tekstveld',
signature_field: 'Signatuurveld',
initials_field: 'Initialenveld',
date_field: 'Datumveld',
number_field: 'Getalveld',
image_field: 'Afbeeldingsveld',
file_field: 'Bestandsveld',
select_field: 'Selectieveld',
checkbox_field: 'Aankruisvakje',
multiple_field: 'Meerkeuzeveld',
radio_field: 'Radiogroepveld',
cells_field: 'Cellenveld',
stamp_field: 'Stempelveld',
payment_field: 'Betalingsveld',
phone_field: 'Telefoonveld',
draw_a_text_field_on_the_page_with_a_mouse: 'Teken met de muis een tekstveld op de pagina',
drag_and_drop_any_other_field_type_on_the_page: 'Sleep elk ander veldtype naar de pagina',
click_on_the_field_type_above_to_start_drawing_it: 'Klik op het bovenstaande veldtype om te beginnen met tekenen',
please_draw_fields_to_prepare_the_document: 'Teken velden om het document voor te bereiden.',
only_pdf_and_images_are_supported: 'Alleen PDF en afbeeldingen worden ondersteund.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Ontgrendel het SMS-geverifieerde veld voor telefoonnummer met een betaald abonnement. Gebruik het tekstveld voor telefoonnummers zonder verificatie.',
available_only_in_pro: 'Alleen beschikbaar in Pro',
failed_to_download_files: 'Bestanden downloaden mislukt',
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Voeg velden toe voor de {submitter_name}. Of verwijder de {submitter_name} als deze niet nodig is.',
draw_field: 'Teken het veld {field}',
replace: 'Vervangen',
uploading_: 'Bezig met uploaden...',
add_document: 'Doc. toevoegen',
none: 'Geen',
ssn: 'SSN',
ein: 'EIN',
email: 'E-mail',
url: 'URL',
zip: 'Postcode',
custom: 'Aangepast',
numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters',
regexp_validation: 'Regex validatie',
enter_pdf_password: 'Voer PDF-wachtwoord in',
wrong_password: 'Onjuist wachtwoord',
currency: 'Valuta',
save_and_preview: 'Opslaan & bekijken',
preferences: 'Voorkeuren',
available_in_pro: 'Beschikbaar in Pro',
some_fields_are_missing_in_the_formula: 'Sommige velden ontbreken in de formule.',
learn_more: 'Meer informatie',
and: 'en',
or: 'of',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
syncing: 'Synchroniseren...'
}
export { en, es, it, pt, fr, de, nl }
@@ -1,5 +1,5 @@
<template> <template>
<div class="absolute text-center w-full bottom-0 pr-3 mb-4"> <div class="absolute w-full bottom-0 pr-3 mb-4">
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 draw-field-container-mobile"> <span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 draw-field-container-mobile">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<component <component
+18 -8
View File
@@ -9,7 +9,7 @@
loading="lazy" loading="lazy"
> >
<div <div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1 hover:bg-black/10 transition-colors" class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1 hover:bg-black/10 transition-colors rounded"
@click="$emit('scroll-to', item)" @click="$emit('scroll-to', item)"
> >
<div <div
@@ -23,7 +23,7 @@
> >
<div> <div>
<button <button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true" @click.stop="isShowConditionsModal = true"
> >
<IconRouteAltLeft <IconRouteAltLeft
@@ -48,7 +48,7 @@
> >
<div> <div>
<button <button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px" style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)" @click.stop="$emit('remove', item)"
> >
@@ -63,7 +63,7 @@
class="tooltip tooltip-left before:text-xs" class="tooltip tooltip-left before:text-xs"
> >
<button <button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="$emit('reorder', item)" @click.stop="$emit('reorder', item)"
> >
<IconSortDescending2 <IconSortDescending2
@@ -75,14 +75,14 @@
</span> </span>
<template v-if="withArrows"> <template v-if="withArrows">
<button <button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px" style="width: 24px; height: 24px"
@click.stop="$emit('up', item)" @click.stop="$emit('up', item)"
> >
&uarr; &uarr;
</button> </button>
<button <button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px" style="width: 24px; height: 24px"
@click.stop="$emit('down', item)" @click.stop="$emit('down', item)"
> >
@@ -94,12 +94,20 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex pb-2 pt-1.5 document-preview-name"> <div class="flex items-center gap-1 pb-2 pt-1.5 document-preview-name">
<GoogleDriveDocumentSettings
v-if="item.google_drive_file_id"
:template-id="template.id"
:google-drive-file-id="item.google_drive_file_id"
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<Contenteditable <Contenteditable
:model-value="item.name" :model-value="item.name"
:icon-width="16" :icon-width="16"
:icon-inline="true"
:float-icon="!item.google_drive_file_id"
:hide-icon="!item.google_drive_file_id"
:editable="editable" :editable="editable"
style="max-width: 95%"
class="mx-auto" class="mx-auto"
@update:model-value="onUpdateName" @update:model-value="onUpdateName"
/> />
@@ -123,6 +131,7 @@ import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue' import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace' import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
import Field from './field' import Field from './field'
import FieldType from './field_type' import FieldType from './field_type'
@@ -133,6 +142,7 @@ export default {
IconRouteAltLeft, IconRouteAltLeft,
ConditionsModal, ConditionsModal,
ReplaceButton, ReplaceButton,
GoogleDriveDocumentSettings,
IconSortDescending2 IconSortDescending2
}, },
inject: ['t'], inject: ['t'],
+217 -10
View File
@@ -3,8 +3,15 @@
<label <label
id="add_document_button" id="add_document_button"
:for="inputId" :for="inputId"
class="btn btn-outline w-full add-document-button" class="btn btn-outline w-full add-document-button px-0"
:class="{ 'btn-disabled': isLoading }" :class="{ 'btn-disabled': isLoading }"
>
<div
class="flex items-center justify-between w-full h-full"
>
<span
class="flex items-center space-x-2 w-full justify-center"
:class="{ 'pl-3': withGoogleDrive }"
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isLoading" v-if="isLoading"
@@ -18,13 +25,132 @@
<span v-if="isLoading"> <span v-if="isLoading">
{{ t('uploading_') }} {{ t('uploading_') }}
</span> </span>
<span v-else> <span
v-else
class="mr-1 whitespace-nowrap truncate"
>
{{ t('add_document') }} {{ t('add_document') }}
</span> </span>
</span>
<span
v-if="withGoogleDrive"
class="dropdown dropdown-end dropdown-top inline h-full"
style="width: 33px"
>
<label
tabindex="0"
class="flex items-center h-full cursor-pointer"
>
<IconChevronDown class="w-5 h-5 flex-shrink-0" />
</label> </label>
<ul
tabindex="0"
:style="{ backgroundColor }"
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
>
<li>
<button
type="button"
@click="openGoogleDriveModal"
>
<IconBrandGoogleDrive class="w-5 h-5 flex-shrink-0" />
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
</button>
</li>
</ul>
</span>
</div>
</label>
<Teleport
v-if="showGoogleDriveModal"
:to="modalContainerEl"
>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="showGoogleDriveModal = false"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
Google Drive
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="showGoogleDriveModal = false"
>&times;</a>
</div>
<div>
<form
v-if="showGoogleDriveOauthButton"
method="post"
:action="googleDriveOauthPath"
@submit="isConnectGoogleDriveClicked = true"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
id="gdrive_oauth_button"
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
data-turbo="false"
type="submit"
:disabled="isConnectGoogleDriveClicked"
>
<span v-if="isConnectGoogleDriveClicked">
<span class="flex items-center justify-center space-x-2">
<IconInnerShadowTop class="animate-spin" />
<span class="">Submitting...</span>
</span>
</span>
<span
v-else
>
<span class="flex items-center justify-center space-x-2">
<IconBrandGoogleDrive />
<span>Connect Google Drive</span>
</span>
</span>
</button>
</form>
<div
v-else
class="relative"
>
<iframe
class="border border-base-300 rounded-lg"
style="width: 100%; height: 440px; background: white;"
src="/template_google_drive"
/>
<div v-if="isLoadingGoogleDrive">
<div
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
style="margin: 1px"
/>
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
<IconInnerShadowTop class="animate-spin" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<form <form
ref="form" ref="form"
class="hidden" class="hidden"
>
<input
v-for="file in googleDriveFiles"
:key="file.id"
name="google_drive_file_ids[]"
:value="file.id"
> >
<input <input
:id="inputId" :id="inputId"
@@ -40,20 +166,32 @@
</template> </template>
<script> <script>
import { IconUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
export default { export default {
name: 'DocumentsUpload', name: 'DocumentsUpload',
components: { components: {
IconUpload, IconUpload,
IconInnerShadowTop IconInnerShadowTop,
IconChevronDown,
IconBrandGoogleDrive
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't', 'backgroundColor'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@@ -63,22 +201,85 @@ export default {
emits: ['success', 'error'], emits: ['success', 'error'],
data () { data () {
return { return {
isLoading: false isLoading: false,
isConnectGoogleDriveClicked: false,
isLoadingGoogleDrive: false,
googleDriveFiles: [],
showGoogleDriveModal: false,
showGoogleDriveOauthButton: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
}, },
uploadUrl () { queryParams () {
return `/templates/${this.templateId}/documents` return new URLSearchParams(window.location.search)
},
googleDriveOauthPath () {
const params = {
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
state: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString()
}
const query = new URLSearchParams(params).toString()
return `/auth/google_oauth2?${query}`
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
} }
}, },
mounted () {
window.addEventListener('message', this.messageHandler)
if (this.queryParams.get('google_drive_open') === '1') {
this.openGoogleDriveModal()
window.history.replaceState({}, document.title, window.location.pathname)
}
},
beforeUnmount () {
window.removeEventListener('message', this.messageHandler)
},
methods: { methods: {
async upload () { openGoogleDriveModal () {
this.showGoogleDriveModal = true
this.isLoadingGoogleDrive = true
},
messageHandler (event) {
if (event.data.type === 'google-drive-files-picked') {
this.googleDriveFiles = event.data.files || []
this.$nextTick(() => {
this.isLoadingGoogleDrive = true
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
if (resp.ok) {
this.showGoogleDriveModal = false
}
}).finally(() => {
this.isLoadingGoogleDrive = false
})
})
} else if (event.data.type === 'google-drive-picker-loaded') {
this.isLoadingGoogleDrive = false
} else if (event.data.type === 'google-drive-picker-request-oauth') {
this.showGoogleDriveOauthButton = true
}
},
async upload ({ path } = {}) {
this.isLoading = true this.isLoading = true
this.baseFetch(this.uploadUrl, { return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
@@ -111,6 +312,10 @@ export default {
this.isLoading = false this.isLoading = false
} }
}) })
} else if (data.status === 'google_drive_file_missing') {
alert(data.error)
this.$emit('error', data.error)
this.isLoading = false
} else { } else {
this.$emit('error', data.error) this.$emit('error', data.error)
this.isLoading = false this.isLoading = false
@@ -122,6 +327,8 @@ export default {
this.isLoading = false this.isLoading = false
}) })
} }
return resp
}).catch(() => { }).catch(() => {
this.isLoading = false this.isLoading = false
}) })
+1 -1
View File
@@ -11,7 +11,7 @@ class GeneratePreviewImagesJob
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1, max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, (1..max_page), Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, 1..max_page,
concurrency: 1) concurrency: 1)
end end
end end
+13 -5
View File
@@ -93,18 +93,26 @@ class ProcessSubmitterCompletionJob
def enqueue_completed_emails(submitter) def enqueue_completed_emails(submitter)
submission = submitter.submission submission = submitter.submission
template = submitter.template
user = submission.created_by_user || submitter.template.author user = submission.created_by_user || template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false && if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
submitter.template&.preferences&.dig('completed_notification_email_enabled') != false (!template || template.preferences['completed_notification_email_enabled'] != false)
if submission.submitters.map(&:email).exclude?(user.email) && user_submitter = submission.submitters.find { |s| s.email == user.email }
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration' is_sent_to_user =
if user.role != 'integration' &&
(!user_submitter || user_submitter.preferences['send_email'] == false) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
SubmitterMailer.completed_email(submitter, user).deliver_later! SubmitterMailer.completed_email(submitter, user).deliver_later!
true
end end
build_bcc_addresses(submission).each do |to| build_bcc_addresses(submission).each do |to|
next if is_sent_to_user && to == user.email
SubmitterMailer.completed_email(submitter, user, to:).deliver_later! SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
end end
end end
@@ -7,6 +7,8 @@ class SendSubmitterInvitationEmailJob
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
return if submitter.completed_at? 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) return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
unless Accounts.can_send_invitation_emails?(submitter.account) unless Accounts.can_send_invitation_emails?(submitter.account)
+7
View File
@@ -22,6 +22,7 @@
# #
class AccountConfig < ApplicationRecord class AccountConfig < ApplicationRecord
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email' SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email' SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email' SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails' BCC_EMAILS = 'bcc_emails'
@@ -59,6 +60,12 @@ class AccountConfig < ApplicationRecord
'body' => I18n.t(:submitter_invitation_email_sign_body) '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 { SUBMITTER_COMPLETED_EMAIL_KEY => lambda {
{ {
'subject' => I18n.t(:template_name_has_been_completed_by_submitters), 'subject' => I18n.t(:template_name_has_been_completed_by_submitters),
+1 -1
View File
@@ -50,7 +50,7 @@ class User < ApplicationRecord
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
FULL_EMAIL_REGEXP = FULL_EMAIL_REGEXP =
/\A[a-z0-9][\.']?(?:(?:[a-z0-9_-]+[\.\+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[\.-])*[a-z0-9]+\.[a-z]{2,}\z/i /\A[a-z0-9][.']?(?:(?:[a-z0-9_-]+[.+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[.-])*[a-z0-9]+\.[a-z]{2,}\z/i
has_one_attached :signature has_one_attached :signature
has_one_attached :initials has_one_attached :initials
+1 -1
View File
@@ -15,7 +15,7 @@
<%= hidden_field_tag :redir, params[:redir] %> <%= hidden_field_tag :redir, params[:redir] %>
<% end %> <% end %>
<submit-form data-on="change"> <submit-form data-on="change">
<%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES]).map { |code| [t("language_#{code}"), code] }, I18n.locale), class: 'select select-sm border-base-content/30 text-base' %> <%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES nl-NL]).map { |code| [t("language_#{code}"), code] }, I18n.locale), class: 'select select-sm border-base-content/30 text-base' %>
</submit-form> </submit-form>
<% end %> <% end %>
</div> </div>
+1
View File
@@ -0,0 +1 @@
<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="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-google-drive"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 10l-6 10l-3 -5l6 -10z" /><path d="M9 15h12l-3 5h-12" /><path d="M15 15l-6 -10h6l6 10z" /></svg>

After

Width:  |  Height:  |  Size: 458 B

+8
View File
@@ -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

+6
View File
@@ -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

+5
View File
@@ -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

+6
View File
@@ -19,6 +19,12 @@
<%= stylesheet_pack_tag 'application', media: 'all' %> <%= stylesheet_pack_tag 'application', media: 'all' %>
</head> </head>
<body> <body>
<% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
<% if url_params[:action] == 'new' %>
<open-modal src="<%= params[:modal] %>"></open-modal>
<% end %>
<% end %>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>
<turbo-frame id="drawer"></turbo-frame> <turbo-frame id="drawer"></turbo-frame>
<%= render 'shared/navbar' %> <%= render 'shared/navbar' %>
+2
View File
@@ -9,6 +9,8 @@
<span class="disabled"> <span class="disabled">
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %> <%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<% if disabled_with %>
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span> <span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
<% end %>
</span> </span>
</span> </span>
+9 -2
View File
@@ -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 %>"> <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') %> <%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
</span> </span>
<p class="text-sm leading-none text-base-content/60 pt-1.5"> <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) %> <%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p> </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"> <p class="text-base-content/80 mt-1">
<% if event.event_type == 'complete_verification' %> <% if event.event_type == 'complete_verification' %>
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %> <%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
+1 -115
View File
@@ -1,115 +1 @@
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %> <%= render partial: 'submissions/send_email_base', locals: local_assigns %>
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
<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="<%= %w[message_field].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" 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>
@@ -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>
+2 -1
View File
@@ -1,10 +1,11 @@
<% align = field.dig('preferences', 'align') %> <% align = field.dig('preferences', 'align') %>
<% valign = field.dig('preferences', 'valign') %> <% valign = field.dig('preferences', 'valign') %>
<% color = field.dig('preferences', 'color') %> <% color = field.dig('preferences', 'color') %>
<% bg_color = field.dig('preferences', 'background') %>
<% font = field.dig('preferences', 'font') %> <% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %> <% 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 } %> <% 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' %> <% if field['type'] == 'signature' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %> <% 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' %>"> <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' %>">
+2 -2
View File
@@ -81,7 +81,7 @@
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %> <% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<% if document.preview_images.first %> <% if document.preview_images.first %>
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer"> <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"> <div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %> <%= item['name'].presence || document.filename.base %>
</div> </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| %> <% (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 = 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'] %>"> <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"> <div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %> <% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', 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' %> <%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
</toggle-submit> </toggle-submit>
<% end %> <% 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 %> <% end %>
</div> </div>
</div> </div>
@@ -6,7 +6,7 @@
<%= svg_icon('cloud_upload', class: 'w-9 h-9') %> <%= svg_icon('cloud_upload', class: 'w-9 h-9') %>
</span> </span>
<div class="font-medium mb-1"> <div class="font-medium mb-1">
<%= t('upload_new_document') %> <%= t('upload_a_new_document') %>
</div> </div>
</span> </span>
<span class="flex flex-col items-center hidden" data-target="dashboard-dropzone.fileDropzoneLoading"> <span class="flex flex-col items-center hidden" data-target="dashboard-dropzone.fileDropzoneLoading">
+10 -3
View File
@@ -5,16 +5,23 @@
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border-2 border-base-300 border-dashed" data-target="file-dropzone.area"> <label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border-2 border-base-300 border-dashed" data-target="file-dropzone.area">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2 pointer-events-none"> <div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2 pointer-events-none">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<span data-target="file-dropzone.icon" class="flex flex-col items-center"> <span data-target="file-dropzone.icon" class="flex flex-col items-center <%= 'mb-5' if Docuseal.multitenant? %>">
<span> <span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %> <%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span> </span>
<div class="font-medium mb-1"> <div class="font-medium mb-1">
<%= t('upload_new_document') %> <%= t('upload_a_new_document') %>
</div> </div>
<div class="text-xs"> <div class="text-sm">
<%= t('click_to_upload_or_drag_and_drop_html') %> <%= t('click_to_upload_or_drag_and_drop_html') %>
</div> </div>
<% if Docuseal.multitenant? %>
<a class="flex absolute bottom-4 items-center text-sm mt-2 pointer-events-auto" href="<%= new_template_path(modal_tab: 'google_drive') %>" data-turbo-frame="modal">
<span><%= t('or_add_from') %></span>
<%= svg_icon('brand_gdrive', class: 'w-4 h-4 ml-1') %>
<span class="ml-1 font-medium hover:underline">Google Drive</span>
</a>
<% end %>
</span> </span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden"> <span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %> <%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
+31
View File
@@ -0,0 +1,31 @@
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-4">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div>
<div class="mt-3 mb-4 flex items-center justify-between">
<label for="folder_name" class="cursor-pointer">
<%= svg_icon('folder', class: 'w-6 h-6') %>
</label>
<folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
<%= t('change_folder') %>
</label>
</set-value>
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %>
</div>
<% end %>
+4 -4
View File
@@ -103,12 +103,12 @@
</div> </div>
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %> <% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span> </span>
<% end %> <% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %> <% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('trash', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span> </span>
<% end %> <% end %>
</div> </div>
@@ -210,12 +210,12 @@
</div> </div>
<% if !submission.archived_at? && !template&.archived_at? %> <% if !submission.archived_at? && !template&.archived_at? %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span> </span>
<% end %> <% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %> <% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('trash', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span> </span>
<% end %> <% end %>
</div> </div>
+1 -1
View File
@@ -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' %> <%= 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 %>
<% 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>
+1 -31
View File
@@ -1,33 +1,3 @@
<%= render 'shared/turbo_modal', title: @base_template ? t('clone_template') : t('new_document_template') do %> <%= render 'shared/turbo_modal', title: @base_template ? t('clone_template') : t('new_document_template') do %>
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <%= render 'templates/file_form' %>
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-6">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div>
<div class="mt-3 mb-4 flex items-center justify-between">
<label for="folder_name" class="cursor-pointer">
<%= svg_icon('folder', class: 'w-6 h-6') %>
</label>
<folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
<%= t('change_folder') %>
</label>
</set-value>
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %>
</div>
<% end %>
<% end %> <% end %>
@@ -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>
+4 -194
View File
@@ -98,157 +98,17 @@
<%= t('signature_request_email') %> <%= t('signature_request_email') %>
</div> </div>
<div class="collapse-content"> <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| %> <%= render 'templates_preferences/submitter_invitation_email_form' %>
<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 %>
</div> </div>
</div> </div>
<%= render 'templates_preferences/submitter_invitation_reminder_email_collapse' %>
<div class="collapse collapse-arrow join-item border border-base-300"> <div class="collapse collapse-arrow join-item border border-base-300">
<input type="checkbox" name="accordion"> <input type="checkbox" name="accordion">
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
<%= t('documents_copy_email') %> <%= t('documents_copy_email') %>
</div> </div>
<div class="collapse-content"> <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| %> <%= render 'templates_preferences/submitter_documents_copy_email_form' %>
<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 %>
</div> </div>
</div> </div>
<div class="collapse collapse-arrow join-item border border-base-300"> <div class="collapse collapse-arrow join-item border border-base-300">
@@ -257,57 +117,7 @@
<%= t('completed_notification_email') %> <%= t('completed_notification_email') %>
</div> </div>
<div class="collapse-content"> <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| %> <%= render 'templates_preferences/submitter_completed_email_form' %>
<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 %>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -25,8 +25,8 @@ module DocuSeal
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true' config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT nl-NL
es it de fr pl uk cs pt he nl ar ko ja] es it de fr nl pl uk cs pt he ar ko ja]
config.i18n.fallbacks = [:en] config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) } config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
+1728 -815
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -98,8 +98,9 @@ Rails.application.routes.draw do
resources :submissions_filters, only: %i[show], param: 'name' resources :submissions_filters, only: %i[show], param: 'name'
resources :templates, only: %i[new create edit update show destroy] do resources :templates, only: %i[new create edit update show destroy] do
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development? 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 :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 :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions' resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create] resources :submissions, only: %i[new create]
@@ -107,7 +108,7 @@ Rails.application.routes.draw do
resource :preview, only: %i[show], controller: 'templates_preview' resource :preview, only: %i[show], controller: 'templates_preview'
resource :form, only: %i[show], controller: 'templates_form_preview' resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal' 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' resource :share_link, only: %i[show create], controller: 'templates_share_link'
resources :recipients, only: %i[create], controller: 'templates_recipients' resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields' resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
+37
View File
@@ -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
+1 -1
View File
@@ -78,7 +78,7 @@ module Docuseal
@fulltext_search = @fulltext_search =
if SearchEntry.table_exists? if SearchEntry.table_exists?
Docuseal.multitenant? ? true : AccountConfig.exists?(key: :fulltext_search, value: true) Docuseal.multitenant? || AccountConfig.exists?(key: :fulltext_search, value: true)
else else
false false
end end
+160
View File
@@ -25,6 +25,8 @@ class Pdfium
typedef :pointer, :FPDF_BITMAP typedef :pointer, :FPDF_BITMAP
typedef :pointer, :FPDF_FORMHANDLE typedef :pointer, :FPDF_FORMHANDLE
typedef :pointer, :FPDF_TEXTPAGE typedef :pointer, :FPDF_TEXTPAGE
typedef :pointer, :FPDF_PAGEOBJECT
typedef :pointer, :FPDF_PATHSEGMENT
MAX_SIZE = 32_767 MAX_SIZE = 32_767
@@ -37,6 +39,9 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400 FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800 FPDF_PRINTING = 0x800
TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true)
LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true)
# rubocop:disable Naming/ClassAndModuleCamelCase # rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int, layout :version, :int,
@@ -77,6 +82,37 @@ class Pdfium
attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void
attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int
attach_function :FPDFText_GetText, %i[FPDF_TEXTPAGE int int pointer], :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 :int, :FPDF_BOOL
typedef :pointer, :IPDF_JSPLATFORM typedef :pointer, :IPDF_JSPLATFORM
@@ -157,6 +193,7 @@ class Pdfium
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})" raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
end end
# rubocop:disable Metrics
class Document class Document
attr_reader :document_ptr, :form_handle attr_reader :document_ptr, :form_handle
@@ -386,6 +423,128 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null? Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end 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?
char_count.times do |i|
unicode = Pdfium.FPDFText_GetUnicode(text_page, i)
char = [unicode].pack('U*')
left_ptr = FFI::MemoryPointer.new(:double)
right_ptr = FFI::MemoryPointer.new(:double)
bottom_ptr = FFI::MemoryPointer.new(:double)
top_ptr = FFI::MemoryPointer.new(:double)
result = Pdfium.FPDFText_GetCharBox(text_page, i, left_ptr, right_ptr, bottom_ptr, top_ptr)
next if result.zero?
left = left_ptr.read_double
right = right_ptr.read_double
origin_x_ptr = FFI::MemoryPointer.new(:double)
origin_y_ptr = FFI::MemoryPointer.new(:double)
Pdfium.FPDFText_GetCharOrigin(text_page, i, origin_x_ptr, origin_y_ptr)
origin_y = origin_y_ptr.read_double
font_size = Pdfium.FPDFText_GetFontSize(text_page, i)
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 = abs_x / width
y = abs_y / height
node_width = abs_width / width
node_height = abs_height / height
@text_nodes << TextNode.new(content: char, x: x, y: y, w: node_width, h: node_height)
end
@text_nodes = @text_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
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(x: norm_x, y: norm_y, w: norm_w, h: norm_h, tilt: tilt)
end
@line_nodes = @line_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
end
def close def close
return if closed? return if closed?
@@ -445,4 +604,5 @@ class Pdfium
at_exit do at_exit do
cleanup_library cleanup_library
end end
# rubocop:enable Metrics
end end
+1
View File
@@ -39,6 +39,7 @@ Puma::Plugin.create do
configs = Sidekiq.configure_embed do |config| configs = Sidekiq.configure_embed do |config|
config.logger.level = Logger::INFO config.logger.level = Logger::INFO
sidekiq_config = YAML.load_file('config/sidekiq.yml') sidekiq_config = YAML.load_file('config/sidekiq.yml')
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
config.queues = sidekiq_config['queues'] config.queues = sidekiq_config['queues']
config.concurrency = ENV.fetch('SIDEKIQ_THREADS', 5).to_i config.concurrency = ENV.fetch('SIDEKIQ_THREADS', 5).to_i
config.merge!(sidekiq_config) config.merge!(sidekiq_config)
+1 -1
View File
@@ -56,7 +56,7 @@ module SearchEntries
end end
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword] [sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.\-]{2,}/) elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/)
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)] ['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
else else
keyword = TextUtils.transliterate(keyword.downcase).squish keyword = TextUtils.transliterate(keyword.downcase).squish
+1 -1
View File
@@ -6,7 +6,7 @@ module SendWebhookRequest
LOCALHOSTS = %w[0.0.0.0 127.0.0.1 localhost].freeze LOCALHOSTS = %w[0.0.0.0 127.0.0.1 localhost].freeze
MANUAL_ATTEMPT = 99_999 MANUAL_ATTEMPT = 99_999
AUTOMATED_RETRY_RANGE = 1..MANUAL_ATTEMPT - 1 AUTOMATED_RETRY_RANGE = 1..(MANUAL_ATTEMPT - 1)
HttpsError = Class.new(StandardError) HttpsError = Class.new(StandardError)
LocalhostError = Class.new(StandardError) LocalhostError = Class.new(StandardError)
+4 -2
View File
@@ -23,6 +23,8 @@ module Submissions
RTL_REGEXP = TextUtils::RTL_REGEXP RTL_REGEXP = TextUtils::RTL_REGEXP
MAX_IMAGE_HEIGHT = 100 MAX_IMAGE_HEIGHT = 100
CHECKSUM_LIMIT = 30
US_TIMEZONES = TimeUtils::US_TIMEZONES US_TIMEZONES = TimeUtils::US_TIMEZONES
module_function module_function
@@ -216,7 +218,7 @@ module Submissions
composer.document.layout.formatted_text_box( composer.document.layout.formatted_text_box(
[ [
{ text: "#{I18n.t('original_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] }, { 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", "\n",
{ text: "#{I18n.t('result_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] }, { text: "#{I18n.t('result_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] },
document.metadata['sha256'] || document.checksum, document.metadata['sha256'] || document.checksum,
@@ -481,7 +483,7 @@ module Submissions
def select_attachments(submitter) def select_attachments(submitter)
original_documents = submitter.submission.schema_documents.preload(:blob) original_documents = submitter.submission.schema_documents.preload(:blob)
is_more_than_two_images = original_documents.count(&:image?) > 1 is_more_than_two_images = original_documents.many?(&:image?)
submitter.documents.preload(:blob).reject do |attachment| submitter.documents.preload(:blob).reject do |attachment|
is_more_than_two_images && is_more_than_two_images &&
+53 -23
View File
@@ -244,6 +244,7 @@ module Submissions
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i 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 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_name = field.dig('preferences', 'font')
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym 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'] with_signature_id = field['preferences']['with_signature_id']
end 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 case field_type
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) } when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value } attachment = submitter.attachments.find { |a| a.uuid == value }
@@ -311,8 +319,8 @@ module Submissions
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
if with_signature_id_reason if with_signature_id_reason
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \ "#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \ "#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \ "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}" "#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
else else
@@ -321,9 +329,9 @@ module Submissions
end end
end end
reason_text = HexaPDF::Layout::TextFragment.create(reason_string, base_font_size = (font_size / 1.8).to_i
font:,
font_size: (font_size / 1.8).to_i) result = nil
if area['h']&.positive? && (area['w'].to_f / area['h']) > 6 if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
area_x = area['x'] * width area_x = area['x'] * width
@@ -344,12 +352,10 @@ module Submissions
id_string = "ID: #{attachment.uuid}".upcase id_string = "ID: #{attachment.uuid}".upcase
while true loop do
text = HexaPDF::Layout::TextFragment.create(id_string, text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
font:,
font_size: (font_size / 1.8).to_i)
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 break if result.status == :success
@@ -358,25 +364,39 @@ module Submissions
break if id_string.length < 8 break if id_string.length < 8
end 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_x = area_x + half_width
text_y = height - area_y text_y = height - area_y
reason_result = layouter.fit([reason_text], half_width, height) layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
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))
else else
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: base_font_size)
id_string = "ID: #{attachment.uuid}".upcase id_string = "ID: #{attachment.uuid}".upcase
loop do loop do
text = HexaPDF::Layout::TextFragment.create(id_string, text = HexaPDF::Layout::TextFragment.create(id_string,
font:, 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 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')) 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, .draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height) height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
@@ -699,20 +719,30 @@ module Submissions
begin begin
pdf.sign(io, write_options: { validate: false }, **sign_params) 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) Rollbar.error(e) if defined?(Rollbar)
begin
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params) 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 end
maybe_enable_ltv(io, sign_params) maybe_enable_ltv(io, sign_params)
else else
begin begin
pdf.write(io, incremental: true, validate: false) pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError, NoMethodError => e rescue HexaPDF::Error, NoMethodError => e
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
begin
pdf.write(io, incremental: false, validate: false) 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
end end
@@ -833,7 +863,7 @@ module Submissions
def find_last_submitter(submission, submitter: nil) def find_last_submitter(submission, submitter: nil)
submission.submitters submission.submitters
.select(&:completed_at?) .select(&:completed_at?)
.select { |e| submitter.nil? ? true : e.id != submitter.id && e.completed_at <= submitter.completed_at } .select { |e| submitter.nil? || (e.id != submitter.id && e.completed_at <= submitter.completed_at) }
.max_by(&:completed_at) .max_by(&:completed_at)
end end
+19 -2
View File
@@ -13,6 +13,17 @@ module Submitters
UnableToSendCode = Class.new(StandardError) UnableToSendCode = Class.new(StandardError)
InvalidOtp = 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 module_function
@@ -56,7 +67,7 @@ module Submitters
end end
[sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight] [sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight]
elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/[.\-]{2,}/) elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/[.-]{2,}/)
terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq
if terms.size > 1 if terms.size > 1
@@ -100,7 +111,7 @@ module Submitters
end end
original_documents = submitter.submission.schema_documents.preload(:blob) original_documents = submitter.submission.schema_documents.preload(:blob)
is_more_than_two_images = original_documents.count(&:image?) > 1 is_more_than_two_images = original_documents.many?(&:image?)
submitter.documents.preload(:blob).reject do |attachment| submitter.documents.preload(:blob).reject do |attachment|
is_more_than_two_images && is_more_than_two_images &&
@@ -111,6 +122,12 @@ module Submitters
def create_attachment!(submitter, params) def create_attachment!(submitter, params)
blob = blob =
if (file = params[:file]) 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, ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
+2
View File
@@ -20,6 +20,8 @@ module Submitters
def call(text, font: nil) def call(text, font: nil)
font = FONT_ALIASES[font] || font font = FONT_ALIASES[font] || font
text = ERB::Util.html_escape(text)
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font], text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
width: WIDTH, height: HEIGHT, wrap: :none) width: WIDTH, height: HEIGHT, wrap: :none)
+1 -1
View File
@@ -94,7 +94,7 @@ module Submitters
end end
def fetch_fields(template, submitter_name: nil, for_submitter: nil) def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name if submitter_name && !for_submitter
submitter = submitter =
template.submitters.find { |e| e['name'] == submitter_name } || template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, raise(UnknownSubmitterName,
+485
View File
@@ -0,0 +1,485 @@
# frozen_string_literal: true
module Templates
module DetectFields
module_function
TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true)
PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true)
DATE_REGEXP = /
(?:
date
| signed\sat
| datum
)
\s*[:-]?\s*\z
/ix
NUMBER_REGEXP = /
(?:
price
| \$
| €
| total
| quantity
| prix
| quantité
| preis
| summe
| gesamt(?:betrag)?
| menge
| anzahl
| stückzahl
)
\s*[:-]?\s*\z
/ix
SIGNATURE_REGEXP = /
(?:
signature
| sign\shere
| sign
| signez\sici
| signer\sici
| unterschrift
| unterschreiben
| unterzeichnen
)
\s*[:-]?\s*\z
/ix
# 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 = fields.map do |f|
{
uuid: SecureRandom.uuid,
type: f.type,
required: f.type != 'checkbox',
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 = 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 != 'checkbox',
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 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 = []
current_text = ''.b
text_nodes = page.text_nodes
text_idx = 0
field_idx = 0
while text_idx < text_nodes.length || field_idx < fields.length
text_node = text_nodes[text_idx]
field = fields[field_idx]
process_text_node = false
process_field_node = false
if text_node && field
text_y_center = text_node.y + (text_node.h / 2.0)
field_y_center = field.y + (field.h / 2.0)
y_threshold = text_node.h / 2.0
vertical_distance = (text_y_center - field_y_center).abs
if vertical_distance < y_threshold
is_underscore = text_node.content == '_'
is_left_of_field = text_node.x < field.x
if is_underscore && is_left_of_field
text_x_end = text_node.x + text_node.w
distance = field.x - text_x_end
proximity_threshold = text_node.w * 3.0
if distance < proximity_threshold
process_field_node = true
else
process_text_node = true
end
elsif is_left_of_field
process_text_node = true
else
process_field_node = true
end
elsif text_node.y < field.y
process_text_node = true
else
process_field_node = true
end
elsif text_node
process_text_node = true
elsif field
process_field_node = true
end
if process_field_node
unless current_text.empty?
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node
tail_node = new_text_node
current_text = ''.b
end
new_field_node = PageNode.new(prev: tail_node, elem: field, page: page.page_index, attachment_uuid:)
tail_node.next = new_field_node
tail_node = new_field_node
field_nodes << tail_node
while text_idx < text_nodes.length
text_node_to_check = text_nodes[text_idx]
is_part_of_field = false
if text_node_to_check.content == '_'
check_y_center = text_node_to_check.y + (text_node_to_check.h / 2.0)
check_y_dist = (check_y_center - field_y_center).abs
check_y_thresh = text_node_to_check.h / 2.0
if check_y_dist < check_y_thresh
padding = text_node_to_check.w * 3.0
field_x_start = field.x - padding
field_x_end = field.x + field.w + padding
text_x_start = text_node_to_check.x
text_x_end = text_node_to_check.x + text_node_to_check.w
is_part_of_field = true if text_x_start <= field_x_end && field_x_start <= text_x_end
end
end
break unless is_part_of_field
text_idx += 1
end
field_idx += 1
elsif process_text_node
if text_idx > 0
prev_text_node = text_nodes[text_idx - 1]
x_gap = text_node.x - (prev_text_node.x + prev_text_node.w)
gap_w = text_node.w > prev_text_node.w ? text_node.w : prev_text_node.w
current_text << ' ' if x_gap > gap_w * 2
end
current_text << text_node.content
text_idx += 1
end
end
unless current_text.empty?
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node
tail_node = new_text_node
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.x + node.w
y2 = node.y + node.h
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.x + next_node.w
next_y2 = next_node.y + next_node.h
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.x + box1.w, box2.x + box2.w].min
y2 = [box1.y + box1.h, box2.y + box2.h].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.x + box1.w < box2.x || box2.x + box2.w < box1.x ||
box1.y + box1.h < box2.y || box2.y + box2.h < 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'
field_bottom = image_field.y + image_field.h
text_fields.each do |text_field|
break if text_field.y > field_bottom
next if text_field.y + text_field.h < image_field.y
next unless boxes_overlap?(image_field, text_field) && calculate_iou(image_field, text_field) > 0.5
break image_field.confidence += by
end
end
image_fields
end
# rubocop:enable Metrics, Style
end
end
+346
View File
@@ -0,0 +1,346 @@
# frozen_string_literal: true
module Templates
module ImageToFields
module_function
Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true)
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)
fields = build_fields_from_detections(detections, image)
sort_fields(fields, y_threshold: 10.0 / image.height)
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 sort_fields(fields, y_threshold: 0.01)
sorted_fields = fields.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
lines = []
current_line = []
sorted_fields.each do |field|
if current_line.blank? || (field.y - current_line.first.y).abs < y_threshold
current_line << field
else
lines << current_line.sort_by(&:x)
current_line = [field]
end
end
lines << current_line.sort_by(&:x) if current_line.present?
lines.flatten
end
def apply_nms(detections, threshold = 0.5)
return detections if detections[:xyxy].shape[0].zero?
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
View File
@@ -99,7 +99,7 @@ module Templates
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1 max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1
generate_document_preview_images(attachment, data, (0..[number_of_pages - 1, max_pages_to_process].min)) generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min)
end end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY) def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
+1 -1
View File
@@ -2,7 +2,7 @@
module TextUtils module TextUtils
RTL_REGEXP = /[\p{Hebrew}\p{Arabic}]/ RTL_REGEXP = /[\p{Hebrew}\p{Arabic}]/
MASK_REGEXP = /[^\s\-_\[\]\(\)\+\?\.\,]/ MASK_REGEXP = /[^\s\-_\[\]()+?.,]/
MASK_SYMBOL = 'X' MASK_SYMBOL = 'X'
TRANSLITERATIONS = TRANSLITERATIONS =