Compare commits

..
311 Commits
Author SHA1 Message Date
Andrey Antukh 33d51a51d1 📚 Update changelog 2024-11-04 17:29:52 +01:00
Andrey Antukh ab4be85669 Merge pull request #5250 from penpot/alotor-hotfix-plugins-api-problem
🐛 Fix problem with promises in plugins
2024-11-04 17:27:41 +01:00
alonso.torres 6c0dce580d 🐛 Fix problem with promises in plugins 2024-11-04 17:12:45 +01:00
Andrey Antukh 59050a7bc6 📎 Backport frontend/package.json changes
Mainly for compatibility with the upcoming devenv changes
2024-11-04 16:16:28 +01:00
Andrey Antukh 3334fb0e99 🐛 Add migration to fix invalid pages 2024-11-04 15:34:09 +01:00
Andrey Antukh 24268bbf33 Merge pull request #5248 from penpot/palba-add-event-for-add-frame
 Add event for add-frame
2024-11-04 14:25:19 +01:00
Pablo Alba cd3f8f0c43 Add event for add-frame 2024-11-04 12:56:34 +01:00
Andrey Antukh d3a8954605 Merge pull request #5247 from penpot/alotor-fix-plugin-problem
🐛 Fix problem with plugins path positioning
2024-11-04 12:16:25 +01:00
alonso.torres 1cda61e230 🐛 Fix problem with plugins path positioning 2024-11-04 11:02:04 +01:00
Alejandro aca3e3db4f Merge pull request #5237 from penpot/niwinz-hotfix-2
🐛 Fix incorrect thumbnail lookup on dashboard project view
2024-10-31 16:12:22 +01:00
Andrey Antukh 74f9166f3d Merge pull request #5238 from penpot/bameda-manage-build-docs-bundle
🎉 add command to build docs bundle
2024-10-30 23:15:22 +01:00
David Barragán Merino 977a2090fb 🎉 add command to build docs bundle 2024-10-30 19:17:04 +01:00
Andrey Antukh 14e6ea9393 Merge pull request #5236 from penpot/palba-testab-templates-link2
🎉 Add test A/B for add a link to the libraries page
2024-10-30 16:44:17 +01:00
Andrey Antukh 3eb35f0aa6 🐛 Fix incorrect thumbnail lookup on dashboard project view
That causes a repeated generation of thumbnails on each page
view instead of reusing already generated thumbnails.
2024-10-30 16:19:16 +01:00
Pablo Alba 92b7a35c58 🎉 Add test A/B for add a link to the libraries page 2024-10-30 16:13:05 +01:00
Andrey Antukh 99807b4cd4 Merge pull request #5231 from penpot/bameda-merge-docs-repo
📚 Merge repository penpot/penpot-docs
2024-10-30 13:46:37 +01:00
Andrey Antukh bff415c7cd 📎 Set yarn 4.3.1 for docs
The same as the rest of packages
2024-10-30 13:33:16 +01:00
Andrey Antukh 1d84835fd5 📎 Move .nvmrc file to the repo root 2024-10-30 13:31:24 +01:00
David Barragán Merino 88296480ec 📚 Merge penpot/penpot-docs repository 2024-10-30 13:30:02 +01:00
Pablo Alba 4f5bc77379 Update libraries links to new versions 2024-10-30 13:28:37 +01:00
Alejandro 3932054ea6 Merge pull request #5222 from penpot/niwinz-bugfix-8
🐛 Backport bugfixes from develop to staging
2024-10-30 11:33:56 +01:00
Andrey Antukh 243fd17305 Merge pull request #5227 from penpot/palba-update-readme
🎉 Update readme with plugins info
2024-10-29 14:50:34 +01:00
Pablo Alba 4b8febd7dc 🎉 Update readme with plugins info 2024-10-29 13:14:15 +01:00
Andrey Antukh 7c73e44ab8 Add minor improvement on error reporting on shape validation 2024-10-29 11:47:47 +01:00
Andrey Antukh e533762f33 📎 Show version on dbg header 2024-10-29 11:47:45 +01:00
Andrey Antukh 40c118df55 🐛 Fix incorrect pred composition on number schema types
Fixes the following:

  => (sm/validate (sm/schema [::sm/int {:max 10}]) nil)
  Cannot invoke "Object.getClass()" because "x" is null
2024-10-29 11:47:06 +01:00
Andrey Antukh f43fc282d3 Increase internal s3 http client limits
Tries to improve performance of accidental spikes/bursts of
requests to s3 service. This is not a final solution to all issues
caused by unexpected burst, is a simple improvement to the current
apprach.
2024-10-29 11:47:06 +01:00
Andrey Antukh 8616e2f25c Use penpot own executor for s3 response completion executor 2024-10-29 11:47:06 +01:00
Andrey Antukh 4299fd28f0 Expose ::wrk/executor as ExecutorService instance
Instead of a plain Executor instance
2024-10-29 11:47:06 +01:00
Andrey Antukh 302ff92b31 🐛 Fix incorrect handling of EOF on s3 upload thread 2024-10-29 11:47:06 +01:00
Andrey Antukh b62cc9c8e9 📎 Update backend scripts/repl with a default config 2024-10-29 11:47:06 +01:00
Andrey Antukh 225c2ca6e6 Add better reporting for s3 storage backend errors 2024-10-29 11:47:06 +01:00
Andrey Antukh e5bdd852ca 🐛 Fix corner case on selection storage backend from settings
Related to how backward compatibility is handled with previous
settings.
2024-10-29 11:47:06 +01:00
Andrey Antukh 591788403a Add safer mechanism for tempfile naming
Using a uuidv8 that has strong guarranties about councurrent
ids generation that a simple random long
2024-10-29 11:47:06 +01:00
Andrey Antukh f1b82e289d 🐛 Add retry mechanism for internal tmp file handling on s3 backend 2024-10-29 11:47:06 +01:00
Alejandro f4ae8ea5ac Merge pull request #5218 from penpot/niwinz-bugfix-10
🐛 Fix issues with invalid fills
2024-10-29 08:57:50 +01:00
Andrey Antukh d9310d651a 🐛 Fix exception on adding animation to an interraction
Happens when an animation is added to a just created interaction
and then changed to other animation type. Bug is caused by missing
dependency on react handlers.
2024-10-28 18:21:02 +01:00
Andrey Antukh 6b817d102b 🐛 Add migration for a fix of invalid fills 2024-10-28 18:04:27 +01:00
Andrey Antukh 08a9371322 🐛 Use proper ::sm/int schema type on color and shape schemas 2024-10-28 18:04:25 +01:00
Yamila Moreno f96da090d6 📎 Fix readme badges 2024-10-28 11:07:40 +01:00
Andrey Antukh 8d8f203b8a Merge pull request #5204 from penpot/alotor-bugfix-import
🐛 Fix problem with imports
2024-10-25 14:32:41 +02:00
alonso.torres f40ffacfbd 🐛 Fix problem with imports 2024-10-25 13:34:40 +02:00
luisddm ae435f67a5 🐛 Fix intentation and ellipsis in the left sidebar when inspector is activated in viewer mode 2024-10-24 14:37:39 +02:00
Andrey Antukh d89dfc5e30 Merge pull request #5194 from penpot/alotor-bugfix-grid
🐛 Fix problem with swap components on grid
2024-10-22 17:31:31 +02:00
alonso.torres cd586c81ee 🐛 Fix problem with swap components on grid 2024-10-22 16:14:14 +02:00
Andrey Antukh 16e1e01234 Merge pull request #5171 from penpot/palba-fix-rulers-on-view-only
🐛 Fix you can manage rulers on view mode
2024-10-21 17:29:40 +02:00
Belén Albeza fe6c9f24d3 🐛 Fix edit grid unit dropdown being clipped 2024-10-21 10:15:57 +02:00
Pablo Alba fe314cf146 🐛 Fix you can manage rulers on view mode 2024-10-18 14:03:46 +02:00
Andrey Antukh 97a880c946 Merge pull request #5179 from penpot/alotor-bugfixing-5
Alotor bugfixing 5
2024-10-18 14:00:53 +02:00
alonso.torres df66955594 🐛 Fix problem with shadows and frames in Safari 2024-10-18 11:52:41 +02:00
alonso.torres 07f055bd49 🐛 Fix problem when duplicating board with guide 2024-10-18 11:52:41 +02:00
alonso.torres 22d5b125bd 🐛 Fix problem with layers overflowing panel 2024-10-18 11:52:40 +02:00
alonso.torres ef3b4a5895 🐛 Fix problem with plugins icons 2024-10-17 14:51:01 +02:00
Andrey Antukh 02611029fb Merge pull request #5176 from penpot/juan-relesae-notes-2.3
Relesae notes 2.3 on onboarding slides
2024-10-16 16:31:19 +02:00
Elhombretecla 14e4e6d6ea 🎉 Add release note slides for 2.3 2024-10-16 14:33:20 +02:00
Andrey Antukh 9170c70f2a Merge pull request #5169 from penpot/alotor-bugfixing4
🐛 Fix problem with inner strokes bounds
2024-10-15 13:02:53 +02:00
alonso.torres 83d8bf37a6 🐛 Fix problem with inner strokes bounds 2024-10-15 12:28:23 +02:00
Andrey Antukh 1fb21d537c 🐛 Send thread-id on create-comment-thread rpc method 2024-10-15 09:29:40 +02:00
Andrey Antukh ac80e9a1ac Respect overrides of jvm_opts on devenv bashrc file 2024-10-15 09:13:46 +02:00
Andrey Antukh dbbb8e76ab Allow override java opts for build scripts 2024-10-15 09:13:46 +02:00
Andrey Antukh 916f055aec Merge pull request #5165 from penpot/alotor-bugfixing-3
Alotor bugfixing 3
2024-10-14 19:16:39 +02:00
alonso.torres 6d8c183160 Add plugins whitelisting for removing the disclaimer 2024-10-14 15:25:37 +02:00
alonso.torres 9d2f484aa3 🐛 Fix problem with horizontal/vertical lines and shadows 2024-10-14 15:25:37 +02:00
alonso.torres 2dc0cfdee3 🐛 Fix problem with caps and inner shadows 2024-10-14 15:25:37 +02:00
alonso.torres a25abd0ca4 🐛 Fix percent calculation on grid layout tracks 2024-10-14 15:25:37 +02:00
alonso.torres 3a9119cf29 🐛 Add visual feedback when moving an element into a board 2024-10-14 15:25:37 +02:00
alonso.torres c236e0765b 🐛 Fix problems with show in viewer and interactions 2024-10-14 15:25:37 +02:00
alonso.torres f8fad95fef 🐛 Fix problem with shortcuts in text editor 2024-10-14 11:45:50 +02:00
alonso.torres 97ae295cb9 🐛 Fix problem updating layout when toggle visibility in component copy 2024-10-14 11:45:27 +02:00
Eva Marco bd888dcde2 🐛 Fix constraints buttons 2024-10-14 11:41:26 +02:00
Andrey Antukh 784274f8ae Merge pull request #5163 from penpot/palba-bugfixing-011
Palba bugfixing 011
2024-10-14 11:40:45 +02:00
Pablo Alba eda6c6a4c3 🐛 Fix "Done" button on toolbar on inspect mode should go to design mode 2024-10-11 14:36:57 +02:00
Pablo Alba 7d7594818c 🐛 Fix Internal Error page: "go to your penpot" wrong design 2024-10-11 14:35:56 +02:00
Andrey Antukh 7cc8f67e24 Merge pull request #5161 from penpot/niwinz-bugfix-6
🐛 Fix storybook build
2024-10-11 12:28:41 +02:00
Andrey Antukh 87fc3bbb8e 🐛 Fix storybook build 2024-10-11 12:11:37 +02:00
Andrey Antukh bbb2cc972f Merge pull request #5159 from penpot/alotor-plugins-fixes-2
Plugins improvements
2024-10-11 09:04:29 +02:00
alonso.torres 6a07e6ae01 Add update plugin permission dialog 2024-10-10 17:12:39 +02:00
Andrey Antukh 87dfd2b3c8 🐛 Force sync update on storage before immediate refresh 2024-10-10 16:04:15 +02:00
Andrey Antukh b0bfb8006d 💄 Add cosmetic changes to dashboard templates layer 2024-10-10 16:04:15 +02:00
Andrey Antukh d46274abf2 Add better error reporting on zip file importation 2024-10-10 16:04:15 +02:00
Andrey Antukh 23f7889cff 💄 Add cosmetic change to create-temp-file rpc method 2024-10-10 16:04:15 +02:00
Andrey Antukh 534659cdc6 🐛 Fix flows import and export on zip format 2024-10-10 16:04:15 +02:00
alonso.torres 1e68d4ec87 Close plugin on esc button 2024-10-10 16:03:45 +02:00
alonso.torres 1779fd3e8b Fix zero case for plugins 2024-10-10 16:03:45 +02:00
alonso.torres 3c496ddd9d ⬆️ Update plugins runtime 2024-10-10 16:03:45 +02:00
Andrey Antukh 47bc9d8ef1 Merge pull request #5157 from penpot/alotor-bugfixing-2
Alotor bugfixing 2
2024-10-10 11:45:48 +02:00
alonso.torres a3a5fe056d 📚 Update changelog 2024-10-10 11:45:16 +02:00
Eero Pitkänen fbb3271c81 🐛 Fix dragging path points by returning closest point instead of only the distance 2024-10-10 11:45:16 +02:00
alonso.torres ecc93d9246 🐛 Fix problem with precision on boolean calculation 2024-10-10 11:45:16 +02:00
alonso.torres 302672f5b0 🐛 Fix problem with hover layers when hidden/blocked 2024-10-10 11:45:16 +02:00
alonso.torres 4f16ea2d2d 🐛 Fix problem with stroke and filter ordering in frames 2024-10-10 11:45:12 +02:00
Andrey Antukh b7a0b7d629 🐛 Increase feedback limits to reasonable values 2024-10-10 11:27:04 +02:00
Andrey Antukh bd6f1bef10 🐛 Don't raise an unexpected exception on multiple-input enter
When a enter is pressed and field is empty
2024-10-10 11:27:04 +02:00
Andrey Antukh c4941bb102 🐛 Fix unexpected exception on handling audit log on team invitations
A regression introduced in previous commits of this release
2024-10-10 11:27:04 +02:00
Andrey Antukh b8a606a35f 🐛 Fix incorrect dependency for log-emails and smtp flags 2024-10-10 11:23:04 +02:00
Andrey Antukh 370eebeb64 🐛 Remove unused shadow config from exporter 2024-10-10 11:23:04 +02:00
Andrey Antukh 35bcb082a0 🐛 Remove data-testid usage from shape 2024-10-10 11:23:04 +02:00
Andrey Antukh dd220e228e Merge pull request #5152 from penpot/alotor-fix-selection
🐛 Fix problem with selection
2024-10-09 13:50:51 +02:00
alonso.torres 7b63aa4a4f 🐛 Fix problem with selection 2024-10-09 13:34:33 +02:00
Andrey Antukh 33a07346dd 💄 Add minor cmd naming change for e2e test commands 2024-10-09 13:09:01 +02:00
Andrey Antukh abd77559ab 🐛 Fix svg exportation with shapes with svg-unsafe characters in the name 2024-10-09 13:09:01 +02:00
Andrey Antukh 28878caca9 🐛 Fix cache issues with plugin runtime import uri 2024-10-09 13:09:01 +02:00
Andrey Antukh 74f3379b5d Merge pull request #5150 from penpot/alotor-bugfixing
Alotor bugfixing
2024-10-09 12:16:26 +02:00
alonso.torres 379770343a 🐛 Close plugin if open when installed 2024-10-09 10:50:56 +02:00
alonso.torres 6327286328 ⬆️ Update runtime 2024-10-09 09:39:47 +02:00
alonso.torres 3a2677a91a 🐛 Fix problem with shadows in text for Safari 2024-10-08 15:40:20 +02:00
alonso.torres fcd232aa35 🐛 Fix problem with go back button on error page 2024-10-08 15:40:20 +02:00
alonso.torres f194e2c1c6 📚 Updates changelog 2024-10-08 15:34:41 +02:00
Andrey Antukh ea6731e22b Add EOF handling on sse response helper 2024-10-08 15:30:33 +02:00
Andrey Antukh 002b1679c3 ♻️ Clean assertion and schema chechking API 2024-10-08 15:30:33 +02:00
Andrey Antukh 45f3a67950 Relax transaction requeriments for team invitation creation 2024-10-08 14:51:14 +02:00
Andrey Antukh c6917bb0cf Relax transaction requirements on create-team rpc method 2024-10-08 14:51:14 +02:00
Andrey Antukh f777845d14 Relax transaction requirement on comment thread creation rpc method 2024-10-08 14:51:14 +02:00
Andrey Antukh a1f5bcae80 ♻️ Add better ergonomics for the internal quotes API 2024-10-08 14:51:14 +02:00
Andrey Antukh 3e11b4aa74 Add facility for wrap a rpc method in a db transaction 2024-10-08 14:51:14 +02:00
Aitor Moreno 4f48236fee Merge pull request #5141 from penpot/niwinz-enhancements-text-editor-v2-2
 Add minor improvements to text editor v2 events handling
2024-10-07 12:58:17 +02:00
Andrey Antukh ffadf29ad7 Add minor improvements to text editor v2 events handling
Also updates the editor code to the latest version
2024-10-07 10:13:21 +02:00
Aitor Moreno 352efcb610 Merge pull request #5139 from penpot/niwinz-enhancements-text-editor-v2
 Add minor improvements for text-editor-v2
2024-10-04 09:38:50 +02:00
Andrey Antukh 334e83479f Add minor improvements for text-editor-v2 2024-10-03 09:51:04 +02:00
Alejandro Alonso 476eedbd2c Merge remote-tracking branch 'origin/staging' into develop 2024-10-03 07:19:53 +02:00
Alejandro ae7e28b71b Merge pull request #5137 from penpot/niwinz-enhancements-1
 Add limits for invitation creation RPC method
2024-10-03 07:18:18 +02:00
Andrey Antukh be30174a49 Add limits for team invitations 2024-10-02 16:05:33 +02:00
Alejandro 8373654f80 Merge pull request #5134 from penpot/alotor-hotfix-2.3
Alotor hotfix 2.3
2024-10-02 13:57:05 +02:00
alonso.torres 471c636580 🐛 Fix visual problem with the font-size dropdown in assets 2024-10-02 13:45:50 +02:00
alonso.torres 635c6efe42 🐛 Fix problem with Ctrl+F shortcut on the dashboard 2024-10-02 13:45:30 +02:00
Alejandro d570048f78 Merge pull request #5132 from penpot/niwinz-bugfix-1
🐛 Fix issues on migration 55
2024-10-02 13:36:43 +02:00
Andrey Antukh dcc49dafd3 Merge pull request #5029 from penpot/azazeln28-refactor-text-editor
♻️ Refactor text editor
2024-10-02 11:05:26 +02:00
AzazelN28 7398f7ce0d ♻️ Replace Draft.js with custom editor 2024-10-01 22:31:16 +02:00
Andrey Antukh 76479a2486 🐛 Fix page background migration 2024-10-01 16:44:54 +02:00
Andrey Antukh 31f62dcc12 🐛 Fix incorrect flows conversion on migration 55 2024-10-01 16:34:22 +02:00
Andrey Antukh 3d7df5b005 Merge pull request #5115 from penpot/alotor-plugins
Plugins update
2024-10-01 12:53:03 +02:00
alonso.torres c16a116707 Modifications after review 2024-10-01 11:57:52 +02:00
alonso.torres f7f06f59ce ⬆️ Upgrade plugin runtime 2024-10-01 09:34:45 +02:00
alonso.torres d1277afee6 New plugin install workflow 2024-09-30 16:03:40 +02:00
alonso.torres a510d01136 Plugins api changes 2024-09-30 15:49:46 +02:00
alonso.torres 0e651df65f Updates permissions for comments 2024-09-30 15:20:34 +02:00
alonso.torres 758e0458bc 🐛 Fix problem when returning parent proxy 2024-09-30 15:20:34 +02:00
alonso.torres e18b4666ba Update permissions dialog 2024-09-30 15:20:34 +02:00
Alejandro Alonso 864088eecd Merge remote-tracking branch 'origin/staging' into develop 2024-09-30 09:38:11 +02:00
Pablo Alba 0b39318b33 🐛 Fix request dialog is shown in all errors 2024-09-30 09:36:41 +02:00
Alejandro d5a9961ec8 Merge pull request #5124 from penpot/eva-move-tab-switcher
♻️  Move tab-switcher to its own folder inside DS
2024-09-30 09:29:15 +02:00
Alejandro 7dac7de365 Merge pull request #5123 from penpot/palba-change-emails-footer
 Update emails footer
2024-09-30 07:11:27 +02:00
Alejandro dd0721e91e Merge pull request #5126 from penpot/palba-fix-show-request-on-all-errors
🐛 Fix request dialog is shown in all errors
2024-09-30 06:19:58 +02:00
Pablo Alba 21fde2e991 🐛 Fix request dialog is shown in all errors 2024-09-27 10:38:24 +02:00
Eva Marco ca1893164d Add the undefied option to props schema enums 2024-09-26 17:39:52 +02:00
Eva Marco b619ac3e08 ♻️ Move tab-switcher to its own folder inside DS 2024-09-26 17:39:45 +02:00
Belén Albeza d7eb86c86d Merge pull request #5095 from penpot/eva-update-ds-components
♻️ Update colors and icons
2024-09-26 12:00:45 +02:00
Pablo Alba 6c4f216da8 Update emails footer 2024-09-26 09:45:42 +02:00
Alejandro Alonso f786a00e89 Merge remote-tracking branch 'origin/staging' into develop 2024-09-25 11:35:41 +02:00
Alejandro 47cecb2ac4 Merge pull request #5119 from penpot/eva-add-props
  Add shema prop
2024-09-25 11:31:57 +02:00
Eva Marco 5d6ceec803 Add shema prop 2024-09-25 11:07:40 +02:00
Alejandro Alonso bec11220e3 🐛 Fix storage typo 2024-09-25 10:41:55 +02:00
Alejandro Alonso 9b802e1c7d Merge remote-tracking branch 'origin/staging' into develop 2024-09-24 14:27:20 +02:00
Alejandro 16cf16c422 Merge pull request #5114 from penpot/superalex-fix-fetch-profile-exception
🐛 Fix fetch profile exception
2024-09-24 14:24:56 +02:00
Alejandro Alonso 4e1eee197e 🐛 Fix fetch profile exception 2024-09-24 14:13:46 +02:00
Alejandro 91c8af9e38 Merge pull request #5108 from penpot/palba-fix-login-redirect
🐛 Fix bad redirect on new oops page with penpot login
2024-09-24 11:27:04 +02:00
Pablo Alba 58593a9428 🐛 Fix bad redirect on new oops page with penpot login 2024-09-24 10:32:57 +02:00
Alejandro Alonso 21aa8b0703 Merge remote-tracking branch 'origin/staging' into develop 2024-09-24 09:42:56 +02:00
Eva Marco 17cf57f7ca Merge pull request #5113 from penpot/superalex-fix-show-in-assets-panel
🐛 Fix show in assets panel
2024-09-24 09:38:32 +02:00
Alejandro Alonso f7cfe36f37 🐛 Fix show in assets panel 2024-09-24 08:49:52 +02:00
Alejandro c26f909565 Merge pull request #5110 from penpot/eva-fix-code-block-height
🐛 Fix code block height
2024-09-24 06:54:44 +02:00
Eva Marco 6db7fe5f7b 🐛 Fix code block height 2024-09-23 17:03:25 +02:00
Pablo Alba a207114d95 Merge pull request #5109 from penpot/hiru-fix-swap-inside-group
🐛 Fix error when swapping a copy that is the only child of a group
2024-09-23 15:12:55 +02:00
Eva Marco b8299a5ea5 🐛 Fix create team without invitations on onboarding 2024-09-23 15:09:43 +02:00
Andrés Moya 1fa461e996 🐛 Fix error when swapping a copy that is the only child of a group 2024-09-23 15:01:01 +02:00
Alejandro 2e3745099b Merge pull request #5107 from penpot/alotor-remove-export-option
🐛 Removed "merge assets" option from export
2024-09-23 13:22:56 +02:00
Pablo Alba 03ebeb0657 🐛 Fix session storage entry name 2024-09-23 12:15:49 +02:00
alonso.torres 6892cffe54 🐛 Removed "merge assets" option from export 2024-09-23 12:13:33 +02:00
Alejandro 19a613e90c Merge pull request #5105 from penpot/superalex-merge-conflicts-2
Merge conflicts
2024-09-23 10:57:33 +02:00
Alejandro Alonso 7fe95f218b 🐛 Fix logged in redirect 2024-09-23 10:36:59 +02:00
Alejandro Alonso a1fc785771 Merge remote-tracking branch 'origin/staging' into superalex-merge-conflicts-2 2024-09-23 10:08:06 +02:00
Alejandro Alonso e0034dc205 🐛 Fix onboarding edn urls 2024-09-23 06:30:19 +02:00
Alejandro bd9eab08b7 Merge pull request #5101 from penpot/palba-migrate-templates-v2
 Update templates links to binary v2
2024-09-23 06:12:15 +02:00
Pablo Alba b5121657ee Update templates links to binary v2 2024-09-18 16:23:12 +02:00
Andrey Antukh 4f04dbc294 🐛 Fix frame flows issues related to the refactor 2024-09-18 12:15:12 +02:00
alonso.torres 2b2a84da64 🐛 Fix problem with frame guides 2024-09-18 12:15:12 +02:00
Andrey Antukh 21dd9a260c 📎 Rename common files changes test namespace 2024-09-18 12:15:12 +02:00
Andrey Antukh 7b9b5bafc1 🔥 Remove duplicated test 2024-09-18 12:15:12 +02:00
Andrey Antukh 41ebba6ce0 Add generative tests for flows and saved-grids 2024-09-18 12:15:12 +02:00
Andrey Antukh 61446592b3 Move generative test related code to a separated ns 2024-09-18 12:15:12 +02:00
Andrey Antukh b82c6326cf Add better error reporting on changes validation 2024-09-18 12:15:12 +02:00
Andrey Antukh a2f466810b ♻️ Add minor refactor on set-plugin-data change 2024-09-18 12:15:12 +02:00
Andrey Antukh 1bd1782d66 ♻️ Add better reporting for generative tests 2024-09-18 12:15:12 +02:00
Andrey Antukh ea6a1c05fa 🐛 Fix incorrect assignation of plugin data on page data structure 2024-09-18 12:15:12 +02:00
Andrey Antukh 4f84e77b10 Add generative tests for set-guide change 2024-09-18 12:15:12 +02:00
Andrey Antukh fa75a3539f 📎 Rename shape decode encode test file 2024-09-18 12:15:12 +02:00
Andrey Antukh fa12d9785a Add tests for basic obj crud change operations
Restored and adapted from already existing commented code
2024-09-18 12:15:12 +02:00
Andrey Antukh c578e31ae2 📎 Update some docstrings on common/schema ns 2024-09-18 12:15:12 +02:00
Andrey Antukh 749c369080 Add less verbose shape validation 2024-09-18 12:15:12 +02:00
Andrey Antukh 4ad4057878 ♻️ Refactor page options data structure 2024-09-18 12:15:12 +02:00
Andrey Antukh 2dea0b52ed Merge pull request #5077 from penpot/ladybenko-8638-docker-rust
Set up devenv for Rust
2024-09-17 17:30:38 +02:00
Aitor Moreno ca257d1caf Merge pull request #5097 from penpot/eva-fix-arrow-keys-tabs
🐛  Fix arrow key movement on tabs
2024-09-17 17:23:32 +02:00
Aitor Moreno e164692391 Merge pull request #5098 from penpot/eva-fix-desing-panel
🐛  Fix path side panel options
2024-09-17 17:22:21 +02:00
Eva Marco b58edea544 🐛 Fix path side panel options 2024-09-17 17:00:40 +02:00
Eva Marco 9a587c91a8 🐛 Fix arrow key movement on tabs 2024-09-17 16:36:44 +02:00
Belén Albeza 7590a7ce4d Merge pull request #5072 from penpot/eva-add-schema-to-ds-components
 Add schema validation to all DS components
2024-09-17 16:22:22 +02:00
Aitor Moreno aae1571a5c Merge pull request #5096 from penpot/alotor-bugfixes
Bugfixes
2024-09-17 16:14:59 +02:00
alonso.torres ebaf30727c 🐛 Fix copy/paste images in Safari 2024-09-17 15:38:18 +02:00
Belén Albeza 884ceb052b Use dynamic import for wasm module 2024-09-17 14:52:27 +02:00
Belén Albeza cc7ed497e8 🎉 Enable conditional use of wasm module 2024-09-17 14:51:55 +02:00
Belén Albeza cd6a739abb 🔧 Add dummy rust project + build scripts with wasm-pack 2024-09-17 14:51:55 +02:00
Belén Albeza f0cecfd517 🔧 Install Rust+Cargo in devenv 2024-09-17 14:51:55 +02:00
alonso.torres f5f255e2d5 🐛 Fix problem with comments max length 2024-09-17 14:18:51 +02:00
Eva Marco 5ffa56be3d ♻️ Update select background color on input 2024-09-17 14:11:50 +02:00
Alejandro e65c0d9f48 Merge pull request #5088 from penpot/niwinz-bugfix-2
🐛 Fix issues related to invalid colors inserted on shape shadow
2024-09-17 13:58:43 +02:00
Eva Marco 076cb0e35b Add schema validation to all DS components 2024-09-17 13:43:35 +02:00
Andrey Antukh 2a90ca6546 Merge pull request #5094 from penpot/alotor-fix-plugins
🐛 Fix small problems in plugins
2024-09-17 12:37:43 +02:00
Eva Marco a26deafa75 ♻️ Update the colors and icon of some toast notification 2024-09-17 12:08:17 +02:00
alonso.torres cf705e352b 🐛 Fix small problems in plugins 2024-09-17 10:19:37 +02:00
Andrey Antukh 86c5ca4213 🐛 Fix incorrect redirect handling on request-access go-home button 2024-09-16 18:53:56 +02:00
Andrey Antukh 179d534237 🐛 Fix several issues related to invalid colors inserted on shadows 2024-09-16 18:32:40 +02:00
Andrey Antukh 162507264c 🐛 Reexecute file migration 26 again for shapes that has transform prop as nil 2024-09-16 18:32:40 +02:00
Andrey Antukh 7e0a8b6227 Merge pull request #5092 from penpot/palbs-fix-request-acces-dont-go-your-team
🐛 Fix request access to the Team don't go to Your Penpot team
2024-09-16 18:32:19 +02:00
Andrey Antukh b50fcee079 Merge pull request #5090 from penpot/alotor-new-apis
Plugins - API's modifications
2024-09-16 18:29:51 +02:00
alonso.torres 9bca42c14a Fixed plugin registration props 2024-09-16 15:46:02 +02:00
Pablo Alba 475d14edf4 🐛 Fix request access to the Team don't go to Your Penpot team 2024-09-16 14:42:52 +02:00
alonso.torres 214733c880 ⬆️ Update plugins runtime 2024-09-16 09:48:56 +02:00
alonso.torres 979828ffe3 🐛 Fix issue when exporting libraries when merging libraries 2024-09-16 09:08:51 +02:00
alonso.torres 65bb795199 🐛 Fix visual problem with stroke cap menu 2024-09-16 09:07:57 +02:00
Alejandro a0546b2e63 Merge pull request #5086 from penpot/niwinz-bugfix-1
🐛 Ignore object thumbnail requests if file is already marked as deleted
2024-09-13 12:30:27 +02:00
alonso.torres d6f6d78b1e New viewport functions 2024-09-13 12:29:07 +02:00
alonso.torres 8c1fba5160 Add api methods to align, distribute and flatten shapes 2024-09-13 12:29:07 +02:00
alonso.torres fb39dd5440 Methods for comments 2024-09-13 12:29:07 +02:00
alonso.torres dd0c5b7806 Add support to guides for plugins 2024-09-13 11:30:59 +02:00
Andrey Antukh 9e94cf7b99 ♻️ Simplify internal implementation of sm/schema namespace 2024-09-13 11:30:55 +02:00
Andrey Antukh b882b9e283 🔥 Remove usage of public usage of sm/define funcion 2024-09-13 11:30:55 +02:00
Andrey Antukh cdcff62232 Store some profile props on browser global storage 2024-09-13 11:30:55 +02:00
Andrey Antukh c8caca77a3 Add storage namespacing
Allows separate global properties from user specific properties
2024-09-13 11:30:55 +02:00
Andrey Antukh f291125377 🐛 Add migration for invalid value on layout-wrap-type on shape prop 2024-09-12 21:33:01 +02:00
Andrey Antukh 0ce981a68c 🐛 Add missing ref-id unsassign on srepl helpers for process file 2024-09-12 21:32:34 +02:00
Andrey Antukh a8814dcaba 🐛 Add missing fields on file-gc libraries fetching sql 2024-09-12 21:07:19 +02:00
Pablo Alba 229eeae6db 🐛 Fix bad redirect on new oops page with social login 2024-09-12 16:35:49 +02:00
Andrey Antukh d03788af93 🐛 Ignore object thumbnail requests if file is already marked as deleted 2024-09-12 15:23:31 +02:00
Alejandro Alonso 017aad6454 🐛 Fix export failed error when exporting multiple shapes 2024-09-12 12:55:32 +02:00
Alejandro Alonso 042b3a71d8 Merge remote-tracking branch 'origin/staging' into develop 2024-09-11 12:46:04 +02:00
Alejandro Alonso 767ec37b83 Merge remote-tracking branch 'origin/main' into staging 2024-09-11 12:45:39 +02:00
Alejandro Alonso 89f64e0c49 🐛 Fix challenge redirect with parameters 2024-09-11 12:30:12 +02:00
Alejandro Alonso eadae5e2cd Merge remote-tracking branch 'origin/staging' into develop 2024-09-11 12:05:45 +02:00
Alejandro Alonso d108ad904e Merge remote-tracking branch 'origin/main' into staging 2024-09-11 12:05:21 +02:00
Alejandro Alonso 6564736d3e 🐛 Fix challenge redirect with parameters 2024-09-11 12:03:32 +02:00
Alejandro Alonso 7f9c4df284 Merge remote-tracking branch 'origin/staging' into develop 2024-09-11 11:34:35 +02:00
Alejandro Alonso d01cd70c6b Merge remote-tracking branch 'origin/main' into staging 2024-09-11 11:34:04 +02:00
Alejandro ea7768117c Merge pull request #5082 from penpot/superalex-fix-challenge-redirect-with-parameters
🐛 Fix challenge redirect with parameters
2024-09-11 11:33:45 +02:00
Alejandro Alonso 5bfb39cdf6 🐛 Fix challenge redirect with parameters 2024-09-11 11:23:19 +02:00
Pablo Alba 29f1c2bdad Merge pull request #5080 from penpot/niwinz-oidc-fix-limits-issues
🐛 Fix oidc auth internal limits issue
2024-09-10 16:41:44 +02:00
Andrey Antukh e79f9ba40f 🐛 Increase token limit 2024-09-10 12:39:54 +02:00
Andrey Antukh 452aabdec6 🐛 Don't send user props on auth token after oidc login 2024-09-10 12:39:54 +02:00
Alejandro Alonso 9e3f8e7827 Merge remote-tracking branch 'origin/staging' into develop 2024-09-09 11:09:53 +02:00
Eva Marco 3a4e9ccc5a 👷 Fix CI error 2024-09-09 10:32:50 +02:00
Aitor Moreno 860e32d965 Merge pull request #5070 from penpot/superalex-fix-onboarding
🐛 Fix onboarding questions
2024-09-09 10:19:03 +02:00
Andrey Antukh 495f9dfa84 Merge pull request #5064 from penpot/superalex-release-notes-2.2
 Release notes for 2.2
2024-09-09 09:54:50 +02:00
Alejandro 133ca33cb5 Merge pull request #5076 from penpot/niwinz-exporter-config-parse
🐛 Fix issues on parsing configuration on exporter
2024-09-09 09:54:12 +02:00
Andrey Antukh 1c69a9fd8a 🐛 Fix config parsing on exporter 2024-09-09 09:47:55 +02:00
Andrey Antukh 15faa57e01 🐛 Fix decoding on sm/set schema 2024-09-09 09:46:50 +02:00
Alejandro f5510234cf Merge pull request #5066 from penpot/eva-fix-frame-row-menu
🐛 Fix guides submenu visualization
2024-09-09 06:49:05 +02:00
Belén Albeza eb720b053a Merge pull request #5057 from penpot/eva-fix-css-compilation
🔧 Rearrange css files for compilation
2024-09-06 14:45:52 +02:00
Alejandro Alonso efc61241a0 Merge remote-tracking branch 'origin/staging' into develop 2024-09-06 13:50:37 +02:00
Alejandro Alonso 5b0331611d Merge remote-tracking branch 'origin/main' into staging 2024-09-06 13:50:09 +02:00
Alejandro f8f1c58f61 Merge pull request #5069 from penpot/niwinz-storybook-build-fix
🐛 Fix storybook build related to commonjs to esm module conversion issue
2024-09-06 13:43:04 +02:00
Andrey Antukh 3f34aa92fa Add support for optional human challenge 2024-09-06 13:39:53 +02:00
Alejandro Alonso c99102e49b 🐛 Fix onboarding questions 2024-09-06 13:18:39 +02:00
Andrey Antukh d583661e58 🐛 Fix storybook build related to commonjs to esm module conversion issue 2024-09-06 12:31:48 +02:00
Andrey Antukh cfad1d178f Merge pull request #5068 from penpot/alotor-plugins-install-profile
 Change installation data to profile
2024-09-06 12:05:52 +02:00
Andrey Antukh ffa326e08f Merge pull request #5067 from penpot/alotor-fix-validation
🐛 Fix problem when dismissing shared library update
2024-09-06 11:31:22 +02:00
alonso.torres c24b2dadec Change installation data to profile 2024-09-06 11:10:32 +02:00
alonso.torres 03040ed40b 🐛 Fix problem when dismissing shared library update 2024-09-06 11:02:02 +02:00
Alejandro Alonso 5e89cd1cb3 Release notes for 2.2 2024-09-06 10:49:20 +02:00
Eva Marco bf202473e9 🐛 Fix guides submenu visualization 2024-09-06 09:47:09 +02:00
Andrey Antukh 9a3b5337d7 Merge pull request #5062 from penpot/alotor-plugins-fix-interactions
🐛 Fix plugins add interaction
2024-09-05 16:22:46 +02:00
alonso.torres 396cbb27b2 🐛 Fix plugins add interaction 2024-09-05 16:00:04 +02:00
Alejandro b4e6f8bc73 Merge pull request #5061 from penpot/niwinz-challenge
 Add support for optional human challenge
