Compare commits

...
38 Commits
Author SHA1 Message Date
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
65 changed files with 3817 additions and 1273 deletions

No files matched your search

+1
View File
@@ -37,3 +37,4 @@ yarn-debug.log*
/docuseal
/ee
dump.rdb
*.onnx
+6
View File
@@ -42,6 +42,12 @@ Metrics/CyclomaticComplexity:
Metrics/PerceivedComplexity:
Max: 15
Style/MultipleComparison:
Enabled: false
Naming/PredicateMethod:
Enabled: false
Layout/LineLength:
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://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -50,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -66,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
COPY ./bin ./bin
COPY ./app ./app
@@ -83,6 +86,7 @@ COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
+2
View File
@@ -24,7 +24,9 @@ gem 'image_processing'
gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray'
gem 'oj'
gem 'onnxruntime'
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
+30 -15
View File
@@ -76,7 +76,7 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
arabic-letter-connector (0.1.1)
ast (2.4.2)
ast (2.4.3)
aws-eventstream (1.3.0)
aws-partitions (1.1027.0)
aws-sdk-core (3.214.0)
@@ -291,10 +291,10 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.13.2)
json (2.15.0)
jwt (2.9.3)
base64
language_server-protocol (3.17.0.3)
language_server-protocol (3.17.0.5)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
@@ -305,6 +305,7 @@ GEM
letter_opener (~> 1.9)
railties (>= 6.1)
rexml
lint_roller (1.1.0)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
@@ -356,17 +357,26 @@ GEM
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
ffi
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (3.3.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
parallel (1.26.3)
parser (3.3.6.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
@@ -383,6 +393,7 @@ GEM
pretender (0.5.0)
actionpack (>= 6.1)
prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -395,7 +406,7 @@ GEM
puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.3)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
@@ -448,7 +459,7 @@ GEM
psych (>= 4.0.0)
redis-client (0.23.0)
connection_pool
regexp_parser (2.9.3)
regexp_parser (2.11.3)
reline (0.6.2)
io-console (~> 0.5)
representable (3.2.0)
@@ -485,18 +496,20 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.69.2)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.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)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
@@ -567,9 +580,9 @@ GEM
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.1.2)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
uri (1.0.3)
useragent (0.16.11)
@@ -634,7 +647,9 @@ DEPENDENCIES
letter_opener_web
lograge
mysql2
numo-narray
oj
onnxruntime
pagy
pg
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
- Users management
- 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
- Easy to deploy in minutes
+3 -1
View File
@@ -8,7 +8,8 @@ class AccountsController < ApplicationController
'es-ES' => 'Español',
'pt-PT' => 'Português',
'de-DE' => 'Deutsch',
'it-IT' => 'Italiano'
'it-IT' => 'Italiano',
'nl-NL' => 'Nederlands'
}.freeze
before_action :load_account
@@ -46,6 +47,7 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account)
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
@@ -34,6 +34,10 @@ module Api
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
end
def build_new_cookie_signatures_json(submitter, attachment)
@@ -147,7 +147,7 @@ module Api
is_send_email = !params[:send_email].in?(['false', false])
if (emails = (params[:emails] || params[:email]).presence) &&
(params[:submission].blank? && params[:submitters].blank?)
params[:submission].blank? && params[:submitters].blank?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redir_uri = Addressable::URI.parse(params[:redir])
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
allow_other_host: true
end
end
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
respond_to do |f|
f.html { render :success }
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
private
def already_sent?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
true
end
end
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
@template = @submission.template
render :completed, layout: 'form'
+3 -1
View File
@@ -75,7 +75,9 @@ class SubmitFormController < ApplicationController
render json: { error: e.message }, status: :unprocessable_content
end
def completed; end
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
end
def success; end
+2 -1
View File
@@ -118,7 +118,8 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
: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]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
DEBUG_FILE = ''
def show
attachment = @template.documents.first
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
fields = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@@ -0,0 +1,27 @@
# frozen_string_literal: true
class TemplatesDetectFieldsController < ApplicationController
include ActionController::Live
load_and_authorize_resource :template
def create
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
sse.write({ completed: true })
ensure
response.stream.close
end
end
+6
View File
@@ -49,6 +49,8 @@ import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -136,6 +138,8 @@ safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -152,6 +156,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true',
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true',
@@ -160,6 +165,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
@@ -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
}
if (this.field.preferences?.background) {
style.background = this.field.preferences.background
}
return style
},
isNarrow () {
+11 -3
View File
@@ -163,12 +163,20 @@ export default {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
}).then(async (resp) => {
const data = await resp.json()
if (resp.status === 422) {
alert(data.error)
} else {
return data
}
})
}
})).then((result) => {
this.$emit('upload', result)
if (result && result[0]) {
this.$emit('upload', result)
}
}).finally(() => {
this.isLoading = false
})
+109 -109
View File
@@ -91,7 +91,7 @@ const en = {
type_text: 'Type text',
email_has_been_sent: 'Email has been sent',
processing: 'Processing',
pay_with_strip: 'Pay with Stripe',
pay_with_stripe: 'Pay with Stripe',
reupload: 'Reupload',
upload: 'Upload',
files: 'Files',
@@ -192,7 +192,7 @@ const es = {
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando',
pay_with_strip: 'Pagar con Stripe',
pay_with_stripe: 'Pagar con Stripe',
reupload: 'Volver a subir',
upload: 'Subir',
files: 'Archivos',
@@ -222,7 +222,7 @@ const it = {
value_is_invalid: 'Il valore 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',
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',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
@@ -293,7 +293,7 @@ const it = {
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione',
pay_with_strip: 'Paga con Stripe',
pay_with_stripe: 'Paga con Stripe',
reupload: 'Ricarica',
upload: 'Carica',
files: 'File',
@@ -304,64 +304,64 @@ const it = {
const de = {
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
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',
identity_verification: 'Identitätsüberprüfung',
identity_verification: 'Identitätsprüfung',
complete: 'Abschließen',
fill_all_required_fields_to_complete: 'Alle erforderlichen Felder ausfüllen, um abzuschließen',
sign_and_complete: 'Signieren und Abschließen',
fill_all_required_fields_to_complete: 'Füllen Sie alle Pflichtfelder aus, um abzuschließ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',
email: 'E-Mail',
approved: 'Genehmigt',
reviewed: 'Überprüft',
other: 'Andere',
authored_by_me: 'Von mir verfasst',
approved_by: 'Genehmigt von',
reviewed_by: 'Überprüft von',
authored_by: 'Verfasst von',
reviewed_by: 'Geprüft von',
authored_by: 'Erstellt von',
select_a_reason: 'Grund auswählen',
value_is_invalid: 'Wert ist ungültig',
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',
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',
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',
pay: 'Bezahlen',
take_photo: 'Foto aufnehmen',
number_phone_is_invalid: 'Telefonnummer ({number}) ist ungültig',
file: 'Datei',
select: 'Auswählen',
digitally_signed_by: 'Elektronisch signiert von',
reason: 'Grund',
select: 'Auswahl',
checkbox: 'Checkbox',
multiple: 'Mehrere',
multiple: 'Mehrfach',
radio: 'Radio',
cells: 'Zellen',
stamp: 'Stempel',
minimize: 'Minimieren',
payment: 'Zahlung',
number_phone_is_invalid: '{number} Telefonnummer ist ungültig',
phone: 'Telefon',
start_now: 'Jetzt starten',
continue: 'Fortsetzen',
sign_now: 'Jetzt unterschreiben',
continue: 'Weiter',
sign_now: 'Jetzt unterzeichnen',
type_here_: 'Hier eingeben...',
optional: 'optional',
option: 'Option',
appears_on: 'Erscheint auf',
page: 'Seite',
take_photo: 'Foto aufnehmen',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
select_your_option: 'Option auswählen',
complete_hightlighted_checkboxes_and_click: 'Füllen Sie die markierten Kontrollkästchen aus und klicken Sie auf',
submit: 'Einreichen',
next: 'Weiter',
click_to_upload: 'Zum Hochladen klicken',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
@@ -370,137 +370,137 @@ const de = {
draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!',
document_has_been_signed: 'Dokument wurde unterschrieben!',
documents_have_been_signed: 'Dokumente wurden unterschrieben!',
form_has_been_completed: 'Formular wurde abgeschlossen!',
document_has_been_signed: 'Dokument wurde unterzeichnet!',
documents_have_been_signed: 'Dokumente wurden unterzeichnet!',
create_a_free_account: 'Kostenloses Konto erstellen',
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',
verified_phone_number: 'Telefonnummer überprüfen',
verified_phone_number: 'Telefonnummer verifizieren',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
verification_code_has_been_resent: 'Der Verifizierungscode wurde per SMS erneut gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle Pflichtfelder aus',
set_today: 'Heute',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben',
draw: 'Zeichnen',
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet',
email_has_been_sent: 'E-Mail wurde gesendet',
processing: 'Verarbeitung',
pay_with_strip: 'Mit Stripe bezahlen',
pay_with_stripe: 'Mit Stripe bezahlen',
reupload: 'Erneut hochladen',
upload: 'Hochladen',
files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden'
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
}
const fr = {
please_upload_an_image_file: 'Veuillez télécharger un fichier image',
must_be_characters_length: 'Doit contenir {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é.",
verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité",
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
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 poursuivre la vérification d'identité.",
verify_id: "Vérifier l'ID",
identity_verification: "Vérification d'identité",
complete: 'Terminer',
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
sign_and_complete: 'Signer et Terminer',
invite: 'Inviter',
email: 'Courriel',
fill_all_required_fields_to_complete: 'Remplissez tous les champs obligatoires pour terminer',
sign_and_complete: 'Signer et terminer',
text: 'Texte',
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é',
reviewed: 'Révisé',
other: 'Autre',
authored_by_me: 'Rédigé par moi',
invite: 'Inviter',
email: 'E-mail',
approved_by: 'Approuvé par',
reviewed_by: 'Révisé par',
authored_by: 'Rédigé par',
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',
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é',
signature: 'Signature',
initials: 'Initiales',
digitally_signed_by: 'Signé numériquement par',
reason: 'Raison',
pay: 'Payer',
date: 'Date',
number: 'Numéro',
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',
select: 'Choisir',
digitally_signed_by: 'Signé électroniquement par',
reason: 'Motif',
select: 'Sélectionner',
checkbox: 'Coche',
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cellules',
stamp: 'Tampon',
minimize: 'Réduire',
payment: 'Paiement',
number_phone_is_invalid: '{number} téléphone est invalide',
phone: 'Téléphone',
start_now: 'Commencer maintenant',
continue: 'Continuer',
sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...',
type_here_: 'Saisissez ici...',
optional: 'facultatif',
option: 'Option',
appears_on: 'Apparaît sur',
page: 'Page',
take_photo: 'Prendre une photo',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
complete_hightlighted_checkboxes_and_click: 'Cochez les cases mises en évidence puis cliquez',
submit: 'soumettre',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
click_to_upload: 'Cliquez pour téléverser',
or_drag_and_drop_files: 'ou faites glisser et déposez des fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger',
clear: 'Effacer',
redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !',
document_has_been_signed: 'Le document a été signé!',
documents_have_been_signed: 'Les documents ont été signés!',
create_a_free_account: 'Créer un Compte Gratuit',
type_signature_here: 'Saisissez la signature ici',
type_initial_here: 'Saisissez les initiales ici',
form_has_been_completed: 'Le formulaire a été rempli !',
document_has_been_signed: 'Le document a été signé !',
documents_have_been_signed: 'Les documents ont été signés !',
create_a_free_account: 'Créer un compte gratuit',
powered_by: 'Propulsé par',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
open_source_documents_software: 'logiciel de documents open source',
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',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
change_phone_number: 'Changer de numéro de téléphone',
sending: 'Envoi...',
resend_code: 'Renvoyer le code',
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',
set_today: "Définir Aujourd'hui",
draw_signature: 'Dessiner une signature',
set_today: "Définir à aujourd'hui",
toggle_multiline_text: 'Basculer le texte multiligne',
draw_signature: 'Dessiner la signature',
type_initial: 'Saisir les initiales',
draw: 'Dessiner',
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé",
processing: 'Traitement',
pay_with_strip: 'Paiement avec Stripe',
reupload: 'Recharger',
upload: 'Télécharger',
email_has_been_sent: "L'e-mail a été envoyé",
processing: 'Traitement en cours',
pay_with_stripe: 'Payer avec Stripe',
reupload: 'Retéléverser',
upload: 'Téléverser',
files: 'Fichiers',
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 = {
@@ -596,7 +596,7 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_strip: 'Płatność za pomocą Stripe',
pay_with_stripe: 'Płatność za pomocą Stripe',
reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie',
files: 'Pliki',
@@ -798,7 +798,7 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_strip: 'Zaplacení přes Stripe',
pay_with_stripe: 'Zaplacení přes Stripe',
reupload: 'Znovu nahrát',
upload: 'Nahrát',
files: 'Soubory',
@@ -899,7 +899,7 @@ const pt = {
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_strip: 'Pagar com Stripe',
pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar',
upload: 'Carregar',
files: 'Arquivos',
@@ -1000,7 +1000,7 @@ const he = {
type_text: 'הקלד טקסט',
email_has_been_sent: 'האימייל נשלח',
processing: 'מעבד',
pay_with_strip: 'שלם עם סטרייפ',
pay_with_stripe: 'שלם עם סטרייפ',
reupload: 'העלה שוב',
upload: 'העלאה',
files: 'קבצים',
@@ -1101,7 +1101,7 @@ const nl = {
type_text: 'Typ tekst',
email_has_been_sent: 'E-mail is verzonden',
processing: 'Verwerken',
pay_with_strip: 'Betalen met Stripe',
pay_with_stripe: 'Betalen met Stripe',
reupload: 'Opnieuw uploaden',
upload: 'Uploaden',
files: 'Bestanden',
@@ -1303,7 +1303,7 @@ const ko = {
type_text: '텍스트 입력',
email_has_been_sent: '이메일이 전송되었습니다',
processing: '처리 중',
pay_with_strip: '스트라이프로 결제',
pay_with_stripe: '스트라이프로 결제',
reupload: '다시 업로드',
upload: '업로드',
files: '파일',
@@ -1404,7 +1404,7 @@ const ja = {
type_text: 'テキストを入力',
email_has_been_sent: 'メールが送信されました',
processing: '処理中',
pay_with_strip: 'Stripeで支払う',
pay_with_stripe: 'Stripeで支払う',
reupload: '再アップロード',
upload: 'アップロード',
files: 'ファイル',
@@ -123,6 +123,11 @@
v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<label
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
for="initials_text_input"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
@@ -62,7 +62,7 @@
width="22"
/>
<span>
{{ t('pay_with_strip') }}
{{ t('pay_with_stripe') }}
</span>
</button>
</div>
+22 -2
View File
@@ -260,8 +260,12 @@
:style="{ backgroundColor }"
>
<Upload
v-if="sortedDocuments.length && editable && withUploadButton"
v-if="editable && withUploadButton"
v-show="sortedDocuments.length"
ref="upload"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:template-id="template.id"
@success="updateFromUpload"
/>
@@ -297,6 +301,8 @@
v-if="withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:with-google-drive="withGoogleDrive"
@click-google-drive="$refs.upload.openGoogleDriveModal()"
@success="updateFromUpload"
/>
<button
@@ -368,6 +374,8 @@
v-if="withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
@success="updateFromUpload"
/>
<button
@@ -441,6 +449,7 @@
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
@@ -610,6 +619,11 @@ export default {
required: false,
default: true
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withAddPageButton: {
type: Boolean,
required: false,
@@ -766,6 +780,11 @@ export default {
required: false,
default: false
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: {
type: Boolean,
required: false,
@@ -1757,8 +1776,9 @@ export default {
},
onDocumentReplace (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)
if (data.fields) {
@@ -8,8 +8,8 @@
dir="auto"
:contenteditable="editable"
style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block"
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
class="peer outline-none"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@focus="$emit('focus', $event)"
@@ -18,22 +18,19 @@
{{ value }}
</span>
<span
v-if="withRequired"
title="Required"
class="text-red-500 peer-focus:hidden"
@click="focusContenteditable"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
*
<IconPencil
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' }"
:title="t('edit')"
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/>
</span>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:title="t('edit')"
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/>
</div>
</template>
@@ -62,7 +59,12 @@ export default {
required: false,
default: 30
},
withRequired: {
hideIcon: {
type: Boolean,
required: false,
default: true
},
floatIcon: {
type: Boolean,
required: false,
default: false
+20 -5
View File
@@ -12,8 +12,8 @@
:for="inputId"
: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="flex flex-col items-center">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center pointer-events-none">
<IconInnerShadowTop
v-if="isLoading"
class="animate-spin"
@@ -40,6 +40,15 @@
>
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</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>
<form
@@ -62,7 +71,7 @@
<script>
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 {
name: 'FileDropzone',
@@ -71,7 +80,8 @@ export default {
IconCloudUpload,
IconInnerShadowTop,
IconFileSymlink,
IconFiles
IconFiles,
IconBrandGoogleDrive
},
inject: ['baseFetch', 't'],
props: {
@@ -99,6 +109,11 @@ export default {
required: false,
default: true
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
title: {
type: String,
required: false,
@@ -110,7 +125,7 @@ export default {
default: 'image/*, application/pdf, application/zip'
}
},
emits: ['success', 'error', 'loading'],
emits: ['success', 'error', 'loading', 'click-google-drive'],
data () {
return {
isLoading: false,
@@ -52,7 +52,7 @@
>
<span class="py-1 flex items-center">
<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]"
/>
<span>
@@ -100,7 +100,7 @@
class="cursor-pointer text-base-100 flex h-full items-center justify-center"
>
<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]"
/>
</label>
@@ -108,11 +108,11 @@
v-else
ref="label"
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">
<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]"
/>
<Contenteditable
@@ -125,7 +125,7 @@
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</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
:is="editable ? 'IconPlus' : 'IconChevronDown'"
width="18"
@@ -153,7 +153,7 @@
>
<span class="py-1 flex items-center">
<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]"
/>
<span>
@@ -164,7 +164,7 @@
v-if="!compact && submitters.length > 1 && editable"
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
:title="t('up')"
class="relative w-2"
@@ -184,7 +184,7 @@
</div>
<button
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)"
>
<IconTrashX :width="18" />
+105 -2
View File
@@ -208,6 +208,34 @@
</li>
</ul>
</div>
<div
v-if="withFieldsDetection && editable && fields.length < 2"
class="my-2"
>
<button
class="btn w-full"
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
@click="fieldPagesLoaded !== null ? null : detectFields()"
>
<template v-if="fieldPagesLoaded !== null">
<IconInnerShadowTop
width="22"
class="animate-spin"
/>
<span class="hidden md:inline">
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
</span>
</template>
<template v-else>
<IconListSearch width="22" />
<span
class="hidden md:inline"
>
{{ t('autodetect_fields') }}
</span>
</template>
</button>
</div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
@@ -231,7 +259,7 @@
import Field from './field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconListSearch } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
@@ -240,11 +268,13 @@ export default {
Field,
FieldType,
IconCirclePlus,
IconListSearch,
IconInnerShadowTop,
FieldSubmitter,
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
props: {
fields: {
type: Array,
@@ -255,6 +285,11 @@ export default {
required: false,
default: null
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
@@ -331,12 +366,18 @@ export default {
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
data () {
return {
fieldPagesLoaded: null,
defaultFieldsSearch: ''
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
numberOfPages () {
return this.template.documents.reduce((acc, doc) => {
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
}, 0)
},
isShowFieldSearch () {
if (this.withFieldsSearch === false) {
return false
@@ -389,6 +430,68 @@ export default {
this.$emit('set-drag', field)
},
detectFields () {
const fields = []
this.fieldPagesLoaded = 0
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
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) {
alert(data.error)
break
} else if (data.completed) {
this.fieldPagesLoaded = null
this.template.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)
}
}
}
}
}).catch(error => {
console.error('Error in streaming message: ', error)
}).finally(() => {
this.fieldPagesLoaded = null
this.isFieldsLoading = false
})
},
setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', {
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>
+363 -153
View File
@@ -1,4 +1,6 @@
const en = {
view: 'View',
autodetect_fields: 'Autodetect fields',
payment_link: 'Payment link',
strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -176,10 +178,14 @@ const en = {
and: 'and',
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_tour: 'Start Tour'
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
syncing: 'Syncing...'
}
const es = {
view: 'Vista',
payment_link: 'Enlace de pago',
strikeout: 'Tachar',
draw_strikethrough_the_document: 'Dibujar una línea de tachado en el documento',
@@ -357,10 +363,14 @@ const es = {
and: 'y',
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_tour: 'Iniciar guía'
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
}
const it = {
view: 'Vista',
payment_link: 'Link di pagamento',
strikeout: 'Barrato',
draw_strikethrough_the_document: 'Disegna una linea barrata sul documento',
@@ -538,10 +548,14 @@ const it = {
and: 'e',
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_tour: 'Inizia il tour'
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
syncing: 'Sincronizzazione...'
}
const pt = {
view: 'Visualizar',
payment_link: 'Link de pagamento',
strikeout: 'Tachado',
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
@@ -719,94 +733,104 @@ const pt = {
and: 'e',
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_tour: 'Iniciar tour'
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
}
const fr = {
view: 'Voir',
payment_link: 'Lien de paiement',
strikeout: 'Barrer',
draw_strikethrough_the_document: 'Tracer une ligne de suppression sur le document',
strikeout: 'Rature',
draw_strikethrough_the_document: 'Tracer une rature sur le document',
quantity: 'Quantité',
prefillable: 'Pré-remplissable',
signature_id: 'ID de signature',
error_message: 'Message d\'erreur',
prefillable: 'Préremplissable',
signature_id: 'Identifiant de signature',
error_message: "Message d'erreur",
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle',
font: 'Police',
party: 'Partie',
date_signed: 'Date de signature',
method: 'Méthode',
reorder_fields: 'Réorganiser les champs',
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',
one_off: 'Ponctuel',
editable: 'Éditable',
search_field: 'Champ de recherche',
field_not_found: 'Champ non trouvé',
field_not_found: 'Champ introuvable',
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',
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',
heading: 'En-tête',
heading: 'Titre',
validation: 'Validation',
add_blank_page: 'Ajouter une page blanche',
right: 'Droite',
add_condition: 'Ajouter une condition',
center: 'Centre',
with_logo: 'Avec logo',
description: 'Description',
display_title: "Titre d'affichage",
unchecked: 'Non coché',
display_title: 'Afficher le titre',
with_logo: 'Avec logo',
unchecked: 'Décoché',
price: 'Prix',
type_value: 'Saisir une valeur',
equal: 'Égal',
not_equal: 'Non égal',
not_equal: 'Différent',
contains: 'Contient',
signing_date: 'Date de signature',
does_not_contain: 'Ne contient pas',
not_empty: 'Non vide',
empty: 'Vide',
select_field_: 'Sélectionner un champ...',
select_value_: 'Sélectionner une valeur...',
remove_condition: 'Supprimer la condition',
condition: 'Condition',
formula: 'Formule',
edit: 'Éditer',
add_condition: 'Ajouter une condition',
are_you_sure_: 'Êtes-vous sûr ?',
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',
up: 'Haut',
down: 'Bas',
set_signing_date: 'Définir la date de signature',
are_you_sure_: 'Êtes-vous sûr?',
sign_yourself: 'Signez-vous',
checked: 'Coché',
send: 'Envoyer',
remove: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
draw_field_on_the_document: 'Dessiner un champ sur le document',
click_to_upload: 'Cliquez pour télécharger',
any: "N'importe lequel",
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',
uploading: 'Téléchargement en cours',
processing_: 'Traitement en cours...',
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images',
add_documents_or_images: 'Ajoutez des documents ou des images',
add_a_new_document: 'Adicionar um novo documento',
uploading: 'Téléversement en cours',
processing_: 'Traitement...',
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document',
replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer les documents',
required: 'Requis',
clone_and_replace_documents: 'Cloner et remplacer des documents',
required: 'Obligatoire',
default_value: 'Valeur par défaut',
format: 'Format',
read_only: 'Lecture seule',
page: 'Page',
draw_new_area: 'Dessiner une nouvelle zone',
draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages',
add_option: 'Ajouter une option',
option: 'Option',
options: 'Options',
condition: 'Condition',
first_party: 'Première partie',
second_party: 'Deuxième partie',
third_party: 'Troisième partie',
@@ -817,27 +841,27 @@ const fr = {
eighth_party: 'Huitième partie',
ninth_party: 'Neuvième partie',
tenth_party: 'Dixième partie',
eleventh_party: 'Onzième Parti',
twelfth_party: 'Douzième Parti',
thirteenth_party: 'Treizième Parti',
fourteenth_party: 'Quatorzième Parti',
fifteenth_party: 'Quinzième Parti',
sixteenth_party: 'Seizième Parti',
seventeenth_party: 'Dix-septième Parti',
eighteenth_party: 'Dix-huitième Parti',
nineteenth_party: 'Dix-Neuvième Fête',
twentieth_party: 'Vingtième Fête',
eleventh_party: 'Onzième partie',
twelfth_party: 'Douzième partie',
thirteenth_party: 'Treizième partie',
fourteenth_party: 'Quatorzième partie',
fifteenth_party: 'Quinzième partie',
sixteenth_party: 'Seizième partie',
seventeenth_party: 'Dix-septième partie',
eighteenth_party: 'Dix-huitième partie',
nineteenth_party: 'Dix-neuvième partie',
twentieth_party: 'Vingtième partie',
draw: 'Dessiner',
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',
number: 'Nombre',
signature: 'Signature',
initials: 'Initiales',
date: 'Date',
number: 'Numéro',
image: 'Image',
file: 'Fichier',
select: 'Choisir',
select: 'Sélection',
checkbox: 'Coche',
multiple: 'Multiple',
radio: 'Radio',
@@ -845,40 +869,34 @@ const fr = {
stamp: 'Tampon',
payment: 'Paiement',
phone: 'Téléphone',
text_field: 'Champ de Texte',
signature_field: 'Champ de Signature',
initials_field: "Champ d'Initiales",
date_field: 'Champ de Date',
number_field: 'Champ de Numéro',
image_field: "Champ d'Image",
file_field: 'Champ de Fichier',
select_field: 'Champ de Choix',
checkbox_field: 'Champ de Coche',
multiple_field: 'Champ de Choix Multiple',
radio_field: 'Champ de Groupe Radio',
cells_field: 'Champ de Cellules',
stamp_field: 'Champ de Tampon',
payment_field: 'Champ de Paiement',
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',
drag_and_drop_any_other_field_type_on_the_page: 'Glissez et déposez tout autre type de champ sur la page',
text_field: 'Champ de texte',
signature_field: 'Champ de signature',
initials_field: "Champ d'initiales",
date_field: 'Champ de date',
number_field: 'Champ de numéro',
image_field: "Champ d'image",
file_field: 'Champ de fichier',
select_field: 'Champ de choix',
checkbox_field: 'Champ de coche',
multiple_field: 'Champ de choix multiple',
radio_field: 'Champ de groupe radio',
cells_field: 'Champ de cellules',
stamp_field: 'Champ de tampon',
payment_field: 'Champ de paiement',
phone_field: 'Champ de téléphone',
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-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',
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.',
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.',
available_only_in_pro: 'Disponible uniquement en Pro',
failed_to_download_files: 'Échec du téléchargement des 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.',
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 dans la version Pro',
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 supprimez {submitter_name} si ce n'est pas nécessaire.",
draw_field: 'Dessiner le champ {field}',
replace: 'Remplacer',
uploading_: 'Téléchargement en cours...',
add_document: 'Télécharger',
any: 'Tout',
drawn: 'Dessiné',
drawn_or_typed: 'Dessiné ou Tapé',
drawn_or_upload: 'Dessiné ou Téléchargé',
upload: 'Téléverser',
typed: 'Tapé',
uploading_: 'Téléversement...',
add_document: 'Téléverser',
none: 'Aucun',
ssn: 'SSN',
ein: 'EIN',
@@ -889,90 +907,99 @@ const fr = {
numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement',
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',
currency: 'Devise',
save_and_preview: 'Enregistrer et prévisualiser',
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.',
learn_more: 'En savoir plus',
and: 'et',
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_tour: 'Démarrer'
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',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
syncing: 'Synchronisation...'
}
const de = {
view: 'Anzeigen',
payment_link: 'Zahlungslink',
strikeout: 'Streichung',
draw_strikethrough_the_document: 'Ziehe eine Streichung auf das Dokument',
strikeout: 'Durchstreichen',
draw_strikethrough_the_document: 'Durchstreichung im Dokument zeichnen',
quantity: 'Menge',
prefillable: 'Vorausfüllbar',
signature_id: 'Signatur-ID',
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum',
min: 'Min.',
max: 'Max.',
font: 'Schriftart',
party: 'Partei',
method: 'Verfahren',
date_signed: 'Unterzeichnungsdatum',
method: 'Methode',
reorder_fields: 'Felder neu anordnen',
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.',
wiederkehrend: 'Wiederkehrend',
einmalig: 'Einmalig',
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.',
editable: 'Bearbeitbar',
recurrent: 'Wiederkehrend',
one_off: 'Einmalig',
search_field: 'Suchfeld',
field_not_found: 'Feld nicht gefunden',
clear: 'Löschen',
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',
clear: 'Leeren',
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',
heading: 'Überschrift',
validation: 'Validierung',
add_blank_page: 'Leere Seite hinzufügen',
right: 'Rechts',
add_condition: 'Bedingung hinzufügen',
center: 'Zentriert',
with_logo: 'Mit Logo',
description: 'Beschreibung',
display_title: 'Anzeigetitel',
unchecked: 'Nicht überprüft',
with_logo: 'Mit Logo',
unchecked: 'Nicht markiert',
price: 'Preis',
type_value: 'Wert eingeben',
equal: 'Gleich',
not_equal: 'Ungleich',
contains: 'Enthält',
does_not_contain: 'Enthält nicht',
not_empty: 'Nicht leer',
empty: 'Leer',
signing_date: 'Signaturdatum',
select_field_: 'Feld auswählen...',
select_value_: 'Wert auswählen...',
remove_condition: 'Bedingung entfernen',
condition: 'Bedingung',
formula: 'Formel',
add_condition: 'Bedingung hinzufügen',
are_you_sure_: 'Sind Sie sicher?',
sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum',
send: 'Senden',
remove: 'Entfernen',
edit: 'Bearbeiten',
settings: 'Einstellungen',
up: 'Nach oben',
down: 'Nach unten',
set_signing_date: 'Unterschriftdatum festlegen',
are_you_sure_: 'Bist du sicher?',
sign_yourself: 'Unterschreiben Sie selbst',
checked: 'Ausgewählt',
send: 'Senden',
remove: 'Entfernen',
checked: 'Markiert',
save: 'Speichern',
cancel: 'Abbrechen',
draw_field_on_the_document: 'Ein Feld auf dem Dokument zeichnen',
click_to_upload: 'Klicken Sie zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen',
uploading: 'Hochladen',
processing_: 'Verarbeitung...',
any: 'Beliebig',
drawn: 'Gezeichnet',
drawn_or_typed: 'Gezeichnet oder getippt',
drawn_or_upload: 'Gezeichnet oder hochgeladen',
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_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen',
@@ -983,11 +1010,12 @@ const de = {
format: 'Format',
read_only: 'Nur lesen',
page: 'Seite',
draw_new_area: 'Neuen Bereich zeichnen',
draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren',
add_option: 'Option hinzufügen',
option: 'Option',
options: 'Options',
options: 'Optionen',
condition: 'Bedingung',
first_party: 'Erste Partei',
second_party: 'Zweite Partei',
third_party: 'Dritte Partei',
@@ -1006,21 +1034,21 @@ const de = {
sixteenth_party: 'Sechzehnte Partei',
seventeenth_party: 'Siebzehnte Partei',
eighteenth_party: 'Achtzehnte Partei',
nineteenth_party: 'Neunzehnte Party',
twentieth_party: 'Zwanzigste Party',
nineteenth_party: 'Neunzehnte Partei',
twentieth_party: 'Zwanzigste Partei',
draw: 'Zeichnen',
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',
number: 'Zahl',
signature: 'Unterschrift',
initials: 'Initialen',
date: 'Datum',
number: 'Nummer',
image: 'Bild',
file: 'Datei',
select: 'Auswählen',
select: 'Auswahl',
checkbox: 'Checkbox',
multiple: 'Mehrere',
multiple: 'Mehrfach',
radio: 'Radio',
cells: 'Zellen',
stamp: 'Stempel',
@@ -1034,32 +1062,26 @@ const de = {
image_field: 'Bildfeld',
file_field: 'Dateifeld',
select_field: 'Auswahlfeld',
checkbox_field: 'Checkbox-Feld',
multiple_field: 'Mehrfach-Auswahlfeld',
radio_field: 'Radio-Gruppenfeld',
checkbox_field: 'Checkboxfeld',
multiple_field: 'Mehrfachauswahlfeld',
radio_field: 'Radiogruppe',
cells_field: 'Zellenfeld',
stamp_field: 'Stempelfeld',
payment_field: 'Zahlungsfeld',
phone_field: 'Telefonfeld',
draw_a_text_field_on_the_page_with_a_mouse: 'Textfeld auf der Seite mit der Maus zeichnen',
drag_and_drop_any_other_field_type_on_the_page: 'Ziehe und lasse einen anderen Feldtyp auf die Seite fallen',
click_on_the_field_type_above_to_start_drawing_it: 'Klicke auf den Feldtyp oben, um mit dem Zeichnen zu beginnen',
please_draw_fields_to_prepare_the_document: 'Bitte zeichne Felder, um das Dokument vorzubereiten.',
only_pdf_and_images_are_supported: 'Nur PDFs 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.',
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: 'Jeden anderen Feldtyp auf die Seite ziehen und ablegen',
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 zeichnen Sie Felder, um das Dokument vorzubereiten.',
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: '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',
failed_to_download_files: 'Fehler beim Herunterladen der Dateien',
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.',
draw_field: 'Feld {field} zeichnen',
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ügen Sie Felder für {submitter_name} hinzu oder entfernen Sie {submitter_name}, falls nicht benötigt.',
draw_field: '{field}-Feld zeichnen',
replace: 'Ersetzen',
uploading_: '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',
ssn: 'SSN',
ein: 'EIN',
@@ -1069,19 +1091,207 @@ const de = {
custom: 'Benutzerdefiniert',
numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben',
regexp_validation: 'Regulärer Ausdruck Überprüfung',
enter_pdf_password: 'Geben Sie das PDF-Passwort ein',
regexp_validation: 'RegExp-Validierung',
enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort',
currency: 'Währung',
save_and_preview: 'Speichern und Vorschau',
preferences: 'Einstellungen',
preferences: 'Voreinstellungen',
available_in_pro: 'In Pro verfügbar',
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
learn_more: 'Erfahren Sie mehr',
learn_more: 'Mehr erfahren',
and: 'und',
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_tour: 'Starten'
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: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
syncing: 'Synchronisiere...'
}
export { en, es, it, pt, fr, de }
const nl = {
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>
<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">
<div class="flex items-center space-x-2">
<component
+18 -8
View File
@@ -9,7 +9,7 @@
loading="lazy"
>
<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)"
>
<div
@@ -23,7 +23,7 @@
>
<div>
<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"
>
<IconRouteAltLeft
@@ -48,7 +48,7 @@
>
<div>
<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"
@click.stop="$emit('remove', item)"
>
@@ -63,7 +63,7 @@
class="tooltip tooltip-left before:text-xs"
>
<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)"
>
<IconSortDescending2
@@ -75,14 +75,14 @@
</span>
<template v-if="withArrows">
<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"
@click.stop="$emit('up', item)"
>
&uarr;
</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"
@click.stop="$emit('down', item)"
>
@@ -94,12 +94,20 @@
</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
:model-value="item.name"
:icon-width="16"
:icon-inline="true"
:float-icon="!item.google_drive_file_id"
:hide-icon="!item.google_drive_file_id"
:editable="editable"
style="max-width: 95%"
class="mx-auto"
@update:model-value="onUpdateName"
/>
@@ -123,6 +131,7 @@ import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
import Field from './field'
import FieldType from './field_type'
@@ -133,6 +142,7 @@ export default {
IconRouteAltLeft,
ConditionsModal,
ReplaceButton,
GoogleDriveDocumentSettings,
IconSortDescending2
},
inject: ['t'],
+231 -24
View File
@@ -3,29 +3,155 @@
<label
id="add_document_button"
: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 }"
>
<IconInnerShadowTop
v-if="isLoading"
width="20"
class="animate-spin"
/>
<IconUpload
v-else
width="20"
/>
<span v-if="isLoading">
{{ t('uploading_') }}
</span>
<span v-else>
{{ t('add_document') }}
</span>
<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
v-if="isLoading"
width="20"
class="animate-spin"
/>
<IconUpload
v-else
width="20"
/>
<span v-if="isLoading">
{{ t('uploading_') }}
</span>
<span
v-else
class="mr-1 whitespace-nowrap truncate"
>
{{ t('add_document') }}
</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>
<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
ref="form"
class="hidden"
>
<input
v-for="file in googleDriveFiles"
:key="file.id"
name="google_drive_file_ids[]"
:value="file.id"
>
<input
:id="inputId"
ref="input"
@@ -40,20 +166,32 @@
</template>
<script>
import { IconUpload, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
export default {
name: 'DocumentsUpload',
components: {
IconUpload,
IconInnerShadowTop
IconInnerShadowTop,
IconChevronDown,
IconBrandGoogleDrive
},
inject: ['baseFetch', 't'],
inject: ['baseFetch', 't', 'backgroundColor'],
props: {
templateId: {
type: [Number, String],
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
acceptFileTypes: {
type: String,
required: false,
@@ -63,22 +201,85 @@ export default {
emits: ['success', 'error'],
data () {
return {
isLoading: false
isLoading: false,
isConnectGoogleDriveClicked: false,
isLoadingGoogleDrive: false,
googleDriveFiles: [],
showGoogleDriveModal: false,
showGoogleDriveOauthButton: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
queryParams () {
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: {
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.baseFetch(this.uploadUrl, {
return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form)
@@ -111,6 +312,10 @@ export default {
this.isLoading = false
}
})
} else if (data.status === 'google_drive_file_missing') {
alert(data.error)
this.$emit('error', data.error)
this.isLoading = false
} else {
this.$emit('error', data.error)
this.isLoading = false
@@ -122,6 +327,8 @@ export default {
this.isLoading = false
})
}
return resp
}).catch(() => {
this.isLoading = false
})
+1 -1
View File
@@ -11,7 +11,7 @@ class GeneratePreviewImagesJob
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
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)
end
end
+15 -7
View File
@@ -93,18 +93,26 @@ class ProcessSubmitterCompletionJob
def enqueue_completed_emails(submitter)
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 &&
submitter.template&.preferences&.dig('completed_notification_email_enabled') != false
if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
(!template || template.preferences['completed_notification_email_enabled'] != false)
user_submitter = submission.submitters.find { |s| s.email == user.email }
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!
true
end
build_bcc_addresses(submission).each do |to|
next if is_sent_to_user && to == user.email
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
end
end
+1 -1
View File
@@ -50,7 +50,7 @@ class User < ApplicationRecord
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
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 :initials
+1 -1
View File
@@ -15,7 +15,7 @@
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<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>
<% end %>
</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

+6
View File
@@ -19,6 +19,12 @@
<%= stylesheet_pack_tag 'application', media: 'all' %>
</head>
<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="drawer"></turbo-frame>
<%= render 'shared/navbar' %>
+3 -1
View File
@@ -9,6 +9,8 @@
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
<% if disabled_with %>
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
<% end %>
</span>
</span>
+3 -2
View File
@@ -1,5 +1,6 @@
<% 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">
@@ -11,7 +12,7 @@
<% 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">
<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>
@@ -38,7 +39,7 @@
<% 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 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">
+2 -1
View File
@@ -1,10 +1,11 @@
<% align = field.dig('preferences', 'align') %>
<% valign = field.dig('preferences', 'valign') %>
<% color = field.dig('preferences', 'color') %>
<% bg_color = field.dig('preferences', 'background') %>
<% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
@@ -38,14 +38,6 @@
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
</toggle-submit>
<% end %>
<% if Docuseal.multitenant? %>
<div class="divider uppercase"><%= t('or') %></div>
<% end %>
<% end %>
<% if Docuseal.multitenant? %>
<div>
<%= link_to t('create_free_account'), registration_path, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
@@ -6,7 +6,7 @@
<%= svg_icon('cloud_upload', class: 'w-9 h-9') %>
</span>
<div class="font-medium mb-1">
<%= t('upload_new_document') %>
<%= t('upload_a_new_document') %>
</div>
</span>
<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">
<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">
<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>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<div class="font-medium mb-1">
<%= t('upload_new_document') %>
<%= t('upload_a_new_document') %>
</div>
<div class="text-xs">
<div class="text-sm">
<%= t('click_to_upload_or_drag_and_drop_html') %>
</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 data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= 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>
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
<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>
<% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %>
<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>
<% end %>
</div>
@@ -210,12 +210,12 @@
</div>
<% if !submission.archived_at? && !template&.archived_at? %>
<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>
<% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %>
<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>
<% end %>
</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' %>
<% 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 %>
<%= 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-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 %>
<%= render 'templates/file_form' %>
<% end %>
+2 -2
View File
@@ -25,8 +25,8 @@ module DocuSeal
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
es it de fr pl uk cs pt he nl ar ko ja]
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 nl pl uk cs pt he ar ko ja]
config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
+1686 -815
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -100,6 +100,9 @@ Rails.application.routes.draw do
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[create], controller: 'template_documents'
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
if !Docuseal.multitenant? || Docuseal.demo?
resources :detect_fields, only: %i[create], controller: 'templates_detect_fields'
end
resources :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create]
+1 -1
View File
@@ -78,7 +78,7 @@ module Docuseal
@fulltext_search =
if SearchEntry.table_exists?
Docuseal.multitenant? ? true : AccountConfig.exists?(key: :fulltext_search, value: true)
Docuseal.multitenant? || AccountConfig.exists?(key: :fulltext_search, value: true)
else
false
end
+160
View File
@@ -25,6 +25,8 @@ class Pdfium
typedef :pointer, :FPDF_BITMAP
typedef :pointer, :FPDF_FORMHANDLE
typedef :pointer, :FPDF_TEXTPAGE
typedef :pointer, :FPDF_PAGEOBJECT
typedef :pointer, :FPDF_PATHSEGMENT
MAX_SIZE = 32_767
@@ -37,6 +39,9 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800
TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true)
LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true)
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int,
@@ -77,6 +82,37 @@ class Pdfium
attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void
attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int
attach_function :FPDFText_GetText, %i[FPDF_TEXTPAGE int int pointer], :int
attach_function :FPDFText_GetUnicode, %i[FPDF_TEXTPAGE int], :uint
attach_function :FPDFText_GetCharBox, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
attach_function :FPDFText_GetCharOrigin, %i[FPDF_TEXTPAGE int pointer pointer], :int
attach_function :FPDFText_GetCharIndexAtPos, %i[FPDF_TEXTPAGE double double double double], :int
attach_function :FPDFText_CountRects, %i[FPDF_TEXTPAGE int int], :int
attach_function :FPDFText_GetRect, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
attach_function :FPDFText_GetFontSize, %i[FPDF_TEXTPAGE int], :double
# Page object functions for extracting paths/lines
attach_function :FPDFPage_CountObjects, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetObject, %i[FPDF_PAGE int], :FPDF_PAGEOBJECT
attach_function :FPDFPageObj_GetType, [:FPDF_PAGEOBJECT], :int
attach_function :FPDFPageObj_GetBounds, %i[FPDF_PAGEOBJECT pointer pointer pointer pointer], :int
attach_function :FPDFPath_CountSegments, [:FPDF_PAGEOBJECT], :int
attach_function :FPDFPath_GetPathSegment, %i[FPDF_PAGEOBJECT int], :FPDF_PATHSEGMENT
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
# Page object types
FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1
FPDF_PAGEOBJ_PATH = 2
FPDF_PAGEOBJ_IMAGE = 3
FPDF_PAGEOBJ_SHADING = 4
FPDF_PAGEOBJ_FORM = 5
# Path segment types
FPDF_SEGMENT_UNKNOWN = -1
FPDF_SEGMENT_LINETO = 0
FPDF_SEGMENT_BEZIERTO = 1
FPDF_SEGMENT_MOVETO = 2
typedef :int, :FPDF_BOOL
typedef :pointer, :IPDF_JSPLATFORM
@@ -157,6 +193,7 @@ class Pdfium
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
end
# rubocop:disable Metrics
class Document
attr_reader :document_ptr, :form_handle
@@ -386,6 +423,128 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def text_nodes
return @text_nodes if @text_nodes
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
char_count = Pdfium.FPDFText_CountChars(text_page)
@text_nodes = []
return @text_nodes if char_count.zero?
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
return if closed?
@@ -445,4 +604,5 @@ class Pdfium
at_exit do
cleanup_library
end
# rubocop:enable Metrics
end
+1 -1
View File
@@ -56,7 +56,7 @@ module SearchEntries
end
[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)]
else
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
MANUAL_ATTEMPT = 99_999
AUTOMATED_RETRY_RANGE = 1..MANUAL_ATTEMPT - 1
AUTOMATED_RETRY_RANGE = 1..(MANUAL_ATTEMPT - 1)
HttpsError = Class.new(StandardError)
LocalhostError = Class.new(StandardError)
+1 -1
View File
@@ -481,7 +481,7 @@ module Submissions
def select_attachments(submitter)
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|
is_more_than_two_images &&
+41 -21
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
fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
bg_color = field.dig('preferences', 'background').to_s.delete_prefix('#').presence
font_name = field.dig('preferences', 'font')
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
@@ -289,6 +290,13 @@ module Submissions
with_signature_id = field['preferences']['with_signature_id']
end
if bg_color.present?
canvas.fill_color(bg_color)
.rectangle(area['x'] * width, height - (area['y'] * height) - (area['h'] * height),
area['w'] * width, area['h'] * height)
.fill
end
case field_type
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value }
@@ -311,8 +319,8 @@ module Submissions
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
if with_signature_id_reason
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
else
@@ -321,9 +329,9 @@ module Submissions
end
end
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: (font_size / 1.8).to_i)
base_font_size = (font_size / 1.8).to_i
result = nil
if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
area_x = area['x'] * width
@@ -344,12 +352,10 @@ module Submissions
id_string = "ID: #{attachment.uuid}".upcase
while true
text = HexaPDF::Layout::TextFragment.create(id_string,
font:,
font_size: (font_size / 1.8).to_i)
loop do
text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
result = layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
result = layouter.fit([text], half_width, base_font_size / 0.65)
break if result.status == :success
@@ -358,25 +364,39 @@ module Submissions
break if id_string.length < 8
end
string = [id_string, reason_string].join("\n")
loop do
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
result = layouter.fit([text], half_width, area_h)
break if result.status == :success
base_font_size *= 0.9
break if base_font_size < 2
end
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
text_x = area_x + half_width
text_y = height - area_y
reason_result = layouter.fit([reason_text], half_width, height)
layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
layouter.fit([reason_text], half_width, reason_result.lines.sum(&:height))
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y - TEXT_TOP_MARGIN - result.lines.sum(&:height))
layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
else
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: base_font_size)
id_string = "ID: #{attachment.uuid}".upcase
loop do
text = HexaPDF::Layout::TextFragment.create(id_string,
font:,
font_size: (font_size / 1.8).to_i)
font_size: base_font_size)
result = layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
result = layouter.fit([text], area['w'] * width, base_font_size / 0.65)
break if result.status == :success
@@ -395,7 +415,7 @@ module Submissions
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
layouter.fit([text], area['w'] * width, base_font_size / 0.65)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
@@ -833,7 +853,7 @@ module Submissions
def find_last_submitter(submission, submitter: nil)
submission.submitters
.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)
end
+19 -2
View File
@@ -13,6 +13,17 @@ module Submitters
UnableToSendCode = Class.new(StandardError)
InvalidOtp = Class.new(StandardError)
MaliciousFileExtension = Class.new(StandardError)
DANGEROUS_EXTENSIONS = Set.new(%w[
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
hta cpl jar app deb rpm dmg pkg mpkg dll so dylib sys
inf reg ps1 psm1 psd1 ps1xml psc1 pssc bat cmd vb vba
sh bash zsh fish run out bin elf gadget workflow lnk scf
url desktop application action workflow apk ipa xap appx
appxbundle msix msixbundle diagcab diagpkg cpl msc ocx
drv scr ins isp mst paf prf shb shs slk ws wsc inf1 inf2
].freeze)
module_function
@@ -56,7 +67,7 @@ module Submitters
end
[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
if terms.size > 1
@@ -100,7 +111,7 @@ module Submitters
end
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|
is_more_than_two_images &&
@@ -111,6 +122,12 @@ module Submitters
def create_attachment!(submitter, params)
blob =
if (file = params[:file])
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if DANGEROUS_EXTENSIONS.include?(extension)
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
+1 -1
View File
@@ -94,7 +94,7 @@ module Submitters
end
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name
if submitter_name && !for_submitter
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName,
+270
View File
@@ -0,0 +1,270 @@
# frozen_string_literal: true
module Templates
module DetectFields
module_function
TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true)
# rubocop:disable Metrics
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields,
nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, &)
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:, padding:, &)
end
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: true,
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
end
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil)
doc = Pdfium::Document.open_bytes(io.read)
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: 0.05, 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.filter_map do |f|
next if f.confidence < confidence
{
uuid: SecureRandom.uuid,
type: f.type,
required: true,
preferences: {},
areas: [{
x: f.x, y: f.y,
w: f.w, h: f.h,
page: page_number,
attachment_uuid: attachment&.uuid
}]
}
end
yield [attachment&.uuid, page_number, fields] if block_given?
fields
ensure
page.close
end
ensure
doc.close
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
end
end
+340
View File
@@ -0,0 +1,340 @@
# 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 })
boxes = Numo::SFloat.cast(outputs['dets'])[0, true, true]
logits = Numo::SFloat.cast(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 })
boxes = Numo::SFloat.cast(outputs['dets'])[0, true, true]
logits = Numo::SFloat.cast(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)
Array.new(detections[:xyxy].shape[0]) 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
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
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
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
+1 -1
View File
@@ -2,7 +2,7 @@
module TextUtils
RTL_REGEXP = /[\p{Hebrew}\p{Arabic}]/
MASK_REGEXP = /[^\s\-_\[\]\(\)\+\?\.\,]/
MASK_REGEXP = /[^\s\-_\[\]()+?.,]/
MASK_SYMBOL = 'X'
TRANSLITERATIONS =