Compare commits

...
295 Commits
Author SHA1 Message Date
Alex Turchyn da44d51a3b Merge from docusealco/wip 2026-06-15 13:37:25 +03:00
Pete Matsyburka 73ce10b26a add cleanup document helper 2026-06-15 12:05:50 +03:00
Pete Matsyburka 13874b8830 add documents editor 2026-06-15 10:21:23 +03:00
Pete Matsyburka ac66809a05 fix date tz 2026-06-12 14:03:20 +03:00
Pete Matsyburka f5229d5dec fix zoom 2026-06-12 12:47:23 +03:00
Pete Matsyburka 222291d11d update gem 2026-06-09 21:51:06 +03:00
Pete Matsyburka 28f47e1093 handle template archived 2026-06-09 19:49:12 +03:00
Pete Matsyburka 7784346a97 update gem 2026-06-09 12:11:16 +03:00
Alex Turchyn ed46af8418 Merge from docusealco/wip 2026-06-08 11:56:14 +03:00
Pete Matsyburka 095f25a6e9 adjust image type 2026-06-08 09:31:38 +03:00
Pete Matsyburka 3cdab30dc8 remove oj 2026-06-05 16:55:55 +03:00
Pete Matsyburka 8e462047ba fix upload callback 2026-06-05 14:45:22 +03:00
Pete Matsyburka b7f8b65d9d adjust button style 2026-06-05 09:55:21 +03:00
Alex Turchyn da21c37254 add Google Drive replace option 2026-06-05 09:55:21 +03:00
Pete Matsyburka a89aef4a89 adjust zoom 2026-06-04 14:39:02 +03:00
Alex Turchyn dd6516fd11 add cmd+scroll zooming to template builder 2026-06-04 08:20:38 +03:00
Pete Matsyburka 344a3ce198 adjust sanitize 2026-06-03 13:24:08 +03:00
Pete Matsyburka 62f8283587 adjust html to text 2026-06-02 13:44:52 +03:00
Alex Turchyn 1f89accac3 Merge from docusealco/wip 2026-06-01 14:58:06 +03:00
Pete Matsyburka 9fcaef4cf7 use new_from_memory_copy 2026-05-31 14:28:35 +03:00
Pete Matsyburka d5738a0631 disable variant_processor 2026-05-31 12:51:52 +03:00
Pete Matsyburka 8bf7a1f95a adjust image detect fields 2026-05-31 09:20:51 +03:00
Pete Matsyburka 04cf36891e fix complete button press enter 2026-05-31 09:09:57 +03:00
Pete Matsyburka b2d9948c30 remove active storage analyzers 2026-05-31 08:06:52 +03:00
Pete Matsyburka a2c9ac1707 remove image_processing 2026-05-30 21:37:00 +03:00
Pete Matsyburka 46a6bd0108 fix stamp 2026-05-30 12:04:23 +03:00
Pete Matsyburka 5f069e7a40 use load vips 2026-05-30 07:49:40 +03:00
Pete Matsyburka ff57e5c6ae fix pdfa 2026-05-29 15:31:32 +03:00
Alex Turchyn f65b6e2d76 add confirm prompt for template upload via URL 2026-05-29 09:29:21 +03:00
Pete Matsyburka 58fd180ae0 update gem 2026-05-29 08:04:53 +03:00
Pete Matsyburka cd6503c4c3 adjust image size 2026-05-28 15:37:20 +03:00
Pete Matsyburka e8b36c2b6d update gh stars 2026-05-28 14:38:34 +03:00
Pete Matsyburka 51743f1359 fix spec 2026-05-28 10:18:45 +03:00
Pete Matsyburka 504c42646b refactor template builder data 2026-05-28 10:06:47 +03:00
Pete Matsyburka c61e84d1b4 adjust archive 2026-05-28 08:40:05 +03:00
Pete Matsyburka b8ab01c46c add dynamic documents to template response 2026-05-28 08:29:10 +03:00
Pete Matsyburka 9558060bde check resubmit config 2026-05-28 08:29:09 +03:00
Pete Matsyburka 0741c879f1 use expires_at with file links 2026-05-27 22:19:18 +03:00
Pete Matsyburka 2e6dd2867e file url ttl env var 2026-05-26 21:54:59 +03:00
Pete Matsyburka 9f3fcccb61 optimize build 2026-05-26 16:50:10 +03:00
Pete Matsyburka ae08d7abad adjust opacity_layer 2026-05-26 13:11:15 +03:00
Pete Matsyburka 16808a7ec6 use pngload_buffer 2026-05-26 13:11:15 +03:00
Pete Matsyburka b1dfcd6283 fix email typo 2026-05-26 13:11:15 +03:00
Pete Matsyburka 369f4c0f08 sync prefillable field changes 2026-05-26 13:11:15 +03:00
Pete Matsyburka 827de03579 fix result generatrion 2026-05-26 08:57:30 +03:00
Alex Turchyn 0c7f4e5b43 fix sidekiq embedded redis connection 2026-05-25 21:52:34 +03:00
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
Alex Turchyn 60082655d4 Merge from docusealco/wip 2026-05-18 13:45:07 +03:00
Pete Matsyburka 7fe56941fd optimize pdf images 2026-05-17 19:55:03 +03:00
Pete Matsyburka abd498dd33 adjust validation message 2026-05-16 08:15:02 +03:00
Pete Matsyburka 99ca0136ed add percent format 2026-05-16 08:15:02 +03:00
Pete Matsyburka e52830c9b4 add authorization checks 2026-05-16 08:15:01 +03:00
Pete Matsyburka 755decca27 adjust capture revision 2026-05-15 17:50:18 +03:00
Pete Matsyburka 41604008d1 clamp area box 2026-05-15 17:20:29 +03:00
Pete Matsyburka 5bddce8969 fix i18n 2026-05-14 12:16:36 +03:00
Pete Matsyburka b25503f141 fix i18n 2026-05-13 10:08:13 +03:00
Pete Matsyburka bdf1850448 resend emails 2026-05-13 09:59:06 +03:00
Pete Matsyburka 230d3ccf69 fix with download config 2026-05-13 08:52:41 +03:00
Pete Matsyburka e378025a2d fix refactor complete button 2026-05-12 22:24:09 +03:00
Alex Turchyn 04129ded90 add complete button to signing form header 2026-05-12 19:19:49 +03:00
Pete Matsyburka a7891f89f8 do not override custom webhook header 2026-05-11 17:13:26 +03:00
Pete Matsyburka 37d4a8e834 update gem 2026-05-11 14:06:17 +03:00
Alex Turchyn 528a1216f8 Merge from docusealco/wip 2026-05-11 13:52:58 +03:00
Pete Matsyburka f2479bd259 adjust open revisions 2026-05-11 11:11:16 +03:00
Pete Matsyburka 135f0826d2 fix style 2026-05-09 21:17:18 +03:00
Pete Matsyburka 5710f0177b update gh stars 2026-05-09 19:25:42 +03:00
Pete Matsyburka 1a3a0528ba fix i18n 2026-05-09 10:34:20 +03:00
Pete Matsyburka 59793ff374 adjust builder 2026-05-08 19:35:46 +03:00
Pete Matsyburka 3c4ed42419 fix i18n 2026-05-08 18:08:35 +03:00
Pete Matsyburka 6a17b61550 update gem 2026-05-08 16:25:11 +03:00
Pete Matsyburka 76659497b7 typos 2026-05-08 16:12:09 +03:00
Pete Matsyburka ee50c957cb update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka a9dd200919 add pdfium rotate 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1fa953104a update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 50eb5b070e fix typo 2026-05-08 15:43:34 +03:00
Pete Matsyburka 10fd624bec add revisions 2026-05-08 15:43:34 +03:00
Alex Turchyn 04ec2f8260 allow permanent delete submissions in archived templates 2026-05-08 15:43:34 +03:00
Pete Matsyburka d828d79574 improve log 2026-05-08 15:43:34 +03:00
Pete Matsyburka 01dd3fefe5 retry webhooks in test mode 2026-05-08 15:43:34 +03:00
Pete Matsyburka 45ae954c0c add hmac webhook secret 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1304849b55 update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 0875faa079 fix date preview 2026-05-08 15:43:34 +03:00
Pete Matsyburka 3aa15d6ea6 update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka 6557329e97 gh 13k 2026-05-08 15:43:34 +03:00
Pete Matsyburka be27ce4161 Merge pull request #666 from aqilaziz/docs-fix-readme-typos
docs: fix README typos
2026-05-08 14:02:33 +03:00
aqilaziz a3391a970e docs: fix README typos 2026-05-08 05:23:28 +07:00
Alex Turchyn 744d45d2c5 Merge from docusealco/wip 2026-05-04 13:55:19 +03:00
Pete Matsyburka 310b16f54a use mcp source 2026-05-04 09:42:51 +03:00
Pete Matsyburka 6d3ad4a869 update gh stars 2026-05-03 18:37:56 +03:00
Alex Turchyn 1ce2448820 update create_template and send_documents annotations 2026-05-03 17:43:22 +03:00
Pete Matsyburka 07b4ada5be optimize edit json 2026-05-03 16:32:37 +03:00
Pete Matsyburka f70bf530ba hide qr link 2026-05-03 08:19:58 +03:00
Pete Matsyburka a26aa0f411 remove log 2026-05-03 07:27:24 +03:00
Pete Matsyburka 584577c21f fix ed.jp email typo 2026-05-01 13:50:17 +03:00
Pete Matsyburka 1503375eed respond to HEAD file proxy 2026-04-30 17:55:13 +03:00
Pete Matsyburka 2dec099f20 fix signature canvas minimize 2026-04-30 14:51:24 +03:00
Pete Matsyburka bf6eed4ea2 fix canvas error 2026-04-30 14:42:22 +03:00
Pete Matsyburka e0dc33cfb4 fix email domain search 2026-04-30 11:27:17 +03:00
Pete Matsyburka 9c9dc27537 fix new area order 2026-04-30 11:13:35 +03:00
Pete Matsyburka 7305637b8c sort appears on 2026-04-30 10:32:51 +03:00
Pete Matsyburka a6e22adf2e fix preview 2026-04-29 18:00:47 +03:00
Pete Matsyburka 809c6b1aa8 fix modal open 2026-04-29 10:27:02 +03:00
Pete Matsyburka 1536ded2a6 redis error log 2026-04-28 19:30:51 +03:00
Pete Matsyburka 76d54971ce fix test mode clone 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6dec56198c add require email 2fa to submitter update 2026-04-28 11:58:17 +03:00
Pete Matsyburka d96d252df3 use post for test mode 2026-04-28 11:58:17 +03:00
Pete Matsyburka 3fca76a716 change state param 2026-04-28 11:58:17 +03:00
Pete Matsyburka a184fa11f1 move auth links 2026-04-28 11:58:17 +03:00
Pete Matsyburka e9c0b6a703 rate limit 2026-04-28 11:58:17 +03:00
Pete Matsyburka ec7df08496 adjust send submission email 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6627d7eac5 fix erblint 2026-04-27 15:34:57 +03:00
Pete Matsyburka 1f8945d710 fix expired filter 2026-04-27 15:31:29 +03:00
Alex Turchyn daaa289a5c Merge from docusealco/wip 2026-04-27 13:43:34 +03:00
Pete Matsyburka 81a6a48d70 validate color 2026-04-26 15:13:19 +03:00
Pete Matsyburka b93c7cd261 add has_many document_metadata 2026-04-26 10:24:20 +03:00
Pete Matsyburka 570482e12d rename error 2026-04-25 12:39:26 +03:00
Pete Matsyburka 39407557f2 adjust page preview 2026-04-25 09:01:53 +03:00
Pete Matsyburka 3b040d558d adjust compression 2026-04-24 18:39:19 +03:00
Alex Turchyn 56ba3c2a52 add custom classes 2026-04-24 17:01:33 +03:00
Pete Matsyburka c7afe33c72 add time formats 2026-04-24 14:01:28 +03:00
Pete Matsyburka 82500c2d7d update dep 2026-04-24 10:56:19 +03:00
Pete Matsyburka fb1c1f117b remove unused const 2026-04-24 10:56:19 +03:00
Pete Matsyburka 7c6a7513fd add with custom field tab 2026-04-24 09:59:34 +03:00
Pete Matsyburka 6d13119ee0 input mode condition and formula 2026-04-22 10:58:50 +03:00
Pete Matsyburka 1e2c752937 detect existing fields 2026-04-22 10:27:18 +03:00
Pete Matsyburka e82faf8320 queue page field detection 2026-04-22 07:43:45 +03:00
Pete Matsyburka 4adb1001b0 add rotate incremental config 2026-04-21 12:35:00 +03:00
Pete Matsyburka 992a1b26c0 adjust tooltip position 2026-04-20 20:18:07 +03:00
Pete Matsyburka c07bc6687c add metadata param 2026-04-20 15:43:41 +03:00
Alex Turchyn eea44bda34 Merge from docusealco/wip 2026-04-20 13:57:33 +03:00
Pete Matsyburka 7cdf263da1 add screen reader mode 2026-04-19 20:18:52 +03:00
Pete Matsyburka ee65a5693c improve accessibility 2026-04-19 10:08:16 +03:00
Pete Matsyburka 0af6ccf35f pipeline field detection 2026-04-19 09:01:08 +03:00
Pete Matsyburka 888f1ec6df adjust mcp 2026-04-18 08:33:42 +03:00
Pete Matsyburka c95a8616ac deduplicate submitter uuids 2026-04-17 22:01:03 +03:00
Pete Matsyburka 5ea6289b7a fix test mode modal 2026-04-16 09:00:43 +03:00
Pete Matsyburka d4a79ca5db adjust dynamic editor 2026-04-15 10:06:41 +03:00
Pete Matsyburka 70015ce1c4 adjust dynamic editor 2026-04-14 14:14:25 +03:00
Pete Matsyburka 6b85c28944 add error message 2026-04-14 10:51:12 +03:00
Pete Matsyburka 6c289cf273 optimize build 2026-04-14 09:37:27 +03:00
Pete Matsyburka a64bc3c618 change button style 2026-04-14 09:21:39 +03:00
Pete Matsyburka 46cf1e3067 add log 2026-04-14 09:13:33 +03:00
Pete Matsyburka 565e1eb2bc adjust validation 2026-04-13 19:55:07 +03:00
Pete Matsyburka e689687805 fix erblint 2026-04-13 19:21:24 +03:00
Pete Matsyburka 3c3b61fb47 adjust reason field 2026-04-13 19:18:55 +03:00
Pete Matsyburka 1355d350c5 fix xlsx boolean value 2026-04-13 18:13:38 +03:00
Pete Matsyburka fda911e178 add submitter field validation 2026-04-13 16:32:03 +03:00
Pete Matsyburka f995e1864c add shared link qr code 2026-04-13 13:59:00 +03:00
Alex Turchyn 911e55ccc3 Merge from docusealco/wip 2026-04-13 13:25:16 +03:00
Pete Matsyburka 41fedfcc40 show verification error 2026-04-12 13:30:13 +03:00
Pete Matsyburka 2f9fc95af3 remove unused module 2026-04-11 18:13:25 +03:00
Pete Matsyburka 11b0b16ca7 prefill signature client side 2026-04-11 18:13:25 +03:00
Pete Matsyburka f8cb7ffdab remove mathjs 2026-04-11 18:13:23 +03:00
Pete Matsyburka 8b59c0aaa0 adjust normalize values 2026-04-10 15:20:35 +03:00
Pete Matsyburka bb2fb7a0c2 refactor download 2026-04-10 12:54:48 +03:00
Pete Matsyburka 8f8b36617a fix first party download from preview 2026-04-10 11:15:16 +03:00
Pete Matsyburka ff53436fd4 fix dynamic editor layout 2026-04-08 17:18:38 +03:00
Pete Matsyburka cf09a4f733 update gem 2026-04-08 12:24:31 +03:00
Pete Matsyburka 2303c21cea download users csv 2026-04-08 12:24:22 +03:00
Pete Matsyburka 89e797b95f update message 2026-04-08 10:29:45 +03:00
Pete Matsyburka 20375c3a42 update gem 2026-04-08 08:40:41 +03:00
Pete Matsyburka 339ceda18d upload attachment if not completed 2026-04-07 18:20:16 +03:00
Pete Matsyburka 97ce32fd52 set attachment name 2026-04-07 16:34:15 +03:00
Alex Turchyn 8c4cbd86f7 Merge from docusealco/wip 2026-04-06 15:22:38 +03:00
Pete Matsyburka eaedaa96bb fix rubocop 2026-04-02 13:28:12 +03:00
Pete Matsyburka bd7ad5fce8 update gems 2026-04-02 13:28:12 +03:00
Pete Matsyburka 759dac1b70 update schema 2026-04-01 19:07:57 +03:00
Pete Matsyburka 38577c6235 fix dynamic doc fields assignment 2026-04-01 16:08:49 +03:00
Pete Matsyburka b24fff0f55 fix rtl stamp 2026-04-01 11:10:09 +03:00
Pete Matsyburka 9e0efeb092 fix unicode in stamp 2026-04-01 10:29:31 +03:00
Pete Matsyburka 41152f90fc fix erblint 2026-03-30 15:18:34 +03:00
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
Alex Turchyn 861daaedf4 Merge from docusealco/wip 2026-03-09 10:40:29 +02:00
Pete Matsyburka d8430f4e63 fix migration 2026-03-09 09:02:56 +02:00
Pete Matsyburka dd44864485 fix field position 2026-03-06 11:37:53 +02:00
Pete Matsyburka b3bb29f383 fix brakeman 2026-03-05 21:42:55 +02:00
Pete Matsyburka b4d137c0de remove flash 2026-03-05 12:30:26 +02:00
Alex Turchyn 62a969d8fe add MCP support 2026-03-05 12:17:14 +02:00
Pete Matsyburka 961f09e092 set dynamic fields 2026-03-05 10:08:29 +02:00
Pete Matsyburka b2dfa83f18 upload json 2026-03-04 12:27:15 +02:00
Pete Matsyburka 37196ff89f add dynamic documents 2026-03-04 10:13:30 +02:00
Pete Matsyburka b50d982497 smtp timeout env vars 2026-03-03 22:49:55 +02:00
Pete Matsyburka 4a484aca69 hide storage nav if configured with env 2026-03-03 22:01:44 +02:00
Pete Matsyburka 0bf37d571a update color 2026-03-03 18:46:06 +02:00
Alex Turchyn d4e94ac2bc Merge from docusealco/wip 2026-03-02 17:26:04 +02:00
Pete Matsyburka 75bc789bb1 add reply to 2026-03-02 14:47:37 +02:00
Pete Matsyburka bc231d5bf1 add reply to 2026-03-02 14:34:16 +02:00
Alex Turchyn ab9008b3be fix brakeman SQL injection warning 2026-03-01 16:14:15 +02:00
Pete Matsyburka 65491936c3 fix archived export 2026-02-26 17:20:05 +02:00
Pete Matsyburka e589998840 add field type class 2026-02-26 11:08:26 +02:00
Pete Matsyburka 0afba8d11e adjust with prefillable 2026-02-26 11:03:08 +02:00
Pete Matsyburka f622ed168f fix autocomplete 2026-02-25 13:01:03 +02:00
Pete Matsyburka 5a31df40d8 add field settings classes 2026-02-25 11:02:00 +02:00
Pete Matsyburka db3b80c96c fix clone 2026-02-25 11:02:00 +02:00
Pete Matsyburka bd28a761d0 adjust bmp loader 2026-02-25 11:02:00 +02:00
Pete Matsyburka 4f2830a34d increase image resolution 2026-02-25 11:02:00 +02:00
Pete Matsyburka a5716cd518 show html textarea 2026-02-25 11:02:00 +02:00
Pete Matsyburka d035525c7a additional terms 2026-02-25 11:02:00 +02:00
Alex Turchyn 13fa87c449 Merge from docusealco/wip 2026-02-23 13:56:04 +02:00
Pete Matsyburka 246ccfb049 fix highlight 2026-02-23 08:31:38 +02:00
Pete Matsyburka cbffb1e5b8 cloudflare r2 fix 2026-02-22 20:13:19 +02:00
Pete Matsyburka c4b007411d radio select in formula 2026-02-22 19:54:06 +02:00
Pete Matsyburka 04264fe937 fix reason field 2026-02-22 12:15:44 +02:00
Pete Matsyburka 0c37ad12b7 add variables key 2026-02-22 09:46:04 +02:00
Pete Matsyburka 8c5298ed2e refactor link tooltip 2026-02-22 09:28:30 +02:00
Pete Matsyburka 262118e047 update deps 2026-02-22 07:37:05 +02:00
Pete Matsyburka e5b63ea2b0 fix multi party email form 2026-02-21 17:31:04 +02:00
Pete Matsyburka 746757d402 rtl in editor 2026-02-21 16:38:25 +02:00
Pete Matsyburka e6e640328b use br for email content 2026-02-21 16:20:48 +02:00
Pete Matsyburka 845782a69c link input width 2026-02-21 15:48:59 +02:00
Pete Matsyburka fc6baa1b3b adjust autolink 2026-02-21 15:48:58 +02:00
Pete Matsyburka 47822ecc15 remove autolink 2026-02-21 14:55:11 +02:00
Pete Matsyburka 3d0c7f1118 markdown in preferences 2026-02-21 14:44:19 +02:00
Pete Matsyburka e9c8e4d325 adjust editor 2026-02-21 14:32:10 +02:00
Alex Turchyn 39cc82ce0c add markdown editor 2026-02-21 11:15:09 +02:00
Pete Matsyburka 451f83421a fix cell render 2026-02-21 10:49:05 +02:00
Pete Matsyburka 5073f2eefb adjsut sign yourself 2026-02-21 10:17:58 +02:00
Pete Matsyburka 231fff5508 canvas blocked message 2026-02-21 09:53:03 +02:00
Pete Matsyburka f1d146eca3 fix typos 2026-02-20 22:39:03 +02:00
Pete Matsyburka b6635fcc4f change user only when root 2026-02-20 22:25:12 +02:00
Pete Matsyburka fb5e13ee4c adjust recipients form 2026-02-20 18:26:10 +02:00
Pete Matsyburka ba84741a64 invite party via field 2026-02-20 12:41:45 +02:00
Pete Matsyburka 65c275ac17 rename 2026-02-20 10:16:06 +02:00
Pete Matsyburka 825322d489 adjust for custom domain 2026-02-20 09:14:36 +02:00
Pete Matsyburka 739e2abdf8 vips size limit 2026-02-19 14:24:05 +02:00
Pete Matsyburka 1d2394e31e size limit 2026-02-19 14:17:59 +02:00
Pete Matsyburka 1b41af798d update 2026-02-17 19:42:47 +02:00
Pete Matsyburka 118f4a231b detailed time format 2026-02-17 18:09:34 +02:00
Pete Matsyburka e48652f425 fix rubocop 2026-02-17 10:29:00 +02:00
Pete Matsyburka 848f01edf8 add spec 2026-02-17 10:19:29 +02:00
Pete Matsyburka 43fbc42770 fix reason field 2026-02-17 10:04:11 +02:00
Pete Matsyburka 2c736a0eed validate readonly field 2026-02-17 08:53:13 +02:00
372 changed files with 19875 additions and 3053 deletions