2024-09-05 15:49:50 +02:00
Andrey Antukh d88f28f5c2 Add support for optional human challenge 2024-09-05 15:35:39 +02:00
Andrey Antukh 886c0c596f Merge pull request #5060 from penpot/alotor-bugfixes
Bugfixes
2024-09-05 15:32:44 +02:00
alonso.torres b15b394c65 🐛 Fix problem when creating a component instance from grid layout 2024-09-05 15:11:04 +02:00
Eva Marco e36cf1d963 🐛 Fix onboarding slide after rearrange 2024-09-05 14:46:49 +02:00
alonso.torres caf78a6b4d 🐛 Fix layer panel overflowing 2024-09-05 11:50:16 +02:00
alonso.torres 6a161267ba 🐛 Fix problem with overlay positions in viewer 2024-09-05 10:48:00 +02:00
Eva Marco a0bb5e5ef3 ♻️ Remove unnecesary code 2024-09-05 09:41:11 +02:00
Eva Marco 34cc211912 🔧 Rearrange css files for compilation 2024-09-05 09:39:43 +02:00
Eva Marco e95713c1df 🐛 Fix visual integration test 2024-09-05 09:39:43 +02:00
Alejandro Alonso e189dc965d Merge remote-tracking branch 'origin/staging' into develop 2024-09-05 09:37:16 +02:00
alonso.torres a180c33a32 🐛 Fix problem with SVG import 2024-09-05 09:26:22 +02:00
Alejandro ea8febdb7d Merge pull request #5056 from penpot/niwinz-refactor-recent-colors
♻️ Refactor recent colors and local storage abstraction
2024-09-05 09:07:26 +02:00
Alejandro f765cc8dbc Merge pull request #5011 from penpot/palba-testab-start-workspace
A/B test start directly at the workspace
2024-09-05 07:05:57 +02:00
Pablo Alba 81b7972347 🎉 Test A/B for start in workspace 2024-09-04 17:19:39 +02:00
Andrey Antukh 1281670c61 Clear storage on user logout 2024-09-04 16:20:00 +02:00
Andrey Antukh b8c6103858 Add performance enhancements for util/storage abstraction layer 2024-09-04 16:20:00 +02:00
Andrey Antukh b2c0bed84c Add efficiency improvements to use-resize-hook 2024-09-04 16:20:00 +02:00
Andrey Antukh 9619fcbc1f Make efficiency improvements to use-shared-state hook 2024-09-04 16:20:00 +02:00
Andrey Antukh e9c55e9eb4 Make recent colors to be stored locally instead of on file 2024-09-04 16:20:00 +02:00
Belén Albeza 53f580ad40 Merge pull request #5017 from penpot/eva-add-select-to-ds
 Add select component to the DS
2024-09-04 15:51:10 +02:00
Andrey Antukh cf0045681e Merge pull request #5054 from penpot/alotor-plugins-fixes
Update API types
2024-09-04 14:16:01 +02:00
Andrey Antukh 488d034a58 Merge pull request #5055 from penpot/eva-fix-webhook-checkbox
🐛  Fix webhook checkbox position
2024-09-04 14:15:37 +02:00
alonso.torres 762a883b39 🐛 Fix problem with font weight and style 2024-09-04 13:52:48 +02:00
alonso.torres a63ded1ba1 Change type names in plugins 2024-09-04 13:29:56 +02:00
Eva Marco 8d66275187 🐛 Fix webhook checkbox position 2024-09-04 12:51:30 +02:00
alonso.torres f812b28892 ⬆️ Update plugin dependencies 2024-09-04 12:38:50 +02:00
Alejandro 59063e861c Merge pull request #5053 from penpot/niwinz-update-file-refactor
♻️ Refactor file-update for make it more reusable
2024-09-04 12:30:36 +02:00
Andrey Antukh 873c9b1903 Merge pull request #5050 from penpot/hiru-ordered-maps
🔧 Add serializable ordered collections
2024-09-04 12:26:21 +02:00
Andrey Antukh 9da891e9b0 📎 Enable auto-file-snapshot feature scripts/repl 2024-09-04 12:18:31 +02:00
Andrey Antukh a6de12323e ♻️ Refactor file-update for make it more reusable 2024-09-04 12:18:31 +02:00
Alejandro Alonso edeb16bc26 Merge remote-tracking branch 'origin/staging' into develop 2024-09-04 12:02:31 +02:00
Alejandro Alonso 90d947391a Merge remote-tracking branch 'origin/staging' into develop 2024-09-04 08:59:05 +02:00
Andrés Moya 47cc80a93f 🔧 Add serializable ordered collections 2024-09-03 23:35:53 +02:00
Andrey Antukh 1f8cfde1cf Merge pull request #5046 from penpot/alotor-plugins-fixes
Plugins small fixes
2024-09-03 14:36:59 +02:00
Alejandro Alonso 5f2ec595cb 📎 Update changelog 2024-09-03 13:15:48 +02:00
alonso.torres 37a6446e32 🐛 Fix problem with font style 2024-09-03 13:10:28 +02:00
alonso.torres be84b1cb01 🐛 Change place for circular dependency workaround 2024-09-03 13:10:28 +02:00
Eva Marco 298db46722 Add documentation to select on storybook 2024-09-02 16:56:53 +02:00
Eva Marco 0c6b0598fa Add new select ds component to storybook 2024-08-29 14:14:12 +02:00
Eva Marco f2a2d772b0 Add new select component to the ds 2024-08-29 14:14:08 +02:00
981 changed files with 33404 additions and 9824 deletions

No files matched your search