No files matched your search

+7 -7
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -104,7 +104,7 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install libvips sudo apt-get install libvips liblept5
- name: Run Brakeman - name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn run: bundle exec brakeman -q --exit-on-warn
@@ -132,7 +132,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@@ -162,8 +162,8 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install -y libvips sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz rm -f pdfium-linux.tgz
- name: Run - name: Run
+18 -2
View File
@@ -28,7 +28,7 @@ Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists: Metrics/ParameterLists:
Max: 10 Max: 12
Metrics/MethodLength: Metrics/MethodLength:
Max: 30 Max: 30
@@ -51,6 +51,15 @@ Style/NumericPredicate:
Style/MinMaxComparison: Style/MinMaxComparison:
Enabled: false Enabled: false
Style/EmptyClassDefinition:
Enabled: false
Style/OneClassPerFile:
Enabled: false
Layout/MultilineMethodCallIndentation:
Enabled: false
Naming/PredicateMethod: Naming/PredicateMethod:
Enabled: false Enabled: false
@@ -84,7 +93,7 @@ RSpec/AnyInstance:
Enabled: false Enabled: false
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 5 Max: 6
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
@@ -106,3 +115,10 @@ Rails/StrongParametersExpect:
Rails/RedirectBackOrTo: Rails/RedirectBackOrTo:
Enabled: false Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- production
- local
+23 -19
View File
@@ -1,22 +1,23 @@
FROM ruby:4.0.1-alpine AS download FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \ RUN apk --no-cache add wget unzip && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \ wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' FROM ruby:4.0.5-alpine AS webpack
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -36,22 +37,23 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -69,7 +71,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./ COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && 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 apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | 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")
COPY --chown=docuseal:docuseal ./bin ./bin COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app COPY --chown=docuseal:docuseal ./app ./app
@@ -79,23 +81,25 @@ COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./ COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt COPY --from=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts && \ RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \ bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal WORKDIR /data/docuseal
ENV HOME=/home/docuseal ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+3 -6
View File
@@ -2,8 +2,9 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.1' ruby '4.0.5'
gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false gem 'aws-sdk-secretsmanager', require: false
@@ -20,20 +21,16 @@ gem 'faraday'
gem 'faraday-follow_redirects' gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing'
gem 'jwt', require: false gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'numo-narray-alt', require: false gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime', require: false gem 'onnxruntime', require: false
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender' gem 'pretender'
gem 'puma', require: false gem 'puma', require: false
gem 'rack' gem 'rack'
gem 'rails' gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rotp' gem 'rotp'
gem 'rouge', require: false gem 'rouge', require: false
@@ -44,7 +41,7 @@ gem 'shakapacker'
gem 'sidekiq' gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false
gem 'strip_attributes' gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false gem 'trilogy', require: false
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'
+153 -188
View File
@@ -1,39 +1,31 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.16) action_text-trix (2.1.18)
railties railties
actioncable (8.1.2) actioncable (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.2) actionmailbox (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.2) actionmailer (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.2) actionpack (8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -41,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.2) actiontext (8.1.3)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.2) actionview (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.2) activejob (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.2) activemodel (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
activerecord (8.1.2) activerecord (8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.2) activestorage (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.2) activesupport (8.1.3)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -83,16 +75,16 @@ GEM
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.8) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.20.0) annotaterb (4.22.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1) arabic-letter-connector (0.1.1)
ast (2.4.3) ast (2.4.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1209.0) aws-partitions (1.1233.0)
aws-sdk-core (3.241.4) aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@@ -100,15 +92,15 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.121.0) aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.212.0) aws-sdk-s3 (1.218.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.128.0) aws-sdk-secretsmanager (1.129.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
@@ -116,7 +108,7 @@ GEM
cgi cgi
rexml rexml
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.21) bcrypt (3.1.22)
better_html (2.2.0) better_html (2.2.0)
actionview (>= 7.0) actionview (>= 7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
@@ -124,11 +116,11 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (4.0.1) bigdecimal (4.1.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.21.1) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.2) brakeman (8.0.4)
racc racc
builder (3.3.0) builder (3.3.0)
bullet (8.1.0) bullet (8.1.0)
@@ -158,8 +150,6 @@ GEM
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.5) csv (3.3.5)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
@@ -171,16 +161,16 @@ GEM
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
declarative (0.0.20) declarative (0.0.20)
devise (4.9.4) devise (5.0.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 7.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (6.3.1) devise-two-factor (6.4.0)
activesupport (>= 7.0, < 8.2) activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 5.0) devise (>= 4.0, < 6.0)
railties (>= 7.0, < 8.2) railties (>= 7.2, < 8.2)
rotp (~> 6.0) rotp (~> 6.0)
diff-lcs (1.6.2) diff-lcs (1.6.2)
digest-crc (0.7.0) digest-crc (0.7.0)
@@ -189,7 +179,7 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (6.0.1) erb (6.0.4)
erb_lint (0.9.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)
@@ -203,9 +193,9 @@ GEM
factory_bot_rails (6.5.1) factory_bot_rails (6.5.1)
factory_bot (~> 6.5) factory_bot (~> 6.5)
railties (>= 6.1.0) railties (>= 6.1.0)
faker (3.6.0) faker (3.6.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -213,18 +203,17 @@ GEM
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-net_http (3.4.2) faraday-net_http (3.4.2)
net-http (~> 0.5) net-http (~> 0.5)
ferrum (0.17.1) ferrum (0.17.2)
addressable (~> 2.5) addressable (~> 2.5)
base64 (~> 0.2) base64 (~> 0.2)
concurrent-ruby (~> 1.1) concurrent-ruby (~> 1.1)
webrick (~> 1.7) webrick (~> 1.7)
websocket-driver (~> 0.7) websocket-driver (~> 0.7)
ffi (1.17.3) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.4-arm64-darwin)
ffi (1.17.3-arm64-darwin) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl)
ffi (1.17.3-x86_64-linux-musl)
foreman (0.90.0) foreman (0.90.0)
thor (~> 1.4) thor (~> 1.4)
geom2d (0.4.1) geom2d (0.4.1)
@@ -240,7 +229,7 @@ GEM
retriable (~> 3.1) retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0) google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.59.0) google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0) google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
@@ -248,8 +237,8 @@ GEM
google-cloud-env (2.3.1) google-cloud-env (2.3.1)
base64 (~> 0.2) base64 (~> 0.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0) google-cloud-errors (1.6.0)
google-cloud-storage (1.58.0) google-cloud-storage (1.59.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2) google-apis-core (>= 0.18, < 2)
@@ -259,7 +248,7 @@ GEM
googleauth (~> 1.9) googleauth (~> 1.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-logging-utils (0.2.0) google-logging-utils (0.2.0)
googleauth (1.16.1) googleauth (1.16.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2) google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1) google-logging-utils (~> 0.1)
@@ -268,25 +257,22 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.2.1) hashdiff (1.2.1)
hexapdf (1.5.0) hexapdf (1.7.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
strscan (>= 3.1.2) strscan (>= 3.1.2)
htmlentities (4.4.2)
i18n (1.14.8) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2) io-console (0.8.2)
irb (1.16.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.18.1) json (2.19.8)
jwt (3.1.2) jwt (3.2.0)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.1.1) launchy (3.1.1)
@@ -307,7 +293,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.25.0) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -319,16 +305,15 @@ GEM
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
method_source (1.1.0) method_source (1.1.0)
mini_magick (5.3.1)
logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (6.0.1) minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.19.1) multi_json (1.19.1)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-imap (0.6.2) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -338,41 +323,35 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu) nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl) nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl) nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
numo-narray-alt (0.9.13) numo-narray-alt (0.10.3)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
ffi
onnxruntime (0.10.1-aarch64-linux) onnxruntime (0.10.1-aarch64-linux)
ffi ffi
onnxruntime (0.10.1-arm64-darwin) onnxruntime (0.10.1-arm64-darwin)
ffi ffi
onnxruntime (0.10.1-x86_64-linux) onnxruntime (0.10.1-x86_64-linux)
ffi ffi
openssl (4.0.0) openssl (4.0.1)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
package_json (0.2.0) package_json (0.2.0)
pagy (43.2.8) pagy (43.4.4)
json json
uri
yaml yaml
parallel (1.27.0) parallel (1.28.0)
parser (3.3.10.1) parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.6.3)
pg (1.6.3-aarch64-linux) pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl) pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin) pg (1.6.3-arm64-darwin)
@@ -380,18 +359,10 @@ GEM
pg (1.6.3-x86_64-linux-musl) pg (1.6.3-x86_64-linux-musl)
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
premailer (1.27.0) pretender (1.0.0)
addressable actionpack (>= 7.2)
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.6.0)
actionpack (>= 7.1)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.8.0) prism (1.9.0)
pry (0.16.0) pry (0.16.0)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
@@ -401,51 +372,47 @@ GEM
psych (5.3.1) psych (5.3.1)
date date
stringio stringio
public_suffix (7.0.2) public_suffix (7.0.5)
puma (7.2.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.6)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.2) rails (8.1.3)
actioncable (= 8.1.2) actioncable (= 8.1.3)
actionmailbox (= 8.1.2) actionmailbox (= 8.1.3)
actionmailer (= 8.1.2) actionmailer (= 8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actiontext (= 8.1.2) actiontext (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) railties (= 8.1.3)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0) rails-i18n (8.1.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
rails_autolink (1.1.8) railties (8.1.3)
actionview (> 3.1) actionpack (= 8.1.3)
activesupport (> 3.1) activesupport (= 8.1.3)
railties (> 3.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -453,12 +420,12 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.4.2)
rdoc (7.1.0) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
redis-client (0.26.4) redis-client (0.29.0)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
@@ -472,7 +439,7 @@ GEM
responders (3.2.0) responders (3.2.0)
actionpack (>= 7.0) actionpack (>= 7.0)
railties (>= 7.0) railties (>= 7.0)
retriable (3.1.2) retriable (3.4.1)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rouge (4.7.0) rouge (4.7.0)
@@ -485,19 +452,19 @@ GEM
rspec-expectations (3.13.5) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.7) rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (8.0.2) rspec-rails (8.0.4)
actionpack (>= 7.2) actionpack (>= 7.2)
activesupport (>= 7.2) activesupport (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (~> 3.13) rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (~> 3.13) rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (~> 3.13) rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.6) rspec-support (3.13.7)
rubocop (1.82.1) rubocop (1.86.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -505,10 +472,10 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0) rubocop-ast (1.49.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.7) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
@@ -533,19 +500,19 @@ GEM
rubyzip (>= 3.2.2) rubyzip (>= 3.2.2)
rubyzip (3.2.2) rubyzip (3.2.2)
securerandom (0.4.1) securerandom (0.4.1)
semantic_range (3.1.0) semantic_range (3.1.1)
shakapacker (9.5.0) shakapacker (9.7.0)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json package_json
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (8.1.0) sidekiq (8.1.6)
connection_pool (>= 3.0.0) connection_pool (>= 3.0.0)
json (>= 2.16.0) json (>= 2.16.0)
logger (>= 1.7.0) logger (>= 1.7.0)
rack (>= 3.2.0) rack (>= 3.2.0)
redis-client (>= 0.26.0) redis-client (>= 0.29.0)
signet (0.21.0) signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@@ -558,20 +525,22 @@ GEM
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (2.9.0-aarch64-linux-gnu) sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl) sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl) sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0) stringio (3.2.0)
strip_attributes (2.0.1) strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0) activemodel (>= 3.0, < 9.0)
strscan (3.1.7) strscan (3.1.8)
thor (1.5.0) thor (1.5.0)
timeout (0.6.0) timeout (0.6.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
trilogy (2.12.2)
bigdecimal
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.21) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
twitter_cldr (6.14.0) twitter_cldr (6.14.0)
@@ -581,7 +550,7 @@ GEM
tzinfo tzinfo
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.3) tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
@@ -592,12 +561,11 @@ GEM
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
webmock (3.26.1) webmock (3.26.2)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@@ -609,7 +577,7 @@ GEM
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yaml (0.4.0) yaml (0.4.0)
zeitwerk (2.7.4) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -619,6 +587,7 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
addressable
annotaterb annotaterb
arabic-letter-connector arabic-letter-connector
aws-sdk-s3 aws-sdk-s3
@@ -646,23 +615,19 @@ DEPENDENCIES
foreman foreman
google-cloud-storage google-cloud-storage
hexapdf hexapdf
image_processing
jwt jwt
letter_opener_web letter_opener_web
lograge lograge
numo-narray-alt numo-narray-alt
oj
onnxruntime onnxruntime
pagy pagy
pg pg
premailer-rails
pretender pretender
pry-rails pry-rails
puma puma
rack rack
rails rails
rails-i18n rails-i18n
rails_autolink
rotp rotp
rouge rouge
rqrcode rqrcode
@@ -678,7 +643,7 @@ DEPENDENCIES
simplecov simplecov
sqlite3 sqlite3
strip_attributes strip_attributes
trilogy! trilogy
turbo-rails turbo-rails
twitter_cldr twitter_cldr
tzinfo-data tzinfo-data
@@ -686,7 +651,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 4.0.1 ruby 4.0.5
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3
+5
View File
@@ -0,0 +1,5 @@
Additional Terms
In accordance with Section 7(b) of the GNU Affero General Public License,
a covered work must retain the original DocuSeal attribution in interactive
user interfaces.
+5 -5
View File
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Company logo and white-label - Company logo and white-label
- User roles - User roles
- Automated reminders - Automated reminders
- Invitation and identify verification via SMS - Invitation and identity verification via SMS
- Conditional fields and formulas - Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import - Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML - SSO / SAML
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway| |Heroku|Railway|
|:--:|:---:| |:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**| |**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
``` ```
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable. By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
#### Docker Compose #### Docker Compose
@@ -97,8 +97,8 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
## License ## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. Distributed under the AGPLv3 License with Section 7(b) Additional Terms. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) and [LICENSE_ADDITIONAL_TERMS](https://github.com/docusealco/docuseal/blob/master/LICENSE_ADDITIONAL_TERMS) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC. Unless otherwise noted, all files © 2023-2026 DocuSeal LLC.
## Tools ## Tools
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA, AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT, AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY, AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY, AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY, AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY, AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -23,7 +24,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze ].freeze
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
@@ -35,7 +37,7 @@ class AccountConfigsController < ApplicationController
end end
def destroy def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key) raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy! @account_config.destroy!
@@ -44,8 +46,12 @@ class AccountConfigsController < ApplicationController
private private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key]) raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config = @account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key]) AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
+3 -1
View File
@@ -53,7 +53,9 @@ class AccountsController < ApplicationController
# rubocop:disable Layout/LineLength # rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
:account_delete_button, :account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
helpers.tag.br +
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
) )
# rubocop:enable Layout/LineLength # rubocop:enable Layout/LineLength
end end
@@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take attachment = blob.attachments.take
@record = attachment.record @record = attachment.record
@@ -33,12 +41,19 @@ module Api
else else
http_cache_forever public: true do http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes' response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition] if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end end
end end
end end
# rubocop:enable Metrics
private private
@@ -57,8 +72,6 @@ module Api
return if !require_ttl && !require_auth return if !require_ttl && !require_auth
end end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied raise CanCan::AccessDenied
end end
end end
@@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics # rubocop:disable Metrics
def show def show
@@ -18,6 +19,12 @@ module Api
return head :not_found unless blob return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a| is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) || (current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } || a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
@@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex' headers['X-Robots-Tag'] = 'noindex'
end end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
+23 -7
View File
@@ -8,29 +8,37 @@ module Api
COOKIE_STORE_LIMIT = 10 COOKIE_STORE_LIMIT = 10
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
file = params[:file]
if params[:type].in?(%w[initials signature]) if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path) image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image) if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar) Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end end
if ImageUtils.error?(image) if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar) Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" }, return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content status: :unprocessable_content
end end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end end
attachment = Submitters.create_attachment!(submitter, params) attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && submitter.email.present? if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment) cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
@@ -40,6 +48,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment) def build_new_cookie_signatures_json(submitter, attachment)
values = values =
begin begin
+11 -2
View File
@@ -54,6 +54,12 @@ module Api
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil? return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank? if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
@@ -156,7 +162,7 @@ module Api
params:) params:)
else else
submissions_attrs, attachments = submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template) Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
submissions = Submissions.create_from_submitters( submissions = Submissions.create_from_submitters(
template:, template:,
@@ -172,7 +178,10 @@ module Api
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters) Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter| submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at? if submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
end
end end
submissions submissions
+18 -5
View File
@@ -34,18 +34,24 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:) render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end end
# rubocop:disable Metrics/MethodLength
def update def update
if @submitter.completed_at? if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!( normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:), submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account), @submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter for_submitter: @submitter,
purpose: :api
) )
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params], Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
@@ -60,7 +66,10 @@ module Api
@submitter.submission.save! @submitter.submission.save!
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at? if @submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
end
end end
if @submitter.completed_at? if @submitter.completed_at?
@@ -78,6 +87,7 @@ module Api
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
# rubocop:enable Metrics/MethodLength
def submitter_params def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
@@ -151,9 +161,7 @@ module Api
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter) submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end end
submitter submitter
@@ -195,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to') submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa') if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa'] submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last') if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last'] submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end end
@@ -9,7 +9,7 @@ module Api
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [@template],
associations: [schema_documents: :preview_images_attachments] associations: [{ schema_documents: :preview_images_attachments }]
).call ).call
cloned_template = Templates::Clone.call( cloned_template = Templates::Clone.call(
+43 -16
View File
@@ -9,20 +9,7 @@ module Api
templates = paginate(templates.preload(:author, folder: :parent_folder)) templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents = schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@@ -30,6 +17,7 @@ module Api
data: templates.map do |t| data: templates.map do |t|
Templates::SerializeForApi.call(t, Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id }, schema_documents: schema_documents.select { |e| e.record_id == t.id },
dynamic_documents:,
preview_image_attachments:, preview_image_attachments:,
expires_at:) expires_at:)
end, end,
@@ -69,6 +57,7 @@ module Api
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated') WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@@ -78,6 +67,8 @@ module Api
@template.destroy! @template.destroy!
else else
@template.update!(archived_at: Time.current) @template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end end
render json: @template.as_json(only: %i[id archived_at]) render json: @template.as_json(only: %i[id archived_at])
@@ -85,6 +76,41 @@ module Api
private private
def preload_relations(templates)
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
dynamic_document_uuids =
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
dynamic_documents =
if dynamic_document_uuids.present?
DynamicDocument.where(template: templates.map(&:id))
.where(uuid: dynamic_document_uuids)
.preload(current_version: { document_attachment: :blob })
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
else
DynamicDocument.none
end
preview_attachment_ids =
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: preview_attachment_ids,
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
[schema_documents, dynamic_documents, preview_image_attachments]
end
def filter_templates(templates, params) def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q]) templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
@@ -107,7 +133,8 @@ module Api
:external_id, :external_id,
:shared_link, :shared_link,
{ {
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]], submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
optional_invite_by_uuid linked_to_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
:title, :description, :prefillable, :title, :description, :prefillable,
@@ -116,7 +143,7 @@ module Api
conditions: [%i[field_uuid value action operation]], conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]], options: [%i[value uuid]],
validation: %i[message pattern min max step], validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
} }
] ]
@@ -42,10 +42,6 @@ class ApplicationController < ActionController::Base
end end
def default_url_options def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options Docuseal.default_url_options
end end
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code render json: { status: error_status_code, error: }.compact, status: error_status_code
end end
f.html { render error_status_code.to_s, status: error_status_code } f.any { render error_status_code.to_s, status: error_status_code }
end end
end end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*' headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000' headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true headers['Access-Control-Allow-Credentials'] = 'true'
end end
def error_status_code def error_status_code
+1 -1
View File
@@ -3,7 +3,7 @@
class EsignSettingsController < ApplicationController class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated' DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do CertFormRecord = Struct.new(:name, :file, :password) do
include ActiveModel::Validations include ActiveModel::Validations
def to_key def to_key
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT FORMAT = Templates::ProcessDocument::FORMAT
def show def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment) result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment return head :not_found unless attachment
@@ -33,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}" file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX) f.flock(File::LOCK_EX)
@@ -1,6 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class RevealAccessTokenController < ApplicationController class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show def show
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
end end
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug]) template = Template.find_by!(slug: params[:template_slug])
@submitter = @submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase) Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug] elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug]) submission = Submission.find_by(slug: params[:submission_slug])
@@ -27,9 +27,11 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f| respond_to do |f|
f.html { render :success } f.html { render :success }
+10 -1
View File
@@ -101,11 +101,18 @@ class StartFormController < ApplicationController
def load_resubmit_submitter def load_resubmit_submitter
@resubmit_submitter = @resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit]) submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
end end
end end
def can_resubmit?(submitter)
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def authorize_start! def authorize_start!
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && @template.archived_at?
return redirect_to start_form_path(@template.slug) if @template.archived_at? return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter return if @resubmit_submitter
@@ -172,6 +179,8 @@ class StartFormController < ApplicationController
submitters: [submitter], submitters: [submitter],
source: :link) source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id submitter.account_id = submitter.submission.account_id
submitter submitter
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
'email_verified' => 'email_check', 'email_verified' => 'email_check',
'click_sms' => 'hand_click', 'click_sms' => 'hand_click',
'decline_form' => 'x', 'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play', 'start_verification' => 'player_play',
'complete_verification' => 'check', 'complete_verification' => 'check',
'invite_party' => 'user_plus' 'invite_party' => 'user_plus'
+19 -13
View File
@@ -26,7 +26,7 @@ class SubmissionsController < ApplicationController
unless @submission.submitters.all?(&:completed_at?) unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@submission], records: [@submission],
associations: [submitters: :start_form_submission_events] associations: [{ submitters: :start_form_submission_events }]
).call ).call
end end
@@ -38,6 +38,8 @@ class SubmissionsController < ApplicationController
end end
def create def create
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1' save_template_message(@template, params) if params[:save_message] == '1'
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@@ -51,18 +53,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails], emails: params[:emails],
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
else else
submissions_attrs = submissions_params[:submission].to_h.values create_submissions(@template, submissions_params, params)
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end end
WebhookUrls.enqueue_events(submissions, 'submission.created') WebhookUrls.enqueue_events(submissions, 'submission.created')
@@ -97,6 +88,21 @@ class SubmissionsController < ApplicationController
private private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params) def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present? template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present? template.preferences['request_email_body'] = params[:body] if params[:body].present?
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
def index def index
@submitter = Submitter.preload({ attachments_attachments: :blob }, @submitter = Submitter.preload({ attachments_attachments: :blob },
submission: { template: { documents_attachments: :blob } }) submission: { template: { documents_attachments: :blob } })
.find_by(slug: params[:submitter_slug]) .find_by(slug: params[:submit_form_slug])
respond_to do |f| respond_to do |f|
f.html do f.html do
@@ -1,92 +1,25 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user! load_and_authorize_resource :submission
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
signature_valid =
if @submitter&.slug == params[:submitter_slug]
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter) Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true' if params[:combined] == 'true'
respond_with_combined(last_submitter) url = Submitters.build_combined_url(last_submitter)
if url
render json: [url]
else
head :not_found
end
else else
render json: build_urls(last_submitter) render json: Submitters.build_document_urls(last_submitter)
end end
end end
private
def respond_with_combined(submitter)
url = build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
def build_urls(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end end
@@ -5,10 +5,11 @@ class SubmissionsExportController < ApplicationController
load_and_authorize_resource :submission, through: :template, parent: false, only: :index load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index def index
submissions = @submissions.active submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob }) submissions = submissions.preload(submitters: { documents_attachments: :blob,
.order(id: :asc) attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true) submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params) submissions = Submissions::Filter.call(submissions, current_user, params)
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
TTL = 40.minutes TTL = 40.minutes
def show def show
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? @sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid = signature_valid =
if submitter && submitter.submission.slug == params[:slug] if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
@submission = submitter.submission @submission = @sig_submitter.submission
true true
else
@sig_submitter = nil
end end
@submission ||= Submission.find_by!(slug: params[:slug]) @submission ||= Submission.find_by!(slug: params[:slug])
@@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain' render 'submissions/show', layout: 'plain', locals: { is_preview: true }
end end
def completed def completed
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class SubmissionsPreviewDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
def index
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
unless current_user_submission?(@submission)
if use_2fa?(@submission)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submission?(submission)
current_user && current_ability.can?(:read, submission)
end
def use_2fa?(submission)
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
false
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class SubmissionsResendEmailController < ApplicationController
load_and_authorize_resource :submission
before_action do
authorize!(:manage, :resend_all)
authorize!(:update, @submission)
end
def create
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
if Docuseal.multitenant?
recent_submitter_ids =
SubmissionEvent.where(submitter_id: submitters.map(&:id),
event_type: 'send_email',
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
end
submitters.each do |submitter|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
submitter.sent_at ||= Time.current
submitter.save!
end
notice =
if submitters.empty?
I18n.t('email_has_been_sent_already')
else
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
end
redirect_back(fallback_location: submission_path(@submission), notice:)
end
end
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission load_and_authorize_resource :submission
def create def create
authorize!(:update, @submission)
@submission.update!(archived_at: nil) @submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived') redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
@@ -0,0 +1,70 @@
# frozen_string_literal: true
class SubmitFormCompletedDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: submitter_slug)
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
end
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
end
+21 -2
View File
@@ -3,11 +3,12 @@
class SubmitFormController < ApplicationController class SubmitFormController < ApplicationController
layout 'form' layout 'form'
around_action :with_browser_locale, only: %i[show completed success] around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
before_action :load_submitter, only: %i[show update completed] before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show] before_action :maybe_require_link_2fa, only: %i[show]
@@ -78,6 +79,8 @@ class SubmitFormController < ApplicationController
render json: { field_uuid: e.message }, status: :unprocessable_content render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e rescue Submitters::SubmitValues::ValidationError => e
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
@@ -91,6 +94,12 @@ class SubmitFormController < ApplicationController
def success; end def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private private
def maybe_require_link_2fa def maybe_require_link_2fa
@@ -108,8 +117,18 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at? render :declined if @submitter.declined_at?
end end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug]) @submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end end
def build_attachments_index(submission) def build_attachments_index(submission)
@@ -4,31 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
def create before_action :load_submitter
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? || def create
submitter.completed_at? || return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
submitter.submission.archived_at? || @submitter.completed_at? ||
submitter.submission.expired? || @submitter.submission.archived_at? ||
submitter.submission.template&.archived_at? || @submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, @submitter.submission.template&.archived_at? ||
request) !Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current) @submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] }) SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end end
user = submitter.submission.created_by_user || submitter.template.author user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later! SubmitterMailer.declined_email(@submitter, user).deliver_later!
end end
WebhookUrls.enqueue_events(submitter, 'form.declined') WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug) redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end end
end end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
def index def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_content if @submitter.declined_at? || return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? || @submitter.submission.archived_at? ||
@@ -33,7 +33,7 @@ class SubmitFormDownloadController < ApplicationController
end end
urls = attachments.map do |attachment| urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i) ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end end
render json: urls render json: urls
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
skip_authorization_check skip_authorization_check
def create def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter) return head :unprocessable_content unless can_invite?(@submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid') invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid') optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item| (invite_submitters + optional_invite_submitters).each do |item|
@@ -19,18 +19,20 @@ class SubmitFormInviteController < ApplicationController
next unless attrs next unless attrs
next if attrs[:email].blank? next if attrs[:email].blank?
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id) email = Submissions.normalize_email(attrs[:email])
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid }) @submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
end end
submitter.submission.update!(submitters_order: :preserved) @submitter.submission.update!(submitters_order: :preserved)
end end
submitter.submission.submitters.reload @submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } } if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request) Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok head :ok
else else
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
text_runs = documents.to_h do |document|
[
document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
]
end
render json: { text_runs: }
end
end
@@ -5,21 +5,21 @@ class SubmitFormValuesController < ApplicationController
skip_authorization_check skip_authorization_check
def index def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || return render json: {} if @submitter.completed_at? ||
submitter.declined_at? || @submitter.declined_at? ||
submitter.submission.template&.archived_at? || @submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? || @submitter.submission.archived_at? ||
submitter.submission.expired? || @submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request) !Submitters::AuthorizedForForm.call(@submitter, current_user, request)
value = submitter.values[params['field_uuid']] value = @submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present? attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: { render json: {
value:, value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok }
end end
end end
@@ -7,25 +7,34 @@ class SubmittersAutocompleteController < ApplicationController
LIMIT = 100 LIMIT = 100
def index def index
submitters = search_submitters(@submitters) field = SELECT_COLUMNS.find { |c| c == params[:field] }
submitters = search_submitters(@submitters, field)
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] } arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
values =
if field
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
else
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
end
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h } attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs render json: attrs
end end
private private
def search_submitters(submitters) def search_submitters(submitters, field)
if SELECT_COLUMNS.include?(params[:field]) if field
if Docuseal.fulltext_search? if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field]) Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
else else
column = Submitter.arel_table[params[:field].to_sym] column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%" term = "#{params[:q].downcase}%"
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SubmittersDownloadController < ApplicationController
load_and_authorize_resource :submitter
def index
Submissions::EnsureResultGenerated.call(@submitter)
render json: Submitters.build_document_urls(@submitter)
end
end
@@ -1,9 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug load_and_authorize_resource :submitter
def create def create
authorize!(:update, @submitter)
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter, if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email', event_type: 'send_email',
created_at: 10.hours.ago..Time.current) created_at: 10.hours.ago..Time.current)
@@ -6,17 +6,19 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) } render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end end
def create def create
authorize!(:update, @template)
if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end end
old_fields_hash = @template.fields.hash old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true) documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,7 +29,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields, fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters, submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json( documents: documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { include: {
preview_images: { methods: %i[url metadata filename] } preview_images: { methods: %i[url metadata filename] }
} }
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class TemplateDocumentsCropController < ApplicationController
load_and_authorize_resource :template
before_action :load_attachment
rescue_from Leptonica::LeptonicaError do
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
def index
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
end
def create
authorize!(:update, @template)
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
render json: {
document: document.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def load_attachment
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
end
def crop_params
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class TemplateDocumentsModifyController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
documents_layout =
params.require(:documents).map do |item|
item.permit(:attachment_uuid,
pages: [:attachment_uuid, :page, :rotate,
{ redact: [%i[x y w h]], replaced_page: %i[attachment_uuid page] }]).to_h
end
Templates::ModifyDocuments.call(@template, documents_layout)
render json: {
schema: @template.schema,
fields: @template.fields,
submitters: @template.submitters,
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::ModifyDocuments::InvalidLayout
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplateDocumentsPageObjectsController < ApplicationController
load_and_authorize_resource :template
def index
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
end
end
@@ -8,11 +8,14 @@ class TemplatesCloneAndReplaceController < ApplicationController
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [@template],
associations: [schema_documents: :preview_images_attachments] associations: [{ schema_documents: :preview_images_attachments }]
).call ).call
cloned_template = Templates::Clone.call(@template, author: current_user) cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*') cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
authorize!(:create, cloned_template)
cloned_template.save! cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true) documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
@@ -12,7 +12,7 @@ class TemplatesCloneController < ApplicationController
def create def create
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@base_template], records: [@base_template],
associations: [schema_documents: :preview_images_attachments] associations: [{ schema_documents: :preview_images_attachments }]
).call ).call
@template = Templates::Clone.call(@base_template, author: current_user, @template = Templates::Clone.call(@base_template, author: current_user,
@@ -21,7 +21,7 @@ class TemplatesCloneController < ApplicationController
authorize!(:create, @template) authorize!(:create, @template)
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id])) if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id] @template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id @template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id @template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
+8 -15
View File
@@ -27,18 +27,7 @@ class TemplatesController < ApplicationController
def new; end def new; end
def edit def edit
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
@@ -84,6 +73,8 @@ class TemplatesController < ApplicationController
else else
@template.update!(archived_at: Time.current) @template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived') I18n.t('template_has_been_archived')
end end
@@ -95,9 +86,11 @@ class TemplatesController < ApplicationController
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(
:name, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, { schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]], { conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]], submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
:title, :description, :prefillable, :title, :description, :prefillable,
@@ -106,7 +99,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]], conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]], options: [%i[value uuid]],
validation: %i[message pattern min max step], validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
) )
end end
end end
+24 -15
View File
@@ -9,32 +9,34 @@ class TemplatesDebugController < ApplicationController
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] } schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] } attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download if attachment
data = attachment.download
unless attachment.image? unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data)) pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data) fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
debug_file if DEBUG_FILE.present? debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }] associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call ).call
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] } params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params) documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:) @template.update!(schema:)
end end
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
page_number = params[:page].presence&.to_i page_number = params[:page].presence&.to_i
documents.each do |document| documents.each do |document|
io = StringIO.new(document.download) io =
if document.image?
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
else
StringIO.new(document.download)
end
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)| Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: }) sse.write({ attachment_uuid:, page:, fields: })
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
def edit; end def edit; end
def update def update
authorize!(:update, @template)
name = [params[:parent_name], params[:name]].compact_blank.join(' / ') name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name) @template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
@@ -58,8 +58,8 @@ class TemplatesPreferencesController < ApplicationController
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] + completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body], [{ completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []] submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
).tap do |attrs| ).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1' attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :show, layout: 'plain' render :show, layout: 'plain'
end end
@@ -22,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
def submitters_params def submitters_params
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
invite_by_uuid linked_to_uuid email option order]] } invite_by_uuid invite_via_field_uuid linked_to_uuid email option order]] }
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s| params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank? next if s[:uuid].blank?
@@ -36,6 +36,7 @@ class TemplatesRecipientsController < ApplicationController
s[:order] = s[:order].to_i if s[:order].present? s[:order] = s[:order].to_i if s[:order].present?
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank? s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank? s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
s.delete(:invite_via_field_uuid) if s[:invite_via_field_uuid].blank?
normalize_option_value(s) normalize_option_value(s)
end end
@@ -53,6 +54,7 @@ class TemplatesRecipientsController < ApplicationController
attrs.delete(:email) attrs.delete(:email)
attrs.delete(:linked_to_uuid) attrs.delete(:linked_to_uuid)
attrs.delete(:invite_by_uuid) attrs.delete(:invite_by_uuid)
attrs.delete(:invite_via_field_uuid)
attrs.delete(:optional_invite_by_uuid) attrs.delete(:optional_invite_by_uuid)
when /\Alinked_to_(.*)\z/ when /\Alinked_to_(.*)\z/
attrs[:linked_to_uuid] = ::Regexp.last_match(-1) attrs[:linked_to_uuid] = ::Regexp.last_match(-1)
@@ -4,8 +4,12 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
authorize!(:update, @template)
@template.update!(archived_at: nil) @template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived') redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end end
end end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class TemplatesShareLinkQrController < ApplicationController
load_and_authorize_resource :template
def show
return render :disabled, layout: 'plain' unless @template.shared_link?
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
@page_size =
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
'Letter'
else
'A4'
end
render :show, layout: false
end
end
@@ -5,14 +5,16 @@ class TemplatesUploadsController < ApplicationController
layout 'plain' layout 'plain'
def show; end def show
redirect_to root_path if params[:url].blank?
end
def create def create
url_params = create_file_params_from_url if params[:url].present? url_params = create_file_params_from_url if params[:url].present?
save_template!(@template, url_params) save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true) documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank? if @template.fields.blank?
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesVersionsController < ApplicationController
load_and_authorize_resource :template
def index
versions = @template.template_versions.order(id: :desc).preload(:author)
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
end
def show
version = @template.template_versions.find(params[:id])
render json: TemplateVersions.serialize(version)
end
def create
authorize!(:update, @template)
TemplateVersions.find_or_create_for(@template, author: current_user)
head :ok
end
end
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy skip_authorization_check only: :destroy
def show def create
authorize!(:manage, current_account) authorize!(:manage, current_account)
authorize!(:manage, current_user) authorize!(:manage, current_user)
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
+13 -1
View File
@@ -16,7 +16,19 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration') @users.active.where.not(role: 'integration')
end end
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)) @users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
end
if current_ability.can?(:manage, current_account)
format.csv do
send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv'
end
end
end
end end
def new; end def new; end
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record) Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated' when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record) Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived' when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at]) @webhook_event.record.as_json(only: %i[id archived_at])
end end
end end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end
@@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
def new; end def new; end
def create def create
@webhook_url.save! if @webhook_url.url.present?
@webhook_url.save!
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved') redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
else
redirect_back fallback_location: settings_webhooks_path
end
end end
def update def update
+45 -7
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
@@ -40,19 +41,20 @@ import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group' import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor' import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import MountOnClick from './elements/mount_on_click' import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event' import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to' import ScrollTo from './elements/scroll_to'
import SetValue from './elements/set_value' import SetValue from './elements/set_value'
import ReviewForm from './elements/review_form' import ReviewForm from './elements/review_form'
import ShowOnValue from './elements/show_on_value' import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes' import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field' import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker' import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal' import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart' import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition' import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -111,6 +113,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete) safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm) safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies) safeRegisterElement('toggle-cookies', ToggleCookies)
@@ -131,19 +134,20 @@ safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer) safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('mount-on-click', MountOnClick) safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent) safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo) safeRegisterElement('scroll-to', ScrollTo)
safeRegisterElement('set-value', SetValue) safeRegisterElement('set-value', SetValue)
safeRegisterElement('review-form', ReviewForm) safeRegisterElement('review-form', ReviewForm)
safeRegisterElement('show-on-value', ShowOnValue) safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal) safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart) safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -153,16 +157,23 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appElem.classList.add('md:h-screen') this.appElem.classList.add('md:h-screen')
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withPrefillable: template.fields?.some((f) => f.prefillable),
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null, withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null, withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true', withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
withRevisions: true,
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true, withCustomFields: true,
@@ -172,7 +183,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true', withGoogleDrive: this.dataset.withGoogleDrive === 'true',
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
withReplaceAndCloneUpload: true, withReplaceAndCloneUpload: true,
withDownload: true, withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
@@ -186,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
} }
onSubmit = (e) => { onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') { if (e.detail.success) {
e.detail.fetchResponse.response.json().then((data) => { if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
this.component.template.submitters = data.submitters e.detail.fetchResponse.response.json().then((data) => {
}) this.component.template.submitters = data.submitters
})
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
}
} }
} }
+4
View File
@@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
.font-courier { .font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
} }
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
+2 -1
View File
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight
+27
View File
@@ -0,0 +1,27 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'prompt',
'processing',
'logo'
]
connectedCallback () {
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.prompt.classList.add('hidden')
this.processing.classList.remove('hidden')
this.logo.classList.add('animate-bounce')
}
get form () {
return this.querySelector('form')
}
})
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,14 +0,0 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const invalidMessage = this.dataset.invalidMessage || ''
input.addEventListener('invalid', () => {
input.setCustomValidity(input.value ? invalidMessage : '')
})
input.addEventListener('input', () => {
input.setCustomValidity('')
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable' import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover() if (!this.isLoading) this.hideDraghover()
} }
uploadFiles (files, url) { async uploadFiles (files, url) {
this.isLoading = true this.isLoading = true
this.form.action = url this.form.action = url
this.form.querySelector('[type="file"]').files = files const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click() this.form.querySelector('[type="submit"]').click()
} }
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('click', () => this.downloadFiles()) this.addEventListener('click', () => this.downloadFiles())
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.downloadFiles()
}
})
} }
toggleState () { toggleState () {
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable' import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable' import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement { export default actionable(targetable(class extends HTMLElement {
static [target.static] = [ static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30') this.classList.add('border-base-300', 'hover:bg-base-200/30')
} }
onDrop (e) { async onDrop (e) {
e.preventDefault() e.preventDefault()
this.input.files = e.dataTransfer.files this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files) await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
} }
onSelectFiles (e) { async onSelectFiles (e) {
e.preventDefault() e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
+382
View File
@@ -0,0 +1,382 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
function loadTiptap () {
return Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
Editor: core.Editor,
Extension: core.Extension,
Bold: bold.default || bold,
Italic: italic.default || italic,
Paragraph: paragraph.default || paragraph,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Document: document.default || document,
Link: link.default || link,
Underline: underline.default || underline,
UndoRedo: extensions.UndoRedo,
Markdown: markdown.Markdown,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}))
}
class LinkTooltip {
constructor (container, editor, templateEl) {
this.container = container
this.editor = editor
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
this.input = this.tooltip.querySelector('input')
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
container.style.position = 'relative'
container.appendChild(this.tooltip)
}
isVisible () {
return !this.tooltip.classList.contains('hidden')
}
normalizeUrl (url) {
if (!url) return url
if (/^{/i.test(url)) return url
if (/^https?:\/\//i.test(url)) return url
if (/^mailto:/i.test(url)) return url
return `https://${url}`
}
show (url, pos, { focus = false } = {}) {
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.tooltip.classList.remove('hidden')
const coords = this.editor.view.coordsAtPos(pos)
const containerRect = this.container.getBoundingClientRect()
this.tooltip.style.left = `${coords.left - containerRect.left}px`
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
if (focus) this.input.focus()
this.saveHandler = () => {
const inputUrl = this.input.value.trim()
if (inputUrl) {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
}
this.hide()
}
this.removeHandler = () => {
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hide()
}
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
this.saveHandler()
} else if (e.key === 'Escape') {
e.preventDefault()
this.hide()
}
}
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
this.input.addEventListener('keydown', this.keyHandler)
}
hide () {
if (this.saveHandler) {
this.saveButton.removeEventListener('click', this.saveHandler)
this.saveHandler = null
}
if (this.removeHandler) {
this.removeButton.removeEventListener('click', this.removeHandler)
this.removeHandler = null
}
if (this.keyHandler) {
this.input.removeEventListener('keydown', this.keyHandler)
this.keyHandler = null
}
this.tooltip.classList.add('hidden')
this.currentMark = null
}
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'bg-amber-100 py-0.5 px-1 rounded'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
this.editor = new Editor({
element: this.editorElement,
extensions: [
Markdown,
Document,
Paragraph,
Text,
Bold,
Italic,
HardBreak.extend({
addKeyboardShortcuts () {
return {
Enter: () => this.editor.commands.setHardBreak()
}
}
}),
UndoRedo,
Link.extend({
inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
}
}),
Underline,
VariableHighlight
],
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
contentType: 'markdown',
editorProps: {
attributes: {
style: 'min-height: 220px',
dir: 'auto',
class: 'p-3 outline-none focus:outline-none'
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+18
View File
@@ -0,0 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const dialog = document.getElementById(this.dataset.target)
this.querySelector('button').addEventListener('click', () => {
if (dialog) {
dialog.inert = false
dialog.showModal()
}
})
if (dialog) {
dialog.addEventListener('close', () => {
dialog.inert = true
})
}
}
}
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
this.classList.remove('hidden', '-translate-y-10', 'opacity-0') this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100') this.classList.add('translate-y-0', 'opacity-100')
this.inert = false
} }
hideButtons () { hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100') this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0') this.classList.add('-translate-y-10', 'opacity-0')
this.inert = true
setTimeout(() => { setTimeout(() => {
if (this.classList.contains('-translate-y-10')) { if (this.classList.contains('-translate-y-10')) {
+2 -1
View File
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight
+8 -2
View File
@@ -1,12 +1,18 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.input.addEventListener('change', (event) => { this.input.addEventListener('change', (event) => {
if (!this.target) return
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
if (this.dataset.attribute) { if (this.dataset.attribute) {
this.target[this.dataset.attribute] = event.target.checked this.target[this.dataset.attribute] = value === dataValue
} }
if (this.dataset.className) { if (this.dataset.className) {
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value) this.target.classList.toggle(this.dataset.className, value !== dataValue)
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') { if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value this.target.disabled = event.target.value !== this.dataset.value
} }
+10 -2
View File
@@ -1,10 +1,18 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
const button = this.querySelector('a, button') const button = this.querySelector('a, button, label')
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => { button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => { this.dataset.classes.split(' ').forEach((cls) => {
button.classList.toggle(cls) if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
}) })
}) })
} }
+7 -2
View File
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons' import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer) safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -31,12 +33,13 @@ safeRegisterElement('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
scrollPadding: this.dataset.scrollPadding || '-80px', scrollPadding: this.dataset.scrollPadding || '-80px',
signatureText: this.dataset.signatureText,
language: this.dataset.language, language: this.dataset.language,
dryRun: this.dataset.dryRun === 'true', dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true', withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false', withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false', withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true', withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false', reuseSignature: this.dataset.reuseSignature !== 'false',
@@ -49,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'), completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl, completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields) fields: JSON.parse(this.dataset.fields),
completeButtonContainer: document.getElementById('complete_button_container'),
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
}) })
this.app.mount(this.appElem) this.app.mount(this.appElem)
@@ -0,0 +1,434 @@
<template>
<template
v-for="(pages, docUuid) in textRuns"
:key="docUuid"
>
<template
v-for="(_, pageIndex) in pages"
:key="pageIndex"
>
<template
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
:key="i"
>
<Teleport
v-if="pageElem"
:to="pageElem"
>
<template
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
:key="index"
>
<template v-if="item.type === 'text_group'">
<span
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
>{{ item.text }}</span>
<span
v-for="(run, runIndex) in item.items"
:key="runIndex"
aria-hidden="true"
class="absolute overflow-hidden text-transparent"
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
>{{ run.text }}</span>
</template>
<FieldArea
v-else-if="item.type === 'field_area'"
:ref="setAreaRef"
v-model="values[item.field.uuid]"
:values="values"
:field="item.field"
:area="item.area"
:submittable="true"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === item.step"
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
:is-value-set="item.step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
/>
<FieldArea
v-else-if="item.type === 'readonly_field_area'"
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
:values="readonlyConditionalFieldValues"
:field="item.field"
:area="item.area"
:submittable="false"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:submitter="submitter"
:attachments-index="attachmentsIndex"
/>
<FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded"
:model-value="calculateFormula(item.field)"
:is-inline-size="isInlineSize"
:field="item.field"
:area="item.area"
:submittable="false"
:field-index="item.fieldIndex"
/>
</template>
</Teleport>
</template>
</template>
</template>
</template>
<script>
import FieldArea from './area'
import FormulaAreas from './formula_areas'
import FieldAreas from './areas'
export default {
name: 'AccessibilityAreas',
components: {
FieldArea
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
required: true
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
steps: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFields: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFieldValues: {
type: Object,
required: false,
default: () => ({})
},
formulaFields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
},
readonlyValues: {
type: Object,
required: false,
default: () => ({})
},
submitter: {
type: Object,
required: true
},
currentStep: {
type: Array,
required: false,
default: () => []
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
scrollEl: {
type: Object,
required: false,
default: null
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus-step'],
data () {
return {
isMathLoaded: false,
math: null,
textRuns: {},
areaRefs: []
}
},
computed: {
fieldValuesIndex () {
return this.filledFieldsIndex || this.extractStaticValues()
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
},
fieldAreasIndex () {
const index = Object.create(null)
this.steps.forEach((step, stepIndex) => {
step.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'field_area',
field,
area,
step,
stepIndex,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
})
return index
},
formulaAreasIndex () {
const index = Object.create(null)
this.formulaFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'formula_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
},
readonlyFieldAreasIndex () {
const index = Object.create(null)
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'readonly_field_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
}
},
beforeUpdate () {
this.areaRefs = []
},
async mounted () {
const [metadataResult] = await Promise.all([
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
...this.fetchOptions
}).then((r) => r.json()).catch(() => ({})),
this.loadMath()
])
this.textRuns = metadataResult.text_runs || {}
},
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField,
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
setAreaRef (el) {
if (el) {
this.areaRefs.push(el)
}
},
async loadMath () {
if (this.formulaFields.length && !this.isMathLoaded) {
const { Calculator } = await import('./calculator')
this.math = new Calculator()
this.isMathLoaded = true
}
},
extractStaticValues () {
const result = Object.create(null)
const root = this.$root.$el?.parentNode?.getRootNode() || document
const pageContainers = root.querySelectorAll('page-container')
pageContainers.forEach((container) => {
const overlay = container.querySelector('[id^="page-"]')
if (!overlay) return
const parts = overlay.id.split('-')
const pageIndex = parseInt(parts[parts.length - 1])
const docUuid = parts.slice(1, -1).join('-')
const fieldValues = overlay.querySelectorAll('field-value')
if (!fieldValues.length) return
result[docUuid] ||= Object.create(null)
result[docUuid][pageIndex] = []
fieldValues.forEach((el) => {
const style = el.style
const x = parseFloat(style.left) / 100
const y = parseFloat(style.top) / 100
const w = parseFloat(style.width) / 100
const h = parseFloat(style.height) / 100
const text = el.textContent.trim()
if (text) {
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
}
})
})
return result
},
findPageElement (docUuid, pageIndex) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
},
sortedItemsForPage (docUuid, pageIndex) {
const items = []
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
pageTextRuns.forEach((run) => {
items.push({
type: 'text_run',
text: run.text,
x: run.x,
y: run.y,
w: run.w,
h: run.h,
font_size: run.font_size
})
})
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...fieldAreas)
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...readonlyFieldAreas)
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
items.push(...formulaAreas)
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
items.push(...pageFieldValues)
items.sort((a, b) => {
const aCenterY = a.y + a.h / 2
const bCenterY = b.y + b.h / 2
const lineThreshold = Math.min(a.h, b.h) / 2
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
return a.x - b.x
}
return aCenterY - bCenterY
})
const grouped = []
let currentGroup = null
const closeGroup = () => {
if (!currentGroup) return
const groupItems = currentGroup.items
const minX = Math.min(...groupItems.map((i) => i.x))
const minY = Math.min(...groupItems.map((i) => i.y))
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
currentGroup.x = minX
currentGroup.y = minY
currentGroup.w = maxEndX - minX
currentGroup.h = maxEndY - minY
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
grouped.push(currentGroup)
currentGroup = null
}
for (const item of items) {
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
if (!isTextLike) {
closeGroup()
grouped.push(item)
continue
}
if (!currentGroup) {
currentGroup = { type: 'text_group', items: [] }
}
currentGroup.items.push(item)
}
closeGroup()
return grouped
}
}
}
</script>
+17 -2
View File
@@ -42,10 +42,25 @@ export default {
const areas = {} const areas = {}
this.field.areas?.forEach((area) => { this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
}) })
return Object.values(areas).slice(0, 6) const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
} }
} }
} }
+71 -27
View File
@@ -1,9 +1,14 @@
<template> <template>
<div <div
class="flex absolute lg:text-base -outline-offset-1 field-area" class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
dir="auto" dir="auto"
:style="[computedStyle, fontStyle]" :style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:role="submittable && !isNativeInputField ? 'button' : undefined"
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
> >
<div <div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -18,6 +23,7 @@
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10 text-base-content" class="max-h-10 text-base-content"
aria-hidden="true"
/> />
</span> </span>
</div> </div>
@@ -129,6 +135,7 @@
v-if="submittable" v-if="submittable"
type="checkbox" type="checkbox"
:value="false" :value="false"
:aria-label="field.name || fieldNames[field.type]"
class="aspect-square base-checkbox" class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue" :checked="!!modelValue"
@@ -149,6 +156,8 @@
v-if="submittable" v-if="submittable"
type="radio" type="radio"
:value="false" :value="false"
:name="`radio-area-${field.uuid}`"
:aria-label="optionValue(option)"
class="aspect-square checked:checkbox checked:checkbox-xs" class="aspect-square checked:checkbox checked:checkbox-xs"
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === optionValue(option)" :checked="!!modelValue && modelValue === optionValue(option)"
@@ -169,6 +178,7 @@
v-if="submittable" v-if="submittable"
type="checkbox" type="checkbox"
:value="false" :value="false"
:aria-label="optionValue(option)"
class="aspect-square base-checkbox" class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(optionValue(option))" :checked="!!modelValue && modelValue.includes(optionValue(option))"
@@ -400,6 +410,16 @@ export default {
kba: this.t('kba') kba: this.t('kba')
} }
}, },
isNativeInputField () {
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
},
fieldAreaLabel () {
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
if (this.area.option_uuid && this.option) {
return `${name} - ${this.optionValue(this.option)}`
}
return name
},
strikethroughWidth () { strikethroughWidth () {
if (this.isInlineSize) { if (this.isInlineSize) {
return '0.6cqmin' return '0.6cqmin'
@@ -507,7 +527,8 @@ export default {
try { try {
return this.formatDate( return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY') this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
) )
} catch { } catch {
return this.modelValue return this.modelValue
@@ -622,40 +643,63 @@ export default {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number) return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else { } else {
return number return number
} }
}, },
formatDate (date, format) { formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
const monthFormats = { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
M: 'numeric', const dayFormats = { D: 'numeric', DD: '2-digit' }
MM: '2-digit', const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
MMM: 'short', const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
MMMM: 'long' const minuteFormats = { m: 'numeric', mm: '2-digit' }
} const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = { const hasTime = /[HhAasz]/.test(format)
D: 'numeric',
DD: '2-digit'
}
const yearFormats = { const opts = {
YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)], day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)], year: yearFormats[format.match(/Y+/)]
timeZone: 'UTC' }
}).formatToParts(date)
return format if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
.replace(/M+/, parts.find((p) => p.type === 'month').value) if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
.replace(/Y+/, parts.find((p) => p.type === 'year').value) if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime && utc) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
}, },
updateMultipleSelectValue (value) { updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) { if (this.modelValue?.includes(value)) {
@@ -1,7 +1,11 @@
<template> <template>
<div> <div>
<div v-if="value.length"> <ul
<div v-if="value.length"
:aria-label="t('uploaded_files')"
class="list-none p-0 m-0"
>
<li
v-for="(val, index) in value" v-for="(val, index) in value"
:key="index" :key="index"
class="flex mb-2" class="flex mb-2"
@@ -20,23 +24,27 @@
<IconPaperclip <IconPaperclip
:width="16" :width="16"
class="flex-none" class="flex-none"
:heigh="16" :height="16"
aria-hidden="true"
/> />
<span> <span>
{{ attachmentsIndex[val].filename }} {{ attachmentsIndex[val].filename }}
</span> </span>
</a> </a>
<button <button
type="button"
class="remove-attachment-button" class="remove-attachment-button"
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
@click.prevent="removeAttachment(val)" @click.prevent="removeAttachment(val)"
> >
<IconTrashX <IconTrashX
:width="18" :width="18"
:heigh="19" :height="19"
aria-hidden="true"
/> />
</button> </button>
</div> </li>
</div> </ul>
<template v-else> <template v-else>
<input <input
value="" value=""
@@ -0,0 +1,874 @@
/*
* Portions of this file are derived from the Dentaku gem
* https://github.com/rubysolo/dentaku
*
* MIT License
*
* Copyright (c) 2012 Solomon White
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Modifications and JavaScript port
* Copyright (c) 2026 DocuSeal, LLC
*/
class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } }
class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } }
class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } }
class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } }
class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } }
class UnboundVariableError extends CalculatorError {
constructor (vars) {
super('no value provided for variables: ' + vars.join(', '))
this.name = 'UnboundVariableError'
this.unbound = vars
}
}
class Token {
constructor (category, value, raw) {
this.category = category
this.value = value
this.raw = raw == null ? String(value) : String(raw)
}
is (cat) { return this.category === cat }
}
const OPERATOR_NAMES = {
'^': 'pow',
'+': 'add',
'-': 'subtract',
'*': 'multiply',
'/': 'divide',
'%': 'mod',
'|': 'bitor',
'&': 'bitand',
'<<': 'bitshiftleft',
'>>': 'bitshiftright'
}
const COMPARATOR_NAMES = {
'<=': 'le',
'>=': 'ge',
'!=': 'ne',
'<>': 'ne',
'<': 'lt',
'>': 'gt',
'==': 'eq',
'=': 'eq'
}
const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' }
function buildScanners (caseSensitive) {
return [
(s) => {
const m = /^\s+/.exec(s)
return m ? { tokens: [], length: m[0].length } : null
},
(s) => {
const m = /^null\b/i.exec(s)
return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null
},
(s) => {
const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length }
},
(s) => {
const m = /^0x[0-9a-f]+\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length }
},
(s) => {
const m = /^"([^"]*)"/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s) => {
const m = /^'([^']*)'/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s, last) => {
if (s[0] !== '-') return null
const ok = last == null ||
last.is('operator') ||
last.is('comparator') ||
last.is('combinator') ||
last.value === 'open' ||
last.value === 'comma' ||
last.value === 'array_start'
if (!ok) return null
return { tokens: [new Token('operator', 'negate', '-')], length: 1 }
},
(s) => {
const m = /^(and|or|&&|\|\|)\s/i.exec(s)
if (!m) return null
const raw = m[1].toLowerCase()
const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw
return { tokens: [new Token('combinator', name, m[0])], length: m[0].length }
},
(s) => {
const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s)
if (!m) return null
return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(\(|\)|,)/.exec(s)
if (!m) return null
return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 }
if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 }
return null
},
(s) => {
const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s)
if (!m) return null
return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(true|false)\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length }
},
(s) => {
const m = /^(\w+!?)\s*\(/.exec(s)
if (!m) return null
const name = m[1].toLowerCase()
return {
tokens: [
new Token('function', name, m[1]),
new Token('grouping', 'open', '(')
],
length: m[0].length
}
},
(s) => {
const m = /^[A-Za-z_][\w.]*\b/.exec(s)
if (!m) return null
const value = caseSensitive ? m[0] : m[0].toLowerCase()
return { tokens: [new Token('identifier', value, m[0])], length: m[0].length }
},
(s) => {
const m = /^`([^`]*)`/.exec(s)
if (!m) return null
return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length }
}
]
}
function tokenize (input, options) {
options = options || {}
let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '')
const scanners = buildScanners(options.caseSensitive === true)
const tokens = []
let nesting = 0
while (remaining.length > 0) {
let matched = false
for (let i = 0; i < scanners.length; i++) {
const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null)
if (result) {
for (const tok of result.tokens) {
if (tok.category === 'grouping' && tok.value === 'open') nesting++
if (tok.category === 'grouping' && tok.value === 'close') {
nesting--
if (nesting < 0) throw new TokenizerError('too many closing parentheses')
}
tokens.push(tok)
}
remaining = remaining.slice(result.length)
matched = true
break
}
}
if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'")
}
if (nesting > 0) throw new TokenizerError('too many opening parentheses')
return tokens
}
function uniq (arr) {
const seen = new Set()
const out = []
for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } }
return out
}
function toNumber (val) {
if (typeof val === 'number') return val
if (typeof val === 'boolean') return val ? 1 : 0
if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric")
if (typeof val === 'string') {
if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric")
const n = Number(val)
if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric")
return n
}
throw new ArgumentError("'" + val + "' is not coercible to numeric")
}
class Node {
static precedence = 0
static arity = null
static rightAssociative = false
static resolveClass () { return this }
static minParamCount () { return this.arity }
static maxParamCount () { return this.arity }
value () { return null }
dependencies () { return [] }
get type () { return null }
}
class NilNode extends Node { value () { return null } }
class Numeric extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'numeric' }
}
class Logical extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'logical' }
}
class StringNode extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'string' }
}
class Identifier extends Node {
constructor (token) { super(); this.ident = token.value }
value (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
if (v instanceof Node) return v.value(ctx)
if (typeof v === 'function') return v()
return v
}
throw new UnboundVariableError([this.ident])
}
dependencies (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
return v instanceof Node ? v.dependencies(ctx) : []
}
return [this.ident]
}
}
class Grouping extends Node {
constructor (node) { super(); this.node = node }
value (ctx) { return this.node.value(ctx) }
get type () { return this.node.type }
dependencies (ctx) { return this.node.dependencies(ctx) }
}
class Operation extends Node {
constructor (left, right) { super(); this.left = left; this.right = right }
dependencies (ctx) {
const l = this.left ? this.left.dependencies(ctx) : []
const r = this.right ? this.right.dependencies(ctx) : []
return uniq(l.concat(r))
}
}
class Arithmetic extends Operation { get type () { return 'numeric' } }
class Addition extends Arithmetic {
static precedence = 10
value (ctx) {
const l = this.left.value(ctx)
const r = this.right.value(ctx)
if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r)
return toNumber(l) + toNumber(r)
}
}
class Subtraction extends Arithmetic {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) }
}
class Multiplication extends Arithmetic {
static precedence = 20
value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) }
}
class Division extends Arithmetic {
static precedence = 20
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) / r
}
}
class Modulo extends Arithmetic {
static arity = 2
static precedence = 20
static resolveClass (nextTok) {
if (!nextTok) return Percentage
if (nextTok.category === 'operator') return Percentage
if (nextTok.category === 'comparator') return Percentage
if (nextTok.category === 'combinator') return Percentage
if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage
if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage
return Modulo
}
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) % r
}
}
class Percentage extends Arithmetic {
static arity = 1
static precedence = 30
constructor (child) { super(child, null) }
value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class Exponentiation extends Arithmetic {
static precedence = 30
static rightAssociative = true
value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) }
}
class Negation extends Arithmetic {
static arity = 1
static precedence = 40
static rightAssociative = true
constructor (node) { super(node, null) }
value (ctx) { return -toNumber(this.left.value(ctx)) }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class BitwiseOr extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseAnd extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftLeft extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftRight extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
function cmpCast (v) {
if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v)
return v
}
class Comparator extends Operation {
static precedence = 5
get type () { return 'logical' }
}
class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } }
class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } }
class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } }
class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } }
class NotEqual extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l !== r
}
}
class Equal extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l === r
}
}
class Combinator extends Operation {
static precedence = 0
get type () { return 'logical' }
}
class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } }
class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } }
class ArrayNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...items) {
super()
this.items = items
}
value (ctx) { return this.items.map((i) => i.value(ctx)) }
dependencies (ctx) {
return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx))))
}
get type () { return 'array' }
}
class FunctionNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...args) {
super()
this.args = args
}
dependencies (ctx) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
const FUNCTION_REGISTRY = Object.create(null)
function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls }
function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] }
function defineFunction (name, spec) {
const min = spec.min == null ? 0 : spec.min
const max = spec.max == null ? Infinity : spec.max
const call = spec.call
const type = spec.type || 'numeric'
class F extends FunctionNode {
static arity = (min === max) ? min : null
static minParamCount () { return min }
static maxParamCount () { return max }
value (ctx) {
const vals = this.args.map((a) => a.value(ctx))
return call(vals, ctx)
}
get type () { return type }
}
registerFunctionClass(name, F)
return F
}
function flattenNumeric (vals) {
const out = [];
(function walk (v) {
if (Array.isArray(v)) v.forEach(walk)
else out.push(toNumber(v))
})(vals)
return out
}
defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) })
defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) })
defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) })
defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) })
defineFunction('avg', {
min: 1,
call: (v) => {
const nums = flattenNumeric(v)
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
})
defineFunction('count', {
min: 1,
max: 1,
call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1)
})
defineFunction('round', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return Math.round(n * f) / f
}
})
defineFunction('roundup', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f
}
})
defineFunction('rounddown', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f
}
})
const MATH_FNS = {
sqrt: Math.sqrt,
cbrt: Math.cbrt,
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
exp: Math.exp,
log: Math.log,
log2: Math.log2,
log10: Math.log10,
floor: Math.floor,
ceil: Math.ceil
}
Object.keys(MATH_FNS).forEach((name) => {
const fn = MATH_FNS[name]
defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) })
})
defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) })
defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) })
defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) })
defineFunction('pi', { min: 0, max: 0, call: () => Math.PI })
defineFunction('e', { min: 0, max: 0, call: () => Math.E })
defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] })
defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) })
defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) })
defineFunction('xor', {
min: 2,
type: 'logical',
call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false)
})
class IfFunction extends FunctionNode {
static arity = 3
static minParamCount () { return 3 }
static maxParamCount () { return 3 }
value (ctx) {
return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx)
}
get type () { return this.args[1].type }
dependencies (ctx) {
try {
return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx)
} catch (e) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
}
registerFunctionClass('if', IfFunction)
defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length })
defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() })
defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() })
defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') })
defineFunction('left', {
min: 2,
max: 2,
type: 'string',
call: (v) => String(v[0]).slice(0, toNumber(v[1]))
})
defineFunction('right', {
min: 2,
max: 2,
type: 'string',
call: (v) => {
const n = toNumber(v[1])
return n === 0 ? '' : String(v[0]).slice(-n)
}
})
defineFunction('mid', {
min: 3,
max: 3,
type: 'string',
call: (v) => {
const s = String(v[0])
const start = toNumber(v[1]) - 1
const len = toNumber(v[2])
return s.slice(start, start + len)
}
})
defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() })
defineFunction('contains', {
min: 2,
max: 2,
type: 'logical',
call: (v) => {
if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1
return String(v[0]).indexOf(String(v[1])) !== -1
}
})
const OP_CLASSES = {
add: Addition,
subtract: Subtraction,
multiply: Multiplication,
divide: Division,
pow: Exponentiation,
negate: Negation,
mod: Modulo,
bitor: BitwiseOr,
bitand: BitwiseAnd,
bitshiftleft: BitwiseShiftLeft,
bitshiftright: BitwiseShiftRight,
lt: LessThan,
gt: GreaterThan,
le: LessThanOrEqual,
ge: GreaterThanOrEqual,
ne: NotEqual,
eq: Equal,
and: And,
or: Or
}
function isOperationClass (cls) {
return cls && (cls.prototype instanceof Operation)
}
function parse (tokens) {
if (!tokens || tokens.length === 0) return new NilNode()
const output = []
const operations = []
const arities = []
const skip = new Set()
function consume (count) {
if (count == null) count = 2
const op = operations.pop()
if (!op) throw new ParseError('invalid statement')
const outputSize = output.length
const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null
const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null
const actualArgs = (op.arity != null) ? op.arity : count
const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count)
const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count)
if (outputSize < minSize || actualArgs < minSize) {
throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')')
}
if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) {
throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')')
}
if (op === ArrayNode && output.length === 0) {
output.push(new ArrayNode())
return
}
if (outputSize < actualArgs) throw new ParseError('invalid statement')
const args = []
for (let i = 0; i < actualArgs; i++) args.unshift(output.pop())
// eslint-disable-next-line new-cap
output.push(new op(...args))
}
function handleOperator (token, lookahead) {
let cls = OP_CLASSES[token.value]
if (!cls) throw new ParseError('Unknown operator ' + token.value)
if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead)
const prec = cls.precedence
if (cls.rightAssociative) {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec < top.precedence) consume()
else break
}
} else {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec <= top.precedence) consume()
else break
}
}
operations.push(cls)
}
function handleFunction (token) {
const fn = getFunctionClass(token.value)
if (!fn) throw new ParseError('Undefined function ' + token.value)
arities.push(0)
operations.push(fn)
}
function handleArray (token) {
if (token.value === 'array_start') {
operations.push(ArrayNode)
arities.push(0)
} else {
while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume()
if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket')
consume(arities.pop() + 1)
}
}
function handleGrouping (token, lookahead, index) {
if (token.value === 'open') {
if (lookahead && lookahead.value === 'close') {
skip.add(index + 1)
arities.pop()
consume(0)
} else {
operations.push(Grouping)
}
} else if (token.value === 'close') {
while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume()
const lparen = operations.pop()
if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis')
const top = operations[operations.length - 1]
if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1)
} else if (token.value === 'comma') {
if (arities.length === 0) throw new ParseError('invalid statement')
arities[arities.length - 1] += 1
while (operations.length > 0 &&
operations[operations.length - 1] !== Grouping &&
operations[operations.length - 1] !== ArrayNode) consume()
} else {
throw new ParseError('Unknown grouping token ' + token.value)
}
}
for (let i = 0; i < tokens.length; i++) {
if (skip.has(i)) continue
const token = tokens[i]
const lookahead = tokens[i + 1]
switch (token.category) {
case 'numeric': output.push(new Numeric(token)); break
case 'logical': output.push(new Logical(token)); break
case 'string': output.push(new StringNode(token)); break
case 'identifier': output.push(new Identifier(token)); break
case 'null': output.push(new NilNode()); break
case 'operator':
case 'comparator':
case 'combinator':
handleOperator(token, lookahead); break
case 'function': handleFunction(token); break
case 'array': handleArray(token); break
case 'grouping': handleGrouping(token, lookahead, i); break
default:
throw new ParseError('Not implemented for tokens of category ' + token.category)
}
}
while (operations.length > 0) consume()
if (output.length !== 1) throw new ParseError('Invalid statement')
return output[0]
}
class Calculator {
constructor (options) {
options = options || {}
this.caseSensitive = options.caseSensitive === true
this.memory = Object.create(null)
this._astCache = Object.create(null)
}
key (k) { return this.caseSensitive ? k : String(k).toLowerCase() }
normalize (data) {
const out = Object.create(null)
if (!data) return out
for (const k of Object.keys(data)) out[this.key(k)] = data[k]
return out
}
store (data) { Object.assign(this.memory, this.normalize(data)); return this }
bind (data) { return this.store(data) }
clear () { this.memory = Object.create(null); return this }
ast (expression) {
const cached = this._astCache[expression]
if (cached) return cached
const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive }))
this._astCache[expression] = node
return node
}
tokenize (expression) {
return tokenize(expression, { caseSensitive: this.caseSensitive })
}
dependencies (expression) {
return this.ast(expression).dependencies(this.memory)
}
evaluate (expression, data, onError) {
try {
return this.evaluateStrict(expression, data)
} catch (e) {
if (typeof data === 'function') return data(expression, e)
if (typeof onError === 'function') return onError(expression, e)
return undefined
}
}
evaluateStrict (expression, data) {
const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data))
const node = this.ast(expression)
return node.value(ctx)
}
addFunction (name, spec) {
defineFunction(name, {
min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs,
max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs,
type: spec.type || 'numeric',
call: spec.call
})
return this
}
}
export {
Calculator,
tokenize,
parse,
CalculatorError,
ParseError,
TokenizerError,
UnboundVariableError,
ZeroDivisionError,
ArgumentError
}
export function addFunction (name, spec) { defineFunction(name, spec) }
+14 -3
View File
@@ -3,10 +3,13 @@
id="form_completed" id="form_completed"
class="mx-auto max-w-md flex flex-col completed-form" class="mx-auto max-w-md flex flex-col completed-form"
dir="auto" dir="auto"
role="status"
tabindex="-1"
> >
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto"> <div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck <IconCircleCheck
class="inline text-green-600" class="inline text-green-600"
aria-hidden="true"
:width="30" :width="30"
:height="30" :height="30"
/> />
@@ -42,8 +45,12 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSendingCopy" v-if="isSendingCopy"
class="animate-spin" class="animate-spin"
aria-hidden="true"
/>
<IconMail
v-else
aria-hidden="true"
/> />
<IconMail v-else />
<span> <span>
{{ t('send_copy_via_email') }} {{ t('send_copy_via_email') }}
</span> </span>
@@ -57,8 +64,12 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isDownloading" v-if="isDownloading"
class="animate-spin" class="animate-spin"
aria-hidden="true"
/>
<IconDownload
v-else
aria-hidden="true"
/> />
<IconDownload v-else />
<span> <span>
{{ t('download') }} {{ t('download') }}
</span> </span>
@@ -199,7 +210,7 @@ export default {
}) })
} }
document.querySelectorAll('#decline_button').forEach((button) => { document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
button.setAttribute('disabled', 'true') button.setAttribute('disabled', 'true')
}) })
}, },
+64 -17
View File
@@ -27,15 +27,20 @@
</label> </label>
<button <button
v-if="withToday" v-if="withToday"
type="button"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button" class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >
<IconCalendarCheck :width="16" /> <IconCalendarCheck
:width="16"
aria-hidden="true"
/>
{{ t('set_today') }} {{ t('set_today') }}
</button> </button>
</div> </div>
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
dir="auto" dir="auto"
> >
@@ -51,12 +56,19 @@
:max="validationMax" :max="validationMax"
class="base-input !text-2xl text-center w-full" class="base-input !text-2xl text-center w-full"
:required="field.required" :required="field.required"
type="date" :aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:name="`values[${field.uuid}]`" :type="inputType"
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
@keydown.enter="onEnter" @keydown.enter="onEnter"
@focus="$emit('focus')" @focus="$emit('focus')"
@paste="onPaste" @paste="onPaste"
> >
<input
v-if="formatType === 'datetime'"
type="hidden"
:name="`values[${field.uuid}]`"
:value="modelValue"
>
</div> </div>
</div> </div>
</template> </template>
@@ -92,14 +104,19 @@ export default {
}, },
emits: ['update:model-value', 'focus', 'submit'], emits: ['update:model-value', 'focus', 'submit'],
computed: { computed: {
formatType () {
const format = this.field.preferences?.format || ''
if (/[HhAasz]/.test(format)) return 'datetime'
if (format && !/[Dd]/.test(format)) return 'month'
return 'date'
},
inputType () {
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
},
dateNowString () { dateNowString () {
const today = new Date() return this.formatDateValue(new Date())
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}, },
validationMin () { validationMin () {
if (this.field.validation?.min) { if (this.field.validation?.min) {
@@ -116,6 +133,8 @@ export default {
} }
}, },
withToday () { withToday () {
if (this.formatType === 'datetime') return false
const todayDate = new Date().setHours(0, 0, 0, 0) const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) { if (this.validationMin) {
@@ -132,9 +151,25 @@ export default {
}, },
value: { value: {
set (value) { set (value) {
if (this.formatType === 'datetime' && value) {
const d = new Date(value)
if (!isNaN(d)) {
this.$emit('update:model-value', d.toISOString())
return
}
}
this.$emit('update:model-value', value) this.$emit('update:model-value', value)
}, },
get () { get () {
if (this.formatType === 'datetime') {
const d = new Date(this.modelValue)
return isNaN(d) ? '' : this.formatDateValue(d)
}
return this.modelValue return this.modelValue
} }
} }
@@ -158,20 +193,32 @@ export default {
const parsedDate = new Date(pasteData) const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) { if (isNaN(parsedDate)) return
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000) this.setInputValue(parsedDate)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}, },
setCurrentDate () { setCurrentDate () {
this.setInputValue(new Date())
},
setInputValue (date) {
const inputEl = this.$refs.input const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000) if (this.formatType === 'date') {
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
} else {
inputEl.value = this.formatDateValue(date)
}
inputEl.dispatchEvent(new Event('input', { bubbles: true })) inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
formatDateValue (date) {
const pad = (n) => String(n).padStart(2, '0')
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (this.formatType === 'month') return ymd.slice(0, 7)
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return ymd
} }
} }
} }
+40 -29
View File
@@ -39,8 +39,9 @@
ref="input" ref="input"
:multiple="multiple" :multiple="multiple"
:accept="accept" :accept="accept"
:aria-label="message"
type="file" type="file"
class="hidden" class="sr-only"
@change="onSelectFiles" @change="onSelectFiles"
> >
</label> </label>
@@ -50,6 +51,36 @@
<script> <script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
components: { components: {
@@ -152,8 +183,14 @@ export default {
} }
}) })
} else { } else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') { try {
file = await this.convertBmpToPng(file) if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
} }
formData.append('file', file) formData.append('file', file)
@@ -180,32 +217,6 @@ export default {
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }
+157 -10
View File
@@ -1,5 +1,52 @@
<template> <template>
<Teleport
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
to="#sr_only_content"
>
<button
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<Teleport
v-for="item in (withAccessibilityAreas === null ? schema : [])"
:key="item.attachment_uuid"
:to="`#document-${item.attachment_uuid} .sr_only_content`"
>
<button
v-if="!isAccessibilityMode"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<AccessibilityAreas
v-if="withAccessibilityAreas || isAccessibilityMode"
ref="areas"
:submitter-slug="submitterSlug"
:steps="stepFields"
:readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields"
:values="values"
:readonly-values="readonlyFieldValues"
:submitter="submitter"
:scroll-el="scrollEl"
:current-step="currentStepFields"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:scroll-padding="scrollPadding"
:attachments-index="attachmentsIndex"
:fetch-options="fetchOptions"
:filled-fields-index="filledFieldsIndex"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
ref="areas" ref="areas"
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
@@ -14,6 +61,7 @@
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]" @focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/> />
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
:steps="readonlyConditionalFields.map((e) => [e])" :steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFieldValues" :values="readonlyConditionalFieldValues"
:submitter="submitter" :submitter="submitter"
@@ -21,7 +69,7 @@
:submittable="false" :submittable="false"
/> />
<FormulaFieldAreas <FormulaFieldAreas
v-if="formulaFields.length" v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields" :fields="formulaFields"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:values="values" :values="values"
@@ -47,7 +95,7 @@
v-else v-else
id="complete_form_button" id="complete_form_button"
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center" class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
form="steps_form" form="complete_form"
type="submit" type="submit"
name="completed" name="completed"
value="true" value="true"
@@ -57,6 +105,7 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSubmittingComplete" v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5" class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/> />
<span> <span>
{{ t('complete') }} {{ t('complete') }}
@@ -64,6 +113,39 @@
</span> </span>
</button> </button>
</Teleport> </Teleport>
<Teleport
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
:key="ref"
:to="ref"
>
<button
class="complete-button btn btn-sm btn-neutral text-white px-4"
form="complete_form"
type="submit"
name="completed"
value="true"
:disabled="isSubmittingComplete"
>
<span class="flex items-center">
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/>
<span>
{{ t('complete') }}
</span>
</span>
</button>
</Teleport>
<form
v-if="!isCompleted && !isInvite"
id="complete_form"
class="hidden"
:action="submitPath"
method="post"
@submit.prevent="submitStep"
/>
<button <button
v-if="!isFormVisible" v-if="!isFormVisible"
id="expand_form_button" id="expand_form_button"
@@ -88,6 +170,7 @@
class="absolute right-0 mr-4" class="absolute right-0 mr-4"
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</button> </button>
<div <div
@@ -103,11 +186,13 @@
class="absolute right-0 top-0 minimize-form-button" class="absolute right-0 top-0 minimize-form-button"
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'" :class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'"
:title="t('minimize')" :title="t('minimize')"
:aria-label="t('minimize')"
@click.prevent="minimizeForm" @click.prevent="minimizeForm"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</button> </button>
<div <div
@@ -193,6 +278,7 @@
/> />
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -203,6 +289,7 @@
:id="currentField.uuid" :id="currentField.uuid"
dir="auto" dir="auto"
:required="currentField.required" :required="currentField.required"
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
class="select base-input !text-2xl w-full text-center font-normal" class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }" :class="{ 'text-gray-300': !values[currentField.uuid] }"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@@ -250,6 +337,7 @@
</label> </label>
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -309,6 +397,7 @@
> >
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -402,6 +491,8 @@
:remember-signature="rememberSignature" :remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:require-signing-reason="requireSigningReason" :require-signing-reason="requireSigningReason"
:signature-text="signatureText"
:signature-src="signatureSrc"
:button-text="submitButtonText" :button-text="submitButtonText"
:dry-run="dryRun" :dry-run="dryRun"
:with-disclosure="withDisclosure" :with-disclosure="withDisclosure"
@@ -509,6 +600,7 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSubmitting" v-if="isSubmitting"
class="mr-1 animate-spin" class="mr-1 animate-spin"
aria-hidden="true"
/> />
<span> <span>
{{ submitButtonText }} {{ submitButtonText }}
@@ -520,6 +612,7 @@
</button> </button>
<div <div
v-if="showFillAllRequiredFields" v-if="showFillAllRequiredFields"
role="alert"
class="text-center mt-1" class="text-center mt-1"
> >
{{ t('please_fill_all_required_fields') }} {{ t('please_fill_all_required_fields') }}
@@ -552,8 +645,10 @@
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
/> />
<div <nav
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
:aria-label="t('form_progress')"
:aria-hidden="isCompleted"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none" class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
> >
<div class="flex items-center flex-wrap steps-progress"> <div class="flex items-center flex-wrap steps-progress">
@@ -561,16 +656,18 @@
v-for="(step, index) in stepFields" v-for="(step, index) in stepFields"
:key="step[0].uuid" :key="step[0].uuid"
> >
<a <button
v-if="!onlyRequiredFields || step.some((f) => f.required)" v-if="!onlyRequiredFields || step.some((f) => f.required)"
href="#" type="button"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1" :aria-label="`${t('step')} ${index + 1}`"
:aria-current="index === currentStep ? 'step' : undefined"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1 p-0"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }" :class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]" @click="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/> />
</template> </template>
</div> </div>
</div> </nav>
<div <div
v-else v-else
class="mt-5" class="mt-5"
@@ -582,6 +679,7 @@
<script> <script>
import FieldAreas from './areas' import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas' import FormulaFieldAreas from './formula_areas'
import AccessibilityAreas from './accessibility_areas'
import ImageStep from './image_step' import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import InitialsStep from './initials_step' import InitialsStep from './initials_step'
@@ -640,6 +738,7 @@ export default {
name: 'SubmissionForm', name: 'SubmissionForm',
components: { components: {
FieldAreas, FieldAreas,
AccessibilityAreas,
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AppearsOn, AppearsOn,
@@ -725,6 +824,16 @@ export default {
required: false, required: false,
default: null default: null
}, },
completeButtonContainer: {
type: Object,
required: false,
default: null
},
completeButtonScrollContainer: {
type: Object,
required: false,
default: null
},
schema: { schema: {
type: Array, type: Array,
required: false, required: false,
@@ -822,6 +931,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
withAccessibilityAreas: {
type: Boolean,
required: false,
default: null
},
fields: { fields: {
type: Array, type: Array,
required: false, required: false,
@@ -832,6 +951,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
previousSignatureValue: { previousSignatureValue: {
type: String, type: String,
required: false, required: false,
@@ -922,7 +1051,9 @@ export default {
isSubmitting: false, isSubmitting: false,
isSubmittingComplete: false, isSubmittingComplete: false,
submittedValues: {}, submittedValues: {},
recalculateButtonDisabledKey: '' isFormStarted: false,
recalculateButtonDisabledKey: '',
isAccessibilityMode: false
} }
}, },
computed: { computed: {
@@ -969,6 +1100,10 @@ export default {
}) })
}) })
}, },
showCompleteButton () {
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
},
submitButtonText () { submitButtonText () {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
return this.t('submit') return this.t('submit')
@@ -1446,7 +1581,7 @@ export default {
} }
this.enableScrollIntoField = false this.enableScrollIntoField = false
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], input[type="tel"], textarea, select')?.focus()
this.enableScrollIntoField = true this.enableScrollIntoField = true
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
@@ -1570,6 +1705,8 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
this.isFormStarted = true
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex)) const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) { if (nextStep) {
@@ -1606,6 +1743,7 @@ export default {
}, },
async performComplete (resp) { async performComplete (resp) {
this.isCompleted = true this.isCompleted = true
this.isFormVisible = true
if (resp?.text) { if (resp?.text) {
const respData = await resp.text() const respData = await resp.text()
@@ -1617,6 +1755,15 @@ export default {
if (this.completedRedirectUrl) { if (this.completedRedirectUrl) {
window.location.href = sanitizeUrl(this.completedRedirectUrl) window.location.href = sanitizeUrl(this.completedRedirectUrl)
} else {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const completedEl = root.getElementById('form_completed')
if (completedEl) {
completedEl.focus()
}
})
} }
} }
} }
@@ -68,34 +68,9 @@ export default {
} }
}, },
async mounted () { async mounted () {
const { const { Calculator } = await import('./calculator')
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
this.math = create({ this.math = new Calculator()
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.isMathLoaded = true this.isMathLoaded = true
}, },
+249 -53
View File
@@ -1,7 +1,19 @@
const en = { const en = {
step: 'Step',
form_progress: 'Form progress',
close: 'Close',
uploaded_files: 'Uploaded files',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file', please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length', must_be_characters_length: 'Must be {number} characters long',
must_be_valid_ssn: 'Must be a valid SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Must be a valid EIN (XX-XXXXXXX)',
must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID', verify_id: 'Verify ID',
identity_verification: 'Identity verification', identity_verification: 'Identity verification',
@@ -97,13 +109,27 @@ const en = {
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds' browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds',
enter_screen_reader_mode: 'Enter screen reader mode'
} }
const es = { const es = {
step: 'Paso',
form_progress: 'Progreso del formulario',
close: 'Cerrar',
uploaded_files: 'Archivos subidos',
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen', please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud', must_be_characters_length: 'Debe tener {number} caracteres de longitud',
must_be_valid_ssn: 'Debe ser un SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Debe ser un EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad', identity_verification: 'Verificación de identidad',
@@ -124,7 +150,7 @@ const es = {
verification_code_is_invalid: 'El código de verificación no es válido', verification_code_is_invalid: 'El código de verificación no es válido',
sign_on_the_touchscreen: 'Firmar en pantalla táctil', sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el', by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta la',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica', electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma', esignature_disclosure: 'Divulgación de eFirma',
already_paid: 'Ya pagado', already_paid: 'Ya pagado',
@@ -199,13 +225,27 @@ const es = {
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
wait_countdown_seconds: 'Espera {countdown} segundos' browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos',
enter_screen_reader_mode: 'Activar modo lector de pantalla'
} }
const it = { const it = {
step: 'Passo',
form_progress: 'Progresso del modulo',
close: 'Chiudi',
uploaded_files: 'File caricati',
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine', please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri', must_be_characters_length: 'Deve essere lungo {number} caratteri',
must_be_valid_ssn: 'Deve essere un SSN valido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve essere un EIN valido (XX-XXXXXXX)',
must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità", identity_verification: "Verifica dell'identità",
@@ -226,7 +266,7 @@ const it = {
verification_code_is_invalid: 'Il codice di verifica non è valido', verification_code_is_invalid: 'Il codice di verifica non è valido',
sign_on_the_touchscreen: 'Firma su schermo tattile', sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma", scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il', by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti la',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica', electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma', esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza', minimize: 'Minimizza',
@@ -239,7 +279,7 @@ const it = {
date: 'Data', date: 'Data',
number: 'Numero', number: 'Numero',
image: 'Immagine', image: 'Immagine',
pay: 'Pagamento', pay: 'Paga',
file: 'File', file: 'File',
select: 'Seleziona', select: 'Seleziona',
checkbox: 'Checkbox', checkbox: 'Checkbox',
@@ -301,13 +341,27 @@ const it = {
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
wait_countdown_seconds: 'Attendi {countdown} secondi' browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi',
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
} }
const de = { const de = {
step: 'Schritt',
form_progress: 'Formularfortschritt',
close: 'Schließen',
uploaded_files: 'Hochgeladene Dateien',
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein', must_be_characters_length: 'Muss {number} Zeichen lang sein',
must_be_valid_ssn: 'Muss eine gültige SSN sein (XXX-XX-XXXX)',
must_be_valid_ein: 'Muss eine gültige EIN sein (XX-XXXXXXX)',
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
identity_verification: 'Identitätsprüfung', identity_verification: 'Identitätsprüfung',
@@ -403,13 +457,27 @@ const de = {
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.', 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' browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
} }
const fr = { const fr = {
step: 'Étape',
form_progress: 'Progression du formulaire',
close: 'Fermer',
uploaded_files: 'Fichiers téléchargés',
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image', please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères', must_be_characters_length: 'Doit comporter {number} caractères',
must_be_valid_ssn: 'Doit être un SSN valide (XXX-XX-XXXX)',
must_be_valid_ein: 'Doit être un EIN valide (XX-XXXXXXX)',
must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
verify_id: "Vérifier l'ID", verify_id: "Vérifier l'ID",
identity_verification: "Vérification d'identité", identity_verification: "Vérification d'identité",
@@ -505,13 +573,27 @@ const fr = {
upload: 'Téléverser', upload: 'Téléverser',
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes' browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
} }
const pl = { const pl = {
step: 'Krok',
form_progress: 'Postęp formularza',
close: 'Zamknij',
uploaded_files: 'Przesłane pliki',
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu', please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków', must_be_characters_length: 'Musi mieć długość {number} znaków',
must_be_valid_ssn: 'Musi być prawidłowym numerem SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musi być prawidłowym numerem EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID', verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości', identity_verification: 'Weryfikacja tożsamości',
@@ -534,7 +616,7 @@ const pl = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na', by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury', electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu', esignature_disclosure: 'informację o e-podpisie',
minimize: 'Zminimalizuj', minimize: 'Zminimalizuj',
text: 'Tekst', text: 'Tekst',
already_paid: 'Już zapłacono', already_paid: 'Już zapłacono',
@@ -602,18 +684,32 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy', toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany', email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie', processing: 'Przetwarzanie',
pay_with_stripe: 'Płatność za pomocą Stripe', pay_with_stripe: 'Zapłać za pomocą Stripe',
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.', signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund' browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund',
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
} }
const uk = { const uk = {
step: 'Крок',
form_progress: 'Прогрес форми',
close: 'Закрити',
uploaded_files: 'Завантажені файли',
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення', please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів', must_be_characters_length: 'Має містити {number} символів',
must_be_valid_ssn: 'Має бути дійсним SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Має бути дійсним EIN (XX-XXXXXXX)',
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності', verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи', identity_verification: 'Ідентифікація особи',
@@ -631,12 +727,12 @@ const uk = {
authored_by: 'Автор', authored_by: 'Автор',
select_a_reason: 'Виберіть причину', select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним', value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним', verification_code_is_invalid: 'Невірний код підтвердження',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані', sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з', by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису', electronic_signature_disclosure: 'інформацією про е-підпис',
esignature_disclosure: 'Розголошення еПідпису', esignature_disclosure: 'інформацією про е-підпис',
minimize: 'Зменшити', minimize: 'Зменшити',
text: 'Текст', text: 'Текст',
already_paid: 'Вже оплачено', already_paid: 'Вже оплачено',
@@ -704,23 +800,37 @@ const uk = {
toggle_multiline_text: 'Перемкнути Багаторядковий Текст', toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений', email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка', processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe', pay_with_stripe: 'Сплатити за допомогою Stripe',
reupload: 'Перезавантажити', reupload: 'Перезавантажити',
upload: 'Завантажити', upload: 'Завантажити',
files: 'Файли', files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд',
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
} }
const cs = { const cs = {
step: 'Krok',
form_progress: 'Průběh formuláře',
close: 'Zavřít',
uploaded_files: 'Nahrané soubory',
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor', please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků', must_be_characters_length: 'Musí mít délku {number} znaků',
must_be_valid_ssn: 'Musí být platné SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musí být platné EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID', verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity', identity_verification: 'Ověření identity',
complete: 'Dokončit', complete: 'Dokončit',
fill_all_required_fields_to_complete: 'Please complete all mandatory fields', fill_all_required_fields_to_complete: 'Vyplňte povinná pole pro dokončení',
sign_and_complete: 'Podepsat a dokončit', sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat', invite: 'Pozvat',
email: 'E-mail', email: 'E-mail',
@@ -728,8 +838,8 @@ const cs = {
reviewed: 'Zkontrolováno', reviewed: 'Zkontrolováno',
other: 'Jiné', other: 'Jiné',
authored_by_me: 'Autorem jsem já', authored_by_me: 'Autorem jsem já',
approved_by: 'Schváleno kým', approved_by: 'Schválil(a)',
reviewed_by: 'Zkontrolováno kým', reviewed_by: 'Zkontroloval(a)',
authored_by: 'Autorem', authored_by: 'Autorem',
select_a_reason: 'Vyberte důvod', select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná', value_is_invalid: 'Hodnota je neplatná',
@@ -738,7 +848,7 @@ const cs = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s', by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu', electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu', esignature_disclosure: 'informací o e-podpisu',
already_paid: 'Už zaplaceno', already_paid: 'Už zaplaceno',
minimize: 'Minimalizovat', minimize: 'Minimalizovat',
text: 'Text', text: 'Text',
@@ -806,18 +916,32 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text', toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán', email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování', processing: 'Zpracování',
pay_with_stripe: 'Zaplacení přes Stripe', pay_with_stripe: 'Zaplatit přes Stripe',
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund',
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
} }
const pt = { const pt = {
step: 'Passo',
form_progress: 'Progresso do formulário',
close: 'Fechar',
uploaded_files: 'Arquivos enviados',
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem', please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres', must_be_characters_length: 'Deve ter {number} caracteres',
must_be_valid_ssn: 'Deve ser um SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve ser um EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade', identity_verification: 'Verificação de identidade',
@@ -829,18 +953,18 @@ const pt = {
approved: 'Aprovado', approved: 'Aprovado',
reviewed: 'Revisado', reviewed: 'Revisado',
other: 'Outro', other: 'Outro',
authored_by_me: 'Autorizado por mim', authored_by_me: 'Escrito por mim',
approved_by: 'Aprovado por', approved_by: 'Aprovado por',
reviewed_by: 'Revisado por', reviewed_by: 'Revisado por',
authored_by: 'Autorizado por', authored_by: 'Escrito por',
select_a_reason: 'Selecione um motivo', select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido', value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido', verification_code_is_invalid: 'Código de verificação é inválido',
sign_on_the_touchscreen: 'Assinar na tela sensível', sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o', by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com a',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica', electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura', esignature_disclosure: 'Informação sobre eAssinatura',
already_paid: 'Já pago', already_paid: 'Já pago',
minimize: 'Minimizar', minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
@@ -909,17 +1033,31 @@ const pt = {
email_has_been_sent: 'Email enviado', email_has_been_sent: 'Email enviado',
processing: 'Processamento', processing: 'Processamento',
pay_with_stripe: 'Pagar com Stripe', pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar', reupload: 'Recarregar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos',
enter_screen_reader_mode: 'Ativar modo leitor de tela'
} }
const he = { const he = {
step: 'שלב',
form_progress: 'התקדמות הטופס',
close: 'סגור',
uploaded_files: 'קבצים שהועלו',
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה', please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים', must_be_characters_length: 'חייב להיות באורך של {number} תווים',
must_be_valid_ssn: 'חייב להיות SSN חוקי (XXX-XX-XXXX)',
must_be_valid_ein: 'חייב להיות EIN חוקי (XX-XXXXXXX)',
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה', verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות', identity_verification: 'אימות זהות',
@@ -942,8 +1080,8 @@ const he = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל', by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית', electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה', esignature_disclosure: 'חשיפת חתימה אלקטרונית',
minimize: 'לקטן', minimize: 'מזער',
text: 'טקסט', text: 'טקסט',
already_paid: 'כבר שולם', already_paid: 'כבר שולם',
signature: 'חתימה', signature: 'חתימה',
@@ -954,9 +1092,9 @@ const he = {
number: 'מספר', number: 'מספר',
image: 'תמונה', image: 'תמונה',
file: 'קובץ', file: 'קובץ',
pay: 'תשלום', pay: 'שלם',
select: 'בחר', select: 'בחר',
checkbox: 'תיק בחירה', checkbox: 'סימון',
multiple: 'רב ערכים', multiple: 'רב ערכים',
radio: 'רדיו', radio: 'רדיו',
cells: 'תאים', cells: 'תאים',
@@ -983,9 +1121,9 @@ const he = {
download: 'הורדה', download: 'הורדה',
clear: 'נקה', clear: 'נקה',
redraw: 'צייר מחדש', redraw: 'צייר מחדש',
draw_initials: 'צייר ציוני ראשי תיבות', draw_initials: 'צייר ראשי תיבות',
type_signature_here: 'הקלד חתימה כאן', type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן', type_initial_here: 'הקלד ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם', form_has_been_completed: 'הטופס הושלם',
document_has_been_signed: 'המסמך נחתם!', document_has_been_signed: 'המסמך נחתם!',
documents_have_been_signed: 'המסמכים נחתמו!', documents_have_been_signed: 'המסמכים נחתמו!',
@@ -995,7 +1133,7 @@ const he = {
open_source_documents_software: 'תוכנה פתוחה למסמכים', open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון', verified_phone_number: 'אימות מספר טלפון',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx', use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד משתמש שש ספרות', six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון', change_phone_number: 'שינוי מספר טלפון',
sending: 'שולח', sending: 'שולח',
resend_code: 'שלח מחדש קוד', resend_code: 'שלח מחדש קוד',
@@ -1004,7 +1142,7 @@ const he = {
set_today: 'קבע היום', set_today: 'קבע היום',
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת', toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
draw_signature: 'צייר חתימה', draw_signature: 'צייר חתימה',
type_initial: 'הקלד ציוני ראשי תיבות', type_initial: 'הקלד ראשי תיבות',
draw: 'צייר', draw: 'צייר',
type: 'הקלד', type: 'הקלד',
type_text: 'הקלד טקסט', type_text: 'הקלד טקסט',
@@ -1015,15 +1153,29 @@ const he = {
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
wait_countdown_seconds: 'המתן {countdown} שניות' browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות',
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
} }
const nl = { const nl = {
step: 'Stap',
form_progress: 'Formuliervoortgang',
close: 'Sluiten',
uploaded_files: 'Geüploade bestanden',
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand', please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn', must_be_characters_length: 'Moet {number} tekens lang zijn',
must_be_valid_ssn: 'Moet een geldig SSN zijn (XXX-XX-XXXX)',
must_be_valid_ein: 'Moet een geldig EIN zijn (XX-XXXXXXX)',
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID', verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie', identity_verification: 'Identiteitsverificatie',
complete: 'Voltooien', complete: 'Voltooien',
fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien', fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien',
@@ -1071,7 +1223,7 @@ const nl = {
continue: 'Doorgaan', continue: 'Doorgaan',
sign_now: 'Nu ondertekenen', sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...', type_here_: 'Typ hier...',
optional: 'Optioneel', optional: 'optioneel',
option: 'Optie', option: 'Optie',
appears_on: 'Verschijnt op', appears_on: 'Verschijnt op',
page: 'Pagina', page: 'Pagina',
@@ -1095,11 +1247,11 @@ const nl = {
powered_by: 'Aangedreven door', powered_by: 'Aangedreven door',
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.', please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software', open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Geverifieerd telefoonnummer', verified_phone_number: 'Telefoonnummer verifiëren',
use_international_format: 'Gebruik internationaal formaat: +1xxx', use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code', six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer', change_phone_number: 'Wijzig telefoonnummer',
sending: 'Voltooien...', sending: 'Verzenden...',
resend_code: 'Code opnieuw verzenden', resend_code: 'Code opnieuw verzenden',
verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS', verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS',
please_fill_all_required_fields: 'Vul alle verplichte velden in', please_fill_all_required_fields: 'Vul alle verplichte velden in',
@@ -1117,13 +1269,27 @@ const nl = {
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
wait_countdown_seconds: 'Wacht {countdown} seconden' browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden',
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
} }
const ar = { const ar = {
step: 'خطوة',
form_progress: 'تقدم النموذج',
close: 'إغلاق',
uploaded_files: 'الملفات المرفوعة',
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة', please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا', must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
must_be_valid_ssn: 'يجب أن يكون SSN صالحًا (XXX-XX-XXXX)',
must_be_valid_ein: 'يجب أن يكون EIN صالحًا (XX-XXXXXXX)',
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية', verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية', identity_verification: 'التحقق من الهوية',
@@ -1150,7 +1316,7 @@ const ar = {
esignature_disclosure: 'كشف التوقيع الإلكتروني', esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص', text: 'نص',
signature: 'توقيع', signature: 'توقيع',
initials: 'الاختصارات', initials: 'حروف أولى',
date: 'تاريخ', date: 'تاريخ',
number: 'رقم', number: 'رقم',
digitally_signed_by: 'تم التوقيع رقميًا بواسطة', digitally_signed_by: 'تم التوقيع رقميًا بواسطة',
@@ -1187,9 +1353,9 @@ const ar = {
download: 'تحميل', download: 'تحميل',
clear: 'مسح', clear: 'مسح',
redraw: 'إعادة الرسم', redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات', draw_initials: 'ارسم حروف أولى',
type_signature_here: 'اكتب التوقيع هنا', type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا', type_initial_here: 'اكتب حروف أولى هنا',
form_has_been_completed: 'تم إكمال النموذج!', form_has_been_completed: 'تم إكمال النموذج!',
document_has_been_signed: 'تم توقيع الوثيقة!', document_has_been_signed: 'تم توقيع الوثيقة!',
documents_have_been_signed: 'تم توقيع الوثائق!', documents_have_been_signed: 'تم توقيع الوثائق!',
@@ -1208,24 +1374,38 @@ const ar = {
set_today: 'تعيين اليوم', set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر', toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع', draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات', type_initial: 'اكتب حروف أولى',
draw: 'ارسم', draw: 'ارسم',
type: 'اكتب', type: 'اكتب',
type_text: 'اكتب نصًا', type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني', email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة', processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe', pay_with_stripe: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل', reupload: 'إعادة التحميل',
upload: 'تحميل', upload: 'تحميل',
files: 'الملفات', files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية',
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
} }
const ko = { const ko = {
step: '단계',
form_progress: '양식 진행',
close: '닫기',
uploaded_files: '업로드된 파일',
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요', please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다', must_be_characters_length: '{number}자여야 합니다',
must_be_valid_ssn: '유효한 SSN이어야 합니다 (XXX-XX-XXXX)',
must_be_valid_ein: '유효한 EIN이어야 합니다 (XX-XXXXXXX)',
must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인', verify_id: '아이디 확인',
identity_verification: '신원 확인', identity_verification: '신원 확인',
@@ -1254,10 +1434,10 @@ const ko = {
signature: '서명', signature: '서명',
initials: '이니셜', initials: '이니셜',
date: '날짜', date: '날짜',
digitally_signed_by: '디지털 서명이 완료되었습니다', digitally_signed_by: '디지털 서명자:',
reason: '이유', reason: '이유',
number: '숫자', number: '숫자',
pay: '급여', pay: '결제',
image: '이미지', image: '이미지',
take_photo: '사진 찍기', take_photo: '사진 찍기',
file: '파일', file: '파일',
@@ -1321,13 +1501,27 @@ const ko = {
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요',
enter_screen_reader_mode: '스크린 리더 모드 활성화'
} }
const ja = { const ja = {
step: 'ステップ',
form_progress: 'フォームの進捗',
close: '閉じる',
uploaded_files: 'アップロードされたファイル',
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください', please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません', must_be_characters_length: '{number}文字でなければなりません',
must_be_valid_ssn: '有効なSSNである必要があります (XXX-XX-XXXX)',
must_be_valid_ein: '有効なEINである必要があります (XX-XXXXXXX)',
must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認', verify_id: '本人確認',
identity_verification: '本人確認', identity_verification: '本人確認',
@@ -1423,7 +1617,9 @@ const ja = {
upload: 'アップロード', upload: 'アップロード',
files: 'ファイル', files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。', signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください' browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください',
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja } const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
@@ -15,16 +15,21 @@
</template> </template>
</label> </label>
<button <button
type="button"
class="btn btn-outline btn-sm reupload-button" class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('reupload') }} {{ t('reupload') }}
</button> </button>
</div> </div>
<div> <div>
<img <img
:src="attachmentsIndex[modelValue].url" :src="attachmentsIndex[modelValue].url"
:alt="field.name || t('image')"
class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview" class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview"
> >
</div> </div>
@@ -22,43 +22,60 @@
class="md:tooltip" class="md:tooltip"
:data-tip="t('type_initial')" :data-tip="t('type_initial')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('type_initial')"
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput" @click="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('type') }} {{ t('type') }}
</span> </span>
</a> </button>
</span> </span>
<span <span
v-else v-else
class="md:tooltip ml-2" class="md:tooltip ml-2"
:data-tip="t('draw_initials')" :data-tip="t('draw_initials')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('draw_initials')"
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput" @click="toggleTextInput"
> >
<IconSignature :width="16" /> <IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('draw') }} {{ t('draw') }}
</span> </span>
</a> </button>
</span> </span>
<span <span
class="md:tooltip" class="md:tooltip"
:data-tip="t('click_to_upload')" :data-tip="t('click_to_upload')"
> >
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"> <button
<IconUpload :width="16" /> type="button"
:aria-label="t('click_to_upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.uploadInput.click()"
>
<IconUpload
:width="16"
aria-hidden="true"
/>
<input <input
:key="uploadImageInputKey" :key="uploadImageInputKey"
ref="uploadInput"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@@ -67,37 +84,45 @@
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('upload') }} {{ t('upload') }}
</span> </span>
</label> </button>
</span> </span>
<a <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="remove" @click="remove"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</a> </button>
<a <button
v-else v-else
href="#" type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="clear" @click="clear"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</a> </button>
<a <button
type="button"
:title="t('minimize')" :title="t('minimize')"
href="#" :aria-label="t('minimize')"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click="$emit('minimize')"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</a> </button>
</div> </div>
</div> </div>
<div <div
@@ -150,6 +175,7 @@
<script> <script>
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isCanvasBlocked } from './validate_signature'
import { IconReload, IconTextSize, IconUpload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconTextSize, IconUpload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
@@ -358,7 +384,9 @@ export default {
}, },
initTextInitial () { initTextInitial () {
if (this.submitter.name) { if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('') const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
} }
if (this.$refs.textInput.value) { if (this.$refs.textInput.value) {
@@ -419,7 +447,15 @@ export default {
} }
}).catch((error) => { }).catch((error) => {
if (this.field.required === true) { if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw')) if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return reject(error) return reject(error)
} else { } else {
@@ -42,6 +42,7 @@
{{ error }} {{ error }}
</div> </div>
<button <button
type="button"
class="base-button w-full flex justify-center submit-form-button" class="base-button w-full flex justify-center submit-form-button"
@click="restartKba" @click="restartKba"
> >
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more