+1 -1
View File
@@ -111,7 +111,7 @@ jobs:
yarn run build:app:assets yarn run build:app:assets
clojure -M:dev:shadow-cljs release main clojure -M:dev:shadow-cljs release main
yarn playwright install --with-deps chromium yarn playwright install --with-deps chromium
yarn e2e:test yarn test:e2e
- run: - run:
name: "backend tests" name: "backend tests"
+1
View File
@@ -0,0 +1 @@
v20.11.1
+82 -1
View File
@@ -1,11 +1,81 @@
# CHANGELOG # CHANGELOG
## 2.3.1
### :bug: Bugs fixed
- Fix unexpected issue on interaction between plugins sandbox and
internal impl of promise
## 2.3.0
### :rocket: Epics and highlights
- **New plugin system.**
Penpot now supports custom plugins. Read everything about developing your plugins [HERE](https://help.penpot.app/plugins/)
### :boom: Breaking changes & Deprecations
### :heart: Community contributions (Thank you!)
- All our plugins beta testers :heart:.
- Fix problem when translating multiple path points by @eeropic [#4459](https://github.com/penpot/penpot/issues/4459)
### :sparkles: New features
- **Replace Draft.js completely with a custom editor** [Taiga #7706](https://tree.taiga.io/project/penpot/us/7706)
This refactor adds better IME support, more performant text editing
experience and a better clipboard support while keeping full
retrocompatibility with previous editor.
You can enable it with the `enable-feature-text-editor-v2` configuration flag.
### :bug: Bugs fixed
- Fix problem with constraints buttons [Taiga #8465](https://tree.taiga.io/project/penpot/issue/8465)
- Fix problem with go back button on error page [Taiga #8887](https://tree.taiga.io/project/penpot/issue/8887)
- Fix problem with shadows in text for Safari [Taiga #8770](https://tree.taiga.io/project/penpot/issue/8770)
- Fix a regression with feedback form subject and content limits [Taiga #8908](https://tree.taiga.io/project/penpot/issue/8908)
- Fix problem with stroke and filter ordering in frames [Github #5058](https://github.com/penpot/penpot/issues/5058)
- Fix problem with hover layers when hidden/blocked [Github #5074](https://github.com/penpot/penpot/issues/5074)
- Fix problem with precision on boolean calculation [Taiga #8482](https://tree.taiga.io/project/penpot/issue/8482)
- Fix problem when translating multiple path points [Github #4459](https://github.com/penpot/penpot/issues/4459)
- Fix problem on importing (and exporting) files with flows [Taiga #8914](https://tree.taiga.io/project/penpot/issue/8914)
- Fix Internal Error page: "go to your penpot" wrong design [Taiga #8922](https://tree.taiga.io/project/penpot/issue/8922)
- Fix problem updating layout when toggle visibility in component copy [Github #5143](https://github.com/penpot/penpot/issues/5143)
- Fix "Done" button on toolbar on inspect mode should go to design mode [Taiga #8933](https://tree.taiga.io/project/penpot/issue/8933)
- Fix problem with shortcuts in text editor [Github #5078](https://github.com/penpot/penpot/issues/5078)
- Fix problems with show in viewer and interactions [Github #4868](https://github.com/penpot/penpot/issues/4868)
- Add visual feedback when moving an element into a board [Github #3210](https://github.com/penpot/penpot/issues/3210)
- Fix percent calculation on grid layout tracks [Github #4688](https://github.com/penpot/penpot/issues/4688)
- Fix problem with caps and inner shadows [Github #4517](https://github.com/penpot/penpot/issues/4517)
- Fix problem with horizontal/vertical lines and shadows [Github #4516](https://github.com/penpot/penpot/issues/4516)
- Fix problem with layers overflowing panel [Taiga #9021](https://tree.taiga.io/project/penpot/issue/9021)
- Fix in workspace you can manage rulers on view mode [Taiga #8966](https://tree.taiga.io/project/penpot/issue/8966)
- Fix problem with swap components in grid layout [Taiga #9066](https://tree.taiga.io/project/penpot/issue/9066)
## 2.2.1
### :bug: Bugs fixed
- Fix problem with Ctrl+F shortcut on the dashboard [Taiga #8876](https://tree.taiga.io/project/penpot/issue/8876)
- Fix visual problem with the font-size dropdown in assets [Taiga #8872](https://tree.taiga.io/project/penpot/issue/8872)
- Add limits for invitation RPC methods (hard limit 25 emails per request)
## 2.2.0 ## 2.2.0
### :rocket: Epics and highlights ### :rocket: Epics and highlights
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
- Removed "merge assets" option when exporting ".svg + .json" files. After the components changes the option wasn't
working properly and we're planning to change the format soon. We think it's better to deprecate the option for the
time being.
### :heart: Community contributions (Thank you!) ### :heart: Community contributions (Thank you!)
- Set proper default tenant on exporter (by @june128) [#4946](https://github.com/penpot/penpot/pull/4946) - Set proper default tenant on exporter (by @june128) [#4946](https://github.com/penpot/penpot/pull/4946)
@@ -71,10 +141,21 @@
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix webhook checkbox position [Taiga #8634](https://tree.taiga.io/project/penpot/issue/8634)
- Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254) - Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254)
- Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351) - Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351)
- Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353) - Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353)
- Fix components are not dragged from the group to the assets tab [Taiga #8273](https://tree.taiga.io/project/penpot/issue/8273) - Fix components are not dragged from the group to the assets tab [Taiga #8273](https://tree.taiga.io/project/penpot/issue/8273)
- Fix problem with SVG import [Github #4888](https://github.com/penpot/penpot/issues/4888)
- Fix problem with overlay positions in viewer [Taiga #8464](https://tree.taiga.io/project/penpot/issue/8464)
- Fix layer panel overflowing [Taiga #8665](https://tree.taiga.io/project/penpot/issue/8665)
- Fix problem when creating a component instance from grid layout [Github #4881](https://github.com/penpot/penpot/issues/4881)
- Fix problem when dismissing shared library update [Taiga #8669](https://tree.taiga.io/project/penpot/issue/8669)
- Fix visual problem with stroke cap menu [Taiga #8730](https://tree.taiga.io/project/penpot/issue/8730)
- Fix issue when exporting libraries when merging libraries [Taiga #8758](https://tree.taiga.io/project/penpot/issue/8758)
- Fix problem with comments max length [Taiga #8778](https://tree.taiga.io/project/penpot/issue/8778)
- Fix copy/paste images in Safari [Taiga #8771](https://tree.taiga.io/project/penpot/issue/8771)
- Fix swap when the copy is the only child of a group [#5075](https://github.com/penpot/penpot/issues/5075)
## 2.1.5 ## 2.1.5
@@ -121,7 +202,7 @@
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
### :heart: Community contributions (Thank you!) ### :heart: Communityq contributions (Thank you!)
### :sparkles: New features ### :sparkles: New features
+9 -4
View File
@@ -8,10 +8,12 @@
<img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png"> <img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png">
</picture> </picture>
<p align="center"><a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img src="https://camo.githubusercontent.com/3fcf3d6b678ea15fde3cf7d6af0e242160366282d62a7c182d83a50bfee3f45e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4d504c2d322e302d626c75652e737667" alt="License: MPL-2.0" data-canonical-src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a> <p align="center">
<a href="https://gitter.im/penpot/community" rel="nofollow"><img src="https://camo.githubusercontent.com/5b0aecb33434f82a7b158eab7247544235ada0cf7eeb9ce8e52562dd67f614b7/68747470733a2f2f6261646765732e6769747465722e696d2f736572656e6f2d78797a2f636f6d6d756e6974792e737667" alt="Gitter" data-canonical-src="https://badges.gitter.im/sereno-xyz/community.svg" style="max-width:100%;"></a> <a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img src="https://camo.githubusercontent.com/4a1d1112f0272e3393b1e8da312ff4435418e9e2eb4c0964881e3680f90a653c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d616e61676564253230776974682d54414947412e696f2d3730396631342e737667" alt="Managed with Taiga.io" data-canonical-src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a> <a href="https://gitter.im/penpot/community" rel="nofollow"><img alt="Gitter" src="https://badges.gitter.im/sereno-xyz/community.svg" style="max-width:100%;"></a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img src="https://camo.githubusercontent.com/daadb4894128d1e19b72d80236f5959f1f2b47f9fe081373f3246131f0189f6c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f476974706f642d72656164792d2d746f2d2d636f64652d626c75653f6c6f676f3d676974706f64" alt="Gitpod ready-to-code" data-canonical-src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a></p> <a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
</p>
<p align="center"> <p align="center">
<a href="https://penpot.app/"><b>Website</b></a> • <a href="https://penpot.app/"><b>Website</b></a> •
@@ -58,6 +60,9 @@ Penpots latest [huge release 2.0](https://penpot.app/dev-diaries), takes the
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration. Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
### Plugin system ###
[Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions.
### Designed for developers ### ### Designed for developers ###
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo". Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
+48 -268
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title> <title>
@@ -110,15 +111,20 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>
<tr> <tr>
<td style="width:97px;"> <td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" /> <img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -151,7 +157,8 @@
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]--> <![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;"> <div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
@@ -164,29 +171,43 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello {{name|abbreviate:25}}!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">We received a request to change your current email to {{ pending-email }}.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
We received a request to change your current email to {{ pending-email }}.</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Click to the link below to confirm the change:</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Click to the link below to confirm the change:</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="center" vertical-align="middle"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr> <tr>
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle"> <td align="center" bgcolor="#31EFB8" role="presentation"
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Confirm email change </a> style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
target="_blank"> Confirm email change </a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -194,17 +215,24 @@
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">If you received this email by mistake, please consider changing your password for security reasons.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
If you received this email by mistake, please consider changing your password for security
reasons.</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -221,258 +249,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
</html> </html>
@@ -0,0 +1,323 @@
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for
cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/penpotapp" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-x.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/penpotdesign/"
target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-linkedin.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://fosstodon.org/@penpot/" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-mastodon.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot"
target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title> <title>
@@ -110,15 +111,20 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>
<tr> <tr>
<td style="width:97px;"> <td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" /> <img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -151,7 +157,8 @@
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]--> <![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;"> <div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
@@ -164,24 +171,36 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="center" vertical-align="middle"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr> <tr>
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle"> <td align="center" bgcolor="#31EFB8" role="presentation"
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Accept invite </a> style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
target="_blank"> Accept invite </a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -189,12 +208,16 @@
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -211,258 +234,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
</html> </html>
+1 -275
View File
@@ -235,283 +235,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title> <title>
@@ -110,15 +111,20 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>
<tr> <tr>
<td style="width:97px;"> <td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" /> <img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -151,7 +157,8 @@
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]--> <![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;"> <div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
@@ -164,24 +171,37 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello {{name|abbreviate:25}}!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">We have received a request to reset your password. Click the link below to choose a new one:</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
We have received a request to reset your password. Click the link below to choose a new one:
</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="center" vertical-align="middle"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr> <tr>
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle"> <td align="center" bgcolor="#31EFB8" role="presentation"
<a href="{{ public-uri }}/#/auth/recovery?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Reset password </a> style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
valign="middle">
<a href="{{ public-uri }}/#/auth/recovery?token={{token}}"
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
target="_blank"> Reset password </a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -189,17 +209,24 @@
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">If you received this email by mistake, you can safely ignore it. Your password won't be changed.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
If you received this email by mistake, you can safely ignore it. Your password won't be changed.
</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -216,258 +243,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
</html> </html>
+42 -266
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title> <title>
@@ -110,15 +111,20 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>
<tr> <tr>
<td style="width:97px;"> <td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" /> <img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -151,7 +157,8 @@
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]--> <![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;"> <div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
@@ -164,24 +171,37 @@
class="" style="vertical-align:top;width:600px;" class="" style="vertical-align:top;width:600px;"
> >
<![endif]--> <![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello {{name|abbreviate:25}}!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Thanks for signing up for your Penpot account! Please verify your email using the link below and get started building mockups and prototypes today!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Thanks for signing up for your Penpot account! Please verify your email using the link below and
get started building mockups and prototypes today!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="center" vertical-align="middle"
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr> <tr>
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle"> <td align="center" bgcolor="#31EFB8" role="presentation"
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Verify email </a> style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
target="_blank"> Verify email </a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -189,12 +209,16 @@
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div> <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -211,258 +235,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
</html> </html>
@@ -245,283 +245,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
@@ -268,283 +268,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
@@ -285,283 +285,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
@@ -243,283 +243,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table {% include "app/email/includes/footer.html" %}
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
<![endif]-->
<div class="mj-column-px-425 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://twitter.com/penpotapp" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-github.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png"
style="border-radius:3px;display:block;" width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot | Made with &lt;3 and Open Source</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div> </div>
</body> </body>
+16 -13
View File
@@ -1,39 +1,42 @@
[{:id "wireframing-kit" [{:id "wireframing-kit"
:name "Wireframe library" :name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/wireframing-kit.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}
{:id "prototype-examples" {:id "prototype-examples"
:name "Prototype template" :name "Prototype template"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/prototype-examples.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Prototype%20examples%20v1.1.penpot"}
{:id "plants-app" {:id "plants-app"
:name "UI mockup example" :name "UI mockup example"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Plants-app.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Plants-app.penpot"}
{:id "penpot-design-system" {:id "penpot-design-system"
:name "Design system example" :name "Design system example"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Penpot-Design-system.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Penpot%20-%20Design%20System%20v2.1.penpot"}
{:id "tutorial-for-beginners" {:id "tutorial-for-beginners"
:name "Tutorial for beginners" :name "Tutorial for beginners"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/tutorial-for-beginners.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/tutorial-for-beginners.penpot"}
{:id "lucide-icons" {:id "lucide-icons"
:name "Lucide Icons" :name "Lucide Icons"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Lucide-icons.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Lucide-icons.penpot"}
{:id "font-awesome" {:id "font-awesome"
:name "Font Awesome" :name "Font Awesome"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Font-Awesome.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/FontAwesome.penpot"}
{:id "black-white-mobile-templates" {:id "black-white-mobile-templates"
:name "Black & White Mobile Templates" :name "Black & White Mobile Templates"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Black-White-Mobile-Templates.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Black-&-White-Mobile-Templates.penpot"}
{:id "avataaars" {:id "avataaars"
:name "Avataaars" :name "Avataaars"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Avataaars-by-Pablo-Stanley.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Avataaars-by-Pablo-Stanley.penpot"}
{:id "ux-notes" {:id "ux-notes"
:name "UX Notes" :name "UX Notes"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/UX-Notes.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/UX-Notes.penpot"}
{:id "whiteboarding-kit" {:id "whiteboarding-kit"
:name "Whiteboarding Kit" :name "Whiteboarding Kit"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Whiteboarding-mapping-kit.penpot"}
{:id "open-color-scheme" {:id "open-color-scheme"
:name "Open Color Scheme" :name "Open Color Scheme"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/main/Open%20Color%20Scheme%20(v1.9.1).penpot"}
{:id "flex-layout-playground" {:id "flex-layout-playground"
:name "Flex Layout Playground" :name "Flex Layout Playground"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}] :file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Flex%20Layout%20Playground%20v2.0.penpot"}
{:id "welcome"
:name "Welcome"
:file-uri "https://github.com/penpot/penpot-files/raw/main/welcome.penpot"}]
+1 -1
View File
@@ -7,7 +7,7 @@ Debug Main Page
{% block content %} {% block content %}
<nav> <nav>
<div class="title"> <div class="title">
<h1>ADMIN DEBUG INTERFACE</h1> <h1>ADMIN DEBUG INTERFACE (VERSION: {{version}})</h1>
</div> </div>
</nav> </nav>
<main class="dashboard"> <main class="dashboard">
+3 -1
View File
@@ -23,8 +23,9 @@ export PENPOT_FLAGS="\
enable-urepl-server \ enable-urepl-server \
enable-rpc-climit \ enable-rpc-climit \
enable-rpc-rlimit \ enable-rpc-rlimit \
enable-quotes \
enable-soft-rpc-rlimit \ enable-soft-rpc-rlimit \
enable-file-snapshot \ enable-auto-file-snapshot \
enable-webhooks \ enable-webhooks \
enable-access-tokens \ enable-access-tokens \
enable-tiered-file-data-storage \ enable-tiered-file-data-storage \
@@ -67,6 +68,7 @@ export AWS_SECRET_ACCESS_KEY=penpot-devenv
export PENPOT_OBJECTS_STORAGE_BACKEND=s3 export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_OBJECTS_STORAGE_FS_DIRECTORY="assets"
export OPTIONS=" export OPTIONS="
-A:jmx-remote -A:dev \ -A:jmx-remote -A:dev \
+1
View File
@@ -17,6 +17,7 @@ export PENPOT_FLAGS="\
disable-secure-session-cookies \ disable-secure-session-cookies \
enable-rpc-climit \ enable-rpc-climit \
enable-smtp \ enable-smtp \
enable-quotes \
enable-file-snapshot \ enable-file-snapshot \
enable-access-tokens \ enable-access-tokens \
enable-tiered-file-data-storage \ enable-tiered-file-data-storage \
-1
View File
@@ -567,7 +567,6 @@
(tokens/generate (::setup/props cfg) (tokens/generate (::setup/props cfg)
{:iss :auth {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:props (:props info)
:profile-id (:id profile)})) :profile-id (:id profile)}))
props (audit/profile->props profile) props (audit/profile->props profile)
context (d/without-nils {:external-session-id (:external-session-id info)})] context (d/without-nils {:external-session-id (:external-session-id info)})]
+2 -4
View File
@@ -315,15 +315,13 @@
(l/dbg :hint "sendmail" (l/dbg :hint "sendmail"
:id (:id params) :id (:id params)
:to (:to params) :to (:to params)
:subject (str/trim (:subject params)) :subject (str/trim (:subject params)))
:body (str/join "," (map :type (:body params))))
(.sendMessage ^Transport transport (.sendMessage ^Transport transport
^MimeMessage message ^MimeMessage message
(.getAllRecipients message)))))) (.getAllRecipients message))))))
(when (or (contains? cf/flags :log-emails) (when (contains? cf/flags :log-emails)
(not (contains? cf/flags :smtp)))
(send-to-logger! cfg params)))) (send-to-logger! cfg params))))
(defmethod ig/pre-init-spec ::handler [_] (defmethod ig/pre-init-spec ::handler [_]
+1 -1
View File
@@ -47,7 +47,7 @@
{::rres/status 200 {::rres/status 200
::rres/headers {"content-type" "text/html"} ::rres/headers {"content-type" "text/html"}
::rres/body (-> (io/resource "app/templates/debug.tmpl") ::rres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {}))}) (tmpl/render {:version (:full cf/version)}))})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FILE CHANGES ;; FILE CHANGES
+3
View File
@@ -60,6 +60,9 @@
(try (try
(let [result (handler)] (let [result (handler)]
(events/tap :end result)) (events/tap :end result))
(catch java.io.EOFException cause
(events/tap :error (errors/handle' cause request)))
(catch Throwable cause (catch Throwable cause
(l/err :hint "unexpected error on processing sse response" (l/err :hint "unexpected error on processing sse response"
:cause cause) :cause cause)
+7 -7
View File
@@ -278,18 +278,18 @@
:inc 1) :inc 1)
message) message)
(def ^:private schema:params
(sm/define
[:map {:title "params"}
[:session-id ::sm/uuid]]))
(defn- http-handler (defn- http-handler
[cfg {:keys [params ::session/profile-id] :as request}] [cfg {:keys [params ::session/profile-id] :as request}]
(let [{:keys [session-id]} (sm/conform! schema:params params)] (let [session-id (some-> params :session-id sm/parse-uuid)]
(when-not (uuid? session-id)
(ex/raise :type :validation
:code :missing-session-id
:hint "missing or invalid session-id found"))
(cond (cond
(not profile-id) (not profile-id)
(ex/raise :type :authentication (ex/raise :type :authentication
:hint "Authentication required.") :hint "authentication required")
;; WORKAROUND: we use the adapter specific predicate for ;; WORKAROUND: we use the adapter specific predicate for
;; performance reasons; for now, the ring default impl for ;; performance reasons; for now, the ring default impl for
+1 -1
View File
@@ -63,7 +63,7 @@
(ex/format-throwable cause :data? false :explain? false :header? false :summary? false))} (ex/format-throwable cause :data? false :explain? false :header? false :summary? false))}
(when-let [params (or (:request/params context) (:params context))] (when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 30 :level 12)}) {:params (pp/pprint-str params :length 30 :level 13)})
(when-let [value (:value context)] (when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 12)}) {:value (pp/pprint-str value :length 30 :level 12)})
+1 -1
View File
@@ -135,7 +135,7 @@
(l/dbg :hint "run webhook" (l/dbg :hint "run webhook"
:event-name (:name event) :event-name (:name event)
:webhook-id (:id whook) :webhook-id (str (:id whook))
:webhook-uri (:uri whook) :webhook-uri (:uri whook)
:webhook-mtype (:mtype whook)) :webhook-mtype (:mtype whook))
+2 -1
View File
@@ -475,7 +475,8 @@
::sto.s3/bucket (or (cf/get :storage-assets-s3-bucket) ::sto.s3/bucket (or (cf/get :storage-assets-s3-bucket)
(cf/get :objects-storage-s3-bucket)) (cf/get :objects-storage-s3-bucket))
::sto.s3/io-threads (or (cf/get :storage-assets-s3-io-threads) ::sto.s3/io-threads (or (cf/get :storage-assets-s3-io-threads)
(cf/get :objects-storage-s3-io-threads))} (cf/get :objects-storage-s3-io-threads))
::wrk/executor (ig/ref ::wrk/executor)}
:app.storage.fs/backend :app.storage.fs/backend
{::sto.fs/directory (or (cf/get :storage-assets-fs-directory) {::sto.fs/directory (or (cf/get :storage-assets-fs-directory)
+8
View File
@@ -149,6 +149,13 @@
:hint "authentication required for this endpoint") :hint "authentication required for this endpoint")
(f cfg params))))) (f cfg params)))))
(defn- wrap-db-transaction
[_ f mdata]
(if (::db/transaction mdata)
(fn [cfg params]
(db/tx-run! cfg f params))
f))
(defn- wrap-audit (defn- wrap-audit
[_ f mdata] [_ f mdata]
(if (or (contains? cf/flags :webhooks) (if (or (contains? cf/flags :webhooks)
@@ -196,6 +203,7 @@
(defn- wrap-all (defn- wrap-all
[cfg f mdata] [cfg f mdata]
(as-> f $ (as-> f $
(wrap-db-transaction cfg $ mdata)
(cond/wrap cfg $ mdata) (cond/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata) (retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata) (climit/wrap cfg $ mdata)
+17 -20
View File
@@ -30,18 +30,17 @@
:tid token-id :tid token-id
:iat created-at}) :iat created-at})
expires-at (some-> expiration dt/in-future)] expires-at (some-> expiration dt/in-future)
token (db/insert! conn :access-token
(db/insert! conn :access-token {:id token-id
{:id token-id :name name
:name name :token token
:token token :profile-id profile-id
:profile-id profile-id :created-at created-at
:created-at created-at :updated-at created-at
:updated-at created-at :expires-at expires-at
:expires-at expires-at :perms (db/create-array conn "text" [])})]
:perms (db/create-array conn "text" [])}))) (decode-row token)))
(defn repl:create-access-token (defn repl:create-access-token
[{:keys [::db/pool] :as system} profile-id name expiration] [{:keys [::db/pool] :as system} profile-id name expiration]
@@ -60,14 +59,12 @@
(sv/defmethod ::create-access-token (sv/defmethod ::create-access-token
{::doc/added "1.18" {::doc/added "1.18"
::sm/params schema:create-access-token} ::sm/params schema:create-access-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}] [cfg {:keys [::rpc/profile-id name expiration]}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)] (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
(quotes/check-quote! conn ::quotes/profile-id profile-id})
{::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id}) (db/tx-run! cfg create-access-token profile-id name expiration))
(-> (create-access-token cfg profile-id name expiration)
(decode-row)))))
(def ^:private schema:delete-access-token (def ^:private schema:delete-access-token
[:map {:title "delete-access-token"} [:map {:title "delete-access-token"}
+19 -7
View File
@@ -27,9 +27,11 @@
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.setup.welcome-file :refer [create-welcome-file]]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(def schema:password (def schema:password
@@ -241,6 +243,7 @@
params (d/without-nils params) params (d/without-nils params)
token (tokens/generate (::setup/props cfg) params)] token (tokens/generate (::setup/props cfg) params)]
(with-meta {:token token} (with-meta {:token token}
{::audit/profile-id uuid/zero}))) {::audit/profile-id uuid/zero})))
@@ -350,7 +353,7 @@
:extra-data ptoken}))) :extra-data ptoken})))
(defn register-profile (defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token fullname theme] :as params}] [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}]
(let [theme (when (= theme "light") theme) (let [theme (when (= theme "light") theme)
claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register}) claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
params (-> claims params (-> claims
@@ -380,8 +383,13 @@
invitation (when-let [token (:invitation-token params)] invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation})) (tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
props (audit/profile->props profile)] props (audit/profile->props profile)
create-welcome-file-when-needed
(fn []
(when (:create-welcome-file params)
(let [cfg (dissoc cfg ::db/conn)]
(wrk/submit! executor (create-welcome-file cfg profile)))))]
(cond (cond
;; When profile is blocked, we just ignore it and return plain data ;; When profile is blocked, we just ignore it and return plain data
(:is-blocked profile) (:is-blocked profile)
@@ -418,6 +426,7 @@
(if (:is-active profile) (if (:is-active profile)
(-> (profile/strip-private-attrs profile) (-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta (rph/with-meta
{::audit/replace-props props {::audit/replace-props props
::audit/context {:action "login"} ::audit/context {:action "login"}
@@ -427,10 +436,12 @@
(when-not (eml/has-reports? conn (:email profile)) (when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile)) (send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)} (-> {:email (:email profile)}
{::audit/replace-props props (rph/with-defer create-welcome-file-when-needed)
::audit/context {:action "email-verification"} (rph/with-meta
::audit/profile-id (:id profile)}))) {::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)}))))
:else :else
(let [elapsed? (elapsed-verify-threshold? profile) (let [elapsed? (elapsed-verify-threshold? profile)
@@ -462,7 +473,8 @@
[:map {:title "register-profile"} [:map {:title "register-profile"}
[:token schema:token] [:token schema:token]
[:fullname [::sm/word-string {:max 100}]] [:fullname [::sm/word-string {:max 100}]]
[:theme {:optional true} [:string {:max 10}]]]) [:theme {:optional true} [:string {:max 10}]]
[:create-welcome-file {:optional true} :boolean]])
(sv/defmethod ::register-profile (sv/defmethod ::register-profile
{::rpc/auth false {::rpc/auth false
+46 -37
View File
@@ -71,10 +71,15 @@
[conn comment-id & {:as opts}] [conn comment-id & {:as opts}]
(db/get-by-id conn :comment comment-id opts)) (db/get-by-id conn :comment comment-id opts))
(def ^:private sql:get-next-seqn
"SELECT (f.comment_thread_seqn + 1) AS next_seqn
FROM file AS f
WHERE f.id = ?
FOR UPDATE")
(defn- get-next-seqn (defn- get-next-seqn
[conn file-id] [conn file-id]
(let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?" (let [res (db/exec-one! conn [sql:get-next-seqn file-id])]
res (db/exec-one! conn [sql file-id])]
(:next-seqn res))) (:next-seqn res)))
(def sql:upsert-comment-thread-status (def sql:upsert-comment-thread-status
@@ -292,7 +297,7 @@
[:map {:title "create-comment-thread"} [:map {:title "create-comment-thread"}
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:position ::gpt/point] [:position ::gpt/point]
[:content [:string {:max 250}]] [:content [:string {:max 750}]]
[:page-id ::sm/uuid] [:page-id ::sm/uuid]
[:frame-id ::sm/uuid] [:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]]) [:share-id {:optional true} [:maybe ::sm/uuid]]])
@@ -304,38 +309,43 @@
::rtry/when rtry/conflict-exception? ::rtry/when rtry/conflict-exception?
::sm/params schema:create-comment-thread} ::sm/params schema:create-comment-thread}
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}] [cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [{:keys [team-id project-id page-name]} (get-file conn file-id page-id)]
(run! (partial quotes/check-quote! cfg) (-> cfg
(list {::quotes/id ::quotes/comment-threads-per-file (assoc ::quotes/profile-id profile-id)
::quotes/profile-id profile-id (assoc ::quotes/team-id team-id)
::quotes/team-id team-id (assoc ::quotes/project-id project-id)
::quotes/project-id project-id (assoc ::quotes/file-id file-id)
::quotes/file-id file-id} (quotes/check! {::quotes/id ::quotes/comment-threads-per-file}
{::quotes/id ::quotes/comments-per-file {::quotes/id ::quotes/comments-per-file}))
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}))
(create-comment-thread conn {:created-at request-at (let [params {:created-at request-at
:profile-id profile-id :profile-id profile-id
:file-id file-id :file-id file-id
:page-id page-id :page-id page-id
:page-name page-name :page-name page-name
:position position :position position
:content content :content content
:frame-id frame-id}))))) :frame-id frame-id}
thread (db/tx-run! cfg create-comment-thread params)]
(vary-meta thread assoc ::audit/props thread))))
(defn- create-comment-thread (defn- create-comment-thread
[conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}] [{:keys [::db/conn] :as cfg}
{:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
(let [;; NOTE: we take the next seq number from a separate query
;; because we need to lock the file for avoid race conditions
;; FIXME: this method touches and locks the file table,which
;; is already heavy-update tablel; we need to think on move
;; the sequence state management to a different table or
;; different storage (example: redis) for alivate the update
;; pression on the file table
(let [;; NOTE: we take the next seq number from a separate query because the whole
;; operation can be retried on conflict, and in this case the new seq shold be
;; retrieved from the database.
seqn (get-next-seqn conn file-id) seqn (get-next-seqn conn file-id)
thread-id (uuid/next) thread-id (uuid/next)
thread (db/insert! conn :comment-thread thread (db/insert! conn :comment-thread
@@ -364,7 +374,8 @@
;; Optimistic update of current seq number on file. ;; Optimistic update of current seq number on file.
(db/update! conn :file (db/update! conn :file
{:comment-thread-seqn seqn} {:comment-thread-seqn seqn}
{:id file-id}) {:id file-id}
{::db/return-keys false})
(-> thread (-> thread
(select-keys [:id :file-id :page-id]) (select-keys [:id :file-id :page-id])
@@ -387,7 +398,6 @@
(files/check-comment-permissions! conn profile-id file-id share-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id))))) (upsert-comment-thread-status! conn profile-id id)))))
;; --- COMMAND: Update Comment Thread ;; --- COMMAND: Update Comment Thread
(def ^:private (def ^:private
@@ -432,12 +442,11 @@
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)] {:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(files/check-comment-permissions! conn profile-id file-id share-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(quotes/check-quote! conn (quotes/check! cfg {::quotes/id ::quotes/comments-per-file
{::quotes/id ::quotes/comments-per-file ::quotes/profile-id profile-id
::quotes/profile-id profile-id ::quotes/team-id team-id
::quotes/team-id team-id ::quotes/project-id project-id
::quotes/project-id project-id ::quotes/file-id file-id})
::quotes/file-id file-id})
;; Update the page-name cached attribute on comment thread table. ;; Update the page-name cached attribute on comment thread table.
(when (not= page-name (:page-name thread)) (when (not= page-name (:page-name thread))
+2 -2
View File
@@ -21,8 +21,8 @@
(def ^:private schema:send-user-feedback (def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"} [:map {:title "send-user-feedback"}
[:subject [:string {:max 250}]] [:subject [:string {:max 400}]]
[:content [:string {:max 250}]]]) [:content [:string {:max 2500}]]])
(sv/defmethod ::send-user-feedback (sv/defmethod ::send-user-feedback
{::doc/added "1.18" {::doc/added "1.18"
+34 -47
View File
@@ -174,38 +174,34 @@
;; --- COMMAND QUERY: get-file (by id) ;; --- COMMAND QUERY: get-file (by id)
(def schema:file (def schema:file
(sm/define [:map {:title "File"}
[:map {:title "File"} [:id ::sm/uuid]
[:id ::sm/uuid] [:features ::cfeat/features]
[:features ::cfeat/features] [:has-media-trimmed ::sm/boolean]
[:has-media-trimmed ::sm/boolean] [:comment-thread-seqn [::sm/int {:min 0}]]
[:comment-thread-seqn [::sm/int {:min 0}]] [:name [:string {:max 250}]]
[:name [:string {:max 250}]] [:revn [::sm/int {:min 0}]]
[:revn [::sm/int {:min 0}]] [:modified-at ::dt/instant]
[:modified-at ::dt/instant] [:is-shared ::sm/boolean]
[:is-shared ::sm/boolean] [:project-id ::sm/uuid]
[:project-id ::sm/uuid] [:created-at ::dt/instant]
[:created-at ::dt/instant] [:data {:optional true} :any]])
[:data {:optional true} :any]]))
(def schema:permissions-mixin (def schema:permissions-mixin
(sm/define [:map {:title "PermissionsMixin"}
[:map {:title "PermissionsMixin"} [:permissions ::perms/permissions]])
[:permissions ::perms/permissions]]))
(def schema:file-with-permissions (def schema:file-with-permissions
(sm/define [:merge {:title "FileWithPermissions"}
[:merge {:title "FileWithPermissions"} schema:file
schema:file schema:permissions-mixin])
schema:permissions-mixin]))
(def ^:private (def ^:private
schema:get-file schema:get-file
(sm/define [:map {:title "get-file"}
[:map {:title "get-file"} [:features {:optional true} ::cfeat/features]
[:features {:optional true} ::cfeat/features] [:id ::sm/uuid]
[:id ::sm/uuid] [:project-id {:optional true} ::sm/uuid]])
[:project-id {:optional true} ::sm/uuid]]))
(defn- migrate-file (defn- migrate-file
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}] [{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
@@ -273,7 +269,7 @@
(defn get-minimal-file (defn get-minimal-file
[cfg id & {:as opts}] [cfg id & {:as opts}]
(let [opts (assoc opts ::sql/columns [:id :modified-at :revn :data-ref-id :data-backend])] (let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :data-ref-id :data-backend])]
(db/get cfg :file {:id id} opts))) (db/get cfg :file {:id id} opts)))
(defn get-file-etag (defn get-file-etag
@@ -360,7 +356,7 @@
f.name, f.name,
f.revn, f.revn,
f.is_shared, f.is_shared,
ft.media_id ft.media_id AS thumbnail_id
from file as f from file as f
left join file_thumbnail as ft on (ft.file_id = f.id left join file_thumbnail as ft on (ft.file_id = f.id
and ft.revn = f.revn and ft.revn = f.revn
@@ -371,13 +367,7 @@
(defn get-project-files (defn get-project-files
[conn project-id] [conn project-id]
(->> (db/exec! conn [sql:project-files project-id]) (db/exec! conn [sql:project-files project-id]))
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(def schema:get-project-files (def schema:get-project-files
[:map {:title "get-project-files"} [:map {:title "get-project-files"}
@@ -487,7 +477,7 @@
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:page-id {:optional true} ::sm/uuid] [:page-id {:optional true} ::sm/uuid]
[:share-id {:optional true} ::sm/uuid] [:share-id {:optional true} ::sm/uuid]
[:object-id {:optional true} [:or ::sm/uuid ::sm/coll-of-uuid]] [:object-id {:optional true} [:or ::sm/uuid [::sm/set ::sm/uuid]]]
[:features {:optional true} ::cfeat/features]]) [:features {:optional true} ::cfeat/features]])
(sv/defmethod ::get-page (sv/defmethod ::get-page
@@ -914,10 +904,9 @@
(def ^:private (def ^:private
schema:set-file-shared schema:set-file-shared
(sm/define [:map {:title "set-file-shared"}
[:map {:title "set-file-shared"} [:id ::sm/uuid]
[:id ::sm/uuid] [:is-shared ::sm/boolean]])
[:is-shared ::sm/boolean]]))
(sv/defmethod ::set-file-shared (sv/defmethod ::set-file-shared
{::doc/added "1.17" {::doc/added "1.17"
@@ -944,9 +933,8 @@
(def ^:private (def ^:private
schema:delete-file schema:delete-file
(sm/define [:map {:title "delete-file"}
[:map {:title "delete-file"} [:id ::sm/uuid]])
[:id ::sm/uuid]]))
(defn- delete-file (defn- delete-file
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
@@ -978,10 +966,9 @@
(def ^:private (def ^:private
schema:link-file-to-library schema:link-file-to-library
(sm/define [:map {:title "link-file-to-library"}
[:map {:title "link-file-to-library"} [:file-id ::sm/uuid]
[:file-id ::sm/uuid] [:library-id ::sm/uuid]])
[:library-id ::sm/uuid]]))
(sv/defmethod ::link-file-to-library (sv/defmethod ::link-file-to-library
{::doc/added "1.17" {::doc/added "1.17"
@@ -1058,7 +1045,7 @@
(def ^:private schema:ignore-file-library-sync-status (def ^:private schema:ignore-file-library-sync-status
[:map {:title "ignore-file-library-sync-status"} [:map {:title "ignore-file-library-sync-status"}
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:date ::dt/duration]]) [:date ::dt/instant]])
;; TODO: improve naming ;; TODO: improve naming
(sv/defmethod ::ignore-file-library-sync-status (sv/defmethod ::ignore-file-library-sync-status
+40 -37
View File
@@ -98,46 +98,49 @@
{::doc/added "1.17" {::doc/added "1.17"
::doc/module :files ::doc/module :files
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-file} ::sm/params schema:create-file
[cfg {:keys [::rpc/profile-id project-id] :as params}] ::db/transaction true}
(db/tx-run! cfg [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
(fn [{:keys [::db/conn] :as cfg}] (projects/check-edition-permissions! conn profile-id project-id)
(projects/check-edition-permissions! conn profile-id project-id) (let [team (teams/get-team conn
(let [team (teams/get-team conn :profile-id profile-id
:profile-id profile-id :project-id project-id)
:project-id project-id) team-id (:id team)
team-id (:id team)
;; When we create files, we only need to respect the team ;; When we create files, we only need to respect the team
;; features, because some features can be enabled ;; features, because some features can be enabled
;; globally, but the team is still not migrated properly. ;; globally, but the team is still not migrated properly.
features (-> (cfeat/get-team-enabled-features cf/flags team) features (-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params))) (cfeat/check-client-features! (:features params)))
;; We also include all no migration features declared by ;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime ;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file ;; feature on frontend and make it permanent on file
features (-> (:features params #{}) features (-> (:features params #{})
(set/intersection cfeat/no-migration-features) (set/intersection cfeat/no-migration-features)
(set/union features)) (set/union features))
params (-> params params (-> params
(assoc :profile-id profile-id) (assoc :profile-id profile-id)
(assoc :features features))] (assoc :features features))]
(run! (partial quotes/check-quote! conn) (quotes/check! cfg {::quotes/id ::quotes/files-per-project
(list {::quotes/id ::quotes/files-per-project ::quotes/team-id team-id
::quotes/team-id team-id ::quotes/profile-id profile-id
::quotes/profile-id profile-id ::quotes/project-id project-id})
::quotes/project-id project-id}))
;; When newly computed features does not match exactly with ;; FIXME: IMPORTANT: this code can have race
;; the features defined on team row, we update it. ;; conditions, because we have no locks for updating
(when (not= features (:features team)) ;; team so, creating two files concurrently can lead
(let [features (db/create-array conn "text" features)] ;; to lost team features updating
(db/update! conn :team
{:features features}
{:id team-id})))
(-> (create-file cfg params) ;; When newly computed features does not match exactly with
(vary-meta assoc ::audit/props {:team-id team-id})))))) ;; the features defined on team row, we update it.
(when (not= features (:features team))
(let [features (db/create-array conn "text" features)]
(db/update! conn :team
{:features features}
{:id team-id})))
(-> (create-file cfg params)
(vary-meta assoc ::audit/props {:team-id team-id}))))
+28 -27
View File
@@ -45,37 +45,38 @@
(sv/defmethod ::create-temp-file (sv/defmethod ::create-temp-file
{::doc/added "1.17" {::doc/added "1.17"
::doc/module :files ::doc/module :files
::sm/params schema:create-temp-file} ::sm/params schema:create-temp-file
[cfg {:keys [::rpc/profile-id project-id] :as params}] ::db/transaction true}
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
(projects/check-edition-permissions! conn profile-id project-id) (projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id) (let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
input-features
(:features params #{})
;; When we create files, we only need to respect the team ;; If the imported project doesn't contain v2 we need to remove it
;; features, because some features can be enabled team-features
;; globally, but the team is still not migrated properly. (cond-> (cfeat/get-team-enabled-features cf/flags team)
input-features (:features params #{}) (not (contains? input-features "components/v2"))
(disj "components/v2"))
;; If the imported project doesn't contain v2 we need to remove it ;; We also include all no migration features declared by
team-features ;; client; that enables the ability to enable a runtime
(cond-> (cfeat/get-team-enabled-features cf/flags team) ;; feature on frontend and make it permanent on file
(not (contains? input-features "components/v2")) features
(disj "components/v2")) (-> input-features
(set/intersection cfeat/no-migration-features)
(set/union team-features))
params
(-> params
(assoc :profile-id profile-id)
(assoc :deleted-at (dt/in-future {:days 1}))
(assoc :features features))]
;; We also include all no migration features declared by (files.create/create-file cfg params)))
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features (-> input-features
(set/intersection cfeat/no-migration-features)
(set/union team-features))
params (-> params
(assoc :profile-id profile-id)
(assoc :deleted-at (dt/in-future {:days 1}))
(assoc :features features))]
(files.create/create-file cfg params)))))
;; --- MUTATION COMMAND: update-temp-file ;; --- MUTATION COMMAND: update-temp-file
@@ -179,18 +179,16 @@
(def ^:private (def ^:private
schema:get-file-data-for-thumbnail schema:get-file-data-for-thumbnail
(sm/define [:map {:title "get-file-data-for-thumbnail"}
[:map {:title "get-file-data-for-thumbnail"} [:file-id ::sm/uuid]
[:file-id ::sm/uuid] [:features {:optional true} ::cfeat/features]])
[:features {:optional true} ::cfeat/features]]))
(def ^:private (def ^:private
schema:partial-file schema:partial-file
(sm/define [:map {:title "PartialFile"}
[:map {:title "PartialFile"} [:id ::sm/uuid]
[:id ::sm/uuid] [:revn {:min 0} ::sm/int]
[:revn {:min 0} ::sm/int] [:page :any]])
[:page :any]]))
(sv/defmethod ::get-file-data-for-thumbnail (sv/defmethod ::get-file-data-for-thumbnail
"Retrieves the data for generate the thumbnail of the file. Used "Retrieves the data for generate the thumbnail of the file. Used
@@ -233,7 +231,7 @@
"INSERT INTO file_tagged_object_thumbnail (file_id, object_id, tag, media_id) "INSERT INTO file_tagged_object_thumbnail (file_id, object_id, tag, media_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT (file_id, object_id, tag) ON CONFLICT (file_id, object_id, tag)
DO UPDATE SET updated_at=?, media_id=?, deleted_at=null DO UPDATE SET updated_at=?, media_id=?, deleted_at=?
RETURNING *") RETURNING *")
(defn- persist-thumbnail! (defn- persist-thumbnail!
@@ -251,17 +249,19 @@
:content-type mtype :content-type mtype
:bucket "file-object-thumbnail"}))) :bucket "file-object-thumbnail"})))
(defn- create-file-object-thumbnail! (defn- create-file-object-thumbnail!
[{:keys [::sto/storage] :as cfg} file-id object-id media tag] [{:keys [::sto/storage] :as cfg} file object-id media tag]
(let [tsnow (dt/now) (let [file-id (:id file)
media (persist-thumbnail! storage media tsnow) timestamp (dt/now)
media (persist-thumbnail! storage media timestamp)
[th1 th2] (db/tx-run! cfg (fn [{:keys [::db/conn]}] [th1 th2] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [th1 (db/exec-one! conn [sql:get-file-object-thumbnail file-id object-id tag]) (let [th1 (db/exec-one! conn [sql:get-file-object-thumbnail file-id object-id tag])
th2 (db/exec-one! conn [sql:create-file-object-thumbnail th2 (db/exec-one! conn [sql:create-file-object-thumbnail
file-id object-id tag (:id media) file-id object-id tag
tsnow (:id media)])] (:id media)
timestamp
(:id media)
(:deleted-at file)])]
[th1 th2])))] [th1 th2])))]
(when (and (some? th1) (when (and (some? th1)
@@ -294,8 +294,8 @@
(media/validate-media-size! media) (media/validate-media-size! media)
(db/run! cfg files/check-edition-permissions! profile-id file-id) (db/run! cfg files/check-edition-permissions! profile-id file-id)
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
(create-file-object-thumbnail! cfg file-id object-id media (or tag "frame"))) (create-file-object-thumbnail! cfg file object-id media (or tag "frame"))))
;; --- MUTATION COMMAND: delete-file-object-thumbnail ;; --- MUTATION COMMAND: delete-file-object-thumbnail
+232 -183
View File
@@ -38,6 +38,20 @@
[clojure.set :as set] [clojure.set :as set]
[promesa.exec :as px])) [promesa.exec :as px]))
(declare ^:private get-lagged-changes)
(declare ^:private send-notifications!)
(declare ^:private update-file)
(declare ^:private update-file*)
(declare ^:private process-changes-and-validate)
(declare ^:private take-snapshot?)
(declare ^:private delete-old-snapshots!)
;; PUBLIC API; intended to be used outside of this module
(declare update-file!)
(declare update-file-data!)
(declare persist-file!)
(declare get-file)
;; --- SCHEMA ;; --- SCHEMA
(def ^:private (def ^:private
@@ -97,41 +111,6 @@
(or (contains? library-change-types type) (or (contains? library-change-types type)
(contains? file-change-types type))) (contains? file-change-types type)))
(def ^:private sql:get-file
"SELECT f.*, p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?
AND (f.deleted_at IS NULL OR
f.deleted_at > now())
FOR KEY SHARE")
(defn get-file
[conn id]
(let [file (db/exec-one! conn [sql:get-file id])]
(when-not file
(ex/raise :type :not-found
:code :object-not-found
:hint (format "file with id '%s' does not exists" id)))
(update file :features db/decode-pgarray #{})))
(defn- wrap-with-pointer-map-context
[f]
(fn [cfg {:keys [id] :as file}]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
(let [result (f cfg file)]
(feat.fdata/persist-pointers! cfg id)
result))))
(declare ^:private delete-old-snapshots!)
(declare ^:private get-lagged-changes)
(declare ^:private send-notifications!)
(declare ^:private take-snapshot?)
(declare ^:private update-file)
(declare ^:private update-file*)
(declare ^:private update-file-data)
;; If features are specified from params and the final feature ;; If features are specified from params and the final feature
;; set is different than the persisted one, update it on the ;; set is different than the persisted one, update it on the
;; database. ;; database.
@@ -147,7 +126,8 @@
::sm/result schema:update-file-result ::sm/result schema:update-file-result
::doc/module :files ::doc/module :files
::doc/added "1.17"} ::doc/added "1.17"}
[cfg {:keys [::rpc/profile-id id] :as params}] [{:keys [::mtx/metrics] :as cfg}
{:keys [::rpc/profile-id id changes changes-with-metadata] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id id) (files/check-edition-permissions! conn profile-id id)
(db/xact-lock! conn id) (db/xact-lock! conn id)
@@ -161,14 +141,30 @@
(cfeat/check-client-features! (:features params)) (cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file) (:features params))) (cfeat/check-file-features! (:features file) (:features params)))
params (assoc params changes (if changes-with-metadata
:profile-id profile-id (->> changes-with-metadata (mapcat :changes) vec)
:features features (vec changes))
:team team
:file file) params (-> params
(assoc :profile-id profile-id)
(assoc :features features)
(assoc :team team)
(assoc :file file)
(assoc :changes changes))
cfg (assoc cfg ::timestamp (dt/now))
tpoint (dt/tpoint)] tpoint (dt/tpoint)]
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
;; When newly computed features does not match exactly with ;; When newly computed features does not match exactly with
;; the features defined on team row, we update it. ;; the features defined on team row, we update it.
(when (not= features (:features team)) (when (not= features (:features team))
@@ -177,90 +173,126 @@
{:features features} {:features features}
{:id (:id team)}))) {:id (:id team)})))
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(binding [l/*context* (some-> (meta params) (binding [l/*context* (some-> (meta params)
(get :app.http/request) (get :app.http/request)
(errors/request->context))] (errors/request->context))]
(-> (update-file cfg params) (-> (update-file* cfg params)
(rph/with-defer #(let [elapsed (tpoint)] (rph/with-defer #(let [elapsed (tpoint)]
(l/trace :hint "update-file" :time (dt/format-duration elapsed)))))))))) (l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
(defn update-file
[{:keys [::mtx/metrics] :as cfg}
{:keys [file features changes changes-with-metadata] :as params}]
(let [features (-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file)))
update-fn (cond-> update-file*
(contains? features "fdata/pointer-map")
(wrap-with-pointer-map-context))
changes (if changes-with-metadata
(->> changes-with-metadata (mapcat :changes) vec)
(vec changes))]
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(let [file (assoc file :features features)
params (-> params
(assoc :file file)
(assoc :changes changes)
(assoc ::created-at (dt/now)))]
(-> (update-fn cfg params)
(vary-meta assoc ::audit/replace-props
{:id (:id file)
:name (:name file)
:features (:features file)
:project-id (:project-id file)
:team-id (:team-id file)}))))))
(defn- update-file* (defn- update-file*
[{:keys [::db/conn ::wrk/executor] :as cfg} "Internal function, part of the update-file process, that encapsulates
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}] the changes application offload to a separated thread and emit all
corresponding notifications.
Follow the inner implementation to `update-file-data!` function.
Only intended for internal use on this module."
[{:keys [::db/conn ::wrk/executor ::timestamp] :as cfg}
{:keys [profile-id file features changes session-id skip-validate] :as params}]
(let [;; Retrieve the file data (let [;; Retrieve the file data
file (feat.fdata/resolve-file-data cfg file) file (feat.fdata/resolve-file-data cfg file)
file (assoc file :features
(-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file))))
;; Process the file data on separated thread for avoid to do ;; Process the file data on separated thread for avoid to do
;; the CPU intensive operation on vthread. ;; the CPU intensive operation on vthread.
file (px/invoke! executor
(fn []
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(update-file-data! cfg file
process-changes-and-validate
changes skip-validate))))]
file (px/invoke! executor (partial update-file-data cfg file changes skip-validate)) (when (feat.fdata/offloaded? file)
features (db/create-array conn "text" (:features file))]
;; NOTE: if file was offloaded, we need to touch the referenced
;; storage object because on this update operation the data will
;; be overwritted.
(when (= "objects-storage" (:data-backend file))
(let [storage (sto/resolve cfg ::db/reuse-conn true)] (let [storage (sto/resolve cfg ::db/reuse-conn true)]
(sto/touch-object! storage (:data-ref-id file)))) (some->> (:data-ref-id file) (sto/touch-object! storage))))
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at created-at
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:label (::snapshot-label file)
:data (::snapshot-data file)
:features (db/create-array conn "text" (:features file))
:changes (blob/encode changes)}
{::db/return-keys false})
;; TODO: move this to asynchronous task
(when (::snapshot-data file) (when (::snapshot-data file)
(delete-old-snapshots! cfg file)) (delete-old-snapshots! cfg file))
(persist-file! cfg file)
(let [params (assoc params :file file)
response {:revn (:revn file)
:lagged (get-lagged-changes conn params)}
features (db/create-array conn "text" (:features file))]
;; Insert change (xlog)
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at timestamp
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:features features
:label (::snapshot-label file)
:data (::snapshot-data file)
:changes (blob/encode changes)}
{::db/return-keys false})
;; Send asynchronous notifications
(send-notifications! cfg params)
(vary-meta response assoc ::audit/replace-props
{:id (:id file)
:name (:name file)
:features (:features file)
:project-id (:project-id file)
:team-id (:team-id file)}))))
(defn update-file!
"A public api that allows apply a transformation to a file with all context setup."
[cfg file-id update-fn & args]
(let [file (get-file cfg file-id)
file (apply update-file-data! cfg file update-fn args)]
(persist-file! cfg file)))
(def ^:private sql:get-file
"SELECT f.*, p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?
AND (f.deleted_at IS NULL OR
f.deleted_at > now())
FOR KEY SHARE")
(defn get-file
"Get not-decoded file, only decodes the features set."
[conn id]
(let [file (db/exec-one! conn [sql:get-file id])]
(when-not file
(ex/raise :type :not-found
:code :object-not-found
:hint (format "file with id '%s' does not exists" id)))
(update file :features db/decode-pgarray #{})))
(defn persist-file!
"Function responsible of persisting already encoded file. Should be
used together with `get-file` and `update-file-data!`.
It also updates the project modified-at attr."
[{:keys [::db/conn ::timestamp]} file]
(let [features (db/create-array conn "text" (:features file))
;; The timestamp can be nil because this function is also
;; intended to be used outside of this module
modified-at (or timestamp (dt/now))]
(db/update! conn :project
{:modified-at modified-at}
{:id (:project-id file)}
{::db/return-keys false})
(db/update! conn :file (db/update! conn :file
{:revn (:revn file) {:revn (:revn file)
:data (:data file) :data (:data file)
@@ -268,20 +300,95 @@
:features features :features features
:data-backend nil :data-backend nil
:data-ref-id nil :data-ref-id nil
:modified-at created-at :modified-at modified-at
:has-media-trimmed false} :has-media-trimmed false}
{:id (:id file)}) {:id (:id file)}
{::db/return-keys false})))
(db/update! conn :project (defn- update-file-data!
{:modified-at created-at} "Perform a file data transformation in with all update context setup.
{:id (:project-id file)})
(let [params (assoc params :file file)] This function expected not-decoded file and transformation function. Returns
;; Send asynchronous notifications an encoded file.
(send-notifications! cfg params)
{:revn (:revn file) This function is not responsible of saving the file. It only saves
:lagged (get-lagged-changes conn params)}))) fdata/pointer-map modified fragments."
[cfg {:keys [id] :as file} update-fn & args]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
(let [file (update file :data (fn [data]
(-> data
(blob/decode)
(assoc :id (:id file)))))
;; For avoid unnecesary overhead of creating multiple pointers
;; and handly internally with objects map in their worst
;; case (when probably all shapes and all pointers will be
;; readed in any case), we just realize/resolve them before
;; applying the migration to the file
file (if (fmg/need-migration? file)
(-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
file)
file (apply update-fn cfg file args)
;; TODO: reuse operations if file is migrated
;; TODO: move encoding to a separated thread
file (if (take-snapshot? file)
(let [tpoint (dt/tpoint)
snapshot (-> (:data file)
(feat.fdata/process-pointers deref)
(feat.fdata/process-objects (partial into {}))
(blob/encode))
elapsed (tpoint)
label (str "internal/snapshot/" (:revn file))]
(l/trc :hint "take snapshot"
:file-id (str (:id file))
:revn (:revn file)
:label label
:elapsed (dt/format-duration elapsed))
(-> file
(assoc ::snapshot-data snapshot)
(assoc ::snapshot-label label)))
file)
file (cond-> file
(contains? cfeat/*current* "fdata/objects-map")
(feat.fdata/enable-objects-map)
(contains? cfeat/*current* "fdata/pointer-map")
(feat.fdata/enable-pointer-map)
:always
(update :data blob/encode))]
(feat.fdata/persist-pointers! cfg id)
file)))
(defn- get-file-libraries
"A helper for preload file libraries, mainly used for perform file
semantical and structural validation"
[{:keys [::db/conn] :as cfg} file]
(->> (files/get-file-libraries conn (:id file))
(into [file] (map (fn [{:keys [id]}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* nil]
;; We do not resolve the objects maps here
;; because there is a lower probability that all
;; shapes needed to be loded into memory, so we
;; leeave it on lazy status
(-> (files/get-file cfg id :migrate? false)
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))))))
(d/index-by :id)))
(defn- soft-validate-file-schema! (defn- soft-validate-file-schema!
[file] [file]
@@ -298,68 +405,19 @@
(l/error :hint "file validation error" (l/error :hint "file validation error"
:cause cause)))) :cause cause))))
(defn- update-file-data (defn- process-changes-and-validate
[{:keys [::db/conn] :as cfg} file changes skip-validate] [cfg file changes skip-validate]
(let [file (update file :data (fn [data] (let [;; WARNING: this ruins performance; maybe we need to find
(-> data
(blob/decode)
(assoc :id (:id file)))))
;; For avoid unnecesary overhead of creating multiple pointers
;; and handly internally with objects map in their worst
;; case (when probably all shapes and all pointers will be
;; readed in any case), we just realize/resolve them before
;; applying the migration to the file
file (if (fmg/need-migration? file)
(-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
file)
;; WARNING: this ruins performance; maybe we need to find
;; some other way to do general validation ;; some other way to do general validation
libs (when (and (or (contains? cf/flags :file-validation) libs (when (and (or (contains? cf/flags :file-validation)
(contains? cf/flags :soft-file-validation)) (contains? cf/flags :soft-file-validation))
(not skip-validate)) (not skip-validate))
(->> (files/get-file-libraries conn (:id file)) (get-file-libraries cfg file))
(into [file] (map (fn [{:keys [id]}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* nil]
;; We do not resolve the objects maps here
;; because there is a lower probability that all
;; shapes needed to be loded into memory, so we
;; leeave it on lazy status
(-> (files/get-file cfg id :migrate? false)
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))))))
(d/index-by :id)))
file (-> (files/check-version! file) file (-> (files/check-version! file)
(update :revn inc) (update :revn inc)
(update :data cpc/process-changes changes) (update :data cpc/process-changes changes)
(update :data d/without-nils)) (update :data d/without-nils))]
file (if (take-snapshot? file)
(let [tpoint (dt/tpoint)
snapshot (-> (:data file)
(feat.fdata/process-pointers deref)
(feat.fdata/process-objects (partial into {}))
(blob/encode))
elapsed (tpoint)
label (str "internal/snapshot/" (:revn file))]
(l/trc :hint "take snapshot"
:file-id (str (:id file))
:revn (:revn file)
:label label
:elapsed (dt/format-duration elapsed))
(-> file
(assoc ::snapshot-data snapshot)
(assoc ::snapshot-label label)))
file)]
(binding [pmap/*tracked* nil] (binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation) (when (contains? cf/flags :soft-file-validation)
@@ -376,15 +434,7 @@
(not skip-validate)) (not skip-validate))
(val/validate-file-schema! file))) (val/validate-file-schema! file)))
(cond-> file file))
(contains? cfeat/*current* "fdata/objects-map")
(feat.fdata/enable-objects-map)
(contains? cfeat/*current* "fdata/pointer-map")
(feat.fdata/enable-pointer-map)
:always
(update :data blob/encode))))
(defn- take-snapshot? (defn- take-snapshot?
"Defines the rule when file `data` snapshot should be saved." "Defines the rule when file `data` snapshot should be saved."
@@ -426,8 +476,7 @@
result (db/exec-one! conn [sql:delete-snapshots id last-date])] result (db/exec-one! conn [sql:delete-snapshots id last-date])]
(l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result))))) (l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result)))))
(def ^:private (def ^:private sql:lagged-changes
sql:lagged-changes
"select s.id, s.revn, s.file_id, "select s.id, s.revn, s.file_id,
s.session_id, s.changes s.session_id, s.changes
from file_change as s from file_change as s
+6 -3
View File
@@ -86,6 +86,9 @@
[:font-weight [::sm/one-of {:format "number"} valid-weight]] [:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]]) [:font-style [::sm/one-of {:format "string"} valid-style]]])
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
;; connection around the font creation
(sv/defmethod ::create-font-variant (sv/defmethod ::create-font-variant
{::doc/added "1.18" {::doc/added "1.18"
::climit/id [[:process-font/by-profile ::rpc/profile-id] ::climit/id [[:process-font/by-profile ::rpc/profile-id]
@@ -96,9 +99,9 @@
(db/tx-run! cfg (db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id) (teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/font-variants-per-team (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id ::quotes/profile-id profile-id
::quotes/team-id team-id}) ::quotes/team-id team-id})
(create-font-variant cfg (assoc params :profile-id profile-id))))) (create-font-variant cfg (assoc params :profile-id profile-id)))))
(defn create-font-variant (defn create-font-variant
+14 -15
View File
@@ -88,10 +88,9 @@
(def ^:private (def ^:private
schema:duplicate-file schema:duplicate-file
(sm/define [:map {:title "duplicate-file"}
[:map {:title "duplicate-file"} [:file-id ::sm/uuid]
[:file-id ::sm/uuid] [:name {:optional true} [:string {:max 250}]]])
[:name {:optional true} [:string {:max 250}]]]))
(sv/defmethod ::duplicate-file (sv/defmethod ::duplicate-file
"Duplicate a single file in the same team." "Duplicate a single file in the same team."
@@ -150,10 +149,9 @@
(def ^:private (def ^:private
schema:duplicate-project schema:duplicate-project
(sm/define [:map {:title "duplicate-project"}
[:map {:title "duplicate-project"} [:project-id ::sm/uuid]
[:project-id ::sm/uuid] [:name {:optional true} [:string {:max 250}]]])
[:name {:optional true} [:string {:max 250}]]]))
(sv/defmethod ::duplicate-project (sv/defmethod ::duplicate-project
"Duplicate an entire project with all the files" "Duplicate an entire project with all the files"
@@ -327,10 +325,9 @@
(def ^:private (def ^:private
schema:move-files schema:move-files
(sm/define [:map {:title "move-files"}
[:map {:title "move-files"} [:ids ::sm/set-of-uuid]
[:ids ::sm/set-of-uuid] [:project-id ::sm/uuid]])
[:project-id ::sm/uuid]]))
(sv/defmethod ::move-files (sv/defmethod ::move-files
"Move a set of files from one project to other." "Move a set of files from one project to other."
@@ -396,8 +393,8 @@
;; --- COMMAND: Clone Template ;; --- COMMAND: Clone Template
(defn- clone-template (defn clone-template
[cfg {:keys [project-id ::rpc/profile-id] :as params} template] [cfg {:keys [project-id profile-id] :as params} template]
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
;; NOTE: the importation process performs some operations that ;; NOTE: the importation process performs some operations that
;; are not very friendly with virtual threads, and for avoid ;; are not very friendly with virtual threads, and for avoid
@@ -416,6 +413,7 @@
(doseq [file-id result] (doseq [file-id result]
(let [props (assoc props :id file-id) (let [props (assoc props :id file-id)
event (-> (audit/event-from-rpc-params params) event (-> (audit/event-from-rpc-params params)
(assoc ::audit/profile-id profile-id)
(assoc ::audit/name "create-file") (assoc ::audit/name "create-file")
(assoc ::audit/props props))] (assoc ::audit/props props))]
(audit/submit! cfg event)))) (audit/submit! cfg event))))
@@ -437,7 +435,8 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]}) (let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
_ (teams/check-edition-permissions! pool profile-id (:team-id project)) _ (teams/check-edition-permissions! pool profile-id (:team-id project))
template (tmpl/get-template-stream cfg template-id)] template (tmpl/get-template-stream cfg template-id)
params (assoc params :profile-id profile-id)]
(when-not template (when-not template
(ex/raise :type :not-found (ex/raise :type :not-found
+64 -56
View File
@@ -10,6 +10,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.plugins :refer [schema:plugin-registry]]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
@@ -40,6 +41,33 @@
(declare strip-private-attrs) (declare strip-private-attrs)
(declare verify-password) (declare verify-password)
(def schema:props
[:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry]
[:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid]
[:onboarding-viewed {:optional true} ::sm/boolean]
[:v2-info-shown {:optional true} ::sm/boolean]
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
[:release-notes-viewed {:optional true}
[::sm/text {:max 100}]]])
(def schema:profile
[:map {:title "Profile"}
[:id ::sm/uuid]
[:fullname [::sm/word-string {:max 250}]]
[:email ::sm/email]
[:is-active {:optional true} ::sm/boolean]
[:is-blocked {:optional true} ::sm/boolean]
[:is-demo {:optional true} ::sm/boolean]
[:is-muted {:optional true} ::sm/boolean]
[:created-at {:optional true} ::sm/inst]
[:modified-at {:optional true} ::sm/inst]
[:default-project-id {:optional true} ::sm/uuid]
[:default-team-id {:optional true} ::sm/uuid]
[:props {:optional true} schema:props]])
(defn clean-email (defn clean-email
"Clean and normalizes email address string" "Clean and normalizes email address string"
[email] [email]
@@ -53,24 +81,6 @@
email)] email)]
email)) email))
(def ^:private
schema:profile
(sm/define
[:map {:title "Profile"}
[:id ::sm/uuid]
[:fullname [::sm/word-string {:max 250}]]
[:email ::sm/email]
[:is-active {:optional true} ::sm/boolean]
[:is-blocked {:optional true} ::sm/boolean]
[:is-demo {:optional true} ::sm/boolean]
[:is-muted {:optional true} ::sm/boolean]
[:created-at {:optional true} ::sm/inst]
[:modified-at {:optional true} ::sm/inst]
[:default-project-id {:optional true} ::sm/uuid]
[:default-team-id {:optional true} ::sm/uuid]
[:props {:optional true}
[:map-of {:title "ProfileProps"} :keyword :any]]]))
;; --- QUERY: Get profile (own) ;; --- QUERY: Get profile (own)
(sv/defmethod ::get-profile (sv/defmethod ::get-profile
@@ -99,11 +109,10 @@
(def ^:private (def ^:private
schema:update-profile schema:update-profile
(sm/define [:map {:title "update-profile"}
[:map {:title "update-profile"} [:fullname [::sm/word-string {:max 250}]]
[:fullname [::sm/word-string {:max 250}]] [:lang {:optional true} [:string {:max 8}]]
[:lang {:optional true} [:string {:max 8}]] [:theme {:optional true} [:string {:max 250}]]])
[:theme {:optional true} [:string {:max 250}]]]))
(sv/defmethod ::update-profile (sv/defmethod ::update-profile
{::doc/added "1.0" {::doc/added "1.0"
@@ -144,11 +153,10 @@
(def ^:private (def ^:private
schema:update-profile-password schema:update-profile-password
(sm/define [:map {:title "update-profile-password"}
[:map {:title "update-profile-password"} [:password [::sm/word-string {:max 500}]]
[:password [::sm/word-string {:max 500}]] ;; Social registered users don't have old-password
;; Social registered users don't have old-password [:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]])
[:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]]))
(sv/defmethod ::update-profile-password (sv/defmethod ::update-profile-password
{::doc/added "1.0" {::doc/added "1.0"
@@ -199,9 +207,8 @@
(def ^:private (def ^:private
schema:update-profile-photo schema:update-profile-photo
(sm/define [:map {:title "update-profile-photo"}
[:map {:title "update-profile-photo"} [:file ::media/upload]])
[:file ::media/upload]]))
(sv/defmethod ::update-profile-photo (sv/defmethod ::update-profile-photo
{:doc/added "1.1" {:doc/added "1.1"
@@ -268,9 +275,8 @@
(def ^:private (def ^:private
schema:request-email-change schema:request-email-change
(sm/define [:map {:title "request-email-change"}
[:map {:title "request-email-change"} [:email ::sm/email]])
[:email ::sm/email]]))
(sv/defmethod ::request-email-change (sv/defmethod ::request-email-change
{::doc/added "1.0" {::doc/added "1.0"
@@ -351,36 +357,38 @@
:extra-data ptoken}) :extra-data ptoken})
nil)) nil))
;; --- MUTATION: Update Profile Props ;; --- MUTATION: Update Profile Props
(def ^:private (def ^:private
schema:update-profile-props schema:update-profile-props
(sm/define [:map {:title "update-profile-props"}
[:map {:title "update-profile-props"} [:props schema:props]])
[:props [:map-of :keyword :any]]]))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::sql/for-update true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id})
(filter-props props)))
(sv/defmethod ::update-profile-props (sv/defmethod ::update-profile-props
{::doc/added "1.0" {::doc/added "1.0"
::sm/params schema:update-profile-props} ::sm/params schema:update-profile-props}
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] [cfg {:keys [::rpc/profile-id props]}]
(db/with-atomic [conn pool] (db/tx-run! cfg (fn [cfg]
(let [profile (get-profile conn profile-id ::sql/for-update true) (update-profile-props cfg profile-id props))))
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id})
(filter-props props))))
;; --- MUTATION: Delete Profile ;; --- MUTATION: Delete Profile
+18 -15
View File
@@ -168,6 +168,17 @@
;; --- MUTATION: Create Project ;; --- MUTATION: Create Project
(defn- create-project
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [project (teams/create-project conn params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
:profile-id profile-id
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false)))
(def ^:private schema:create-project (def ^:private schema:create-project
[:map {:title "create-project"} [:map {:title "create-project"}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
@@ -178,23 +189,15 @@
{::doc/added "1.18" {::doc/added "1.18"
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-project} ::sm/params schema:create-project}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] [cfg {:keys [::rpc/profile-id team-id] :as params}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (assoc params :profile-id profile-id) (teams/check-edition-permissions! cfg profile-id team-id)
project (teams/create-project conn params)] (quotes/check! cfg {::quotes/id ::quotes/projects-per-team
(teams/create-project-role conn profile-id (:id project) :owner) ::quotes/profile-id profile-id
(db/insert! conn :team-project-profile-rel ::quotes/team-id team-id})
{:project-id (:id project)
:profile-id profile-id
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false))))
(let [params (assoc params :profile-id profile-id)]
(db/tx-run! cfg create-project params)))
;; --- MUTATION: Toggle Project Pin ;; --- MUTATION: Toggle Project Pin
+181 -131
View File
@@ -82,19 +82,17 @@
(cond-> row (cond-> row
(some? features) (assoc :features (db/decode-pgarray features #{})))) (some? features) (assoc :features (db/decode-pgarray features #{}))))
(defn- check-profile-muted
"Check if the member's email is part of the global bounce report"
(defn- check-valid-email-muted
"Check if the member's email is part of the global bounce report."
[conn member] [conn member]
(let [email (profile/clean-email (:email member))] (let [email (profile/clean-email (:email member))]
(when (and member (not (eml/allow-send-emails? conn member))) (when (and member (not (eml/allow-send-emails? conn member)))
(ex/raise :type :validation (ex/raise :type :validation
:code :member-is-muted :code :member-is-muted
:email email :email email
:hint "the profile has reported repeatedly as spam or has bounces")))) :hint "the profile has reported repeatedly as spam or has bounces"))))
(defn- check-valid-email-bounce (defn- check-email-bounce
"Check if the email is part of the global complain report" "Check if the email is part of the global complain report"
[conn email show?] [conn email show?]
(when (eml/has-bounce-reports? conn email) (when (eml/has-bounce-reports? conn email)
@@ -103,7 +101,7 @@
:email (if show? email "private") :email (if show? email "private")
:hint "this email has been repeatedly reported as bounce"))) :hint "this email has been repeatedly reported as bounce")))
(defn- check-valid-email-spam (defn- check-email-spam
"Check if the member email is part of the global complain report" "Check if the member email is part of the global complain report"
[conn email show?] [conn email show?]
(when (eml/has-complaint-reports? conn email) (when (eml/has-complaint-reports? conn email)
@@ -227,16 +225,16 @@
;; --- Query: Team Members ;; --- Query: Team Members
(def sql:team-members (def sql:team-members
"select tp.*, "SELECT tp.*,
p.id, p.id,
p.email, p.email,
p.fullname as name, p.fullname AS name,
p.fullname as fullname, p.fullname AS fullname,
p.photo_id, p.photo_id,
p.is_active p.is_active
from team_profile_rel as tp FROM team_profile_rel AS tp
join profile as p on (p.id = tp.profile_id) JOIN profile AS p ON (p.id = tp.profile_id)
where tp.team_id = ?") WHERE tp.team_id = ?")
(defn get-team-members (defn get-team-members
[conn team-id] [conn team-id]
@@ -403,17 +401,19 @@
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:create-team} ::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags) (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
(cfeat/check-client-features! (:features params))) ::quotes/profile-id profile-id})
team (create-team cfg (assoc params
:profile-id profile-id (let [features (-> (cfeat/get-enabled-features cf/flags)
:features features))] (cfeat/check-client-features! (:features params)))
(with-meta team params (-> params
{::audit/props {:id (:id team)}}))))) (assoc :profile-id profile-id)
(assoc :features features))
team (db/tx-run! cfg create-team params)]
(with-meta team
{::audit/props {:id (:id team)}})))
(defn create-team (defn create-team
"This is a complete team creation process, it creates the team "This is a complete team creation process, it creates the team
@@ -767,21 +767,51 @@
:member-id member-id})) :member-id member-id}))
(defn- create-profile-identity-token (defn- create-profile-identity-token
[cfg profile] [cfg profile-id]
(dm/assert!
"expected valid uuid for profile-id"
(uuid? profile-id))
(tokens/generate (::setup/props cfg) (tokens/generate (::setup/props cfg)
{:iss :profile-identity {:iss :profile-identity
:profile-id (:id profile) :profile-id profile-id
:exp (dt/in-future {:days 30})})) :exp (dt/in-future {:days 30})}))
(def ^:private schema:create-invitation
[:map {:title "params:create-invitation"}
[::rpc/profile-id ::sm/uuid]
[:team
[:map
[:id ::sm/uuid]
[:name :string]]]
[:profile
[:map
[:id ::sm/uuid]
[:fullname :string]]]
[:role [::sm/one-of valid-roles]]
[:email ::sm/email]])
(def ^:private check-create-invitation-params!
(sm/check-fn schema:create-invitation))
(defn- create-invitation (defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
(dm/assert!
"expected valid connection on cfg parameter"
(db/connection? conn))
(dm/assert!
"expected valid params for `create-invitation` fn"
(check-create-invitation-params! params))
(let [email (profile/clean-email email) (let [email (profile/clean-email email)
member (profile/get-profile-by-email conn email)] member (profile/get-profile-by-email conn email)]
(check-valid-email-muted conn member) (check-profile-muted conn member)
(check-valid-email-bounce conn email true) (check-email-bounce conn email true)
(check-valid-email-spam conn email true) (check-email-spam conn email true)
;; When we have email verification disabled and invitation user is ;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the ;; already present in the database, we proceed to add it to the
@@ -815,7 +845,8 @@
(name role) expire (name role) expire
(name role) expire]) (name role) expire])
updated? (not= id (:id invitation)) updated? (not= id (:id invitation))
tprops {:profile-id (:id profile) profile-id (:id profile)
tprops {:profile-id profile-id
:invitation-id (:id invitation) :invitation-id (:id invitation)
:valid-until expire :valid-until expire
:team-id (:id team) :team-id (:id team)
@@ -823,12 +854,11 @@
:member-id (:id member) :member-id (:id member)
:role role} :role role}
itoken (create-invitation-token cfg tprops) itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile)] ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens) (when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken)) (l/info :hint "invitation token" :token itoken))
(let [props (-> (dissoc tprops :profile-id) (let [props (-> (dissoc tprops :profile-id)
(audit/clean-props)) (audit/clean-props))
evname (if updated? evname (if updated?
@@ -851,26 +881,27 @@
itoken)))) itoken))))
(defn- add-user-to-team (defn- add-user-to-team
[conn profile team email role] [conn profile team role email]
(let [team-id (:id team) (let [team-id (:id team)
member (db/get* conn :profile member (db/get* conn :profile
{:email (str/lower email)} {:email (str/lower email)}
{::sql/columns [:id :email]}) {::sql/columns [:id :email]})
params (merge params (merge
{:team-id team-id {:team-id team-id
:profile-id (:id member)} :profile-id (:id member)}
(role->params role))] (role->params role))]
;; Do not allow blocked users to join teams. ;; Do not allow blocked users to join teams.
(when (:is-blocked member) (when (:is-blocked member)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :profile-blocked)) :code :profile-blocked))
(quotes/check-quote! conn (quotes/check!
{::quotes/id ::quotes/profiles-per-team {::db/conn conn
::quotes/profile-id (:id member) ::quotes/id ::quotes/profiles-per-team
::quotes/team-id team-id}) ::quotes/profile-id (:id member)
::quotes/team-id team-id})
;; Insert the member to the team ;; Insert the member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
@@ -902,68 +933,89 @@
[conn team-id] [conn team-id]
(db/exec! conn [sql:valid-requests-email team-id])) (db/exec! conn [sql:valid-requests-email team-id]))
(def ^:private xf:map-email
(map :email))
(defn- create-team-invitations
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}]
(let [join-requests (into #{} xf:map-email
(get-valid-requests-email conn (:id team)))
team-members (into #{} xf:map-email
(get-team-members conn (:id team)))
invitations (into #{}
(comp
;; We don't re-send inviation to
;; already existing members
(remove team-members)
;; We don't send invitations to
;; join-requested members
(remove join-requests)
(map (fn [email] (assoc params :email email)))
(keep (partial create-invitation cfg)))
emails)]
;; For requested invitations, do not send invitation emails, add
;; the user directly to the team
(->> (filter join-requests emails)
(run! (partial add-user-to-team conn profile team role)))
invitations))
(def ^:private schema:create-team-invitations (def ^:private schema:create-team-invitations
[:map {:title "create-team-invitations"} [:map {:title "create-team-invitations"}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:role schema:role] [:role schema:role]
[:emails [::sm/set ::sm/email]]]) [:emails [::sm/set ::sm/email]]])
(def ^:private max-invitations-by-request-threshold
"The number of invitations can be sent in a single rpc request"
25)
(sv/defmethod ::create-team-invitations (sv/defmethod ::create-team-invitations
"A rpc call that allow to send a single or multiple invitations to "A rpc call that allow to send a single or multiple invitations to
join the team." join the team."
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:create-team-invitations} ::sm/params schema:create-team-invitations}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id emails role] :as params}] [cfg {:keys [::rpc/profile-id team-id emails] :as params}]
(db/with-atomic [conn pool] (let [perms (get-permissions cfg profile-id team-id)
(let [perms (get-permissions conn profile-id team-id) profile (db/get-by-id cfg :profile profile-id)
profile (db/get-by-id conn :profile profile-id) emails (into #{} (map profile/clean-email) emails)]
team (db/get-by-id conn :team team-id)
emails (into #{} (map profile/clean-email) emails)]
(run! (partial quotes/check-quote! conn) (when-not (:is-admin perms)
(list {::quotes/id ::quotes/invitations-per-team (ex/raise :type :validation
::quotes/profile-id profile-id :code :insufficient-permissions))
::quotes/team-id (:id team)
::quotes/incr (count emails)}
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
(when-not (:is-admin perms) (when (> (count emails) max-invitations-by-request-threshold)
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :code :max-invitations-by-request
:hint "the maximum of invitation on single request is reached"
:threshold max-invitations-by-request-threshold))
;; Check if the current profile is allowed to send emails. (-> cfg
(check-valid-email-muted conn profile) (assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(assoc ::quotes/incr (count emails))
(quotes/check! {::quotes/id ::quotes/invitations-per-team}
{::quotes/id ::quotes/profiles-per-team}))
;; Check if the current profile is allowed to send emails
(check-profile-muted cfg profile)
(let [requested (into #{} (map :email) (get-valid-requests-email conn team-id)) (let [team (db/get-by-id cfg :team team-id)
emails-to-add (filter #(contains? requested %) emails) ;; NOTE: Is important pass RPC method params down to the
emails (remove #(contains? requested %) emails) ;; `create-team-invitations` because it uses the implicit
cfg (assoc cfg ::db/conn conn) ;; RPC properties from params for fill necessary data on
members (->> (db/exec! conn [sql:team-members team-id]) ;; emiting an entry to the audit-log
(into #{} (map :email))) invitations (db/tx-run! cfg create-team-invitations
(-> params
(assoc :profile profile)
(assoc :team team)
(assoc :emails emails)))]
invitations (into #{} (with-meta {:total (count invitations)
(comp :invitations invitations}
;; We don't re-send inviation to already existing members {::audit/props {:invitations (count invitations)}}))))
(remove (partial contains? members))
(map (fn [email]
(-> params
(assoc :email email)
(assoc :team team)
(assoc :profile profile)
(assoc :role role))))
(keep (partial create-invitation cfg)))
emails)]
;; For requested invitations, do not send invitation emails, add the user directly to the team
(doseq [email emails-to-add]
(add-user-to-team conn profile team email role))
(with-meta {:total (count invitations)
:invitations invitations}
{::audit/props {:invitations (count invitations)}})))))
;; --- Mutation: Create Team & Invite Members ;; --- Mutation: Create Team & Invite Members
@@ -977,52 +1029,50 @@
(sv/defmethod ::create-team-with-invitations (sv/defmethod ::create-team-with-invitations
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:create-team-with-invitations} ::sm/params schema:create-team-with-invitations
[cfg {:keys [::rpc/profile-id emails role name] :as params}] ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}]
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
(db/tx-run! cfg params (-> params
(fn [{:keys [::db/conn] :as cfg}] (assoc :profile-id profile-id)
(let [features (-> (cfeat/get-enabled-features cf/flags) (assoc :features features))
(cfeat/check-client-features! (:features params)))
params (-> params team (create-team cfg params)
(assoc :profile-id profile-id) emails (into #{} (map profile/clean-email) emails)]
(assoc :features features))
cfg (assoc cfg ::db/conn conn) (-> cfg
team (create-team cfg params) (assoc ::quotes/profile-id profile-id)
profile (db/get-by-id conn :profile profile-id) (assoc ::quotes/team-id (:id team))
emails (into #{} (map profile/clean-email) emails)] (assoc ::quotes/incr (count emails))
(quotes/check! {::quotes/id ::quotes/teams-per-profile}
{::quotes/id ::quotes/invitations-per-team}
{::quotes/id ::quotes/profiles-per-team}))
(let [props {:name name :features features} (when (> (count emails) max-invitations-by-request-threshold)
event (-> (audit/event-from-rpc-params params) (ex/raise :type :validation
(assoc ::audit/name "create-team") :code :max-invitations-by-request
(assoc ::audit/props props))] :hint "the maximum of invitation on single request is reached"
(audit/submit! cfg event)) :threshold max-invitations-by-request-threshold))
;; Create invitations for all provided emails. (let [props {:name name :features features}
(->> emails event (-> (audit/event-from-rpc-params params)
(map (fn [email] (assoc ::audit/name "create-team")
(-> params (assoc ::audit/props props))]
(assoc :team team) (audit/submit! cfg event))
(assoc :profile profile)
(assoc :email email)
(assoc :role role))))
(run! (partial create-invitation cfg)))
(run! (partial quotes/check-quote! conn) ;; Create invitations for all provided emails.
(list {::quotes/id ::quotes/teams-per-profile (let [profile (db/get-by-id conn :profile profile-id)
::quotes/profile-id profile-id} params (-> params
{::quotes/id ::quotes/invitations-per-team (assoc :team team)
::quotes/profile-id profile-id (assoc :profile profile)
::quotes/team-id (:id team) (assoc :role role))
::quotes/incr (count emails)} invitations (->> emails
{::quotes/id ::quotes/profiles-per-team (map (fn [email] (assoc params :email email)))
::quotes/profile-id profile-id (map (partial create-invitation cfg)))]
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
(vary-meta team assoc ::audit/props {:invitations (count emails)}))))) (vary-meta team assoc ::audit/props {:invitations (count invitations)}))))
;; --- Query: get-team-invitation-token ;; --- Query: get-team-invitation-token
@@ -1215,11 +1265,11 @@
:code :invalid-parameters)) :code :invalid-parameters))
;; Check that the requester is not muted ;; Check that the requester is not muted
(check-valid-email-muted conn requester) (check-profile-muted conn requester)
;; Check that the owner is not marked as bounce nor spam ;; Check that the owner is not marked as bounce nor spam
(check-valid-email-bounce conn (:email team-owner) false) (check-email-bounce conn (:email team-owner) false)
(check-valid-email-spam conn (:email team-owner) true) (check-email-spam conn (:email team-owner) true)
(let [request (create-team-access-request (let [request (create-team-access-request
cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})] cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})]
+14 -25
View File
@@ -30,21 +30,19 @@
(def ^:private schema:verify-token (def ^:private schema:verify-token
[:map {:title "verify-token"} [:map {:title "verify-token"}
[:token [:string {:max 1000}]]]) [:token [:string {:max 5000}]]])
(sv/defmethod ::verify-token (sv/defmethod ::verify-token
{::rpc/auth false {::rpc/auth false
::doc/added "1.15" ::doc/added "1.15"
::doc/module :auth ::doc/module :auth
::sm/params schema:verify-token} ::sm/params schema:verify-token}
[{:keys [::db/pool] :as cfg} {:keys [token] :as params}] [cfg {:keys [token] :as params}]
(db/with-atomic [conn pool] (let [claims (tokens/verify (::setup/props cfg) {:token token})]
(let [claims (tokens/verify (::setup/props cfg) {:token token}) (db/tx-run! cfg process-token params claims)))
cfg (assoc cfg :conn conn)]
(process-token cfg params claims))))
(defmethod process-token :change-email (defmethod process-token :change-email
[{:keys [conn] :as cfg} _params {:keys [profile-id email] :as claims}] [{:keys [::db/conn] :as cfg} _params {:keys [profile-id email] :as claims}]
(let [email (profile/clean-email email)] (let [email (profile/clean-email email)]
(when (profile/get-profile-by-email conn email) (when (profile/get-profile-by-email conn email)
(ex/raise :type :validation (ex/raise :type :validation
@@ -60,7 +58,7 @@
::audit/profile-id profile-id}))) ::audit/profile-id profile-id})))
(defmethod process-token :verify-email (defmethod process-token :verify-email
[{:keys [conn] :as cfg} _ {:keys [profile-id] :as claims}] [{:keys [::db/conn] :as cfg} _ {:keys [profile-id] :as claims}]
(let [profile (profile/get-profile conn profile-id) (let [profile (profile/get-profile conn profile-id)
claims (assoc claims :profile profile)] claims (assoc claims :profile profile)]
@@ -81,22 +79,14 @@
::audit/profile-id (:id profile)})))) ::audit/profile-id (:id profile)}))))
(defmethod process-token :auth (defmethod process-token :auth
[{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}] [{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
(let [profile (profile/get-profile conn profile-id {::sql/for-update true}) (let [profile (profile/get-profile conn profile-id)]
props (merge (:props profile) (assoc claims :profile profile)))
(:props claims))]
(when (not= props (:props profile))
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}))
(let [profile (assoc profile :props props)]
(assoc claims :profile profile))))
;; --- Team Invitation ;; --- Team Invitation
(defn- accept-invitation (defn- accept-invitation
[{:keys [conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member] [{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
(let [;; Update the role if there is an invitation (let [;; Update the role if there is an invitation
role (or (some-> invitation :role keyword) role) role (or (some-> invitation :role keyword) role)
params (merge params (merge
@@ -109,10 +99,9 @@
(ex/raise :type :restriction (ex/raise :type :restriction
:code :profile-blocked)) :code :profile-blocked))
(quotes/check-quote! conn (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
{::quotes/id ::quotes/profiles-per-team ::quotes/profile-id (:id member)
::quotes/profile-id (:id member) ::quotes/team-id team-id})
::quotes/team-id team-id})
;; Insert the invited member to the team ;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
@@ -148,7 +137,7 @@
(sm/lazy-validator schema:team-invitation-claims)) (sm/lazy-validator schema:team-invitation-claims))
(defmethod process-token :team-invitation (defmethod process-token :team-invitation
[{:keys [conn] :as cfg} [{:keys [::db/conn] :as cfg}
{:keys [::rpc/profile-id token] :as params} {:keys [::rpc/profile-id token] :as params}
{:keys [member-id team-id member-email] :as claims}] {:keys [member-id team-id member-email] :as claims}]
+181 -123
View File
@@ -7,16 +7,13 @@
(ns app.rpc.quotes (ns app.rpc.quotes
"Penpot resource usage quotes." "Penpot resource usage quotes."
(:require (:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.spec :as us]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(defmulti check-quote ::id) (defmulti check-quote ::id)
@@ -26,14 +23,16 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:quote (def ^:private schema:quote
(sm/define [:map {:title "Quote"}
[:map {:title "Quote"} [::team-id {:optional true} ::sm/uuid]
[::team-id {:optional true} ::sm/uuid] [::project-id {:optional true} ::sm/uuid]
[::project-id {:optional true} ::sm/uuid] [::file-id {:optional true} ::sm/uuid]
[::file-id {:optional true} ::sm/uuid] [::incr {:optional true} [::sm/int {:min 0}]]
[::incr {:optional true} [::sm/int {:min 0}]] [::id :keyword]
[::id :keyword] [::profile-id ::sm/uuid]])
[::profile-id ::sm/uuid]]))
(def valid-quote?
(sm/lazy-validator schema:quote))
(def ^:private enabled (volatile! true)) (def ^:private enabled (volatile! true))
@@ -47,20 +46,31 @@
[] []
(vswap! enabled (constantly false))) (vswap! enabled (constantly false)))
(defn check-quote! (defn- check
[ds quote] [cfg quote]
(dm/assert! (let [quote (merge cfg quote)
"expected valid quote map" id (::id quote)]
(sm/validate schema:quote quote))
(when (contains? cf/flags :quotes) (when-not (valid-quote? quote)
(when @enabled (ex/raise :type :internal
;; This approach add flexibility on how and where the :code :invalid-quote-definition
;; check-quote! can be called (in or out of transaction) :hint "found invalid data for quote schema"
(db/run! ds (fn [cfg] :quote (name id)))
(-> (merge cfg quote)
(assoc ::target (name (::id quote))) (-> (assoc quote ::target (name id))
(check-quote))))))) (check-quote))))
(defn check!
([cfg]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg check {}))))
([cfg & others]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg (fn [cfg]
(run! (partial check cfg) others)))))))
(defn- send-notification! (defn- send-notification!
[{:keys [::db/conn] :as params}] [{:keys [::db/conn] :as params}]
@@ -101,7 +111,7 @@
(map :quote) (map :quote)
(reduce max (- Integer/MAX_VALUE))) (reduce max (- Integer/MAX_VALUE)))
quote (if (pos? quote) quote default) quote (if (pos? quote) quote default)
total (->> (db/exec! conn count-sql) first :total)] total (:total (db/exec-one! conn count-sql))]
(when (> (+ total incr) quote) (when (> (+ total incr) quote)
(if (contains? cf/flags :soft-quotes) (if (contains? cf/flags :soft-quotes)
@@ -113,72 +123,81 @@
:count total))))) :count total)))))
(def ^:private sql:get-quotes-1 (def ^:private sql:get-quotes-1
"select id, quote from usage_quote "SELECT id, quote
where target = ? FROM usage_quote
and profile_id = ? WHERE target = ?
and team_id is null AND profile_id = ?
and project_id is null AND team_id IS NULL
and file_id is null;") AND project_id IS NULL
AND file_id IS NULL;")
(def ^:private sql:get-quotes-2 (def ^:private sql:get-quotes-2
"select id, quote from usage_quote "SELECT id, quote
where target = ? FROM usage_quote
and ((team_id = ? and (profile_id = ? or profile_id is null)) or WHERE target = ?
(profile_id = ? and team_id is null and project_id is null and file_id is null));") AND ((team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
(def ^:private sql:get-quotes-3 (def ^:private sql:get-quotes-3
"select id, quote from usage_quote "SELECT id, quote
where target = ? FROM usage_quote
and ((project_id = ? and (profile_id = ? or profile_id is null)) or WHERE target = ?
(team_id = ? and (profile_id = ? or profile_id is null)) or AND ((project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? and team_id is null and project_id is null and file_id is null));") (team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
(def ^:private sql:get-quotes-4 (def ^:private sql:get-quotes-4
"select id, quote from usage_quote "SELECT id, quote
where target = ? FROM usage_quote
and ((file_id = ? and (profile_id = ? or profile_id is null)) or WHERE target = ?
(project_id = ? and (profile_id = ? or profile_id is null)) or AND ((file_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(team_id = ? and (profile_id = ? or profile_id is null)) or (project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? and team_id is null and project_id is null and file_id is null));") (team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAMS-PER-PROFILE ;; QUOTE: TEAMS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-teams-per-profile (def ^:private schema:teams-per-profile
"select count(*) as total [:map [::profile-id ::sm/uuid]])
from team_profile_rel
where profile_id = ?")
(s/def ::profile-id ::us/uuid) (def ^:private valid-teams-per-profile-quote?
(s/def ::teams-per-profile (sm/lazy-validator schema:teams-per-profile))
(s/keys :req [::profile-id ::target]))
(def ^:private sql:get-teams-per-profile
"SELECT count(*) AS total
FROM team_profile_rel
WHERE profile_id = ?")
(defmethod check-quote ::teams-per-profile (defmethod check-quote ::teams-per-profile
[{:keys [::profile-id ::target] :as quote}] [{:keys [::profile-id ::target] :as quote}]
(us/assert! ::teams-per-profile quote) (assert (valid-teams-per-profile-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-teams-per-profile profile-id]) (assoc ::count-sql [sql:get-teams-per-profile profile-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: ACCESS-TOKENS-PER-PROFILE ;; QUOTE: ACCESS-TOKENS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-access-tokens-per-profile (def ^:private schema:access-tokens-per-profile
"select count(*) as total [:map [::profile-id ::sm/uuid]])
from access_token
where profile_id = ?")
(s/def ::access-tokens-per-profile (def ^:private valid-access-tokens-per-profile-quote?
(s/keys :req [::profile-id ::target])) (sm/lazy-validator schema:access-tokens-per-profile))
(def ^:private sql:get-access-tokens-per-profile
"SELECT count(*) AS total
FROM access_token
WHERE profile_id = ?")
(defmethod check-quote ::access-tokens-per-profile (defmethod check-quote ::access-tokens-per-profile
[{:keys [::profile-id ::target] :as quote}] [{:keys [::profile-id ::target] :as quote}]
(us/assert! ::access-tokens-per-profile quote) (assert (valid-access-tokens-per-profile-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::quote-sql [sql:get-quotes-1 target profile-id])
@@ -189,40 +208,51 @@
;; QUOTE: PROJECTS-PER-TEAM ;; QUOTE: PROJECTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-projects-per-team (def ^:private schema:projects-per-team
"select count(*) as total [:map
from project as p [::profile-id ::sm/uuid]
where p.team_id = ? [::team-id ::sm/uuid]])
and p.deleted_at is null")
(s/def ::team-id ::us/uuid) (def ^:private valid-projects-per-team-quote?
(s/def ::projects-per-team (sm/lazy-validator schema:projects-per-team))
(s/keys :req [::profile-id ::team-id ::target]))
(def ^:private sql:get-projects-per-team
"SELECT count(*) AS total
FROM project AS p
WHERE p.team_id = ?
AND p.deleted_at IS NULL")
(defmethod check-quote ::projects-per-team (defmethod check-quote ::projects-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}] [{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-projects-per-team-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-projects-per-team team-id]) (assoc ::count-sql [sql:get-projects-per-team team-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: FONT-VARIANTS-PER-TEAM ;; QUOTE: FONT-VARIANTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-font-variants-per-team (def ^:private schema:font-variants-per-team
"select count(*) as total [:map
from team_font_variant as v [::profile-id ::sm/uuid]
where v.team_id = ?") [::team-id ::sm/uuid]])
(s/def ::font-variants-per-team (def ^:private valid-font-variant-per-team-quote?
(s/keys :req [::profile-id ::team-id ::target])) (sm/lazy-validator schema:font-variants-per-team))
(def ^:private sql:get-font-variants-per-team
"SELECT count(*) AS total
FROM team_font_variant AS v
WHERE v.team_id = ?")
(defmethod check-quote ::font-variants-per-team (defmethod check-quote ::font-variants-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}] [{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::font-variants-per-team quote) (assert (valid-font-variant-per-team-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
@@ -234,70 +264,86 @@
;; QUOTE: INVITATIONS-PER-TEAM ;; QUOTE: INVITATIONS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-invitations-per-team (def ^:private schema:invitations-per-team
"select count(*) as total [:map
from team_invitation [::profile-id ::sm/uuid]
where team_id = ?") [::team-id ::sm/uuid]])
(s/def ::invitations-per-team (def ^:private valid-invitations-per-team-quote?
(s/keys :req [::profile-id ::team-id ::target])) (sm/lazy-validator schema:invitations-per-team))
(def ^:private sql:get-invitations-per-team
"SELECT count(*) AS total
FROM team_invitation
WHERE team_id = ?")
(defmethod check-quote ::invitations-per-team (defmethod check-quote ::invitations-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}] [{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::invitations-per-team quote) (assert (valid-invitations-per-team-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-invitations-per-team team-id]) (assoc ::count-sql [sql:get-invitations-per-team team-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: PROFILES-PER-TEAM ;; QUOTE: PROFILES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:profiles-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-profiles-per-team-quote?
(sm/lazy-validator schema:profiles-per-team))
(def ^:private sql:get-profiles-per-team (def ^:private sql:get-profiles-per-team
"select (select count(*) "SELECT (SELECT count(*)
from team_profile_rel FROM team_profile_rel
where team_id = ?) + WHERE team_id = ?) +
(select count(*) (SELECT count(*)
from team_invitation FROM team_invitation
where team_id = ? WHERE team_id = ?
and valid_until > now()) as total;") AND valid_until > now()) AS total;")
;; NOTE: the total number of profiles is determined by the number of ;; NOTE: the total number of profiles is determined by the number of
;; effective members plus ongoing valid invitations. ;; effective members plus ongoing valid invitations.
(s/def ::profiles-per-team
(s/keys :req [::profile-id ::team-id ::target]))
(defmethod check-quote ::profiles-per-team (defmethod check-quote ::profiles-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}] [{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::profiles-per-team quote) (assert (valid-profiles-per-team-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-profiles-per-team team-id team-id]) (assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: FILES-PER-PROJECT ;; QUOTE: FILES-PER-PROJECT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-files-per-project (def ^:private schema:files-per-project
"select count(*) as total [:map
from file as f [::profile-id ::sm/uuid]
where f.project_id = ? [::project-id ::sm/uuid]
and f.deleted_at is null") [::team-id ::sm/uuid]])
(s/def ::project-id ::us/uuid) (def ^:private valid-files-per-project-quote?
(s/def ::files-per-project (sm/lazy-validator schema:files-per-project))
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
(def ^:private sql:get-files-per-project
"SELECT count(*) AS total
FROM file AS f
WHERE f.project_id = ?
AND f.deleted_at IS NULL")
(defmethod check-quote ::files-per-project (defmethod check-quote ::files-per-project
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}] [{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
(us/assert! ::files-per-project quote) (assert (valid-files-per-project-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id]) (assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
@@ -308,17 +354,24 @@
;; QUOTE: COMMENT-THREADS-PER-FILE ;; QUOTE: COMMENT-THREADS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-comment-threads-per-file (def ^:private schema:comment-threads-per-file
"select count(*) as total [:map
from comment_thread as ct [::profile-id ::sm/uuid]
where ct.file_id = ?") [::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::comment-threads-per-file (def ^:private valid-comment-threads-per-file-quote?
(s/keys :req [::profile-id ::project-id ::team-id ::target])) (sm/lazy-validator schema:comment-threads-per-file))
(def ^:private sql:get-comment-threads-per-file
"SELECT count(*) AS total
FROM comment_thread AS ct
WHERE ct.file_id = ?")
(defmethod check-quote ::comment-threads-per-file (defmethod check-quote ::comment-threads-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(us/assert! ::files-per-project quote) (assert (valid-comment-threads-per-file-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
@@ -326,23 +379,28 @@
(assoc ::count-sql [sql:get-comment-threads-per-file file-id]) (assoc ::count-sql [sql:get-comment-threads-per-file file-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: COMMENTS-PER-FILE ;; QUOTE: COMMENTS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-comments-per-file (def ^:private schema:comments-per-file
"select count(*) as total [:map
from comment as c [::profile-id ::sm/uuid]
join comment_thread as ct on (ct.id = c.thread_id) [::project-id ::sm/uuid]
where ct.file_id = ?") [::team-id ::sm/uuid]])
(s/def ::comments-per-file (def ^:private valid-comments-per-file-quote?
(s/keys :req [::profile-id ::project-id ::team-id ::target])) (sm/lazy-validator schema:comments-per-file))
(def ^:private sql:get-comments-per-file
"SELECT count(*) AS total
FROM comment AS c
JOIN comment_thread AS ct ON (ct.id = c.thread_id)
WHERE ct.file_id = ?")
(defmethod check-quote ::comments-per-file (defmethod check-quote ::comments-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(us/assert! ::files-per-project quote) (assert (valid-comments-per-file-quote? quote) "invalid quote parameters")
(-> quote (-> quote
(assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE)) (assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
+18 -19
View File
@@ -10,6 +10,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.http.client :as http] [app.http.client :as http]
@@ -19,28 +20,26 @@
[datoteka.fs :as fs] [datoteka.fs :as fs]
[integrant.core :as ig])) [integrant.core :as ig]))
(def ^:private (def ^:private schema:template
schema:template [:map {:title "Template"}
(sm/define [:id ::sm/word-string]
[:map {:title "Template"} [:name ::sm/word-string]
[:id ::sm/word-string] [:file-uri ::sm/word-string]])
[:name ::sm/word-string]
[:file-uri ::sm/word-string]]))
(def ^:private (def ^:private schema:templates
schema:templates [:vector schema:template])
(sm/define
[:vector schema:template])) (def check-templates!
(sm/check-fn schema:templates
:code :invalid-templates
:hint "invalid templates"))
(defmethod ig/init-key ::setup/templates (defmethod ig/init-key ::setup/templates
[_ _] [_ _]
(let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string) (let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string)
templates (check-templates! templates)
dest (fs/join fs/*cwd* "builtin-templates")] dest (fs/join fs/*cwd* "builtin-templates")]
(dm/verify!
"expected a valid templates file"
(sm/check! schema:templates templates))
(doseq [{:keys [id path] :as template} templates] (doseq [{:keys [id path] :as template} templates]
(let [path (or path (fs/join dest id))] (let [path (or path (fs/join dest id))]
(if (fs/exists? path) (if (fs/exists? path)
@@ -60,9 +59,9 @@
(let [resp (http/req! cfg (let [resp (http/req! cfg
{:method :get :uri (:file-uri template)} {:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})] {:response-type :input-stream :sync? true})]
(when-not (= 200 (:status resp))
(dm/verify! (ex/raise :type :internal
"unexpected response found on fetching template" :code :unexpected-status-code
(= 200 (:status resp))) :hint (str "unable to download template, recevied status " (:status resp))))
(io/input-stream (:body resp))))))) (io/input-stream (:body resp)))))))
+64
View File
@@ -0,0 +1,64 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.setup.welcome-file
(:require
[app.common.logging :as l]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files-update :as fupdate]
[app.rpc.commands.management :as management]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.setup :as-alias setup]
[app.setup.templates :as tmpl]
[app.worker :as-alias wrk]))
(def ^:private page-id #uuid "2c6952ee-d00e-8160-8004-d2250b7210cb")
(def ^:private shape-id #uuid "765e9f82-c44e-802e-8004-d72a10b7b445")
(def ^:private update-path
[:data :pages-index page-id :objects shape-id
:content :children 0 :children 0 :children 0])
(def ^:private sql:mark-file-object-thumbnails-deleted
"UPDATE file_tagged_object_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(def ^:private sql:mark-file-thumbnail-deleted
"UPDATE file_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(defn- update-welcome-shape
[_ file name]
(let [text (str "Welcome to Penpot, " name "!")]
(-> file
(update-in update-path assoc :text text)
(update-in [:data :pages-index page-id :objects shape-id] assoc :name "Welcome to Penpot!")
(update-in [:data :pages-index page-id :objects shape-id] dissoc :position-data))))
(defn create-welcome-file
[cfg {:keys [id fullname] :as profile}]
(try
(let [cfg (dissoc cfg ::db/conn)
params {:profile-id (:id profile)
:project-id (:default-project-id profile)}
template-stream (tmpl/get-template-stream cfg "welcome")
file-id (-> (management/clone-template cfg params template-stream)
first)]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(fupdate/update-file! cfg file-id update-welcome-shape fullname)
(profile/update-profile-props cfg id {:welcome-file-id file-id})
(db/exec-one! conn [sql:mark-file-object-thumbnails-deleted file-id])
(db/exec-one! conn [sql:mark-file-thumbnail-deleted file-id]))))
(catch Throwable cause
(l/error :hint "unexpected error on create welcome file " :cause cause))))
+1
View File
@@ -75,6 +75,7 @@
:created-at (:created-at file) :created-at (:created-at file)
:modified-at (:modified-at file) :modified-at (:modified-at file)
:data-backend nil :data-backend nil
:data-ref-id nil
:has-media-trimmed false} :has-media-trimmed false}
{:id (:id file)}))) {:id (:id file)})))
+14 -9
View File
@@ -155,9 +155,10 @@
(defn enable-team-feature! (defn enable-team-feature!
[team-id feature] [team-id feature]
(dm/verify! (when-not (contains? cfeat/supported-features feature)
"feature should be supported" (ex/raise :type :assertion
(contains? cfeat/supported-features feature)) :code :feature-not-supported
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)] (let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system (db/tx-run! main/system
@@ -173,9 +174,11 @@
(defn disable-team-feature! (defn disable-team-feature!
[team-id feature] [team-id feature]
(dm/verify!
"feature should be supported" (when-not (contains? cfeat/supported-features feature)
(contains? cfeat/supported-features feature)) (ex/raise :type :assertion
:code :feature-not-supported
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)] (let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system (db/tx-run! main/system
@@ -203,9 +206,11 @@
[{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level] [{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level]
:or {code :generic level :info} :or {code :generic level :info}
:as params}] :as params}]
(dm/verify!
["invalid level %" level] (when-not (contains? #{:success :error :info :warning} level)
(contains? #{:success :error :info :warning} level)) (ex/raise :type :assertion
:code :incorrect-level
:hint (str "level '" level "' not supported")))
(letfn [(send [dest] (letfn [(send [dest]
(l/inf :hint "sending notification" :dest (str dest)) (l/inf :hint "sending notification" :dest (str dest))
+16 -6
View File
@@ -10,6 +10,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@@ -19,6 +20,7 @@
[app.storage.s3 :as ss3] [app.storage.s3 :as ss3]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.fs :as fs] [datoteka.fs :as fs]
[integrant.core :as ig]) [integrant.core :as ig])
(:import (:import
@@ -30,7 +32,7 @@
(case name (case name
:assets-fs :fs :assets-fs :fs
:assets-s3 :s3 :assets-s3 :s3
:fs))) nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Storage Module State ;; Storage Module State
@@ -52,11 +54,19 @@
(defmethod ig/init-key ::storage (defmethod ig/init-key ::storage
[_ {:keys [::backends ::db/pool] :as cfg}] [_ {:keys [::backends ::db/pool] :as cfg}]
(-> (d/without-nils cfg) (let [backend (or (get-legacy-backend)
(assoc ::backends (d/without-nils backends)) (cf/get :objects-storage-backend)
(assoc ::backend (or (get-legacy-backend) :fs)
(cf/get :objects-storage-backend :fs))) backends (d/without-nils backends)]
(assoc ::db/connectable pool)))
(l/dbg :hint "initialize"
:default (d/name backend)
:available (str/join "," (map d/name (keys backends))))
(-> (d/without-nils cfg)
(assoc ::backends backends)
(assoc ::backend backend)
(assoc ::db/connectable pool))))
(s/def ::backend keyword?) (s/def ::backend keyword?)
(s/def ::storage (s/def ::storage
+67 -76
View File
@@ -17,6 +17,7 @@
[app.storage.impl :as impl] [app.storage.impl :as impl]
[app.storage.tmp :as tmp] [app.storage.tmp :as tmp]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[datoteka.fs :as fs] [datoteka.fs :as fs]
@@ -27,17 +28,15 @@
java.io.FilterInputStream java.io.FilterInputStream
java.io.InputStream java.io.InputStream
java.net.URI java.net.URI
java.nio.ByteBuffer
java.nio.file.Path java.nio.file.Path
java.time.Duration java.time.Duration
java.util.Collection java.util.Collection
java.util.Optional java.util.Optional
java.util.concurrent.Semaphore
org.reactivestreams.Subscriber org.reactivestreams.Subscriber
org.reactivestreams.Subscription
software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.ResponseBytes
software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncRequestBody
software.amazon.awssdk.core.async.AsyncResponseTransformer software.amazon.awssdk.core.async.AsyncResponseTransformer
software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody
software.amazon.awssdk.core.client.config.ClientAsyncConfiguration software.amazon.awssdk.core.client.config.ClientAsyncConfiguration
software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption
software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
@@ -59,6 +58,20 @@
software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest)) software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest))
(def ^:private max-retries
"A maximum number of retries on internal operations"
3)
(def ^:private max-concurrency
"Maximum concurrent request to S3 service"
128)
(def ^:private max-pending-connection-acquires
20000)
(def default-timeout
(dt/duration {:seconds 30}))
(declare put-object) (declare put-object)
(declare get-object-bytes) (declare get-object-bytes)
(declare get-object-data) (declare get-object-data)
@@ -80,7 +93,7 @@
(s/def ::io-threads ::us/integer) (s/def ::io-threads ::us/integer)
(defmethod ig/pre-init-spec ::backend [_] (defmethod ig/pre-init-spec ::backend [_]
(s/keys :opt [::region ::bucket ::prefix ::endpoint ::io-threads])) (s/keys :opt [::region ::bucket ::prefix ::endpoint ::io-threads ::wrk/executor]))
(defmethod ig/prep-key ::backend (defmethod ig/prep-key ::backend
[_ {:keys [::prefix ::region] :as cfg}] [_ {:keys [::prefix ::region] :as cfg}]
@@ -128,18 +141,29 @@
[backend object] [backend object]
(us/assert! ::backend backend) (us/assert! ::backend backend)
(let [result (p/await (get-object-data backend object))] (loop [result (get-object-data backend object)
(if (ex/exception? result) retryn 0]
(cond
(ex/instance? NoSuchKeyException result)
(ex/raise :type :not-found
:code :object-not-found
:hint "s3 object not found"
:cause result)
:else
(throw result))
result))) (let [result (p/await result)]
(if (ex/exception? result)
(cond
(ex/instance? NoSuchKeyException result)
(ex/raise :type :not-found
:code :object-not-found
:hint "s3 object not found"
:object-id (:id object)
:object-path (impl/id->path (:id object))
:cause result)
(and (ex/instance? java.nio.file.FileAlreadyExistsException result)
(< retryn max-retries))
(recur (get-object-data backend object)
(inc retryn))
:else
(throw result))
result))))
(defmethod impl/get-object-bytes :s3 (defmethod impl/get-object-bytes :s3
[backend object] [backend object]
@@ -163,18 +187,14 @@
;; --- HELPERS ;; --- HELPERS
(def default-timeout
(dt/duration {:seconds 30}))
(defn- lookup-region (defn- lookup-region
^Region ^Region
[region] [region]
(Region/of (name region))) (Region/of (name region)))
(defn- build-s3-client (defn- build-s3-client
[{:keys [::region ::endpoint ::io-threads]}] [{:keys [::region ::endpoint ::io-threads ::wrk/executor]}]
(let [executor (px/resolve-executor :virtual) (let [aconfig (-> (ClientAsyncConfiguration/builder)
aconfig (-> (ClientAsyncConfiguration/builder)
(.advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR executor) (.advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR executor)
(.build)) (.build))
@@ -190,6 +210,8 @@
(.connectionTimeout default-timeout) (.connectionTimeout default-timeout)
(.readTimeout default-timeout) (.readTimeout default-timeout)
(.writeTimeout default-timeout) (.writeTimeout default-timeout)
(.maxConcurrency (int max-concurrency))
(.maxPendingConnectionAcquires (int max-pending-connection-acquires))
(.build)) (.build))
client (let [builder (S3AsyncClient/builder) client (let [builder (S3AsyncClient/builder)
@@ -223,69 +245,38 @@
(.serviceConfiguration ^S3Configuration config) (.serviceConfiguration ^S3Configuration config)
(.build)))) (.build))))
(defn- upload-thread (defn- write-input-stream
[id subscriber sem content] [delegate input]
(px/thread (try
{:name "penpot/s3/uploader" (.writeInputStream ^BlockingInputStreamAsyncRequestBody delegate
:virtual true ^InputStream input)
:daemon true} (catch Throwable cause
(l/trace :hint "start upload thread" (l/error :hint "encountered error while writing input stream to service"
:object-id (str id) :cause cause))
:size (impl/get-size content) (finally
::l/sync? true) (.close ^InputStream input))))
(let [stream (io/input-stream content)
bsize (* 1024 64)
tpoint (dt/tpoint)]
(try
(loop []
(.acquire ^Semaphore sem 1)
(let [buffer (byte-array bsize)
readed (.read ^InputStream stream buffer)]
(when (pos? readed)
(let [data (ByteBuffer/wrap ^bytes buffer 0 readed)]
(.onNext ^Subscriber subscriber ^ByteBuffer data)
(when (= readed bsize)
(recur))))))
(.onComplete ^Subscriber subscriber)
(catch InterruptedException _
(l/trace :hint "interrupted upload thread"
:object-:id (str id)
::l/sync? true)
nil)
(catch Throwable cause
(.onError ^Subscriber subscriber cause))
(finally
(l/trace :hint "end upload thread"
:object-id (str id)
:elapsed (dt/format-duration (tpoint))
::l/sync? true)
(.close ^InputStream stream))))))
(defn- make-request-body (defn- make-request-body
[id content] [executor content]
(reify (let [size (impl/get-size content)]
AsyncRequestBody (reify
(contentLength [_] AsyncRequestBody
(Optional/of (long (impl/get-size content)))) (contentLength [_]
(Optional/of (long size)))
(^void subscribe [_ ^Subscriber subscriber]
(let [sem (Semaphore. 0)
thr (upload-thread id subscriber sem content)]
(.onSubscribe subscriber
(reify Subscription
(cancel [_]
(px/interrupt! thr)
(.release sem 1))
(request [_ n]
(.release sem (int n)))))))))
(^void subscribe [_ ^Subscriber subscriber]
(let [delegate (AsyncRequestBody/forBlockingInputStream (long size))
input (io/input-stream content)]
(px/run! executor (partial write-input-stream delegate input))
(.subscribe ^BlockingInputStreamAsyncRequestBody delegate
^Subscriber subscriber))))))
(defn- put-object (defn- put-object
[{:keys [::client ::bucket ::prefix]} {:keys [id] :as object} content] [{:keys [::client ::bucket ::prefix ::wrk/executor]} {:keys [id] :as object} content]
(let [path (dm/str prefix (impl/id->path id)) (let [path (dm/str prefix (impl/id->path id))
mdata (meta object) mdata (meta object)
mtype (:content-type mdata "application/octet-stream") mtype (:content-type mdata "application/octet-stream")
rbody (make-request-body id content) rbody (make-request-body executor content)
request (.. (PutObjectRequest/builder) request (.. (PutObjectRequest/builder)
(bucket bucket) (bucket bucket)
(contentType mtype) (contentType mtype)
+7 -6
View File
@@ -11,13 +11,16 @@
permanently delete these files (look at systemd-tempfiles)." permanently delete these files (look at systemd-tempfiles)."
(:require (:require
[app.common.logging :as l] [app.common.logging :as l]
[app.common.uuid :as uuid]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[datoteka.fs :as fs] [datoteka.fs :as fs]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px] [promesa.exec :as px]
[promesa.exec.csp :as sp])) [promesa.exec.csp :as sp])
(:import
java.nio.file.Files))
(def default-tmp-dir "/tmp/penpot") (def default-tmp-dir "/tmp/penpot")
@@ -76,11 +79,9 @@
[& {:keys [suffix prefix min-age] [& {:keys [suffix prefix min-age]
:or {prefix "penpot." :or {prefix "penpot."
suffix ".tmp"}}] suffix ".tmp"}}]
(let [path (fs/create-tempfile (let [attrs (fs/make-permissions "rw-r--r--")
:perms "rw-r--r--" path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))
:dir default-tmp-dir path (Files/createFile path attrs)]
:suffix suffix
:prefix prefix)]
(fs/delete-on-exit! path) (fs/delete-on-exit! path)
(sp/offer! queue [path (some-> min-age dt/duration)]) (sp/offer! queue [path (some-> min-age dt/duration)])
path)) path))
+7 -1
View File
@@ -133,7 +133,13 @@
file)) file))
(def ^:private sql:get-files-for-library (def ^:private sql:get-files-for-library
"SELECT f.id, f.data, f.modified_at, f.features, f.version "SELECT f.id,
f.data,
f.modified_at,
f.features,
f.version,
f.data_backend,
f.data_ref_id
FROM file AS f FROM file AS f
LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id) LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id)
WHERE fl.library_file_id = ? WHERE fl.library_file_id = ?
+8 -7
View File
@@ -141,21 +141,22 @@
;; --- INSTANT ;; --- INSTANT
(defn instant?
[v]
(instance? Instant v))
(defn instant (defn instant
([s] ([s]
(if (int? s) (cond
(Instant/ofEpochMilli s) (instant? s) s
(Instant/parse s))) (int? s) (Instant/ofEpochMilli s)
:else (Instant/parse s)))
([s fmt] ([s fmt]
(case fmt (case fmt
:rfc1123 (Instant/from (.parse DateTimeFormatter/RFC_1123_DATE_TIME ^String s)) :rfc1123 (Instant/from (.parse DateTimeFormatter/RFC_1123_DATE_TIME ^String s))
:iso (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s)) :iso (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))
:iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))))) :iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s)))))
(defn instant?
[v]
(instance? Instant v))
(defn is-after? (defn is-after?
[da db] [da db]
(.isAfter ^Instant da ^Instant db)) (.isAfter ^Instant da ^Instant db))
+10 -19
View File
@@ -17,12 +17,11 @@
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
java.util.concurrent.Executor
java.util.concurrent.ThreadPoolExecutor)) java.util.concurrent.ThreadPoolExecutor))
(set! *warn-on-reflection* true) (set! *warn-on-reflection* true)
(s/def ::wrk/executor #(instance? Executor %)) (s/def ::wrk/executor #(instance? ThreadPoolExecutor %))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; EXECUTOR ;; EXECUTOR
@@ -36,30 +35,22 @@
(let [factory (px/thread-factory :prefix "penpot/default/") (let [factory (px/thread-factory :prefix "penpot/default/")
executor (px/cached-executor :factory factory :keepalive 60000)] executor (px/cached-executor :factory factory :keepalive 60000)]
(l/inf :hint "executor started") (l/inf :hint "executor started")
(reify executor))
java.lang.AutoCloseable
(close [_]
(l/inf :hint "stoping executor")
(px/shutdown! executor))
clojure.lang.IDeref
(deref [_]
{:active (.getPoolSize ^ThreadPoolExecutor executor)
:running (.getActiveCount ^ThreadPoolExecutor executor)
:completed (.getCompletedTaskCount ^ThreadPoolExecutor executor)})
Executor
(execute [_ runnable]
(.execute ^Executor executor ^Runnable runnable)))))
(defmethod ig/halt-key! ::wrk/executor (defmethod ig/halt-key! ::wrk/executor
[_ instance] [_ instance]
(.close ^java.lang.AutoCloseable instance)) (px/shutdown! instance))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MONITOR ;; MONITOR
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- get-stats
[^ThreadPoolExecutor executor]
{:active (.getPoolSize ^ThreadPoolExecutor executor)
:running (.getActiveCount ^ThreadPoolExecutor executor)
:completed (.getCompletedTaskCount ^ThreadPoolExecutor executor)})
(s/def ::name ::us/keyword) (s/def ::name ::us/keyword)
(defmethod ig/pre-init-spec ::wrk/monitor [_] (defmethod ig/pre-init-spec ::wrk/monitor [_]
@@ -74,7 +65,7 @@
[_ {:keys [::wrk/executor ::mtx/metrics ::interval ::wrk/name]}] [_ {:keys [::wrk/executor ::mtx/metrics ::interval ::wrk/name]}]
(letfn [(monitor! [executor prev-completed] (letfn [(monitor! [executor prev-completed]
(let [labels (into-array String [(d/name name)]) (let [labels (into-array String [(d/name name)])
stats (deref executor) stats (get-stats executor)
completed (:completed stats) completed (:completed stats)
completed-inc (- completed prev-completed) completed-inc (- completed prev-completed)
+7 -5
View File
@@ -76,7 +76,7 @@
:enable-feature-fdata-pointer-map :enable-feature-fdata-pointer-map
:enable-feature-fdata-objets-map :enable-feature-fdata-objets-map
:enable-feature-components-v2 :enable-feature-components-v2
:enable-file-snapshot :enable-auto-file-snapshot
:disable-file-validation]) :disable-file-validation])
(defn state-init (defn state-init
@@ -304,16 +304,18 @@
([params] (update-file* *system* params)) ([params] (update-file* *system* params))
([system {:keys [file-id changes session-id profile-id revn] ([system {:keys [file-id changes session-id profile-id revn]
:or {session-id (uuid/next) revn 0}}] :or {session-id (uuid/next) revn 0}}]
(db/tx-run! system (fn [{:keys [::db/conn] :as system}] (-> system
(let [file (files.update/get-file conn file-id)] (assoc ::files.update/timestamp (dt/now))
(files.update/update-file system (db/tx-run! (fn [{:keys [::db/conn] :as system}]
(let [file (files.update/get-file conn file-id)]
(#'files.update/update-file* system
{:id file-id {:id file-id
:revn revn :revn revn
:file file :file file
:features (:features file) :features (:features file)
:changes changes :changes changes
:session-id session-id :session-id session-id
:profile-id profile-id})))))) :profile-id profile-id})))))))
(declare command!) (declare command!)
+1 -1
View File
@@ -21,7 +21,7 @@
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
(t/deftest ttf-font-upload-1 (t/deftest ttf-font-upload-1
(with-mocks [mock {:target 'app.rpc.quotes/check-quote! :return nil}] (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true}) (let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof) team-id (:default-team-id prof)
proj-id (:default-project-id prof) proj-id (:default-project-id prof)
@@ -8,6 +8,7 @@
(:require (:require
[app.common.fressian :as fres] [app.common.fressian :as fres]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
[app.common.schema.test :as smt]
[app.common.transit :as transit] [app.common.transit :as transit]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@@ -84,54 +85,56 @@
(t/is (= (hash obj1) (hash obj2)))))) (t/is (= (hash obj1) (hash obj2))))))
(t/deftest internal-encode-decode (t/deftest internal-encode-decode
(sg/check! (smt/check!
(sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape))
(cg/not-empty))] (cg/not-empty))]
(let [obj1 (omap/wrap data) (let [obj1 (omap/wrap data)
obj2 (omap/create (deref obj1)) obj2 (omap/create (deref obj1))
obj3 (assoc obj2 uuid/zero 1) obj3 (assoc obj2 uuid/zero 1)
obj4 (omap/create (deref obj3))] obj4 (omap/create (deref obj3))]
;; (app.common.pprint/pprint data) ;; (app.common.pprint/pprint data)
(t/is (= (hash obj1) (hash obj2)))
(t/is (not= (hash obj2) (hash obj3))) (and (= (hash obj1) (hash obj2))
(t/is (bytes? (deref obj3))) (not= (hash obj2) (hash obj3))
(t/is (pos? (alength (deref obj3)))) (bytes? (deref obj3))
(t/is (= (hash obj3) (hash obj4))))))) (pos? (alength (deref obj3)))
(= (hash obj3) (hash obj4)))))
{:num 50}))
(t/deftest fressian-encode-decode (t/deftest fressian-encode-decode
(sg/check! (smt/check!
(sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape))
(cg/not-empty) (cg/not-empty)
(cg/fmap omap/wrap) (cg/fmap omap/wrap)
(cg/fmap (fn [o] {:objects o})))] (cg/fmap (fn [o] {:objects o})))]
(let [res (-> data fres/encode fres/decode)] (let [res (-> data fres/encode fres/decode)]
(t/is (contains? res :objects)) (and (contains? res :objects)
(t/is (omap/objects-map? (:objects res))) (omap/objects-map? (:objects res))
(t/is (= (count (:objects data)) (= (count (:objects data))
(count (:objects res)))) (count (:objects res)))
(t/is (= (hash (:objects data)) (= (hash (:objects data))
(hash (:objects res)))))))) (hash (:objects res))))))
{:num 50}))
(t/deftest transit-encode-decode (t/deftest transit-encode-decode
(sg/check! (smt/check!
(sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape))
(cg/not-empty) (cg/not-empty)
(cg/fmap omap/wrap) (cg/fmap omap/wrap)
(cg/fmap (fn [o] {:objects o})))] (cg/fmap (fn [o] {:objects o})))]
(let [res (-> data transit/encode transit/decode)] (let [res (-> data transit/encode transit/decode)]
;; (app.common.pprint/pprint data) ;; (app.common.pprint/pprint data)
;; (app.common.pprint/pprint res) ;; (app.common.pprint/pprint res)
(doseq [[k v] (:objects res)] (and (every? (fn [[k v]]
(t/is (= v (get-in data [:objects k])))) (= v (get-in data [:objects k])))
(:objects res))
(t/is (contains? res :objects)) (contains? res :objects)
(t/is (contains? data :objects)) (contains? data :objects)
(omap/objects-map? (:objects data))
(t/is (omap/objects-map? (:objects data))) (not (omap/objects-map? (:objects res)))
(t/is (not (omap/objects-map? (:objects res)))) (= (count (:objects data))
(count (:objects res))))))
(t/is (= (count (:objects data)) {:num 50}))
(count (:objects res))))))))
+2
View File
@@ -6,6 +6,8 @@
(ns user (ns user
(:require (:require
[app.common.data :as d]
[app.common.fressian :as fres]
[app.common.json :as json] [app.common.json :as json]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema :as sm] [app.common.schema :as sm]
+37 -2
View File
@@ -44,8 +44,8 @@
(defn ordered-map (defn ordered-map
([] lkm/empty-linked-map) ([] lkm/empty-linked-map)
([a] (conj lkm/empty-linked-map a)) ([k a] (assoc lkm/empty-linked-map k a))
([a & xs] (apply conj lkm/empty-linked-map a xs))) ([k a & xs] (apply assoc lkm/empty-linked-map k a xs)))
(defn ordered-set? (defn ordered-set?
[o] [o]
@@ -564,6 +564,41 @@
new-elems new-elems
(remove p? after)))) (remove p? after))))
(defn addm-at-index
"Insert an element in an ordered map at an arbitrary index"
[coll index key element]
(assert (ordered-map? coll))
(-> (ordered-map)
(into (take index coll))
(assoc key element)
(into (drop index coll))))
(defn insertm-at-index
"Insert a map {k v} of elements in an ordered map at an arbitrary index"
[coll index new-elems]
(assert (ordered-map? coll))
(-> (ordered-map)
(into (take index coll))
(into new-elems)
(into (drop index coll))))
(defn adds-at-index
"Insert an element in an ordered set at an arbitrary index"
[coll index element]
(assert (ordered-set? coll))
(-> (ordered-set)
(into (take index coll))
(conj element)
(into (drop index coll))))
(defn inserts-at-index
"Insert a list of elements in an ordered set at an arbitrary index"
[coll index new-elems]
(assert (ordered-set? coll))
(-> (ordered-set)
(into (take index coll))
(into new-elems)
(into (drop index coll))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Parsing / Conversion ;; Data Parsing / Conversion
+21 -44
View File
@@ -108,14 +108,6 @@
`(do ~@body) `(do ~@body)
(reverse (partition 2 bindings)))) (reverse (partition 2 bindings))))
(defmacro check
"Applies a predicate to the value, if result is true, return the
value if not, returns nil."
[pred-fn value]
`(if (~pred-fn ~value)
~value
nil))
(defmacro get-prop (defmacro get-prop
"A macro based, optimized variant of `get` that access the property "A macro based, optimized variant of `get` that access the property
directly on CLJS, on CLJ works as get." directly on CLJS, on CLJ works as get."
@@ -124,47 +116,32 @@
(list 'js* (c/str "(~{}?." (str/snake prop) "?? ~{})") obj (list 'cljs.core/get obj prop)) (list 'js* (c/str "(~{}?." (str/snake prop) "?? ~{})") obj (list 'cljs.core/get obj prop))
(list `c/get obj prop))) (list `c/get obj prop)))
(def ^:dynamic *assert-context* nil) (defn runtime-assert
[hint f]
(try
(when-not (f)
(throw (ex-info hint {:type :assertion
:code :expr-validation
:hint hint})))
(catch #?(:clj Throwable :cljs :default) cause
(let [data (-> (ex-data cause)
(assoc :type :assertion)
(assoc :code :expr-validation)
(assoc :hint hint))]
(throw (ex-info hint data cause))))))
(defmacro assert! (defmacro assert!
([expr] ([expr]
`(assert! nil ~expr)) `(assert! nil ~expr))
([hint expr] ([hint expr]
(let [hint (cond (let [hint (cond
(vector? hint) (vector? hint)
`(str/ffmt ~@hint) `(str/ffmt ~@hint)
(some? hint) (some? hint)
hint hint
:else :else
(str "expr assert: " (pr-str expr)))] (str "expr assert: " (pr-str expr)))]
(when *assert* (when *assert*
`(binding [*assert-context* ~hint] `(runtime-assert ~hint (fn [] ~expr))))))
(when-not ~expr
(let [hint# ~hint
params# {:type :assertion
:code :expr-validation
:hint hint#}]
(throw (ex-info hint# params#)))))))))
(defmacro verify!
([expr]
`(verify! nil ~expr))
([hint expr]
(let [hint (cond
(vector? hint)
`(str/ffmt ~@hint)
(some? hint)
hint
:else
(str "expr assert: " (pr-str expr)))]
`(binding [*assert-context* ~hint]
(when-not ~expr
(let [hint# ~hint
params# {:type :assertion
:code :expr-validation
:hint hint#}]
(throw (ex-info hint# params#))))))))
+7 -3
View File
@@ -49,7 +49,8 @@
"components/v2" "components/v2"
"styles/v2" "styles/v2"
"layout/grid" "layout/grid"
"plugins/runtime"}) "plugins/runtime"
"text-editor/v2"})
;; A set of features enabled by default ;; A set of features enabled by default
(def default-features (def default-features
@@ -64,7 +65,8 @@
;; team feature field ;; team feature field
(def frontend-only-features (def frontend-only-features
#{"styles/v2" #{"styles/v2"
"plugins/runtime"}) "plugins/runtime"
"text-editor/v2"})
;; Features that are mainly backend only or there are a proper ;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it ;; fallback when frontend reports no support for it
@@ -81,7 +83,8 @@
"fdata/pointer-map" "fdata/pointer-map"
"layout/grid" "layout/grid"
"fdata/shape-data-type" "fdata/shape-data-type"
"plugins/runtime"} "plugins/runtime"
"text-editor/v2"}
(into frontend-only-features))) (into frontend-only-features)))
(sm/register! ::features (sm/register! ::features
@@ -101,6 +104,7 @@
:feature-fdata-objects-map "fdata/objects-map" :feature-fdata-objects-map "fdata/objects-map"
:feature-fdata-pointer-map "fdata/pointer-map" :feature-fdata-pointer-map "fdata/pointer-map"
:feature-plugins "plugins/runtime" :feature-plugins "plugins/runtime"
:feature-text-editor-v2 "text-editor/v2"
nil)) nil))
(defn migrate-legacy-features (defn migrate-legacy-features
+17 -27
View File
@@ -53,7 +53,7 @@
valid? (or (and components-v2 valid? (or (and components-v2
(nil? (:component-id change)) (nil? (:component-id change))
(nil? (:page-id change))) (nil? (:page-id change)))
(ch/check-change! change))] (ch/valid-change? change))]
(when-not valid? (when-not valid?
(let [explain (sm/explain ::ch/change change)] (let [explain (sm/explain ::ch/change change)]
@@ -741,46 +741,36 @@
(defn add-guide (defn add-guide
[file guide] [file guide]
(let [guide (cond-> guide (let [guide (cond-> guide
(nil? (:id guide)) (nil? (:id guide))
(assoc :id (uuid/next))) (assoc :id (uuid/next)))
page-id (:current-page-id file) page-id (:current-page-id file)]
old-guides (or (dm/get-in file [:data :pages-index page-id :options :guides]) {})
new-guides (assoc old-guides (:id guide) guide)]
(-> file (-> file
(commit-change (commit-change
{:type :set-option {:type :set-guide
:page-id page-id :page-id page-id
:option :guides :id (:id guide)
:value new-guides}) :params guide})
(assoc :last-id (:id guide))))) (assoc :last-id (:id guide)))))
(defn delete-guide (defn delete-guide
[file id] [file id]
(let [page-id (:current-page-id file) (let [page-id (:current-page-id file)]
old-guides (or (dm/get-in file [:data :pages-index page-id :options :guides]) {}) (commit-change file
new-guides (dissoc old-guides id)] {:type :set-guide
(-> file :page-id page-id
(commit-change :id id
{:type :set-option :params nil})))
:page-id page-id
:option :guides
:value new-guides}))))
(defn update-guide (defn update-guide
[file guide] [file guide]
(let [page-id (:current-page-id file)]
(let [page-id (:current-page-id file) (commit-change file
old-guides (or (dm/get-in file [:data :pages-index page-id :options :guides]) {}) {:type :set-guide
new-guides (assoc old-guides (:id guide) guide)] :page-id page-id
(-> file :id (:id guide)
(commit-change :params guide})))
{:type :set-option
:page-id page-id
:option :guides
:value new-guides}))))
(defn strip-image-extension [filename] (defn strip-image-extension [filename]
(let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"] (let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"]
+229 -59
View File
@@ -10,21 +10,25 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.schema.desc-native :as smd] [app.common.schema.desc-native :as smd]
[app.common.schema.generators :as sg]
[app.common.types.color :as ctc] [app.common.types.color :as ctc]
[app.common.types.colors-list :as ctcl] [app.common.types.colors-list :as ctcl]
[app.common.types.component :as ctk] [app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl] [app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.file :as ctf] [app.common.types.file :as ctf]
[app.common.types.grid :as ctg]
[app.common.types.page :as ctp] [app.common.types.page :as ctp]
[app.common.types.pages-list :as ctpl] [app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst] [app.common.types.shape-tree :as ctst]
[app.common.types.typographies-list :as ctyl] [app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt] [app.common.types.typography :as ctt]
[app.common.uuid :as uuid]
[clojure.set :as set])) [clojure.set :as set]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -60,6 +64,111 @@
[:type [:= :set-remote-synced]] [:type [:= :set-remote-synced]]
[:remote-synced {:optional true} [:maybe :boolean]]]]]) [:remote-synced {:optional true} [:maybe :boolean]]]]])
(def schema:set-default-grid-change
(let [gen (->> (sg/elements #{:square :column :row})
(sg/mcat (fn [grid-type]
(sg/fmap (fn [params]
{:page-id (uuid/next)
:type :set-default-grid
:grid-type grid-type
:params params})
(case grid-type
:square (sg/generator ctg/schema:square-params)
:column (sg/generator ctg/schema:column-params)
:row (sg/generator ctg/schema:column-params))))))]
[:multi {:decode/json #(update % :grid-type keyword)
:gen/gen gen
:dispatch :grid-type
::smd/simplified true}
[:square
[:map
[:type [:= :set-default-grid]]
[:page-id ::sm/uuid]
[:grid-type [:= :square]]
[:params [:maybe ctg/schema:square-params]]]]
[:column
[:map
[:type [:= :set-default-grid]]
[:page-id ::sm/uuid]
[:grid-type [:= :column]]
[:params [:maybe ctg/schema:column-params]]]]
[:row
[:map
[:type [:= :set-default-grid]]
[:page-id ::sm/uuid]
[:grid-type [:= :row]]
[:params [:maybe ctg/schema:column-params]]]]]))
(def schema:set-guide-change
(let [schema [:map {:title "SetGuideChange"}
[:type [:= :set-guide]]
[:page-id ::sm/uuid]
[:id ::sm/uuid]
[:params [:maybe ::ctp/guide]]]
gen (->> (sg/generator schema)
(sg/fmap (fn [change]
(if (some? (:params change))
(update change :params assoc :id (:id change))
change))))]
[:schema {:gen/gen gen} schema]))
(def schema:set-flow-change
(let [schema [:map {:title "SetFlowChange"}
[:type [:= :set-flow]]
[:page-id ::sm/uuid]
[:id ::sm/uuid]
[:params [:maybe ::ctp/flow]]]
gen (->> (sg/generator schema)
(sg/fmap (fn [change]
(if (some? (:params change))
(update change :params assoc :id (:id change))
change))))]
[:schema {:gen/gen gen} schema]))
(def schema:set-plugin-data-change
(let [types #{:file :page :shape :color :typography :component}
schema [:map {:title "SetPagePluginData"}
[:type [:= :set-plugin-data]]
[:object-type [::sm/one-of types]]
;; It's optional because files don't need the id for type :file
[:object-id {:optional true} ::sm/uuid]
[:page-id {:optional true} ::sm/uuid]
[:namespace {:gen/gen (sg/word-keyword)} :keyword]
[:key {:gen/gen (sg/word-string)} :string]
[:value [:maybe [:string {:gen/gen (sg/word-string)}]]]]
check1 [:fn {:error/path [:page-id]
:error/message "missing page-id"}
(fn [{:keys [object-type] :as change}]
(if (= :shape object-type)
(uuid? (:page-id change))
true))]
gen (->> (sg/generator schema)
(sg/filter :object-id)
(sg/filter :page-id)
(sg/fmap (fn [{:keys [object-type] :as change}]
(cond
(= :file object-type)
(-> change
(dissoc :object-id)
(dissoc :page-id))
(= :shape object-type)
change
:else
(dissoc change :page-id)))))]
[:and {:gen/gen gen} schema check1]))
(def schema:change (def schema:change
[:schema [:schema
[:multi {:dispatch :type [:multi {:dispatch :type
@@ -67,13 +176,18 @@
:decode/json #(update % :type keyword) :decode/json #(update % :type keyword)
::smd/simplified true} ::smd/simplified true}
[:set-option [:set-option
[:map {:title "SetOptionChange"}
[:type [:= :set-option]] ;; DEPRECATED: remove before 2.3 release
;;
;; Is still there for not cause error when event is received
[:map {:title "SetOptionChange"}]]
[:set-comment-thread-position
[:map
[:comment-thread-id ::sm/uuid]
[:page-id ::sm/uuid] [:page-id ::sm/uuid]
[:option [:union [:frame-id [:maybe ::sm/uuid]]
[:keyword] [:position [:maybe ::gpt/point]]]]
[:vector {:gen/max 10} :keyword]]]
[:value :any]]]
[:add-obj [:add-obj
[:map {:title "AddObjChange"} [:map {:title "AddObjChange"}
@@ -103,6 +217,10 @@
[:component-id {:optional true} ::sm/uuid] [:component-id {:optional true} ::sm/uuid]
[:ignore-touched {:optional true} :boolean]]] [:ignore-touched {:optional true} :boolean]]]
[:set-guide schema:set-guide-change]
[:set-flow schema:set-flow-change]
[:set-default-grid schema:set-default-grid-change]
[:fix-obj [:fix-obj
[:map {:title "FixObjChange"} [:map {:title "FixObjChange"}
[:type [:= :fix-obj]] [:type [:= :fix-obj]]
@@ -143,19 +261,12 @@
[:map {:title "ModPageChange"} [:map {:title "ModPageChange"}
[:type [:= :mod-page]] [:type [:= :mod-page]]
[:id ::sm/uuid] [:id ::sm/uuid]
[:name :string]]] ;; All props are optional, background can be nil because is the
;; way to remove already set background
[:background {:optional true} [:maybe ::ctc/rgb-color]]
[:name {:optional true} :string]]]
[:mod-plugin-data [:set-plugin-data schema:set-plugin-data-change]
[:map {:title "ModPagePluginData"}
[:type [:= :mod-plugin-data]]
[:object-type [::sm/one-of #{:file :page :shape :color :typography :component}]]
;; It's optional because files don't need the id for type :file
[:object-id {:optional true} [:maybe ::sm/uuid]]
;; Only needed in type shape
[:page-id {:optional true} [:maybe ::sm/uuid]]
[:namespace :keyword]
[:key :string]
[:value [:maybe :string]]]]
[:del-page [:del-page
[:map {:title "DelPageChange"} [:map {:title "DelPageChange"}
@@ -190,10 +301,9 @@
[:type [:= :del-color]] [:type [:= :del-color]]
[:id ::sm/uuid]]] [:id ::sm/uuid]]]
;; DEPRECATED: remove before 2.3
[:add-recent-color [:add-recent-color
[:map {:title "AddRecentColorChange"} [:map {:title "AddRecentColorChange"}]]
[:type [:= :add-recent-color]]
[:color ::ctc/recent-color]]]
[:add-media [:add-media
[:map {:title "AddMediaChange"} [:map {:title "AddMediaChange"}
@@ -264,11 +374,11 @@
(sm/register! ::change schema:change) (sm/register! ::change schema:change)
(sm/register! ::changes schema:changes) (sm/register! ::changes schema:changes)
(def check-change! (def valid-change?
(sm/check-fn ::change)) (sm/lazy-validator schema:change))
(def check-changes! (def check-changes!
(sm/check-fn ::changes)) (sm/check-fn schema:changes))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Specific helpers ;; Specific helpers
@@ -304,10 +414,12 @@
;; If object has changed or is new verify is correct ;; If object has changed or is new verify is correct
(when (and (some? shape-new) (when (and (some? shape-new)
(not= shape-old shape-new)) (not= shape-old shape-new))
(dm/verify! (when-not (and (cts/valid-shape? shape-new)
"expected valid shape" (cts/shape? shape-new))
(and (cts/check-shape! shape-new) (ex/raise :type :assertion
(cts/shape? shape-new))))))] :code :data-validation
:hint "invalid shape found after applying changes"
::sm/explain (cts/explain-shape shape-new))))))]
(->> (into #{} (map :page-id) items) (->> (into #{} (map :page-id) items)
(mapcat (fn [page-id] (mapcat (fn [page-id]
@@ -340,12 +452,10 @@
(process-changes data items true)) (process-changes data items true))
([data items verify?] ([data items verify?]
;; When verify? false we spec the schema validation. Currently used to make just ;; When verify? false we spec the schema validation. Currently used
;; 1 validation even if the changes are applied twice ;; to make just 1 validation even if the changes are applied twice
(when verify? (when verify?
(dm/verify! (check-changes! items))
"expected valid changes"
(check-changes! items)))
(binding [*touched-changes* (volatile! #{})] (binding [*touched-changes* (volatile! #{})]
(let [result (reduce #(or (process-change %1 %2) %1) data items) (let [result (reduce #(or (process-change %1 %2) %1) data items)
@@ -357,14 +467,71 @@
#?(:clj (validate-shapes! data result items)) #?(:clj (validate-shapes! data result items))
result)))) result))))
;; DEPRECATED: remove after 2.3 release
(defmethod process-change :set-option (defmethod process-change :set-option
[data {:keys [page-id option value]}] [data _]
data)
;; --- Comment Threads
(defmethod process-change :set-comment-thread-position
[data {:keys [page-id comment-thread-id position frame-id]}]
(d/update-in-when data [:pages-index page-id] (d/update-in-when data [:pages-index page-id]
(fn [data] (fn [page]
(let [path (if (seqable? option) option [option])] (if (and position frame-id)
(if value (update page :comment-thread-positions assoc
(assoc-in data (into [:options] path) value) comment-thread-id {:frame-id frame-id
(assoc data :options (d/dissoc-in (:options data) path))))))) :position position})
(update page :comment-thread-positions dissoc
comment-thread-id)))))
;; --- Guides
(defmethod process-change :set-guide
[data {:keys [page-id id params]}]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
(let [guides (get page :guides)
guides (dissoc guides id)]
(if (empty? guides)
(dissoc page :guides)
(assoc page :guides guides)))))
(let [params (assoc params :id id)]
(d/update-in-when data [:pages-index page-id] update :guides assoc id params))))
;; --- Flows
(defmethod process-change :set-flow
[data {:keys [page-id id params]}]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
(let [flows (get page :flows)
flows (dissoc flows id)]
(if (empty? flows)
(dissoc page :flows)
(assoc page :flows flows)))))
(let [params (assoc params :id id)]
(d/update-in-when data [:pages-index page-id] update :flows assoc id params))))
;; --- Grids
(defmethod process-change :set-default-grid
[data {:keys [page-id grid-type params]}]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
(let [default-grids (get page :default-grids)
default-grids (dissoc default-grids grid-type)]
(if (empty? default-grids)
(dissoc page :default-grids)
(assoc page :default-grids default-grids)))))
(d/update-in-when data [:pages-index page-id] update :default-grids assoc grid-type params)))
;; --- Shape / Obj
(defmethod process-change :add-obj (defmethod process-change :add-obj
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}] [data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
@@ -546,6 +713,7 @@
(d/update-in-when [pid :shapes] d/without-obj sid) (d/update-in-when [pid :shapes] d/without-obj sid)
(d/update-in-when [pid :shapes] d/vec-without-nils) (d/update-in-when [pid :shapes] d/vec-without-nils)
(cond-> component? (d/update-when pid #(dissoc % :remote-synced)))))))) (cond-> component? (d/update-when pid #(dissoc % :remote-synced))))))))
(update-parent-id [objects id] (update-parent-id [objects id]
(-> objects (-> objects
(d/update-when id assoc :parent-id parent-id))) (d/update-when id assoc :parent-id parent-id)))
@@ -604,25 +772,34 @@
(ctpl/add-page data page))) (ctpl/add-page data page)))
(defmethod process-change :mod-page (defmethod process-change :mod-page
[data {:keys [id name]}] [data {:keys [id] :as params}]
(d/update-in-when data [:pages-index id] assoc :name name)) (d/update-in-when data [:pages-index id]
(fn [page]
(let [name (get params :name)
bg (get params :background :not-found)]
(cond-> page
(string? name)
(assoc :name name)
(defmethod process-change :mod-plugin-data (string? bg)
(assoc :background bg)
(nil? bg)
(dissoc :background))))))
(defmethod process-change :set-plugin-data
[data {:keys [object-type object-id page-id namespace key value]}] [data {:keys [object-type object-id page-id namespace key value]}]
(when (and (= object-type :shape) (nil? page-id))
(ex/raise :type :internal :hint "update for shapes needs a page-id"))
(letfn [(update-fn [data] (letfn [(update-fn [data]
(if (some? value) (if (some? value)
(assoc-in data [:plugin-data namespace key] value) (assoc-in data [:plugin-data namespace key] value)
(update-in data [:plugin-data namespace] (fnil dissoc {}) key)))] (update-in data [:plugin-data namespace] dissoc key)))]
(case object-type (case object-type
:file :file
(update-fn data) (update-fn data)
:page :page
(d/update-in-when data [:pages-index object-id :options] update-fn) (d/update-in-when data [:pages-index object-id] update-fn)
:shape :shape
(d/update-in-when data [:pages-index page-id :objects object-id] update-fn) (d/update-in-when data [:pages-index page-id :objects object-id] update-fn)
@@ -656,18 +833,11 @@
[data {:keys [id]}] [data {:keys [id]}]
(ctcl/delete-color data id)) (ctcl/delete-color data id))
;; DEPRECATED: remove before 2.3
(defmethod process-change :add-recent-color (defmethod process-change :add-recent-color
[data {:keys [color]}] [data _]
;; Moves the color to the top of the list and then truncates up to 15 data)
(update
data
:recent-colors
(fn [rc]
(let [rc (->> rc (d/removev (partial ctc/eq-recent-color? color)))
rc (-> rc (conj color))]
(cond-> rc
(> (count rc) 15)
(subvec 1))))))
;; -- Media ;; -- Media
+123 -58
View File
@@ -135,12 +135,6 @@
(or (contains? (meta changes) ::page-id) (or (contains? (meta changes) ::page-id)
(contains? (meta changes) ::component-id)))) (contains? (meta changes) ::component-id))))
(defn- assert-page!
[changes]
(dm/assert!
"Call (with-page) before using this function"
(contains? (meta changes) ::page)))
(defn- assert-objects! (defn- assert-objects!
[changes] [changes]
(dm/assert! (dm/assert!
@@ -195,41 +189,85 @@
(apply-changes-local))) (apply-changes-local)))
(defn mod-page (defn mod-page
[changes page new-name] ([changes options]
(-> changes (let [page (::page (meta changes))]
(update :redo-changes conj {:type :mod-page :id (:id page) :name new-name}) (mod-page changes page options)))
(update :undo-changes conj {:type :mod-page :id (:id page) :name (:name page)})
(apply-changes-local)))
(defn mod-plugin-data ([changes page {:keys [name background]}]
(let [change {:type :mod-page :id (:id page)}
redo (cond-> change
(some? name)
(assoc :name name)
(some? background)
(assoc :background background))
undo (cond-> change
(some? name)
(assoc :name (:name page))
(some? background)
(assoc :background (:background page)))]
(-> changes
(update :redo-changes conj redo)
(update :undo-changes conj undo)
(apply-changes-local)))))
(defn set-plugin-data
([changes namespace key value] ([changes namespace key value]
(mod-plugin-data changes :file nil nil namespace key value)) (set-plugin-data changes :file nil nil namespace key value))
([changes type id namespace key value] ([changes type id namespace key value]
(mod-plugin-data changes type id nil namespace key value)) (set-plugin-data changes type id nil namespace key value))
([changes type id page-id namespace key value] ([changes type id page-id namespace key value]
(let [data (::file-data (meta changes)) (let [data (::file-data (meta changes))
old-val old-val
(case type (case type
:file :file
(get-in data [:plugin-data namespace key]) (dm/get-in data [:plugin-data namespace key])
:page :page
(get-in data [:pages-index id :options :plugin-data namespace key]) (dm/get-in data [:pages-index id :options :plugin-data namespace key])
:shape :shape
(get-in data [:pages-index page-id :objects id :plugin-data namespace key]) (dm/get-in data [:pages-index page-id :objects id :plugin-data namespace key])
:color :color
(get-in data [:colors id :plugin-data namespace key]) (dm/get-in data [:colors id :plugin-data namespace key])
:typography :typography
(get-in data [:typographies id :plugin-data namespace key]) (dm/get-in data [:typographies id :plugin-data namespace key])
:component :component
(get-in data [:components id :plugin-data namespace key]))] (dm/get-in data [:components id :plugin-data namespace key]))
redo-change
(cond-> {:type :set-plugin-data
:object-type type
:namespace namespace
:key key
:value value}
(uuid? id)
(assoc :object-id id)
(uuid? page-id)
(assoc :page-id page-id))
undo-change
(cond-> {:type :set-plugin-data
:object-type type
:namespace namespace
:key key
:value old-val}
(uuid? id)
(assoc :object-id id)
(uuid? page-id)
(assoc :page-id page-id))]
(-> changes (-> changes
(update :redo-changes conj {:type :mod-plugin-data :object-type type :object-id id :page-id page-id :namespace namespace :key key :value value}) (update :redo-changes conj redo-change)
(update :undo-changes conj {:type :mod-plugin-data :object-type type :object-id id :page-id page-id :namespace namespace :key key :value old-val}) (update :undo-changes conj undo-change)
(apply-changes-local))))) (apply-changes-local)))))
(defn del-page (defn del-page
@@ -246,42 +284,76 @@
(update :undo-changes conj {:type :mov-page :id page-id :index prev-index}) (update :undo-changes conj {:type :mov-page :id page-id :index prev-index})
(apply-changes-local))) (apply-changes-local)))
(defn set-page-option (defn set-guide
[changes option-key option-val] [changes id guide]
(assert-page! changes)
(let [page-id (::page-id (meta changes)) (let [page-id (::page-id (meta changes))
page (::page (meta changes)) page (::page (meta changes))
old-val (get-in page [:options option-key])] old-val (dm/get-in page [:guides id])]
(-> changes (-> changes
(update :redo-changes conj {:type :set-option (update :redo-changes conj {:type :set-guide
:page-id page-id :page-id page-id
:option option-key :id id
:value option-val}) :params guide})
(update :undo-changes conj {:type :set-option (update :undo-changes conj {:type :set-guide
:page-id page-id :page-id page-id
:option option-key :id id
:value old-val}) :params old-val}))))
(apply-changes-local)))) (defn set-flow
[changes id flow]
(defn update-page-option
[changes option-key update-fn & args]
(assert-page! changes)
(let [page-id (::page-id (meta changes)) (let [page-id (::page-id (meta changes))
page (::page (meta changes)) page (::page (meta changes))
old-val (get-in page [:options option-key]) old-val (dm/get-in page [:flows id])
new-val (apply update-fn old-val args)]
(-> changes changes (-> changes
(update :redo-changes conj {:type :set-option (update :redo-changes conj {:type :set-flow
:page-id page-id :page-id page-id
:option option-key :id id
:value new-val}) :params flow})
(update :undo-changes conj {:type :set-option (update :undo-changes conj {:type :set-flow
:page-id page-id :page-id page-id
:option option-key :id id
:value old-val}) :params old-val}))]
(apply-changes-local)))) ;; FIXME: not sure if we need this
(apply-changes-local changes)))
(defn set-comment-thread-position
[changes {:keys [id frame-id position] :as thread}]
(let [page-id (::page-id (meta changes))
page (::page (meta changes))
old-val (dm/get-in page [:comment-thread-positions id])
changes (-> changes
(update :redo-changes conj {:type :set-comment-thread-position
:comment-thread-id id
:page-id page-id
:frame-id frame-id
:position position})
(update :undo-changes conj {:type :set-comment-thread-position
:page-id page-id
:comment-thread-id id
:frame-id (:frame-id old-val)
:position (:position old-val)}))]
;; FIXME: not sure if we need this
(apply-changes-local changes)))
(defn set-default-grid
[changes type params]
(let [page-id (::page-id (meta changes))
page (::page (meta changes))
old-val (dm/get-in page [:grids type])
changes (update changes :redo-changes conj {:type :set-default-grid
:page-id page-id
:grid-type type
:params params})
changes (update changes :undo-changes conj {:type :set-default-grid
:page-id page-id
:grid-type type
:params old-val})]
;; FIXME: not sure if we need this
(apply-changes-local changes)))
;; Shape tree changes ;; Shape tree changes
@@ -607,13 +679,6 @@
(reduce resize-parent changes all-parents))) (reduce resize-parent changes all-parents)))
;; Library changes ;; Library changes
(defn add-recent-color
[changes color]
(-> changes
(update :redo-changes conj {:type :add-recent-color :color color})
(apply-changes-local)))
(defn add-color (defn add-color
[changes color] [changes color]
(-> changes (-> changes
+1 -1
View File
@@ -6,4 +6,4 @@
(ns app.common.files.defaults) (ns app.common.files.defaults)
(def version 51) (def version 57)
+127 -7
View File
@@ -13,6 +13,7 @@
[app.common.files.defaults :as cfd] [app.common.files.defaults :as cfd]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.geom.shapes.path :as gsp] [app.common.geom.shapes.path :as gsp]
@@ -863,11 +864,9 @@
(assoc shadow :color color))) (assoc shadow :color color)))
(update-object [object] (update-object [object]
(d/update-when object :shadow (let [xform (comp (map fix-shadow)
#(into [] (filter valid-shadow?))]
(comp (map fix-shadow) (d/update-when object :shadow #(into [] xform %))))
(filter valid-shadow?))
%)))
(update-container [container] (update-container [container]
(d/update-when container :objects update-vals update-object))] (d/update-when container :objects update-vals update-object))]
@@ -1010,13 +1009,127 @@
(defn migrate-up-51 (defn migrate-up-51
"This migration fixes library invalid colors" "This migration fixes library invalid colors"
[data] [data]
(let [update-colors (let [update-colors
(fn [colors] (fn [colors]
(into {} (filter #(-> % val valid-color?) colors)))] (into {} (filter #(-> % val valid-color?) colors)))]
(update data :colors update-colors))) (update data :colors update-colors)))
(defn migrate-up-52
"Fixes incorrect value on `layout-wrap-type` prop"
[data]
(letfn [(update-shape [shape]
(if (= :no-wrap (:layout-wrap-type shape))
(assoc shape :layout-wrap-type :nowrap)
shape))
(update-page [page]
(d/update-when page :objects update-vals update-shape))]
(update data :pages-index update-vals update-page)))
(defn migrate-up-54
"Fixes shapes with invalid colors in shadow: it first tries a non
destructive fix, and if it is not possible, then, shadow is removed"
[data]
(letfn [(fix-shadow [shadow]
(update shadow :color d/without-nils))
(update-shape [shape]
(let [xform (comp (map fix-shadow)
(filter valid-shadow?))]
(d/update-when shape :shadow #(into [] xform %))))
(update-container [container]
(d/update-when container :objects update-vals update-shape))]
(-> data
(update :pages-index update-vals update-container)
(update :components update-vals update-container))))
(defn migrate-up-55
"This migration moves page options to the page level"
[data]
(let [update-page
(fn [{:keys [options] :as page}]
(cond-> page
(and (some? (:saved-grids options))
(not (contains? page :default-grids)))
(assoc :default-grids (:saved-grids options))
(and (some? (:background options))
(not (contains? page :background)))
(assoc :background (:background options))
(and (some? (:flows options))
(or (not (contains? page :flows))
(not (map? (:flows page)))))
(assoc :flows (d/index-by :id (:flows options)))
(and (some? (:guides options))
(not (contains? page :guides)))
(assoc :guides (:guides options))
(and (some? (:comment-threads-position options))
(not (contains? page :comment-thread-positions)))
(assoc :comment-thread-positions (:comment-threads-position options))))]
(update data :pages-index d/update-vals update-page)))
(defn migrate-up-56
[data]
(letfn [(fix-fills [object]
(d/update-when object :fills (partial filterv valid-fill?)))
(update-object [object]
(-> object
(fix-fills)
;; If shape contains shape-ref but has a nil value, we
;; should remove it from shape object
(cond-> (and (contains? object :shape-ref)
(nil? (get object :shape-ref)))
(dissoc :shape-ref))
;; The text shape also can has fills on the text
;; fragments so we need to fix fills there
(cond-> (cfh/text-shape? object)
(update :content (partial txt/transform-nodes identity fix-fills)))))
(update-container [container]
(d/update-when container :objects update-vals update-object))]
(-> data
(update :pages-index update-vals update-container)
(update :components update-vals update-container))))
(defn migrate-up-57
[data]
(letfn [(fix-thread-positions [positions]
(reduce-kv (fn [result id {:keys [position] :as data}]
(let [data (cond
(gpt/point? position)
data
(and (map? position)
(gpt/valid-point-attrs? position))
(assoc data :position (gpt/point position))
:else
(assoc data :position (gpt/point 0 0)))]
(assoc result id data)))
positions
positions))
(update-page [page]
(d/update-when page :comment-thread-positions fix-thread-positions))]
(-> data
(update :pages (fn [pages] (into [] (remove nil?) pages)))
(update :pages-index dissoc nil)
(update :pages-index update-vals update-page))))
(def migrations (def migrations
"A vector of all applicable migrations" "A vector of all applicable migrations"
[{:id 2 :migrate-up migrate-up-2} [{:id 2 :migrate-up migrate-up-2}
@@ -1059,4 +1172,11 @@
{:id 48 :migrate-up migrate-up-48} {:id 48 :migrate-up migrate-up-48}
{:id 49 :migrate-up migrate-up-49} {:id 49 :migrate-up migrate-up-49}
{:id 50 :migrate-up migrate-up-50} {:id 50 :migrate-up migrate-up-50}
{:id 51 :migrate-up migrate-up-51}]) {:id 51 :migrate-up migrate-up-51}
{:id 52 :migrate-up migrate-up-52}
{:id 53 :migrate-up migrate-up-26}
{:id 54 :migrate-up migrate-up-54}
{:id 55 :migrate-up migrate-up-55}
{:id 56 :migrate-up migrate-up-56}
{:id 57 :migrate-up migrate-up-57}])
+2 -2
View File
@@ -15,10 +15,10 @@
[old-page page check-attrs] [old-page page check-attrs]
(let [old-objects (get old-page :objects) (let [old-objects (get old-page :objects)
old-guides (or (get-in old-page [:options :guides]) []) old-guides (or (get old-page :guides) [])
new-objects (get page :objects) new-objects (get page :objects)
new-guides (or (get-in page [:options :guides]) []) new-guides (or (get page :guides) [])
changed-object? changed-object?
(fn [id] (fn [id]
+12 -11
View File
@@ -57,16 +57,17 @@
:misplaced-slot :misplaced-slot
:missing-slot}) :missing-slot})
(def ^:private (def ^:private schema:error
schema:error [:map {:title "ValidationError"}
(sm/define [:code {:optional false} [::sm/one-of error-codes]]
[:map {:title "ValidationError"} [:hint {:optional false} :string]
[:code {:optional false} [::sm/one-of error-codes]] [:shape {:optional true} :map] ; Cannot validate a shape because here it may be broken
[:hint {:optional false} :string] [:shape-id {:optional true} ::sm/uuid]
[:shape {:optional true} :map] ; Cannot validate a shape because here it may be broken [:file-id ::sm/uuid]
[:shape-id {:optional true} ::sm/uuid] [:page-id {:optional true} [:maybe ::sm/uuid]]])
[:file-id ::sm/uuid]
[:page-id {:optional true} [:maybe ::sm/uuid]]])) (def check-error!
(sm/check-fn schema:error))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ERROR HANDLING ;; ERROR HANDLING
@@ -95,7 +96,7 @@
(dm/assert! (dm/assert!
"expected valid error" "expected valid error"
(sm/check! schema:error error)) (check-error! error))
(vswap! *errors* conj error))) (vswap! *errors* conj error)))
+13
View File
@@ -15,6 +15,7 @@
java.time.Instant java.time.Instant
java.time.OffsetDateTime java.time.OffsetDateTime
java.util.List java.util.List
linked.map.LinkedMap
org.fressian.Reader org.fressian.Reader
org.fressian.StreamingWriter org.fressian.StreamingWriter
org.fressian.Writer org.fressian.Writer
@@ -109,6 +110,13 @@
(clojure.lang.PersistentArrayMap. (.toArray kvs)) (clojure.lang.PersistentArrayMap. (.toArray kvs))
(clojure.lang.PersistentHashMap/create (seq kvs))))) (clojure.lang.PersistentHashMap/create (seq kvs)))))
(defn read-ordered-map
[^Reader rdr]
(let [kvs ^java.util.List (read-object! rdr)]
(reduce #(assoc %1 (first %2) (second %2))
(d/ordered-map)
(partition-all 2 (seq kvs)))))
(def ^:dynamic *write-handler-lookup* nil) (def ^:dynamic *write-handler-lookup* nil)
(def ^:dynamic *read-handler-lookup* nil) (def ^:dynamic *read-handler-lookup* nil)
@@ -225,6 +233,11 @@
:wfn write-map-like :wfn write-map-like
:rfn read-map-like} :rfn read-map-like}
{:name "linked/map"
:class LinkedMap
:wfn write-map-like
:rfn read-ordered-map}
{:name "clj/keyword" {:name "clj/keyword"
:class clojure.lang.Keyword :class clojure.lang.Keyword
:wfn write-named :wfn write-named
+3
View File
@@ -56,6 +56,9 @@
[:x ::sm/safe-number] [:x ::sm/safe-number]
[:y ::sm/safe-number]]) [:y ::sm/safe-number]])
(def valid-point-attrs?
(sm/validator schema:point-attrs))
(def valid-point? (def valid-point?
(sm/validator (sm/validator
[:and [:fn point?] schema:point-attrs])) [:and [:fn point?] schema:point-attrs]))
@@ -10,6 +10,7 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth])) [app.common.math :as mth]))
(defn shape-stroke-margin (defn shape-stroke-margin
@@ -60,6 +61,7 @@
filter-y (mth/min y (+ y offset-y (- spread) (- blur) -5)) filter-y (mth/min y (+ y offset-y (- spread) (- blur) -5))
filter-w (+ w (mth/abs offset-x) (* spread 2) (* blur 2) 10) filter-w (+ w (mth/abs offset-x) (* spread 2) (* blur 2) 10)
filter-h (+ h (mth/abs offset-y) (* spread 2) (* blur 2) 10)] filter-h (+ h (mth/abs offset-y) (* spread 2) (* blur 2) 10)]
(grc/make-rect filter-x filter-y filter-w filter-h))) (grc/make-rect filter-x filter-y filter-w filter-h)))
(defn get-rect-filter-bounds (defn get-rect-filter-bounds
@@ -96,12 +98,15 @@
([shape ignore-margin?] ([shape ignore-margin?]
(let [strokes (:strokes shape) (let [strokes (:strokes shape)
open-path? (and ^boolean (cfh/path-shape? shape)
^boolean (gsh/open-path? shape))
stroke-width stroke-width
(->> strokes (->> strokes
(map #(case (get % :stroke-alignment :center) (map #(case (get % :stroke-alignment :center)
:center (/ (:stroke-width % 0) 2) :center (/ (:stroke-width % 0) 2)
:outer (:stroke-width % 0) :outer (:stroke-width % 0)
0)) (if open-path? (:stroke-width % 0) 0)))
(reduce d/max 0)) (reduce d/max 0))
stroke-margin stroke-margin
+4 -2
View File
@@ -852,8 +852,10 @@
(defn ray-overlaps? (defn ray-overlaps?
[ray-point {selrect :selrect}] [ray-point {selrect :selrect}]
(and (>= (:y ray-point) (:y1 selrect)) (and (or (> (:y ray-point) (:y1 selrect))
(<= (:y ray-point) (:y2 selrect)))) (mth/almost-zero? (- (:y ray-point) (:y1 selrect))))
(or (< (:y ray-point) (:y2 selrect))
(mth/almost-zero? (- (:y ray-point) (:y2 selrect))))))
(defn content->geom-data (defn content->geom-data
[content] [content]
+45 -43
View File
@@ -232,6 +232,7 @@
[(:parent-id first-shape)] [(:parent-id first-shape)]
(fn [shape objects] (fn [shape objects]
(-> shape (-> shape
(ctl/assign-cells objects)
(ctl/push-into-cell [(:id first-shape)] row column) (ctl/push-into-cell [(:id first-shape)] row column)
(ctl/assign-cells objects))) (ctl/assign-cells objects)))
{:with-objects? true}) {:with-objects? true})
@@ -1947,54 +1948,55 @@
(defn generate-duplicate-flows (defn generate-duplicate-flows
[changes shapes page ids-map] [changes shapes page ids-map]
(let [flows (-> page :options :flows) (let [flows (get page :flows)
unames (volatile! (into #{} (map :name flows))) unames (volatile! (cfh/get-used-names (vals flows)))
frames-with-flow (->> shapes has-flow? (partial ctp/get-frame-flow flows)]
(filter #(= (:type %) :frame))
(filter #(some? (ctp/get-frame-flow flows (:id %)))))] (reduce (fn [changes frame-id]
(if-not (empty? frames-with-flow) (let [name (cfh/generate-unique-name @unames "Flow 1")
(let [update-flows (fn [flows] frame-id (get ids-map frame-id)
(reduce flow-id (uuid/next)
(fn [flows frame] new-flow {:id flow-id
(let [name (cfh/generate-unique-name @unames "Flow 1") :name name
_ (vswap! unames conj name) :starting-frame frame-id}]
new-flow {:id (uuid/next)
:name name (vswap! unames conj name)
:starting-frame (get ids-map (:id frame))}] (pcb/set-flow changes flow-id new-flow)))
(ctp/add-flow flows new-flow)))
flows changes
frames-with-flow))] (->> shapes
(pcb/update-page-option changes :flows update-flows)) (filter cfh/frame-shape?)
changes))) (map :id)
(filter has-flow?)))))
(defn generate-duplicate-guides (defn generate-duplicate-guides
[changes shapes page ids-map delta] [changes shapes page ids-map delta]
(let [guides (get-in page [:options :guides]) (let [guides (get page :guides)
frames (->> shapes (filter cfh/frame-shape?)) frames (filter cfh/frame-shape? shapes)]
new-guides ;; FIXME: this can be implemented efficiently just indexing guides
(reduce ;; by frame-id instead of iterate over all guides all the time
(fn [g frame]
(let [new-id (ids-map (:id frame))
new-frame (-> frame (gsh/move delta))
new-guides (reduce (fn [changes frame]
(->> guides (let [new-id (get ids-map (:id frame))
(vals) new-frame (gsh/move frame delta)]
(filter #(= (:frame-id %) (:id frame)))
(map #(-> % (reduce-kv (fn [changes _ guide]
(assoc :id (uuid/next)) (if (= (:id frame) (:frame-id guide))
(assoc :frame-id new-id) (let [guide-id (uuid/next)
(assoc :position (if (= (:axis %) :x) position (if (= (:axis guide) :x)
(+ (:position %) (- (:x new-frame) (:x frame))) (+ (:position guide) (- (:x new-frame) (:x frame)))
(+ (:position %) (- (:y new-frame) (:y frame))))))))] (+ (:position guide) (- (:y new-frame) (:y frame))))
(cond-> g guide {:id guide-id
(not-empty new-guides) :frame-id new-id
(conj (into {} (map (juxt :id identity) new-guides)))))) :position position
guides :axis (:axis guide)}]
frames)] (pcb/set-guide changes guide-id guide))
(-> (pcb/with-page changes page) changes))
(pcb/set-page-option :guides new-guides)))) changes
guides)))
(pcb/with-page changes page)
frames)))
(defn generate-duplicate-component-change (defn generate-duplicate-component-change
[changes objects page component-root parent-id frame-id delta libraries library-data] [changes objects page component-root parent-id frame-id delta libraries library-data]
+34 -37
View File
@@ -7,13 +7,11 @@
(ns app.common.logic.shapes (ns app.common.logic.shapes
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb] [app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.types.component :as ctk] [app.common.types.component :as ctk]
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.page :as ctp]
[app.common.types.shape.interactions :as ctsi] [app.common.types.shape.interactions :as ctsi]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid])) [app.common.uuid :as uuid]))
@@ -85,7 +83,9 @@
(pcb/with-page page) (pcb/with-page page)
(pcb/with-objects objects) (pcb/with-objects objects)
(pcb/with-library-data file)) (pcb/with-library-data file))
lookup (d/getf objects) lookup (d/getf objects)
groups-to-unmask groups-to-unmask
(reduce (fn [group-ids id] (reduce (fn [group-ids id]
;; When the shape to delete is the mask of a masked group, ;; When the shape to delete is the mask of a masked group,
@@ -110,30 +110,21 @@
interactions))) interactions)))
(vals objects)) (vals objects))
ids-set (set ids-to-delete) changes
guides-to-remove (reduce (fn [changes {:keys [id] :as flow}]
(->> (dm/get-in page [:options :guides]) (if (contains? ids-to-delete (:starting-frame flow))
(vals) (pcb/set-flow changes id nil)
(filter #(contains? ids-set (:frame-id %))) changes))
(map :id)) changes
(:flows page))
guides
(->> guides-to-remove
(reduce dissoc (dm/get-in page [:options :guides])))
starting-flows
(filter (fn [flow]
;; If any of the deleted is a frame that starts a flow,
;; this must be deleted, too.
(contains? ids-to-delete (:starting-frame flow)))
(-> page :options :flows))
all-parents all-parents
(reduce (fn [res id] (reduce (fn [res id]
;; All parents of any deleted shape must be resized. ;; All parents of any deleted shape must be resized.
(into res (cfh/get-parent-ids objects id))) (into res (cfh/get-parent-ids objects id)))
(d/ordered-set) (d/ordered-set)
ids-to-delete) (concat ids-to-delete ids-to-hide))
all-children all-children
(->> ids-to-delete ;; Children of deleted shapes must be also deleted. (->> ids-to-delete ;; Children of deleted shapes must be also deleted.
@@ -158,7 +149,11 @@
empty-parents empty-parents
;; Any parent whose children are all deleted, must be deleted too. ;; Any parent whose children are all deleted, must be deleted too.
(into (d/ordered-set) (find-all-empty-parents #{})) ;; Unless we are during a component swap: in this case we are replacing a shape by
;; other one, so must not delete empty parents.
(if-not component-swap
(into (d/ordered-set) (find-all-empty-parents #{}))
#{})
components-to-delete components-to-delete
(if components-v2 (if components-v2
@@ -172,8 +167,18 @@
(into ids-to-delete all-children)) (into ids-to-delete all-children))
[]) [])
changes (-> changes ids-set (set ids-to-delete)
(pcb/set-page-option :guides guides))
guides-to-delete
(->> (:guides page)
(vals)
(filter #(contains? ids-set (:frame-id %)))
(map :id))
changes (reduce (fn [changes guide-id]
(pcb/set-flow changes guide-id nil))
changes
guides-to-delete)
changes (reduce (fn [changes component-id] changes (reduce (fn [changes component-id]
;; It's important to delete the component before the main instance, because we ;; It's important to delete the component before the main instance, because we
@@ -181,6 +186,7 @@
(pcb/delete-component changes component-id (:id page))) (pcb/delete-component changes component-id (:id page)))
changes changes
components-to-delete) components-to-delete)
changes (-> changes changes (-> changes
(generate-update-shape-flags ids-to-hide objects {:hidden true}) (generate-update-shape-flags ids-to-hide objects {:hidden true})
(pcb/remove-objects all-children {:ignore-touched true}) (pcb/remove-objects all-children {:ignore-touched true})
@@ -197,11 +203,7 @@
(into [] (into []
(remove #(and (ctsi/has-destination %) (remove #(and (ctsi/has-destination %)
(contains? ids-to-delete (:destination %)))) (contains? ids-to-delete (:destination %))))
interactions))))) interactions))))))]
(cond-> (seq starting-flows)
(pcb/update-page-option :flows (fn [flows]
(->> (map :id starting-flows)
(reduce ctp/remove-flow flows))))))]
[all-parents changes])) [all-parents changes]))
@@ -406,17 +408,12 @@
;; Resize parent containers that need to ;; Resize parent containers that need to
(pcb/resize-parents parents)))) (pcb/resize-parents parents))))
(defn change-show-in-viewer [shape hide?] (defn change-show-in-viewer [shape hide?]
(cond-> (assoc shape :hide-in-viewer hide?) (assoc shape :hide-in-viewer hide?))
;; When a frame is no longer shown in view mode, it cannot have interactions
hide?
(dissoc :interactions)))
(defn add-new-interaction [shape interaction] (defn add-new-interaction [shape interaction]
(-> shape (-> shape
(update :interactions ctsi/add-interaction interaction) (update :interactions ctsi/add-interaction interaction)))
;; When a interaction is created, the frame must be shown in view mode
(dissoc :hide-in-viewer))) (defn show-in-viewer [shape]
(dissoc shape :hide-in-viewer))
+102 -157
View File
@@ -9,7 +9,6 @@
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]])) #?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
[app.common.schema.openapi :as-alias oapi] [app.common.schema.openapi :as-alias oapi]
@@ -29,11 +28,6 @@
[malli.util :as mu])) [malli.util :as mu]))
(defprotocol ILazySchema (defprotocol ILazySchema
(-get-schema [_])
(-get-validator [_])
(-get-explainer [_])
(-get-decoder [_])
(-get-encoder [_])
(-validate [_ o]) (-validate [_ o])
(-explain [_ o]) (-explain [_ o])
(-decode [_ o])) (-decode [_ o]))
@@ -53,27 +47,21 @@
[s] [s]
(m/type-properties s)) (m/type-properties s))
(defn lazy-schema? (defn- lazy-schema?
[s] [s]
(satisfies? ILazySchema s)) (satisfies? ILazySchema s))
(defn schema (defn schema
[s] [s]
(if (lazy-schema? s) (m/schema s default-options))
(-get-schema s)
(m/schema s default-options)))
(defn validate (defn validate
[s value] [s value]
(if (lazy-schema? s) (m/validate s value default-options))
(-validate s value)
(m/validate s value default-options)))
(defn explain (defn explain
[s value] [s value]
(if (lazy-schema? s) (m/explain s value default-options))
(-explain s value)
(m/explain s value default-options)))
(defn simplify (defn simplify
"Given an explain data structure, return a simplified version of it" "Given an explain data structure, return a simplified version of it"
@@ -171,29 +159,19 @@
(defn validator (defn validator
[s] [s]
(if (lazy-schema? s) (-> s schema m/validator))
(-get-validator s)
(-> s schema m/validator)))
(defn explainer (defn explainer
[s] [s]
(if (lazy-schema? s) (-> s schema m/explainer))
(-get-explainer s)
(-> s schema m/explainer)))
(defn encoder (defn encoder
([s]
(assert (lazy-schema? s) "expected lazy schema")
(-get-decoder s))
([s transformer] ([s transformer]
(m/encoder s default-options transformer)) (m/encoder s default-options transformer))
([s options transformer] ([s options transformer]
(m/encoder s options transformer))) (m/encoder s options transformer)))
(defn decoder (defn decoder
([s]
(assert (lazy-schema? s) "expected lazy schema")
(-get-decoder s))
([s transformer] ([s transformer]
(m/decoder s default-options transformer)) (m/decoder s default-options transformer))
([s options transformer] ([s options transformer]
@@ -242,6 +220,8 @@
(v/-block "Schema" (v/-visit schema printer) printer)]}) (v/-block "Schema" (v/-visit schema printer) printer)]})
(defn pretty-explain (defn pretty-explain
"A helper that allows print a console-friendly output for the
explain; should not be used for other purposes"
[explain & {:keys [variant message] [explain & {:keys [variant message]
:or {variant ::explain :or {variant ::explain
message "Validation Error"}}] message "Validation Error"}}]
@@ -259,102 +239,50 @@
([s] (lookup sr/default-registry s)) ([s] (lookup sr/default-registry s))
([registry s] (schema (mr/schema registry s)))) ([registry s] (schema (mr/schema registry s))))
(defn fast-check! (defn- fast-check!
"A fast path for checking process, assumes the ILazySchema protocol "A fast path for checking process, assumes the ILazySchema protocol
implemented on the provided `s` schema. Sould not be used directly." implemented on the provided `s` schema. Sould not be used directly."
[s value] [s type code hint value]
(when-not ^boolean (-validate s value) (when-not ^boolean (-validate s value)
(let [hint (d/nilv dm/*assert-context* "check error") (let [explain (-explain s value)]
explain (-explain s value)] (throw (ex-info hint {:type type
(throw (ex-info hint {:type :assertion :code code
:code :data-validation
:hint hint :hint hint
::explain explain})))) ::explain explain}))))
true) value)
(declare define) (declare ^:private lazy-schema)
(defn check-fn (defn check-fn
"Create a predefined check function" "Create a predefined check function"
[s] [s & {:keys [hint type code]}]
(let [schema (if (lazy-schema? s) s (define s))] (let [schema (if (lazy-schema? s) s (lazy-schema s))
(partial fast-check! schema))) hint (or ^boolean hint "check error")
type (or ^boolean type :assertion)
code (or ^boolean code :data-validation)]
(partial fast-check! schema type code hint)))
(defn check! (defn check!
"A helper intended to be used on assertions for validate/check the "A helper intended to be used on assertions for validate/check the
schema over provided data. Raises an assertion exception, should be schema over provided data. Raises an assertion exception."
used together with `dm/assert!` or `dm/verify!`." [s value & {:keys [hint type code]}]
[s value] (let [s (if (lazy-schema? s) s (lazy-schema s))
(if (lazy-schema? s) hint (or ^boolean hint "check error")
(fast-check! s value) type (or ^boolean type :assertion)
(do code (or ^boolean code :data-validation)]
(when-not ^boolean (m/validate s value default-options) (fast-check! s type code hint value)))
(let [hint (d/nilv dm/*assert-context* "check error")
explain (explain s value)]
(throw (ex-info hint {:type :assertion
:code :data-validation
:hint hint
::explain explain}))))
true)))
(defn fast-validate!
"A fast path for validation process, assumes the ILazySchema protocol
implemented on the provided `s` schema. Sould not be used directly."
([s value] (fast-validate! s value nil))
([s value options]
(when-not ^boolean (-validate s value)
(let [explain (-explain s value)
options (into {:type :validation
:code :data-validation
::explain explain}
options)
hint (get options :hint "schema validation error")]
(throw (ex-info hint options))))))
(defn validate-fn
"Create a predefined validate function that raises an expception"
[s]
(let [schema (if (lazy-schema? s) s (define s))]
(partial fast-validate! schema)))
(defn validate!
"A generic validation function for predefined schemas."
([s value] (validate! s value nil))
([s value options]
(if (lazy-schema? s)
(fast-validate! s value options)
(when-not ^boolean (m/validate s value default-options)
(let [explain (explain s value)
options (into {:type :validation
:code :data-validation
::explain explain}
options)
hint (get options :hint "schema validation error")]
(throw (ex-info hint options)))))))
;; FIXME: revisit
(defn conform!
[schema value]
(assert (lazy-schema? schema) "expected `schema` to satisfy ILazySchema protocol")
(let [params (-decode schema value)]
(fast-validate! schema params nil)
params))
(defn register! [type s] (defn register! [type s]
(let [s (if (map? s) (m/-simple-schema s) s)] (let [s (if (map? s) (m/-simple-schema s) s)]
(swap! sr/registry assoc type s) (swap! sr/registry assoc type s)
nil)) nil))
(defn define (defn- lazy-schema
"Create ans instance of ILazySchema" "Create ans instance of ILazySchema"
[s & {:keys [transformer] :or {transformer json-transformer} :as options}] [s]
(let [schema (delay (schema s)) (let [schema (delay (schema s))
validator (delay (m/validator @schema)) validator (delay (m/validator @schema))
explainer (delay (m/explainer @schema)) explainer (delay (m/explainer @schema))]
options (c/merge default-options (dissoc options :transformer))
decoder (delay (m/decoder @schema options transformer))
encoder (delay (m/encoder @schema options transformer))]
(reify (reify
m/AST m/AST
@@ -397,16 +325,6 @@
(m/-form @schema)) (m/-form @schema))
ILazySchema ILazySchema
(-get-schema [_]
@schema)
(-get-validator [_]
@validator)
(-get-explainer [_]
@explainer)
(-get-encoder [_]
@encoder)
(-get-decoder [_]
@decoder)
(-validate [_ o] (-validate [_ o]
(@validator o)) (@validator o))
(-explain [_ o] (-explain [_ o]
@@ -448,7 +366,7 @@
(defn parse-email (defn parse-email
[s] [s]
(if (string? s) (if (string? s)
(re-matches email-re s) (first (re-seq email-re s))
nil)) nil))
(defn email-string? (defn email-string?
@@ -476,7 +394,7 @@
::oapi/type "string" ::oapi/type "string"
::oapi/format "email"}}) ::oapi/format "email"}})
(def non-empty-strings-xf (def xf:filter-word-strings
(comp (comp
(filter string?) (filter string?)
(remove str/empty?) (remove str/empty?)
@@ -489,11 +407,8 @@
:min 0 :min 0
:max 1 :max 1
:compile :compile
(fn [{:keys [coerce kind max min] :as props} children _] (fn [{:keys [kind max min] :as props} children _]
(let [xform (if coerce (let [kind (or (last children) kind)
(comp non-empty-strings-xf (map coerce))
non-empty-strings-xf)
kind (or (last children) kind)
pred pred
(cond (cond
@@ -501,9 +416,6 @@
(nil? kind) any? (nil? kind) any?
:else (validator kind)) :else (validator kind))
encode-child
(encoder kind string-transformer)
pred pred
(cond (cond
(and max min) (and max min)
@@ -531,31 +443,49 @@
(fn [value] (fn [value]
(every? pred value))) (every? pred value)))
decode
decode-string-child
(decoder kind string-transformer)
decode-string
(fn [v] (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)] (let [v (if (string? v) (str/split v #"[\s,]+") v)
(into #{} xform v))) x (comp xf:filter-word-strings (map decode-string-child))]
(into #{} x v)))
decode-json-child
(decoder kind json-transformer)
decode-json
(fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)
x (comp xf:filter-word-strings (map decode-json-child))]
(into #{} x v)))
encode-string-child
(encoder kind string-transformer)
encode-string
(fn [o]
(if (set? o)
(str/join ", " (map encode-string-child o))
o))
encode-json encode-json
(fn [o] (fn [o]
(if (set? o) (if (set? o)
(vec o) (vec o)
o))
encode-string
(fn [o]
(if (set? o)
(str/join ", " (map encode-child o))
o))] o))]
{:pred pred {:pred pred
:type-properties :type-properties
{:title "set" {:title "set"
:description "Set of Strings" :description "Set of Strings"
:error/message "should be a set of strings" :error/message "should be a set of strings"
:gen/gen (-> kind sg/generator sg/set) :gen/gen (-> kind sg/generator sg/set)
:decode/string decode :decode/string decode-string
:decode/json decode :decode/json decode-json
:encode/string encode-string :encode/string encode-string
:encode/json encode-json :encode/json encode-json
::oapi/type "array" ::oapi/type "array"
@@ -569,21 +499,14 @@
:min 0 :min 0
:max 1 :max 1
:compile :compile
(fn [{:keys [coerce kind max min] :as props} children _] (fn [{:keys [kind max min] :as props} children _]
(let [xform (if coerce (let [kind (or (last children) kind)
(comp non-empty-strings-xf (map coerce))
non-empty-strings-xf)
kind (or (last children) kind)
pred pred
(cond (cond
(fn? kind) kind (fn? kind) kind
(nil? kind) any? (nil? kind) any?
:else (validator kind)) :else (validator kind))
encode-child
(encoder kind string-transformer)
pred pred
(cond (cond
(and max min) (and max min)
@@ -611,15 +534,31 @@
(fn [value] (fn [value]
(every? pred value))) (every? pred value)))
decode decode-string-child
(decoder kind string-transformer)
decode-json-child
(decoder kind json-transformer)
decode-string
(fn [v] (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)] (let [v (if (string? v) (str/split v #"[\s,]+") v)
(into [] xform v))) x (comp xf:filter-word-strings (map decode-string-child))]
(into #{} x v)))
decode-json
(fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)
x (comp xf:filter-word-strings (map decode-json-child))]
(into #{} x v)))
encode-string-child
(encoder kind string-transformer)
encode-string encode-string
(fn [o] (fn [o]
(if (vector? o) (if (vector? o)
(str/join ", " (map encode-child o)) (str/join ", " (map encode-string-child o))
o))] o))]
{:pred pred {:pred pred
@@ -628,8 +567,8 @@
:description "Set of Strings" :description "Set of Strings"
:error/message "should be a set of strings" :error/message "should be a set of strings"
:gen/gen (-> kind sg/generator sg/set) :gen/gen (-> kind sg/generator sg/set)
:decode/string decode :decode/string decode-string
:decode/json decode :decode/json decode-json
:encode/string encode-string :encode/string encode-string
::oapi/type "array" ::oapi/type "array"
::oapi/format "set" ::oapi/format "set"
@@ -646,7 +585,7 @@
:gen/gen (-> :string sg/generator sg/set) :gen/gen (-> :string sg/generator sg/set)
:decode/string (fn [v] :decode/string (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)] (let [v (if (string? v) (str/split v #"[\s,]+") v)]
(into #{} non-empty-strings-xf v))) (into #{} xf:filter-word-strings v)))
::oapi/type "array" ::oapi/type "array"
::oapi/format "set" ::oapi/format "set"
::oapi/items {:type "string"} ::oapi/items {:type "string"}
@@ -662,7 +601,7 @@
:gen/gen (-> :keyword sg/generator sg/set) :gen/gen (-> :keyword sg/generator sg/set)
:decode/string (fn [v] :decode/string (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)] (let [v (if (string? v) (str/split v #"[\s,]+") v)]
(into #{} (comp non-empty-strings-xf (map keyword)) v))) (into #{} (comp xf:filter-word-strings (map keyword)) v)))
::oapi/type "array" ::oapi/type "array"
::oapi/format "set" ::oapi/format "set"
::oapi/items {:type "string" :format "keyword"} ::oapi/items {:type "string" :format "keyword"}
@@ -747,8 +686,8 @@
pred) pred)
pred (if (some? max) pred (if (some? max)
(fn [v] (fn [v]
(and (>= max v) (and (pred v)
(pred v))) (>= max v)))
pred)] pred)]
{:pred pred {:pred pred
@@ -785,8 +724,8 @@
pred) pred)
pred (if (some? max) pred (if (some? max)
(fn [v] (fn [v]
(and (>= max v) (and (pred v)
(pred v))) (>= max v)))
pred)] pred)]
{:pred pred {:pred pred
@@ -815,8 +754,8 @@
pred) pred)
pred (if (some? max) pred (if (some? max)
(fn [v] (fn [v]
(and (>= max v) (and (pred v)
(pred v))) (>= max v)))
pred) pred)
gen (sg/one-of gen (sg/one-of
@@ -1041,6 +980,12 @@
(def check-email! (def check-email!
(check-fn ::email)) (check-fn ::email))
(def check-uuid!
(check-fn ::uuid :hint "expected valid uuid instance"))
(def check-string!
(check-fn :string :hint "expected string"))
(def check-coll-of-uuid! (def check-coll-of-uuid!
(check-fn ::coll-of-uuid)) (check-fn ::coll-of-uuid))
+7 -29
View File
@@ -5,46 +5,21 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.common.schema.generators (ns app.common.schema.generators
(:refer-clojure :exclude [set subseq uuid for filter map let boolean]) (:refer-clojure :exclude [set subseq uuid filter map let boolean])
#?(:cljs (:require-macros [app.common.schema.generators])) #?(:cljs (:require-macros [app.common.schema.generators]))
(:require (:require
[app.common.schema.registry :as sr] [app.common.schema.registry :as sr]
[app.common.uri :as u] [app.common.uri :as u]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[clojure.core :as c] [clojure.core :as c]
[clojure.test.check :as tc]
[clojure.test.check.generators :as tg] [clojure.test.check.generators :as tg]
[clojure.test.check.properties :as tp]
[cuerdas.core :as str] [cuerdas.core :as str]
[malli.generator :as mg])) [malli.generator :as mg]))
(defn default-reporter-fn
[{:keys [type result] :as args}]
(case type
:complete
(prn (select-keys args [:result :num-tests :seed "time-elapsed-ms"]))
:failure
(do
(prn (select-keys args [:num-tests :seed :failed-after-ms]))
(when #?(:clj (instance? Throwable result)
:cljs (instance? js/Error result))
(throw result)))
nil))
(defmacro for
[& params]
`(tp/for-all ~@params))
(defmacro let (defmacro let
[& params] [& params]
`(tg/let ~@params)) `(tg/let ~@params))
(defn check!
[p & {:keys [num] :or {num 20} :as options}]
(tc/quick-check num p (assoc options :reporter-fn default-reporter-fn :max-size 50)))
(defn sample (defn sample
([g] ([g]
(mg/sample g {:registry sr/default-registry})) (mg/sample g {:registry sr/default-registry}))
@@ -83,6 +58,11 @@
(tg/such-that (fn [v] (>= (count v) 4)) $$ 100) (tg/such-that (fn [v] (>= (count v) 4)) $$ 100)
(tg/fmap str/lower $$))) (tg/fmap str/lower $$)))
(defn word-keyword
[]
(->> (word-string)
(tg/fmap keyword)))
(defn email (defn email
[] []
(->> (word-string) (->> (word-string)
@@ -91,7 +71,6 @@
(tg/fmap (fn [v] (tg/fmap (fn [v]
(str v "@example.net"))))) (str v "@example.net")))))
(defn uri (defn uri
[] []
(tg/let [scheme (tg/elements ["http" "https"]) (tg/let [scheme (tg/elements ["http" "https"])
@@ -103,8 +82,7 @@
(defn uuid (defn uuid
[] []
(->> tg/small-integer (tg/fmap (fn [_] (uuid/next)) (small-int)))
(tg/fmap (fn [_] (uuid/next)))))
(defn subseq (defn subseq
"Given a collection, generates \"subsequences\" which are sequences "Given a collection, generates \"subsequences\" which are sequences
+97
View File
@@ -0,0 +1,97 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.schema.test
(:refer-clojure :exclude [for])
#?(:cljs (:require-macros [app.common.schema.test]))
(:require
[app.common.exceptions :as ex]
[app.common.pprint :as pp]
[clojure.test :as ct]
[clojure.test.check :as tc]
[clojure.test.check.properties :as tp]))
(defn- get-testing-var
[]
(let [testing-vars #?(:clj ct/*testing-vars*
:cljs (:testing-vars ct/*current-env*))]
(first testing-vars)))
(defn- get-testing-sym
[var]
(let [tmeta (meta var)]
(:name tmeta)))
(defn default-reporter-fn
"Default function passed as the :reporter-fn to clojure.test.check/quick-check.
Delegates to clojure.test/report."
[{:keys [type] :as args}]
(case type
:complete
(ct/report {:type ::complete ::params args})
:trial
(ct/report {:type ::trial ::params args})
:failure
(ct/report {:type ::fail ::params args})
:shrunk
(ct/report {:type ::thrunk ::params args})
nil))
(defmethod ct/report #?(:clj ::complete :cljs [:cljs.test/default ::complete])
[{:keys [::params] :as m}]
#?(:clj (ct/inc-report-counter :pass)
:cljs (ct/inc-report-counter! :pass))
(let [tvar (get-testing-var)
tsym (get-testing-sym tvar)
time (:time-elapsed-ms params)]
(println "Generative test:" (str "'" tsym "'")
(str "(pass=TRUE, tests=" (:num-tests params) ", seed=" (:seed params) ", elapsed=" time "ms)"))))
(defmethod ct/report #?(:clj ::thrunk :cljs [:cljs.test/default ::thrunk])
[{:keys [::params] :as m}]
(let [smallest (-> params :shrunk :smallest vec)]
(println)
(println "Condition failed with the following params:")
(println)
(pp/pprint smallest)))
(defmethod ct/report #?(:clj ::trial :cljs [:cljs.test/default ::trial])
[_]
#?(:clj (ct/inc-report-counter :pass)
:cljs (ct/inc-report-counter! :pass)))
(defmethod ct/report #?(:clj ::fail :cljs [:cljs.test/default ::fail])
[{:keys [::params] :as m}]
#?(:clj (ct/inc-report-counter :fail)
:cljs (ct/inc-report-counter! :fail))
(let [tvar (get-testing-var)
tsym (get-testing-sym tvar)
res (:result params)]
(println)
(println "Generative test:" (str "'" tsym "'")
(str "(pass=FALSE, tests=" (:num-tests params) ", seed=" (:seed params) ")"))
(when (ex/exception? res)
#?(:clj (ex/print-throwable res)
:cljs (js/console.error res)))))
(defmacro for
[bindings & body]
`(tp/for-all ~bindings ~@body))
(defn check!
[p & {:keys [num] :or {num 20} :as options}]
(let [result (tc/quick-check num p (assoc options :reporter-fn default-reporter-fn :max-size 50))
pass? (:pass? result)
total-tests (:num-tests result)]
(ct/is (= num total-tests))
(ct/is (true? pass?))))
+18 -15
View File
@@ -10,6 +10,7 @@
[app.common.colors :as clr] [app.common.colors :as clr]
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
@@ -29,12 +30,12 @@
{:x 0 :y 0 :width 1 :height 1}) {:x 0 :y 0 :width 1 :height 1})
(defn- assert-valid-num [attr num] (defn- assert-valid-num [attr num]
(dm/verify! (when-not (and (d/num? num)
["%1 attribute has invalid value: %2" (d/name attr) num] (<= num max-safe-int)
(and (d/num? num) (>= num min-safe-int))
(<= num max-safe-int) (ex/raise :type :assertion
(>= num min-safe-int))) :code :data-validation
:hint (str "invalid numeric value for `" attr "`: " num)))
(cond (cond
(and (> num 0) (< num 1)) 1 (and (> num 0) (< num 1)) 1
(and (< num 0) (> num -1)) -1 (and (< num 0) (> num -1)) -1
@@ -43,19 +44,21 @@
(defn- assert-valid-pos-num (defn- assert-valid-pos-num
[attr num] [attr num]
(dm/verify! (when-not (pos? num)
["%1 attribute should be positive" (d/name attr)] (ex/raise :type :assertion
(pos? num)) :code :data-validation
:hint (str "invalid numeric value for `" attr "`: " num " (should be positive)")))
num) num)
(defn- assert-valid-blend-mode (defn- assert-valid-blend-mode
[mode] [mode]
(let [clean-value (-> mode str/trim str/lower keyword)] (let [value (-> mode str/trim str/lower keyword)]
(dm/verify!
["%1 is not a valid blend mode" clean-value] (when-not (contains? cts/blend-modes value)
(contains? cts/blend-modes clean-value)) (ex/raise :type :assertion
clean-value)) :code :data-validation
:hint (str "unexpected blend mode: " value)))
value))
(defn- svg-dimensions (defn- svg-dimensions
[{:keys [attrs] :as data}] [{:keys [attrs] :as data}]
+10
View File
@@ -78,6 +78,12 @@
(def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs)) (def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs))
(def text-style-attrs
(d/concat-vec root-attrs paragraph-attrs text-node-attrs))
(def default-root-attrs
{:vertical-align "top"})
(def default-text-attrs (def default-text-attrs
{:typography-ref-file nil {:typography-ref-file nil
:typography-ref-id nil :typography-ref-id nil
@@ -92,9 +98,13 @@
:text-transform "none" :text-transform "none"
:text-align "left" :text-align "left"
:text-decoration "none" :text-decoration "none"
:text-direction "ltr"
:fills [{:fill-color clr/black :fills [{:fill-color clr/black
:fill-opacity 1}]}) :fill-opacity 1}]})
(def default-attrs
(merge default-root-attrs default-text-attrs))
(def typography-fields (def typography-fields
[:font-id [:font-id
:font-family :font-family
+8 -2
View File
@@ -12,7 +12,7 @@
[app.common.uri :as uri] [app.common.uri :as uri]
[cognitect.transit :as t] [cognitect.transit :as t]
[lambdaisland.uri :as luri] [lambdaisland.uri :as luri]
[linked.core :as lk] [linked.map :as lkm]
[linked.set :as lks]) [linked.set :as lks])
#?(:clj #?(:clj
(:import (:import
@@ -24,6 +24,7 @@
java.time.Instant java.time.Instant
java.time.OffsetDateTime java.time.OffsetDateTime
lambdaisland.uri.URI lambdaisland.uri.URI
linked.map.LinkedMap
linked.set.LinkedSet))) linked.set.LinkedSet)))
(def write-handlers (atom nil)) (def write-handlers (atom nil))
@@ -118,10 +119,15 @@
{:id "u" {:id "u"
:rfn parse-uuid}) :rfn parse-uuid})
{:id "ordered-map"
:class #?(:clj LinkedMap :cljs lkm/LinkedMap)
:wfn vec
:rfn #(into lkm/empty-linked-map %)}
{:id "ordered-set" {:id "ordered-set"
:class #?(:clj LinkedSet :cljs lks/LinkedSet) :class #?(:clj LinkedSet :cljs lks/LinkedSet)
:wfn vec :wfn vec
:rfn #(into (lk/set) %)} :rfn #(into lks/empty-linked-set %)}
{:id "duration" {:id "duration"
:class #?(:clj Duration :cljs lxn/Duration) :class #?(:clj Duration :cljs lxn/Duration)
+30 -19
View File
@@ -56,8 +56,8 @@
(def schema:image-color (def schema:image-color
[:map {:title "ImageColor"} [:map {:title "ImageColor"}
[:name {:optional true} :string] [:name {:optional true} :string]
[:width :int] [:width ::sm/int]
[:height :int] [:height ::sm/int]
[:mtype {:optional true} [:maybe :string]] [:mtype {:optional true} [:maybe :string]]
[:id ::sm/uuid] [:id ::sm/uuid]
[:keep-aspect-ratio {:optional true} :boolean]]) [:keep-aspect-ratio {:optional true} :boolean]])
@@ -80,21 +80,23 @@
[:opacity {:optional true} [:maybe ::sm/safe-number]] [:opacity {:optional true} [:maybe ::sm/safe-number]]
[:offset ::sm/safe-number]]]]]) [:offset ::sm/safe-number]]]]])
(def schema:color-attrs
[:map {:title "ColorAttrs"}
[:id {:optional true} ::sm/uuid]
[:name {:optional true} :string]
[:path {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :string]]
[:color {:optional true} [:maybe ::rgb-color]]
[:opacity {:optional true} [:maybe ::sm/safe-number]]
[:modified-at {:optional true} ::sm/inst]
[:ref-id {:optional true} ::sm/uuid]
[:ref-file {:optional true} ::sm/uuid]
[:gradient {:optional true} [:maybe schema:gradient]]
[:image {:optional true} [:maybe schema:image-color]]
[:plugin-data {:optional true} ::ctpg/plugin-data]])
(def schema:color (def schema:color
[:and [:and schema:color-attrs
[:map {:title "Color"}
[:id {:optional true} ::sm/uuid]
[:name {:optional true} :string]
[:path {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :string]]
[:color {:optional true} [:maybe ::rgb-color]]
[:opacity {:optional true} [:maybe ::sm/safe-number]]
[:modified-at {:optional true} ::sm/inst]
[:ref-id {:optional true} ::sm/uuid]
[:ref-file {:optional true} ::sm/uuid]
[:gradient {:optional true} [:maybe schema:gradient]]
[:image {:optional true} [:maybe schema:image-color]]
[:plugin-data {:optional true} ::ctpg/plugin-data]]
[::sm/contains-any {:strict true} [:color :gradient :image]]]) [::sm/contains-any {:strict true} [:color :gradient :image]]])
(def schema:recent-color (def schema:recent-color
@@ -107,14 +109,14 @@
[::sm/contains-any {:strict true} [:color :gradient :image]]]) [::sm/contains-any {:strict true} [:color :gradient :image]]])
(sm/register! ::rgb-color type:rgb-color) (sm/register! ::rgb-color type:rgb-color)
(sm/register! ::color schema:color) (sm/register! ::color schema:color)
(sm/register! ::gradient schema:gradient) (sm/register! ::gradient schema:gradient)
(sm/register! ::image-color schema:image-color) (sm/register! ::image-color schema:image-color)
(sm/register! ::recent-color schema:recent-color) (sm/register! ::recent-color schema:recent-color)
(sm/register! ::color-attrs schema:color-attrs)
(def check-color! (def check-color!
(sm/check-fn schema:color)) (sm/check-fn schema:color :hint "expected valid color struct"))
(def check-recent-color! (def check-recent-color!
(sm/check-fn schema:recent-color)) (sm/check-fn schema:recent-color))
@@ -392,13 +394,22 @@
(process-shape-colors shape sync-color))) (process-shape-colors shape sync-color)))
(defn eq-recent-color? (defn- eq-recent-color?
[c1 c2] [c1 c2]
(or (= c1 c2) (or (= c1 c2)
(and (some? (:color c1)) (and (some? (:color c1))
(some? (:color c2)) (some? (:color c2))
(= (:color c1) (:color c2))))) (= (:color c1) (:color c2)))))
(defn add-recent-color
"Moves the color to the top of the list and then truncates up to 15"
[state file-id color]
(update state file-id (fn [colors]
(let [colors (d/removev (partial eq-recent-color? color) colors)
colors (conj colors color)]
(cond-> colors
(> (count colors) 15)
(subvec 1))))))
(defn stroke->color-att (defn stroke->color-att
[stroke file-id shared-libs] [stroke file-id shared-libs]
+23 -2
View File
@@ -6,6 +6,7 @@
(ns app.common.types.grid (ns app.common.types.grid
(:require (:require
[app.common.colors :as clr]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.color :as ctc])) [app.common.types.color :as ctc]))
@@ -54,7 +55,7 @@
[:display :boolean] [:display :boolean]
[:params schema:square-params]]]]) [:params schema:square-params]]]])
(def schema:saved-grids (def schema:default-grids
[:map {:title "PageGrid"} [:map {:title "PageGrid"}
[:square {:optional true} ::square-params] [:square {:optional true} ::square-params]
[:row {:optional true} ::column-params] [:row {:optional true} ::column-params]
@@ -63,4 +64,24 @@
(sm/register! ::square-params schema:square-params) (sm/register! ::square-params schema:square-params)
(sm/register! ::column-params schema:column-params) (sm/register! ::column-params schema:column-params)
(sm/register! ::grid schema:grid) (sm/register! ::grid schema:grid)
(sm/register! ::saved-grids schema:saved-grids) (sm/register! ::default-grids schema:default-grids)
(def ^:private default-square-params
{:size 16
:color {:color clr/info
:opacity 0.4}})
(def ^:private default-layout-params
{:size 12
:type :stretch
:item-length nil
:gutter 8
:margin 0
:color {:color clr/default-layout
:opacity 0.1}})
(def default-grid-params
{:square default-square-params
:column default-layout-params
:row default-layout-params})
+33 -33
View File
@@ -7,6 +7,7 @@
(ns app.common.types.page (ns app.common.types.page
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.geom.point :as-alias gpt]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.color :as-alias ctc] [app.common.types.color :as-alias ctc]
[app.common.types.grid :as ctg] [app.common.types.grid :as ctg]
@@ -24,38 +25,56 @@
[:name :string] [:name :string]
[:starting-frame ::sm/uuid]]) [:starting-frame ::sm/uuid]])
(def schema:flows
[:map-of {:gen/max 2} ::sm/uuid schema:flow])
(def schema:guide (def schema:guide
[:map {:title "Guide"} [:map {:title "Guide"}
[:id ::sm/uuid] [:id ::sm/uuid]
[:axis [::sm/one-of #{:x :y}]] [:axis [::sm/one-of #{:x :y}]]
[:position ::sm/safe-number] [:position ::sm/safe-number]
;; FIXME: remove maybe?
[:frame-id {:optional true} [:maybe ::sm/uuid]]]) [:frame-id {:optional true} [:maybe ::sm/uuid]]])
(def schema:guides
[:map-of {:gen/max 2} ::sm/uuid schema:guide])
(def schema:objects
[:map-of {:gen/max 5} ::sm/uuid ::cts/shape])
(def schema:comment-thread-position
[:map {:title "CommentThreadPosition"}
[:frame-id ::sm/uuid]
[:position ::gpt/point]])
(def schema:page (def schema:page
[:map {:title "FilePage"} [:map {:title "FilePage"}
[:id ::sm/uuid] [:id ::sm/uuid]
[:name :string] [:name :string]
[:objects [:objects schema:objects]
[:map-of {:gen/max 5} ::sm/uuid ::cts/shape]] [:default-grids {:optional true} ::ctg/default-grids]
[:flows {:optional true} schema:flows]
[:guides {:optional true} schema:guides]
[:plugin-data {:optional true} ::ctpg/plugin-data]
[:background {:optional true} ::ctc/rgb-color]
[:comment-thread-positions {:optional true}
[:map-of ::sm/uuid schema:comment-thread-position]]
[:options [:options
[:map {:title "PageOptions"} ;; DEPERECATED: remove after 2.3 release
[:background {:optional true} ::ctc/rgb-color] [:map {:title "PageOptions"}]]])
[:saved-grids {:optional true} ::ctg/saved-grids]
[:flows {:optional true}
[:vector {:gen/max 2} schema:flow]]
[:guides {:optional true}
[:map-of {:gen/max 2} ::sm/uuid schema:guide]]
[:plugin-data {:optional true} ::ctpg/plugin-data]]]])
(sm/register! ::page schema:page) (sm/register! ::page schema:page)
(sm/register! ::guide schema:guide) (sm/register! ::guide schema:guide)
(sm/register! ::flow schema:flow) (sm/register! ::flow schema:flow)
(def check-page-guide! (def valid-guide?
(sm/check-fn ::guide)) (sm/lazy-validator schema:guide))
;; FIXME: convert to validator
(def check-page! (def check-page!
(sm/check-fn ::page)) (sm/check-fn schema:page))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INIT & HELPERS ;; INIT & HELPERS
@@ -80,25 +99,6 @@
(assoc :id (or id (uuid/next))) (assoc :id (or id (uuid/next)))
(assoc :name (or name "Page 1")))) (assoc :name (or name "Page 1"))))
;; --- Helpers for flow
(defn rename-flow
[flow name]
(assoc flow :name name))
(defn add-flow
[flows flow]
(conj (or flows []) flow))
(defn remove-flow
[flows flow-id]
(d/removev #(= (:id %) flow-id) flows))
(defn update-flow
[flows flow-id update-fn]
(let [index (d/index-of-pred flows #(= (:id %) flow-id))]
(update flows index update-fn)))
(defn get-frame-flow (defn get-frame-flow
[flows frame-id] [flows frame-id]
(d/seek #(= (:starting-frame %) frame-id) flows)) (d/seek #(= (:starting-frame %) frame-id) (vals flows)))
+22
View File
@@ -29,3 +29,25 @@
schema:string]]) schema:string]])
(sm/register! ::plugin-data schema:plugin-data) (sm/register! ::plugin-data schema:plugin-data)
(def ^:private schema:registry-entry
[:map
[:plugin-id :string]
[:name :string]
[:description {:optional true} :string]
[:host :string]
[:code :string]
[:icon {:optional true} :string]
[:permissions [:set :string]]])
(def schema:plugin-registry
[:map
[:ids [:vector :string]]
[:data
[:map-of {:gen/max 5}
:string
schema:registry-entry]]])
(sm/register! ::plugin-registry schema:plugin-registry)
(sm/register! ::registry-entry schema:registry-entry)
+13 -3
View File
@@ -125,6 +125,9 @@
(sm/register! ::stroke schema:stroke) (sm/register! ::stroke schema:stroke)
(def check-stroke!
(sm/check-fn schema:stroke))
(def schema:shape-base-attrs (def schema:shape-base-attrs
[:map {:title "ShapeMinimalRecord"} [:map {:title "ShapeMinimalRecord"}
[:id ::sm/uuid] [:id ::sm/uuid]
@@ -221,8 +224,8 @@
[:map {:title "ImageAttrs"} [:map {:title "ImageAttrs"}
[:metadata [:metadata
[:map [:map
[:width {:gen/gen (sg/small-int :min 1)} :int] [:width {:gen/gen (sg/small-int :min 1)} ::sm/int]
[:height {:gen/gen (sg/small-int :min 1)} :int] [:height {:gen/gen (sg/small-int :min 1)} ::sm/int]
[:mtype {:optional true [:mtype {:optional true
:gen/gen (sg/elements ["image/jpeg" :gen/gen (sg/elements ["image/jpeg"
"image/png"])} "image/png"])}
@@ -352,7 +355,14 @@
(sm/check-fn schema:shape-attrs)) (sm/check-fn schema:shape-attrs))
(def check-shape! (def check-shape!
(sm/check-fn schema:shape)) (sm/check-fn schema:shape
:hint "expected valid shape"))
(def valid-shape?
(sm/lazy-validator schema:shape))
(def explain-shape
(sm/lazy-explainer schema:shape))
(defn has-images? (defn has-images?
[{:keys [fills strokes]}] [{:keys [fills strokes]}]
@@ -1622,13 +1622,17 @@
(defn remap-grid-cells (defn remap-grid-cells
"Remaps the shapes ids inside the cells" "Remaps the shapes ids inside the cells"
[shape ids-map] [shape ids-map]
(let [do-remap-cells (let [remap-shape
(fn [id]
(get ids-map id id))
remap-cell
(fn [cell] (fn [cell]
(-> cell (-> cell
(update :shapes #(into [] (keep ids-map) %)))) (update :shapes #(into [] (keep remap-shape) %))))
shape shape
(-> shape (-> shape
(update :layout-grid-cells update-vals do-remap-cells))] (update :layout-grid-cells update-vals remap-cell))]
shape)) shape))
(defn merge-cells (defn merge-cells
@@ -27,3 +27,6 @@
[:color ::ctc/color]]) [:color ::ctc/color]])
(sm/register! ::shadow schema:shadow) (sm/register! ::shadow schema:shadow)
(def check-shadow!
(sm/check-fn schema:shadow))
@@ -0,0 +1,864 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.files-changes-test
(:require
[app.common.features :as ffeat]
[app.common.files.changes :as ch]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.schema.test :as smt]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.pprint :refer [pprint]]
[clojure.test :as t]
[common-tests.types.shape-decode-encode-test :refer [json-roundtrip]]))
(defn- make-file-data
[file-id page-id]
(binding [ffeat/*current* #{"components/v2"}]
(ctf/make-file-data file-id page-id)))
(t/deftest add-obj
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)
id-a (uuid/custom 2 1)
id-b (uuid/custom 2 2)
id-c (uuid/custom 2 3)]
(t/testing "Adds single object"
(let [chg {:type :add-obj
:page-id page-id
:id id-a
:parent-id uuid/zero
:frame-id uuid/zero
:obj (cts/setup-shape
{:frame-id uuid/zero
:parent-id uuid/zero
:id id-a
:type :rect
:name "rect"})}
res (ch/process-changes data [chg])]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= 2 (count objects)))
(t/is (= (:obj chg) (get objects id-a)))
(t/is (= [id-a] (get-in objects [uuid/zero :shapes]))))))
(t/testing "Adds several objects with different indexes"
(let [chg (fn [id index]
{:type :add-obj
:page-id page-id
:id id
:frame-id uuid/zero
:index index
:obj (cts/setup-shape
{:id id
:frame-id uuid/zero
:type :rect
:name (str id)})})
res (ch/process-changes data [(chg id-a 0)
(chg id-b 0)
(chg id-c 1)])]
;; (clojure.pprint/pprint data)
;; (clojure.pprint/pprint res)
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= 4 (count objects)))
(t/is (not (nil? (get objects id-a))))
(t/is (not (nil? (get objects id-b))))
(t/is (not (nil? (get objects id-c))))
(t/is (= [id-b id-c id-a] (get-in objects [uuid/zero :shapes]))))))))
(t/deftest mod-obj
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(t/testing "simple mod-obj"
(let [chg {:type :mod-obj
:page-id page-id
:id uuid/zero
:operations [{:type :set
:attr :name
:val "foobar"}]}
res (ch/process-changes data [chg])]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= "foobar" (get-in objects [uuid/zero :name]))))))
(t/testing "mod-obj for not existing shape"
(let [chg {:type :mod-obj
:page-id page-id
:id (uuid/next)
:operations [{:type :set
:attr :name
:val "foobar"}]}
res (ch/process-changes data [chg])]
(t/is (= res data))))))
(t/deftest del-obj
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
id (uuid/custom 2 1)
data (make-file-data file-id page-id)
data (-> data
(assoc-in [:pages-index page-id :objects uuid/zero :shapes] [id])
(assoc-in [:pages-index page-id :objects id]
{:id id
:frame-id uuid/zero
:type :rect
:name "rect"}))]
(t/testing "delete"
(let [chg {:type :del-obj
:page-id page-id
:id id}
res (ch/process-changes data [chg])]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= 1 (count objects)))
(t/is (= [] (get-in objects [uuid/zero :shapes]))))))
(t/testing "delete idempotency"
(let [chg {:type :del-obj
:page-id page-id
:id id}
res1 (ch/process-changes data [chg])
res2 (ch/process-changes res1 [chg])]
(t/is (= res1 res2))
(let [objects (get-in res1 [:pages-index page-id :objects])]
(t/is (= 1 (count objects)))
(t/is (= [] (get-in objects [uuid/zero :shapes]))))))))
(t/deftest move-objects-1
(let [frame-a-id (uuid/custom 0 1)
frame-b-id (uuid/custom 0 2)
group-a-id (uuid/custom 0 3)
group-b-id (uuid/custom 0 4)
rect-a-id (uuid/custom 0 5)
rect-b-id (uuid/custom 0 6)
rect-c-id (uuid/custom 0 7)
rect-d-id (uuid/custom 0 8)
rect-e-id (uuid/custom 0 9)
file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)
data (update-in data [:pages-index page-id :objects]
#(-> %
(assoc-in [uuid/zero :shapes] [frame-a-id frame-b-id])
(assoc-in [frame-a-id]
(cts/setup-shape
{:id frame-a-id
:parent-id uuid/zero
:frame-id uuid/zero
:name "Frame a"
:shapes [group-a-id group-b-id rect-e-id]
:type :frame}))
(assoc-in [frame-b-id]
(cts/setup-shape
{:id frame-b-id
:parent-id uuid/zero
:frame-id uuid/zero
:name "Frame b"
:shapes []
:type :frame}))
;; Groups
(assoc-in [group-a-id]
(cts/setup-shape
{:id group-a-id
:name "Group A"
:type :group
:parent-id frame-a-id
:frame-id frame-a-id
:shapes [rect-a-id rect-b-id rect-c-id]}))
(assoc-in [group-b-id]
(cts/setup-shape
{:id group-b-id
:name "Group B"
:type :group
:parent-id frame-a-id
:frame-id frame-a-id
:shapes [rect-d-id]}))
;; Shapes
(assoc-in [rect-a-id]
(cts/setup-shape
{:id rect-a-id
:name "Rect A"
:type :rect
:parent-id group-a-id
:frame-id frame-a-id}))
(assoc-in [rect-b-id]
(cts/setup-shape
{:id rect-b-id
:name "Rect B"
:type :rect
:parent-id group-a-id
:frame-id frame-a-id}))
(assoc-in [rect-c-id]
(cts/setup-shape
{:id rect-c-id
:name "Rect C"
:type :rect
:parent-id group-a-id
:frame-id frame-a-id}))
(assoc-in [rect-d-id]
(cts/setup-shape
{:id rect-d-id
:name "Rect D"
:parent-id group-b-id
:type :rect
:frame-id frame-a-id}))
(assoc-in [rect-e-id]
(cts/setup-shape
{:id rect-e-id
:name "Rect E"
:type :rect
:parent-id frame-a-id
:frame-id frame-a-id}))))]
(t/testing "Create new group an add objects from the same group"
(let [new-group-id (uuid/next)
changes [{:type :add-obj
:page-id page-id
:id new-group-id
:frame-id frame-a-id
:obj (cts/setup-shape
{:id new-group-id
:type :group
:frame-id frame-a-id
:name "Group C"})}
{:type :mov-objects
:page-id page-id
:parent-id new-group-id
:shapes [rect-b-id rect-c-id]}]
res (ch/process-changes data changes)]
;; (clojure.pprint/pprint data)
;; (println "===============")
;; (clojure.pprint/pprint res)
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id group-b-id rect-e-id new-group-id]
(get-in objects [frame-a-id :shapes])))
(t/is (= [rect-b-id rect-c-id]
(get-in objects [new-group-id :shapes])))
(t/is (= [rect-a-id]
(get-in objects [group-a-id :shapes]))))))
(t/testing "Move elements to an existing group at index"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-b-id
:index 0
:shapes [rect-a-id rect-c-id]}]
res (ch/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id group-b-id rect-e-id]
(get-in objects [frame-a-id :shapes])))
(t/is (= [rect-b-id]
(get-in objects [group-a-id :shapes])))
(t/is (= [rect-a-id rect-c-id rect-d-id]
(get-in objects [group-b-id :shapes]))))))
(t/testing "Move elements from group and frame to an existing group at index"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-b-id
:index 0
:shapes [rect-a-id rect-e-id]}]
res (ch/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id group-b-id]
(get-in objects [frame-a-id :shapes])))
(t/is (= [rect-b-id rect-c-id]
(get-in objects [group-a-id :shapes])))
(t/is (= [rect-a-id rect-e-id rect-d-id]
(get-in objects [group-b-id :shapes]))))))
(t/testing "Move elements from several groups"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-b-id
:index 0
:shapes [rect-a-id rect-e-id]}]
res (ch/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id group-b-id]
(get-in objects [frame-a-id :shapes])))
(t/is (= [rect-b-id rect-c-id]
(get-in objects [group-a-id :shapes])))
(t/is (= [rect-a-id rect-e-id rect-d-id]
(get-in objects [group-b-id :shapes]))))))
(t/testing "Move all elements from a group"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-a-id
:shapes [rect-d-id]}]
res (ch/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id group-b-id rect-e-id]
(get-in objects [frame-a-id :shapes])))
(t/is (empty? (get-in objects [group-b-id :shapes]))))))
(t/testing "Move elements to a group with different frame"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id frame-b-id
:shapes [group-a-id]}]
res (ch/process-changes data changes)]
;; (pprint (get-in data [:pages-index page-id :objects]))
;; (println "==========")
;; (pprint (get-in res [:pages-index page-id :objects]))
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-b-id rect-e-id] (get-in objects [frame-a-id :shapes])))
(t/is (= [group-a-id] (get-in objects [frame-b-id :shapes])))
(t/is (= frame-b-id (get-in objects [group-a-id :frame-id])))
(t/is (= frame-b-id (get-in objects [rect-a-id :frame-id])))
(t/is (= frame-b-id (get-in objects [rect-b-id :frame-id])))
(t/is (= frame-b-id (get-in objects [rect-c-id :frame-id]))))))
(t/testing "Move elements to frame zero"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id uuid/zero
:shapes [group-a-id]
:index 0}]
res (ch/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])]
;; (pprint (get-in data [:objects uuid/zero]))
;; (println "==========")
;; (pprint (get-in objects [uuid/zero]))
(t/is (= [group-a-id frame-a-id frame-b-id]
(get-in objects [uuid/zero :shapes]))))))
(t/testing "Don't allow to move inside self"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-a-id
:shapes [group-a-id]}]
res (ch/process-changes data changes)]
(t/is (= data res))))))
(t/deftest mov-objects-regression-1
(let [shape-1-id (uuid/custom 2 1)
shape-2-id (uuid/custom 2 2)
shape-3-id (uuid/custom 2 3)
frame-id (uuid/custom 1 1)
file-id (uuid/custom 4 4)
page-id (uuid/custom 0 1)
changes [{:type :add-obj
:id frame-id
:page-id page-id
:parent-id uuid/zero
:frame-id uuid/zero
:obj (cts/setup-shape
{:type :frame
:name "Frame"})}
{:type :add-obj
:page-id page-id
:frame-id frame-id
:parent-id frame-id
:id shape-1-id
:obj (cts/setup-shape
{:type :rect
:name "Shape 1"})}
{:type :add-obj
:page-id page-id
:id shape-2-id
:parent-id uuid/zero
:frame-id uuid/zero
:obj (cts/setup-shape
{:type :rect
:name "Shape 2"})}
{:type :add-obj
:page-id page-id
:id shape-3-id
:parent-id uuid/zero
:frame-id uuid/zero
:obj (cts/setup-shape
{:type :rect
:name "Shape 3"})}]
data (make-file-data file-id page-id)
data (ch/process-changes data changes)]
(t/testing "preserve order on multiple shape mov 1"
(let [changes [{:type :mov-objects
:page-id page-id
:shapes [shape-2-id shape-3-id]
:parent-id uuid/zero
:index 0}]
res (ch/process-changes data changes)]
;; (println "==> BEFORE")
;; (pprint (get-in data [:objects]))
;; (println "==> AFTER")
;; (pprint (get-in res [:objects]))
(t/is (= [frame-id shape-2-id shape-3-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-2-id shape-3-id frame-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))))
(t/testing "preserve order on multiple shape mov 1"
(let [changes [{:type :mov-objects
:page-id page-id
:shapes [shape-3-id shape-2-id]
:parent-id uuid/zero
:index 0}]
res (ch/process-changes data changes)]
;; (println "==> BEFORE")
;; (pprint (get-in data [:objects]))
;; (println "==> AFTER")
;; (pprint (get-in res [:objects]))
(t/is (= [frame-id shape-2-id shape-3-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-3-id shape-2-id frame-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))))
(t/testing "move inside->outside-inside"
(let [changes [{:type :mov-objects
:page-id page-id
:shapes [shape-2-id]
:parent-id frame-id}
{:type :mov-objects
:page-id page-id
:shapes [shape-2-id]
:parent-id uuid/zero}]
res (ch/process-changes data changes)]
(t/is (= (get-in res [:pages-index page-id :objects shape-1-id :frame-id])
(get-in data [:pages-index page-id :objects shape-1-id :frame-id])))
(t/is (= (get-in res [:pages-index page-id :objects shape-2-id :frame-id])
(get-in data [:pages-index page-id :objects shape-2-id :frame-id])))))))
(t/deftest move-objects-2
(let [shape-1-id (uuid/custom 1 1)
shape-2-id (uuid/custom 1 2)
shape-3-id (uuid/custom 1 3)
shape-4-id (uuid/custom 1 4)
group-1-id (uuid/custom 1 5)
file-id (uuid/custom 1 6)
page-id (uuid/custom 0 1)
changes [{:type :add-obj
:page-id page-id
:id shape-1-id
:frame-id uuid/zero
:obj (cts/setup-shape
{:id shape-1-id
:type :rect
:name "Shape a"})}
{:type :add-obj
:page-id page-id
:id shape-2-id
:frame-id uuid/zero
:obj (cts/setup-shape
{:id shape-2-id
:type :rect
:name "Shape b"})}
{:type :add-obj
:page-id page-id
:id shape-3-id
:frame-id uuid/zero
:obj (cts/setup-shape
{:id shape-3-id
:type :rect
:name "Shape c"})}
{:type :add-obj
:page-id page-id
:id shape-4-id
:frame-id uuid/zero
:obj (cts/setup-shape
{:id shape-4-id
:type :rect
:name "Shape d"})}
{:type :add-obj
:page-id page-id
:id group-1-id
:frame-id uuid/zero
:obj (cts/setup-shape
{:id group-1-id
:type :group
:name "Group"})}
{:type :mov-objects
:page-id page-id
:parent-id group-1-id
:shapes [shape-1-id shape-2-id]}]
data (make-file-data file-id page-id)
data (ch/process-changes data changes)]
(t/testing "case 1"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id uuid/zero
:index 2
:shapes [shape-3-id]}]
res (ch/process-changes data changes)]
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; After
(t/is (= [shape-4-id shape-3-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
))
(t/testing "case 2"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-1-id
:index 2
:shapes [shape-3-id]}]
res (ch/process-changes data changes)]
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id]
(get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; After:
(t/is (= [shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id shape-3-id]
(get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
))
(t/testing "case 3"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-1-id
:index 1
:shapes [shape-3-id]}]
res (ch/process-changes data changes)]
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id]
(get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; After
(t/is (= [shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-3-id shape-2-id]
(get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
))
(t/testing "case 4"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id group-1-id
:index 0
:shapes [shape-3-id]}]
res (ch/process-changes data changes)]
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id]
(get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; After
(t/is (= [shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-3-id shape-1-id shape-2-id]
(get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
))
(t/testing "case 5"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id uuid/zero
:index 0
:shapes [shape-2-id]}]
res (ch/process-changes data changes)]
;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id]
(get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; After
(t/is (= [shape-2-id shape-3-id shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id]
(get-in res [:pages-index page-id :objects group-1-id :shapes])))))
(t/testing "case 6"
(let [changes [{:type :mov-objects
:page-id page-id
:parent-id uuid/zero
:index 0
:shapes [shape-2-id shape-1-id]}]
res (ch/process-changes data changes)]
;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; Before
(t/is (= [shape-3-id shape-4-id group-1-id]
(get-in data [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (= [shape-1-id shape-2-id]
(get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; After
(t/is (= [shape-2-id shape-1-id shape-3-id shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects uuid/zero :shapes])))
(t/is (not= nil
(get-in res [:pages-index page-id :objects group-1-id])))))))
(t/deftest set-guide-json-encode-decode
(let [schema ch/schema:set-guide-change
encode (sm/encoder schema (sm/json-transformer))
decode (sm/decoder schema (sm/json-transformer))]
(smt/check!
(smt/for [data (sg/generator schema)]
(let [data-1 (encode data)
data-2 (json-roundtrip data-1)
data-3 (decode data-2)]
;; (app.common.pprint/pprint data-2)
;; (app.common.pprint/pprint data-3)
(= data data-3)))
{:num 1000})))
(t/deftest set-guide-1
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (sg/generator ch/schema:set-guide-change)]
(let [change (assoc change :page-id page-id)
result (ch/process-changes data [change])]
(= (:params change)
(get-in result [:pages-index page-id :guides (:id change)]))))
{:num 1000})))
(t/deftest set-guide-2
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (->> (sg/generator ch/schema:set-guide-change)
(sg/filter :params))]
(let [change1 (assoc change :page-id page-id)
result1 (ch/process-changes data [change1])
change2 (assoc change1 :params nil)
result2 (ch/process-changes result1 [change2])]
(and (some? (:params change1))
(= (:params change1)
(get-in result1 [:pages-index page-id :guides (:id change1)]))
(nil? (:params change2))
(nil? (get-in result2 [:pages-index page-id :guides])))))
{:num 1000})))
(t/deftest set-plugin-data-json-encode-decode
(let [schema ch/schema:set-plugin-data-change
encode (sm/encoder schema (sm/json-transformer))
decode (sm/decoder schema (sm/json-transformer))]
(smt/check!
(smt/for [data (sg/generator schema)]
(let [data-1 (encode data)
data-2 (json-roundtrip data-1)
data-3 (decode data-2)]
(= data data-3)))
{:num 1000})))
(t/deftest set-plugin-data-gen-and-validate
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (sg/generator ch/schema:set-plugin-data-change)]
(sm/validate ch/schema:set-plugin-data-change change))
{:num 1000})))
(t/deftest set-flow-json-encode-decode
(let [schema ch/schema:set-flow-change
encode (sm/encoder schema (sm/json-transformer))
decode (sm/decoder schema (sm/json-transformer))]
(smt/check!
(smt/for [data (sg/generator schema)]
(let [data-1 (encode data)
data-2 (json-roundtrip data-1)
data-3 (decode data-2)]
;; (app.common.pprint/pprint data-2)
;; (app.common.pprint/pprint data-3)
(= data data-3)))
{:num 1000})))
(t/deftest set-flow-1
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (sg/generator ch/schema:set-flow-change)]
(let [change (assoc change :page-id page-id)
result (ch/process-changes data [change])]
(= (:params change)
(get-in result [:pages-index page-id :flows (:id change)]))))
{:num 1000})))
(t/deftest set-flow-2
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (->> (sg/generator ch/schema:set-flow-change)
(sg/filter :params))]
(let [change1 (assoc change :page-id page-id)
result1 (ch/process-changes data [change1])
change2 (assoc change1 :params nil)
result2 (ch/process-changes result1 [change2])]
(and (some? (:params change1))
(= (:params change1)
(get-in result1 [:pages-index page-id :flows (:id change1)]))
(nil? (:params change2))
(nil? (get-in result2 [:pages-index page-id :flows])))))
{:num 1000})))
(t/deftest set-default-grid-json-encode-decode
(let [schema ch/schema:set-default-grid-change
encode (sm/encoder schema (sm/json-transformer))
decode (sm/decoder schema (sm/json-transformer))]
(smt/check!
(smt/for [data (sg/generator schema)]
(let [data-1 (encode data)
data-2 (json-roundtrip data-1)
data-3 (decode data-2)]
;; (println "==========")
;; (app.common.pprint/pprint data-2)
;; (app.common.pprint/pprint data-3)
;; (println "==========")
(= data data-3)))
{:num 1000})))
(t/deftest set-default-grid-1
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (sg/generator ch/schema:set-default-grid-change)]
(let [change (assoc change :page-id page-id)
result (ch/process-changes data [change])]
;; (app.common.pprint/pprint change)
(= (:params change)
(get-in result [:pages-index page-id :default-grids (:grid-type change)]))))
{:num 1000})))
(t/deftest set-default-grid-2
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(smt/check!
(smt/for [change (->> (sg/generator ch/schema:set-default-grid-change)
(sg/filter :params))]
(let [change1 (assoc change :page-id page-id)
result1 (ch/process-changes data [change1])
change2 (assoc change1 :params nil)
result2 (ch/process-changes result1 [change2])]
;; (app.common.pprint/pprint change1)
(and (some? (:params change1))
(= (:params change1)
(get-in result1 [:pages-index page-id :default-grids (:grid-type change1)]))
(nil? (:params change2))
(nil? (get-in result2 [:pages-index page-id :default-grids])))))
{:num 1000})))
@@ -289,42 +289,3 @@
(t/is (= (:fill-opacity fill') 1)) (t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil)) (t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') #{:fill-group})))) (t/is (= (:touched copy2-child') #{:fill-group}))))
(t/deftest test-touched-when-changing-lower
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1
:main1-root
:main1-child
:component2
:main2-root
:main2-nested-head
:copy2-root
:copy2-root-params {:children-labels [:copy2-child]}))
page (thf/current-page file)
copy2-child (ths/get-shape file :copy2-child)
;; ==== Action
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy2-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file' (thf/apply-changes file changes)
;; ==== Get
copy2-root' (ths/get-shape file' :copy2-root)
copy2-child' (ths/get-shape file' :copy2-child)
fills' (:fills copy2-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy2-root'))
(t/is (some? copy2-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') #{:fill-group}))))
@@ -1,75 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.logic.hide-in-viewer-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.shape.interactions :as ctsi]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest test-remove-show-in-view-mode-delete-interactions
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-frame :frame-dest)
(tho/add-frame :frame-origin)
(ths/add-interaction :frame-origin :frame-dest))
frame-origin (ths/get-shape file :frame-origin)
page (thf/current-page file)
;; ==== Action
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/update-shapes [(:id frame-origin)] #(cls/change-show-in-viewer % true)))
file' (thf/apply-changes file changes)
;; ==== Get
frame-origin' (ths/get-shape file' :frame-origin)]
;; ==== Check
(t/is (some? (:interactions frame-origin)))
(t/is (nil? (:interactions frame-origin')))))
(t/deftest test-add-new-interaction-updates-show-in-view-mode
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-frame :frame-dest :hide-in-viewer true)
(tho/add-frame :frame-origin :hide-in-viewer true))
frame-dest (ths/get-shape file :frame-dest)
frame-origin (ths/get-shape file :frame-origin)
page (thf/current-page file)
;; ==== Action
new-interaction (-> ctsi/default-interaction
(ctsi/set-destination (:id frame-dest))
(assoc :position-relative-to (:id frame-dest)))
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/update-shapes [(:id frame-origin)] #(cls/add-new-interaction % new-interaction)))
file' (thf/apply-changes file changes)
;; ==== Get
frame-origin' (ths/get-shape file' :frame-origin)]
;; ==== Check
(t/is (true? (:hide-in-viewer frame-origin)))
(t/is (nil? (:hide-in-viewer frame-origin')))))
-740
View File
@@ -1,740 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.pages-test
(:require
[app.common.features :as ffeat]
[app.common.files.changes :as ch]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.pprint :refer [pprint]]
[clojure.test :as t]))
(defn- make-file-data
[file-id page-id]
(binding [ffeat/*current* #{"components/v2"}]
(ctf/make-file-data file-id page-id)))
(t/deftest process-change-set-option
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(t/testing "Sets option single"
(let [chg {:type :set-option
:page-id page-id
:option :test
:value "test"}
res (ch/process-changes data [chg])]
(t/is (= "test" (get-in res [:pages-index page-id :options :test])))))
(t/testing "Sets option nested"
(let [chgs [{:type :set-option
:page-id page-id
:option [:values :test :a]
:value "a"}
{:type :set-option
:page-id page-id
:option [:values :test :b]
:value "b"}]
res (ch/process-changes data chgs)]
(t/is (= {:a "a" :b "b"}
(get-in res [:pages-index page-id :options :values :test])))))
(t/testing "Remove option single"
(let [chg {:type :set-option
:page-id page-id
:option :test
:value nil}
res (ch/process-changes data [chg])]
(t/is (empty? (keys (get-in res [:pages-index page-id :options]))))))
(t/testing "Remove option nested 1"
(let [chgs [{:type :set-option
:page-id page-id
:option [:values :test :a]
:value "a"}
{:type :set-option
:page-id page-id
:option [:values :test :b]
:value "b"}
{:type :set-option
:page-id page-id
:option [:values :test]
:value nil}]
res (ch/process-changes data chgs)]
(t/is (empty? (keys (get-in res [:pages-index page-id :options]))))))
(t/testing "Remove option nested 2"
(let [chgs [{:type :set-option
:option [:values :test1 :a]
:page-id page-id
:value "a"}
{:type :set-option
:option [:values :test2 :b]
:page-id page-id
:value "b"}
{:type :set-option
:page-id page-id
:option [:values :test2]
:value nil}]
res (ch/process-changes data chgs)]
(t/is (= [:test1] (keys (get-in res [:pages-index page-id :options :values]))))))))
(t/deftest process-change-add-obj
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)
id-a (uuid/custom 2 1)
id-b (uuid/custom 2 2)
id-c (uuid/custom 2 3)]
(t/testing "Adds single object"
(let [chg {:type :add-obj
:page-id page-id
:id id-a
:parent-id uuid/zero
:frame-id uuid/zero
:obj (cts/setup-shape
{:frame-id uuid/zero
:parent-id uuid/zero
:id id-a
:type :rect
:name "rect"})}
res (ch/process-changes data [chg])]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= 2 (count objects)))
(t/is (= (:obj chg) (get objects id-a)))
(t/is (= [id-a] (get-in objects [uuid/zero :shapes]))))))
(t/testing "Adds several objects with different indexes"
(let [chg (fn [id index]
{:type :add-obj
:page-id page-id
:id id
:frame-id uuid/zero
:index index
:obj (cts/setup-shape
{:id id
:frame-id uuid/zero
:type :rect
:name (str id)})})
res (ch/process-changes data [(chg id-a 0)
(chg id-b 0)
(chg id-c 1)])]
;; (clojure.pprint/pprint data)
;; (clojure.pprint/pprint res)
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= 4 (count objects)))
(t/is (not (nil? (get objects id-a))))
(t/is (not (nil? (get objects id-b))))
(t/is (not (nil? (get objects id-c))))
(t/is (= [id-b id-c id-a] (get-in objects [uuid/zero :shapes]))))))))
(t/deftest process-change-mod-obj
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(t/testing "simple mod-obj"
(let [chg {:type :mod-obj
:page-id page-id
:id uuid/zero
:operations [{:type :set
:attr :name
:val "foobar"}]}
res (ch/process-changes data [chg])]
(let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= "foobar" (get-in objects [uuid/zero :name]))))))
(t/testing "mod-obj for not existing shape"
(let [chg {:type :mod-obj
:page-id page-id
:id (uuid/next)
:operations [{:type :set
:attr :name
:val "foobar"}]}
res (ch/process-changes data [chg])]
(t/is (= res data))))))
;; (t/deftest process-change-del-obj
;; (let [file-id (uuid/custom 2 2)
;; page-id (uuid/custom 1 1)
;; id (uuid/custom 2 1)
;; data (make-file-data file-id page-id)
;; data (-> data
;; (assoc-in [:pages-index page-id :objects uuid/zero :shapes] [id])
;; (assoc-in [:pages-index page-id :objects id]
;; {:id id
;; :frame-id uuid/zero
;; :type :rect
;; :name "rect"}))]
;; (t/testing "delete"
;; (let [chg {:type :del-obj
;; :page-id page-id
;; :id id}
;; res (ch/process-changes data [chg])]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= 1 (count objects)))
;; (t/is (= [] (get-in objects [uuid/zero :shapes]))))))
;; (t/testing "delete idempotency"
;; (let [chg {:type :del-obj
;; :page-id page-id
;; :id id}
;; res1 (ch/process-changes data [chg])
;; res2 (ch/process-changes res1 [chg])]
;; (t/is (= res1 res2))
;; (let [objects (get-in res1 [:pages-index page-id :objects])]
;; (t/is (= 1 (count objects)))
;; (t/is (= [] (get-in objects [uuid/zero :shapes]))))))))
;; (t/deftest process-change-move-objects
;; (let [frame-a-id (uuid/custom 0 1)
;; frame-b-id (uuid/custom 0 2)
;; group-a-id (uuid/custom 0 3)
;; group-b-id (uuid/custom 0 4)
;; rect-a-id (uuid/custom 0 5)
;; rect-b-id (uuid/custom 0 6)
;; rect-c-id (uuid/custom 0 7)
;; rect-d-id (uuid/custom 0 8)
;; rect-e-id (uuid/custom 0 9)
;; file-id (uuid/custom 2 2)
;; page-id (uuid/custom 1 1)
;; data (make-file-data file-id page-id)
;; data (update-in data [:pages-index page-id :objects]
;; #(-> %
;; (assoc-in [uuid/zero :shapes] [frame-a-id frame-b-id])
;; (assoc-in [frame-a-id]
;; {:id frame-a-id
;; :parent-id uuid/zero
;; :frame-id uuid/zero
;; :name "Frame a"
;; :shapes [group-a-id group-b-id rect-e-id]
;; :type :frame})
;; (assoc-in [frame-b-id]
;; {:id frame-b-id
;; :parent-id uuid/zero
;; :frame-id uuid/zero
;; :name "Frame b"
;; :shapes []
;; :type :frame})
;; ;; Groups
;; (assoc-in [group-a-id]
;; {:id group-a-id
;; :name "Group A"
;; :type :group
;; :parent-id frame-a-id
;; :frame-id frame-a-id
;; :shapes [rect-a-id rect-b-id rect-c-id]})
;; (assoc-in [group-b-id]
;; {:id group-b-id
;; :name "Group B"
;; :type :group
;; :parent-id frame-a-id
;; :frame-id frame-a-id
;; :shapes [rect-d-id]})
;; ;; Shapes
;; (assoc-in [rect-a-id]
;; {:id rect-a-id
;; :name "Rect A"
;; :type :rect
;; :parent-id group-a-id
;; :frame-id frame-a-id})
;; (assoc-in [rect-b-id]
;; {:id rect-b-id
;; :name "Rect B"
;; :type :rect
;; :parent-id group-a-id
;; :frame-id frame-a-id})
;; (assoc-in [rect-c-id]
;; {:id rect-c-id
;; :name "Rect C"
;; :type :rect
;; :parent-id group-a-id
;; :frame-id frame-a-id})
;; (assoc-in [rect-d-id]
;; {:id rect-d-id
;; :name "Rect D"
;; :parent-id group-b-id
;; :type :rect
;; :frame-id frame-a-id})
;; (assoc-in [rect-e-id]
;; {:id rect-e-id
;; :name "Rect E"
;; :type :rect
;; :parent-id frame-a-id
;; :frame-id frame-a-id})))]
;; (t/testing "Create new group an add objects from the same group"
;; (let [new-group-id (uuid/next)
;; changes [{:type :add-obj
;; :page-id page-id
;; :id new-group-id
;; :frame-id frame-a-id
;; :obj {:id new-group-id
;; :type :group
;; :frame-id frame-a-id
;; :name "Group C"}}
;; {:type :mov-objects
;; :page-id page-id
;; :parent-id new-group-id
;; :shapes [rect-b-id rect-c-id]}]
;; res (ch/process-changes data changes)]
;; ;; (clojure.pprint/pprint data)
;; ;; (println "===============")
;; ;; (clojure.pprint/pprint res)
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-a-id group-b-id rect-e-id new-group-id]
;; (get-in objects [frame-a-id :shapes])))
;; (t/is (= [rect-b-id rect-c-id]
;; (get-in objects [new-group-id :shapes])))
;; (t/is (= [rect-a-id]
;; (get-in objects [group-a-id :shapes]))))))
;; (t/testing "Move elements to an existing group at index"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-b-id
;; :index 0
;; :shapes [rect-a-id rect-c-id]}]
;; res (ch/process-changes data changes)]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-a-id group-b-id rect-e-id]
;; (get-in objects [frame-a-id :shapes])))
;; (t/is (= [rect-b-id]
;; (get-in objects [group-a-id :shapes])))
;; (t/is (= [rect-a-id rect-c-id rect-d-id]
;; (get-in objects [group-b-id :shapes]))))))
;; (t/testing "Move elements from group and frame to an existing group at index"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-b-id
;; :index 0
;; :shapes [rect-a-id rect-e-id]}]
;; res (ch/process-changes data changes)]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-a-id group-b-id]
;; (get-in objects [frame-a-id :shapes])))
;; (t/is (= [rect-b-id rect-c-id]
;; (get-in objects [group-a-id :shapes])))
;; (t/is (= [rect-a-id rect-e-id rect-d-id]
;; (get-in objects [group-b-id :shapes]))))))
;; (t/testing "Move elements from several groups"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-b-id
;; :index 0
;; :shapes [rect-a-id rect-e-id]}]
;; res (ch/process-changes data changes)]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-a-id group-b-id]
;; (get-in objects [frame-a-id :shapes])))
;; (t/is (= [rect-b-id rect-c-id]
;; (get-in objects [group-a-id :shapes])))
;; (t/is (= [rect-a-id rect-e-id rect-d-id]
;; (get-in objects [group-b-id :shapes]))))))
;; (t/testing "Move all elements from a group"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-a-id
;; :shapes [rect-d-id]}]
;; res (ch/process-changes data changes)]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-a-id group-b-id rect-e-id]
;; (get-in objects [frame-a-id :shapes])))
;; (t/is (empty? (get-in objects [group-b-id :shapes]))))))
;; (t/testing "Move elements to a group with different frame"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id frame-b-id
;; :shapes [group-a-id]}]
;; res (ch/process-changes data changes)]
;; ;; (pprint (get-in data [:pages-index page-id :objects]))
;; ;; (println "==========")
;; ;; (pprint (get-in res [:pages-index page-id :objects]))
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; (t/is (= [group-b-id rect-e-id] (get-in objects [frame-a-id :shapes])))
;; (t/is (= [group-a-id] (get-in objects [frame-b-id :shapes])))
;; (t/is (= frame-b-id (get-in objects [group-a-id :frame-id])))
;; (t/is (= frame-b-id (get-in objects [rect-a-id :frame-id])))
;; (t/is (= frame-b-id (get-in objects [rect-b-id :frame-id])))
;; (t/is (= frame-b-id (get-in objects [rect-c-id :frame-id]))))))
;; (t/testing "Move elements to frame zero"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id uuid/zero
;; :shapes [group-a-id]
;; :index 0}]
;; res (ch/process-changes data changes)]
;; (let [objects (get-in res [:pages-index page-id :objects])]
;; ;; (pprint (get-in data [:objects uuid/zero]))
;; ;; (println "==========")
;; ;; (pprint (get-in objects [uuid/zero]))
;; (t/is (= [group-a-id frame-a-id frame-b-id]
;; (get-in objects [uuid/zero :shapes]))))))
;; (t/testing "Don't allow to move inside self"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-a-id
;; :shapes [group-a-id]}]
;; res (ch/process-changes data changes)]
;; (t/is (= data res))))
;; ))
;; (t/deftest process-change-mov-objects-regression
;; (let [shape-1-id (uuid/custom 2 1)
;; shape-2-id (uuid/custom 2 2)
;; shape-3-id (uuid/custom 2 3)
;; frame-id (uuid/custom 1 1)
;; file-id (uuid/custom 4 4)
;; page-id (uuid/custom 0 1)
;; changes [{:type :add-obj
;; :id frame-id
;; :page-id page-id
;; :parent-id uuid/zero
;; :frame-id uuid/zero
;; :obj {:type :frame
;; :name "Frame"}}
;; {:type :add-obj
;; :page-id page-id
;; :frame-id frame-id
;; :parent-id frame-id
;; :id shape-1-id
;; :obj {:type :rect
;; :name "Shape 1"}}
;; {:type :add-obj
;; :page-id page-id
;; :id shape-2-id
;; :parent-id uuid/zero
;; :frame-id uuid/zero
;; :obj {:type :rect
;; :name "Shape 2"}}
;; {:type :add-obj
;; :page-id page-id
;; :id shape-3-id
;; :parent-id uuid/zero
;; :frame-id uuid/zero
;; :obj {:type :rect
;; :name "Shape 3"}}
;; ]
;; data (make-file-data file-id page-id)
;; data (ch/process-changes data changes)]
;; (t/testing "preserve order on multiple shape mov 1"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :shapes [shape-2-id shape-3-id]
;; :parent-id uuid/zero
;; :index 0}]
;; res (ch/process-changes data changes)]
;; ;; (println "==> BEFORE")
;; ;; (pprint (get-in data [:objects]))
;; ;; (println "==> AFTER")
;; ;; (pprint (get-in res [:objects]))
;; (t/is (= [frame-id shape-2-id shape-3-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-2-id shape-3-id frame-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))))
;; (t/testing "preserve order on multiple shape mov 1"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :shapes [shape-3-id shape-2-id]
;; :parent-id uuid/zero
;; :index 0}]
;; res (ch/process-changes data changes)]
;; ;; (println "==> BEFORE")
;; ;; (pprint (get-in data [:objects]))
;; ;; (println "==> AFTER")
;; ;; (pprint (get-in res [:objects]))
;; (t/is (= [frame-id shape-2-id shape-3-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-3-id shape-2-id frame-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))))
;; (t/testing "move inside->outside-inside"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :shapes [shape-2-id]
;; :parent-id frame-id}
;; {:type :mov-objects
;; :page-id page-id
;; :shapes [shape-2-id]
;; :parent-id uuid/zero}]
;; res (ch/process-changes data changes)]
;; (t/is (= (get-in res [:pages-index page-id :objects shape-1-id :frame-id])
;; (get-in data [:pages-index page-id :objects shape-1-id :frame-id])))
;; (t/is (= (get-in res [:pages-index page-id :objects shape-2-id :frame-id])
;; (get-in data [:pages-index page-id :objects shape-2-id :frame-id])))))
;; ))
;; (t/deftest process-change-move-objects-2
;; (let [shape-1-id (uuid/custom 1 1)
;; shape-2-id (uuid/custom 1 2)
;; shape-3-id (uuid/custom 1 3)
;; shape-4-id (uuid/custom 1 4)
;; group-1-id (uuid/custom 1 5)
;; file-id (uuid/custom 1 6)
;; page-id (uuid/custom 0 1)
;; changes [{:type :add-obj
;; :page-id page-id
;; :id shape-1-id
;; :frame-id uuid/zero
;; :obj {:id shape-1-id
;; :type :rect
;; :name "Shape a"}}
;; {:type :add-obj
;; :page-id page-id
;; :id shape-2-id
;; :frame-id uuid/zero
;; :obj {:id shape-2-id
;; :type :rect
;; :name "Shape b"}}
;; {:type :add-obj
;; :page-id page-id
;; :id shape-3-id
;; :frame-id uuid/zero
;; :obj {:id shape-3-id
;; :type :rect
;; :name "Shape c"}}
;; {:type :add-obj
;; :page-id page-id
;; :id shape-4-id
;; :frame-id uuid/zero
;; :obj {:id shape-4-id
;; :type :rect
;; :name "Shape d"}}
;; {:type :add-obj
;; :page-id page-id
;; :id group-1-id
;; :frame-id uuid/zero
;; :obj {:id group-1-id
;; :type :group
;; :name "Group"}}
;; {:type :mov-objects
;; :page-id page-id
;; :parent-id group-1-id
;; :shapes [shape-1-id shape-2-id]}]
;; data (make-file-data file-id page-id)
;; data (ch/process-changes data changes)]
;; (t/testing "case 1"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id uuid/zero
;; :index 2
;; :shapes [shape-3-id]}]
;; res (ch/process-changes data changes)]
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; ;; After
;; (t/is (= [shape-4-id shape-3-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; ;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; ;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
;; ))
;; (t/testing "case 2"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-1-id
;; :index 2
;; :shapes [shape-3-id]}]
;; res (ch/process-changes data changes)]
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id]
;; (get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; ;; After:
;; (t/is (= [shape-4-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id shape-3-id]
;; (get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; ;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; ;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; ))
;; (t/testing "case 3"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-1-id
;; :index 1
;; :shapes [shape-3-id]}]
;; res (ch/process-changes data changes)]
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id]
;; (get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; ;; After
;; (t/is (= [shape-4-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-3-id shape-2-id]
;; (get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; ;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; ;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; ))
;; (t/testing "case 4"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id group-1-id
;; :index 0
;; :shapes [shape-3-id]}]
;; res (ch/process-changes data changes)]
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id]
;; (get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; ;; After
;; (t/is (= [shape-4-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-3-id shape-1-id shape-2-id]
;; (get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; ;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; ;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; ))
;; (t/testing "case 5"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id uuid/zero
;; :index 0
;; :shapes [shape-2-id]}]
;; res (ch/process-changes data changes)]
;; ;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; ;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
;; ;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; ;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id]
;; (get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; ;; After
;; (t/is (= [shape-2-id shape-3-id shape-4-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id]
;; (get-in res [:pages-index page-id :objects group-1-id :shapes])))
;; ))
;; (t/testing "case 6"
;; (let [changes [{:type :mov-objects
;; :page-id page-id
;; :parent-id uuid/zero
;; :index 0
;; :shapes [shape-2-id shape-1-id]}]
;; res (ch/process-changes data changes)]
;; ;; (pprint (get-in data [:pages-index page-id :objects uuid/zero]))
;; ;; (pprint (get-in res [:pages-index page-id :objects uuid/zero]))
;; ;; (pprint (get-in data [:pages-index page-id :objects group-1-id]))
;; ;; (pprint (get-in res [:pages-index page-id :objects group-1-id]))
;; ;; Before
;; (t/is (= [shape-3-id shape-4-id group-1-id]
;; (get-in data [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (= [shape-1-id shape-2-id]
;; (get-in data [:pages-index page-id :objects group-1-id :shapes])))
;; ;; After
;; (t/is (= [shape-2-id shape-1-id shape-3-id shape-4-id group-1-id]
;; (get-in res [:pages-index page-id :objects uuid/zero :shapes])))
;; (t/is (not= nil
;; (get-in res [:pages-index page-id :objects group-1-id])))
;; ))
;; ))
@@ -4,12 +4,13 @@
;; ;;
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns common-tests.types.decoder-test (ns common-tests.types.shape-decode-encode-test
(:require (:require
[app.common.json :as json] [app.common.json :as json]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
[app.common.schema.test :as smt]
[app.common.types.color :refer [schema:color schema:gradient]] [app.common.types.color :refer [schema:color schema:gradient]]
[app.common.types.plugins :refer [schema:plugin-data]] [app.common.types.plugins :refer [schema:plugin-data]]
[app.common.types.shape :as tsh] [app.common.types.shape :as tsh]
@@ -49,102 +50,102 @@
(t/deftest gradient-json-roundtrip (t/deftest gradient-json-roundtrip
(let [encode (sm/encoder schema:gradient (sm/json-transformer)) (let [encode (sm/encoder schema:gradient (sm/json-transformer))
decode (sm/decoder schema:gradient (sm/json-transformer))] decode (sm/decoder schema:gradient (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [gradient (sg/generator schema:gradient)] (smt/for [gradient (sg/generator schema:gradient)]
(let [gradient-1 (encode gradient) (let [gradient-1 (encode gradient)
gradient-2 (json-roundtrip gradient-1) gradient-2 (json-roundtrip gradient-1)
gradient-3 (decode gradient-2)] gradient-3 (decode gradient-2)]
;; (app.common.pprint/pprint gradient) ;; (app.common.pprint/pprint gradient)
;; (app.common.pprint/pprint gradient-3) ;; (app.common.pprint/pprint gradient-3)
(t/is (= gradient gradient-3)))) (= gradient gradient-3)))
{:num 500}))) {:num 500})))
(t/deftest color-json-roundtrip (t/deftest color-json-roundtrip
(let [encode (sm/encoder schema:color (sm/json-transformer)) (let [encode (sm/encoder schema:color (sm/json-transformer))
decode (sm/decoder schema:color (sm/json-transformer))] decode (sm/decoder schema:color (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [color (sg/generator schema:color)] (smt/for [color (sg/generator schema:color)]
(let [color-1 (encode color) (let [color-1 (encode color)
color-2 (json-roundtrip color-1) color-2 (json-roundtrip color-1)
color-3 (decode color-2)] color-3 (decode color-2)]
;; (app.common.pprint/pprint color) ;; (app.common.pprint/pprint color)
;; (app.common.pprint/pprint color-3) ;; (app.common.pprint/pprint color-3)
(t/is (= color color-3)))) (= color color-3)))
{:num 500}))) {:num 500})))
(t/deftest shape-shadow-json-roundtrip (t/deftest shape-shadow-json-roundtrip
(let [encode (sm/encoder schema:shadow (sm/json-transformer)) (let [encode (sm/encoder schema:shadow (sm/json-transformer))
decode (sm/decoder schema:shadow (sm/json-transformer))] decode (sm/decoder schema:shadow (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [shadow (sg/generator schema:shadow)] (smt/for [shadow (sg/generator schema:shadow)]
(let [shadow-1 (encode shadow) (let [shadow-1 (encode shadow)
shadow-2 (json-roundtrip shadow-1) shadow-2 (json-roundtrip shadow-1)
shadow-3 (decode shadow-2)] shadow-3 (decode shadow-2)]
;; (app.common.pprint/pprint shadow) ;; (app.common.pprint/pprint shadow)
;; (app.common.pprint/pprint shadow-3) ;; (app.common.pprint/pprint shadow-3)
(t/is (= shadow shadow-3)))) (= shadow shadow-3)))
{:num 500}))) {:num 500})))
(t/deftest shape-animation-json-roundtrip (t/deftest shape-animation-json-roundtrip
(let [encode (sm/encoder schema:animation (sm/json-transformer)) (let [encode (sm/encoder schema:animation (sm/json-transformer))
decode (sm/decoder schema:animation (sm/json-transformer))] decode (sm/decoder schema:animation (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [animation (sg/generator schema:animation)] (smt/for [animation (sg/generator schema:animation)]
(let [animation-1 (encode animation) (let [animation-1 (encode animation)
animation-2 (json-roundtrip animation-1) animation-2 (json-roundtrip animation-1)
animation-3 (decode animation-2)] animation-3 (decode animation-2)]
;; (app.common.pprint/pprint animation) ;; (app.common.pprint/pprint animation)
;; (app.common.pprint/pprint animation-3) ;; (app.common.pprint/pprint animation-3)
(t/is (= animation animation-3)))) (= animation animation-3)))
{:num 500}))) {:num 500})))
(t/deftest shape-interaction-json-roundtrip (t/deftest shape-interaction-json-roundtrip
(let [encode (sm/encoder schema:interaction (sm/json-transformer)) (let [encode (sm/encoder schema:interaction (sm/json-transformer))
decode (sm/decoder schema:interaction (sm/json-transformer))] decode (sm/decoder schema:interaction (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [interaction (sg/generator schema:interaction)] (smt/for [interaction (sg/generator schema:interaction)]
(let [interaction-1 (encode interaction) (let [interaction-1 (encode interaction)
interaction-2 (json-roundtrip interaction-1) interaction-2 (json-roundtrip interaction-1)
interaction-3 (decode interaction-2)] interaction-3 (decode interaction-2)]
;; (app.common.pprint/pprint interaction) ;; (app.common.pprint/pprint interaction)
;; (app.common.pprint/pprint interaction-3) ;; (app.common.pprint/pprint interaction-3)
(t/is (= interaction interaction-3)))) (= interaction interaction-3)))
{:num 500}))) {:num 500})))
(t/deftest shape-path-content-json-roundtrip (t/deftest shape-path-content-json-roundtrip
(let [encode (sm/encoder schema:path-content (sm/json-transformer)) (let [encode (sm/encoder schema:path-content (sm/json-transformer))
decode (sm/decoder schema:path-content (sm/json-transformer))] decode (sm/decoder schema:path-content (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [path-content (sg/generator schema:path-content)] (smt/for [path-content (sg/generator schema:path-content)]
(let [path-content-1 (encode path-content) (let [path-content-1 (encode path-content)
path-content-2 (json-roundtrip path-content-1) path-content-2 (json-roundtrip path-content-1)
path-content-3 (decode path-content-2)] path-content-3 (decode path-content-2)]
;; (app.common.pprint/pprint path-content) ;; (app.common.pprint/pprint path-content)
;; (app.common.pprint/pprint path-content-3) ;; (app.common.pprint/pprint path-content-3)
(t/is (= path-content path-content-3)))) (= path-content path-content-3)))
{:num 500}))) {:num 500})))
(t/deftest plugin-data-json-roundtrip (t/deftest plugin-data-json-roundtrip
(let [encode (sm/encoder schema:plugin-data (sm/json-transformer)) (let [encode (sm/encoder schema:plugin-data (sm/json-transformer))
decode (sm/decoder schema:plugin-data (sm/json-transformer))] decode (sm/decoder schema:plugin-data (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [data (sg/generator schema:plugin-data)] (smt/for [data (sg/generator schema:plugin-data)]
(let [data-1 (encode data) (let [data-1 (encode data)
data-2 (json-roundtrip data-1) data-2 (json-roundtrip data-1)
data-3 (decode data-2)] data-3 (decode data-2)]
(t/is (= data data-3)))) (= data data-3)))
{:num 500}))) {:num 500})))
(t/deftest shape-json-roundtrip (t/deftest shape-json-roundtrip
(let [encode (sm/encoder ::tsh/shape (sm/json-transformer)) (let [encode (sm/encoder ::tsh/shape (sm/json-transformer))
decode (sm/decoder ::tsh/shape (sm/json-transformer))] decode (sm/decoder ::tsh/shape (sm/json-transformer))]
(sg/check! (smt/check!
(sg/for [shape (sg/generator ::tsh/shape)] (smt/for [shape (sg/generator ::tsh/shape)]
(let [shape-1 (encode shape) (let [shape-1 (encode shape)
shape-2 (json-roundtrip shape-1) shape-2 (json-roundtrip shape-1)
shape-3 (decode shape-2)] shape-3 (decode shape-2)]
;; (app.common.pprint/pprint shape) ;; (app.common.pprint/pprint shape)
;; (app.common.pprint/pprint shape-3) ;; (app.common.pprint/pprint shape-3)
(t/is (= shape shape-3)))) (= shape shape-3)))
{:num 1000}))) {:num 1000})))
-33
View File
@@ -1,33 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.types-test
(:require
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.transit :as transit]
[app.common.types.file :as ctf]
[app.common.types.page :as ctp]
[app.common.types.shape :as cts]
[clojure.test :as t]))
(t/deftest transit-encode-decode-with-shape
(sg/check!
(sg/for [fdata (sg/generator ::cts/shape)]
(let [res (-> fdata transit/encode-str transit/decode-str)]
(t/is (= res fdata))))
{:num 18 :seed 1683548002439}))
(t/deftest types-shape-spec
(sg/check!
(sg/for [fdata (sg/generator ::cts/shape)]
(binding [app.common.data.macros/*assert-context* true]
(t/is (sm/validate ::cts/shape fdata))))))
(t/deftest types-page-spec
(-> (sg/for [fdata (sg/generator ::ctp/page)]
(t/is (sm/validate ::ctp/page fdata)))
(sg/check! {:num 30})))
-18
View File
@@ -1,18 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.uuid-test
(:require
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[clojure.test :as t]))
(t/deftest non-repeating-uuid-next-1-schema
(sg/check!
(sg/for [uuid1 (sg/generator ::sm/uuid)
uuid2 (sg/generator ::sm/uuid)]
(t/is (not= uuid1 uuid2)))
{:num 100}))
+23
View File
@@ -8,6 +8,8 @@ ENV NODE_VERSION=v20.11.1 \
CLJKONDO_VERSION=2024.03.13 \ CLJKONDO_VERSION=2024.03.13 \
BABASHKA_VERSION=1.3.189 \ BABASHKA_VERSION=1.3.189 \
CLJFMT_VERSION=0.12.0 \ CLJFMT_VERSION=0.12.0 \
RUSTUP_VERSION=1.27.1 \
RUST_VERSION=1.81.0 \
LANG=en_US.UTF-8 \ LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 LC_ALL=en_US.UTF-8
@@ -242,6 +244,27 @@ RUN set -ex; \
mv /tmp/mc /usr/local/bin/; \ mv /tmp/mc /usr/local/bin/; \
chmod +x /usr/local/bin/mc; chmod +x /usr/local/bin/mc;
# Install Rust toolchain
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH;
RUN set -eux; \
# Same steps as in Rust official Docker image https://github.com/rust-lang/docker-rust/blob/9f287282d513a84cb7c7f38f197838f15d37b6a9/1.81.0/bookworm/Dockerfile
dpkgArch="$(dpkg --print-architecture)"; \
case "${dpkgArch##*-}" in \
amd64) rustArch='x86_64-unknown-linux-gnu'; rustupSha256='6aeece6993e902708983b209d04c0d1dbb14ebb405ddb87def578d41f920f56d' ;; \
arm64) rustArch='aarch64-unknown-linux-gnu'; rustupSha256='1cffbf51e63e634c746f741de50649bbbcbd9dbe1de363c9ecef64e278dba2b2' ;; \
*) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \
esac; \
url="https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${rustArch}/rustup-init"; \
wget "$url"; \
echo "${rustupSha256} *rustup-init" | sha256sum -c -; \
chmod +x rustup-init; \
./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION --default-host ${rustArch}; \
rm rustup-init; \
chmod -R a+w $RUSTUP_HOME $CARGO_HOME;
WORKDIR /home WORKDIR /home
COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/nginx.conf /etc/nginx/nginx.conf
+4 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PATH=/usr/lib/jvm/openjdk/bin:/usr/local/nodejs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin export PATH=/usr/lib/jvm/openjdk/bin:/usr/local/nodejs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
export JAVA_OPTS="-Xmx1000m -Xms50m" export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms200m"};
alias l='ls --color -GFlh' alias l='ls --color -GFlh'
alias rm='rm -r' alias rm='rm -r'
@@ -9,6 +9,9 @@ alias ls='ls --color -F'
alias lsd='ls -d *(/)' alias lsd='ls -d *(/)'
alias lsf='ls -h *(.)' alias lsf='ls -h *(.)'
# init Cargo / Rust env
. "/usr/local/cargo/env"
# include .bashrc if it exists # include .bashrc if it exists
if [ -f "$HOME/.bashrc.local" ]; then if [ -f "$HOME/.bashrc.local" ]; then
. "$HOME/.bashrc.local" . "$HOME/.bashrc.local"
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
+144
View File
@@ -0,0 +1,144 @@
const { DateTime } = require("luxon");
const fs = require("fs");
const pluginNavigation = require("@11ty/eleventy-navigation");
const pluginRss = require("@11ty/eleventy-plugin-rss");
const pluginSyntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
const pluginAncestry = require("@tigersway/eleventy-plugin-ancestry");
const metagen = require('eleventy-plugin-metagen');
const pluginTOC = require('eleventy-plugin-nesting-toc');
const markdownIt = require("markdown-it");
const markdownItAnchor = require("markdown-it-anchor");
const markdownItPlantUML = require("markdown-it-plantuml");
const elasticlunr = require("elasticlunr");
module.exports = function(eleventyConfig) {
eleventyConfig.addPlugin(pluginNavigation);
eleventyConfig.addPlugin(pluginRss);
eleventyConfig.addPlugin(pluginSyntaxHighlight);
eleventyConfig.addPlugin(pluginAncestry);
eleventyConfig.addPlugin(metagen);
eleventyConfig.addPlugin(pluginTOC, {
tags: ['h1', 'h2', 'h3']
});
eleventyConfig.setDataDeepMerge(true);
eleventyConfig.addLayoutAlias("post", "layouts/post.njk");
eleventyConfig.addFilter("readableDate", dateObj => {
return DateTime.fromJSDate(dateObj, {zone: 'utc'}).toFormat("dd LLL yyyy");
});
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-date-string
eleventyConfig.addFilter('htmlDateString', (dateObj) => {
return DateTime.fromJSDate(dateObj, {zone: 'utc'}).toFormat('yyyy-LL-dd');
});
// Remove trailing # in automatic generated toc, because of
// anchors added at the end of the titles.
eleventyConfig.addFilter('stripHash', (toc) => {
return toc.replace(/ #\<\/a\>/g, "</a>");
});
// Get the first `n` elements of a collection.
eleventyConfig.addFilter("head", (array, n) => {
if( n < 0 ) {
return array.slice(n);
}
return array.slice(0, n);
});
// Get the lowest in a list of numbers.
eleventyConfig.addFilter("min", (...numbers) => {
return Math.min.apply(null, numbers);
});
// Build a search index
eleventyConfig.addFilter("search", (collection) => {
// What fields we'd like our index to consist of
// TODO: remove html tags from content
var index = elasticlunr(function () {
this.addField("title");
this.addField("content");
this.setRef("id");
});
// loop through each page and add it to the index
collection.forEach((page) => {
index.addDoc({
id: page.url,
title: page.template.frontMatter.data.title,
content: page.template.frontMatter.content,
});
});
return index.toJSON();
});
eleventyConfig.addPassthroughCopy("img");
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("js");
/* Markdown Overrides */
let markdownLibrary = markdownIt({
html: true,
breaks: false,
linkify: true
}).use(markdownItAnchor, {
permalink: true,
permalinkClass: "direct-link",
permalinkSymbol: "#"
}).use(markdownItPlantUML, {
});
eleventyConfig.setLibrary("md", markdownLibrary);
// Browsersync Overrides
eleventyConfig.setBrowserSyncConfig({
callbacks: {
ready: function(err, browserSync) {
const content_404 = fs.readFileSync('_dist/404.html');
browserSync.addMiddleware("*", (req, res) => {
// Provides the 404 content without redirect.
res.write(content_404);
res.end();
});
},
},
ui: false,
ghostMode: false
});
return {
templateFormats: [
"md",
"njk",
"html",
"liquid"
],
// If your site lives in a different subdirectory, change this.
// Leading or trailing slashes are all normalized away, so dont worry about those.
// If you dont have a subdirectory, use "" or "/" (they do the same thing)
// This is only used for link URLs (it does not affect your file structure)
// Best paired with the `url` filter: https://www.11ty.dev/docs/filters/url/
// You can also pass this in on the command line using `--pathprefix`
// pathPrefix: "/",
markdownTemplateEngine: "liquid",
htmlTemplateEngine: "njk",
dataTemplateEngine: "njk",
// These are all optional, defaults are shown:
dir: {
input: ".",
includes: "_includes",
data: "_data",
output: "_dist"
}
};
};
+1
View File
@@ -0,0 +1 @@
README.md
+118
View File
@@ -0,0 +1,118 @@
# Distribution files
_dist/*
# yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.idea
+5
View File
@@ -0,0 +1,5 @@
{
"editor.rulers": [
80
]
}
Loaded 100 of 981 files, more files were not shown because too many files have changed in this diff. Show more