diff --git a/.circleci/config.yml b/.circleci/config.yml index 09cc658e4..40a8c8603 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -111,7 +111,7 @@ jobs: yarn run build:app:assets clojure -M:dev:shadow-cljs release main yarn playwright install --with-deps chromium - yarn e2e:test + yarn test:e2e - run: name: "backend tests" diff --git a/CHANGES.md b/CHANGES.md index ea160e12e..b2d836bb8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,18 +1,190 @@ # CHANGELOG -## 2.2.0 +## 2.4.0 ### :rocket: Epics and highlights ### :boom: Breaking changes & Deprecations +- Use [nginx-unprivileged](https://hub.docker.com/r/nginxinc/nginx-unprivileged) as base image for Penpot's frontend docker image. Now all the docker images runs with the same unprivileged user (penpot). Because of that, the default NGINX listen port now is 8080, instead of 80, so you will have to modify your infrastructure to apply this change. + ### :heart: Community contributions (Thank you!) ### :sparkles: New features ### :bug: Bugs fixed +## 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) + +## 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 + +### :rocket: Epics and highlights + +### :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!) + +- Set proper default tenant on exporter (by @june128) [#4946](https://github.com/penpot/penpot/pull/4946) +- Correct a spelling in onboarding.edn (by @n-stha) [#4936](https://github.com/penpot/penpot/pull/4936) + +### :sparkles: New features + +- **Tiered File Data Storage** [Taiga #8376](https://tree.taiga.io/project/penpot/us/8376) + + This feature allows offloading file data that is not actively used + from the database to object storage (e.g., filesystem, S3), thereby + freeing up space in the database. It can be enabled with the + `enable-enable-tiered-file-data-storage` flag. + + *(On-Premise feature, EXPERIMENTAL).* + +- **JSON Interoperability for HTTP API** [Taiga #8372](https://tree.taiga.io/project/penpot/us/8372) + + Enables full JSON interoperability for our HTTP API. Previously, + JSON was only barely supported for output when the + `application/json` media type was specified in the `Accept` header, + or when `_fmt=json` was passed as a query parameter. With this + update, we now offer proper bi-directional support for using our API + with plain JSON, instead of Transit. + +- **Automatic File Snapshotting** + + Adds the ability to automatically take and maintain a limited set of + snapshots of active files without explicit user intervention. This + feature allows on-premise administrators to recover the state of a + file from a past point in time in a limited manner. + + It can be enabled with the `enable-auto-file-snapshot` flag and + configured with the following settings: + + ```bash + # Take snapshots every 10 update operations + PENPOT_AUTO_FILE_SNAPSHOT_EVERY=10 + + # Take a snapshot if it has been more than 3 hours since the file was last modified + PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT=3h + + # The total number of snapshots to keep + PENPOT_AUTO_FILE_SNAPSHOT_TOTAL=10 + ``` + + Snapshots are only taken during update operations; there is NO + active background process for this. + +- Add separated flag `enable-oidc-registration` for enable the + registration only for OIDC authentication backend [Github + #4882](https://github.com/penpot/penpot/issues/4882) + +- Update templates in libraries & templates in dashboard modal [Taiga #8145](https://tree.taiga.io/project/penpot/us/8145) + +- **Design System** + + We implemented and subbed in new components from our Design System: `loader*` ([Taiga #8355](https://tree.taiga.io/project/penpot/task/8355)) and `tab-switcher*` ([Taiga #8518](https://tree.taiga.io/project/penpot/task/8518)). + +- **Storybook** [Taiga #6329](https://tree.taiga.io/project/penpot/us/6329) + + The Design System components are now published in a Storybook, available at `/storybook`. + +### :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 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 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 + +### :bug: Bugs fixed + +- Fix broken webhooks [Taiga #8370](https://tree.taiga.io/project/penpot/issue/8370) + +## 2.1.4 + +### :bug: Bugs fixed + +- Fix json encoding on zip encoding decoding. +- Add schema validation for color changes. +- Fix render of some texts without position data. + +## 2.1.3 + +- Don't allow registration when registration is disabled and invitation token is used [Github #4975](https://github.com/penpot/penpot/issues/4975) + +## 2.1.2 + +### :bug: Bugs fixed + +- User switch language to "zh_hant" will get 400 [Github #4884](https://github.com/penpot/penpot/issues/4884) +- Smtp config ignoring port if ssl is set [Github #4872](https://github.com/penpot/penpot/issues/4872) +- Ability to let users to authenticate with a private oidc provider only [Github #4963](https://github.com/penpot/penpot/issues/4963) ## 2.1.1 @@ -33,7 +205,7 @@ ### :boom: Breaking changes & Deprecations -### :heart: Community contributions (Thank you!) +### :heart: Communityq contributions (Thank you!) ### :sparkles: New features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae27a0135..9e2091679 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ quick win. If is going to be your first pull request, You can learn how from this free video series: -https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github +https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github We will use the `easy fix` mark for tag for indicate issues that are easy for beginners. diff --git a/backend/dev/user.clj b/backend/dev/user.clj index 9fc59d5e1..5f742ff15 100644 --- a/backend/dev/user.clj +++ b/backend/dev/user.clj @@ -20,6 +20,7 @@ [app.common.schema.desc-native :as smdn] [app.common.schema.generators :as sg] [app.common.spec :as us] + [app.common.json :as json] [app.common.transit :as t] [app.common.types.file :as ctf] [app.common.uuid :as uuid] @@ -29,7 +30,6 @@ [app.srepl.helpers :as srepl.helpers] [app.srepl.main :as srepl] [app.util.blob :as blob] - [app.util.json :as json] [app.util.time :as dt] [clj-async-profiler.core :as prof] [clojure.contrib.humanize :as hum] diff --git a/backend/resources/app/email/change-email/en.html b/backend/resources/app/email/change-email/en.html index d63efa72f..7a5f1f118 100644 --- a/backend/resources/app/email/change-email/en.html +++ b/backend/resources/app/email/change-email/en.html @@ -1,5 +1,6 @@ - + @@ -110,15 +111,20 @@ 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%"> + <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="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> <tr> <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> </tr> </tbody> @@ -151,7 +157,8 @@ <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <![endif]--> <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> <tr> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> @@ -164,29 +171,43 @@ 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%"> + <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="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> </tr> <tr> <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> </tr> <tr> <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> </tr> <tr> - <td align="center" vertical-align="middle" 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%;"> + <td align="center" vertical-align="middle" + 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> - <td align="center" bgcolor="#31EFB8" role="presentation" 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 align="center" bgcolor="#31EFB8" role="presentation" + 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> </tr> </table> @@ -194,17 +215,24 @@ </tr> <tr> <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> </tr> <tr> <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> </tr> <tr> <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> </tr> </table> @@ -221,258 +249,10 @@ </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:24px 0 0 0;text-align:center;"> - <!--[if mso | IE]> - <table role="presentation" border="0" cellpadding="0" cellspacing="0"> + {% include "app/email/includes/footer.html" %} - <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 <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> </body> -</html> +</html> \ No newline at end of file diff --git a/backend/resources/app/email/includes/footer.html b/backend/resources/app/email/includes/footer.html new file mode 100644 index 000000000..4581ff37a --- /dev/null +++ b/backend/resources/app/email/includes/footer.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 <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]--> \ No newline at end of file diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 93763c106..43bcbde67 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -1,5 +1,6 @@ <!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> <title> @@ -110,15 +111,20 @@ 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%"> + <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="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> <tr> <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> </tr> </tbody> @@ -151,7 +157,8 @@ <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <![endif]--> <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> <tr> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> @@ -164,24 +171,36 @@ 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%"> + <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="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> </tr> <tr> <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> </tr> <tr> - <td align="center" vertical-align="middle" 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%;"> + <td align="center" vertical-align="middle" + 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> - <td align="center" bgcolor="#31EFB8" role="presentation" 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 align="center" bgcolor="#31EFB8" role="presentation" + 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> </tr> </table> @@ -189,12 +208,16 @@ </tr> <tr> <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> </tr> <tr> <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> </tr> </table> @@ -211,258 +234,10 @@ </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:24px 0 0 0;text-align:center;"> - <!--[if mso | IE]> - <table role="presentation" border="0" cellpadding="0" cellspacing="0"> + {% include "app/email/includes/footer.html" %} - <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 <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> </body> -</html> +</html> \ No newline at end of file diff --git a/backend/resources/app/email/join-team/en.html b/backend/resources/app/email/join-team/en.html new file mode 100644 index 000000000..1a59e70ce --- /dev/null +++ b/backend/resources/app/email/join-team/en.html @@ -0,0 +1,244 @@ +<!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"> + +<head> + <title> + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ Hello!
+
+
+ As you requested, {{invited-by|abbreviate:25}} has added you to the team “{{ + team|abbreviate:25}}”.
+
+ + + + +
+ Go to the Team +
+
+
+ Enjoy!
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + \ No newline at end of file diff --git a/backend/resources/app/email/join-team/en.subj b/backend/resources/app/email/join-team/en.subj new file mode 100644 index 000000000..296ce140f --- /dev/null +++ b/backend/resources/app/email/join-team/en.subj @@ -0,0 +1 @@ +You have joined {{team}} diff --git a/backend/resources/app/email/join-team/en.txt b/backend/resources/app/email/join-team/en.txt new file mode 100644 index 000000000..78cba680e --- /dev/null +++ b/backend/resources/app/email/join-team/en.txt @@ -0,0 +1,10 @@ +Hello! + +As you requested, {{invited-by|abbreviate:25}} has added you to the team “{{ team|abbreviate:25}}”. + +Go to the team with this link: + +{{ public-uri }}/#/dashboard/team/{{team-id}} + +Enjoy! +The Penpot team. diff --git a/backend/resources/app/email/password-recovery/en.html b/backend/resources/app/email/password-recovery/en.html index ed18ef12c..7770402b7 100644 --- a/backend/resources/app/email/password-recovery/en.html +++ b/backend/resources/app/email/password-recovery/en.html @@ -1,5 +1,6 @@ - + @@ -110,15 +111,20 @@ 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%"> + <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="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> <tr> <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> </tr> </tbody> @@ -151,7 +157,8 @@ <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <![endif]--> <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> <tr> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> @@ -164,24 +171,37 @@ 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%"> + <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="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> </tr> <tr> <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> </tr> <tr> - <td align="center" vertical-align="middle" 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%;"> + <td align="center" vertical-align="middle" + 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> - <td align="center" bgcolor="#31EFB8" role="presentation" 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 align="center" bgcolor="#31EFB8" role="presentation" + 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> </tr> </table> @@ -189,17 +209,24 @@ </tr> <tr> <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> </tr> <tr> <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> </tr> <tr> <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> </tr> </table> @@ -216,258 +243,10 @@ </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:24px 0 0 0;text-align:center;"> - <!--[if mso | IE]> - <table role="presentation" border="0" cellpadding="0" cellspacing="0"> + {% include "app/email/includes/footer.html" %} - <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 <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> </body> -</html> +</html> \ No newline at end of file diff --git a/backend/resources/app/email/register/en.html b/backend/resources/app/email/register/en.html index 3f058b184..c5fb5bc3f 100644 --- a/backend/resources/app/email/register/en.html +++ b/backend/resources/app/email/register/en.html @@ -1,5 +1,6 @@ <!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> <title> @@ -110,15 +111,20 @@ 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%"> + <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="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> <tr> <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> </tr> </tbody> @@ -151,7 +157,8 @@ <td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"> <![endif]--> <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> <tr> <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> @@ -164,24 +171,37 @@ 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%"> + <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="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> </tr> <tr> <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> </tr> <tr> - <td align="center" vertical-align="middle" 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%;"> + <td align="center" vertical-align="middle" + 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> - <td align="center" bgcolor="#31EFB8" role="presentation" 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 align="center" bgcolor="#31EFB8" role="presentation" + 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> </tr> </table> @@ -189,12 +209,16 @@ </tr> <tr> <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> </tr> <tr> <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> </tr> </table> @@ -211,258 +235,10 @@ </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:24px 0 0 0;text-align:center;"> - <!--[if mso | IE]> - <table role="presentation" border="0" cellpadding="0" cellspacing="0"> + {% include "app/email/includes/footer.html" %} - <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 <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> </body> -</html> +</html> \ No newline at end of file diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html new file mode 100644 index 000000000..3146665f0 --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html @@ -0,0 +1,254 @@ +<!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"> + +<head> + <title> + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ Hello!
+
+
+

+ {{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the + file named “{{file-name|abbreviate:25}}”. +

+

+ Since this file is in your Penpot team, you can provide access by sending a view-only link. + This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. +

+

To proceed, please click the button below to generate and send the view-only link:

+
+
+ + + + +
+ Send a View-Only link +
+
+
+

If you do not wish to grant access at this time, you can simply disregard this email.

+

Thank you

+
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + \ No newline at end of file diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.subj b/backend/resources/app/email/request-file-access-yourpenpot-view/en.subj new file mode 100644 index 000000000..2e577c3e0 --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.subj @@ -0,0 +1 @@ +Request View-Only Access to “{{file-name|abbreviate:25}}” diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt new file mode 100644 index 000000000..67eb6cedf --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt @@ -0,0 +1,17 @@ +Hello! + +{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”. + +Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. + +To proceed, please click the link below to generate and send the view-only link: + +{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true + + + +If you do not wish to grant access at this time, you can simply disregard this email. +Thank you + + +The Penpot team. diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.html b/backend/resources/app/email/request-file-access-yourpenpot/en.html new file mode 100644 index 000000000..e32a1603f --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.html @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ Hello!
+
+
+

+ {{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named + “{{file-name|abbreviate:25}}”. +

+

+ Please note that the file is currently in Your Penpot 's team, so direct access cannot be + granted. However, you have two options to provide the requested access: +

+
    +
  • +

    Move the File to Another Team:

    +

    You can move the file to another team and then give access to that team, inviting + {{requested-by|abbreviate:25}}.

    +
  • +
+

+
+
+
+
    +
  • +

    Send a View-Only Link:

    +

    Alternatively, you can create and share a view-only link to the file. This will allow + {{requested-by|abbreviate:25}} to view the content without making any changes.

    +

    Click the button below to generate and send the link:

    +
  • +
+

+
+
+ + + + +
+ Send a View-Only link +
+
+
+

If you do not wish to grant access at this time, you can simply disregard this email.

+

Thank you

+
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + \ No newline at end of file diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.subj b/backend/resources/app/email/request-file-access-yourpenpot/en.subj new file mode 100644 index 000000000..d4a90980b --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.subj @@ -0,0 +1 @@ +Request Access to “{{file-name|abbreviate:25}}” diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.txt b/backend/resources/app/email/request-file-access-yourpenpot/en.txt new file mode 100644 index 000000000..140cb0445 --- /dev/null +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.txt @@ -0,0 +1,30 @@ +Hello! + + +Hello! + +{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”. + +Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access: + +- Move the File to Another Team: + +You can move the file to another team and then give access to that team, inviting {{requested-by|abbreviate:25}}. + + + +- Send a View-Only Link: + +Alternatively, you can create and share a view-only link to the file. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. + +Click the link below to generate and send the link: + +{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true + + + +If you do not wish to grant access at this time, you can simply disregard this email. +Thank you + + +The Penpot team. diff --git a/backend/resources/app/email/request-file-access/en.html b/backend/resources/app/email/request-file-access/en.html new file mode 100644 index 000000000..370687e3f --- /dev/null +++ b/backend/resources/app/email/request-file-access/en.html @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ Hello!
+
+
+

+ {{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named + “{{file-name|abbreviate:25}}”. +

+

+ To provide this access, you have the following options: +

+
    +
  • +

    Give Access to the “{{team-name|abbreviate:25}}” Team:

    +

    This will automatically include {{requested-by|abbreviate:25}} in the team, so the user + can see all the projects and files in it.

    +

    Click the button below to provide team access:

    +
  • +
+

+
+
+ + + + +
+ Give access to “{{team-name|abbreviate:25}}” Team +
+
+
+
    +
  • +

    Send a View-Only Link:

    +

    Alternatively, you can create and share a view-only link to the file. This will allow + {{requested-by|abbreviate:25}} to view the content without making any changes.

    +

    Click the button below to generate and send the link:

    +
  • +
+

+
+
+ + + + +
+ Send a View-Only link +
+
+
+

If you do not wish to grant access at this time, you can simply disregard this email.

+

Thank you

+
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + + +
+ + + \ No newline at end of file diff --git a/backend/resources/app/email/request-file-access/en.subj b/backend/resources/app/email/request-file-access/en.subj new file mode 100644 index 000000000..d4a90980b --- /dev/null +++ b/backend/resources/app/email/request-file-access/en.subj @@ -0,0 +1 @@ +Request Access to “{{file-name|abbreviate:25}}” diff --git a/backend/resources/app/email/request-file-access/en.txt b/backend/resources/app/email/request-file-access/en.txt new file mode 100644 index 000000000..d327e4780 --- /dev/null +++ b/backend/resources/app/email/request-file-access/en.txt @@ -0,0 +1,34 @@ +Hello! + + +Hello! + +{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”. + +To provide this access, you have the following options: + +- Give Access to the “{{team-name|abbreviate:25}}” Team: + +This will automatically include {{requested-by|abbreviate:25}} in the team, so the user can see all the projects and files in it. + +Click the link below to provide team access: + +{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}} + + + +- Send a View-Only Link: + +Alternatively, you can create and share a view-only link to the file. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. + +Click the link below to generate and send the link: + +{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true + + + +If you do not wish to grant access at this time, you can simply disregard this email. +Thank you + + +The Penpot team. diff --git a/backend/resources/app/email/request-team-access/en.html b/backend/resources/app/email/request-team-access/en.html new file mode 100644 index 000000000..54a7dcc2a --- /dev/null +++ b/backend/resources/app/email/request-team-access/en.html @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ Hello!
+
+
+

+ {{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have access to the + “{{team-name|abbreviate:25}}” Team. +

+

+ To provide access, please click the button below: +

+
+
+ + + + +
+ Give access to “{{team-name|abbreviate:25}}” +
+
+
+

If you do not wish to grant access at this time, you can simply disregard this email.

+

Thank you

+
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + \ No newline at end of file diff --git a/backend/resources/app/email/request-team-access/en.subj b/backend/resources/app/email/request-team-access/en.subj new file mode 100644 index 000000000..d455c082b --- /dev/null +++ b/backend/resources/app/email/request-team-access/en.subj @@ -0,0 +1 @@ +Request Access to “{{team-name|abbreviate:25}}” diff --git a/backend/resources/app/email/request-team-access/en.txt b/backend/resources/app/email/request-team-access/en.txt new file mode 100644 index 000000000..225bc1e26 --- /dev/null +++ b/backend/resources/app/email/request-team-access/en.txt @@ -0,0 +1,14 @@ +Hello! + +{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have access to the “{{team-name|abbreviate:25}}” Team. + +To provide access, please click the link below: + +{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}} + + +If you do not wish to grant access at this time, you can simply disregard this email. +Thank you + + +The Penpot team. diff --git a/backend/resources/app/onboarding.edn b/backend/resources/app/onboarding.edn index a6449f5fd..5762f09b9 100644 --- a/backend/resources/app/onboarding.edn +++ b/backend/resources/app/onboarding.edn @@ -1,39 +1,42 @@ [{:id "wireframing-kit" :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/main/wireframing-kit.penpot"} {:id "prototype-examples" :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/main/prototype-examples.penpot"} {:id "plants-app" :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" :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/main/Penpot-Design-system.penpot"} {:id "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" :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" :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" :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" :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" :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" :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" :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" :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/main/Flex%20Layout%20Playground.penpot"} + {:id "welcome" + :name "Welcome" + :file-uri "https://github.com/penpot/penpot-files/raw/main/welcome.penpot"}] diff --git a/backend/scripts/repl b/backend/scripts/repl index 84e979707..d5c2bd166 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -23,10 +23,12 @@ export PENPOT_FLAGS="\ enable-urepl-server \ enable-rpc-climit \ enable-rpc-rlimit \ + enable-quotes \ enable-soft-rpc-rlimit \ - enable-file-snapshot \ + enable-auto-file-snapshot \ enable-webhooks \ enable-access-tokens \ + enable-tiered-file-data-storage \ enable-file-validation \ enable-file-schema-validation \ disable-feature-design-tokens"; @@ -63,9 +65,10 @@ mc mb penpot-s3/penpot -p -q export AWS_ACCESS_KEY_ID=penpot-devenv export AWS_SECRET_ACCESS_KEY=penpot-devenv -export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3 -export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000 -export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot + +export PENPOT_OBJECTS_STORAGE_BACKEND=s3 +export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 +export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot export OPTIONS=" -A:jmx-remote -A:dev \ diff --git a/backend/scripts/start-dev b/backend/scripts/start-dev index e21a0530a..bb9e23aca 100755 --- a/backend/scripts/start-dev +++ b/backend/scripts/start-dev @@ -17,8 +17,10 @@ export PENPOT_FLAGS="\ disable-secure-session-cookies \ enable-rpc-climit \ enable-smtp \ + enable-quotes \ enable-file-snapshot \ enable-access-tokens \ + enable-tiered-file-data-storage \ enable-file-validation \ enable-file-schema-validation \ disable-feature-design-tokens"; @@ -57,9 +59,9 @@ mc mb penpot-s3/penpot -p -q export AWS_ACCESS_KEY_ID=penpot-devenv export AWS_SECRET_ACCESS_KEY=penpot-devenv -export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3 -export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000 -export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot +export PENPOT_OBJECTS_STORAGE_BACKEND=s3 +export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 +export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot entrypoint=${1:-app.main}; diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 824f8a937..049b95c17 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -567,7 +567,6 @@ (tokens/generate (::setup/props cfg) {:iss :auth :exp (dt/in-future "15m") - :props (:props info) :profile-id (:id profile)})) props (audit/profile->props profile) context (d/without-nils {:external-session-id (:external-session-id info)})] @@ -592,7 +591,8 @@ :else (let [info (assoc info :is-active (provider-has-email-verified? cfg info))] - (if (contains? cf/flags :registration) + (if (or (contains? cf/flags :registration) + (contains? cf/flags :oidc-registration)) (redirect-to-register cfg info request) (redirect-with-error "registration-disabled"))))) diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 3e1c93aa0..87f02d391 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -22,7 +22,6 @@ [app.db :as db] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -403,9 +402,9 @@ (write-obj! output rels))) (defmethod write-section :v1/sobjects - [{:keys [::sto/storage ::output]}] + [{:keys [::output] :as cfg}] (let [sids (-> bfc/*state* deref :sids) - storage (media/configure-assets-storage storage)] + storage (sto/resolve cfg)] (l/dbg :hint "found sobjects" :items (count sids) @@ -620,8 +619,8 @@ ::l/sync? true)))))) (defmethod read-section :v1/sobjects - [{:keys [::sto/storage ::db/conn ::input ::bfc/overwrite ::bfc/timestamp]}] - (let [storage (media/configure-assets-storage storage) + [{:keys [::db/conn ::input ::bfc/overwrite ::bfc/timestamp] :as cfg}] + (let [storage (sto/resolve cfg) ids (read-obj! input) thumb? (into #{} (map :media-id) (:thumbnails @bfc/*state*))] diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index 1a5f10342..bef327acc 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -20,7 +20,6 @@ [app.db.sql :as sql] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] [app.storage :as sto] [app.storage.tmp :as tmp] [app.util.events :as events] @@ -347,9 +346,7 @@ [cfg team-id] (let [id (uuid/next) tp (dt/tpoint) - - cfg (-> (create-database cfg) - (update ::sto/storage media/configure-assets-storage))] + cfg (create-database cfg)] (l/inf :hint "start" :operation "export" @@ -390,7 +387,6 @@ tp (dt/tpoint) cfg (-> (create-database cfg path) - (update ::sto/storage media/configure-assets-storage) (assoc ::bfc/timestamp (dt/now)))] (l/inf :hint "start" diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index d1315d48b..a06f52950 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -42,9 +42,9 @@ :rpc-rlimit-config "resources/rlimit.edn" :rpc-climit-config "resources/climit.edn" - :file-snapshot-total 10 - :file-snapshot-every 5 - :file-snapshot-timeout "3h" + :auto-file-snapshot-total 10 + :auto-file-snapshot-every 5 + :auto-file-snapshot-timeout "3h" :public-uri "http://localhost:3449" :host "localhost" @@ -52,8 +52,8 @@ :redis-uri "redis://redis/0" - :assets-storage-backend :assets-fs - :storage-assets-fs-directory "assets" + :objects-storage-backend "fs" + :objects-storage-fs-directory "assets" :assets-path "/internal/assets/" :smtp-default-reply-to "Penpot " @@ -91,25 +91,25 @@ [:public-uri {:optional false} :string] [:host {:optional false} :string] - [:http-server-port {:optional true} :int] + [:http-server-port {:optional true} ::sm/int] [:http-server-host {:optional true} :string] - [:http-server-max-body-size {:optional true} :int] - [:http-server-max-multipart-body-size {:optional true} :int] - [:http-server-io-threads {:optional true} :int] - [:http-server-worker-threads {:optional true} :int] + [:http-server-max-body-size {:optional true} ::sm/int] + [:http-server-max-multipart-body-size {:optional true} ::sm/int] + [:http-server-io-threads {:optional true} ::sm/int] + [:http-server-worker-threads {:optional true} ::sm/int] [:telemetry-uri {:optional true} :string] - [:telemetry-with-taiga {:optional true} :boolean] ;; DELETE + [:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE - [:file-snapshot-total {:optional true} :int] - [:file-snapshot-every {:optional true} :int] - [:file-snapshot-timeout {:optional true} ::dt/duration] + [:auto-file-snapshot-total {:optional true} ::sm/int] + [:auto-file-snapshot-every {:optional true} ::sm/int] + [:auto-file-snapshot-timeout {:optional true} ::dt/duration] - [:media-max-file-size {:optional true} :int] + [:media-max-file-size {:optional true} ::sm/int] [:deletion-delay {:optional true} ::dt/duration] ;; REVIEW - [:telemetry-enabled {:optional true} :boolean] - [:default-blob-version {:optional true} :int] - [:allow-demo-users {:optional true} :boolean] + [:telemetry-enabled {:optional true} ::sm/boolean] + [:default-blob-version {:optional true} ::sm/int] + [:allow-demo-users {:optional true} ::sm/boolean] [:error-report-webhook {:optional true} :string] [:user-feedback-destination {:optional true} :string] @@ -118,30 +118,30 @@ [:rpc-climit-config {:optional true} ::fs/path] [:audit-log-archive-uri {:optional true} :string] - [:audit-log-http-handler-concurrency {:optional true} :int] + [:audit-log-http-handler-concurrency {:optional true} ::sm/int] - [:default-executor-parallelism {:optional true} :int] ;; REVIEW - [:scheduled-executor-parallelism {:optional true} :int] ;; REVIEW - [:worker-default-parallelism {:optional true} :int] - [:worker-webhook-parallelism {:optional true} :int] + [:default-executor-parallelism {:optional true} ::sm/int] ;; REVIEW + [:scheduled-executor-parallelism {:optional true} ::sm/int] ;; REVIEW + [:worker-default-parallelism {:optional true} ::sm/int] + [:worker-webhook-parallelism {:optional true} ::sm/int] [:database-password {:optional true} [:maybe :string]] [:database-uri {:optional true} :string] [:database-username {:optional true} [:maybe :string]] - [:database-readonly {:optional true} :boolean] - [:database-min-pool-size {:optional true} :int] - [:database-max-pool-size {:optional true} :int] + [:database-readonly {:optional true} ::sm/boolean] + [:database-min-pool-size {:optional true} ::sm/int] + [:database-max-pool-size {:optional true} ::sm/int] - [:quotes-teams-per-profile {:optional true} :int] - [:quotes-access-tokens-per-profile {:optional true} :int] - [:quotes-projects-per-team {:optional true} :int] - [:quotes-invitations-per-team {:optional true} :int] - [:quotes-profiles-per-team {:optional true} :int] - [:quotes-files-per-project {:optional true} :int] - [:quotes-files-per-team {:optional true} :int] - [:quotes-font-variants-per-team {:optional true} :int] - [:quotes-comment-threads-per-file {:optional true} :int] - [:quotes-comments-per-file {:optional true} :int] + [:quotes-teams-per-profile {:optional true} ::sm/int] + [:quotes-access-tokens-per-profile {:optional true} ::sm/int] + [:quotes-projects-per-team {:optional true} ::sm/int] + [:quotes-invitations-per-team {:optional true} ::sm/int] + [:quotes-profiles-per-team {:optional true} ::sm/int] + [:quotes-files-per-project {:optional true} ::sm/int] + [:quotes-files-per-team {:optional true} ::sm/int] + [:quotes-font-variants-per-team {:optional true} ::sm/int] + [:quotes-comment-threads-per-file {:optional true} ::sm/int] + [:quotes-comments-per-file {:optional true} ::sm/int] [:auth-data-cookie-domain {:optional true} :string] [:auth-token-cookie-name {:optional true} :string] @@ -178,15 +178,15 @@ [:ldap-bind-dn {:optional true} :string] [:ldap-bind-password {:optional true} :string] [:ldap-host {:optional true} :string] - [:ldap-port {:optional true} :int] - [:ldap-ssl {:optional true} :boolean] - [:ldap-starttls {:optional true} :boolean] + [:ldap-port {:optional true} ::sm/int] + [:ldap-ssl {:optional true} ::sm/boolean] + [:ldap-starttls {:optional true} ::sm/boolean] [:ldap-user-query {:optional true} :string] [:profile-bounce-max-age {:optional true} ::dt/duration] - [:profile-bounce-threshold {:optional true} :int] + [:profile-bounce-threshold {:optional true} ::sm/int] [:profile-complaint-max-age {:optional true} ::dt/duration] - [:profile-complaint-threshold {:optional true} :int] + [:profile-complaint-threshold {:optional true} ::sm/int] [:redis-uri {:optional true} :string] @@ -197,26 +197,34 @@ [:smtp-default-reply-to {:optional true} :string] [:smtp-host {:optional true} :string] [:smtp-password {:optional true} [:maybe :string]] - [:smtp-port {:optional true} :int] - [:smtp-ssl {:optional true} :boolean] - [:smtp-tls {:optional true} :boolean] + [:smtp-port {:optional true} ::sm/int] + [:smtp-ssl {:optional true} ::sm/boolean] + [:smtp-tls {:optional true} ::sm/boolean] [:smtp-username {:optional true} [:maybe :string]] [:urepl-host {:optional true} :string] - [:urepl-port {:optional true} :int] + [:urepl-port {:optional true} ::sm/int] [:prepl-host {:optional true} :string] - [:prepl-port {:optional true} :int] + [:prepl-port {:optional true} ::sm/int] - [:assets-storage-backend {:optional true} :keyword] [:media-directory {:optional true} :string] ;; REVIEW [:media-uri {:optional true} :string] [:assets-path {:optional true} :string] + ;; Legacy, will be removed in 2.5 + [:assets-storage-backend {:optional true} :keyword] [:storage-assets-fs-directory {:optional true} :string] [:storage-assets-s3-bucket {:optional true} :string] [:storage-assets-s3-region {:optional true} :keyword] [:storage-assets-s3-endpoint {:optional true} :string] - [:storage-assets-s3-io-threads {:optional true} :int]])) + [:storage-assets-s3-io-threads {:optional true} ::sm/int] + + [:objects-storage-backend {:optional true} :keyword] + [:objects-storage-fs-directory {:optional true} :string] + [:objects-storage-s3-bucket {:optional true} :string] + [:objects-storage-s3-region {:optional true} :keyword] + [:objects-storage-s3-endpoint {:optional true} :string] + [:objects-storage-s3-io-threads {:optional true} ::sm/int]])) (def default-flags [:enable-backend-api-doc @@ -245,7 +253,7 @@ env))) (def decode-config - (sm/decoder schema:config sm/default-transformer)) + (sm/decoder schema:config sm/string-transformer)) (def validate-config (sm/validator schema:config)) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index 097ada50a..2df9a53b1 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -153,7 +153,7 @@ (s/def ::conn some?) (s/def ::nilable-pool (s/nilable ::pool)) (s/def ::pool pool?) -(s/def ::pool-or-conn some?) +(s/def ::connectable some?) (defn closed? [pool] @@ -407,6 +407,7 @@ (ex/raise :type :not-found :code :object-not-found :table table + :params params :hint "database object not found")) row)) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index 03228e45b..eee5ec42a 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -17,6 +17,8 @@ [app.db :as db] [app.db.sql :as sql] [app.email.invite-to-team :as-alias email.invite-to-team] + [app.email.join-team :as-alias email.join-team] + [app.email.request-team-access :as-alias email.request-team-access] [app.metrics :as mtx] [app.util.template :as tmpl] [app.worker :as wrk] @@ -155,10 +157,10 @@ [:map [::username {:optional true} :string] [::password {:optional true} :string] - [::tls {:optional true} :boolean] - [::ssl {:optional true} :boolean] + [::tls {:optional true} ::sm/boolean] + [::ssl {:optional true} ::sm/boolean] [::host {:optional true} :string] - [::port {:optional true} :int] + [::port {:optional true} ::sm/int] [::default-from {:optional true} :string] [::default-reply-to {:optional true} :string]]) @@ -304,6 +306,8 @@ (let [session (create-smtp-session cfg)] (with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))] (.connect ^Transport transport + ^String (::host cfg) + ^String (::port cfg) ^String (::username cfg) ^String (::password cfg)) @@ -311,15 +315,13 @@ (l/dbg :hint "sendmail" :id (:id params) :to (:to params) - :subject (str/trim (:subject params)) - :body (str/join "," (map :type (:body params)))) + :subject (str/trim (:subject params))) (.sendMessage ^Transport transport ^MimeMessage message (.getAllRecipients message)))))) - (when (or (contains? cf/flags :log-emails) - (not (contains? cf/flags :smtp))) + (when (contains? cf/flags :log-emails) (send-to-logger! cfg params)))) (defmethod ig/pre-init-spec ::handler [_] @@ -397,6 +399,79 @@ "Teams member invitation email." (template-factory ::invite-to-team)) + +(s/def ::email.join-team/invited-by ::us/string) +(s/def ::email.join-team/team ::us/string) +(s/def ::email.join-team/team-id ::us/uuid) + +(s/def ::join-team + (s/keys :req-un [::email.join-team/invited-by + ::email.join-team/team-id + ::email.join-team/team])) + +(def join-team + "Teams member joined after request email." + (template-factory ::join-team)) + +(s/def ::email.request-team-access/requested-by ::us/string) +(s/def ::email.request-team-access/requested-by-email ::us/string) +(s/def ::email.request-team-access/team-name ::us/string) +(s/def ::email.request-team-access/team-id ::us/uuid) +(s/def ::email.request-team-access/file-name ::us/string) +(s/def ::email.request-team-access/file-id ::us/uuid) +(s/def ::email.request-team-access/page-id ::us/uuid) + +(s/def ::request-file-access + (s/keys :req-un [::email.request-team-access/requested-by + ::email.request-team-access/requested-by-email + ::email.request-team-access/team-name + ::email.request-team-access/team-id + ::email.request-team-access/file-name + ::email.request-team-access/file-id + ::email.request-team-access/page-id])) + +(def request-file-access + "File access request email." + (template-factory ::request-file-access)) + + +(s/def ::request-file-access-yourpenpot + (s/keys :req-un [::email.request-team-access/requested-by + ::email.request-team-access/requested-by-email + ::email.request-team-access/team-name + ::email.request-team-access/team-id + ::email.request-team-access/file-name + ::email.request-team-access/file-id + ::email.request-team-access/page-id])) + +(def request-file-access-yourpenpot + "File access on Your Penpot request email." + (template-factory ::request-file-access-yourpenpot)) + +(s/def ::request-file-access-yourpenpot-view + (s/keys :req-un [::email.request-team-access/requested-by + ::email.request-team-access/requested-by-email + ::email.request-team-access/team-name + ::email.request-team-access/team-id + ::email.request-team-access/file-name + ::email.request-team-access/file-id + ::email.request-team-access/page-id])) + +(def request-file-access-yourpenpot-view + "File access on Your Penpot view mode request email." + (template-factory ::request-file-access-yourpenpot-view)) + +(s/def ::request-team-access + (s/keys :req-un [::email.request-team-access/requested-by + ::email.request-team-access/requested-by-email + ::email.request-team-access/team-name + ::email.request-team-access/team-id])) + +(def request-team-access + "Team access request email." + (template-factory ::request-team-access)) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BOUNCE/COMPLAINS HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/features/components_v2.clj b/backend/src/app/features/components_v2.clj index 47dc3fad0..5415e70d4 100644 --- a/backend/src/app/features/components_v2.clj +++ b/backend/src/app/features/components_v2.clj @@ -62,6 +62,7 @@ [datoteka.io :as io] [promesa.util :as pu])) + (def ^:dynamic *stats* "A dynamic var for setting up state for collect stats globally." nil) @@ -113,7 +114,7 @@ (sm/lazy-validator ::ctc/color)) (def valid-fill? - (sm/lazy-validator ::cts/fill)) + (sm/lazy-validator cts/schema:fill)) (def valid-stroke? (sm/lazy-validator ::cts/stroke)) @@ -134,10 +135,10 @@ (sm/lazy-validator ::ctc/rgb-color)) (def valid-shape-points? - (sm/lazy-validator ::cts/points)) + (sm/lazy-validator cts/schema:points)) (def valid-image-attrs? - (sm/lazy-validator ::cts/image-attrs)) + (sm/lazy-validator cts/schema:image-attrs)) (def valid-column-grid-params? (sm/lazy-validator ::ctg/column-params)) @@ -1742,7 +1743,7 @@ :validate validate? :skip-on-graphic-error skip-on-graphic-error?) - (db/tx-run! (update system ::sto/storage media/configure-assets-storage) + (db/tx-run! system (fn [system] (binding [*system* system] (when (string? label) diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index baa63f693..1d9a649f3 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -12,10 +12,19 @@ [app.common.logging :as l] [app.db :as db] [app.db.sql :as-alias sql] + [app.storage :as sto] [app.util.blob :as blob] [app.util.objects-map :as omap] [app.util.pointer-map :as pmap])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; OFFLOAD +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn offloaded? + [file] + (= "objects-storage" (:data-backend file))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; OBJECTS-MAP ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -55,31 +64,45 @@ ;; POINTER-MAP ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn get-file-data + "Get file data given a file instance." + [system file] + (if (offloaded? file) + (let [storage (sto/resolve system ::db/reuse-conn true)] + (->> (sto/get-object storage (:data-ref-id file)) + (sto/get-object-bytes storage))) + (:data file))) + +(defn resolve-file-data + [system file] + (let [data (get-file-data system file)] + (assoc file :data data))) + (defn load-pointer "A database loader pointer helper" [system file-id id] - (let [{:keys [content]} (db/get system :file-data-fragment - {:id id :file-id file-id} - {::sql/columns [:content] - ::db/check-deleted false})] + (let [fragment (db/get* system :file-data-fragment + {:id id :file-id file-id} + {::sql/columns [:data :data-backend :data-ref-id :id]})] (l/trc :hint "load pointer" :file-id (str file-id) :id (str id) - :found (some? content)) + :found (some? fragment)) - (when-not content + (when-not fragment (ex/raise :type :internal :code :fragment-not-found :hint "fragment not found" :file-id file-id :fragment-id id)) - (blob/decode content))) + (let [data (get-file-data system fragment)] + ;; FIXME: conditional thread scheduling for decoding big objects + (blob/decode data)))) (defn persist-pointers! - "Given a database connection and the final file-id, persist all - pointers to the underlying storage (the database)." + "Persist all currently tracked pointer objects" [system file-id] (let [conn (db/get-connection system)] (doseq [[id item] @pmap/*tracked*] @@ -89,7 +112,7 @@ (db/insert! conn :file-data-fragment {:id id :file-id file-id - :content content})))))) + :data content})))))) (defn process-pointers "Apply a function to all pointers on the file. Usuly used for diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 06c331849..9a8e69dbf 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -57,11 +57,10 @@ (defn- serve-object "Helper function that returns the appropriate response depending on the storage object backend type." - [{:keys [::sto/storage] :as cfg} {:keys [backend] :as obj}] - (let [backend (sto/resolve-backend storage backend)] - (case (::sto/type backend) - :s3 (serve-object-from-s3 cfg obj) - :fs (serve-object-from-fs cfg obj)))) + [cfg {:keys [backend] :as obj}] + (case backend + (:s3 :assets-s3) (serve-object-from-s3 cfg obj) + (:fs :assets-fs) (serve-object-from-fs cfg obj))) (defn objects-handler "Handler that servers storage objects by id." diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index f70e102ad..de098ad10 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -7,11 +7,13 @@ (ns app.http.middleware (:require [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.logging :as l] + [app.common.schema :as-alias sm] [app.common.transit :as t] [app.config :as cf] [app.http.errors :as errors] - [clojure.data.json :as json] + [app.util.pointer-map :as pmap] [cuerdas.core :as str] [ring.request :as rreq] [ring.response :as rres] @@ -39,16 +41,6 @@ (java.io.BufferedReader. (java.io.InputStreamReader. body)))) -(defn- read-json-key - [k] - (-> k str/kebab keyword)) - -(defn- write-json-key - [k] - (if (or (keyword? k) (symbol? k)) - (str/camel k) - (str k))) - (defn wrap-parse-request [handler] (letfn [(process-request [request] @@ -63,7 +55,7 @@ (str/starts-with? header "application/json") (with-open [reader (get-reader request)] - (let [params (json/read reader :key-fn read-json-key)] + (let [params (json/read reader :key-fn json/read-kebab-key)] (-> request (assoc :body-params params) (update :params merge params)))) @@ -113,6 +105,12 @@ (def ^:const buffer-size (:xnio/buffer-size yt/defaults)) +(defn- write-json-value + [_ val] + (if (pmap/pointer-map? val) + [(pmap/get-id val) (meta val)] + val)) + (defn wrap-format-response [handler] (letfn [(transit-streamable-body [data opts] @@ -134,10 +132,11 @@ (reify rres/StreamableResponseBody (-write-body-to-stream [_ _ output-stream] (try - (with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)] - (with-open [^java.io.OutputStreamWriter writer (java.io.OutputStreamWriter. bos)] - (json/write data writer :key-fn write-json-key))) - + (let [encode (or (-> data meta :encode/json) identity) + data (encode data)] + (with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)] + (with-open [^java.io.OutputStreamWriter writer (java.io.OutputStreamWriter. bos)] + (json/write writer data :key-fn json/write-camel-key :value-fn write-json-value)))) (catch java.io.IOException _) (catch Throwable cause (binding [l/*context* {:value data}] diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 3da84322c..e3f1bebc3 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -60,6 +60,9 @@ (try (let [result (handler)] (events/tap :end result)) + + (catch java.io.EOFException cause + (events/tap :error (errors/handle' cause request))) (catch Throwable cause (l/err :hint "unexpected error on processing sse response" :cause cause) diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj index 864de2987..bac7eecf6 100644 --- a/backend/src/app/http/websocket.clj +++ b/backend/src/app/http/websocket.clj @@ -278,18 +278,18 @@ :inc 1) message) -(def ^:private schema:params - (sm/define - [:map {:title "params"} - [:session-id ::sm/uuid]])) - (defn- http-handler [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 (not profile-id) (ex/raise :type :authentication - :hint "Authentication required.") + :hint "authentication required") ;; WORKAROUND: we use the adapter specific predicate for ;; performance reasons; for now, the ring default impl for diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index c0ca61da9..6b1e7ea28 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -263,6 +263,8 @@ (assoc ::wrk/dedupe dedupe?) (assoc ::wrk/label label) (assoc ::wrk/params (-> params + (dissoc :source) + (dissoc :context) (dissoc :ip-addr) (dissoc :type))))))) params)) diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index cd6385429..4bcd2b009 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -66,21 +66,18 @@ (defmethod ig/init-key ::process-event-handler [_ cfg] (fn [{:keys [props] :as task}] - (let [event (:event props)] - (l/dbg :hint "process webhook event" :name (:name event)) - - (when-let [items (lookup-webhooks cfg event)] - (l/trc :hint "webhooks found for event" :total (count items)) - - (db/tx-run! cfg (fn [cfg] - (doseq [item items] - (wrk/submit! (-> cfg - (assoc ::wrk/task :run-webhook) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 3) - (assoc ::wrk/params {:event event - :config item})))))))))) + (l/dbg :hint "process webhook event" :name (:name props)) + (when-let [items (lookup-webhooks cfg props)] + (l/trc :hint "webhooks found for event" :total (count items)) + (db/tx-run! cfg (fn [cfg] + (doseq [item items] + (wrk/submit! (-> cfg + (assoc ::wrk/task :run-webhook) + (assoc ::wrk/queue :webhooks) + (assoc ::wrk/max-retries 3) + (assoc ::wrk/params {:event props + :config item}))))))))) ;; --- RUN (declare interpret-exception) @@ -138,7 +135,7 @@ (l/dbg :hint "run webhook" :event-name (:name event) - :webhook-id (:id whook) + :webhook-id (str (:id whook)) :webhook-uri (:uri whook) :webhook-mtype (:mtype whook)) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index ee58a21b5..314732d9f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -344,6 +344,8 @@ {:sendmail (ig/ref ::email/handler) :objects-gc (ig/ref :app.tasks.objects-gc/handler) :file-gc (ig/ref :app.tasks.file-gc/handler) + :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/handler) + :offload-file-data (ig/ref :app.tasks.offload-file-data/handler) :file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler) :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) :telemetry (ig/ref :app.tasks.telemetry/handler) @@ -394,9 +396,17 @@ {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} - :app.tasks.file-xlog-gc/handler + :app.tasks.file-gc-scheduler/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.offload-file-data/handler + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage)} + + :app.tasks.file-xlog-gc/handler + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage)} + :app.tasks.telemetry/handler {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) @@ -448,17 +458,28 @@ ::sto/storage {::db/pool (ig/ref ::db/pool) ::sto/backends - {:assets-s3 (ig/ref [::assets :app.storage.s3/backend]) - :assets-fs (ig/ref [::assets :app.storage.fs/backend])}} + {:s3 (ig/ref :app.storage.s3/backend) + :fs (ig/ref :app.storage.fs/backend) - [::assets :app.storage.s3/backend] - {::sto.s3/region (cf/get :storage-assets-s3-region) - ::sto.s3/endpoint (cf/get :storage-assets-s3-endpoint) - ::sto.s3/bucket (cf/get :storage-assets-s3-bucket) - ::sto.s3/io-threads (cf/get :storage-assets-s3-io-threads)} + ;; LEGACY (should not be removed, can only be removed after an + ;; explicit migration because the database objects/rows will + ;; still reference the old names). + :assets-s3 (ig/ref :app.storage.s3/backend) + :assets-fs (ig/ref :app.storage.fs/backend)}} - [::assets :app.storage.fs/backend] - {::sto.fs/directory (cf/get :storage-assets-fs-directory)}}) + :app.storage.s3/backend + {::sto.s3/region (or (cf/get :storage-assets-s3-region) + (cf/get :objects-storage-s3-region)) + ::sto.s3/endpoint (or (cf/get :storage-assets-s3-endpoint) + (cf/get :objects-storage-s3-endpoint)) + ::sto.s3/bucket (or (cf/get :storage-assets-s3-bucket) + (cf/get :objects-storage-s3-bucket)) + ::sto.s3/io-threads (or (cf/get :storage-assets-s3-io-threads) + (cf/get :objects-storage-s3-io-threads))} + + :app.storage.fs/backend + {::sto.fs/directory (or (cf/get :storage-assets-fs-directory) + (cf/get :objects-storage-fs-directory))}}) (def worker-config @@ -485,7 +506,7 @@ :task :tasks-gc} {:cron #app/cron "0 0 2 * * ?" ;; daily - :task :file-gc} + :task :file-gc-scheduler} {:cron #app/cron "0 30 */3,23 * * ?" :task :telemetry} diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 9e1a120fe..4c8ae28ae 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -49,7 +49,7 @@ (sm/register! ::upload [:map {:title "Upload"} [:filename :string] - [:size :int] + [:size ::sm/int] [:path ::fs/path] [:mtype {:optional true} :string] [:headers {:optional true} @@ -313,17 +313,3 @@ (= stype :ttf) (-> (assoc "font/otf" (ttf->otf sfnt)) (assoc "font/ttf" sfnt))))))))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Utility functions -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn configure-assets-storage - "Given storage map, returns a storage configured with the appropriate - backend for assets and optional connection attached." - ([storage] - (assoc storage ::sto/backend (cf/get :assets-storage-backend :assets-fs))) - ([storage pool-or-conn] - (-> (configure-assets-storage storage) - (assoc ::db/pool-or-conn pool-or-conn)))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 86f0fa6f5..5226e5152 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -379,7 +379,40 @@ :fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")} {:name "0120-mod-audit-log-table" - :fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")} + + {:name "0121-mod-file-data-fragment-table" + :fn (mg/resource "app/migrations/sql/0121-mod-file-data-fragment-table.sql")} + + {:name "0122-mod-file-table" + :fn (mg/resource "app/migrations/sql/0122-mod-file-table.sql")} + + {:name "0122-mod-file-data-fragment-table" + :fn (mg/resource "app/migrations/sql/0122-mod-file-data-fragment-table.sql")} + + {:name "0123-mod-file-change-table" + :fn (mg/resource "app/migrations/sql/0123-mod-file-change-table.sql")} + + {:name "0124-mod-profile-table" + :fn (mg/resource "app/migrations/sql/0124-mod-profile-table.sql")} + + {:name "0125-mod-file-table" + :fn (mg/resource "app/migrations/sql/0125-mod-file-table.sql")} + + {:name "0126-add-team-access-request-table" + :fn (mg/resource "app/migrations/sql/0126-add-team-access-request-table.sql")} + + {:name "0127-mod-storage-object-table" + :fn (mg/resource "app/migrations/sql/0127-mod-storage-object-table.sql")} + + {:name "0128-mod-task-table" + :fn (mg/resource "app/migrations/sql/0128-mod-task-table.sql")} + + {:name "0129-mod-file-change-table" + :fn (mg/resource "app/migrations/sql/0129-mod-file-change-table.sql")} + + {:name "0130-mod-file-change-table" + :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0121-mod-file-data-fragment-table.sql b/backend/src/app/migrations/sql/0121-mod-file-data-fragment-table.sql new file mode 100644 index 000000000..bd30e8cb8 --- /dev/null +++ b/backend/src/app/migrations/sql/0121-mod-file-data-fragment-table.sql @@ -0,0 +1,8 @@ +ALTER TABLE file_data_fragment + ADD COLUMN data bytea NULL; + +UPDATE file_data_fragment + SET data = content; + +ALTER TABLE file_data_fragment + DROP COLUMN content; diff --git a/backend/src/app/migrations/sql/0122-mod-file-data-fragment-table.sql b/backend/src/app/migrations/sql/0122-mod-file-data-fragment-table.sql new file mode 100644 index 000000000..87955aea8 --- /dev/null +++ b/backend/src/app/migrations/sql/0122-mod-file-data-fragment-table.sql @@ -0,0 +1,6 @@ +ALTER TABLE file_data_fragment + ADD COLUMN data_backend text NULL, + ADD COLUMN data_ref_id uuid NULL; + +CREATE INDEX IF NOT EXISTS file_data_fragment__data_ref_id__idx + ON file_data_fragment (data_ref_id); diff --git a/backend/src/app/migrations/sql/0122-mod-file-fragment-table.sql b/backend/src/app/migrations/sql/0122-mod-file-fragment-table.sql new file mode 100644 index 000000000..87955aea8 --- /dev/null +++ b/backend/src/app/migrations/sql/0122-mod-file-fragment-table.sql @@ -0,0 +1,6 @@ +ALTER TABLE file_data_fragment + ADD COLUMN data_backend text NULL, + ADD COLUMN data_ref_id uuid NULL; + +CREATE INDEX IF NOT EXISTS file_data_fragment__data_ref_id__idx + ON file_data_fragment (data_ref_id); diff --git a/backend/src/app/migrations/sql/0122-mod-file-table.sql b/backend/src/app/migrations/sql/0122-mod-file-table.sql new file mode 100644 index 000000000..4f0a05155 --- /dev/null +++ b/backend/src/app/migrations/sql/0122-mod-file-table.sql @@ -0,0 +1,4 @@ +ALTER TABLE file ADD COLUMN data_ref_id uuid NULL; + +CREATE INDEX IF NOT EXISTS file__data_ref_id__idx + ON file (data_ref_id); diff --git a/backend/src/app/migrations/sql/0123-mod-file-change-table.sql b/backend/src/app/migrations/sql/0123-mod-file-change-table.sql new file mode 100644 index 000000000..37fccfd51 --- /dev/null +++ b/backend/src/app/migrations/sql/0123-mod-file-change-table.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS file_change__created_at__label__idx + ON file_change (created_at, label); diff --git a/backend/src/app/migrations/sql/0124-mod-profile-table.sql b/backend/src/app/migrations/sql/0124-mod-profile-table.sql new file mode 100644 index 000000000..e9624abd6 --- /dev/null +++ b/backend/src/app/migrations/sql/0124-mod-profile-table.sql @@ -0,0 +1,2 @@ +CREATE INDEX profile__props__newsletter1__idx ON profile (email) WHERE props->>'~:newsletter-news' = 'true'; +CREATE INDEX profile__props__newsletter2__idx ON profile (email) WHERE props->>'~:newsletter-updates' = 'true'; diff --git a/backend/src/app/migrations/sql/0125-mod-file-table.sql b/backend/src/app/migrations/sql/0125-mod-file-table.sql new file mode 100644 index 000000000..20d560bbb --- /dev/null +++ b/backend/src/app/migrations/sql/0125-mod-file-table.sql @@ -0,0 +1,3 @@ +--- This setting allow to optimize the table for heavy write workload +--- leaving space on the page for HOT updates +ALTER TABLE file SET (FILLFACTOR=50); diff --git a/backend/src/app/migrations/sql/0126-add-team-access-request-table.sql b/backend/src/app/migrations/sql/0126-add-team-access-request-table.sql new file mode 100644 index 000000000..548003adb --- /dev/null +++ b/backend/src/app/migrations/sql/0126-add-team-access-request-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE team_access_request ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE, + requester_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE, + valid_until timestamptz NOT NULL, + auto_join_until timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (team_id, requester_id) +); diff --git a/backend/src/app/migrations/sql/0127-mod-storage-object-table.sql b/backend/src/app/migrations/sql/0127-mod-storage-object-table.sql new file mode 100644 index 000000000..521a3fcb0 --- /dev/null +++ b/backend/src/app/migrations/sql/0127-mod-storage-object-table.sql @@ -0,0 +1,3 @@ +--- This setting allow to optimize the table for heavy write workload +--- leaving space on the page for HOT updates +ALTER TABLE storage_object SET (FILLFACTOR=60); diff --git a/backend/src/app/migrations/sql/0128-mod-task-table.sql b/backend/src/app/migrations/sql/0128-mod-task-table.sql new file mode 100644 index 000000000..97fcdbeef --- /dev/null +++ b/backend/src/app/migrations/sql/0128-mod-task-table.sql @@ -0,0 +1,3 @@ +--- This setting allow to optimize the table for heavy write workload +--- leaving space on the page for HOT updates +ALTER TABLE task SET (FILLFACTOR=60); diff --git a/backend/src/app/migrations/sql/0129-mod-file-change-table.sql b/backend/src/app/migrations/sql/0129-mod-file-change-table.sql new file mode 100644 index 000000000..fcf1d4f4c --- /dev/null +++ b/backend/src/app/migrations/sql/0129-mod-file-change-table.sql @@ -0,0 +1,6 @@ +ALTER TABLE file_change + ADD COLUMN data_backend text NULL, + ADD COLUMN data_ref_id uuid NULL; + +CREATE INDEX IF NOT EXISTS file_change__data_ref_id__idx + ON file_change (data_ref_id); diff --git a/backend/src/app/migrations/sql/0130-mod-file-change-table.sql b/backend/src/app/migrations/sql/0130-mod-file-change-table.sql new file mode 100644 index 000000000..272828fc2 --- /dev/null +++ b/backend/src/app/migrations/sql/0130-mod-file-change-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE file_change + ADD COLUMN version integer NULL; diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 09fff7b89..444cf4352 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -149,6 +149,13 @@ :hint "authentication required for this endpoint") (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 [_ f mdata] (if (or (contains? cf/flags :webhooks) @@ -178,41 +185,25 @@ (if-let [schema (::sm/params mdata)] (let [validate (sm/validator schema) explain (sm/explainer schema) - decode (sm/decoder schema)] + decode (sm/decoder schema sm/json-transformer) + encode (sm/encoder schema sm/json-transformer)] (fn [cfg params] (let [params (decode params)] (if (validate params) - (f cfg params) - + (let [result (f cfg params)] + (if (instance? clojure.lang.IObj result) + (vary-meta result assoc :encode/json encode) + result)) (let [params (d/without-qualified params)] (ex/raise :type :validation :code :params-validation ::sm/explain (explain params))))))) f)) -(defn- wrap-output-validation - [_ f mdata] - (if (contains? cf/flags :rpc-output-validation) - (or (when-let [schema (::sm/result mdata)] - (let [schema (if (sm/lazy-schema? schema) - schema - (sm/define schema)) - validate (sm/validator schema) - explain (sm/explainer schema)] - (fn [cfg params] - (let [response (f cfg params)] - (when (map? response) - (when-not (validate response) - (ex/raise :type :validation - :code :data-validation - ::sm/explain (explain response)))) - response)))) - f) - f)) - (defn- wrap-all [cfg f mdata] (as-> f $ + (wrap-db-transaction cfg $ mdata) (cond/wrap cfg $ mdata) (retry/wrap-retry cfg $ mdata) (climit/wrap cfg $ mdata) @@ -220,7 +211,6 @@ (rlimit/wrap cfg $ mdata) (wrap-audit cfg $ mdata) (wrap-spec-conform cfg $ mdata) - (wrap-output-validation cfg $ mdata) (wrap-params-validation cfg $ mdata) (wrap-authentication cfg $ mdata))) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index e8d9675f9..f1cb1d425 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -30,18 +30,17 @@ :tid token-id :iat created-at}) - expires-at (some-> expiration dt/in-future)] - - (db/insert! conn :access-token - {:id token-id - :name name - :token token - :profile-id profile-id - :created-at created-at - :updated-at created-at - :expires-at expires-at - :perms (db/create-array conn "text" [])}))) - + expires-at (some-> expiration dt/in-future) + token (db/insert! conn :access-token + {:id token-id + :name name + :token token + :profile-id profile-id + :created-at created-at + :updated-at created-at + :expires-at expires-at + :perms (db/create-array conn "text" [])})] + (decode-row token))) (defn repl:create-access-token [{:keys [::db/pool] :as system} profile-id name expiration] @@ -60,14 +59,12 @@ (sv/defmethod ::create-access-token {::doc/added "1.18" ::sm/params schema:create-access-token} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}] - (db/with-atomic [conn pool] - (let [cfg (assoc cfg ::db/conn conn)] - (quotes/check-quote! conn - {::quotes/id ::quotes/access-tokens-per-profile - ::quotes/profile-id profile-id}) - (-> (create-access-token cfg profile-id name expiration) - (decode-row))))) + [cfg {:keys [::rpc/profile-id name expiration]}] + + (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile + ::quotes/profile-id profile-id}) + + (db/tx-run! cfg create-access-token profile-id name expiration)) (def ^:private schema:delete-access-token [:map {:title "delete-access-token"} diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index ff8bfdb8f..1ed3fa364 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -27,9 +27,11 @@ [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.setup :as-alias setup] + [app.setup.welcome-file :refer [create-welcome-file]] [app.tokens :as tokens] [app.util.services :as sv] [app.util.time :as dt] + [app.worker :as wrk] [cuerdas.core :as str])) (def schema:password @@ -180,10 +182,11 @@ (defn- validate-register-attempt! [cfg params] - (when-not (contains? cf/flags :registration) - (when-not (contains? params :invitation-token) - (ex/raise :type :restriction - :code :registration-disabled))) + (when (or + (not (contains? cf/flags :registration)) + (not (contains? cf/flags :login-with-password))) + (ex/raise :type :restriction + :code :registration-disabled)) (when (contains? params :invitation-token) (let [invitation (tokens/verify (::setup/props cfg) @@ -240,6 +243,7 @@ params (d/without-nils params) token (tokens/generate (::setup/props cfg) params)] + (with-meta {:token token} {::audit/profile-id uuid/zero}))) @@ -282,6 +286,7 @@ is-demo (:is-demo params false) is-muted (:is-muted params false) is-active (:is-active params false) + theme (:theme params nil) email (str/lower email) params {:id id @@ -292,6 +297,7 @@ :password password :deleted-at (:deleted-at params) :props props + :theme theme :is-active is-active :is-muted is-muted :is-demo is-demo}] @@ -347,30 +353,43 @@ :extra-data ptoken}))) (defn register-profile - [{:keys [::db/conn] :as cfg} {:keys [token fullname] :as params}] - (let [claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register}) + [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}] + (let [theme (when (= theme "light") theme) + claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register}) params (-> claims (into params) - (assoc :fullname fullname)) + (assoc :fullname fullname) + (assoc :theme theme)) profile (if-let [profile-id (:profile-id claims)] (profile/get-profile conn profile-id) - (let [is-active (or (boolean (:is-active claims)) - (not (contains? cf/flags :email-verification))) - params (-> params - (assoc :is-active is-active) - (update :password #(profile/derive-password cfg %)))] - (->> (create-profile! conn params) - (create-profile-rels! conn)))) + ;; NOTE: we first try to match existing profile + ;; by email, that in normal circumstances will + ;; not return anything, but when a user tries to + ;; reuse the same token multiple times, we need + ;; to detect if the profile is already registered + (or (profile/get-profile-by-email conn (:email claims)) + (let [is-active (or (boolean (:is-active claims)) + (not (contains? cf/flags :email-verification))) + params (-> params + (assoc :is-active is-active) + (update :password #(profile/derive-password cfg %))) + profile (->> (create-profile! conn params) + (create-profile-rels! conn))] + (vary-meta profile assoc :created true)))) - ;; When no profile-id comes on claims means a new register - created? (not (:profile-id claims)) + created? (-> profile meta :created true?) invitation (when-let [token (:invitation-token params)] (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 ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -407,6 +426,7 @@ (if (:is-active profile) (-> (profile/strip-private-attrs profile) (rph/with-transform (session/create-fn cfg (:id profile))) + (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props ::audit/context {:action "login"} @@ -416,19 +436,21 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} - {::audit/replace-props props - ::audit/context {:action "email-verification"} - ::audit/profile-id (:id profile)}))) + (-> {:email (:email profile)} + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta + {::audit/replace-props props + ::audit/context {:action "email-verification"} + ::audit/profile-id (:id profile)})))) :else - (let [elapsed? (elapsed-verify-threshold? profile) - complaints? (eml/has-reports? conn (:email profile)) - action (if complaints? - "ignore-because-complaints" - (if elapsed? - "resend-email-verification" - "ignore"))] + (let [elapsed? (elapsed-verify-threshold? profile) + reports? (eml/has-reports? conn (:email profile)) + action (if reports? + "ignore-because-complaints" + (if elapsed? + "resend-email-verification" + "ignore"))] (l/wrn :hint "repeated registry detected" :profile-id (str (:id profile)) @@ -450,7 +472,9 @@ (def schema:register-profile [:map {:title "register-profile"} [:token schema:token] - [:fullname [::sm/word-string {:max 100}]]]) + [:fullname [::sm/word-string {:max 100}]] + [:theme {:optional true} [:string {:max 10}]] + [:create-welcome-file {:optional true} :boolean]]) (sv/defmethod ::register-profile {::rpc/auth false @@ -522,7 +546,6 @@ (create-recovery-token) (send-email-notification conn))))))) - (def schema:request-profile-recovery [:map {:title "request-profile-recovery"} [:email ::sm/email]]) diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 41645a8be..fafecd8b8 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -71,10 +71,15 @@ [conn comment-id & {:as 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 [conn file-id] - (let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?" - res (db/exec-one! conn [sql file-id])] + (let [res (db/exec-one! conn [sql:get-next-seqn file-id])] (:next-seqn res))) (def sql:upsert-comment-thread-status @@ -292,7 +297,7 @@ [:map {:title "create-comment-thread"} [:file-id ::sm/uuid] [:position ::gpt/point] - [:content [:string {:max 250}]] + [:content [:string {:max 750}]] [:page-id ::sm/uuid] [:frame-id ::sm/uuid] [:share-id {:optional true} [:maybe ::sm/uuid]]]) @@ -304,38 +309,43 @@ ::rtry/when rtry/conflict-exception? ::sm/params schema:create-comment-thread} [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}] - (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)] + (let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)] - (run! (partial quotes/check-quote! cfg) - (list {::quotes/id ::quotes/comment-threads-per-file - ::quotes/profile-id profile-id - ::quotes/team-id team-id - ::quotes/project-id project-id - ::quotes/file-id file-id} - {::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})) + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id team-id) + (assoc ::quotes/project-id project-id) + (assoc ::quotes/file-id file-id) + (quotes/check! {::quotes/id ::quotes/comment-threads-per-file} + {::quotes/id ::quotes/comments-per-file})) - (create-comment-thread conn {:created-at request-at - :profile-id profile-id - :file-id file-id - :page-id page-id - :page-name page-name - :position position - :content content - :frame-id frame-id}))))) + (let [params {:created-at request-at + :profile-id profile-id + :file-id file-id + :page-id page-id + :page-name page-name + :position position + :content content + :frame-id frame-id} + thread (db/tx-run! cfg create-comment-thread params)] + + (vary-meta thread assoc ::audit/props 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) thread-id (uuid/next) thread (db/insert! conn :comment-thread @@ -364,7 +374,8 @@ ;; Optimistic update of current seq number on file. (db/update! conn :file {:comment-thread-seqn seqn} - {:id file-id}) + {:id file-id} + {::db/return-keys false}) (-> thread (select-keys [:id :file-id :page-id]) @@ -387,7 +398,6 @@ (files/check-comment-permissions! conn profile-id file-id share-id) (upsert-comment-thread-status! conn profile-id id))))) - ;; --- COMMAND: Update Comment Thread (def ^:private @@ -432,12 +442,11 @@ {: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) - (quotes/check-quote! conn - {::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}) + (quotes/check! cfg {::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}) ;; Update the page-name cached attribute on comment thread table. (when (not= page-name (:page-name thread)) diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 29b79a87b..c641a4ff4 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -21,8 +21,8 @@ (def ^:private schema:send-user-feedback [:map {:title "send-user-feedback"} - [:subject [:string {:max 250}]] - [:content [:string {:max 250}]]]) + [:subject [:string {:max 400}]] + [:content [:string {:max 2500}]]]) (sv/defmethod ::send-user-feedback {::doc/added "1.18" diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 6c9ac43c8..cdf4a0fb9 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -17,6 +17,7 @@ [app.common.schema.desc-js-like :as-alias smdj] [app.common.types.components-list :as ctkl] [app.common.types.file :as ctf] + [app.common.uri :as uri] [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] @@ -68,6 +69,9 @@ :max-version fmg/version)) file)) + +;; --- FILE DATA + ;; --- FILE PERMISSIONS (def ^:private sql:file-permissions @@ -171,38 +175,34 @@ ;; --- COMMAND QUERY: get-file (by id) (def schema:file - (sm/define - [:map {:title "File"} - [:id ::sm/uuid] - [:features ::cfeat/features] - [:has-media-trimmed :boolean] - [:comment-thread-seqn {:min 0} :int] - [:name [:string {:max 250}]] - [:revn {:min 0} :int] - [:modified-at ::dt/instant] - [:is-shared :boolean] - [:project-id ::sm/uuid] - [:created-at ::dt/instant] - [:data {:optional true} :any]])) + [:map {:title "File"} + [:id ::sm/uuid] + [:features ::cfeat/features] + [:has-media-trimmed ::sm/boolean] + [:comment-thread-seqn [::sm/int {:min 0}]] + [:name [:string {:max 250}]] + [:revn [::sm/int {:min 0}]] + [:modified-at ::dt/instant] + [:is-shared ::sm/boolean] + [:project-id ::sm/uuid] + [:created-at ::dt/instant] + [:data {:optional true} :any]]) (def schema:permissions-mixin - (sm/define - [:map {:title "PermissionsMixin"} - [:permissions ::perms/permissions]])) + [:map {:title "PermissionsMixin"} + [:permissions ::perms/permissions]]) (def schema:file-with-permissions - (sm/define - [:merge {:title "FileWithPermissions"} - schema:file - schema:permissions-mixin])) + [:merge {:title "FileWithPermissions"} + schema:file + schema:permissions-mixin]) (def ^:private schema:get-file - (sm/define - [:map {:title "get-file"} - [:features {:optional true} ::cfeat/features] - [:id ::sm/uuid] - [:project-id {:optional true} ::sm/uuid]])) + [:map {:title "get-file"} + [:features {:optional true} ::cfeat/features] + [:id ::sm/uuid] + [:project-id {:optional true} ::sm/uuid]]) (defn- migrate-file [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] @@ -258,58 +258,74 @@ (let [params (merge {:id id} (when (some? project-id) {:project-id project-id})) - file (-> (db/get conn :file params - {::db/check-deleted (not include-deleted?) - ::db/remove-deleted (not include-deleted?) - ::sql/for-update lock-for-update?}) - (decode-row))] + file (->> (db/get conn :file params + {::db/check-deleted (not include-deleted?) + ::db/remove-deleted (not include-deleted?) + ::sql/for-update lock-for-update?}) + (feat.fdata/resolve-file-data cfg) + (decode-row))] (if (and migrate? (fmg/need-migration? file)) (migrate-file cfg file) file))) (defn get-minimal-file [cfg id & {:as opts}] - (let [opts (assoc opts ::sql/columns [:id :modified-at :revn])] + (let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :data-ref-id :data-backend])] (db/get cfg :file {:id id} opts))) +(defn- get-minimal-file-with-perms + [cfg {:keys [:id ::rpc/profile-id]}] + (let [mfile (get-minimal-file cfg id) + perms (get-permissions cfg profile-id id)] + (assoc mfile :permissions perms))) + (defn get-file-etag - [{:keys [::rpc/profile-id]} {:keys [modified-at revn]}] - (str profile-id (dt/format-instant modified-at :iso) revn)) + [{:keys [::rpc/profile-id]} {:keys [modified-at revn permissions]}] + (str profile-id "/" revn "/" + (dt/format-instant modified-at :iso) + "/" + (uri/map->query-string permissions))) (sv/defmethod ::get-file "Retrieve a file by its ID. Only authenticated users." {::doc/added "1.17" - ::cond/get-object #(get-minimal-file %1 (:id %2)) + ::cond/get-object #(get-minimal-file-with-perms %1 %2) ::cond/key-fn get-file-etag ::sm/params schema:get-file - ::sm/result schema:file-with-permissions} - [cfg {:keys [::rpc/profile-id id project-id] :as params}] - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [perms (get-permissions conn profile-id id)] - (check-read-permissions! perms) - (let [team (teams/get-team conn - :profile-id profile-id - :project-id project-id - :file-id id) + ::sm/result schema:file-with-permissions + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}] + ;; The COND middleware makes initial request for a file and + ;; permissions when the incoming request comes with an + ;; ETAG. When ETAG does not matches, the request is resolved + ;; and this code is executed, in this case the permissions + ;; will be already prefetched and we just reuse them instead + ;; of making an additional database queries. + (let [perms (or (:permissions (::cond/object params)) + (get-permissions conn profile-id id))] + (check-read-permissions! perms) - file (-> (get-file cfg id :project-id project-id) - (assoc :permissions perms) - (check-version!)) + (let [team (teams/get-team conn + :profile-id profile-id + :project-id project-id + :file-id id) - _ (-> (cfeat/get-team-enabled-features cf/flags team) - (cfeat/check-client-features! (:features params)) - (cfeat/check-file-features! (:features file) (:features params))) + file (-> (get-file cfg id :project-id project-id) + (assoc :permissions perms) + (check-version!))] - ;; This operation is needed for backward comapatibility with frontends that - ;; does not support pointer-map resolution mechanism; this just resolves the - ;; pointers on backend and return a complete file. - file (if (and (contains? (:features file) "fdata/pointer-map") - (not (contains? (:features params) "fdata/pointer-map"))) - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] - (update file :data feat.fdata/process-pointers deref)) - file)] + (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) - (vary-meta file assoc ::cond/key (get-file-etag params file))))))) + ;; This operation is needed for backward comapatibility with frontends that + ;; does not support pointer-map resolution mechanism; this just resolves the + ;; pointers on backend and return a complete file. + (if (and (contains? (:features file) "fdata/pointer-map") + (not (contains? (:features params) "fdata/pointer-map"))) + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] + (update file :data feat.fdata/process-pointers deref)) + file)))) ;; --- COMMAND QUERY: get-file-fragment (by id) @@ -327,9 +343,11 @@ [:share-id {:optional true} ::sm/uuid]]) (defn- get-file-fragment - [conn file-id fragment-id] - (some-> (db/get conn :file-data-fragment {:file-id file-id :id fragment-id}) - (update :content blob/decode))) + [cfg file-id fragment-id] + (let [resolve-file-data (partial feat.fdata/resolve-file-data cfg)] + (some-> (db/get cfg :file-data-fragment {:file-id file-id :id fragment-id}) + (resolve-file-data) + (update :data blob/decode)))) (sv/defmethod ::get-file-fragment "Retrieve a file fragment by its ID. Only authenticated users." @@ -337,12 +355,12 @@ ::rpc/auth false ::sm/params schema:get-file-fragment ::sm/result schema:file-fragment} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id fragment-id share-id]}] - (dm/with-open [conn (db/open pool)] - (let [perms (get-permissions conn profile-id file-id share-id)] - (check-read-permissions! perms) - (-> (get-file-fragment conn file-id fragment-id) - (rph/with-http-cache long-cache-duration))))) + [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] + (db/run! cfg (fn [cfg] + (let [perms (get-permissions cfg profile-id file-id share-id)] + (check-read-permissions! perms) + (-> (get-file-fragment cfg file-id fragment-id) + (rph/with-http-cache long-cache-duration)))))) ;; --- COMMAND QUERY: get-project-files @@ -402,7 +420,7 @@ "Checks if the file has libraries. Returns a boolean" {::doc/added "1.15.1" ::sm/params schema:has-file-libraries - ::sm/result :boolean} + ::sm/result ::sm/boolean} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! pool profile-id file-id) @@ -481,7 +499,7 @@ [:file-id ::sm/uuid] [:page-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]]) (sv/defmethod ::get-page @@ -723,6 +741,23 @@ [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg get-file-summary (assoc params :profile-id profile-id))) + +;; --- COMMAND QUERY: get-file-info + +(defn- get-file-info + [{:keys [::db/conn] :as cfg} {:keys [id] :as params}] + (db/get* conn :file + {:id id} + {::sql/columns [:id]})) + +(sv/defmethod ::get-file-info + "Retrieve minimal file info by its ID." + {::rpc/auth false + ::doc/added "2.2.0" + ::sm/params schema:get-file} + [cfg params] + (db/tx-run! cfg get-file-info params)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MUTATION COMMANDS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -802,7 +837,8 @@ (db/update! cfg :file {:revn (inc (:revn file)) :data (blob/encode (:data file)) - :modified-at (dt/now)} + :modified-at (dt/now) + :has-media-trimmed false} {:id file-id}) (feat.fdata/persist-pointers! cfg file-id)))) @@ -890,10 +926,9 @@ (def ^:private schema:set-file-shared - (sm/define - [:map {:title "set-file-shared"} - [:id ::sm/uuid] - [:is-shared :boolean]])) + [:map {:title "set-file-shared"} + [:id ::sm/uuid] + [:is-shared ::sm/boolean]]) (sv/defmethod ::set-file-shared {::doc/added "1.17" @@ -920,9 +955,8 @@ (def ^:private schema:delete-file - (sm/define - [:map {:title "delete-file"} - [:id ::sm/uuid]])) + [:map {:title "delete-file"} + [:id ::sm/uuid]]) (defn- delete-file [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] @@ -954,10 +988,9 @@ (def ^:private schema:link-file-to-library - (sm/define - [:map {:title "link-file-to-library"} - [:file-id ::sm/uuid] - [:library-id ::sm/uuid]])) + [:map {:title "link-file-to-library"} + [:file-id ::sm/uuid] + [:library-id ::sm/uuid]]) (sv/defmethod ::link-file-to-library {::doc/added "1.17" @@ -1034,7 +1067,7 @@ (def ^:private schema:ignore-file-library-sync-status [:map {:title "ignore-file-library-sync-status"} [:file-id ::sm/uuid] - [:date ::dt/duration]]) + [:date ::dt/instant]]) ;; TODO: improve naming (sv/defmethod ::ignore-file-library-sync-status diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index b65efa3bf..72c3ab884 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -91,53 +91,56 @@ [:name [:string {:max 250}]] [:project-id ::sm/uuid] [:id {:optional true} ::sm/uuid] - [:is-shared {:optional true} :boolean] + [:is-shared {:optional true} ::sm/boolean] [:features {:optional true} ::cfeat/features]]) (sv/defmethod ::create-file {::doc/added "1.17" ::doc/module :files ::webhooks/event? true - ::sm/params schema:create-file} - [cfg {:keys [::rpc/profile-id project-id] :as params}] - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (projects/check-edition-permissions! conn profile-id project-id) - (let [team (teams/get-team conn - :profile-id profile-id - :project-id project-id) - team-id (:id team) + ::sm/params schema:create-file + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}] + (projects/check-edition-permissions! conn profile-id project-id) + (let [team (teams/get-team conn + :profile-id profile-id + :project-id project-id) + team-id (:id team) - ;; 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. - features (-> (cfeat/get-team-enabled-features cf/flags team) - (cfeat/check-client-features! (:features params))) + ;; 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. + features (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params))) - ;; We also include all no migration features declared by - ;; client; that enables the ability to enable a runtime - ;; feature on frontend and make it permanent on file - features (-> (:features params #{}) - (set/intersection cfeat/no-migration-features) - (set/union features)) + ;; We also include all no migration features declared by + ;; client; that enables the ability to enable a runtime + ;; feature on frontend and make it permanent on file + features (-> (:features params #{}) + (set/intersection cfeat/no-migration-features) + (set/union features)) - params (-> params - (assoc :profile-id profile-id) - (assoc :features features))] + params (-> params + (assoc :profile-id profile-id) + (assoc :features features))] - (run! (partial quotes/check-quote! conn) - (list {::quotes/id ::quotes/files-per-project - ::quotes/team-id team-id - ::quotes/profile-id profile-id - ::quotes/project-id project-id})) + (quotes/check! cfg {::quotes/id ::quotes/files-per-project + ::quotes/team-id team-id + ::quotes/profile-id profile-id + ::quotes/project-id project-id}) - ;; When newly computed features does not match exactly with - ;; 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}))) + ;; FIXME: IMPORTANT: this code can have race + ;; conditions, because we have no locks for updating + ;; team so, creating two files concurrently can lead + ;; to lost team features updating - (-> (create-file cfg params) - (vary-meta assoc ::audit/props {:team-id team-id})))))) + ;; When newly computed features does not match exactly with + ;; 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})))) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 1e9c3081a..2fdb262a0 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -13,13 +13,15 @@ [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] + [app.features.fdata :as feat.fdata] [app.main :as-alias main] - [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.storage :as sto] + [app.util.blob :as blob] + [app.util.pointer-map :as pmap] [app.util.services :as sv] [app.util.time :as dt] [cuerdas.core :as str])) @@ -34,20 +36,21 @@ :code :authentication-required :hint "only admins allowed"))) +(def sql:get-file-snapshots + "SELECT id, label, revn, created_at + FROM file_change + WHERE file_id = ? + AND created_at < ? + AND label IS NOT NULL + ORDER BY created_at DESC + LIMIT ?") + (defn get-file-snapshots [{:keys [::db/conn]} {:keys [file-id limit start-at] :or {limit Long/MAX_VALUE}}] - (let [query (str "select id, label, revn, created_at " - " from file_change " - " where file_id = ? " - " and created_at < ? " - " and data is not null " - " order by created_at desc " - " limit ?") - start-at (or start-at (dt/now)) + (let [start-at (or start-at (dt/now)) limit (min limit 20)] - - (->> (db/exec! conn [query file-id start-at limit]) + (->> (db/exec! conn [sql:get-file-snapshots file-id start-at limit]) (mapv (fn [row] (update row :created-at dt/format-instant :rfc1123)))))) @@ -63,8 +66,8 @@ (db/run! cfg get-file-snapshots params)) (defn restore-file-snapshot! - [{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id id]}] - (let [storage (media/configure-assets-storage storage conn) + [{:keys [::db/conn] :as cfg} {:keys [file-id id]}] + (let [storage (sto/resolve cfg {::db/reuse-conn true}) file (files/get-minimal-file conn file-id {::db/for-update true}) snapshot (db/get* conn :file-change {:file-id file-id @@ -78,43 +81,53 @@ :id id :file-id file-id)) - (when-not (:data snapshot) - (ex/raise :type :precondition - :code :snapshot-without-data - :hint "snapshot has no data" - :label (:label snapshot) - :file-id file-id)) + (let [snapshot (feat.fdata/resolve-file-data cfg snapshot)] + (when-not (:data snapshot) + (ex/raise :type :precondition + :code :snapshot-without-data + :hint "snapshot has no data" + :label (:label snapshot) + :file-id file-id)) - (l/dbg :hint "restoring snapshot" - :file-id (str file-id) - :label (:label snapshot) - :snapshot-id (str (:id snapshot))) + (l/dbg :hint "restoring snapshot" + :file-id (str file-id) + :label (:label snapshot) + :snapshot-id (str (:id snapshot))) - (db/update! conn :file - {:data (:data snapshot) - :revn (inc (:revn file)) - :features (:features snapshot)} - {:id file-id}) + ;; If the file was already offloaded, on restring the snapshot + ;; we are going to replace the file data, so we need to touch + ;; the old referenced storage object and avoid possible leaks + (when (feat.fdata/offloaded? file) + (sto/touch-object! storage (:data-ref-id file))) - ;; clean object thumbnails - (let [sql (str "update file_tagged_object_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - res (db/exec! conn [sql file-id])] + (db/update! conn :file + {:data (:data snapshot) + :revn (inc (:revn file)) + :version (:version snapshot) + :data-backend nil + :data-ref-id nil + :has-media-trimmed false + :features (:features snapshot)} + {:id file-id}) - (doseq [media-id (into #{} (keep :media-id) res)] - (sto/touch-object! storage media-id))) + ;; clean object thumbnails + (let [sql (str "update file_tagged_object_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + res (db/exec! conn [sql file-id])] + (doseq [media-id (into #{} (keep :media-id) res)] + (sto/touch-object! storage media-id))) - ;; clean object thumbnails - (let [sql (str "update file_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - res (db/exec! conn [sql file-id])] - (doseq [media-id (into #{} (keep :media-id) res)] - (sto/touch-object! storage media-id))) + ;; clean file thumbnails + (let [sql (str "update file_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + res (db/exec! conn [sql file-id])] + (doseq [media-id (into #{} (keep :media-id) res)] + (sto/touch-object! storage media-id))) - {:id (:id snapshot) - :label (:label snapshot)})) + {:id (:id snapshot) + :label (:label snapshot)}))) (defn- resolve-snapshot-by-label [conn file-id label] @@ -146,21 +159,33 @@ (merge (resolve-snapshot-by-label conn file-id label)))] (restore-file-snapshot! cfg params))))) +(defn- get-file + [cfg file-id] + (let [file (->> (db/get cfg :file {:id file-id}) + (feat.fdata/resolve-file-data cfg))] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] + (-> file + (update :data blob/decode) + (update :data feat.fdata/process-pointers deref) + (update :data feat.fdata/process-objects (partial into {})) + (update :data blob/encode))))) + (defn take-file-snapshot! - [cfg {:keys [file-id label]}] - (let [conn (db/get-connection cfg) - file (db/get conn :file {:id file-id}) + [cfg {:keys [file-id label ::rpc/profile-id]}] + (let [file (get-file cfg file-id) id (uuid/next)] (l/debug :hint "creating file snapshot" :file-id (str file-id) :label label) - (db/insert! conn :file-change + (db/insert! cfg :file-change {:id id :revn (:revn file) :data (:data file) + :version (:version file) :features (:features file) + :profile-id profile-id :file-id (:id file) :label label} {::db/return-keys false}) diff --git a/backend/src/app/rpc/commands/files_temp.clj b/backend/src/app/rpc/commands/files_temp.clj index 250026076..f639bebdb 100644 --- a/backend/src/app/rpc/commands/files_temp.clj +++ b/backend/src/app/rpc/commands/files_temp.clj @@ -38,44 +38,45 @@ [:name [:string {:max 250}]] [:project-id ::sm/uuid] [:id {:optional true} ::sm/uuid] - [:is-shared :boolean] + [:is-shared ::sm/boolean] [:features ::cfeat/features] - [:create-page :boolean]]) + [:create-page ::sm/boolean]]) (sv/defmethod ::create-temp-file {::doc/added "1.17" ::doc/module :files - ::sm/params schema:create-temp-file} - [cfg {:keys [::rpc/profile-id project-id] :as params}] - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (projects/check-edition-permissions! conn profile-id project-id) - (let [team (teams/get-team conn :profile-id profile-id :project-id project-id) + ::sm/params schema:create-temp-file + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}] + (projects/check-edition-permissions! conn profile-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 - ;; features, because some features can be enabled - ;; globally, but the team is still not migrated properly. - input-features (:features params #{}) + ;; If the imported project doesn't contain v2 we need to remove it + team-features + (cond-> (cfeat/get-team-enabled-features cf/flags team) + (not (contains? input-features "components/v2")) + (disj "components/v2")) - ;; If the imported project doesn't contain v2 we need to remove it - team-features - (cond-> (cfeat/get-team-enabled-features cf/flags team) - (not (contains? input-features "components/v2")) - (disj "components/v2")) + ;; We also include all no migration features declared by + ;; 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))] - ;; We also include all no migration features declared by - ;; 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))))) + (files.create/create-file cfg params))) ;; --- MUTATION COMMAND: update-temp-file @@ -83,7 +84,7 @@ (def ^:private schema:update-temp-file [:map {:title "update-temp-file"} [:changes [:vector ::cpc/change]] - [:revn {:min 0} :int] + [:revn [::sm/int {:min 0}]] [:session-id ::sm/uuid] [:id ::sm/uuid]]) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 446de5378..92c8d16b0 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -179,18 +179,16 @@ (def ^:private schema:get-file-data-for-thumbnail - (sm/define - [:map {:title "get-file-data-for-thumbnail"} - [:file-id ::sm/uuid] - [:features {:optional true} ::cfeat/features]])) + [:map {:title "get-file-data-for-thumbnail"} + [:file-id ::sm/uuid] + [:features {:optional true} ::cfeat/features]]) (def ^:private schema:partial-file - (sm/define - [:map {:title "PartialFile"} - [:id ::sm/uuid] - [:revn {:min 0} :int] - [:page :any]])) + [:map {:title "PartialFile"} + [:id ::sm/uuid] + [:revn {:min 0} ::sm/int] + [:page :any]]) (sv/defmethod ::get-file-data-for-thumbnail "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) VALUES (?, ?, ?, ?) 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 *") (defn- persist-thumbnail! @@ -251,17 +249,19 @@ :content-type mtype :bucket "file-object-thumbnail"}))) - - (defn- create-file-object-thumbnail! - [{:keys [::sto/storage] :as cfg} file-id object-id media tag] - (let [tsnow (dt/now) - media (persist-thumbnail! storage media tsnow) + [{:keys [::sto/storage] :as cfg} file object-id media tag] + (let [file-id (:id file) + timestamp (dt/now) + media (persist-thumbnail! storage media timestamp) [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]) th2 (db/exec-one! conn [sql:create-file-object-thumbnail - file-id object-id tag (:id media) - tsnow (:id media)])] + file-id object-id tag + (:id media) + timestamp + (:id media) + (:deleted-at file)])] [th1 th2])))] (when (and (some? th1) @@ -294,9 +294,8 @@ (media/validate-media-size! media) (db/run! cfg files/check-edition-permissions! profile-id file-id) - - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] - (create-file-object-thumbnail! cfg file-id object-id media (or tag "frame")))) + (when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})] + (create-file-object-thumbnail! cfg file object-id media (or tag "frame")))) ;; --- MUTATION COMMAND: delete-file-object-thumbnail @@ -327,7 +326,7 @@ (files/check-edition-permissions! cfg profile-id file-id) (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (-> cfg - (update ::sto/storage media/configure-assets-storage conn) + (update ::sto/storage sto/configure conn) (delete-file-object-thumbnail! file-id object-id)) nil))) @@ -386,7 +385,7 @@ schema:create-file-thumbnail [:map {:title "create-file-thumbnail"} [:file-id ::sm/uuid] - [:revn :int] + [:revn ::sm/int] [:media ::media/upload]]) (sv/defmethod ::create-file-thumbnail @@ -405,7 +404,6 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (files/check-edition-permissions! conn profile-id file-id) (when-not (db/read-only? conn) - (let [cfg (update cfg ::sto/storage media/configure-assets-storage) - media (create-file-thumbnail! cfg params)] + (let [media (create-file-thumbnail! cfg params)] {:uri (files/resolve-public-uri (:id media)) :id (:id media)}))))) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 76b621b3c..bd98b7071 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -29,6 +29,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.storage :as sto] [app.util.blob :as blob] [app.util.pointer-map :as pmap] [app.util.services :as sv] @@ -37,6 +38,20 @@ [clojure.set :as set] [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 (def ^:private @@ -44,7 +59,7 @@ [:map {:title "update-file"} [:id ::sm/uuid] [:session-id ::sm/uuid] - [:revn {:min 0} :int] + [:revn {:min 0} ::sm/int] [:features {:optional true} ::cfeat/features] [:changes {:optional true} [:vector ::cpc/change]] [:changes-with-metadata {:optional true} @@ -52,7 +67,7 @@ [:changes [:vector ::cpc/change]] [:hint-origin {:optional true} :keyword] [:hint-events {:optional true} [:vector [:string {:max 250}]]]]]] - [:skip-validate {:optional true} :boolean]]) + [:skip-validate {:optional true} ::sm/boolean]]) (def ^:private schema:update-file-result @@ -61,7 +76,7 @@ [:changes [:vector ::cpc/change]] [:file-id ::sm/uuid] [:id ::sm/uuid] - [:revn {:min 0} :int] + [:revn {:min 0} ::sm/int] [:session-id ::sm/uuid]]]) ;; --- HELPERS @@ -96,41 +111,6 @@ (or (contains? library-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 ;; set is different than the persisted one, update it on the ;; database. @@ -146,7 +126,8 @@ ::sm/result schema:update-file-result ::doc/module :files ::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}] (files/check-edition-permissions! conn profile-id id) (db/xact-lock! conn id) @@ -160,14 +141,30 @@ (cfeat/check-client-features! (:features params)) (cfeat/check-file-features! (:features file) (:features params))) - params (assoc params - :profile-id profile-id - :features features - :team team - :file file) + changes (if changes-with-metadata + (->> changes-with-metadata (mapcat :changes) vec) + (vec changes)) + + 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)] + + (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 ;; the features defined on team row, we update it. (when (not= features (:features team)) @@ -176,98 +173,222 @@ {:features features} {:id (:id team)}))) + (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) + (binding [l/*context* (some-> (meta params) (get :app.http/request) (errors/request->context))] - (-> (update-file cfg params) + (-> (update-file* cfg params) (rph/with-defer #(let [elapsed (tpoint)] (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* - [{:keys [::db/conn ::wrk/executor] :as cfg} - {:keys [profile-id file changes session-id ::created-at skip-validate] :as params}] - (let [;; Process the file data on separated thread for avoid to do + "Internal function, part of the update-file process, that encapsulates + 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 + 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 ;; the CPU intensive operation on vthread. - file (px/invoke! executor (partial update-file-data cfg file changes skip-validate)) - features (db/create-array conn "text" (:features file))] + 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))))] - (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) - :label (::snapshot-label file) - :data (::snapshot-data file) - :features (db/create-array conn "text" (:features file)) - :changes (blob/encode changes)} - {::db/return-keys false}) + (when (feat.fdata/offloaded? file) + (let [storage (sto/resolve cfg ::db/reuse-conn true)] + (some->> (:data-ref-id file) (sto/touch-object! storage)))) + ;; TODO: move this to asynchronous task (when (::snapshot-data 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 {:revn (:revn file) :data (:data file) :version (:version file) :features features :data-backend nil - :modified-at created-at + :data-ref-id nil + :modified-at modified-at :has-media-trimmed false} - {:id (:id file)}) + {:id (:id file)} + {::db/return-keys false}))) - (db/update! conn :project - {:modified-at created-at} - {:id (:project-id file)}) +(defn- update-file-data! + "Perform a file data transformation in with all update context setup. - (let [params (assoc params :file file)] - ;; Send asynchronous notifications - (send-notifications! cfg params) + This function expected not-decoded file and transformation function. Returns + an encoded file. - {:revn (:revn file) - :lagged (get-lagged-changes conn params)}))) + This function is not responsible of saving the file. It only saves + 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! [file] @@ -284,68 +405,19 @@ (l/error :hint "file validation error" :cause cause)))) -(defn- update-file-data - [{:keys [::db/conn] :as cfg} file changes skip-validate] - (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) - - ;; WARNING: this ruins performance; maybe we need to find +(defn- process-changes-and-validate + [cfg file changes skip-validate] + (let [;; WARNING: this ruins performance; maybe we need to find ;; some other way to do general validation libs (when (and (or (contains? cf/flags :file-validation) (contains? cf/flags :soft-file-validation)) (not skip-validate)) - (->> (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))) - + (get-file-libraries cfg file)) file (-> (files/check-version! file) (update :revn inc) (update :data cpc/process-changes changes) - (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)] + (update :data d/without-nils))] (binding [pmap/*tracked* nil] (when (contains? cf/flags :soft-file-validation) @@ -362,22 +434,14 @@ (not skip-validate)) (val/validate-file-schema! 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)))) + file)) (defn- take-snapshot? "Defines the rule when file `data` snapshot should be saved." [{:keys [revn modified-at] :as file}] - (when (contains? cf/flags :file-snapshot) - (let [freq (or (cf/get :file-snapshot-every) 20) - timeout (or (cf/get :file-snapshot-timeout) + (when (contains? cf/flags :auto-file-snapshot) + (let [freq (or (cf/get :auto-file-snapshot-every) 20) + timeout (or (cf/get :auto-file-snapshot-timeout) (dt/duration {:hours 1}))] (or (= 1 freq) @@ -401,19 +465,18 @@ "UPDATE file_change SET label = NULL WHERE file_id = ? - AND label IS NOT NULL + AND label LIKE 'internal/%' AND created_at < ?") (defn- delete-old-snapshots! [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] (when-let [snapshots (not-empty (db/exec! conn [sql:get-latest-snapshots id - (cf/get :file-snapshot-total 10)]))] + (cf/get :auto-file-snapshot-total 10)]))] (let [last-date (-> snapshots peek :created-at) 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))))) -(def ^:private - sql:lagged-changes +(def ^:private sql:lagged-changes "select s.id, s.revn, s.file_id, s.session_id, s.changes from file_change as s diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 0942da601..43b90305e 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -86,6 +86,9 @@ [:font-weight [::sm/one-of {:format "number"} valid-weight]] [: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 {::doc/added "1.18" ::climit/id [[:process-font/by-profile ::rpc/profile-id] @@ -95,12 +98,11 @@ [cfg {:keys [::rpc/profile-id team-id] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] - (teams/check-edition-permissions! conn profile-id team-id) - (quotes/check-quote! conn {::quotes/id ::quotes/font-variants-per-team - ::quotes/profile-id profile-id - ::quotes/team-id team-id}) - (create-font-variant cfg (assoc params :profile-id profile-id)))))) + (teams/check-edition-permissions! conn profile-id team-id) + (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team + ::quotes/profile-id profile-id + ::quotes/team-id team-id}) + (create-font-variant cfg (assoc params :profile-id profile-id))))) (defn create-font-variant [{:keys [::sto/storage ::db/conn ::wrk/executor]} {:keys [data] :as params}] @@ -203,14 +205,13 @@ ::sm/params schema:delete-font} [cfg {:keys [::rpc/profile-id id team-id]}] (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage] :as cfg}] + (fn [{:keys [::db/conn] :as cfg}] (teams/check-edition-permissions! conn profile-id team-id) (let [fonts (db/query conn :team-font-variant {:team-id team-id :font-id id :deleted-at nil} {::sql/for-update true}) - storage (media/configure-assets-storage storage conn) tnow (dt/now)] (when-not (seq fonts) @@ -220,11 +221,7 @@ (doseq [font fonts] (db/update! conn :team-font-variant {:deleted-at tnow} - {:id (:id font)}) - (some->> (:woff1-file-id font) (sto/touch-object! storage)) - (some->> (:woff2-file-id font) (sto/touch-object! storage)) - (some->> (:ttf-file-id font) (sto/touch-object! storage)) - (some->> (:otf-file-id font) (sto/touch-object! storage))) + {:id (:id font)})) (rph/with-meta (rph/wrap) {::audit/props {:id id @@ -245,22 +242,16 @@ ::sm/params schema:delete-font-variant} [cfg {:keys [::rpc/profile-id id team-id]}] (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage] :as cfg}] + (fn [{:keys [::db/conn] :as cfg}] (teams/check-edition-permissions! conn profile-id team-id) (let [variant (db/get conn :team-font-variant {:id id :team-id team-id} - {::sql/for-update true}) - storage (media/configure-assets-storage storage conn)] + {::sql/for-update true})] (db/update! conn :team-font-variant {:deleted-at (dt/now)} {:id (:id variant)}) - (some->> (:woff1-file-id variant) (sto/touch-object! storage)) - (some->> (:woff2-file-id variant) (sto/touch-object! storage)) - (some->> (:ttf-file-id variant) (sto/touch-object! storage)) - (some->> (:otf-file-id variant) (sto/touch-object! storage)) - (rph/with-meta (rph/wrap) {::audit/props {:font-family (:font-family variant) :font-id (:font-id variant)}}))))) diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 680541184..fc71a509d 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -88,10 +88,9 @@ (def ^:private schema:duplicate-file - (sm/define - [:map {:title "duplicate-file"} - [:file-id ::sm/uuid] - [:name {:optional true} [:string {:max 250}]]])) + [:map {:title "duplicate-file"} + [:file-id ::sm/uuid] + [:name {:optional true} [:string {:max 250}]]]) (sv/defmethod ::duplicate-file "Duplicate a single file in the same team." @@ -150,10 +149,9 @@ (def ^:private schema:duplicate-project - (sm/define - [:map {:title "duplicate-project"} - [:project-id ::sm/uuid] - [:name {:optional true} [:string {:max 250}]]])) + [:map {:title "duplicate-project"} + [:project-id ::sm/uuid] + [:name {:optional true} [:string {:max 250}]]]) (sv/defmethod ::duplicate-project "Duplicate an entire project with all the files" @@ -327,10 +325,9 @@ (def ^:private schema:move-files - (sm/define - [:map {:title "move-files"} - [:ids ::sm/set-of-uuid] - [:project-id ::sm/uuid]])) + [:map {:title "move-files"} + [:ids ::sm/set-of-uuid] + [:project-id ::sm/uuid]]) (sv/defmethod ::move-files "Move a set of files from one project to other." @@ -382,10 +379,9 @@ (def ^:private schema:move-project - (sm/define - [:map {:title "move-project"} - [:team-id ::sm/uuid] - [:project-id ::sm/uuid]])) + [:map {:title "move-project"} + [:team-id ::sm/uuid] + [:project-id ::sm/uuid]]) (sv/defmethod ::move-project "Move projects between teams" @@ -397,8 +393,8 @@ ;; --- COMMAND: Clone Template -(defn- clone-template - [cfg {:keys [project-id ::rpc/profile-id] :as params} template] +(defn clone-template + [cfg {:keys [project-id profile-id] :as params} template] (db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}] ;; NOTE: the importation process performs some operations that ;; are not very friendly with virtual threads, and for avoid @@ -417,6 +413,7 @@ (doseq [file-id result] (let [props (assoc props :id file-id) event (-> (audit/event-from-rpc-params params) + (assoc ::audit/profile-id profile-id) (assoc ::audit/name "create-file") (assoc ::audit/props props))] (audit/submit! cfg event)))) @@ -425,10 +422,9 @@ (def ^:private schema:clone-template - (sm/define - [:map {:title "clone-template"} - [:project-id ::sm/uuid] - [:template-id ::sm/word-string]])) + [:map {:title "clone-template"} + [:project-id ::sm/uuid] + [:template-id ::sm/word-string]]) (sv/defmethod ::clone-template "Clone into the specified project the template by its id." @@ -439,7 +435,8 @@ [{: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]}) _ (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 (ex/raise :type :not-found diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 992c5d1da..0a5c38e34 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -46,7 +46,7 @@ [:map {:title "upload-file-media-object"} [:id {:optional true} ::sm/uuid] [:file-id ::sm/uuid] - [:is-local :boolean] + [:is-local ::sm/boolean] [:name [:string {:max 250}]] [:content ::media/upload]]) @@ -56,21 +56,19 @@ ::climit/id [[:process-image/by-profile ::rpc/profile-id] [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] + (files/check-edition-permissions! pool profile-id file-id) + (media/validate-media-type! content) + (media/validate-media-size! content) - (files/check-edition-permissions! pool profile-id file-id) - (media/validate-media-type! content) - (media/validate-media-size! content) - - (db/run! cfg (fn [cfg] - (let [object (create-file-media-object cfg params) - props {:name (:name params) - :file-id file-id - :is-local (:is-local params) - :size (:size content) - :mtype (:mtype content)}] - (with-meta object - {::audit/replace-props props})))))) + (db/run! cfg (fn [cfg] + (let [object (create-file-media-object cfg params) + props {:name (:name params) + :file-id file-id + :is-local (:is-local params) + :size (:size content) + :mtype (:mtype content)}] + (with-meta object + {::audit/replace-props props}))))) (defn- big-enough-for-thumbnail? "Checks if the provided image info is big enough for @@ -174,7 +172,7 @@ (def ^:private schema:create-file-media-object-from-url [:map {:title "create-file-media-object-from-url"} [:file-id ::sm/uuid] - [:is-local :boolean] + [:is-local ::sm/boolean] [:url ::sm/uri] [:id {:optional true} ::sm/uuid] [:name {:optional true} [:string {:max 250}]]]) @@ -183,9 +181,8 @@ {::doc/added "1.17" ::sm/params schema:create-file-media-object-from-url} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] - (files/check-edition-permissions! pool profile-id file-id) - (create-file-media-object-from-url cfg (assoc params :profile-id profile-id)))) + (files/check-edition-permissions! pool profile-id file-id) + (create-file-media-object-from-url cfg (assoc params :profile-id profile-id))) (defn download-image [{:keys [::http/client]} uri] @@ -256,7 +253,7 @@ (def ^:private schema:clone-file-media-object [:map {:title "clone-file-media-object"} [:file-id ::sm/uuid] - [:is-local :boolean] + [:is-local ::sm/boolean] [:id ::sm/uuid]]) (sv/defmethod ::clone-file-media-object diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 40b8b8a43..57034c461 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.types.plugins :refer [schema:plugin-registry]] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -40,6 +41,33 @@ (declare strip-private-attrs) (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 "Clean and normalizes email address string" [email] @@ -53,24 +81,6 @@ 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} :boolean] - [:is-blocked {:optional true} :boolean] - [:is-demo {:optional true} :boolean] - [:is-muted {:optional true} :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) (sv/defmethod ::get-profile @@ -99,11 +109,10 @@ (def ^:private schema:update-profile - (sm/define - [:map {:title "update-profile"} - [:fullname [::sm/word-string {:max 250}]] - [:lang {:optional true} [:string {:max 5}]] - [:theme {:optional true} [:string {:max 250}]]])) + [:map {:title "update-profile"} + [:fullname [::sm/word-string {:max 250}]] + [:lang {:optional true} [:string {:max 8}]] + [:theme {:optional true} [:string {:max 250}]]]) (sv/defmethod ::update-profile {::doc/added "1.0" @@ -144,11 +153,10 @@ (def ^:private schema:update-profile-password - (sm/define - [:map {:title "update-profile-password"} - [:password [::sm/word-string {:max 500}]] - ;; Social registered users don't have old-password - [:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]])) + [:map {:title "update-profile-password"} + [:password [::sm/word-string {:max 500}]] + ;; Social registered users don't have old-password + [:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]]) (sv/defmethod ::update-profile-password {::doc/added "1.0" @@ -199,9 +207,8 @@ (def ^:private schema:update-profile-photo - (sm/define - [:map {:title "update-profile-photo"} - [:file ::media/upload]])) + [:map {:title "update-profile-photo"} + [:file ::media/upload]]) (sv/defmethod ::update-profile-photo {:doc/added "1.1" @@ -210,8 +217,7 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] - (update-profile-photo cfg (assoc params :profile-id profile-id)))) + (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}] @@ -269,9 +275,8 @@ (def ^:private schema:request-email-change - (sm/define - [:map {:title "request-email-change"} - [:email ::sm/email]])) + [:map {:title "request-email-change"} + [:email ::sm/email]]) (sv/defmethod ::request-email-change {::doc/added "1.0" @@ -352,36 +357,38 @@ :extra-data ptoken}) nil)) - ;; --- MUTATION: Update Profile Props (def ^:private schema:update-profile-props - (sm/define - [:map {:title "update-profile-props"} - [:props [:map-of :keyword :any]]])) + [:map {:title "update-profile-props"} + [:props schema:props]]) + +(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 {::doc/added "1.0" ::sm/params schema:update-profile-props} - [{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] - (db/with-atomic [conn pool] - (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)))) + [cfg {:keys [::rpc/profile-id props]}] + (db/tx-run! cfg (fn [cfg] + (update-profile-props cfg profile-id props)))) ;; --- MUTATION: Delete Profile diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 4901a6efd..1b8310232 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -168,6 +168,17 @@ ;; --- 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 [:map {:title "create-project"} [:team-id ::sm/uuid] @@ -178,23 +189,15 @@ {::doc/added "1.18" ::webhooks/event? true ::sm/params schema:create-project} - [{:keys [::db/pool] :as 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}) + [cfg {:keys [::rpc/profile-id team-id] :as params}] - (let [params (assoc params :profile-id profile-id) - 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)))) + (teams/check-edition-permissions! cfg profile-id team-id) + (quotes/check! cfg {::quotes/id ::quotes/projects-per-team + ::quotes/profile-id profile-id + ::quotes/team-id team-id}) + (let [params (assoc params :profile-id profile-id)] + (db/tx-run! cfg create-project params))) ;; --- MUTATION: Toggle Project Pin @@ -208,7 +211,7 @@ (def ^:private schema:update-project-pin [:map {:title "update-project-pin"} [:team-id ::sm/uuid] - [:is-pinned :boolean] + [:is-pinned ::sm/boolean] [:id ::sm/uuid]]) (sv/defmethod ::update-project-pin diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 74918de97..e162e3358 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -15,6 +15,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.db.sql :as sql] [app.email :as eml] [app.loggers.audit :as audit] [app.main :as-alias main] @@ -28,6 +29,7 @@ [app.setup :as-alias setup] [app.storage :as sto] [app.tokens :as tokens] + [app.util.blob :as blob] [app.util.services :as sv] [app.util.time :as dt] [app.worker :as wrk] @@ -80,6 +82,35 @@ (cond-> row (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" + [conn member] + (let [email (profile/clean-email (:email member))] + (when (and member (not (eml/allow-send-emails? conn member))) + (ex/raise :type :validation + :code :member-is-muted + :email email + :hint "the profile has reported repeatedly as spam or has bounces")))) + +(defn- check-email-bounce + "Check if the email is part of the global complain report" + [conn email show?] + (when (eml/has-bounce-reports? conn email) + (ex/raise :type :restriction + :code :email-has-permanent-bounces + :email (if show? email "private") + :hint "this email has been repeatedly reported as bounce"))) + +(defn- check-email-spam + "Check if the member email is part of the global complain report" + [conn email show?] + (when (eml/has-complaint-reports? conn email) + (ex/raise :type :restriction + :code :email-has-complaints + :email (if show? email "private") + :hint "this email has been repeatedly reported as spam"))) + + ;; --- Query: Teams (declare get-teams) @@ -194,16 +225,16 @@ ;; --- Query: Team Members (def sql:team-members - "select tp.*, + "SELECT tp.*, p.id, p.email, - p.fullname as name, - p.fullname as fullname, + p.fullname AS name, + p.fullname AS fullname, p.photo_id, p.is_active - from team_profile_rel as tp - join profile as p on (p.id = tp.profile_id) - where tp.team_id = ?") + FROM team_profile_rel AS tp + JOIN profile AS p ON (p.id = tp.profile_id) + WHERE tp.team_id = ?") (defn get-team-members [conn team-id] @@ -333,6 +364,24 @@ (check-read-permissions! conn profile-id team-id) (get-team-invitations conn team-id))) + +;; --- COMMAND QUERY: get-team-info + +(defn- get-team-info + [{:keys [::db/conn] :as cfg} {:keys [id] :as params}] + (db/get* conn :team + {:id id} + {::sql/columns [:id :is-default]})) + +(sv/defmethod ::get-team-info + "Retrieve minimal team info by its ID." + {::rpc/auth false + ::doc/added "2.2.0" + ::sm/params schema:get-team} + [cfg params] + (db/tx-run! cfg get-team-info params)) + + ;; --- Mutation: Create Team (declare create-team) @@ -352,17 +401,19 @@ {::doc/added "1.17" ::sm/params schema:create-team} [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) - (cfeat/check-client-features! (:features params))) - team (create-team cfg (assoc params - :profile-id profile-id - :features features))] - (with-meta team - {::audit/props {:id (:id team)}}))))) + (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile + ::quotes/profile-id profile-id}) + + (let [features (-> (cfeat/get-enabled-features cf/flags) + (cfeat/check-client-features! (:features params))) + params (-> params + (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 "This is a complete team creation process, it creates the team @@ -674,8 +725,7 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] - (update-team-photo cfg (assoc params :profile-id profile-id)))) + (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}] @@ -717,36 +767,51 @@ :member-id member-id})) (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) {:iss :profile-identity - :profile-id (:id profile) + :profile-id profile-id :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 [{: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) member (profile/get-profile-by-email conn email)] - (when (and member (not (eml/allow-send-emails? conn member))) - (ex/raise :type :validation - :code :member-is-muted - :email email - :hint "the profile has reported repeatedly as spam or has bounces")) - - ;; Secondly check if the invited member email is part of the global bounce report. - (when (eml/has-bounce-reports? conn email) - (ex/raise :type :restriction - :code :email-has-permanent-bounces - :email email - :hint "the email you invite has been repeatedly reported as bounce")) - - ;; Secondly check if the invited member email is part of the global complain report. - (when (eml/has-complaint-reports? conn email) - (ex/raise :type :restriction - :code :email-has-complaints - :email email - :hint "the email you invite has been repeatedly reported as spam")) + (check-profile-muted conn member) + (check-email-bounce conn email true) + (check-email-spam conn email true) ;; When we have email verification disabled and invitation user is ;; already present in the database, we proceed to add it to the @@ -780,7 +845,8 @@ (name role) expire (name role) expire]) updated? (not= id (:id invitation)) - tprops {:profile-id (:id profile) + profile-id (:id profile) + tprops {:profile-id profile-id :invitation-id (:id invitation) :valid-until expire :team-id (:id team) @@ -788,12 +854,11 @@ :member-id (:id member) :role role} 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) (l/info :hint "invitation token" :token itoken)) - (let [props (-> (dissoc tprops :profile-id) (audit/clean-props)) evname (if updated? @@ -815,63 +880,142 @@ itoken)))) +(defn- add-user-to-team + [conn profile team role email] + + (let [team-id (:id team) + member (db/get* conn :profile + {:email (str/lower email)} + {::sql/columns [:id :email]}) + params (merge + {:team-id team-id + :profile-id (:id member)} + (role->params role))] + + ;; Do not allow blocked users to join teams. + (when (:is-blocked member) + (ex/raise :type :restriction + :code :profile-blocked)) + + (quotes/check! + {::db/conn conn + ::quotes/id ::quotes/profiles-per-team + ::quotes/profile-id (:id member) + ::quotes/team-id team-id}) + + ;; Insert the member to the team + (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) + + ;; Delete any request + (db/delete! conn :team-access-request + {:team-id team-id :requester-id (:id member)}) + + ;; Delete any invitation + (db/delete! conn :team-invitation + {:team-id team-id :email-to (:email member)}) + + (eml/send! {::eml/conn conn + ::eml/factory eml/join-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :team-id (:id team)}))) + +(def sql:valid-requests-email + "SELECT p.email + FROM team_access_request AS tr + JOIN profile AS p ON (tr.requester_id = p.id) + WHERE tr.team_id = ? + AND tr.auto_join_until > now()") + +(defn- get-valid-requests-email + [conn 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 [:map {:title "create-team-invitations"} [:team-id ::sm/uuid] [:role schema:role] - [:emails ::sm/set-of-emails]]) + [: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 "A rpc call that allow to send a single or multiple invitations to join the team." {::doc/added "1.17" ::sm/params schema:create-team-invitations} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id emails role] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id team-id) - profile (db/get-by-id conn :profile profile-id) - team (db/get-by-id conn :team team-id) - emails (into #{} (map profile/clean-email) emails)] + [cfg {:keys [::rpc/profile-id team-id emails] :as params}] + (let [perms (get-permissions cfg profile-id team-id) + profile (db/get-by-id cfg :profile profile-id) + emails (into #{} (map profile/clean-email) emails)] - (run! (partial quotes/check-quote! conn) - (list {::quotes/id ::quotes/invitations-per-team - ::quotes/profile-id profile-id - ::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) + (ex/raise :type :validation + :code :insufficient-permissions)) - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) - ;; First check if the current profile is allowed to send emails. - (when-not (eml/allow-send-emails? conn profile) - (ex/raise :type :validation - :code :profile-is-muted - :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) + (-> cfg + (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})) - (let [cfg (assoc cfg ::db/conn conn) - members (->> (db/exec! conn [sql:team-members team-id]) - (into #{} (map :email))) + ;; Check if the current profile is allowed to send emails + (check-profile-muted cfg profile) - invitations (into #{} - (comp - ;; We don't re-send inviation to already existing members - (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)] - (with-meta {:total (count invitations) - :invitations invitations} - {::audit/props {:invitations (count invitations)}}))))) + (let [team (db/get-by-id cfg :team team-id) + ;; NOTE: Is important pass RPC method params down to the + ;; `create-team-invitations` because it uses the implicit + ;; RPC properties from params for fill necessary data on + ;; emiting an entry to the audit-log + invitations (db/tx-run! cfg create-team-invitations + (-> params + (assoc :profile profile) + (assoc :team team) + (assoc :emails emails)))] + + (with-meta {:total (count invitations) + :invitations invitations} + {::audit/props {:invitations (count invitations)}})))) ;; --- Mutation: Create Team & Invite Members @@ -880,57 +1024,55 @@ [:name [:string {:max 250}]] [:features {:optional true} ::cfeat/features] [:id {:optional true} ::sm/uuid] - [:emails ::sm/set-of-emails] + [:emails [::sm/set ::sm/email]] [:role schema:role]]) (sv/defmethod ::create-team-with-invitations {::doc/added "1.17" - ::sm/params schema:create-team-with-invitations} - [cfg {:keys [::rpc/profile-id emails role name] :as params}] + ::sm/params schema:create-team-with-invitations + ::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 - (fn [{:keys [::db/conn] :as cfg}] - (let [features (-> (cfeat/get-enabled-features cf/flags) - (cfeat/check-client-features! (:features params))) + params (-> params + (assoc :profile-id profile-id) + (assoc :features features)) - params (-> params - (assoc :profile-id profile-id) - (assoc :features features)) + team (create-team cfg params) + emails (into #{} (map profile/clean-email) emails)] - cfg (assoc cfg ::db/conn conn) - team (create-team cfg params) - profile (db/get-by-id conn :profile profile-id) - emails (into #{} (map profile/clean-email) emails)] + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id (:id team)) + (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} - event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "create-team") - (assoc ::audit/props props))] - (audit/submit! cfg event)) + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) - ;; Create invitations for all provided emails. - (->> emails - (map (fn [email] - (-> params - (assoc :team team) - (assoc :profile profile) - (assoc :email email) - (assoc :role role)))) - (run! (partial create-invitation cfg))) + (let [props {:name name :features features} + event (-> (audit/event-from-rpc-params params) + (assoc ::audit/name "create-team") + (assoc ::audit/props props))] + (audit/submit! cfg event)) - (run! (partial quotes/check-quote! conn) - (list {::quotes/id ::quotes/teams-per-profile - ::quotes/profile-id profile-id} - {::quotes/id ::quotes/invitations-per-team - ::quotes/profile-id profile-id - ::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)})) + ;; Create invitations for all provided emails. + (let [profile (db/get-by-id conn :profile profile-id) + params (-> params + (assoc :team team) + (assoc :profile profile) + (assoc :role role)) + invitations (->> emails + (map (fn [email] (assoc params :email email))) + (map (partial create-invitation cfg)))] - (vary-meta team assoc ::audit/props {:invitations (count emails)}))))) + (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) ;; --- Query: get-team-invitation-token @@ -1007,3 +1149,130 @@ :email-to (profile/clean-email email)} {::db/return-keys true})] (rph/wrap nil {::audit/props {:invitation-id (:id invitation)}}))))) + + + + +;; --- Mutation: Request Team Invitation + +(def sql:upsert-team-access-request + "INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until) + VALUES (?, ?, ?, ?, ?) + ON conflict(id) + DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now() + RETURNING *") + + +(def sql:team-access-request + "SELECT id, (valid_until < now()) AS expired + FROM team_access_request + WHERE team_id = ? + AND requester_id = ?") + +(def sql:team-owner + "SELECT profile_id + FROM team_profile_rel + WHERE team_id = ? + AND is_owner = true") + + +(defn- create-team-access-request + [{:keys [::db/conn] :as cfg} {:keys [team requester team-owner file is-viewer] :as params}] + (let [old-request (->> (db/exec-one! conn [sql:team-access-request (:id team) (:id requester)]) + (decode-row))] + (when (false? (:expired old-request)) + (ex/raise :type :validation + :code :request-already-sent + :hint "you have already made a request to join this team less than 24 hours ago")) + + (let [id (or (:id old-request) (uuid/next)) + valid_until (dt/in-future "24h") + auto_join_until (dt/in-future "168h") ;; 7 days + request (db/exec-one! conn [sql:upsert-team-access-request + id (:id team) (:id requester) valid_until auto_join_until + valid_until auto_join_until]) + factory (cond + (and (some? file) (:is-default team) is-viewer) + eml/request-file-access-yourpenpot-view + (and (some? file) (:is-default team)) + eml/request-file-access-yourpenpot + (some? file) + eml/request-file-access + :else + eml/request-team-access) + page-id (when (some? file) + (-> file :data :pages first))] + + ;; TODO needs audit? + + (eml/send! {::eml/conn conn + ::eml/factory factory + :public-uri (cf/get :public-uri) + :to (:email team-owner) + :requested-by (:fullname requester) + :requested-by-email (:email requester) + :team-name (:name team) + :team-id (:id team) + :file-name (:name file) + :file-id (:id file) + :page-id page-id}) + + request))) + + +(def ^:private schema:create-team-access-request + [:and + [:map {:title "create-team-access-request"} + [:file-id {:optional true} ::sm/uuid] + [:team-id {:optional true} ::sm/uuid] + [:is-viewer {:optional true} ::sm/boolean]] + + [:fn (fn [params] + (or (contains? params :file-id) + (contains? params :team-id)))]]) + + +(sv/defmethod ::create-team-access-request + "A rpc call that allow to request for an invitations to join the team." + {::doc/added "2.2.0" + ::sm/params schema:create-team-access-request} + [cfg {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}] + + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + + (let [requester (db/get-by-id conn :profile profile-id) + team-id (if (some? team-id) + team-id + (:id (get-team-for-file conn file-id))) + team (db/get-by-id conn :team team-id) + owner-id (->> (db/exec! conn [sql:team-owner (:id team)]) + (map decode-row) + (first) + :profile-id) + team-owner (db/get-by-id conn :profile owner-id) + file (when (some? file-id) + (db/get* conn :file + {:id file-id} + {::sql/columns [:id :name :data]})) + file (when (some? file) + (assoc file :data (blob/decode (:data file))))] + + ;;TODO needs quotes? + + (when (or (nil? requester) (nil? team) (nil? team-owner) (and (some? file-id) (nil? file))) + (ex/raise :type :validation + :code :invalid-parameters)) + + ;; Check that the requester is not muted + (check-profile-muted conn requester) + + ;; Check that the owner is not marked as bounce nor spam + (check-email-bounce conn (:email team-owner) false) + (check-email-spam conn (:email team-owner) true) + + (let [request (create-team-access-request + cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})] + (when request + (with-meta {:request request} + {::audit/props {:request 1}}))))))) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 0e4f3c89f..7f0dd6b5f 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -8,6 +8,7 @@ (:require [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] [app.http.session :as session] @@ -29,21 +30,19 @@ (def ^:private schema:verify-token [:map {:title "verify-token"} - [:token [:string {:max 1000}]]]) + [:token [:string {:max 5000}]]]) (sv/defmethod ::verify-token {::rpc/auth false ::doc/added "1.15" ::doc/module :auth ::sm/params schema:verify-token} - [{:keys [::db/pool] :as cfg} {:keys [token] :as params}] - (db/with-atomic [conn pool] - (let [claims (tokens/verify (::setup/props cfg) {:token token}) - cfg (assoc cfg :conn conn)] - (process-token cfg params claims)))) + [cfg {:keys [token] :as params}] + (let [claims (tokens/verify (::setup/props cfg) {:token token})] + (db/tx-run! cfg process-token params claims))) (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)] (when (profile/get-profile-by-email conn email) (ex/raise :type :validation @@ -59,7 +58,7 @@ ::audit/profile-id profile-id}))) (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) claims (assoc claims :profile profile)] @@ -80,22 +79,14 @@ ::audit/profile-id (:id profile)})))) (defmethod process-token :auth - [{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}] - (let [profile (profile/get-profile conn profile-id {::sql/for-update true}) - props (merge (:props 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)))) + [{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}] + (let [profile (profile/get-profile conn profile-id)] + (assoc claims :profile profile))) ;; --- Team 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 role (or (some-> invitation :role keyword) role) params (merge @@ -108,10 +99,9 @@ (ex/raise :type :restriction :code :profile-blocked)) - (quotes/check-quote! conn - {::quotes/id ::quotes/profiles-per-team - ::quotes/profile-id (:id member) - ::quotes/team-id team-id}) + (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team + ::quotes/profile-id (:id member) + ::quotes/team-id team-id}) ;; Insert the invited member to the team (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) @@ -127,6 +117,10 @@ (db/delete! conn :team-invitation {:team-id team-id :email-to member-email}) + ;; Delete any request + (db/delete! conn :team-access-request + {:team-id team-id :requester-id (:id member)}) + (assoc member :is-active true))) (def schema:team-invitation-claims @@ -143,7 +137,7 @@ (sm/lazy-validator schema:team-invitation-claims)) (defmethod process-token :team-invitation - [{:keys [conn] :as cfg} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id token] :as params} {:keys [member-id team-id member-email] :as claims}] @@ -152,11 +146,12 @@ :code :invalid-invitation-token :hint "invitation token contains unexpected data")) - (let [invitation (db/get* conn :team-invitation - {:team-id team-id :email-to member-email}) - profile (db/get* conn :profile - {:id profile-id} - {:columns [:id :email]})] + (let [invitation (db/get* conn :team-invitation + {:team-id team-id :email-to member-email}) + profile (db/get* conn :profile + {:id profile-id} + {:columns [:id :email]}) + registration-disabled? (not (contains? cf/flags :registration))] (when (nil? invitation) (ex/raise :type :validation :code :invalid-token @@ -185,12 +180,12 @@ :hint "logged-in user does not matches the invitation")) ;; If we have not logged-in user, and invitation comes with member-id we - ;; redirect user to login, if no memeber-id is present in the invitation - ;; token, we redirect user the the register page. + ;; redirect user to login, if no memeber-id is present and in the invitation + ;; token and registration is enabled, we redirect user the the register page. {:invitation-token token :iss :team-invitation - :redirect-to (if member-id :auth-login :auth-register) + :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) :state :pending}))) ;; --- Default diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 2649a73a4..e2a56691e 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -111,7 +111,7 @@ [:id ::sm/uuid] [:uri ::sm/uri] [:mtype [::sm/one-of {:format "string"} valid-mtypes]] - [:is-active :boolean]]) + [:is-active ::sm/boolean]]) (sv/defmethod ::update-webhook {::doc/added "1.17" diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index 3fe03c821..2c79d8f66 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -48,20 +48,25 @@ (str "W/\"" (encode s) "\"")) (defn wrap - [_ f {:keys [::get-object ::key-fn ::reuse-key?] :as mdata}] + [_ f {:keys [::get-object ::key-fn ::reuse-key?] :or {reuse-key? true} :as mdata}] (if (and (ifn? get-object) (ifn? key-fn)) (do (l/trc :hint "instrumenting method" :service (::sv/name mdata)) (fn [cfg {:keys [::key] :as params}] (if *enabled* - (let [key' (when (or key reuse-key?) - (some->> (get-object cfg params) (key-fn params) (fmt-key)))] + (let [object (when (some? key) + (get-object cfg params)) + key' (when (some? object) + (->> object (key-fn params) (fmt-key)))] (if (and (some? key) (= key key')) (fn [_] {::rres/status 304}) - (let [result (f cfg params) + (let [params (if (some? object) + (assoc params ::object object) + params) + result (f cfg params) etag (or (and reuse-key? key') - (some-> result meta ::key fmt-key) - (some-> result key-fn fmt-key))] + (some->> result meta ::key fmt-key) + (some->> result (key-fn params) fmt-key))] (rph/with-header result "etag" etag)))) (f cfg params)))) f)) diff --git a/backend/src/app/rpc/doc.clj b/backend/src/app/rpc/doc.clj index 185f3fc4c..ea973ff7a 100644 --- a/backend/src/app/rpc/doc.clj +++ b/backend/src/app/rpc/doc.clj @@ -26,7 +26,6 @@ [clojure.spec.alpha :as s] [cuerdas.core :as str] [integrant.core :as ig] - [malli.transform :as mt] [pretty-spec.core :as ps] [ring.response :as-alias rres])) @@ -98,77 +97,79 @@ ;; OPENAPI / SWAGGER (v3.1) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def output-transformer - (mt/transformer - sm/default-transformer - (mt/key-transformer {:encode str/camel - :decode (comp keyword str/kebab)}))) - (defn prepare-openapi-context [methods] - (letfn [(gen-response-doc [tsx schema] - (let [schema (sm/schema schema) - example (sm/generate schema) - example (sm/encode schema example output-transformer)] - {:default - {:description "A default response" - :content - {"application/json" - {:schema tsx - :example example}}}})) + (let [definitions (atom {}) + options {:registry sr/default-registry + ::oapi/definitions-path "#/components/schemas/" + ::oapi/definitions definitions} - (gen-params-doc [tsx schema] - (let [example (sm/generate schema) - example (sm/encode schema example output-transformer)] - {:required true - :content - {"application/json" - {:schema tsx - :example example}}})) + output-transformer + (sm/json-transformer) - (gen-method-doc [options mdata] - (let [pschema (::sm/params mdata) - rschema (::sm/result mdata) + gen-response-doc + (fn [tsx schema] + (let [schema (sm/schema schema) + example (sm/generate schema) + example (sm/encode schema example output-transformer)] + {:default + {:description "A default response" + :content + {"application/json" + {:schema tsx + :example example}}}})) - sparams (-> pschema (oapi/transform options) (gen-params-doc pschema)) - sresp (some-> rschema (oapi/transform options) (gen-response-doc rschema)) + gen-params-doc + (fn [tsx schema] + (let [example (sm/generate schema) + example (sm/encode schema example output-transformer)] + {:required true + :content + {"application/json" + {:schema tsx + :example example}}})) - rpost {:description (::sv/docstring mdata) - :deprecated (::deprecated mdata false) - :requestBody sparams} + gen-method-doc + (fn [mdata] + (let [pschema (::sm/params mdata) + rschema (::sm/result mdata) - rpost (cond-> rpost - (some? sresp) - (assoc :responses sresp))] + sparams (-> pschema (oapi/transform options) (gen-params-doc pschema)) + sresp (some-> rschema (oapi/transform options) (gen-response-doc rschema)) - {:name (-> mdata ::sv/name d/name) - :module (-> (:ns mdata) (str/split ".") last) - :repr {:post rpost}}))] + rpost {:description (::sv/docstring mdata) + :deprecated (::deprecated mdata false) + :requestBody sparams} - (let [definitions (atom {}) - options {:registry sr/default-registry - ::oapi/definitions-path "#/components/schemas/" - ::oapi/definitions definitions} + rpost (cond-> rpost + (some? sresp) + (assoc :responses sresp))] - paths (binding [oapi/*definitions* definitions] - (->> methods - (map (comp first val)) - (filter ::sm/params) - (map (partial gen-method-doc options)) - (sort-by (juxt :module :name)) - (map (fn [doc] - [(str/ffmt "/command/%" (:name doc)) (:repr doc)])) - (into {})))] - {:openapi "3.0.0" - :info {:version (:main cf/version)} - :servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri)) + {:name (-> mdata ::sv/name d/name) + :module (-> (:ns mdata) (str/split ".") last) + :repr {:post rpost}})) + + paths + (binding [oapi/*definitions* definitions] + (->> methods + (map (comp first val)) + (filter ::sm/params) + (map gen-method-doc) + (sort-by (juxt :module :name)) + (map (fn [doc] + [(str/ffmt "/command/%" (:name doc)) (:repr doc)])) + (into {})))] + + {:openapi "3.0.0" + :info {:version (:main cf/version)} + :servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri)) ;; :description "penpot backend" - }] - :security - {:api_key []} + }] + :security + {:api_key []} - :paths paths - :components {:schemas @definitions}}))) + :paths paths + :components {:schemas @definitions}})) (defn openapi-json-handler [context] diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index ef1d71072..0704d70ed 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -15,11 +15,11 @@ (sm/register! ::permissions [:map {:title "Permissions"} [:type {:gen/elements [:membership :share-link]} :keyword] - [:is-owner :boolean] - [:is-admin :boolean] - [:can-edit :boolean] - [:can-read :boolean] - [:is-logged :boolean]]) + [:is-owner ::sm/boolean] + [:is-admin ::sm/boolean] + [:can-edit ::sm/boolean] + [:can-read ::sm/boolean] + [:is-logged ::sm/boolean]]) (s/def ::role #{:admin :owner :editor :viewer}) diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 87f9bf7f7..888a5e9ab 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -7,16 +7,13 @@ (ns app.rpc.quotes "Penpot resource usage quotes." (:require - [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] - [app.common.spec :as us] [app.config :as cf] [app.db :as db] [app.util.time :as dt] [app.worker :as wrk] - [clojure.spec.alpha :as s] [cuerdas.core :as str])) (defmulti check-quote ::id) @@ -26,14 +23,16 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private schema:quote - (sm/define - [:map {:title "Quote"} - [::team-id {:optional true} ::sm/uuid] - [::project-id {:optional true} ::sm/uuid] - [::file-id {:optional true} ::sm/uuid] - [::incr {:optional true} [:int {:min 0}]] - [::id :keyword] - [::profile-id ::sm/uuid]])) + [:map {:title "Quote"} + [::team-id {:optional true} ::sm/uuid] + [::project-id {:optional true} ::sm/uuid] + [::file-id {:optional true} ::sm/uuid] + [::incr {:optional true} [::sm/int {:min 0}]] + [::id :keyword] + [::profile-id ::sm/uuid]]) + +(def valid-quote? + (sm/lazy-validator schema:quote)) (def ^:private enabled (volatile! true)) @@ -47,20 +46,31 @@ [] (vswap! enabled (constantly false))) -(defn check-quote! - [ds quote] - (dm/assert! - "expected valid quote map" - (sm/validate schema:quote quote)) +(defn- check + [cfg quote] + (let [quote (merge cfg quote) + id (::id quote)] - (when (contains? cf/flags :quotes) - (when @enabled - ;; This approach add flexibility on how and where the - ;; check-quote! can be called (in or out of transaction) - (db/run! ds (fn [cfg] - (-> (merge cfg quote) - (assoc ::target (name (::id quote))) - (check-quote))))))) + (when-not (valid-quote? quote) + (ex/raise :type :internal + :code :invalid-quote-definition + :hint "found invalid data for quote schema" + :quote (name id))) + + (-> (assoc quote ::target (name id)) + (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! [{:keys [::db/conn] :as params}] @@ -101,7 +111,7 @@ (map :quote) (reduce max (- Integer/MAX_VALUE))) 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) (if (contains? cf/flags :soft-quotes) @@ -113,72 +123,81 @@ :count total))))) (def ^:private sql:get-quotes-1 - "select id, quote from usage_quote - where target = ? - and profile_id = ? - and team_id is null - and project_id is null - and file_id is null;") + "SELECT id, quote + FROM usage_quote + WHERE target = ? + AND profile_id = ? + AND team_id IS NULL + AND project_id IS NULL + AND file_id IS NULL;") (def ^:private sql:get-quotes-2 - "select id, quote from usage_quote - where target = ? - 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));") + "SELECT id, quote + FROM usage_quote + WHERE target = ? + 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 - "select id, quote from usage_quote - where target = ? - and ((project_id = ? and (profile_id = ? or profile_id is null)) or - (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));") + "SELECT id, quote + FROM usage_quote + WHERE target = ? + AND ((project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR + (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 - "select id, quote from usage_quote - where target = ? - and ((file_id = ? and (profile_id = ? or profile_id is null)) or - (project_id = ? and (profile_id = ? or profile_id is null)) or - (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));") + "SELECT id, quote + FROM usage_quote + WHERE target = ? + AND ((file_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR + (project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR + (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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private sql:get-teams-per-profile - "select count(*) as total - from team_profile_rel - where profile_id = ?") +(def ^:private schema:teams-per-profile + [:map [::profile-id ::sm/uuid]]) -(s/def ::profile-id ::us/uuid) -(s/def ::teams-per-profile - (s/keys :req [::profile-id ::target])) +(def ^:private valid-teams-per-profile-quote? + (sm/lazy-validator schema:teams-per-profile)) + +(def ^:private sql:get-teams-per-profile + "SELECT count(*) AS total + FROM team_profile_rel + WHERE profile_id = ?") (defmethod check-quote ::teams-per-profile [{:keys [::profile-id ::target] :as quote}] - (us/assert! ::teams-per-profile quote) + (assert (valid-teams-per-profile-quote? quote) "invalid quote parameters") (-> quote (assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-teams-per-profile profile-id]) (generic-check!))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: ACCESS-TOKENS-PER-PROFILE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private sql:get-access-tokens-per-profile - "select count(*) as total - from access_token - where profile_id = ?") +(def ^:private schema:access-tokens-per-profile + [:map [::profile-id ::sm/uuid]]) -(s/def ::access-tokens-per-profile - (s/keys :req [::profile-id ::target])) +(def ^:private valid-access-tokens-per-profile-quote? + (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 [{: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 (assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) @@ -189,40 +208,51 @@ ;; QUOTE: PROJECTS-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(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") +(def ^:private schema:projects-per-team + [:map + [::profile-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::team-id ::us/uuid) -(s/def ::projects-per-team - (s/keys :req [::profile-id ::team-id ::target])) +(def ^:private valid-projects-per-team-quote? + (sm/lazy-validator schema:projects-per-team)) + +(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 [{:keys [::profile-id ::team-id ::target] :as quote}] + (assert (valid-projects-per-team-quote? quote) "invalid quote parameters") + (-> quote (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 ::count-sql [sql:get-projects-per-team team-id]) (generic-check!))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: 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 = ?") +(def ^:private schema:font-variants-per-team + [:map + [::profile-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::font-variants-per-team - (s/keys :req [::profile-id ::team-id ::target])) +(def ^:private valid-font-variant-per-team-quote? + (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 [{: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 (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]) @@ -234,70 +264,86 @@ ;; QUOTE: INVITATIONS-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private sql:get-invitations-per-team - "select count(*) as total - from team_invitation - where team_id = ?") +(def ^:private schema:invitations-per-team + [:map + [::profile-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::invitations-per-team - (s/keys :req [::profile-id ::team-id ::target])) +(def ^:private valid-invitations-per-team-quote? + (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 [{: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 (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 ::count-sql [sql:get-invitations-per-team team-id]) (generic-check!))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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 - "select (select count(*) - from team_profile_rel - where team_id = ?) + - (select count(*) - from team_invitation - where team_id = ? - and valid_until > now()) as total;") + "SELECT (SELECT count(*) + FROM team_profile_rel + WHERE team_id = ?) + + (SELECT count(*) + FROM team_invitation + WHERE team_id = ? + AND valid_until > now()) AS total;") ;; NOTE: the total number of profiles is determined by the number of ;; 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 [{: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 (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 ::count-sql [sql:get-profiles-per-team team-id team-id]) (generic-check!))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: FILES-PER-PROJECT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(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") +(def ^:private schema:files-per-project + [:map + [::profile-id ::sm/uuid] + [::project-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::project-id ::us/uuid) -(s/def ::files-per-project - (s/keys :req [::profile-id ::project-id ::team-id ::target])) +(def ^:private valid-files-per-project-quote? + (sm/lazy-validator schema:files-per-project)) + +(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 [{: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 (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]) @@ -308,17 +354,24 @@ ;; QUOTE: 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 = ?") +(def ^:private schema:comment-threads-per-file + [:map + [::profile-id ::sm/uuid] + [::project-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::comment-threads-per-file - (s/keys :req [::profile-id ::project-id ::team-id ::target])) +(def ^:private valid-comment-threads-per-file-quote? + (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 [{: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 (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 @@ -326,23 +379,28 @@ (assoc ::count-sql [sql:get-comment-threads-per-file file-id]) (generic-check!))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: 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 = ?") +(def ^:private schema:comments-per-file + [:map + [::profile-id ::sm/uuid] + [::project-id ::sm/uuid] + [::team-id ::sm/uuid]]) -(s/def ::comments-per-file - (s/keys :req [::profile-id ::project-id ::team-id ::target])) +(def ^:private valid-comments-per-file-quote? + (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 [{: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 (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 diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index 3c70c7dbc..4d3de1032 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -10,6 +10,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] [app.http.client :as http] @@ -19,28 +20,26 @@ [datoteka.fs :as fs] [integrant.core :as ig])) -(def ^:private - schema:template - (sm/define - [:map {:title "Template"} - [:id ::sm/word-string] - [:name ::sm/word-string] - [:file-uri ::sm/word-string]])) +(def ^:private schema:template + [:map {:title "Template"} + [:id ::sm/word-string] + [:name ::sm/word-string] + [:file-uri ::sm/word-string]]) -(def ^:private - schema:templates - (sm/define - [:vector schema:template])) +(def ^:private schema:templates + [:vector schema:template]) + +(def check-templates! + (sm/check-fn schema:templates + :code :invalid-templates + :hint "invalid templates")) (defmethod ig/init-key ::setup/templates [_ _] (let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string) + templates (check-templates! 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] (let [path (or path (fs/join dest id))] (if (fs/exists? path) @@ -60,9 +59,9 @@ (let [resp (http/req! cfg {:method :get :uri (:file-uri template)} {:response-type :input-stream :sync? true})] - - (dm/verify! - "unexpected response found on fetching template" - (= 200 (:status resp))) + (when-not (= 200 (:status resp)) + (ex/raise :type :internal + :code :unexpected-status-code + :hint (str "unable to download template, recevied status " (:status resp)))) (io/input-stream (:body resp))))))) diff --git a/backend/src/app/setup/welcome_file.clj b/backend/src/app/setup/welcome_file.clj new file mode 100644 index 000000000..8de4acaa7 --- /dev/null +++ b/backend/src/app/setup/welcome_file.clj @@ -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)))) + diff --git a/backend/src/app/srepl/helpers.clj b/backend/src/app/srepl/helpers.clj index 38ea61dd8..702790eba 100644 --- a/backend/src/app/srepl/helpers.clj +++ b/backend/src/app/srepl/helpers.clj @@ -75,6 +75,7 @@ :created-at (:created-at file) :modified-at (:modified-at file) :data-backend nil + :data-ref-id nil :has-media-trimmed false} {:id (:id file)}))) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index a5f002b8d..97294927d 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -155,9 +155,10 @@ (defn enable-team-feature! [team-id feature] - (dm/verify! - "feature should be supported" - (contains? cfeat/supported-features feature)) + (when-not (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)] (db/tx-run! main/system @@ -173,9 +174,11 @@ (defn disable-team-feature! [team-id feature] - (dm/verify! - "feature should be supported" - (contains? cfeat/supported-features feature)) + + (when-not (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)] (db/tx-run! main/system @@ -203,9 +206,11 @@ [{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level] :or {code :generic level :info} :as params}] - (dm/verify! - ["invalid level %" level] - (contains? #{:success :error :info :warning} level)) + + (when-not (contains? #{:success :error :info :warning} level) + (ex/raise :type :assertion + :code :incorrect-level + :hint (str "level '" level "' not supported"))) (letfn [(send [dest] (l/inf :hint "sending notification" :dest (str dest)) @@ -727,13 +732,15 @@ deleted 0 total 0] (if-let [email (first emails)] - (if-let [profile (db/get* system :profile - {:email (str/lower email)} - {::db/remove-deleted false})] + (if-let [profile (some-> (db/get* system :profile + {:email (str/lower email)} + {::db/remove-deleted false}) + (profile/decode-row))] (do (audit/insert! system {::audit/name "delete-profile" ::audit/type "action" + ::audit/profile-id (:id profile) ::audit/tracked-at deleted-at ::audit/props (audit/profile->props profile) ::audit/context {:triggered-by "srepl" diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index c818b03fa..861730e33 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -6,11 +6,13 @@ (ns app.storage "Objects storage abstraction layer." + (:refer-clojure :exclude [resolve]) (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.spec :as us] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.storage.fs :as sfs] [app.storage.impl :as impl] @@ -18,16 +20,23 @@ [app.util.time :as dt] [clojure.spec.alpha :as s] [datoteka.fs :as fs] - [integrant.core :as ig] - [promesa.core :as p]) + [integrant.core :as ig]) (:import java.io.InputStream)) +(defn get-legacy-backend + [] + (let [name (cf/get :assets-storage-backend)] + (case name + :assets-fs :fs + :assets-s3 :s3 + :fs))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Storage Module State ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(s/def ::id #{:assets-fs :assets-s3}) +(s/def ::id #{:assets-fs :assets-s3 :fs :s3}) (s/def ::s3 ::ss3/backend) (s/def ::fs ::sfs/backend) (s/def ::type #{:fs :s3}) @@ -45,11 +54,13 @@ [_ {:keys [::backends ::db/pool] :as cfg}] (-> (d/without-nils cfg) (assoc ::backends (d/without-nils backends)) - (assoc ::db/pool-or-conn pool))) + (assoc ::backend (or (get-legacy-backend) + (cf/get :objects-storage-backend :fs))) + (assoc ::db/connectable pool))) (s/def ::backend keyword?) (s/def ::storage - (s/keys :req [::backends ::db/pool ::db/pool-or-conn] + (s/keys :req [::backends ::db/pool ::db/connectable] :opt [::backend])) (s/def ::storage-with-backend @@ -61,23 +72,26 @@ (defn get-metadata [params] - (into {} - (remove (fn [[k _]] (qualified-keyword? k))) - params)) + (reduce-kv (fn [res k _] + (if (qualified-keyword? k) + (dissoc res k) + res)) + params + params)) (defn- get-database-object-by-hash - [pool-or-conn backend bucket hash] + [connectable backend bucket hash] (let [sql (str "select * from storage_object " " where (metadata->>'~:hash') = ? " " and (metadata->>'~:bucket') = ? " " and backend = ?" " and deleted_at is null" " limit 1")] - (some-> (db/exec-one! pool-or-conn [sql hash bucket (name backend)]) + (some-> (db/exec-one! connectable [sql hash bucket (name backend)]) (update :metadata db/decode-transit-pgobject)))) (defn- create-database-object - [{:keys [::backend ::db/pool-or-conn]} {:keys [::content ::expired-at ::touched-at] :as params}] + [{:keys [::backend ::db/connectable]} {:keys [::content ::expired-at ::touched-at ::touch] :as params}] (let [id (or (:id params) (uuid/random)) mdata (cond-> (get-metadata params) (satisfies? impl/IContentHash content) @@ -86,7 +100,9 @@ :always (dissoc :id)) - ;; FIXME: touch object on deduplicated put operation ?? + touched-at (if touch + (or touched-at (dt/now)) + touched-at) ;; NOTE: for now we don't reuse the deleted objects, but in ;; futute we can consider reusing deleted objects if we @@ -95,10 +111,20 @@ result (when (and (::deduplicate? params) (:hash mdata) (:bucket mdata)) - (get-database-object-by-hash pool-or-conn backend (:bucket mdata) (:hash mdata))) + (let [result (get-database-object-by-hash connectable backend + (:bucket mdata) + (:hash mdata))] + (if touch + (do + (db/update! connectable :storage-object + {:touched-at touched-at} + {:id (:id result)} + {::db/return-keys false}) + (assoc result :touced-at touched-at)) + result))) result (or result - (-> (db/insert! pool-or-conn :storage-object + (-> (db/insert! connectable :storage-object {:id id :size (impl/get-size content) :backend (name backend) @@ -154,9 +180,9 @@ (dm/export impl/object?) (defn get-object - [{:keys [::db/pool-or-conn] :as storage} id] + [{:keys [::db/connectable] :as storage} id] (us/assert! ::storage storage) - (retrieve-database-object pool-or-conn id)) + (retrieve-database-object connectable id)) (defn put-object! "Creates a new object with the provided content." @@ -172,10 +198,10 @@ (defn touch-object! "Mark object as touched." - [{:keys [::db/pool-or-conn] :as storage} object-or-id] + [{:keys [::db/connectable] :as storage} object-or-id] (us/assert! ::storage storage) (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)] - (-> (db/update! pool-or-conn :storage-object + (-> (db/update! connectable :storage-object {:touched-at (dt/now)} {:id id}) (db/get-update-count) @@ -195,11 +221,10 @@ "Returns a byte array of object content." [storage object] (us/assert! ::storage storage) - (if (or (nil? (:expired-at object)) - (dt/is-after? (:expired-at object) (dt/now))) + (when (or (nil? (:expired-at object)) + (dt/is-after? (:expired-at object) (dt/now))) (-> (impl/resolve-backend storage (:backend object)) - (impl/get-object-bytes object)) - (p/resolved nil))) + (impl/get-object-bytes object)))) (defn get-object-url ([storage object] @@ -223,13 +248,26 @@ (-> (impl/get-object-url backend object nil) file-url->path)))) (defn del-object! - [{:keys [::db/pool-or-conn] :as storage} object-or-id] + [{:keys [::db/connectable] :as storage} object-or-id] (us/assert! ::storage storage) (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id) - res (db/update! pool-or-conn :storage-object + res (db/update! connectable :storage-object {:deleted-at (dt/now)} {:id id})] (pos? (db/get-update-count res)))) -(dm/export impl/resolve-backend) (dm/export impl/calculate-hash) + +(defn configure + [storage connectable] + (assoc storage ::db/connectable connectable)) + +(defn resolve + "Resolves the storage instance with preconfigured backend. You can + specify to reuse the database connection from provided + cfg/system (default false)." + [cfg & {:as opts}] + (let [storage (::storage cfg)] + (if (::db/reuse-conn opts false) + (configure storage (db/get-connectable cfg)) + storage))) diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index 52cdce4b1..7f903b000 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -121,5 +121,3 @@ :total total) {:deleted total})))))) - - diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index bd499bb65..03fe0f426 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -28,58 +28,80 @@ [clojure.spec.alpha :as s] [integrant.core :as ig])) -(def ^:private sql:get-team-font-variant-nrefs - "SELECT ((SELECT count(*) FROM team_font_variant WHERE woff1_file_id = ?) + - (SELECT count(*) FROM team_font_variant WHERE woff2_file_id = ?) + - (SELECT count(*) FROM team_font_variant WHERE otf_file_id = ?) + - (SELECT count(*) FROM team_font_variant WHERE ttf_file_id = ?)) AS nrefs") +(def ^:private sql:has-team-font-variant-refs + "SELECT ((SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE woff1_file_id = ?)) OR + (SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE woff2_file_id = ?)) OR + (SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE otf_file_id = ?)) OR + (SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE ttf_file_id = ?))) AS has_refs") -(defn- get-team-font-variant-nrefs +(defn- has-team-font-variant-refs? [conn id] - (-> (db/exec-one! conn [sql:get-team-font-variant-nrefs id id id id]) - (get :nrefs))) - + (-> (db/exec-one! conn [sql:has-team-font-variant-refs id id id id]) + (get :has-refs))) (def ^:private - sql:get-file-media-object-nrefs - "SELECT ((SELECT count(*) FROM file_media_object WHERE media_id = ?) + - (SELECT count(*) FROM file_media_object WHERE thumbnail_id = ?)) AS nrefs") + sql:has-file-media-object-refs + "SELECT ((SELECT EXISTS (SELECT 1 FROM file_media_object WHERE media_id = ?)) OR + (SELECT EXISTS (SELECT 1 FROM file_media_object WHERE thumbnail_id = ?))) AS has_refs") -(defn- get-file-media-object-nrefs +(defn- has-file-media-object-refs? [conn id] - (-> (db/exec-one! conn [sql:get-file-media-object-nrefs id id]) - (get :nrefs))) + (-> (db/exec-one! conn [sql:has-file-media-object-refs id id]) + (get :has-refs))) +(def ^:private sql:has-profile-refs + "SELECT ((SELECT EXISTS (SELECT 1 FROM profile WHERE photo_id = ?)) OR + (SELECT EXISTS (SELECT 1 FROM team WHERE photo_id = ?))) AS has_refs") -(def ^:private sql:get-profile-nrefs - "SELECT ((SELECT count(*) FROM profile WHERE photo_id = ?) + - (SELECT count(*) FROM team WHERE photo_id = ?)) AS nrefs") - -(defn- get-profile-nrefs +(defn- has-profile-refs? [conn id] - (-> (db/exec-one! conn [sql:get-profile-nrefs id id]) - (get :nrefs))) - + (-> (db/exec-one! conn [sql:has-profile-refs id id]) + (get :has-refs))) (def ^:private - sql:get-file-object-thumbnail-nrefs - "SELECT (SELECT count(*) FROM file_tagged_object_thumbnail WHERE media_id = ?) AS nrefs") + sql:has-file-object-thumbnail-refs + "SELECT EXISTS (SELECT 1 FROM file_tagged_object_thumbnail WHERE media_id = ?) AS has_refs") -(defn- get-file-object-thumbnails +(defn- has-file-object-thumbnails-refs? [conn id] - (-> (db/exec-one! conn [sql:get-file-object-thumbnail-nrefs id]) - (get :nrefs))) - + (-> (db/exec-one! conn [sql:has-file-object-thumbnail-refs id]) + (get :has-refs))) (def ^:private - sql:get-file-thumbnail-nrefs - "SELECT (SELECT count(*) FROM file_thumbnail WHERE media_id = ?) AS nrefs") + sql:has-file-thumbnail-refs + "SELECT EXISTS (SELECT 1 FROM file_thumbnail WHERE media_id = ?) AS has_refs") -(defn- get-file-thumbnails +(defn- has-file-thumbnails-refs? [conn id] - (-> (db/exec-one! conn [sql:get-file-thumbnail-nrefs id]) - (get :nrefs))) + (-> (db/exec-one! conn [sql:has-file-thumbnail-refs id]) + (get :has-refs))) +(def ^:private + sql:has-file-data-refs + "SELECT EXISTS (SELECT 1 FROM file WHERE data_ref_id = ?) AS has_refs") + +(defn- has-file-data-refs? + [conn id] + (-> (db/exec-one! conn [sql:has-file-data-refs id]) + (get :has-refs))) + +(def ^:private + sql:has-file-data-fragment-refs + "SELECT EXISTS (SELECT 1 FROM file_data_fragment WHERE data_ref_id = ?) AS has_refs") + +(defn- has-file-data-fragment-refs? + [conn id] + (-> (db/exec-one! conn [sql:has-file-data-fragment-refs id]) + (get :has-refs))) + +(def ^:private + sql:has-file-change-refs + "SELECT EXISTS (SELECT 1 FROM file_change WHERE data_ref_id = ?) AS has_refs") + +(defn- has-file-change-refs? + [conn id] + (-> (db/exec-one! conn [sql:has-file-change-refs id]) + (get :has-refs))) (def ^:private sql:mark-freeze-in-bulk "UPDATE storage_object @@ -91,7 +113,6 @@ (let [ids (db/create-array conn "uuid" ids)] (db/exec-one! conn [sql:mark-freeze-in-bulk ids]))) - (def ^:private sql:mark-delete-in-bulk "UPDATE storage_object SET deleted_at = now(), @@ -123,25 +144,24 @@ "file-media-object")) (defn- process-objects! - [conn get-fn ids bucket] + [conn has-refs? ids bucket] (loop [to-freeze #{} to-delete #{} ids (seq ids)] (if-let [id (first ids)] - (let [nrefs (get-fn conn id)] - (if (pos? nrefs) - (do - (l/debug :hint "processing object" - :id (str id) - :status "freeze" - :bucket bucket :refs nrefs) - (recur (conj to-freeze id) to-delete (rest ids))) - (do - (l/debug :hint "processing object" - :id (str id) - :status "delete" - :bucket bucket :refs nrefs) - (recur to-freeze (conj to-delete id) (rest ids))))) + (if (has-refs? conn id) + (do + (l/debug :hint "processing object" + :id (str id) + :status "freeze" + :bucket bucket) + (recur (conj to-freeze id) to-delete (rest ids))) + (do + (l/debug :hint "processing object" + :id (str id) + :status "delete" + :bucket bucket) + (recur to-freeze (conj to-delete id) (rest ids)))) (do (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) (some->> (seq to-delete) (mark-delete-in-bulk! conn)) @@ -150,15 +170,26 @@ (defn- process-bucket! [conn bucket ids] (case bucket - "file-media-object" (process-objects! conn get-file-media-object-nrefs ids bucket) - "team-font-variant" (process-objects! conn get-team-font-variant-nrefs ids bucket) - "file-object-thumbnail" (process-objects! conn get-file-object-thumbnails ids bucket) - "file-thumbnail" (process-objects! conn get-file-thumbnails ids bucket) - "profile" (process-objects! conn get-profile-nrefs ids bucket) + "file-media-object" (process-objects! conn has-file-media-object-refs? ids bucket) + "team-font-variant" (process-objects! conn has-team-font-variant-refs? ids bucket) + "file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? ids bucket) + "file-thumbnail" (process-objects! conn has-file-thumbnails-refs? ids bucket) + "profile" (process-objects! conn has-profile-refs? ids bucket) + "file-data" (process-objects! conn has-file-data-refs? ids bucket) + "file-data-fragment" (process-objects! conn has-file-data-fragment-refs? ids bucket) + "file-change" (process-objects! conn has-file-change-refs? ids bucket) (ex/raise :type :internal :code :unexpected-unknown-reference - :hint (dm/fmt "unknown reference %" bucket)))) + :hint (dm/fmt "unknown reference '%'" bucket)))) +(defn process-chunk! + [{:keys [::db/conn]} chunk] + (reduce-kv (fn [[nfo ndo] bucket ids] + (let [[nfo' ndo'] (process-bucket! conn bucket ids)] + [(+ nfo nfo') + (+ ndo ndo')])) + [0 0] + (d/group-by lookup-bucket :id #{} chunk))) (def ^:private sql:get-touched-storage-objects @@ -167,29 +198,22 @@ WHERE so.touched_at IS NOT NULL ORDER BY touched_at ASC FOR UPDATE - SKIP LOCKED") + SKIP LOCKED + LIMIT 10") -(defn- group-by-bucket - [row] - (d/group-by lookup-bucket :id #{} row)) - -(defn- get-buckets +(defn get-chunk [conn] - (sequence - (comp (map impl/decode-row) - (partition-all 25) - (mapcat group-by-bucket)) - (db/cursor conn sql:get-touched-storage-objects))) + (->> (db/exec! conn [sql:get-touched-storage-objects]) + (map impl/decode-row) + (not-empty))) (defn- process-touched! - [{:keys [::db/conn]}] - (loop [buckets (get-buckets conn) - freezed 0 + [{:keys [::db/pool] :as cfg}] + (loop [freezed 0 deleted 0] - (if-let [[bucket ids] (first buckets)] - (let [[nfo ndo] (process-bucket! conn bucket ids)] - (recur (rest buckets) - (+ freezed nfo) + (if-let [chunk (get-chunk pool)] + (let [[nfo ndo] (db/tx-run! cfg process-chunk! chunk)] + (recur (+ freezed nfo) (+ deleted ndo))) (do (l/inf :hint "task finished" @@ -198,11 +222,14 @@ {:freeze freezed :delete deleted})))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HANDLER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defmethod ig/pre-init-spec ::handler [_] (s/keys :req [::db/pool])) (defmethod ig/init-key ::handler [_ cfg] - (fn [_] - (db/tx-run! cfg process-touched!))) + (fn [_] (process-touched! cfg))) diff --git a/backend/src/app/storage/impl.clj b/backend/src/app/storage/impl.clj index 156d86b87..6de48b682 100644 --- a/backend/src/app/storage/impl.clj +++ b/backend/src/app/storage/impl.clj @@ -207,15 +207,13 @@ (str "blake2b:" result))) (defn resolve-backend - [{:keys [::db/pool] :as storage} backend-id] + [storage backend-id] (let [backend (get-in storage [::sto/backends backend-id])] (when-not backend (ex/raise :type :internal :code :backend-not-configured :hint (dm/fmt "backend '%' not configured" backend-id))) - (-> backend - (assoc ::sto/id backend-id) - (assoc ::db/pool pool)))) + (assoc backend ::sto/id backend-id))) (defrecord StorageObject [id size created-at expired-at touched-at backend]) diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 79f5ff8b9..a903a6730 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -21,78 +21,31 @@ [app.config :as cf] [app.db :as db] [app.features.fdata :as feat.fdata] - [app.media :as media] [app.storage :as sto] [app.util.blob :as blob] [app.util.pointer-map :as pmap] [app.util.time :as dt] + [app.worker :as wrk] [clojure.set :as set] [clojure.spec.alpha :as s] [integrant.core :as ig])) -(declare ^:private clean-file!) +(declare ^:private get-file) +(declare ^:private decode-file) +(declare ^:private persist-file!) -(defn- decode-file - [cfg {:keys [id] :as file}] - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] - (-> file - (update :features db/decode-pgarray #{}) - (update :data blob/decode) - (update :data feat.fdata/process-pointers deref) - (update :data feat.fdata/process-objects (partial into {})) - (update :data assoc :id id) - (fmg/migrate-file)))) - -(defn- update-file! - [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] - (let [file (if (contains? (:features file) "fdata/objects-map") - (feat.fdata/enable-objects-map file) - file) - - file (if (contains? (:features file) "fdata/pointer-map") - (binding [pmap/*tracked* (pmap/create-tracked)] - (let [file (feat.fdata/enable-pointer-map file)] - (feat.fdata/persist-pointers! cfg id) - file)) - file) - - file (-> file - (update :features db/encode-pgarray conn "text") - (update :data blob/encode))] - - (db/update! conn :file - {:has-media-trimmed true - :features (:features file) - :version (:version file) - :data (:data file)} - {:id id} - {::db/return-keys true}))) - -(def ^:private - sql:get-candidates - "SELECT f.id, +(def ^:private sql:get-snapshots + "SELECT f.file_id AS id, f.data, f.revn, f.version, f.features, - f.modified_at - FROM file AS f - WHERE f.has_media_trimmed IS false - AND f.modified_at < now() - ?::interval - AND f.deleted_at IS NULL - ORDER BY f.modified_at DESC - FOR UPDATE - SKIP LOCKED") - -(defn- get-candidates - [{:keys [::db/conn ::min-age ::file-id]}] - (if (uuid? file-id) - (do - (l/warn :hint "explicit file id passed on params" :file-id (str file-id)) - (db/query conn :file {:id file-id})) - - (let [min-age (db/interval min-age)] - (db/cursor conn [sql:get-candidates min-age] {:chunk-size 1})))) + f.data_backend, + f.data_ref_id + FROM file_change AS f + WHERE f.file_id = ? + AND f.label IS NOT NULL + ORDER BY f.created_at ASC") (def ^:private sql:mark-file-media-object-deleted "UPDATE file_media_object @@ -100,10 +53,17 @@ WHERE file_id = ? AND id != ALL(?::uuid[]) RETURNING id") +(def ^:private xf:collect-used-media + (comp (map :data) (mapcat bfc/collect-used-media))) + (defn- clean-file-media! "Performs the garbage collection of file media objects." - [{:keys [::db/conn]} {:keys [id data] :as file}] - (let [used (bfc/collect-used-media data) + [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] + (let [used (into #{} + xf:collect-used-media + (cons file + (->> (db/cursor conn [sql:get-snapshots id]) + (map (partial decode-file cfg))))) ids (db/create-array conn "uuid" used) unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids]) (into #{} (map :id)))] @@ -172,9 +132,14 @@ file)) - (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 LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id) WHERE fl.library_file_id = ? @@ -230,11 +195,6 @@ (l/dbg :hint "clean" :rel "components" :file-id (str file-id) :total (count unused)) file)) -(def ^:private sql:get-changes - "SELECT id, data FROM file_change - WHERE file_id = ? AND data IS NOT NULL - ORDER BY created_at ASC") - (def ^:private sql:mark-deleted-data-fragments "UPDATE file_data_fragment SET deleted_at = now() @@ -250,8 +210,7 @@ (defn- clean-data-fragments! [{:keys [::db/conn]} {:keys [id] :as file}] - (let [used (into #{} xf:collect-pointers - (cons file (db/cursor conn [sql:get-changes id]))) + (let [used (into #{} xf:collect-pointers [file]) unused (let [ids (db/create-array conn "uuid" used)] (->> (db/exec! conn [sql:mark-deleted-data-fragments id ids]) @@ -274,17 +233,83 @@ (cfv/validate-file-schema! file) file)) +(def ^:private sql:get-file + "SELECT f.id, + f.data, + f.revn, + f.version, + f.features, + f.modified_at, + f.data_backend, + f.data_ref_id + FROM file AS f + WHERE f.has_media_trimmed IS false + AND f.modified_at < now() - ?::interval + AND f.deleted_at IS NULL + AND f.id = ? + FOR UPDATE + SKIP LOCKED") + +(defn- get-file + [{:keys [::db/conn ::min-age ::file-id]}] + (->> (db/exec! conn [sql:get-file min-age file-id]) + (first))) + +(defn- decode-file + [cfg {:keys [id] :as file}] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] + (-> (feat.fdata/resolve-file-data cfg file) + (update :features db/decode-pgarray #{}) + (update :data blob/decode) + (update :data feat.fdata/process-pointers deref) + (update :data feat.fdata/process-objects (partial into {})) + (update :data assoc :id id) + (fmg/migrate-file)))) + +(defn- persist-file! + [{:keys [::db/conn ::sto/storage] :as cfg} {:keys [id] :as file}] + (let [file (if (contains? (:features file) "fdata/objects-map") + (feat.fdata/enable-objects-map file) + file) + + file (if (contains? (:features file) "fdata/pointer-map") + (binding [pmap/*tracked* (pmap/create-tracked)] + (let [file (feat.fdata/enable-pointer-map file)] + (feat.fdata/persist-pointers! cfg id) + file)) + file) + + file (-> file + (update :features db/encode-pgarray conn "text") + (update :data blob/encode))] + + ;; If file was already offloaded, we touch the underlying storage + ;; object for properly trigger storage-gc-touched task + (when (feat.fdata/offloaded? file) + (some->> (:data-ref-id file) (sto/touch-object! storage))) + + (db/update! conn :file + {:has-media-trimmed true + :features (:features file) + :version (:version file) + :data (:data file) + :data-backend nil + :data-ref-id nil} + {:id id} + {::db/return-keys true}))) + (defn- process-file! - [cfg file] - (try + [cfg] + (if-let [file (get-file cfg)] (let [file (decode-file cfg file) file (clean-media! cfg file) - file (update-file! cfg file)] - (clean-data-fragments! cfg file)) - (catch Throwable cause - (l/err :hint "error on cleaning file (skiping)" - :file-id (str (:id file)) - :cause cause)))) + file (persist-file! cfg file)] + (clean-data-fragments! cfg file) + true) + + (do + (l/dbg :hint "skip" :file-id (str (::file-id cfg))) + false))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HANDLER @@ -293,33 +318,29 @@ (defmethod ig/pre-init-spec ::handler [_] (s/keys :req [::db/pool ::sto/storage])) -(defmethod ig/prep-key ::handler - [_ cfg] - (assoc cfg ::min-age (cf/get-deletion-delay))) - (defmethod ig/init-key ::handler [_ cfg] (fn [{:keys [props] :as task}] - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (let [min-age (dt/duration (or (:min-age props) (::min-age cfg))) - cfg (-> cfg - (update ::sto/storage media/configure-assets-storage conn) - (assoc ::file-id (:file-id props)) - (assoc ::min-age min-age)) + (let [min-age (dt/duration (or (:min-age props) + (cf/get-deletion-delay))) + cfg (-> cfg + (assoc ::db/rollback (:rollback? props)) + (assoc ::file-id (:file-id props)) + (assoc ::min-age (db/interval min-age)))] - total (reduce (fn [total file] - (process-file! cfg file) - (inc total)) - 0 - (get-candidates cfg))] + (try + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (let [cfg (update cfg ::sto/storage sto/configure conn) + processed? (process-file! cfg)] + (when (and processed? (contains? cf/flags :tiered-file-data-storage)) + (wrk/submit! (-> cfg + (assoc ::wrk/task :offload-file-data) + (assoc ::wrk/params props) + (assoc ::wrk/priority 10) + (assoc ::wrk/delay 1000)))) + processed?))) - (l/inf :hint "finished" - :min-age (dt/format-duration min-age) - :processed total) - - ;; Allow optional rollback passed by params - (when (:rollback? props) - (db/rollback! conn)) - - {:processed total}))))) + (catch Throwable cause + (l/err :hint "error on cleaning file" + :file-id (str (:file-id props)) + :cause cause)))))) diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj new file mode 100644 index 000000000..a133b6c41 --- /dev/null +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -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.tasks.file-gc-scheduler + "A maintenance task that is responsible of properly scheduling the + file-gc task for all files that matches the eligibility threshold." + (:require + [app.config :as cf] + [app.db :as db] + [app.util.time :as dt] + [app.worker :as wrk] + [clojure.spec.alpha :as s] + [integrant.core :as ig])) + +(def ^:private + sql:get-candidates + "SELECT f.id, + f.modified_at + FROM file AS f + WHERE f.has_media_trimmed IS false + AND f.modified_at < now() - ?::interval + AND f.deleted_at IS NULL + ORDER BY f.modified_at DESC + FOR UPDATE + SKIP LOCKED") + +(defn- get-candidates + [{:keys [::db/conn ::min-age] :as cfg}] + (let [min-age (db/interval min-age)] + (db/cursor conn [sql:get-candidates min-age] {:chunk-size 10}))) + +(defn- schedule! + [{:keys [::min-age] :as cfg}] + (let [total (reduce (fn [total {:keys [id]}] + (let [params {:file-id id :min-age min-age}] + (wrk/submit! (assoc cfg ::wrk/params params)) + (inc total))) + 0 + (get-candidates cfg))] + + {:processed total})) + +(defmethod ig/pre-init-spec ::handler [_] + (s/keys :req [::db/pool])) + +(defmethod ig/prep-key ::handler + [_ cfg] + (assoc cfg ::min-age (cf/get-deletion-delay))) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props] :as task}] + (let [min-age (dt/duration (or (:min-age props) (::min-age cfg)))] + (-> cfg + (assoc ::db/rollback (:rollback? props)) + (assoc ::min-age min-age) + (assoc ::wrk/task :file-gc) + (assoc ::wrk/priority 10) + (assoc ::wrk/mark-retries 0) + (assoc ::wrk/delay 1000) + (db/tx-run! schedule!))))) diff --git a/backend/src/app/tasks/file_xlog_gc.clj b/backend/src/app/tasks/file_xlog_gc.clj index 4e240d7f7..6bbacd250 100644 --- a/backend/src/app/tasks/file_xlog_gc.clj +++ b/backend/src/app/tasks/file_xlog_gc.clj @@ -10,35 +10,59 @@ (:require [app.common.logging :as l] [app.db :as db] + [app.features.fdata :as feat.fdata] + [app.storage :as sto] [app.util.time :as dt] [clojure.spec.alpha :as s] [integrant.core :as ig])) (def ^:private sql:delete-files-xlog - "delete from file_change - where created_at < now() - ?::interval - and label is NULL") + "DELETE FROM file_change + WHERE id IN (SELECT id FROM file_change + WHERE label IS NULL + AND created_at < ? + ORDER BY created_at LIMIT ?) + RETURNING id, data_backend, data_ref_id") + +(def xf:filter-offloded + (comp + (filter feat.fdata/offloaded?) + (keep :data-ref-id))) + +(defn- delete-in-chunks + [{:keys [::chunk-size ::threshold] :as cfg}] + (let [storage (sto/resolve cfg ::db/reuse-conn true)] + (loop [total 0] + (let [chunk (db/exec! cfg [sql:delete-files-xlog threshold chunk-size]) + length (count chunk)] + + ;; touch all references on offloaded changes entries + (doseq [data-ref-id (sequence xf:filter-offloded chunk)] + (l/trc :hint "touching referenced storage object" + :storage-object-id (str data-ref-id)) + (sto/touch-object! storage data-ref-id)) + + (if (pos? length) + (recur (+ total length)) + total))))) (defmethod ig/pre-init-spec ::handler [_] (s/keys :req [::db/pool])) -(defmethod ig/prep-key ::handler - [_ cfg] - (assoc cfg ::min-age (dt/duration {:hours 72}))) - (defmethod ig/init-key ::handler - [_ {:keys [::db/pool] :as cfg}] + [_ cfg] (fn [{:keys [props] :as task}] - (let [min-age (or (:min-age props) (::min-age cfg))] - (db/with-atomic [conn pool] - (let [interval (db/interval min-age) - result (db/exec-one! conn [sql:delete-files-xlog interval]) - result (db/get-update-count result)] + (let [min-age (or (:min-age props) + (dt/duration "72h")) + chunk-size (:chunk-size props 5000) + threshold (dt/minus (dt/now) min-age)] - (l/info :hint "task finished" :min-age (dt/format-duration min-age) :total result) - - (when (:rollback? props) - (db/rollback! conn)) - - result))))) + (-> cfg + (assoc ::db/rollback (:rollback props false)) + (assoc ::threshold threshold) + (assoc ::chunk-size chunk-size) + (db/tx-run! (fn [cfg] + (let [total (delete-in-chunks cfg)] + (l/trc :hint "file xlog cleaned" :total total) + total))))))) diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index 9858585cc..67ed8f9aa 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -11,7 +11,6 @@ [app.common.logging :as l] [app.config :as cf] [app.db :as db] - [app.media :as media] [app.storage :as sto] [app.util.time :as dt] [clojure.spec.alpha :as s] @@ -126,7 +125,7 @@ 0))) (def ^:private sql:get-files - "SELECT id, deleted_at, project_id + "SELECT id, deleted_at, project_id, data_backend, data_ref_id FROM file WHERE deleted_at IS NOT NULL AND deleted_at < now() - ?::interval @@ -136,15 +135,18 @@ SKIP LOCKED") (defn- delete-files! - [{:keys [::db/conn ::min-age ::chunk-size] :as cfg}] + [{:keys [::db/conn ::sto/storage ::min-age ::chunk-size] :as cfg}] (->> (db/cursor conn [sql:get-files min-age chunk-size] {:chunk-size 1}) - (reduce (fn [total {:keys [id deleted-at project-id]}] + (reduce (fn [total {:keys [id deleted-at project-id] :as file}] (l/trc :hint "permanently delete" :rel "file" :id (str id) :project-id (str project-id) :deleted-at (dt/format-instant deleted-at)) + (when (= "objects-storage" (:data-backend file)) + (sto/touch-object! storage (:data-ref-id file))) + ;; And finally, permanently delete the file. (db/delete! conn :file {:id id}) @@ -210,7 +212,7 @@ 0))) (def ^:private sql:get-file-data-fragments - "SELECT file_id, id, deleted_at + "SELECT file_id, id, deleted_at, data_ref_id FROM file_data_fragment WHERE deleted_at IS NOT NULL AND deleted_at < now() - ?::interval @@ -220,15 +222,16 @@ SKIP LOCKED") (defn- delete-file-data-fragments! - [{:keys [::db/conn ::min-age ::chunk-size] :as cfg}] + [{:keys [::db/conn ::sto/storage ::min-age ::chunk-size] :as cfg}] (->> (db/cursor conn [sql:get-file-data-fragments min-age chunk-size] {:chunk-size 1}) - (reduce (fn [total {:keys [file-id id deleted-at]}] + (reduce (fn [total {:keys [file-id id deleted-at data-ref-id]}] (l/trc :hint "permanently delete" :rel "file-data-fragment" :id (str id) :file-id (str file-id) :deleted-at (dt/format-instant deleted-at)) + (some->> data-ref-id (sto/touch-object! storage)) (db/delete! conn :file-data-fragment {:file-id file-id :id id}) (inc total)) @@ -299,9 +302,7 @@ [_ cfg] (fn [{:keys [props] :as task}] (let [min-age (dt/duration (or (:min-age props) (::min-age cfg))) - cfg (-> cfg - (assoc ::min-age (db/interval min-age)) - (update ::sto/storage media/configure-assets-storage))] + cfg (assoc cfg ::min-age (db/interval min-age))] (loop [procs (map deref deletion-proc-vars) total 0] diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj new file mode 100644 index 000000000..cfe50970f --- /dev/null +++ b/backend/src/app/tasks/offload_file_data.clj @@ -0,0 +1,124 @@ +;; 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.tasks.offload-file-data + "A maintenance task responsible of moving file data from hot + storage (the database row) to a cold storage (fs or s3)." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.db :as db] + [app.db.sql :as-alias sql] + [app.storage :as sto] + [clojure.spec.alpha :as s] + [integrant.core :as ig])) + +(defn- offload-file-data! + [{:keys [::db/conn ::sto/storage ::file-id] :as cfg}] + (let [file (db/get conn :file {:id file-id} + {::sql/for-update true})] + (when (nil? (:data file)) + (ex/raise :hint "file already offloaded" + :type :internal + :code :file-already-offloaded + :file-id file-id)) + + (let [data (sto/content (:data file)) + sobj (sto/put-object! storage + {::sto/content data + ::sto/touch true + :bucket "file-data" + :content-type "application/octet-stream" + :file-id file-id})] + + (l/trc :hint "offload file data" + :file-id (str file-id) + :storage-id (str (:id sobj))) + + (db/update! conn :file + {:data-backend "objects-storage" + :data-ref-id (:id sobj) + :data nil} + {:id file-id} + {::db/return-keys false})))) + +(defn- offload-file-data-fragments! + [{:keys [::db/conn ::sto/storage ::file-id] :as cfg}] + (doseq [fragment (db/query conn :file-data-fragment + {:file-id file-id + :deleted-at nil + :data-backend nil} + {::db/for-update true})] + (let [data (sto/content (:data fragment)) + sobj (sto/put-object! storage + {::sto/content data + ::sto/touch true + :bucket "file-data-fragment" + :content-type "application/octet-stream" + :file-id file-id + :file-fragment-id (:id fragment)})] + + (l/trc :hint "offload file data fragment" + :file-id (str file-id) + :file-fragment-id (str (:id fragment)) + :storage-id (str (:id sobj))) + + (db/update! conn :file-data-fragment + {:data-backend "objects-storage" + :data-ref-id (:id sobj) + :data nil} + {:id (:id fragment)} + {::db/return-keys false})))) + +(def sql:get-snapshots + "SELECT fc.* + FROM file_change AS fc + WHERE fc.file_id = ? + AND fc.label IS NOT NULL + AND fc.data IS NOT NULL + AND fc.data_backend IS NULL") + +(defn- offload-file-snapshots! + [{:keys [::db/conn ::sto/storage ::file-id] :as cfg}] + (doseq [snapshot (db/exec! conn [sql:get-snapshots file-id])] + (let [data (sto/content (:data snapshot)) + sobj (sto/put-object! storage + {::sto/content data + ::sto/touch true + :bucket "file-change" + :content-type "application/octet-stream" + :file-id file-id + :file-change-id (:id snapshot)})] + + (l/trc :hint "offload file change" + :file-id (str file-id) + :file-change-id (str (:id snapshot)) + :storage-id (str (:id sobj))) + + (db/update! conn :file-change + {:data-backend "objects-storage" + :data-ref-id (:id sobj) + :data nil} + {:id (:id snapshot)} + {::db/return-keys false})))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HANDLER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defmethod ig/pre-init-spec ::handler [_] + (s/keys :req [::db/pool ::sto/storage])) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props] :as task}] + (-> cfg + (assoc ::db/rollback (:rollback? props)) + (assoc ::file-id (:file-id props)) + (db/tx-run! (fn [cfg] + (offload-file-data! cfg) + (offload-file-data-fragments! cfg) + (offload-file-snapshots! cfg)))))) diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 410595f72..204d6be0c 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -62,19 +62,25 @@ [conn] (-> (db/exec-one! conn ["SELECT count(*) AS count FROM file"]) :count)) +(def ^:private sql:num-file-changes + "SELECT count(*) AS count + FROM file_change + WHERE created_at < date_trunc('day', now()) + '24 hours'::interval + AND created_at > date_trunc('day', now())") + (defn- get-num-file-changes [conn] - (let [sql (str "SELECT count(*) AS count " - " FROM file_change " - " where date_trunc('day', created_at) = date_trunc('day', now())")] - (-> (db/exec-one! conn [sql]) :count))) + (-> (db/exec-one! conn [sql:num-file-changes]) :count)) + +(def ^:private sql:num-touched-files + "SELECT count(distinct file_id) AS count + FROM file_change + WHERE created_at < date_trunc('day', now()) + '24 hours'::interval + AND created_at > date_trunc('day', now())") (defn- get-num-touched-files [conn] - (let [sql (str "SELECT count(distinct file_id) AS count " - " FROM file_change " - " where date_trunc('day', created_at) = date_trunc('day', now())")] - (-> (db/exec-one! conn [sql]) :count))) + (-> (db/exec-one! conn [sql:num-touched-files]) :count)) (defn- get-num-users [conn] diff --git a/backend/src/app/util/overrides.clj b/backend/src/app/util/overrides.clj index 8f8842718..71b2c0c23 100644 --- a/backend/src/app/util/overrides.clj +++ b/backend/src/app/util/overrides.clj @@ -13,7 +13,6 @@ [clojure.pprint :as pprint] [datoteka.fs :as fs])) - (prefer-method print-method clojure.lang.IRecord clojure.lang.IDeref) @@ -26,7 +25,6 @@ clojure.lang.IPersistentMap clojure.lang.IDeref) - (sm/register! ::fs/path {:type ::fs/path :pred fs/path? @@ -36,6 +34,6 @@ :error/message "expected a valid fs path instance" :error/code "errors.invalid-path" :gen/gen (sg/generator :string) + :decode/string fs/path ::oapi/type "string" - ::oapi/format "unix-path" - ::oapi/decode fs/path}}) + ::oapi/format "unix-path"}}) diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj index 4c8f6d40e..c1526bfb4 100644 --- a/backend/src/app/util/time.clj +++ b/backend/src/app/util/time.clj @@ -141,21 +141,22 @@ ;; --- INSTANT +(defn instant? + [v] + (instance? Instant v)) + (defn instant ([s] - (if (int? s) - (Instant/ofEpochMilli s) - (Instant/parse s))) + (cond + (instant? s) s + (int? s) (Instant/ofEpochMilli s) + :else (Instant/parse s))) ([s fmt] (case fmt :rfc1123 (Instant/from (.parse DateTimeFormatter/RFC_1123_DATE_TIME ^String s)) :iso (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? [da db] (.isAfter ^Instant da ^Instant db)) @@ -374,7 +375,10 @@ :type-properties {:error/message "should be an instant" :title "instant" - ::sm/decode instant + :decode/string instant + :encode/string format-instant + :decode/json instant + :encode/json format-instant :gen/gen (tgen/fmap (fn [i] (in-past i)) tgen/pos-int) ::oapi/type "string" ::oapi/format "iso"}}) @@ -386,6 +390,9 @@ {:error/message "should be a duration" :gen/gen (tgen/fmap duration tgen/pos-int) :title "duration" - ::sm/decode duration + :decode/string duration + :encode/string format-duration + :decode/json duration + :encode/json format-duration ::oapi/type "string" ::oapi/format "duration"}}) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8380ea13e..e77b51d6a 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -76,7 +76,7 @@ :enable-feature-fdata-pointer-map :enable-feature-fdata-objets-map :enable-feature-components-v2 - :enable-file-snapshot + :enable-auto-file-snapshot :disable-file-validation]) (defn state-init @@ -304,16 +304,18 @@ ([params] (update-file* *system* params)) ([system {:keys [file-id changes session-id profile-id revn] :or {session-id (uuid/next) revn 0}}] - (db/tx-run! system (fn [{:keys [::db/conn] :as system}] - (let [file (files.update/get-file conn file-id)] - (files.update/update-file system + (-> system + (assoc ::files.update/timestamp (dt/now)) + (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 :revn revn :file file :features (:features file) :changes changes :session-id session-id - :profile-id profile-id})))))) + :profile-id profile-id}))))))) (declare command!) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index d0a8e7475..c34df7154 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -21,10 +21,9 @@ (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) res (th/run-task! :process-webhook-event - {:event - {:type "command" - :name "create-project" - :props {:team-id (:default-team-id prof)}}})] + {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}})] (t/is (= 0 (:call-count @submit-mock))) (t/is (nil? res))))) @@ -34,10 +33,9 @@ (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) res (th/run-task! :process-webhook-event - {:event - {:type "command" - :name "create-project" - :props {:team-id (:default-team-id prof)}}})] + {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}})] (t/is (= 1 (:call-count @submit-mock))) (t/is (nil? res))))) diff --git a/backend/test/backend_tests/rpc_cond_middleware_test.clj b/backend/test/backend_tests/rpc_cond_middleware_test.clj index e74a9c549..e737fc5f5 100644 --- a/backend/test/backend_tests/rpc_cond_middleware_test.clj +++ b/backend/test/backend_tests/rpc_cond_middleware_test.clj @@ -39,7 +39,6 @@ (t/is (nil? error)) (t/is (map? result)) (t/is (contains? (meta result) :app.http/headers)) - (t/is (contains? (meta result) :app.rpc.cond/key)) (let [etag (-> result meta :app.http/headers (get "etag")) {:keys [error result]} (th/command! (assoc params ::cond/key etag))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 5d1fe1824..9a072eaa8 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -25,6 +25,20 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(defn- update-file! + [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :features cfeat/supported-features + :changes changes} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (:result out))) + (t/deftest files-crud (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) @@ -149,8 +163,7 @@ shape-id (uuid/random)] ;; Preventive file-gc - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; Check the number of fragments before adding the page (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] @@ -171,8 +184,7 @@ (t/is (= 3 (count rows)))) ;; The file-gc should mark for remove unused fragments - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; Check the number of fragments (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] @@ -210,15 +222,13 @@ (t/is (= 3 (count rows)))) ;; The file-gc should mark for remove unused fragments - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; The objects-gc should remove unused fragments (let [res (th/run-task! :objects-gc {:min-age 0})] (t/is (= 3 (:processed res)))) - ;; Check the number of fragments; should be 3 because changes - ;; are also holding pointers to fragments; + ;; Check the number of fragments; (let [rows (th/db-query :file-data-fragment {:file-id (:id file) :deleted-at nil})] (t/is (= 2 (count rows)))) @@ -231,8 +241,7 @@ ;; The file-gc should remove fragments related to changes ;; snapshots previously deleted. - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; Check the number of fragments; (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] @@ -325,12 +334,10 @@ (t/is (= 0 (:delete res)))) ;; run the file-gc task immediately without forced min-age - (let [res (th/run-task! :file-gc)] - (t/is (= 0 (:processed res)))) + (t/is (false? (th/run-task! :file-gc {:file-id (:id file)}))) ;; run the task again - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; retrieve file and check trimmed attribute (let [row (th/db-get :file {:id (:id file)})] @@ -367,8 +374,7 @@ ;; Now, we have deleted the usage of pointers to the ;; file-media-objects, if we paste file-gc, they should be marked ;; as deleted. - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [res (th/run-task! :objects-gc {:min-age 0})] (t/is (= 3 (:processed res)))) @@ -490,12 +496,10 @@ :strokes [{:opacity 1 :stroke-image {:id (:id fmo5) :width 100 :height 100 :mtype "image/jpeg"}}]})}]) ;; run the file-gc task immediately without forced min-age - (let [res (th/run-task! :file-gc)] - (t/is (= 0 (:processed res)))) + (t/is (false? (th/run-task! :file-gc {:file-id (:id file)}))) ;; run the task again - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [res (th/run-task! :objects-gc {:min-age 0})] (t/is (= 2 (:processed res)))) @@ -534,9 +538,7 @@ ;; Now, we have deleted the usage of pointers to the ;; file-media-objects, if we paste file-gc, they should be marked ;; as deleted. - - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [res (th/run-task! :objects-gc {:min-age 0})] (t/is (= 7 (:processed res)))) @@ -581,18 +583,18 @@ (t/is (nil? (:error out))) (:result out))) - (update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}] - (let [params {::th/type :update-file - ::rpc/profile-id profile-id - :id file-id - :session-id (uuid/random) - :revn revn - :features cfeat/supported-features - :changes changes} - out (th/command! params)] + #_(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :features cfeat/supported-features + :changes changes} + out (th/command! params)] ;; (th/print-result! out) - (t/is (nil? (:error out))) - (:result out)))] + (t/is (nil? (:error out))) + (:result out)))] (let [storage (:app.storage/storage th/*system*) profile (th/create-profile* 1) @@ -616,7 +618,6 @@ :frame-id frame-id-2)] ;; Add a two frames - (update-file! :file-id (:id file) :profile-id (:id profile) @@ -659,12 +660,10 @@ (t/is (= 0 (:delete res)))) ;; run the file-gc task immediately without forced min-age - (let [res (th/run-task! :file-gc)] - (t/is (= 0 (:processed res)))) + (t/is (false? (th/run-task! :file-gc {:file-id (:id file)}))) ;; run the task again - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; retrieve file and check trimmed attribute (let [row (th/db-get :file {:id (:id file)})] @@ -693,8 +692,7 @@ :page-id page-id :id frame-id-2}]) - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})] (t/is (= 2 (count rows))) @@ -727,8 +725,7 @@ :page-id page-id :id frame-id-1}]) - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})] (t/is (= 1 (count rows))) @@ -1127,8 +1124,7 @@ (th/sleep 300) ;; run the task - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; check that object thumbnails are still here (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})] @@ -1157,8 +1153,7 @@ (t/is (= 2 (count rows)))) ;; run the task again - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) ;; check that we have all object thumbnails (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})] @@ -1220,8 +1215,7 @@ (t/is (= 2 (count rows))))) (t/testing "gc task" - (let [res (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [rows (th/db-query :file-thumbnail {:file-id (:id file)})] (t/is (= 2 (count rows))) @@ -1232,3 +1226,98 @@ (let [rows (th/db-query :file-thumbnail {:file-id (:id file)})] (t/is (= 1 (count rows))))))) + +(t/deftest file-tiered-storage + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + + page-id (uuid/random) + shape-id (uuid/random)] + + ;; Preventive file-gc + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) + + ;; Preventive objects-gc + (let [result (th/run-task! :objects-gc {:min-age 0})] + (t/is (= 1 (:processed result)))) + + ;; Check the number of fragments before adding the page + (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] + (t/is (= 1 (count rows))) + (t/is (every? #(some? (:data %)) rows))) + + ;; Mark the file ellegible again for GC + (th/db-update! :file + {:has-media-trimmed false} + {:id (:id file)}) + + ;; Run FileGC again, with tiered storage activated + (with-redefs [app.config/flags (conj app.config/flags :tiered-file-data-storage)] + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) + + ;; The FileGC task will schedule an inner taskq + (th/run-pending-tasks!)) + + ;; Clean objects after file-gc + (let [result (th/run-task! :objects-gc {:min-age 0})] + (t/is (= 1 (:processed result)))) + + ;; Check the number of fragments before adding the page + (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] + (t/is (= 1 (count rows))) + (t/is (every? #(nil? (:data %)) rows)) + (t/is (every? #(uuid? (:data-ref-id %)) rows)) + (t/is (every? #(= "objects-storage" (:data-backend %)) rows))) + + (let [file (th/db-get :file {:id (:id file)}) + storage (sto/resolve th/*system*)] + (t/is (= "objects-storage" (:data-backend file))) + (t/is (nil? (:data file))) + (t/is (uuid? (:data-ref-id file))) + + (let [sobj (sto/get-object storage (:data-ref-id file))] + (t/is (= "file-data" (:bucket (meta sobj)))) + (t/is (= (:id file) (:file-id (meta sobj)))))) + + ;; Add shape to page that should load from cold storage again into the hot storage (db) + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + [{:type :add-page + :name "test" + :id page-id}]) + + ;; Check the number of fragments + (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] + (t/is (= 2 (count rows)))) + + ;; Check the number of fragments + (let [[row1 row2 :as rows] + (th/db-query :file-data-fragment + {:file-id (:id file) + :deleted-at nil} + {:order-by [:created-at]})] + ;; (pp/pprint rows) + (t/is (= 2 (count rows))) + (t/is (nil? (:data row1))) + (t/is (= "objects-storage" (:data-backend row1))) + (t/is (bytes? (:data row2))) + (t/is (nil? (:data-backend row2)))) + + ;; The file-gc should mark for remove unused fragments + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) + + ;; The objects-gc should remove unused fragments + (let [res (th/run-task! :objects-gc {:min-age 0})] + (t/is (= 2 (:processed res)))) + + ;; Check the number of fragments before adding the page + (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] + (t/is (= 2 (count rows))) + (t/is (every? #(bytes? (:data %)) rows)) + (t/is (every? #(nil? (:data-ref-id %)) rows)) + (t/is (every? #(nil? (:data-backend %)) rows))))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index c73941aff..2ceffbddf 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -114,8 +114,7 @@ ;; Run the File GC task that should remove unused file object ;; thumbnails - (let [result (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed result)))) + (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}) (let [result (th/run-task! :objects-gc {:min-age 0})] (t/is (= 3 (:processed result)))) @@ -134,7 +133,7 @@ (t/is (some? (sto/get-object storage (:media-id row2)))) ;; run the task again - (let [res (th/run-task! "storage-gc-touched" {:min-age 0})] + (let [res (th/run-task! :storage-gc-touched {:min-age 0})] (t/is (= 1 (:delete res))) (t/is (= 0 (:freeze res)))) @@ -217,8 +216,7 @@ ;; Run the File GC task that should remove unused file object ;; thumbnails - (let [result (th/run-task! :file-gc {:min-age 0})] - (t/is (= 1 (:processed result)))) + (t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)}))) (let [result (th/run-task! :objects-gc {:min-age 0})] (t/is (= 2 (:processed result)))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 2d6404435..f20796943 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -21,7 +21,7 @@ (t/use-fixtures :each th/database-reset) (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}) team-id (:default-team-id prof) proj-id (:default-project-id prof) @@ -145,7 +145,7 @@ (t/is (nil? (:result out)))) (let [res (th/run-task! :storage-gc-touched {:min-age 0})] - (t/is (= 6 (:freeze res))) + (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (let [res (th/run-task! :objects-gc {:min-age 0})] @@ -207,7 +207,7 @@ (t/is (nil? (:result out)))) (let [res (th/run-task! :storage-gc-touched {:min-age 0})] - (t/is (= 3 (:freeze res))) + (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (let [res (th/run-task! :objects-gc {:min-age 0})] @@ -268,7 +268,7 @@ (t/is (nil? (:result out)))) (let [res (th/run-task! :storage-gc-touched {:min-age 0})] - (t/is (= 3 (:freeze res))) + (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (let [res (th/run-task! :objects-gc {:min-age 0})] diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 7a90c9a81..1bd49db48 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -505,6 +505,54 @@ (t/is (nil? (:error out))) (t/is (= 0 (:call-count @mock)))))))) +(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1 + (let [sprops (:app.setup/props th/*system*) + itoken (tokens/generate sprops + {:iss :team-invitation + :exp (dt/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user@example.com"}) + data {::th/type :prepare-register-profile + :invitation-token itoken + :email "user@example.com" + :password "foobar"} + + {:keys [result error] :as out} (th/command! data)] + (t/is (nil? error)) + (t/is (map? result)) + (t/is (string? (:token result))) + + (let [rtoken (:token result) + data {::th/type :register-profile + :token rtoken + :fullname "foobar"} + + {:keys [result error] :as out} (th/command! data)] + ;; (th/print-result! out) + (t/is (nil? error)) + (t/is (map? result)) + (t/is (string? (:invitation-token result)))))) + +(t/deftest prepare-and-register-with-invitation-and-enabled-registration-2 + (let [sprops (:app.setup/props th/*system*) + itoken (tokens/generate sprops + {:iss :team-invitation + :exp (dt/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user2@example.com"}) + + data {::th/type :prepare-register-profile + :invitation-token itoken + :email "user@example.com" + :password "foobar"} + out (th/command! data)] + + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-does-not-match-invitation (:code edata)))))) (t/deftest prepare-and-register-with-invitation-and-disabled-registration-1 (with-redefs [app.config/flags [:disable-registration]] @@ -519,22 +567,12 @@ :invitation-token itoken :email "user@example.com" :password "foobar"} + out (th/command! data)] - {:keys [result error] :as out} (th/command! data)] - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:token result))) - - (let [rtoken (:token result) - data {::th/type :register-profile - :token rtoken - :fullname "foobar"} - - {:keys [result error] :as out} (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:invitation-token result))))))) + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :registration-disabled (:code edata))))))) (t/deftest prepare-and-register-with-invitation-and-disabled-registration-2 (with-redefs [app.config/flags [:disable-registration]] @@ -555,7 +593,28 @@ (t/is (not (th/success? out))) (let [edata (-> out :error ex-data)] (t/is (= :restriction (:type edata))) - (t/is (= :email-does-not-match-invitation (:code edata))))))) + (t/is (= :registration-disabled (:code edata))))))) + +(t/deftest prepare-and-register-with-invitation-and-disabled-login-with-password + (with-redefs [app.config/flags [:disable-login-with-password]] + (let [sprops (:app.setup/props th/*system*) + itoken (tokens/generate sprops + {:iss :team-invitation + :exp (dt/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user2@example.com"}) + + data {::th/type :prepare-register-profile + :invitation-token itoken + :email "user@example.com" + :password "foobar"} + out (th/command! data)] + + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :registration-disabled (:code edata))))))) (t/deftest prepare-register-with-registration-disabled (with-redefs [app.config/flags #{}] diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 8b4ccda3f..dd614151e 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -260,6 +260,7 @@ (th/reset-mock! mock) (let [data (assoc data :emails [(:email profile2)]) out (th/command! data)] + ;; (th/print-result! out) (t/is (th/success? out)) (t/is (= 0 (:call-count (deref mock))))) @@ -467,3 +468,146 @@ (let [result (th/run-task! :objects-gc {:min-age 0})] (t/is (= 5 (:processed result)))))) + +(t/deftest create-team-access-request + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) + requester (th/create-profile* 3 {:is-active true :email "requester@bar.com"}) + team (th/create-team* 1 {:profile-id (:id owner)}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id proj)}) + + data {::th/type :create-team-access-request + ::rpc/profile-id (:id requester) + :file-id (:id file)}] + + ;; request success + (let [out (th/command! data) + ;; retrieve the value from the database and check its content + request (db/exec-one! + th/*pool* + ["select count(*) as num from team_access_request where team_id = ? and requester_id = ?" + (:id team) (:id requester)])] + + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))) + (t/is (= 1 (:num request)))) + + ;; request again fails + (th/reset-mock! mock) + (let [out (th/command! data) + edata (-> out :error ex-data)] + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + + (t/is (= :validation (:type edata))) + (t/is (= :request-already-sent (:code edata)))) + + + ;; request again when is expired success + (th/reset-mock! mock) + + (db/exec-one! + th/*pool* + ["update team_access_request set valid_until = ? where team_id = ? and requester_id = ?" + (dt/in-past "1h") (:id team) (:id requester)]) + + (t/is (th/success? (th/command! data))) + (t/is (= 1 (:call-count @mock)))))) + + +(t/deftest create-team-access-request-owner-muted + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true :is-muted true :email "owner@bar.com"}) + requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) + team (th/create-team* 1 {:profile-id (:id owner)}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id proj)}) + + data {::th/type :create-team-access-request + ::rpc/profile-id (:id requester) + :file-id (:id file)}] + + ;; request to team with owner muted should success + (t/is (th/success? (th/command! data))) + (t/is (= 1 (:call-count @mock)))))) + + +(t/deftest create-team-access-request-requester-muted + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) + requester (th/create-profile* 2 {:is-active true :is-muted true :email "requester@bar.com"}) + team (th/create-team* 1 {:profile-id (:id owner)}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id proj)}) + + data {::th/type :create-team-access-request + ::rpc/profile-id (:id requester) + :file-id (:id file)} + + out (th/command! data) + edata (-> out :error ex-data)] + + ;; request with requester muted should fail + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + + (t/is (= :validation (:type edata))) + (t/is (= :member-is-muted (:code edata))) + (t/is (= (:email requester) (:email edata)))))) + + +(t/deftest create-team-access-request-owner-bounce + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) + requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) + team (th/create-team* 1 {:profile-id (:id owner)}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id proj)}) + + pool (:app.db/pool th/*system*) + data {::th/type :create-team-access-request + ::rpc/profile-id (:id requester) + :file-id (:id file)}] + + + (th/create-global-complaint-for pool {:type :bounce :email "owner@bar.com"}) + (let [out (th/command! data) + edata (-> out :error ex-data)] + + ;; request with owner bounce should fail + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + + (t/is (= :restriction (:type edata))) + (t/is (= :email-has-permanent-bounces (:code edata))) + (t/is (= "private" (:email edata))))))) + +(t/deftest create-team-access-request-requester-bounce + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) + requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) + team (th/create-team* 1 {:profile-id (:id owner)}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id proj)}) + + pool (:app.db/pool th/*system*) + data {::th/type :create-team-access-request + ::rpc/profile-id (:id requester) + :file-id (:id file)}] + + ;; request with requester bounce should success + (th/create-global-complaint-for pool {:type :bounce :email "requester@bar.com"}) + (t/is (th/success? (th/command! data))) + (t/is (= 1 (:call-count @mock)))))) + diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index f47472a73..c020c5485 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -166,7 +166,6 @@ out9 (th/command! params)] (t/is (= 8 (:call-count @http-mock))) - (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) (t/is (nil? (:error out3))) diff --git a/backend/test/backend_tests/util_objects_map_test.clj b/backend/test/backend_tests/util_objects_map_test.clj index 29a954597..56c589f6b 100644 --- a/backend/test/backend_tests/util_objects_map_test.clj +++ b/backend/test/backend_tests/util_objects_map_test.clj @@ -8,6 +8,7 @@ (:require [app.common.fressian :as fres] [app.common.schema.generators :as sg] + [app.common.schema.test :as smt] [app.common.transit :as transit] [app.common.types.shape :as cts] [app.common.uuid :as uuid] @@ -84,54 +85,56 @@ (t/is (= (hash obj1) (hash obj2)))))) (t/deftest internal-encode-decode - (sg/check! - (sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) - (cg/not-empty))] + (smt/check! + (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) + (cg/not-empty))] (let [obj1 (omap/wrap data) obj2 (omap/create (deref obj1)) obj3 (assoc obj2 uuid/zero 1) obj4 (omap/create (deref obj3))] ;; (app.common.pprint/pprint data) - (t/is (= (hash obj1) (hash obj2))) - (t/is (not= (hash obj2) (hash obj3))) - (t/is (bytes? (deref obj3))) - (t/is (pos? (alength (deref obj3)))) - (t/is (= (hash obj3) (hash obj4))))))) + + (and (= (hash obj1) (hash obj2)) + (not= (hash obj2) (hash obj3)) + (bytes? (deref obj3)) + (pos? (alength (deref obj3))) + (= (hash obj3) (hash obj4))))) + {:num 50})) (t/deftest fressian-encode-decode - (sg/check! - (sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) - (cg/not-empty) - (cg/fmap omap/wrap) - (cg/fmap (fn [o] {:objects o})))] + (smt/check! + (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) + (cg/not-empty) + (cg/fmap omap/wrap) + (cg/fmap (fn [o] {:objects o})))] (let [res (-> data fres/encode fres/decode)] - (t/is (contains? res :objects)) - (t/is (omap/objects-map? (:objects res))) - (t/is (= (count (:objects data)) - (count (:objects res)))) - (t/is (= (hash (:objects data)) - (hash (:objects res)))))))) + (and (contains? res :objects) + (omap/objects-map? (:objects res)) + (= (count (:objects data)) + (count (:objects res))) + (= (hash (:objects data)) + (hash (:objects res)))))) + {:num 50})) (t/deftest transit-encode-decode - (sg/check! - (sg/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) - (cg/not-empty) - (cg/fmap omap/wrap) - (cg/fmap (fn [o] {:objects o})))] + (smt/check! + (smt/for [data (->> (cg/map cg/uuid (sg/generator ::cts/shape)) + (cg/not-empty) + (cg/fmap omap/wrap) + (cg/fmap (fn [o] {:objects o})))] (let [res (-> data transit/encode transit/decode)] ;; (app.common.pprint/pprint data) ;; (app.common.pprint/pprint res) - (doseq [[k v] (:objects res)] - (t/is (= v (get-in data [:objects k])))) - - (t/is (contains? res :objects)) - (t/is (contains? data :objects)) - - (t/is (omap/objects-map? (:objects data))) - (t/is (not (omap/objects-map? (:objects res)))) - - (t/is (= (count (:objects data)) - (count (:objects res)))))))) + (and (every? (fn [[k v]] + (= v (get-in data [:objects k]))) + (:objects res)) + (contains? res :objects) + (contains? data :objects) + (omap/objects-map? (:objects data)) + (not (omap/objects-map? (:objects res))) + (= (count (:objects data)) + (count (:objects res)))))) + {:num 50})) diff --git a/common/dev/user.clj b/common/dev/user.clj index c558def7b..cb907fade 100644 --- a/common/dev/user.clj +++ b/common/dev/user.clj @@ -8,11 +8,14 @@ (:require [app.common.data :as d] [app.common.fressian :as fres] + [app.common.json :as json] [app.common.pprint :as pp] [app.common.schema :as sm] [app.common.schema.desc-js-like :as smdj] [app.common.schema.desc-native :as smdn] [app.common.schema.generators :as sg] + [malli.core :as m] + [malli.util :as mu] [clojure.java.io :as io] [clojure.pprint :refer [pprint print-table]] [clojure.repl :refer :all] diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 89687c7aa..06c61664b 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -65,7 +65,7 @@ [o [k & ks] v] (if ks (oassoc o k (oassoc-in (get o k) ks v)) - (oassoc o k v))) + (oassoc o k v))) (defn oupdate-in [m ks f & args] @@ -616,7 +616,6 @@ new-elems (remove p? after)))) -;; TODO: remove this (defn addm-at-index "Insert an element in an ordered map at an arbitrary index" [coll index key element] diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc index 7740ef362..31a89e61c 100644 --- a/common/src/app/common/data/macros.cljc +++ b/common/src/app/common/data/macros.cljc @@ -108,14 +108,6 @@ `(do ~@body) (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 "A macro based, optimized variant of `get` that access the property 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 `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! ([expr] `(assert! nil ~expr)) ([hint expr] - (let [hint (cond - (vector? hint) - `(str/ffmt ~@hint) + (let [hint (cond + (vector? hint) + `(str/ffmt ~@hint) - (some? hint) - hint + (some? hint) + hint - :else - (str "expr assert: " (pr-str expr)))] + :else + (str "expr assert: " (pr-str expr)))] (when *assert* - `(binding [*assert-context* ~hint] - (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#)))))))) + `(runtime-assert ~hint (fn [] ~expr)))))) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 8062c8c6d..bd6cb6b7b 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -50,7 +50,8 @@ "styles/v2" "layout/grid" "plugins/runtime" - "design-tokens/v1"}) + "design-tokens/v1" + "text-editor/v2"}) ;; A set of features enabled by default (def default-features @@ -65,7 +66,8 @@ ;; team feature field (def frontend-only-features #{"styles/v2" - "plugins/runtime"}) + "plugins/runtime" + "text-editor/v2"}) ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it @@ -83,7 +85,8 @@ "layout/grid" "fdata/shape-data-type" "plugins/runtime" - "design-tokens/v1"} + "design-tokens/v1" + "text-editor/v2"} (into frontend-only-features))) (sm/register! ::features @@ -91,7 +94,7 @@ {:title "FileFeatures" ::smdj/inline true :gen/gen (smg/subseq supported-features)} - ::sm/set-of-strings]) + [::sm/set :string]]) (defn- flag->feature "Translate a flag to a feature name" @@ -104,6 +107,7 @@ :feature-fdata-pointer-map "fdata/pointer-map" :feature-plugins "plugins/runtime" :feature-design-tokens "design-tokens/v1" + :feature-text-editor-v2 "text-editor/v2" nil)) (defn migrate-legacy-features diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 988164c20..5d93c515f 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -53,7 +53,7 @@ valid? (or (and components-v2 (nil? (:component-id change)) (nil? (:page-id change))) - (ch/check-change! change))] + (ch/valid-change? change))] (when-not valid? (let [explain (sm/explain ::ch/change change)] @@ -741,46 +741,36 @@ (defn add-guide [file guide] - (let [guide (cond-> guide (nil? (:id guide)) (assoc :id (uuid/next))) - 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)] + page-id (:current-page-id file)] (-> file (commit-change - {:type :set-option + {:type :set-guide :page-id page-id - :option :guides - :value new-guides}) + :id (:id guide) + :params guide}) (assoc :last-id (:id guide))))) (defn delete-guide [file id] - (let [page-id (:current-page-id file) - old-guides (or (dm/get-in file [:data :pages-index page-id :options :guides]) {}) - new-guides (dissoc old-guides id)] - (-> file - (commit-change - {:type :set-option - :page-id page-id - :option :guides - :value new-guides})))) + (let [page-id (:current-page-id file)] + (commit-change file + {:type :set-guide + :page-id page-id + :id id + :params nil}))) (defn update-guide [file guide] - - (let [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 - (commit-change - {:type :set-option - :page-id page-id - :option :guides - :value new-guides})))) + (let [page-id (:current-page-id file)] + (commit-change file + {:type :set-guide + :page-id page-id + :id (:id guide) + :params guide}))) (defn strip-image-extension [filename] (let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"] diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 3e19a8037..dbb7d34b4 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -10,15 +10,18 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.schema :as sm] [app.common.schema.desc-native :as smd] + [app.common.schema.generators :as sg] [app.common.types.color :as ctc] [app.common.types.colors-list :as ctcl] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] + [app.common.types.grid :as ctg] [app.common.types.page :as ctp] [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] @@ -28,15 +31,26 @@ [app.common.types.tokens-lib :as ctob] [app.common.types.typographies-list :as ctyl] [app.common.types.typography :as ctt] + [app.common.uuid :as uuid] [clojure.set :as set])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private - schema:operation - [:multi {:dispatch :type :title "Operation" ::smd/simplified true} +(def schema:operation + [:multi {:dispatch :type + :title "Operation" + :decode/json #(update % :type keyword) + ::smd/simplified true} + [:assign + [:map {:title "AssignOperation"} + [:type [:= :assign]] + ;; NOTE: the full decoding is happening on the handler because it + ;; needs a proper context of the current shape and its type + [:value [:map-of :keyword :any]] + [:ignore-touched {:optional true} :boolean] + [:ignore-geometry {:optional true} :boolean]]] [:set [:map {:title "SetOperation"} [:type [:= :set]] @@ -53,17 +67,130 @@ [:type [:= :set-remote-synced]] [:remote-synced {:optional true} [:maybe :boolean]]]]]) -(sm/register! ::change +(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 [:schema - [:multi {:dispatch :type :title "Change" ::smd/simplified true} + [:multi {:dispatch :type + :title "Change" + :decode/json #(update % :type keyword) + ::smd/simplified true} [: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] - [:option [:union - [:keyword] - [:vector {:gen/max 10} :keyword]]] - [:value :any]]] + [:frame-id [:maybe ::sm/uuid]] + [:position [:maybe ::gpt/point]]]] [:add-obj [:map {:title "AddObjChange"} @@ -93,6 +220,10 @@ [:component-id {:optional true} ::sm/uuid] [: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 [:map {:title "FixObjChange"} [:type [:= :fix-obj]] @@ -133,19 +264,12 @@ [:map {:title "ModPageChange"} [:type [:= :mod-page]] [: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 - [: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]]]] + [:set-plugin-data schema:set-plugin-data-change] [:del-page [:map {:title "DelPageChange"} @@ -168,22 +292,21 @@ [:add-color [:map {:title "AddColorChange"} [:type [:= :add-color]] - [:color :any]]] + [:color ::ctc/color]]] [:mod-color [:map {:title "ModColorChange"} [:type [:= :mod-color]] - [:color :any]]] + [:color ::ctc/color]]] [:del-color [:map {:title "DelColorChange"} [:type [:= :del-color]] [:id ::sm/uuid]]] + ;; DEPRECATED: remove before 2.3 [:add-recent-color - [:map {:title "AddRecentColorChange"} - [:type [:= :add-recent-color]] - [:color ::ctc/recent-color]]] + [:map {:title "AddRecentColorChange"}]] [:add-media [:map {:title "AddMediaChange"} @@ -328,14 +451,17 @@ [:set-name :string] [:name :string]]]]]) -(sm/register! ::changes - [:sequential {:gen/max 2} ::change]) +(def schema:changes + [:sequential {:gen/max 5 :gen/min 1} schema:change]) -(def check-change! - (sm/check-fn ::change)) +(sm/register! ::change schema:change) +(sm/register! ::changes schema:changes) + +(def valid-change? + (sm/lazy-validator schema:change)) (def check-changes! - (sm/check-fn ::changes)) + (sm/check-fn schema:changes)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Specific helpers @@ -350,6 +476,16 @@ ;; Page Transformation Changes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:dynamic *touched-changes* + "A dynamic var that used for track changes that touch shapes on + first processing phase of changes. Should be set to a hash-set + instance and will contain changes that caused the touched + modification." + nil) + +(defmulti process-change (fn [_ change] (:type change))) +(defmulti process-operation (fn [_ op] (:type op))) + ;; Changes Processing Impl (defn validate-shapes! @@ -361,10 +497,11 @@ ;; If object has changed or is new verify is correct (when (and (some? shape-new) (not= shape-old shape-new)) - (dm/verify! - "expected valid shape" - (and (cts/check-shape! shape-new) - (cts/shape? shape-new))))))] + (when-not (and (cts/valid-shape? shape-new) + (cts/shape? shape-new)) + (ex/raise :type :assertion + :code :data-validation + :hint "invalid shape found after applying changes")))))] (->> (into #{} (map :page-id) items) (mapcat (fn [page-id] @@ -378,34 +515,105 @@ nil)))) (run! validate-shape!)))) -(defmulti process-change (fn [_ change] (:type change))) -(defmulti process-operation (fn [_ _ op] (:type op))) +(defn- process-touched-change + [data {:keys [id page-id component-id]}] + (let [objects (if page-id + (-> data :pages-index (get page-id) :objects) + (-> data :components (get component-id) :objects)) + shape (get objects id) + croot (ctn/get-component-shape objects shape {:allow-main? true})] + + (if (and (some? croot) (ctk/main-instance? croot)) + (ctkl/set-component-modified data (:component-id croot)) + (if (some? component-id) + (ctkl/set-component-modified data component-id) + data)))) (defn process-changes ([data items] (process-changes data items true)) ([data items verify?] - ;; When verify? false we spec the schema validation. Currently used to make just - ;; 1 validation even if the changes are applied twice + ;; When verify? false we spec the schema validation. Currently used + ;; to make just 1 validation even if the changes are applied twice (when verify? - (dm/verify! - "expected valid changes" - (check-changes! items))) + (check-changes! items)) - (let [result (reduce #(or (process-change %1 %2) %1) data items)] - ;; Validate result shapes (only on the backend) - #?(:clj (validate-shapes! data result items)) - result))) + (binding [*touched-changes* (volatile! #{})] + (let [result (reduce #(or (process-change %1 %2) %1) data items) + result (reduce process-touched-change result @*touched-changes*)] + ;; Validate result shapes (only on the backend) + ;; + ;; TODO: (PERF) add changed shapes tracking and only validate + ;; the tracked changes instead of iterate over all shapes + #?(:clj (validate-shapes! data result items)) + result)))) +;; DEPRECATED: remove before 2.3 release (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] - (fn [data] - (let [path (if (seqable? option) option [option])] - (if value - (assoc-in data (into [:options] path) value) - (assoc data :options (d/dissoc-in (:options data) path))))))) + (fn [page] + (if (and position frame-id) + (update page :comment-thread-positions assoc + comment-thread-id {:frame-id frame-id + :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 [data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}] @@ -417,83 +625,51 @@ (d/update-in-when data [:pages-index page-id] update-container) (d/update-in-when data [:components component-id] update-container)))) +(defn- process-operations + [objects {:keys [id operations] :as change}] + (if-let [shape (get objects id)] + (let [shape (reduce process-operation shape operations) + touched? (-> shape meta ::ctn/touched)] + ;; NOTE: processing operation functions can assign + ;; the ::ctn/touched metadata on shapes, in this case we + ;; need to report them for to be used in the second + ;; phase of changes procesing + (when touched? (some-> *touched-changes* (vswap! conj change))) + (assoc objects id shape)) + + objects)) + (defmethod process-change :mod-obj - [data {:keys [id page-id component-id operations]}] - (let [changed? (atom false) + [data {:keys [page-id component-id] :as change}] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] process-operations change) + (d/update-in-when data [:components component-id :objects] process-operations change))) - process-and-register (partial process-operation - (fn [_shape] (reset! changed? true))) +(defn- process-children-reordering + [objects {:keys [parent-id shapes] :as change}] + (if-let [old-shapes (dm/get-in objects [parent-id :shapes])] + (let [id->idx + (update-vals + (->> (d/enumerate shapes) + (group-by second)) + (comp first first)) - update-fn (fn [objects] - (d/update-when objects id - #(reduce process-and-register % operations))) + new-shapes + (vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] - check-modify-component (fn [data] - (if @changed? - ;; When a shape is modified, if it belongs to a main component instance, - ;; the component needs to be marked as modified. - (let [objects (if page-id - (-> data :pages-index (get page-id) :objects) - (-> data :components (get component-id) :objects)) - shape (get objects id) - component-root (ctn/get-component-shape objects shape {:allow-main? true})] - (if (and (some? component-root) (ctk/main-instance? component-root)) - (ctkl/set-component-modified data (:component-id component-root)) - (if (some? component-id) - (ctkl/set-component-modified data component-id) - data))) - data))] + (if (not= old-shapes new-shapes) + (do + (some-> *touched-changes* (vswap! conj change)) + (update objects parent-id assoc :shapes new-shapes)) + objects)) - (as-> data $ - (if page-id - (d/update-in-when $ [:pages-index page-id :objects] update-fn) - (d/update-in-when $ [:components component-id :objects] update-fn)) - (check-modify-component $)))) + objects)) (defmethod process-change :reorder-children - [data {:keys [parent-id shapes page-id component-id]}] - (let [changed? (atom false) - - update-fn - (fn [objects] - (let [old-shapes (dm/get-in objects [parent-id :shapes]) - - id->idx - (update-vals - (->> shapes - d/enumerate - (group-by second)) - (comp first first)) - - new-shapes - (into [] (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] - - (reset! changed? (not= old-shapes new-shapes)) - - (cond-> objects - @changed? - (d/assoc-in-when [parent-id :shapes] new-shapes)))) - - check-modify-component - (fn [data] - (if @changed? - ;; When a shape is modified, if it belongs to a main component instance, - ;; the component needs to be marked as modified. - (let [objects (if page-id - (-> data :pages-index (get page-id) :objects) - (-> data :components (get component-id) :objects)) - shape (get objects parent-id) - component-root (ctn/get-component-shape objects shape {:allow-main? true})] - (if (and (some? component-root) (ctk/main-instance? component-root)) - (ctkl/set-component-modified data (:component-id component-root)) - data)) - data))] - - (as-> data $ - (if page-id - (d/update-in-when $ [:pages-index page-id :objects] update-fn) - (d/update-in-when $ [:components component-id :objects] update-fn)) - (check-modify-component $)))) + [data {:keys [page-id component-id] :as change}] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] process-children-reordering change) + (d/update-in-when data [:components component-id :objects] process-children-reordering change))) (defmethod process-change :del-obj [data {:keys [page-id component-id id ignore-touched]}] @@ -619,6 +795,7 @@ (d/update-in-when [pid :shapes] d/without-obj sid) (d/update-in-when [pid :shapes] d/vec-without-nils) (cond-> component? (d/update-when pid #(dissoc % :remote-synced)))))))) + (update-parent-id [objects id] (-> objects (d/update-when id assoc :parent-id parent-id))) @@ -677,26 +854,34 @@ (ctpl/add-page data page))) (defmethod process-change :mod-page - [data {:keys [id name]}] - (d/update-in-when data [:pages-index id] assoc :name name)) + [data {:keys [id] :as params}] + (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]}] - - (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) (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 :file (update-fn data) :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 (d/update-in-when data [:pages-index page-id :objects object-id] update-fn) @@ -730,18 +915,11 @@ [data {:keys [id]}] (ctcl/delete-color data id)) +;; DEPRECATED: remove before 2.3 (defmethod process-change :add-recent-color - [data {:keys [color]}] - ;; Moves the color to the top of the list and then truncates up to 15 - (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)))))) + [data _] + data) + ;; -- Media @@ -891,33 +1069,49 @@ (ctob/delete-set name)))) ;; === Operations + +(def ^:private decode-shape + (sm/decoder cts/schema:shape sm/json-transformer)) + +(defmethod process-operation :assign + [{:keys [type] :as shape} {:keys [value] :as op}] + (let [modifications (assoc value :type type) + modifications (decode-shape modifications)] + (reduce-kv (fn [shape k v] + (process-operation shape {:type :set + :attr k + :val v + :ignore-touched (:ignore-touched op) + :ignore-geometry (:ignore-geometry op)})) + shape + modifications))) + (defmethod process-operation :set - [on-changed shape op] + [shape op] (ctn/set-shape-attr shape (:attr op) (:val op) - :on-changed on-changed :ignore-touched (:ignore-touched op) :ignore-geometry (:ignore-geometry op))) (defmethod process-operation :set-touched - [_ shape op] - (let [touched (:touched op) + [shape op] + (let [touched (:touched op) in-copy? (ctk/in-component-copy? shape)] (if (or (not in-copy?) (nil? touched) (empty? touched)) (dissoc shape :touched) (assoc shape :touched touched)))) (defmethod process-operation :set-remote-synced - [_ shape op] + [shape op] (let [remote-synced (:remote-synced op) - in-copy? (ctk/in-component-copy? shape)] + in-copy? (ctk/in-component-copy? shape)] (if (or (not in-copy?) (not remote-synced)) (dissoc shape :remote-synced) (assoc shape :remote-synced true)))) (defmethod process-operation :default - [_ _ op] + [_ op] (ex/raise :type :not-implemented :code :operation-not-implemented :context {:type (:type op)})) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 27f30b344..8513df71d 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -136,12 +136,6 @@ (or (contains? (meta changes) ::page-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! [changes] (dm/assert! @@ -196,41 +190,85 @@ (apply-changes-local))) (defn mod-page - [changes page new-name] - (-> changes - (update :redo-changes conj {:type :mod-page :id (:id page) :name new-name}) - (update :undo-changes conj {:type :mod-page :id (:id page) :name (:name page)}) - (apply-changes-local))) + ([changes options] + (let [page (::page (meta changes))] + (mod-page changes page options))) -(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] - (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] - (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] (let [data (::file-data (meta changes)) old-val (case type :file - (get-in data [:plugin-data namespace key]) + (dm/get-in data [:plugin-data namespace key]) :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 - (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 - (get-in data [:colors id :plugin-data namespace key]) + (dm/get-in data [:colors id :plugin-data namespace key]) :typography - (get-in data [:typographies id :plugin-data namespace key]) + (dm/get-in data [:typographies id :plugin-data namespace key]) :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 - (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 :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 :redo-changes conj redo-change) + (update :undo-changes conj undo-change) (apply-changes-local))))) (defn del-page @@ -247,42 +285,76 @@ (update :undo-changes conj {:type :mov-page :id page-id :index prev-index}) (apply-changes-local))) -(defn set-page-option - [changes option-key option-val] - (assert-page! changes) +(defn set-guide + [changes id guide] (let [page-id (::page-id (meta changes)) - page (::page (meta changes)) - old-val (get-in page [:options option-key])] + page (::page (meta changes)) + old-val (dm/get-in page [:guides id])] (-> changes - (update :redo-changes conj {:type :set-option + (update :redo-changes conj {:type :set-guide :page-id page-id - :option option-key - :value option-val}) - (update :undo-changes conj {:type :set-option + :id id + :params guide}) + (update :undo-changes conj {:type :set-guide :page-id page-id - :option option-key - :value old-val}) - (apply-changes-local)))) - -(defn update-page-option - [changes option-key update-fn & args] - (assert-page! changes) + :id id + :params old-val})))) +(defn set-flow + [changes id flow] (let [page-id (::page-id (meta changes)) - page (::page (meta changes)) - old-val (get-in page [:options option-key]) - new-val (apply update-fn old-val args)] + page (::page (meta changes)) + old-val (dm/get-in page [:flows id]) - (-> changes - (update :redo-changes conj {:type :set-option - :page-id page-id - :option option-key - :value new-val}) - (update :undo-changes conj {:type :set-option - :page-id page-id - :option option-key - :value old-val}) - (apply-changes-local)))) + changes (-> changes + (update :redo-changes conj {:type :set-flow + :page-id page-id + :id id + :params flow}) + (update :undo-changes conj {:type :set-flow + :page-id page-id + :id id + :params old-val}))] + ;; 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 @@ -608,13 +680,6 @@ (reduce resize-parent changes all-parents))) ;; 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 [changes color] (-> changes diff --git a/common/src/app/common/files/defaults.cljc b/common/src/app/common/files/defaults.cljc index 6ef70b5ea..fb70a81ee 100644 --- a/common/src/app/common/files/defaults.cljc +++ b/common/src/app/common/files/defaults.cljc @@ -6,4 +6,4 @@ (ns app.common.files.defaults) -(def version 51) +(def version 55) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 111d05072..2b6c4b450 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -863,11 +863,9 @@ (assoc shadow :color color))) (update-object [object] - (d/update-when object :shadow - #(into [] - (comp (map fix-shadow) - (filter valid-shadow?)) - %))) + (let [xform (comp (map fix-shadow) + (filter valid-shadow?))] + (d/update-when object :shadow #(into [] xform %)))) (update-container [container] (d/update-when container :objects update-vals update-object))] @@ -1010,13 +1008,73 @@ (defn migrate-up-51 "This migration fixes library invalid colors" - [data] (let [update-colors (fn [colors] (into {} (filter #(-> % val valid-color?) 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))) + (def migrations "A vector of all applicable migrations" [{:id 2 :migrate-up migrate-up-2} @@ -1059,4 +1117,8 @@ {:id 48 :migrate-up migrate-up-48} {:id 49 :migrate-up migrate-up-49} {: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}]) diff --git a/common/src/app/common/files/page_diff.cljc b/common/src/app/common/files/page_diff.cljc index e347309ec..821238b95 100644 --- a/common/src/app/common/files/page_diff.cljc +++ b/common/src/app/common/files/page_diff.cljc @@ -15,10 +15,10 @@ [old-page page check-attrs] (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-guides (or (get-in page [:options :guides]) []) + new-guides (or (get page :guides) []) changed-object? (fn [id] diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 5eb708ab3..79e6cf301 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -57,16 +57,17 @@ :misplaced-slot :missing-slot}) -(def ^:private - schema:error - (sm/define - [:map {:title "ValidationError"} - [:code {:optional false} [::sm/one-of error-codes]] - [:hint {:optional false} :string] - [:shape {:optional true} :map] ; Cannot validate a shape because here it may be broken - [:shape-id {:optional true} ::sm/uuid] - [:file-id ::sm/uuid] - [:page-id {:optional true} [:maybe ::sm/uuid]]])) +(def ^:private schema:error + [:map {:title "ValidationError"} + [:code {:optional false} [::sm/one-of error-codes]] + [:hint {:optional false} :string] + [:shape {:optional true} :map] ; Cannot validate a shape because here it may be broken + [:shape-id {:optional true} ::sm/uuid] + [:file-id ::sm/uuid] + [:page-id {:optional true} [:maybe ::sm/uuid]]]) + +(def check-error! + (sm/check-fn schema:error)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ERROR HANDLING @@ -95,7 +96,7 @@ (dm/assert! "expected valid error" - (sm/check! schema:error error)) + (check-error! error)) (vswap! *errors* conj error))) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 7c090a2d6..d6e545cd9 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -67,16 +67,6 @@ ([a b c d e f] (pos->Matrix a b c d e f))) -(def number-regex - #"[+-]?\d*(\.\d+)?([eE][+-]?\d+)?") - -(defn str->matrix - [matrix-str] - (let [params (->> (re-seq number-regex matrix-str) - (filter #(-> % first seq)) - (map (comp d/parse-double first)))] - (apply matrix params))) - (def ^:private schema:matrix-attrs [:map {:title "MatrixAttrs"} [:a ::sm/safe-double] @@ -87,41 +77,70 @@ [:f ::sm/safe-double]]) (def valid-matrix? - (sm/lazy-validator + (sm/validator [:and [:fn matrix?] schema:matrix-attrs])) -(sm/register! ::matrix - (letfn [(decode [o] - (if (map? o) - (map->Matrix o) - (if (string? o) - (str->matrix o) - o))) - (encode [o] - (dm/str (dm/get-prop o :a) "," - (dm/get-prop o :b) "," - (dm/get-prop o :c) "," - (dm/get-prop o :d) "," - (dm/get-prop o :e) "," - (dm/get-prop o :f) ","))] +(defn matrix-generator + [] + (->> (sg/tuple (sg/small-double) + (sg/small-double) + (sg/small-double) + (sg/small-double) + (sg/small-double) + (sg/small-double)) + (sg/fmap #(apply pos->Matrix %)))) - {:type ::matrix - :pred valid-matrix? - :type-properties - {:title "matrix" - :description "Matrix instance" - :error/message "expected a valid point" - :gen/gen (->> (sg/tuple (sg/small-double) - (sg/small-double) - (sg/small-double) - (sg/small-double) - (sg/small-double) - (sg/small-double)) - (sg/fmap #(apply pos->Matrix %))) - ::oapi/type "string" - ::oapi/format "matrix" - ::oapi/decode decode - ::oapi/encode encode}})) +(def ^:private number-regex + #"[+-]?\d*(\.\d+)?([eE][+-]?\d+)?") + +(defn str->matrix + [matrix-str] + (let [params (->> (re-seq number-regex matrix-str) + (filter #(-> % first seq)) + (map (comp d/parse-double first)))] + (apply matrix params))) + +(defn- matrix->str + [o] + (if (matrix? o) + (dm/str (dm/get-prop o :a) "," + (dm/get-prop o :b) "," + (dm/get-prop o :c) "," + (dm/get-prop o :d) "," + (dm/get-prop o :e) "," + (dm/get-prop o :f) ",") + o)) + +(defn- matrix->json + [o] + (if (matrix? o) + (into {} o) + o)) + +(defn- decode-matrix + [o] + (if (map? o) + (map->Matrix o) + (if (string? o) + (str->matrix o) + o))) + +(def schema:matrix + {:type :map + :pred valid-matrix? + :type-properties + {:title "matrix" + :description "Matrix instance" + :error/message "expected a valid matrix instance" + :gen/gen (matrix-generator) + :decode/json decode-matrix + :decode/string decode-matrix + :encode/json matrix->json + :encode/string matrix->str + ::oapi/type "string" + ::oapi/format "matrix"}}) + +(sm/register! ::matrix schema:matrix) ;; FIXME: deprecated (s/def ::a ::us/safe-float) diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index 560f30a5b..2ac57cdbc 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -51,41 +51,55 @@ (s/def ::point (s/and ::point-attrs point?)) - (def ^:private schema:point-attrs [:map {:title "PointAttrs"} [:x ::sm/safe-number] [:y ::sm/safe-number]]) (def valid-point? - (sm/lazy-validator + (sm/validator [:and [:fn point?] schema:point-attrs])) -(sm/register! ::point - (letfn [(decode [p] - (if (map? p) - (map->Point p) - (if (string? p) - (let [[x y] (->> (str/split p #",") (mapv parse-double))] - (pos->Point x y)) - p))) +(defn decode-point + [p] + (if (map? p) + (map->Point p) + (if (string? p) + (let [[x y] (->> (str/split p #",") (mapv parse-double))] + (pos->Point x y)) + p))) - (encode [p] - (dm/str (dm/get-prop p :x) "," - (dm/get-prop p :y)))] +(defn point->str + [p] + (if (point? p) + (dm/str (dm/get-prop p :x) "," + (dm/get-prop p :y)) + p)) - {:type ::point - :pred valid-point? - :type-properties - {:title "point" - :description "Point" - :error/message "expected a valid point" - :gen/gen (->> (sg/tuple (sg/small-int) (sg/small-int)) - (sg/fmap #(apply pos->Point %))) - ::oapi/type "string" - ::oapi/format "point" - ::oapi/decode decode - ::oapi/encode encode}})) +(defn point->json + [p] + (if (point? p) + (into {} p) + p)) + +;; FIXME: make like matrix +(def schema:point + {:type :map + :pred valid-point? + :type-properties + {:title "point" + :description "Point" + :error/message "expected a valid point" + :gen/gen (->> (sg/tuple (sg/small-int) (sg/small-int)) + (sg/fmap #(apply pos->Point %))) + ::oapi/type "string" + ::oapi/format "point" + :decode/json decode-point + :decode/string decode-point + :encode/json point->json + :encode/string point->str}}) + +(sm/register! ::point schema:point) (defn point-like? [{:keys [x y] :as v}] diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc index c23f9942b..3308b9256 100644 --- a/common/src/app/common/geom/rect.cljc +++ b/common/src/app/common/geom/rect.cljc @@ -80,19 +80,38 @@ [:x2 ::sm/safe-number] [:y2 ::sm/safe-number]]) -(sm/register! ::rect - [:and - {:gen/gen (->> (sg/tuple (sg/small-double) - (sg/small-double) - (sg/small-double) - (sg/small-double)) - (sg/fmap #(apply make-rect %)))} - [:fn rect?] - schema:rect-attrs]) +(defn- rect-generator + [] + (->> (sg/tuple (sg/small-double) + (sg/small-double) + (sg/small-double) + (sg/small-double)) + (sg/fmap #(apply make-rect %)))) + +(defn- decode-rect + [o] + (if (map? o) + (map->Rect o) + o)) + +(defn- rect->json + [o] + (if (rect? o) + (into {} o) + o)) + +(def schema:rect + [:and {:error/message "errors.invalid-rect" + :gen/gen (rect-generator) + :decode/json {:leave decode-rect} + :encode/json rect->json} + schema:rect-attrs + [:fn rect?]]) (def valid-rect? - (sm/lazy-validator - [:and [:fn rect?] schema:rect-attrs])) + (sm/validator schema:rect)) + +(sm/register! ::rect schema:rect) (def empty-rect (make-rect 0 0 0.01 0.01)) diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc index 5612837b4..869b7503b 100644 --- a/common/src/app/common/geom/shapes/bounds.cljc +++ b/common/src/app/common/geom/shapes/bounds.cljc @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] [app.common.math :as mth])) (defn shape-stroke-margin @@ -60,6 +61,7 @@ filter-y (mth/min y (+ y offset-y (- spread) (- blur) -5)) filter-w (+ w (mth/abs offset-x) (* 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))) (defn get-rect-filter-bounds @@ -96,12 +98,15 @@ ([shape ignore-margin?] (let [strokes (:strokes shape) + open-path? (and ^boolean (cfh/path-shape? shape) + ^boolean (gsh/open-path? shape)) + stroke-width (->> strokes (map #(case (get % :stroke-alignment :center) :center (/ (:stroke-width % 0) 2) :outer (:stroke-width % 0) - 0)) + (if open-path? (:stroke-width % 0) 0))) (reduce d/max 0)) stroke-margin diff --git a/common/src/app/common/geom/shapes/path.cljc b/common/src/app/common/geom/shapes/path.cljc index 9295c421d..c9863d9f3 100644 --- a/common/src/app/common/geom/shapes/path.cljc +++ b/common/src/app/common/geom/shapes/path.cljc @@ -852,8 +852,10 @@ (defn ray-overlaps? [ray-point {selrect :selrect}] - (and (>= (:y ray-point) (:y1 selrect)) - (<= (:y ray-point) (:y2 selrect)))) + (and (or (> (:y ray-point) (:y1 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 [content] @@ -893,6 +895,7 @@ (reduce +) (not= 0)))) +;; FIXME: this should be on upc/ namespace (defn split-line-to "Given a point and a line-to command will create a two new line-to commands that will split the original line into two given a value between 0-1" @@ -901,6 +904,7 @@ sp (gpt/lerp from-p to-p t-val)] [(upc/make-line-to sp) cmd])) +;; FIXME: this should be on upc/ namespace (defn split-curve-to "Given the point and a curve-to command will split the curve into two new curve-to commands given a value between 0-1" diff --git a/common/src/app/common/json.cljc b/common/src/app/common/json.cljc new file mode 100644 index 000000000..2b6fd0e6b --- /dev/null +++ b/common/src/app/common/json.cljc @@ -0,0 +1,106 @@ +;; 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.json + (:refer-clojure :exclude [read clj->js js->clj]) + (:require + #?(:clj [clojure.data.json :as j]) + [cuerdas.core :as str])) + +#?(:clj + (defn read + [reader & {:as opts}] + (j/read reader opts))) + +#?(:clj + (defn write + [writer data & {:as opts}] + (j/write data writer opts))) + +(defn read-kebab-key + [k] + (if (and (string? k) (not (str/includes? k "/"))) + (-> k str/kebab keyword) + k)) + +(defn write-camel-key + [k] + (if (or (keyword? k) (symbol? k)) + (str/camel k) + (str k))) + +#?(:cljs + (defn ->js + [x & {:keys [key-fn] + :or {key-fn write-camel-key} :as opts}] + (let [f (fn this-fn [x] + (cond + (nil? x) + nil + + (satisfies? cljs.core/IEncodeJS x) + (cljs.core/-clj->js x) + + (or (keyword? x) + (symbol? x)) + (name x) + + (number? x) + x + + (boolean? x) + x + + (map? x) + (reduce-kv (fn [m k v] + (let [k (key-fn k)] + (unchecked-set m k (this-fn v)) + m)) + #js {} + x) + + (coll? x) + (reduce (fn [arr v] + (.push arr (this-fn v)) + arr) + (array) + x) + + :else + (str x)))] + (f x)))) + +#?(:cljs + (defn ->clj + [o & {:keys [key-fn val-fn] :or {key-fn read-kebab-key val-fn identity}}] + (let [f (fn this-fn [x] + (let [x (val-fn x)] + (cond + (array? x) + (persistent! + (.reduce ^js/Array x + #(conj! %1 (this-fn %2)) + (transient []))) + + (identical? (type x) js/Object) + (persistent! + (.reduce ^js/Array (js-keys x) + #(assoc! %1 (key-fn %2) (this-fn (unchecked-get x %2))) + (transient {}))) + + :else + x)))] + (f o)))) + +(defn encode + [data & {:as opts}] + #?(:clj (j/write-str data opts) + :cljs (.stringify js/JSON (->js data opts)))) + +(defn decode + [data & {:as opts}] + #?(:clj (j/read-str data opts) + :cljs (->clj (.parse js/JSON data) opts))) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 85382be2c..25cf38cca 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -1947,54 +1947,54 @@ (defn generate-duplicate-flows [changes shapes page ids-map] - (let [flows (-> page :options :flows) - unames (volatile! (into #{} (map :name flows))) - frames-with-flow (->> shapes - (filter #(= (:type %) :frame)) - (filter #(some? (ctp/get-frame-flow flows (:id %)))))] - (if-not (empty? frames-with-flow) - (let [update-flows (fn [flows] - (reduce - (fn [flows frame] - (let [name (cfh/generate-unique-name @unames "Flow 1") - _ (vswap! unames conj name) - new-flow {:id (uuid/next) - :name name - :starting-frame (get ids-map (:id frame))}] - (ctp/add-flow flows new-flow))) - flows - frames-with-flow))] - (pcb/update-page-option changes :flows update-flows)) - changes))) + (let [flows (get page :flows) + unames (volatile! (cfh/get-used-names (vals flows))) + has-flow? (partial ctp/get-frame-flow flows)] + + (reduce (fn [changes frame-id] + (let [name (cfh/generate-unique-name @unames "Flow 1") + frame-id (get ids-map frame-id) + flow-id (uuid/next) + new-flow {:id flow-id + :name name + :starting-frame frame-id}] + + (vswap! unames conj name) + (pcb/set-flow changes flow-id new-flow))) + + changes + (->> shapes + (filter cfh/frame-shape?) + (map :id) + (filter has-flow?))))) (defn generate-duplicate-guides [changes shapes page ids-map delta] - (let [guides (get-in page [:options :guides]) - frames (->> shapes (filter cfh/frame-shape?)) + (let [guides (get page :guides) + frames (filter cfh/frame-shape? shapes)] - new-guides - (reduce - (fn [g frame] - (let [new-id (ids-map (:id frame)) - new-frame (-> frame (gsh/move delta)) + ;; FIXME: this can be implemented efficiently just indexing guides + ;; by frame-id instead of iterate over all guides all the time - new-guides - (->> guides - (vals) - (filter #(= (:frame-id %) (:id frame))) - (map #(-> % - (assoc :id (uuid/next)) - (assoc :frame-id new-id) - (assoc :position (if (= (:axis %) :x) - (+ (:position %) (- (:x new-frame) (:x frame))) - (+ (:position %) (- (:y new-frame) (:y frame))))))))] - (cond-> g - (not-empty new-guides) - (conj (into {} (map (juxt :id identity) new-guides)))))) - guides - frames)] - (-> (pcb/with-page changes page) - (pcb/set-page-option :guides new-guides)))) + (reduce (fn [changes frame] + (let [new-id (get ids-map (:id frame)) + new-frame (gsh/move frame delta)] + + (reduce-kv (fn [changes _ guide] + (if (= (:id frame) (:frame-id guide)) + (let [guide-id (uuid/next) + position (if (= (:axis guide) :x) + (+ (:position guide) (- (:x new-frame) (:x frame))) + (+ (:position guide) (- (:y new-frame) (:y frame)))) + guide {:id guide-id + :frame-id new-id + :position position}] + (pcb/set-guide changes guide-id guide)) + changes)) + changes + guides))) + (pcb/with-page changes page) + frames))) (defn generate-duplicate-component-change [changes objects page component-root parent-id frame-id delta libraries library-data] diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index f5d38f0c2..0e292847f 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -7,13 +7,11 @@ (ns app.common.logic.shapes (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] [app.common.types.component :as ctk] [app.common.types.container :as ctn] - [app.common.types.page :as ctp] [app.common.types.shape.interactions :as ctsi] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid])) @@ -85,7 +83,9 @@ (pcb/with-page page) (pcb/with-objects objects) (pcb/with-library-data file)) + lookup (d/getf objects) + groups-to-unmask (reduce (fn [group-ids id] ;; When the shape to delete is the mask of a masked group, @@ -110,30 +110,21 @@ interactions))) (vals objects)) - ids-set (set ids-to-delete) - guides-to-remove - (->> (dm/get-in page [:options :guides]) - (vals) - (filter #(contains? ids-set (:frame-id %))) - (map :id)) + changes + (reduce (fn [changes {:keys [id] :as flow}] + (if (contains? ids-to-delete (:starting-frame flow)) + (pcb/set-flow changes id nil) + changes)) + 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 (reduce (fn [res id] ;; All parents of any deleted shape must be resized. (into res (cfh/get-parent-ids objects id))) (d/ordered-set) - ids-to-delete) + (concat ids-to-delete ids-to-hide)) all-children (->> ids-to-delete ;; Children of deleted shapes must be also deleted. @@ -158,7 +149,11 @@ empty-parents ;; 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 (if components-v2 @@ -172,8 +167,18 @@ (into ids-to-delete all-children)) []) - changes (-> changes - (pcb/set-page-option :guides guides)) + ids-set (set ids-to-delete) + + 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] ;; 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))) changes components-to-delete) + changes (-> changes (generate-update-shape-flags ids-to-hide objects {:hidden true}) (pcb/remove-objects all-children {:ignore-touched true}) @@ -197,11 +203,7 @@ (into [] (remove #(and (ctsi/has-destination %) (contains? ids-to-delete (:destination %)))) - interactions))))) - (cond-> (seq starting-flows) - (pcb/update-page-option :flows (fn [flows] - (->> (map :id starting-flows) - (reduce ctp/remove-flow flows))))))] + interactions))))))] [all-parents changes])) @@ -406,17 +408,12 @@ ;; Resize parent containers that need to (pcb/resize-parents parents)))) - - - (defn change-show-in-viewer [shape hide?] - (cond-> (assoc shape :hide-in-viewer hide?) - ;; When a frame is no longer shown in view mode, it cannot have interactions - hide? - (dissoc :interactions))) + (assoc shape :hide-in-viewer hide?)) (defn add-new-interaction [shape interaction] (-> shape - (update :interactions ctsi/add-interaction interaction) - ;; When a interaction is created, the frame must be shown in view mode - (dissoc :hide-in-viewer))) + (update :interactions ctsi/add-interaction interaction))) + +(defn show-in-viewer [shape] + (dissoc shape :hide-in-viewer)) diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 691da5f93..c0c933266 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -5,11 +5,10 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.schema - (:refer-clojure :exclude [deref merge parse-uuid]) + (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean]) #?(:cljs (:require-macros [app.common.schema :refer [ignoring]])) (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.pprint :as pp] [app.common.schema.generators :as sg] [app.common.schema.openapi :as-alias oapi] @@ -29,11 +28,6 @@ [malli.util :as mu])) (defprotocol ILazySchema - (-get-schema [_]) - (-get-validator [_]) - (-get-explainer [_]) - (-get-decoder [_]) - (-get-encoder [_]) (-validate [_ o]) (-explain [_ o]) (-decode [_ o])) @@ -53,27 +47,21 @@ [s] (m/type-properties s)) -(defn lazy-schema? +(defn- lazy-schema? [s] (satisfies? ILazySchema s)) (defn schema [s] - (if (lazy-schema? s) - (-get-schema s) - (m/schema s default-options))) + (m/schema s default-options)) (defn validate [s value] - (if (lazy-schema? s) - (-validate s value) - (m/validate s value default-options))) + (m/validate s value default-options)) (defn explain [s value] - (if (lazy-schema? s) - (-explain s value) - (m/explain s value default-options))) + (m/explain s value default-options)) (defn simplify "Given an explain data structure, return a simplified version of it" @@ -113,34 +101,49 @@ [schema] (mu/optional-keys schema default-options)) -(def default-transformer - (let [default-decoder - {:compile (fn [s _registry] - (let [props (m/type-properties s)] - (or (::oapi/decode props) - (::decode props))))} +(defn transformer + [& transformers] + (apply mt/transformer transformers)) - default-encoder - {:compile (fn [s _] - (let [props (m/type-properties s)] - (or (::oapi/encode props) - (::encode props))))} +;; (defn key-transformer +;; [& {:as opts}] +;; (mt/key-transformer opts)) - coders {:vector mt/-sequential-or-set->vector - :sequential mt/-sequential-or-set->seq - :set mt/-sequential->set - :tuple mt/-sequential->vector}] +;; (defn- transform-map-keys +;; [f o] +;; (cond +;; (record? o) +;; (reduce-kv (fn [res k v] +;; (let [k' (f k)] +;; (if (= k k') +;; res +;; (-> res +;; (assoc k' v) +;; (dissoc k))))) +;; o +;; o) - (mt/transformer - {:name :penpot - :default-decoder default-decoder - :default-encoder default-encoder} - {:name :string - :decoders (mt/-string-decoders) - :encoders (mt/-string-encoders)} - {:name :collections - :decoders coders - :encoders coders}))) +;; (map? o) +;; (persistent! +;; (reduce-kv (fn [res k v] +;; (assoc! res (f k) v)) +;; (transient {}) +;; o)) + +;; :else +;; o)) + +(defn json-transformer + [] + (mt/transformer + (mt/json-transformer) + (mt/collection-transformer))) + +(defn string-transformer + [] + (mt/transformer + (mt/string-transformer) + (mt/collection-transformer))) (defn encode ([s val transformer] @@ -149,8 +152,6 @@ (m/encode s val options transformer))) (defn decode - ([s val] - (m/decode s val default-options default-transformer)) ([s val transformer] (m/decode s val default-options transformer)) ([s val options transformer] @@ -158,31 +159,19 @@ (defn validator [s] - (if (lazy-schema? s) - (-get-validator s) - (-> s schema m/validator))) + (-> s schema m/validator)) (defn explainer [s] - (if (lazy-schema? s) - (-get-explainer s) - (-> s schema m/explainer))) + (-> s schema m/explainer)) (defn encoder - ([s] - (if (lazy-schema? s) - (-get-decoder s) - (encoder s default-options default-transformer))) ([s transformer] (m/encoder s default-options transformer)) ([s options transformer] (m/encoder s options transformer))) (defn decoder - ([s] - (if (lazy-schema? s) - (-get-decoder s) - (decoder s default-options default-transformer))) ([s transformer] (m/decoder s default-options transformer)) ([s options transformer] @@ -199,10 +188,9 @@ (fn [v] (@vfn v)))) (defn lazy-decoder - ([s] (lazy-decoder s default-transformer)) - ([s transformer] - (let [vfn (delay (decoder (if (delay? s) (deref s) s) transformer))] - (fn [v] (@vfn v))))) + [s transformer] + (let [vfn (delay (decoder (if (delay? s) (deref s) s) transformer))] + (fn [v] (@vfn v)))) (defn humanize-explain "Returns a string representation of the explain data structure" @@ -232,6 +220,8 @@ (v/-block "Schema" (v/-visit schema printer) printer)]}) (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] :or {variant ::explain message "Validation Error"}}] @@ -244,129 +234,55 @@ `(try ~expr (catch :default e# nil)) `(try ~expr (catch Throwable e# nil)))) -(defn simple-schema - [& {:keys [pred] :as options}] - (cond-> options - (contains? options :type-properties) - (update :type-properties (fn [props] - (cond-> props - (contains? props :decode/string) - (update :decode/string (fn [decode-fn] - (fn [s] - (if (pred s) - s - (or (ignoring (decode-fn s)) s))))) - (contains? props ::decode) - (update ::decode (fn [decode-fn] - (fn [s] - (if (pred s) - s - (or (ignoring (decode-fn s)) s)))))))) - :always - (m/-simple-schema))) - (defn lookup "Lookups schema from registry." ([s] (lookup sr/default-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 implemented on the provided `s` schema. Sould not be used directly." - [s value] + [s type code hint value] (when-not ^boolean (-validate s value) - (let [hint (d/nilv dm/*assert-context* "check error") - explain (-explain s value)] - (throw (ex-info hint {:type :assertion - :code :data-validation + (let [explain (-explain s value)] + (throw (ex-info hint {:type type + :code code :hint hint ::explain explain})))) - true) + value) -(declare define) +(declare ^:private lazy-schema) (defn check-fn "Create a predefined check function" - [s] - (let [schema (if (lazy-schema? s) s (define s))] - (partial fast-check! schema))) + [s & {:keys [hint type code]}] + (let [schema (if (lazy-schema? s) s (lazy-schema s)) + 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! "A helper intended to be used on assertions for validate/check the - schema over provided data. Raises an assertion exception, should be - used together with `dm/assert!` or `dm/verify!`." - [s value] - (if (lazy-schema? s) - (fast-check! s value) - (do - (when-not ^boolean (m/validate s value default-options) - (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)) + schema over provided data. Raises an assertion exception." + [s value & {:keys [hint type code]}] + (let [s (if (lazy-schema? s) s (lazy-schema s)) + hint (or ^boolean hint "check error") + type (or ^boolean type :assertion) + code (or ^boolean code :data-validation)] + (fast-check! s type code hint value))) (defn register! [type s] - (let [s (if (map? s) (simple-schema s) s)] + (let [s (if (map? s) (m/-simple-schema s) s)] (swap! sr/registry assoc type s) nil)) -(defn define +(defn- lazy-schema "Create ans instance of ILazySchema" - [s & {:keys [transformer] :as options}] + [s] (let [schema (delay (schema s)) validator (delay (m/validator @schema)) - explainer (delay (m/explainer @schema)) - - options (c/merge default-options (dissoc options :transformer)) - transformer (or transformer default-transformer) - decoder (delay (m/decoder @schema options transformer)) - encoder (delay (m/encoder @schema options transformer))] + explainer (delay (m/explainer @schema))] (reify m/AST @@ -409,16 +325,6 @@ (m/-form @schema)) ILazySchema - (-get-schema [_] - @schema) - (-get-validator [_] - @validator) - (-get-explainer [_] - @explainer) - (-get-encoder [_] - @encoder) - (-get-decoder [_] - @decoder) (-validate [_ o] (@validator o)) (-explain [_ o] @@ -448,16 +354,19 @@ :description "UUID formatted string" :error/message "should be an uuid" :gen/gen (sg/uuid) + :decode/string parse-uuid + :decode/json parse-uuid + :encode/string str + :encode/json str ::oapi/type "string" - ::oapi/format "uuid" - ::oapi/decode parse-uuid}}) + ::oapi/format "uuid"}}) (def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") (defn parse-email [s] (if (string? s) - (re-matches email-re s) + (first (re-seq email-re s)) nil)) (defn email-string? @@ -480,13 +389,12 @@ :description "string with valid email address" :error/code "errors.invalid-email" :gen/gen (sg/email) + :decode/string (fn [v] (or (parse-email v) v)) + :decode/json (fn [v] (or (parse-email v) v)) ::oapi/type "string" - ::oapi/format "email" - ::oapi/decode - (fn [v] - (or (parse-email v) v))}}) + ::oapi/format "email"}}) -(def non-empty-strings-xf +(def xf:filter-word-strings (comp (filter string?) (remove str/empty?) @@ -499,41 +407,76 @@ :min 0 :max 1 :compile - (fn [{:keys [coerce kind max min] :as props} children _] - (let [xform (if coerce - (comp non-empty-strings-xf (map coerce)) - non-empty-strings-xf) - kind (or (last children) kind) - pred (cond - (fn? kind) kind - (nil? kind) any? - :else (validator kind)) + (fn [{:keys [kind max min] :as props} children _] + (let [kind (or (last children) kind) - pred (cond - (and max min) - (fn [value] - (let [size (count value)] - (and (set? value) - (<= min size max) - (every? pred value)))) + pred + (cond + (fn? kind) kind + (nil? kind) any? + :else (validator kind)) - min - (fn [value] - (let [size (count value)] - (and (set? value) - (<= min size) - (every? pred value)))) + pred + (cond + (and max min) + (fn [value] + (let [size (count value)] + (and (set? value) + (<= min size max) + (every? pred value)))) - max - (fn [value] - (let [size (count value)] - (and (set? value) - (<= size max) - (every? pred value)))) + min + (fn [value] + (let [size (count value)] + (and (set? value) + (<= min size) + (every? pred value)))) + + max + (fn [value] + (let [size (count value)] + (and (set? value) + (<= size max) + (every? pred value)))) + + :else + (fn [value] + (every? pred value))) + + + decode-string-child + (decoder kind string-transformer) + + decode-string + (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") 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 + (fn [o] + (if (set? o) + (vec o) + o))] - :else - (fn [value] - (every? pred value)))] {:pred pred :type-properties @@ -541,13 +484,14 @@ :description "Set of Strings" :error/message "should be a set of strings" :gen/gen (-> kind sg/generator sg/set) + :decode/string decode-string + :decode/json decode-json + :encode/string encode-string + :encode/json encode-json ::oapi/type "array" ::oapi/format "set" ::oapi/items {:type "string"} - ::oapi/unique-items true - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into #{} xform v)))}}))}) + ::oapi/unique-items true}}))}) (register! ::vec @@ -555,42 +499,67 @@ :min 0 :max 1 :compile - (fn [{:keys [coerce kind max min] :as props} children _] - (let [xform (if coerce - (comp non-empty-strings-xf (map coerce)) - non-empty-strings-xf) + (fn [{:keys [kind max min] :as props} children _] + (let [kind (or (last children) kind) + pred + (cond + (fn? kind) kind + (nil? kind) any? + :else (validator kind)) - kind (or (last children) kind) - pred (cond - (fn? kind) kind - (nil? kind) any? - :else (validator kind)) + pred + (cond + (and max min) + (fn [value] + (let [size (count value)] + (and (set? value) + (<= min size max) + (every? pred value)))) - pred (cond - (and max min) - (fn [value] - (let [size (count value)] - (and (set? value) - (<= min size max) - (every? pred value)))) + min + (fn [value] + (let [size (count value)] + (and (set? value) + (<= min size) + (every? pred value)))) - min - (fn [value] - (let [size (count value)] - (and (set? value) - (<= min size) - (every? pred value)))) + max + (fn [value] + (let [size (count value)] + (and (set? value) + (<= size max) + (every? pred value)))) - max - (fn [value] - (let [size (count value)] - (and (set? value) - (<= size max) - (every? pred value)))) + :else + (fn [value] + (every? pred value))) - :else - (fn [value] - (every? pred value)))] + decode-string-child + (decoder kind string-transformer) + + decode-json-child + (decoder kind json-transformer) + + decode-string + (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") 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 + (fn [o] + (if (vector? o) + (str/join ", " (map encode-string-child o)) + o))] {:pred pred :type-properties @@ -598,14 +567,13 @@ :description "Set of Strings" :error/message "should be a set of strings" :gen/gen (-> kind sg/generator sg/set) + :decode/string decode-string + :decode/json decode-json + :encode/string encode-string ::oapi/type "array" ::oapi/format "set" ::oapi/items {:type "string"} - ::oapi/unique-items true - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into [] xform v)))}}))}) - + ::oapi/unique-items true}}))}) (register! ::set-of-strings {:type ::set-of-strings @@ -615,13 +583,13 @@ :description "Set of Strings" :error/message "should be a set of strings" :gen/gen (-> :string sg/generator sg/set) + :decode/string (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") v)] + (into #{} xf:filter-word-strings v))) ::oapi/type "array" ::oapi/format "set" ::oapi/items {:type "string"} - ::oapi/unique-items true - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into #{} non-empty-strings-xf v)))}}) + ::oapi/unique-items true}}) (register! ::set-of-keywords {:type ::set-of-keywords @@ -631,29 +599,13 @@ :description "Set of Strings" :error/message "should be a set of strings" :gen/gen (-> :keyword sg/generator sg/set) + :decode/string (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") v)] + (into #{} (comp xf:filter-word-strings (map keyword)) v))) ::oapi/type "array" ::oapi/format "set" ::oapi/items {:type "string" :format "keyword"} - ::oapi/unique-items true - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into #{} (comp non-empty-strings-xf (map keyword)) v)))}}) - -(register! ::set-of-emails - {:type ::set-of-emails - :pred #(and (set? %) (every? string? %)) - :type-properties - {:title "set[email]" - :description "Set of Emails" - :error/message "should be a set of emails" - :gen/gen (-> ::email sg/generator sg/set) - ::oapi/type "array" - ::oapi/format "set" - ::oapi/items {:type "string" :format "email"} - ::oapi/unique-items true - ::decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into #{} (keep parse-email) v)))}}) + ::oapi/unique-items true}}) (register! ::set-of-uuid {:type ::set-of-uuid @@ -663,13 +615,13 @@ :description "Set of UUID" :error/message "should be a set of UUID instances" :gen/gen (-> ::uuid sg/generator sg/set) + :decode/string (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") v)] + (into #{} (keep parse-uuid) v))) ::oapi/type "array" ::oapi/format "set" ::oapi/items {:type "string" :format "uuid"} - ::oapi/unique-items true - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into #{} (keep parse-uuid) v)))}}) + ::oapi/unique-items true}}) (register! ::coll-of-uuid {:type ::set-of-uuid @@ -679,13 +631,13 @@ :description "Coll of UUID" :error/message "should be a coll of UUID instances" :gen/gen (-> ::uuid sg/generator sg/set) + :decode/string (fn [v] + (let [v (if (string? v) (str/split v #"[\s,]+") v)] + (into [] (keep parse-uuid) v))) ::oapi/type "array" ::oapi/format "array" ::oapi/items {:type "string" :format "uuid"} - ::oapi/unique-items false - ::oapi/decode (fn [v] - (let [v (if (string? v) (str/split v #"[\s,]+") v)] - (into [] (keep parse-uuid) v)))}}) + ::oapi/unique-items false}}) (register! ::one-of {:type ::one-of @@ -693,70 +645,168 @@ :max 1 :compile (fn [props children _] (let [options (into #{} (last children)) - format (:format props "keyword")] + format (:format props "keyword") + decode (if (= format "keyword") + keyword + identity)] {:pred #(contains? options %) :type-properties {:title "one-of" :description "One of the Set" :gen/gen (sg/elements options) + :decode/string decode + :decode/json decode ::oapi/type "string" - ::oapi/format (:format props "keyword") - ::oapi/decode (if (= format "keyword") - keyword - identity)}}))}) + ::oapi/format (:format props "keyword")}}))}) ;; Integer/MAX_VALUE (def max-safe-int 2147483647) ;; Integer/MIN_VALUE (def min-safe-int -2147483648) -(register! ::safe-int - {:type ::safe-int - :pred #(and (int? %) (>= max-safe-int %) (>= % min-safe-int)) - :type-properties - {:title "int" - :description "Safe Integer" - :error/message "expected to be int in safe range" - :gen/gen (sg/small-int) - ::oapi/type "integer" - ::oapi/format "int64" - ::oapi/decode (fn [s] - (if (string? s) - (parse-long s) - s))}}) +(defn parse-long + [v] + (or (ignoring + (if (string? v) + (c/parse-long v) + v)) + v)) -(register! ::safe-number - {:type ::safe-number - :pred #(and (number? %) (>= max-safe-int %) (>= % min-safe-int)) - :type-properties - {:title "number" - :description "Safe Number" - :error/message "expected to be number in safe range" - :gen/gen (sg/one-of (sg/small-int) - (sg/small-double)) - ::oapi/type "number" - ::oapi/format "double" - ::oapi/decode (fn [s] - (if (string? s) - (parse-double s) - s))}}) +(def type:int + {:type :int + :min 0 + :max 0 + :compile + (fn [{:keys [max min] :as props} _ _] + (let [pred int? + pred (if (some? min) + (fn [v] + (and (>= v min) + (pred v))) + pred) + pred (if (some? max) + (fn [v] + (and (>= max v) + (pred v))) + pred)] -(register! ::safe-double - {:type ::safe-double - :pred #(and (double? %) (>= max-safe-int %) (>= % min-safe-int)) - :type-properties - {:title "number" - :description "Safe Number" - :error/message "expected to be number in safe range" - :gen/gen (sg/small-double) - ::oapi/type "number" - ::oapi/format "double" - ::oapi/decode (fn [s] - (if (string? s) - (parse-double s) - s))}}) + {:pred pred + :type-properties + {:title "int" + :description "int" + :error/message "expected to be int/long" + :error/code "errors.invalid-integer" + :gen/gen (sg/small-int :max max :min min) + :decode/string parse-long + :decode/json parse-long + ::oapi/type "integer" + ::oapi/format "int64"}}))}) -(register! ::contains-any +(defn parse-double + [v] + (or (ignoring + (if (string? v) + (c/parse-double v) + v)) + v)) + +(def type:double + {:type :double + :min 0 + :max 0 + :compile + (fn [{:keys [max min] :as props} _ _] + (let [pred double? + pred (if (some? min) + (fn [v] + (and (>= v min) + (pred v))) + pred) + pred (if (some? max) + (fn [v] + (and (>= max v) + (pred v))) + pred)] + + {:pred pred + :type-properties + {:title "doble" + :description "double number" + :error/message "expected to be double" + :error/code "errors.invalid-double" + :gen/gen (sg/small-double :max max :min min) + :decode/string parse-double + :decode/json parse-double + ::oapi/type "number" + ::oapi/format "double"}}))}) + +(def type:number + {:type :number + :min 0 + :max 0 + :compile + (fn [{:keys [max min] :as props} _ _] + (let [pred number? + pred (if (some? min) + (fn [v] + (and (>= v min) + (pred v))) + pred) + pred (if (some? max) + (fn [v] + (and (>= max v) + (pred v))) + pred) + + gen (sg/one-of + (sg/small-int :max max :min min) + (sg/small-double :max max :min min))] + + {:pred pred + :type-properties + {:title "int" + :description "int" + :error/message "expected to be number" + :error/code "errors.invalid-number" + :gen/gen gen + :decode/string parse-double + :decode/json parse-double + ::oapi/type "number"}}))}) + +(register! ::int type:int) +(register! ::double type:double) +(register! ::number type:number) + +(register! ::safe-int [::int {:max max-safe-int :min min-safe-int}]) +(register! ::safe-double [::double {:max max-safe-int :min min-safe-int}]) +(register! ::safe-number [::number {:max max-safe-int :min min-safe-int}]) + +(defn parse-boolean + [v] + (if (string? v) + (case v + ("true" "t" "1") true + ("false" "f" "0") false + v) + v)) + +(def type:boolean + {:type :boolean + :pred boolean? + :type-properties + {:title "boolean" + :description "boolean" + :error/message "expected boolean" + :error/code "errors.invalid-boolean" + :gen/gen sg/boolean + :decode/string parse-boolean + :decode/json parse-boolean + :encode/string str + ::oapi/type "boolean"}}) + +(register! ::boolean type:boolean) + +(def type:contains-any {:type ::contains-any :min 1 :max 1 @@ -774,20 +824,28 @@ {:title "contains" :description "contains predicate"}}))}) -(register! ::inst +(register! ::contains-any type:contains-any) + +(def type:inst {:type ::inst :pred inst? :type-properties {:title "inst" :description "Satisfies Inst protocol" - :error/message "expected to be number in safe range" + :error/message "should be an instant" :gen/gen (->> (sg/small-int) - (sg/fmap (fn [v] (tm/instant v)))) - ::oapi/type "number" - ::oapi/format "int64"}}) + (sg/fmap (fn [v] (tm/parse-instant v)))) -(register! ::fn - [:schema fn?]) + :decode/string tm/parse-instant + :encode/string tm/format-instant + :decode/json tm/parse-instant + :encode/json tm/format-instant + ::oapi/type "string" + ::oapi/format "iso"}}) + +(register! ::inst type:inst) + +(register! ::fn [:schema fn?]) ;; FIXME: deprecated, replace with ::text @@ -803,6 +861,13 @@ ::oapi/type "string" ::oapi/format "string"}}) + +(defn decode-uri + [val] + (if (u/uri? val) + val + (-> val str/trim u/uri))) + (register! ::uri {:type ::uri :pred u/uri? @@ -838,13 +903,10 @@ :description "URI formatted string" :error/code "errors.invalid-uri" :gen/gen (sg/uri) + :decode/string decode-uri + :decode/json decode-uri ::oapi/type "string" - ::oapi/format "uri" - ::oapi/decode - (fn [val] - (if (u/uri? val) - val - (-> val str/trim u/uri)))}}) + ::oapi/format "uri"}}) (register! ::text {:type :string @@ -918,6 +980,12 @@ (def check-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! (check-fn ::coll-of-uuid)) @@ -925,4 +993,4 @@ (check-fn ::set-of-uuid)) (def check-set-of-emails! - (check-fn ::set-of-emails)) + (check-fn [::set ::email])) diff --git a/common/src/app/common/schema/generators.cljc b/common/src/app/common/schema/generators.cljc index 081e1d5ca..57bc3703f 100644 --- a/common/src/app/common/schema/generators.cljc +++ b/common/src/app/common/schema/generators.cljc @@ -5,46 +5,21 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.schema.generators - (:refer-clojure :exclude [set subseq uuid for filter map let]) + (:refer-clojure :exclude [set subseq uuid filter map let boolean]) #?(:cljs (:require-macros [app.common.schema.generators])) (:require [app.common.schema.registry :as sr] [app.common.uri :as u] [app.common.uuid :as uuid] [clojure.core :as c] - [clojure.test.check :as tc] [clojure.test.check.generators :as tg] - [clojure.test.check.properties :as tp] [cuerdas.core :as str] [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 [& 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 ([g] (mg/sample g {:registry sr/default-registry})) @@ -77,14 +52,16 @@ (defn word-string [] - (as-> tg/string-alphanumeric $$ - (tg/such-that (fn [v] (re-matches #"\w+" v)) $$ 50) - (tg/such-that (fn [v] - (and (not (str/blank? v)) - (not (re-matches #"^\d+.*" v)))) - $$ - 50))) + (as-> tg/string-ascii $$ + (tg/resize 10 $$) + (tg/fmap (fn [v] (apply str (re-seq #"[A-Za-z]+" v))) $$) + (tg/such-that (fn [v] (>= (count v) 4)) $$ 100) + (tg/fmap str/lower $$))) +(defn word-keyword + [] + (->> (word-string) + (tg/fmap keyword))) (defn email [] @@ -94,7 +71,6 @@ (tg/fmap (fn [v] (str v "@example.net"))))) - (defn uri [] (tg/let [scheme (tg/elements ["http" "https"]) @@ -106,8 +82,7 @@ (defn uuid [] - (->> tg/small-integer - (tg/fmap (fn [_] (uuid/next))))) + (tg/fmap (fn [_] (uuid/next)) (small-int))) (defn subseq "Given a collection, generates \"subsequences\" which are sequences @@ -125,6 +100,9 @@ (c/map second)) (c/map list bools elements))))))) +(def any tg/any) +(def boolean tg/boolean) + (defn set [g] (tg/set g)) diff --git a/common/src/app/common/schema/test.cljc b/common/src/app/common/schema/test.cljc new file mode 100644 index 000000000..7fa774dd1 --- /dev/null +++ b/common/src/app/common/schema/test.cljc @@ -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?)))) diff --git a/common/src/app/common/svg/shapes_builder.cljc b/common/src/app/common/svg/shapes_builder.cljc index 41f25e1e2..97d738a3b 100644 --- a/common/src/app/common/svg/shapes_builder.cljc +++ b/common/src/app/common/svg/shapes_builder.cljc @@ -10,6 +10,7 @@ [app.common.colors :as clr] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] @@ -29,12 +30,12 @@ {:x 0 :y 0 :width 1 :height 1}) (defn- assert-valid-num [attr num] - (dm/verify! - ["%1 attribute has invalid value: %2" (d/name attr) num] - (and (d/num? num) - (<= num max-safe-int) - (>= num min-safe-int))) - + (when-not (and (d/num? num) + (<= num max-safe-int) + (>= num min-safe-int)) + (ex/raise :type :assertion + :code :data-validation + :hint (str "invalid numeric value for `" attr "`: " num))) (cond (and (> num 0) (< num 1)) 1 (and (< num 0) (> num -1)) -1 @@ -43,19 +44,21 @@ (defn- assert-valid-pos-num [attr num] - (dm/verify! - ["%1 attribute should be positive" (d/name attr)] - (pos? num)) - + (when-not (pos? num) + (ex/raise :type :assertion + :code :data-validation + :hint (str "invalid numeric value for `" attr "`: " num " (should be positive)"))) num) (defn- assert-valid-blend-mode [mode] - (let [clean-value (-> mode str/trim str/lower keyword)] - (dm/verify! - ["%1 is not a valid blend mode" clean-value] - (contains? cts/blend-modes clean-value)) - clean-value)) + (let [value (-> mode str/trim str/lower keyword)] + + (when-not (contains? cts/blend-modes value) + (ex/raise :type :assertion + :code :data-validation + :hint (str "unexpected blend mode: " value))) + value)) (defn- svg-dimensions [{:keys [attrs] :as data}] diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index c5d14f549..3a7fdec93 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -78,6 +78,12 @@ (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 {:typography-ref-file nil :typography-ref-id nil @@ -92,9 +98,13 @@ :text-transform "none" :text-align "left" :text-decoration "none" + :text-direction "ltr" :fills [{:fill-color clr/black :fill-opacity 1}]}) +(def default-attrs + (merge default-root-attrs default-text-attrs)) + (def typography-fields [:font-id :font-family diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 8cbfe9541..4f27d0531 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -5,13 +5,14 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.time - "A new cross-platform date and time API. It should be prefered over - a platform specific implementation found on `app.util.time`." + "Minimal cross-platoform date time api for specific use cases on types + definition and other common code." #?(:cljs (:require ["luxon" :as lxn]) :clj (:import + java.time.format.DateTimeFormatter java.time.Instant java.time.Duration))) @@ -31,10 +32,29 @@ [one other] (.isAfter one other))) -(defn instant +(defn instant? + [o] + #?(:clj (instance? Instant o) + :cljs (instance? DateTime o))) + +(defn parse-instant [s] - #?(:clj (Instant/ofEpochMilli s) - :cljs (.fromMillis ^js DateTime s #js {:zone "local" :setZone false}))) + (cond + (instant? s) + s + + (int? s) + #?(:clj (Instant/ofEpochMilli s) + :cljs (.fromMillis ^js DateTime s #js {:zone "local" :setZone false})) + + (string? s) + #?(:clj (Instant/parse s) + :cljs (.fromISO ^js DateTime s)))) + +(defn format-instant + [v] + #?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v) + :cljs (.toISO ^js v))) ;; To check for valid date time we can just use the core inst? function diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index 5ab2dc635..fd20b0330 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -9,48 +9,51 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] + [app.common.schema.generators :as sg] [app.common.schema.openapi :as-alias oapi] [app.common.text :as txt] - [app.common.types.color.generic :as-alias color-generic] - [app.common.types.color.gradient :as-alias color-gradient] - [app.common.types.color.gradient.stop :as-alias color-gradient-stop] [app.common.types.plugins :as ctpg] [app.common.uuid :as uuid] - [clojure.test.check.generators :as tgen] [cuerdas.core :as str])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SCHEMAS +;; SCHEMAS & TYPES ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def rgb-color-re #"^#(?:[0-9a-fA-F]{3}){1,2}$") -(defn- random-rgb-color +(defn- generate-rgb-color [] - #?(:clj (format "#%06x" (rand-int 16rFFFFFF)) - :cljs - (let [r (rand-int 255) - g (rand-int 255) - b (rand-int 255)] - (str "#" - (.. r (toString 16) (padStart 2 "0")) - (.. g (toString 16) (padStart 2 "0")) - (.. b (toString 16) (padStart 2 "0")))))) + (sg/fmap (fn [_] + #?(:clj (format "#%06x" (rand-int 16rFFFFFF)) + :cljs + (let [r (rand-int 255) + g (rand-int 255) + b (rand-int 255)] + (str "#" + (.. r (toString 16) (padStart 2 "0")) + (.. g (toString 16) (padStart 2 "0")) + (.. b (toString 16) (padStart 2 "0")))))) + sg/any)) -(sm/register! ::rgb-color - {:type ::rgb-color - :pred #(and (string? %) (some? (re-matches rgb-color-re %))) +(defn rgb-color-string? + [o] + (and (string? o) (some? (re-matches rgb-color-re o)))) + +(def ^:private type:rgb-color + {:type :string + :pred rgb-color-string? :type-properties {:title "rgb-color" :description "RGB Color String" :error/message "expected a valid RGB color" - :gen/gen (->> tgen/any (tgen/fmap (fn [_] (random-rgb-color)))) - + :error/code "errors.invalid-rgb-color" + :gen/gen (generate-rgb-color) ::oapi/type "integer" ::oapi/format "int64"}}) -(sm/register! ::image-color +(def schema:image-color [:map {:title "ImageColor"} [:name {:optional true} :string] [:width :int] @@ -59,7 +62,10 @@ [:id ::sm/uuid] [:keep-aspect-ratio {:optional true} :boolean]]) -(sm/register! ::gradient +(def gradient-types + #{:linear :radial}) + +(def schema:gradient [:map {:title "Gradient"} [:type [::sm/one-of #{:linear :radial}]] [:start-x ::sm/safe-number] @@ -74,38 +80,46 @@ [:opacity {:optional true} [:maybe ::sm/safe-number]] [:offset ::sm/safe-number]]]]]) -(sm/register! ::color - [:and - [: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 ::gradient]] - [:image {:optional true} [:maybe ::image-color]] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]]] +(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 + [:and schema:color-attrs [::sm/contains-any {:strict true} [:color :gradient :image]]]) -(sm/register! ::recent-color +(def schema:recent-color [:and [:map {:title "RecentColor"} [:opacity {:optional true} [:maybe ::sm/safe-number]] [:color {:optional true} [:maybe ::rgb-color]] - [:gradient {:optional true} [:maybe ::gradient]] - [:image {:optional true} [:maybe ::image-color]]] + [:gradient {:optional true} [:maybe schema:gradient]] + [:image {:optional true} [:maybe schema:image-color]]] [::sm/contains-any {:strict true} [:color :gradient :image]]]) +(sm/register! ::rgb-color type:rgb-color) +(sm/register! ::color schema:color) +(sm/register! ::gradient schema:gradient) +(sm/register! ::image-color schema:image-color) +(sm/register! ::recent-color schema:recent-color) +(sm/register! ::color-attrs schema:color-attrs) + (def check-color! - (sm/check-fn ::color)) + (sm/check-fn schema:color :hint "expected valid color struct")) (def check-recent-color! - (sm/check-fn ::recent-color)) + (sm/check-fn schema:recent-color)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS @@ -380,13 +394,22 @@ (process-shape-colors shape sync-color))) -(defn eq-recent-color? +(defn- eq-recent-color? [c1 c2] (or (= c1 c2) (and (some? (:color c1)) (some? (: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 [stroke file-id shared-libs] diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 3a7f88c12..9cecfac38 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -37,8 +37,7 @@ [:modified-at {:optional true} ::sm/inst] [:objects {:optional true} [:map-of {:gen/max 10} ::sm/uuid :map]] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]]]) + [:plugin-data {:optional true} ::ctpg/plugin-data]]) (def check-container! (sm/check-fn ::container)) @@ -541,38 +540,51 @@ ;; --- SHAPE UPDATE (defn set-shape-attr - [shape attr val & {:keys [on-changed ignore-touched ignore-geometry]}] - (let [group (get ctk/sync-attrs attr) - shape-val (get shape attr) - ignore (or ignore-touched (= attr :position-data)) ;; position-data is a derived attribute and - is-geometry? (and (or (= group :geometry-group) ;; never triggers touched by itself - (and (= group :content-group) (= (:type shape) :path))) - (not (#{:width :height} attr))) ;; :content in paths are also considered geometric - ;; TODO: the check of :width and :height probably may be removed - ;; after the check added in data/workspace/modifiers/check-delta - ;; function. Better check it and test toroughly when activating - ;; components-v2 mode. - in-copy? (ctk/in-component-copy? shape) + "Assign attribute to shape with touched logic. + + The returned shape will contain a metadata associated with it + indicating if shape is touched or not." + [shape attr val & {:keys [ignore-touched ignore-geometry]}] + (let [group (get ctk/sync-attrs attr) + shape-val (get shape attr) + + ignore? + (or ignore-touched + ;; position-data is a derived attribute + (= attr :position-data)) + + is-geometry? + (and (or (= group :geometry-group) ;; never triggers touched by itself + (and (= group :content-group) + (= (:type shape) :path))) + ;; :content in paths are also considered geometric + (not (#{:width :height} attr))) + + ;; TODO: the check of :width and :height probably may be + ;; removed after the check added in + ;; data/workspace/modifiers/check-delta function. Better check + ;; it and test toroughly when activating components-v2 mode. + in-copy? + (ctk/in-component-copy? shape) ;; For geometric attributes, there are cases in that the value changes ;; slightly (e.g. when rounding to pixel, or when recalculating text ;; positions in different zoom levels). To take this into account, we ;; ignore geometric changes smaller than 1 pixel. - equal? (if is-geometry? - (gsh/close-attrs? attr val shape-val 1) - (gsh/close-attrs? attr val shape-val))] + equal? + (if is-geometry? + (gsh/close-attrs? attr val shape-val 1) + (gsh/close-attrs? attr val shape-val)) - ;; Notify when value has changed, except when it has not moved relative to the - ;; component head. - (when (and on-changed group (not equal?) (not (and ignore-geometry is-geometry?))) - (on-changed shape)) + touched? + (and group (not equal?) (not (and ignore-geometry is-geometry?)))] (cond-> shape ;; Depending on the origin of the attribute change, we need or not to ;; set the "touched" flag for the group the attribute belongs to. ;; In some cases we need to ignore touched only if the attribute is ;; geometric (position, width or transformation). - (and in-copy? group (not ignore) (not equal?) + (and in-copy? group (not ignore?) (not equal?) (not (and ignore-geometry is-geometry?))) (-> (update :touched ctk/set-touched-group group) (dissoc :remote-synced)) @@ -581,4 +593,7 @@ (dissoc attr) (some? val) - (assoc attr val)))) + (assoc attr val) + + :always + (vary-meta assoc ::touched touched?)))) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 69e57a259..01f7e32f6 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -62,8 +62,7 @@ [:map-of {:gen/max 2} ::sm/uuid ::cty/typography]] [:media {:optional true} [:map-of {:gen/max 5} ::sm/uuid ::media-object]] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]] + [:plugin-data {:optional true} ::ctpg/plugin-data] [:tokens-lib {:optional true} ::ctl/tokens-lib]]) (def check-file-data! diff --git a/common/src/app/common/types/grid.cljc b/common/src/app/common/types/grid.cljc index 72a7ceac6..45a73383f 100644 --- a/common/src/app/common/types/grid.cljc +++ b/common/src/app/common/types/grid.cljc @@ -6,6 +6,7 @@ (ns app.common.types.grid (:require + [app.common.colors :as clr] [app.common.schema :as sm] [app.common.types.color :as ctc])) @@ -13,47 +14,74 @@ ;; SCHEMA ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(sm/register! ::grid-color +(def schema:grid-color [:map {:title "PageGridColor"} [:color ::ctc/rgb-color] [:opacity ::sm/safe-number]]) -(sm/register! ::column-params +(def schema:column-params [:map - [:color ::grid-color] + [:color schema:grid-color] [:type {:optional true} [::sm/one-of #{:stretch :left :center :right}]] [:size {:optional true} [:maybe ::sm/safe-number]] [:margin {:optional true} [:maybe ::sm/safe-number]] [:item-length {:optional true} [:maybe ::sm/safe-number]] [:gutter {:optional true} [:maybe ::sm/safe-number]]]) -(sm/register! ::square-params +(def schema:square-params [:map [:size {:optional true} [:maybe ::sm/safe-number]] - [:color ::grid-color]]) + [:color schema:grid-color]]) -(sm/register! ::grid - [:multi {:dispatch :type} +(def schema:grid + [:multi {:title "Grid" + :dispatch :type + :decode/json #(update % :type keyword)} [:column [:map [:type [:= :column]] [:display :boolean] - [:params ::column-params]]] + [:params schema:column-params]]] [:row [:map [:type [:= :row]] [:display :boolean] - [:params ::column-params]]] + [:params schema:column-params]]] [:square [:map [:type [:= :square]] [:display :boolean] - [:params ::square-params]]]]) + [:params schema:square-params]]]]) -(sm/register! ::saved-grids +(def schema:default-grids [:map {:title "PageGrid"} [:square {:optional true} ::square-params] [:row {:optional true} ::column-params] [:column {:optional true} ::column-params]]) + +(sm/register! ::square-params schema:square-params) +(sm/register! ::column-params schema:column-params) +(sm/register! ::grid schema:grid) +(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}) + diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 3b00643ce..3af84b406 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -7,6 +7,7 @@ (ns app.common.types.page (:require [app.common.data :as d] + [app.common.geom.point :as-alias gpt] [app.common.schema :as sm] [app.common.types.color :as-alias ctc] [app.common.types.grid :as ctg] @@ -18,41 +19,62 @@ ;; SCHEMAS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(sm/register! ::flow - [:map {:title "PageFlow"} +(def schema:flow + [:map {:title "Flow"} [:id ::sm/uuid] [:name :string] [:starting-frame ::sm/uuid]]) -(sm/register! ::guide - [:map {:title "PageGuide"} +(def schema:flows + [:map-of {:gen/max 2} ::sm/uuid schema:flow]) + +(def schema:guide + [:map {:title "Guide"} [:id ::sm/uuid] [:axis [::sm/one-of #{:x :y}]] [:position ::sm/safe-number] + ;; FIXME: remove maybe? [:frame-id {:optional true} [:maybe ::sm/uuid]]]) -(sm/register! ::page +(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 [:map {:title "FilePage"} [:id ::sm/uuid] [:name :string] - [:objects - [:map-of {:gen/max 5} ::sm/uuid ::cts/shape]] + [:objects schema:objects] + [: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 - [:map {:title "PageOptions"} - [:background {:optional true} ::ctc/rgb-color] - [:saved-grids {:optional true} ::ctg/saved-grids] - [:flows {:optional true} - [:vector {:gen/max 2} ::flow]] - [:guides {:optional true} - [:map-of {:gen/max 2} ::sm/uuid ::guide]] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]]]]]) + ;; DEPERECATED: remove after 2.3 release + [:map {:title "PageOptions"}]]]) -(def check-page-guide! - (sm/check-fn ::guide)) +(sm/register! ::page schema:page) +(sm/register! ::guide schema:guide) +(sm/register! ::flow schema:flow) +(def valid-guide? + (sm/lazy-validator schema:guide)) + +;; FIXME: convert to validator (def check-page! - (sm/check-fn ::page)) + (sm/check-fn schema:page)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INIT & HELPERS @@ -77,25 +99,6 @@ (assoc :id (or id (uuid/next))) (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 [flows frame-id] - (d/seek #(= (:starting-frame %) frame-id) flows)) + (d/seek #(= (:starting-frame %) frame-id) (vals flows))) diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 49d31bf2d..128c90f7d 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -6,11 +6,48 @@ (ns app.common.types.plugins (:require - [app.common.schema :as sm])) + [app.common.schema :as sm] + [app.common.schema.generators :as sg])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(sm/register! ::plugin-data - [:map-of {:gen/max 5} :string :string]) +(def ^:private schema:string + [:schema {:gen/gen (sg/word-string)} :string]) + +(def ^:private schema:keyword + [:schema {:gen/gen (->> (sg/word-string) + (sg/fmap keyword))} + :keyword]) + +(def schema:plugin-data + [:map-of {:gen/max 5} + schema:keyword + [:map-of {:gen/max 5} + schema:string + schema:string]]) + +(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) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 50e27a0af..379c2bc2e 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -87,10 +87,15 @@ :exclude :intersection}) -(sm/register! ::points +(def grow-types + #{:auto-width + :auto-height + :fixed}) + +(def schema:points [:vector {:gen/max 4 :gen/min 4} ::gpt/point]) -(sm/register! ::fill +(def schema:fill [:map {:title "Fill"} [:fill-color {:optional true} ::ctc/rgb-color] [:fill-opacity {:optional true} ::sm/safe-number] @@ -99,7 +104,9 @@ [:fill-color-ref-id {:optional true} [:maybe ::sm/uuid]] [:fill-image {:optional true} ::ctc/image-color]]) -(sm/register! ::stroke +(sm/register! ::fill schema:fill) + +(def ^:private schema:stroke [:map {:title "Stroke"} [:stroke-color {:optional true} :string] [:stroke-color-ref-file {:optional true} ::sm/uuid] @@ -117,44 +124,46 @@ [:stroke-color-gradient {:optional true} ::ctc/gradient] [:stroke-image {:optional true} ::ctc/image-color]]) -(sm/register! ::shape-base-attrs +(sm/register! ::stroke schema:stroke) + +(def check-stroke! + (sm/check-fn schema:stroke)) + +(def schema:shape-base-attrs [:map {:title "ShapeMinimalRecord"} [:id ::sm/uuid] [:name :string] [:type [::sm/one-of shape-types]] [:selrect ::grc/rect] - [:points ::points] + [:points schema:points] [:transform ::gmt/matrix] [:transform-inverse ::gmt/matrix] [:parent-id ::sm/uuid] [:frame-id ::sm/uuid]]) -(sm/register! ::shape-geom-attrs +(def schema:shape-geom-attrs [:map {:title "ShapeGeometryAttrs"} [:x ::sm/safe-number] [:y ::sm/safe-number] [:width ::sm/safe-number] [:height ::sm/safe-number]]) -(sm/register! ::shape-attrs +;; FIXME: rename to shape-generic-attrs +(def schema:shape-attrs [:map {:title "ShapeAttrs"} - [:name {:optional true} :string] [:component-id {:optional true} ::sm/uuid] [:component-file {:optional true} ::sm/uuid] [:component-root {:optional true} :boolean] [:main-instance {:optional true} :boolean] [:remote-synced {:optional true} :boolean] [:shape-ref {:optional true} ::sm/uuid] - [:selrect {:optional true} ::grc/rect] - [:points {:optional true} ::points] [:blocked {:optional true} :boolean] [:collapsed {:optional true} :boolean] [:locked {:optional true} :boolean] [:hidden {:optional true} :boolean] [:masked-group {:optional true} :boolean] [:fills {:optional true} - [:vector {:gen/max 2} ::fill]] - [:hide-fill-on-export {:optional true} :boolean] + [:vector {:gen/max 2} schema:fill]] [:proportion {:optional true} ::sm/safe-number] [:proportion-lock {:optional true} :boolean] [:constraints-h {:optional true} @@ -168,204 +177,196 @@ [:r2 {:optional true} ::sm/safe-number] [:r3 {:optional true} ::sm/safe-number] [:r4 {:optional true} ::sm/safe-number] - [:x {:optional true} [:maybe ::sm/safe-number]] - [:y {:optional true} [:maybe ::sm/safe-number]] - [:width {:optional true} [:maybe ::sm/safe-number]] - [:height {:optional true} [:maybe ::sm/safe-number]] [:opacity {:optional true} ::sm/safe-number] [:grids {:optional true} [:vector {:gen/max 2} ::ctg/grid]] [:exports {:optional true} [:vector {:gen/max 2} ::ctse/export]] [:strokes {:optional true} - [:vector {:gen/max 2} ::stroke]] - [:transform {:optional true} ::gmt/matrix] - [:transform-inverse {:optional true} ::gmt/matrix] - [:blend-mode {:optional true} [::sm/one-of blend-modes]] + [:vector {:gen/max 2} schema:stroke]] + [:blend-mode {:optional true} + [::sm/one-of blend-modes]] [:interactions {:optional true} [:vector {:gen/max 2} ::ctsi/interaction]] [:shadow {:optional true} [:vector {:gen/max 1} ::ctss/shadow]] [:blur {:optional true} ::ctsb/blur] [:grow-type {:optional true} - [::sm/one-of #{:auto-width :auto-height :fixed}]]]) + [::sm/one-of grow-types]] [:applied-tokens {:optional true} ::cto/applied-tokens] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]] + [:plugin-data {:optional true} ::ctpg/plugin-data]]) -(sm/register! ::group-attrs +(def schema:group-attrs [:map {:title "GroupAttrs"} - [:type [:= :group]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]]]) -(sm/register! ::frame-attrs +(def ^:private schema:frame-attrs [:map {:title "FrameAttrs"} - [:type [:= :frame]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] [:hide-fill-on-export {:optional true} :boolean] [:show-content {:optional true} :boolean] [:hide-in-viewer {:optional true} :boolean]]) -(sm/register! ::bool-attrs +(def ^:private schema:bool-attrs [:map {:title "BoolAttrs"} - [:type [:= :bool]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] + [:bool-type [::sm/one-of bool-types]] + [:bool-content ::ctsp/content]]) - [:bool-type :keyword] - ;; FIXME: This should be the spec but we need to create a migration - ;; to make this transition safely - ;; [:bool-type [::sm/one-of bool-types]] +(def ^:private schema:rect-attrs + [:map {:title "RectAttrs"}]) - [:bool-content - [:vector {:gen/max 2} - [:map - [:command :keyword] - [:relative {:optional true} :boolean] - [:prev-pos {:optional true} ::gpt/point] - [:params {:optional true} - [:maybe - [:map-of {:gen/max 5} :keyword ::sm/safe-number]]]]]]]) +(def ^:private schema:circle-attrs + [:map {:title "CircleAttrs"}]) -(sm/register! ::rect-attrs - [:map {:title "RectAttrs"} - [:type [:= :rect]]]) +(def ^:private schema:svg-raw-attrs + [:map {:title "SvgRawAttrs"}]) -(sm/register! ::circle-attrs - [:map {:title "CircleAttrs"} - [:type [:= :circle]]]) - -(sm/register! ::svg-raw-attrs - [:map {:title "SvgRawAttrs"} - [:type [:= :svg-raw]]]) - -(sm/register! ::image-attrs +(def schema:image-attrs [:map {:title "ImageAttrs"} - [:type [:= :image]] [:metadata [:map - [:width :int] - [:height :int] - [:mtype {:optional true} [:maybe :string]] + [:width {:gen/gen (sg/small-int :min 1)} :int] + [:height {:gen/gen (sg/small-int :min 1)} :int] + [:mtype {:optional true + :gen/gen (sg/elements ["image/jpeg" + "image/png"])} + [:maybe :string]] [:id ::sm/uuid]]]]) -(sm/register! ::path-attrs +(def ^:private schema:path-attrs [:map {:title "PathAttrs"} - [:type [:= :path]] [:content ::ctsp/content]]) -(sm/register! ::text-attrs +(def ^:private schema:text-attrs [:map {:title "TextAttrs"} - [:type [:= :text]] [:content {:optional true} [:maybe ::ctsx/content]]]) -(sm/register! ::shape-map - [:multi {:dispatch :type :title "Shape"} - [:group - [:and {:title "GroupShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::group-attrs - ::ctsl/layout-child-attrs]] +(defn- decode-shape + [o] + (if (map? o) + (map->Shape o) + o)) - [:frame - [:and {:title "FrameShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::frame-attrs - ::ctsl/layout-attrs - ::ctsl/layout-child-attrs]] +(defn- shape-generator + "Get the shape generator." + [] + (->> (sg/generator schema:shape-base-attrs) + (sg/mcat (fn [{:keys [type] :as shape}] + (sg/let [attrs1 (sg/generator schema:shape-attrs) + attrs2 (sg/generator schema:shape-geom-attrs) + attrs3 (case type + :text (sg/generator schema:text-attrs) + :path (sg/generator schema:path-attrs) + :svg-raw (sg/generator schema:svg-raw-attrs) + :image (sg/generator schema:image-attrs) + :circle (sg/generator schema:circle-attrs) + :rect (sg/generator schema:rect-attrs) + :bool (sg/generator schema:bool-attrs) + :group (sg/generator schema:group-attrs) + :frame (sg/generator schema:frame-attrs))] + (if (or (= type :path) + (= type :bool)) + (merge attrs1 shape attrs3) + (merge attrs1 shape attrs2 attrs3))))) + (sg/fmap map->Shape))) - [:bool - [:and {:title "BoolShape"} - ::shape-base-attrs - ::shape-attrs - ::bool-attrs - ::ctsl/layout-child-attrs]] +(def schema:shape + [:and {:title "Shape" + :gen/gen (shape-generator) + :decode/json {:leave decode-shape}} + [:fn shape?] + [:multi {:dispatch :type + :decode/json (fn [shape] + (update shape :type keyword)) + :title "Shape"} + [:group + [:merge {:title "GroupShape"} + ::ctsl/layout-child-attrs + schema:group-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] - [:rect - [:and {:title "RectShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::rect-attrs - ::ctsl/layout-child-attrs]] + [:frame + [:merge {:title "FrameShape"} + ::ctsl/layout-child-attrs + ::ctsl/layout-attrs + schema:frame-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] - [:circle - [:and {:title "CircleShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::circle-attrs - ::ctsl/layout-child-attrs]] + [:bool + [:merge {:title "BoolShape"} + ::ctsl/layout-child-attrs + schema:bool-attrs + schema:shape-attrs + schema:shape-base-attrs]] - [:image - [:and {:title "ImageShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::image-attrs - ::ctsl/layout-child-attrs]] + [:rect + [:merge {:title "RectShape"} + ::ctsl/layout-child-attrs + schema:rect-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] - [:svg-raw - [:and {:title "SvgRawShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::svg-raw-attrs - ::ctsl/layout-child-attrs]] + [:circle + [:merge {:title "CircleShape"} + ::ctsl/layout-child-attrs + schema:circle-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] - [:path - [:and {:title "PathShape"} - ::shape-base-attrs - ::shape-attrs - ::path-attrs - ::ctsl/layout-child-attrs]] + [:image + [:merge {:title "ImageShape"} + ::ctsl/layout-child-attrs + schema:image-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] - [:text - [:and {:title "TextShape"} - ::shape-base-attrs - ::shape-geom-attrs - ::shape-attrs - ::text-attrs - ::ctsl/layout-child-attrs]]]) + [:svg-raw + [:merge {:title "SvgRawShape"} + ::ctsl/layout-child-attrs + schema:svg-raw-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]] -(sm/register! ::shape - [:and - {:title "Shape" - :gen/gen (->> (sg/generator ::shape-base-attrs) - (sg/mcat (fn [{:keys [type] :as shape}] - (sg/let [attrs1 (sg/generator ::shape-attrs) - attrs2 (sg/generator ::shape-geom-attrs) - attrs3 (case type - :text (sg/generator ::text-attrs) - :path (sg/generator ::path-attrs) - :svg-raw (sg/generator ::svg-raw-attrs) - :image (sg/generator ::image-attrs) - :circle (sg/generator ::circle-attrs) - :rect (sg/generator ::rect-attrs) - :bool (sg/generator ::bool-attrs) - :group (sg/generator ::group-attrs) - :frame (sg/generator ::frame-attrs))] - (if (or (= type :path) - (= type :bool)) - (merge attrs1 shape attrs3) - (merge attrs1 shape attrs2 attrs3))))) - (sg/fmap map->Shape))} - ::shape-map - [:fn shape?]]) + [:path + [:merge {:title "PathShape"} + ::ctsl/layout-child-attrs + schema:path-attrs + schema:shape-attrs + schema:shape-base-attrs]] + + [:text + [:merge {:title "TextShape"} + ::ctsl/layout-child-attrs + schema:text-attrs + schema:shape-attrs + schema:shape-geom-attrs + schema:shape-base-attrs]]]]) + +(sm/register! ::shape schema:shape) (def check-shape-attrs! - (sm/check-fn ::shape-attrs)) + (sm/check-fn schema:shape-attrs)) (def check-shape! - (sm/check-fn ::shape)) + (sm/check-fn schema:shape + :hint "expected valid shape")) + +(def valid-shape? + (sm/lazy-validator schema:shape)) (defn has-images? [{:keys [fills strokes]}] - (or - (some :fill-image fills) - (some :stroke-image strokes))) + (or (some :fill-image fills) + (some :stroke-image strokes))) ;; --- Initialization diff --git a/common/src/app/common/types/shape/export.cljc b/common/src/app/common/types/shape/export.cljc index 7adbf7574..bd2bee0a5 100644 --- a/common/src/app/common/types/shape/export.cljc +++ b/common/src/app/common/types/shape/export.cljc @@ -8,10 +8,12 @@ (:require [app.common.schema :as sm])) -(def export-types #{:png :jpeg :svg :pdf}) +(def types #{:png :jpeg :svg :pdf}) -(sm/register! ::export +(def schema:export [:map {:title "ShapeExport"} - [:type [::sm/one-of export-types]] + [:type [::sm/one-of types]] [:scale ::sm/safe-number] [:suffix :string]]) + +(sm/register! ::export schema:export) diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc index 647e6cf26..29ef5902f 100644 --- a/common/src/app/common/types/shape/interactions.cljc +++ b/common/src/app/common/types/shape/interactions.cljc @@ -11,7 +11,8 @@ [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.shapes.bounds :as gsb] - [app.common.schema :as sm])) + [app.common.schema :as sm] + [app.common.schema.generators :as sg])) ;; WARNING: options are not deleted when changing event or action type, so it can be ;; restored if the user changes it back later. @@ -71,81 +72,116 @@ (def animation-types #{:dissolve :slide :push}) -(sm/register! ::animation - [:multi {:dispatch :animation-type :title "Animation"} - [:dissolve - [:map {:title "AnimationDisolve"} - [:animation-type [:= :dissolve]] - [:duration ::sm/safe-int] - [:easing [::sm/one-of easing-types]]]] - [:slide - [:map {:title "AnimationSlide"} - [:animation-type [:= :slide]] - [:duration ::sm/safe-int] - [:easing [::sm/one-of easing-types]] - [:way [::sm/one-of way-types]] - [:direction [::sm/one-of direction-types]] - [:offset-effect :boolean]]] - [:push - [:map {:title "AnimationPush"} - [:animation-type [:= :push]] - [:duration ::sm/safe-int] - [:easing [::sm/one-of easing-types]] - [:direction [::sm/one-of direction-types]]]]]) +(def schema:dissolve-animation + [:map {:title "AnimationDisolve"} + [:animation-type [:= :dissolve]] + [:duration ::sm/safe-int] + [:easing [::sm/one-of easing-types]]]) + +(def schema:slide-animation + [:map {:title "AnimationSlide"} + [:animation-type [:= :slide]] + [:duration ::sm/safe-int] + [:easing [::sm/one-of easing-types]] + [:way [::sm/one-of way-types]] + [:direction [::sm/one-of direction-types]] + [:offset-effect :boolean]]) + +(def schema:push-animation + [:map {:title "PushAnimation"} + [:animation-type [:= :push]] + [:duration ::sm/safe-int] + [:easing [::sm/one-of easing-types]] + [:direction [::sm/one-of direction-types]]]) + +(def schema:animation + [:multi {:dispatch :animation-type + :title "Animation" + :gen/gen (sg/one-of (sg/generator schema:dissolve-animation) + (sg/generator schema:slide-animation) + (sg/generator schema:push-animation)) + :decode/json #(update % :animation-type keyword)} + [:dissolve schema:dissolve-animation] + [:slide schema:slide-animation] + [:push schema:push-animation]]) + +(sm/register! ::animation schema:animation) (def check-animation! - (sm/check-fn ::animation)) + (sm/check-fn schema:animation)) -(sm/register! ::interaction - [:multi {:dispatch :action-type} - [:navigate - [:map - [:action-type [:= :navigate]] - [:event-type [::sm/one-of event-types]] - [:destination {:optional true} [:maybe ::sm/uuid]] - [:preserve-scroll {:optional true} :boolean] - [:animation {:optional true} ::animation]]] - [:open-overlay - [:map - [:action-type [:= :open-overlay]] - [:event-type [::sm/one-of event-types]] - [:overlay-position ::gpt/point] - [:overlay-pos-type [::sm/one-of overlay-positioning-types]] - [:destination {:optional true} [:maybe ::sm/uuid]] - [:close-click-outside {:optional true} :boolean] - [:background-overlay {:optional true} :boolean] - [:animation {:optional true} ::animation] - [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]] - [:toggle-overlay - [:map - [:action-type [:= :toggle-overlay]] - [:event-type [::sm/one-of event-types]] - [:overlay-position ::gpt/point] - [:overlay-pos-type [::sm/one-of overlay-positioning-types]] - [:destination {:optional true} [:maybe ::sm/uuid]] - [:close-click-outside {:optional true} :boolean] - [:background-overlay {:optional true} :boolean] - [:animation {:optional true} ::animation] - [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]] - [:close-overlay - [:map - [:action-type [:= :close-overlay]] - [:event-type [::sm/one-of event-types]] - [:destination {:optional true} [:maybe ::sm/uuid]] - [:animation {:optional true} ::animation] - [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]] - [:prev-screen - [:map - [:action-type [:= :prev-screen]] - [:event-type [::sm/one-of event-types]]]] - [:open-url - [:map - [:action-type [:= :open-url]] - [:event-type [::sm/one-of event-types]] - [:url :string]]]]) +(def schema:navigate-interaction + [:map + [:action-type [:= :navigate]] + [:event-type [::sm/one-of event-types]] + [:destination {:optional true} [:maybe ::sm/uuid]] + [:preserve-scroll {:optional true} :boolean] + [:animation {:optional true} ::animation]]) + +(def schema:open-overlay-interaction + [:map + [:action-type [:= :open-overlay]] + [:event-type [::sm/one-of event-types]] + [:overlay-position ::gpt/point] + [:overlay-pos-type [::sm/one-of overlay-positioning-types]] + [:destination {:optional true} [:maybe ::sm/uuid]] + [:close-click-outside {:optional true} :boolean] + [:background-overlay {:optional true} :boolean] + [:animation {:optional true} ::animation] + [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]) + +(def schema:toggle-overlay-interaction + [:map + [:action-type [:= :toggle-overlay]] + [:event-type [::sm/one-of event-types]] + [:overlay-position ::gpt/point] + [:overlay-pos-type [::sm/one-of overlay-positioning-types]] + [:destination {:optional true} [:maybe ::sm/uuid]] + [:close-click-outside {:optional true} :boolean] + [:background-overlay {:optional true} :boolean] + [:animation {:optional true} ::animation] + [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]) + +(def schema:close-overlay-interaction + [:map + [:action-type [:= :close-overlay]] + [:event-type [::sm/one-of event-types]] + [:destination {:optional true} [:maybe ::sm/uuid]] + [:animation {:optional true} ::animation] + [:position-relative-to {:optional true} [:maybe ::sm/uuid]]]) + +(def schema:prev-scren-interaction + [:map + [:action-type [:= :prev-screen]] + [:event-type [::sm/one-of event-types]]]) + +(def schema:open-url-interaction + [:map + [:action-type [:= :open-url]] + [:event-type [::sm/one-of event-types]] + [:url :string]]) + +(def schema:interaction + [:multi {:dispatch :action-type + :title "Interaction" + :gen/gen (sg/one-of (sg/generator schema:navigate-interaction) + (sg/generator schema:open-overlay-interaction) + (sg/generator schema:close-overlay-interaction) + (sg/generator schema:toggle-overlay-interaction) + (sg/generator schema:prev-scren-interaction) + (sg/generator schema:open-url-interaction)) + :decode/json #(update % :action-type keyword)} + [:navigate schema:navigate-interaction] + [:open-overlay schema:open-overlay-interaction] + [:toggle-overlay schema:toggle-overlay-interaction] + [:close-overlay schema:close-overlay-interaction] + [:prev-screen schema:prev-scren-interaction] + [:open-url schema:open-url-interaction]]) + +(sm/register! ::interaction schema:interaction) (def check-interaction! - (sm/check-fn ::interaction)) + (sm/check-fn schema:interaction)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index a999145cb..9a71931cc 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1622,13 +1622,17 @@ (defn remap-grid-cells "Remaps the shapes ids inside the cells" [shape ids-map] - (let [do-remap-cells + (let [remap-shape + (fn [id] + (get ids-map id id)) + + remap-cell (fn [cell] (-> cell - (update :shapes #(into [] (keep ids-map) %)))) + (update :shapes #(into [] (keep remap-shape) %)))) shape (-> shape - (update :layout-grid-cells update-vals do-remap-cells))] + (update :layout-grid-cells update-vals remap-cell))] shape)) (defn merge-cells diff --git a/common/src/app/common/types/shape/path.cljc b/common/src/app/common/types/shape/path.cljc index f6002a293..1fd33bd45 100644 --- a/common/src/app/common/types/shape/path.cljc +++ b/common/src/app/common/types/shape/path.cljc @@ -8,40 +8,49 @@ (:require [app.common.schema :as sm])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SCHEMA -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def schema:line-to-segment + [:map + [:command [:= :line-to]] + [:params + [:map + [:x ::sm/safe-number] + [:y ::sm/safe-number]]]]) -(sm/register! ::segment - [:multi {:title "PathSegment" :dispatch :command} - [:line-to - [:map - [:command [:= :line-to]] - [:params - [:map - [:x ::sm/safe-number] - [:y ::sm/safe-number]]]]] - [:close-path - [:map - [:command [:= :close-path]]]] - [:move-to - [:map - [:command [:= :move-to]] - [:params - [:map - [:x ::sm/safe-number] - [:y ::sm/safe-number]]]]] - [:curve-to - [:map - [:command [:= :curve-to]] - [:params - [:map - [:x ::sm/safe-number] - [:y ::sm/safe-number] - [:c1x ::sm/safe-number] - [:c1y ::sm/safe-number] - [:c2x ::sm/safe-number] - [:c2y ::sm/safe-number]]]]]]) +(def schema:close-path-segment + [:map + [:command [:= :close-path]]]) -(sm/register! ::content - [:vector ::segment]) +(def schema:move-to-segment + [:map + [:command [:= :move-to]] + [:params + [:map + [:x ::sm/safe-number] + [:y ::sm/safe-number]]]]) + +(def schema:curve-to-segment + [:map + [:command [:= :curve-to]] + [:params + [:map + [:x ::sm/safe-number] + [:y ::sm/safe-number] + [:c1x ::sm/safe-number] + [:c1y ::sm/safe-number] + [:c2x ::sm/safe-number] + [:c2y ::sm/safe-number]]]]) + +(def schema:path-segment + [:multi {:title "PathSegment" + :dispatch :command + :decode/json #(update % :command keyword)} + [:line-to schema:line-to-segment] + [:close-path schema:close-path-segment] + [:move-to schema:move-to-segment] + [:curve-to schema:curve-to-segment]]) + +(def schema:path-content + [:vector schema:path-segment]) + +(sm/register! ::segment schema:path-segment) +(sm/register! ::content schema:path-content) diff --git a/common/src/app/common/types/shape/shadow.cljc b/common/src/app/common/types/shape/shadow.cljc index 62bdc2691..b1ec5342f 100644 --- a/common/src/app/common/types/shape/shadow.cljc +++ b/common/src/app/common/types/shape/shadow.cljc @@ -7,17 +7,26 @@ (ns app.common.types.shape.shadow (:require [app.common.schema :as sm] + [app.common.schema.generators :as sg] [app.common.types.color :as ctc])) (def styles #{:drop-shadow :inner-shadow}) -(sm/register! ::shadow +(def schema:shadow [:map {:title "Shadow"} [:id [:maybe ::sm/uuid]] - [:style [::sm/one-of styles]] + [:style + [:and {:gen/gen (sg/elements styles)} + :keyword + [::sm/one-of styles]]] [:offset-x ::sm/safe-number] [:offset-y ::sm/safe-number] [:blur ::sm/safe-number] [:spread ::sm/safe-number] [:hidden :boolean] [:color ::ctc/color]]) + +(sm/register! ::shadow schema:shadow) + +(def check-shadow! + (sm/check-fn schema:shadow)) diff --git a/common/src/app/common/types/typography.cljc b/common/src/app/common/types/typography.cljc index e143a2b8b..068595063 100644 --- a/common/src/app/common/types/typography.cljc +++ b/common/src/app/common/types/typography.cljc @@ -31,8 +31,7 @@ [:text-transform :string] [:modified-at {:optional true} ::sm/inst] [:path {:optional true} [:maybe :string]] - [:plugin-data {:optional true} - [:map-of {:gen/max 5} :keyword ::ctpg/plugin-data]]]) + [:plugin-data {:optional true} ::ctpg/plugin-data]]) (def check-typography! (sm/check-fn ::typography)) diff --git a/common/src/app/common/version.cljc b/common/src/app/common/version.cljc index e73bd4269..20250bcf6 100644 --- a/common/src/app/common/version.cljc +++ b/common/src/app/common/version.cljc @@ -9,7 +9,7 @@ (:require [cuerdas.core :as str])) -(def version-re #"^(([A-Za-z]+)\-?)?((\d+)\.(\d+)\.(\d+))(\-?((alpha|prealpha|beta|rc|dev)(\d+)?))?(\-?(\d+))?(\-?g(\w+))$") +(def version-re #"^(([A-Za-z]+)\-?)?((\d+)\.(\d+)\.(\d+))(\-?((RC|DEV)(\d+)?))?(\-?(\d+))?(\-?g(\w+))?$") (defn parse [data] diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc new file mode 100644 index 000000000..5335df2e4 --- /dev/null +++ b/common/test/common_tests/files_changes_test.cljc @@ -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}))) diff --git a/common/test/common_tests/logic/comp_touched_test.cljc b/common/test/common_tests/logic/comp_touched_test.cljc index 1f16a2107..bb615f7ae 100644 --- a/common/test/common_tests/logic/comp_touched_test.cljc +++ b/common/test/common_tests/logic/comp_touched_test.cljc @@ -289,42 +289,3 @@ (t/is (= (:fill-opacity fill') 1)) (t/is (= (:touched copy2-root') nil)) (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})))) \ No newline at end of file diff --git a/common/test/common_tests/logic/hide_in_viewer_test.cljc b/common/test/common_tests/logic/hide_in_viewer_test.cljc deleted file mode 100644 index 051a4732e..000000000 --- a/common/test/common_tests/logic/hide_in_viewer_test.cljc +++ /dev/null @@ -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'))))) diff --git a/common/test/common_tests/pages_test.cljc b/common/test/common_tests/pages_test.cljc deleted file mode 100644 index 146242c26..000000000 --- a/common/test/common_tests/pages_test.cljc +++ /dev/null @@ -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]))) - -;; )) - -;; )) diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc new file mode 100644 index 000000000..05b2c2ae6 --- /dev/null +++ b/common/test/common_tests/schema_test.cljc @@ -0,0 +1,41 @@ +;; 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.schema-test + (:require + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [clojure.test :as t])) + +(t/deftest test-set-of-email + (t/testing "decoding" + (let [candidate1 "a@b.com a@c.net" + schema [::sm/set ::sm/email] + result1 (sm/decode schema candidate1 sm/string-transformer) + result2 (sm/decode schema candidate1 sm/json-transformer)] + (t/is (= result1 #{"a@b.com" "a@c.net"})) + (t/is (= result2 #{"a@b.com" "a@c.net"})))) + + (t/testing "encoding" + (let [candidate #{"a@b.com" "a@c.net"} + schema [::sm/set ::sm/email] + result1 (sm/encode schema candidate sm/string-transformer) + result2 (sm/decode schema candidate sm/json-transformer)] + (t/is (= result1 "a@b.com, a@c.net")) + (t/is (= result2 candidate)))) + + (t/testing "validate" + (let [candidate #{"a@b.com" "a@c.net"} + schema [::sm/set ::sm/email]] + + (t/is (true? (sm/validate schema candidate))) + (t/is (true? (sm/validate schema #{}))) + (t/is (false? (sm/validate schema #{"a"}))))) + + (t/testing "generate" + (let [schema [::sm/set ::sm/email] + value (sg/generate schema)] + (t/is (true? (sm/validate schema (sg/generate schema))))))) diff --git a/common/test/common_tests/types/shape_decode_encode_test.cljc b/common/test/common_tests/types/shape_decode_encode_test.cljc new file mode 100644 index 000000000..2434f5fc6 --- /dev/null +++ b/common/test/common_tests/types/shape_decode_encode_test.cljc @@ -0,0 +1,151 @@ +;; 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.shape-decode-encode-test + (:require + [app.common.json :as json] + [app.common.pprint :as pp] + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.schema.test :as smt] + [app.common.types.color :refer [schema:color schema:gradient]] + [app.common.types.plugins :refer [schema:plugin-data]] + [app.common.types.shape :as tsh] + [app.common.types.shape.interactions :refer [schema:animation schema:interaction]] + [app.common.types.shape.path :refer [schema:path-content]] + [app.common.types.shape.shadow :refer [schema:shadow]] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn json-roundtrip + [data] + (-> data + (json/encode :key-fn json/write-camel-key) + (json/decode :key-fn json/read-kebab-key))) + +(t/deftest map-of-with-strings + (let [schema [:map [:data [:map-of :string :int]]] + encode (sm/encoder schema (sm/json-transformer)) + decode (sm/decoder schema (sm/json-transformer)) + + data1 {:data {"foo/bar" 1 + "foo-baz" 2}} + + data2 (encode data1) + data3 (json-roundtrip data2) + data4 (decode data3)] + + ;; (pp/pprint data1) + ;; (pp/pprint data2) + ;; (pp/pprint data3) + ;; (pp/pprint data4) + + (t/is (= data1 data2)) + (t/is (= data1 data4)) + (t/is (not= data1 data3)))) + +(t/deftest gradient-json-roundtrip + (let [encode (sm/encoder schema:gradient (sm/json-transformer)) + decode (sm/decoder schema:gradient (sm/json-transformer))] + (smt/check! + (smt/for [gradient (sg/generator schema:gradient)] + (let [gradient-1 (encode gradient) + gradient-2 (json-roundtrip gradient-1) + gradient-3 (decode gradient-2)] + ;; (app.common.pprint/pprint gradient) + ;; (app.common.pprint/pprint gradient-3) + (= gradient gradient-3))) + {:num 500}))) + +(t/deftest color-json-roundtrip + (let [encode (sm/encoder schema:color (sm/json-transformer)) + decode (sm/decoder schema:color (sm/json-transformer))] + (smt/check! + (smt/for [color (sg/generator schema:color)] + (let [color-1 (encode color) + color-2 (json-roundtrip color-1) + color-3 (decode color-2)] + ;; (app.common.pprint/pprint color) + ;; (app.common.pprint/pprint color-3) + (= color color-3))) + {:num 500}))) + +(t/deftest shape-shadow-json-roundtrip + (let [encode (sm/encoder schema:shadow (sm/json-transformer)) + decode (sm/decoder schema:shadow (sm/json-transformer))] + (smt/check! + (smt/for [shadow (sg/generator schema:shadow)] + (let [shadow-1 (encode shadow) + shadow-2 (json-roundtrip shadow-1) + shadow-3 (decode shadow-2)] + ;; (app.common.pprint/pprint shadow) + ;; (app.common.pprint/pprint shadow-3) + (= shadow shadow-3))) + {:num 500}))) + +(t/deftest shape-animation-json-roundtrip + (let [encode (sm/encoder schema:animation (sm/json-transformer)) + decode (sm/decoder schema:animation (sm/json-transformer))] + (smt/check! + (smt/for [animation (sg/generator schema:animation)] + (let [animation-1 (encode animation) + animation-2 (json-roundtrip animation-1) + animation-3 (decode animation-2)] + ;; (app.common.pprint/pprint animation) + ;; (app.common.pprint/pprint animation-3) + (= animation animation-3))) + {:num 500}))) + +(t/deftest shape-interaction-json-roundtrip + (let [encode (sm/encoder schema:interaction (sm/json-transformer)) + decode (sm/decoder schema:interaction (sm/json-transformer))] + (smt/check! + (smt/for [interaction (sg/generator schema:interaction)] + (let [interaction-1 (encode interaction) + interaction-2 (json-roundtrip interaction-1) + interaction-3 (decode interaction-2)] + ;; (app.common.pprint/pprint interaction) + ;; (app.common.pprint/pprint interaction-3) + (= interaction interaction-3))) + {:num 500}))) + + +(t/deftest shape-path-content-json-roundtrip + (let [encode (sm/encoder schema:path-content (sm/json-transformer)) + decode (sm/decoder schema:path-content (sm/json-transformer))] + (smt/check! + (smt/for [path-content (sg/generator schema:path-content)] + (let [path-content-1 (encode path-content) + path-content-2 (json-roundtrip path-content-1) + path-content-3 (decode path-content-2)] + ;; (app.common.pprint/pprint path-content) + ;; (app.common.pprint/pprint path-content-3) + (= path-content path-content-3))) + {:num 500}))) + +(t/deftest plugin-data-json-roundtrip + (let [encode (sm/encoder schema:plugin-data (sm/json-transformer)) + decode (sm/decoder schema:plugin-data (sm/json-transformer))] + (smt/check! + (smt/for [data (sg/generator schema:plugin-data)] + (let [data-1 (encode data) + data-2 (json-roundtrip data-1) + data-3 (decode data-2)] + (= data data-3))) + {:num 500}))) + +(t/deftest shape-json-roundtrip + (let [encode (sm/encoder ::tsh/shape (sm/json-transformer)) + decode (sm/decoder ::tsh/shape (sm/json-transformer))] + (smt/check! + (smt/for [shape (sg/generator ::tsh/shape)] + (let [shape-1 (encode shape) + shape-2 (json-roundtrip shape-1) + shape-3 (decode shape-2)] + ;; (app.common.pprint/pprint shape) + ;; (app.common.pprint/pprint shape-3) + (= shape shape-3))) + {:num 1000}))) diff --git a/common/test/common_tests/types_test.cljc b/common/test/common_tests/types_test.cljc deleted file mode 100644 index e5326250d..000000000 --- a/common/test/common_tests/types_test.cljc +++ /dev/null @@ -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}))) diff --git a/common/test/common_tests/uuid_test.cljc b/common/test/common_tests/uuid_test.cljc deleted file mode 100644 index c747b82db..000000000 --- a/common/test/common_tests/uuid_test.cljc +++ /dev/null @@ -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})) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 7182905a6..3b292c69e 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,5 +1,5 @@ FROM debian:bookworm -LABEL maintainer="Andrey Antukh " +LABEL maintainer="Penpot " ARG DEBIAN_FRONTEND=noninteractive @@ -8,6 +8,8 @@ ENV NODE_VERSION=v20.11.1 \ CLJKONDO_VERSION=2024.03.13 \ BABASHKA_VERSION=1.3.189 \ CLJFMT_VERSION=0.12.0 \ + RUSTUP_VERSION=1.27.1 \ + RUST_VERSION=1.81.0 \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 @@ -242,6 +244,27 @@ RUN set -ex; \ mv /tmp/mc /usr/local/bin/; \ 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 COPY files/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/devenv/docker-compose.yaml b/docker/devenv/docker-compose.yaml index 0d6aa068f..f4e9b0e79 100644 --- a/docker/devenv/docker-compose.yaml +++ b/docker/devenv/docker-compose.yaml @@ -125,3 +125,7 @@ services: ports: - "10389:10389" - "10636:10636" + ulimits: + nofile: + soft: "1024" + hard: "1024" \ No newline at end of file diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 745e3f901..da3fdfde0 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -1,7 +1,7 @@ #!/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 JAVA_OPTS="-Xmx1000m -Xms50m" +export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms200m"}; alias l='ls --color -GFlh' alias rm='rm -r' @@ -9,6 +9,9 @@ alias ls='ls --color -F' alias lsd='ls -d *(/)' alias lsf='ls -h *(.)' +# init Cargo / Rust env +. "/usr/local/cargo/env" + # include .bashrc if it exists if [ -f "$HOME/.bashrc.local" ]; then . "$HOME/.bashrc.local" diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index db789dfd2..c68e5181c 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,6 +1,6 @@ FROM ubuntu:22.04 +LABEL maintainer="Penpot " -LABEL maintainer="Andrey Antukh " ENV LANG='en_US.UTF-8' \ LC_ALL='en_US.UTF-8' \ JAVA_HOME="/opt/jdk" \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index e4fceec85..3b62176fc 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,5 +1,5 @@ FROM ubuntu:22.04 -LABEL maintainer="Andrey Antukh " +LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 0edc1b2d9..25ee128ce 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,5 +1,7 @@ -FROM nginx:1.23 -LABEL maintainer="Andrey Antukh " +FROM nginxinc/nginx-unprivileged:1.27.1 +LABEL maintainer="Penpot " + +USER root RUN set -ex; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ @@ -12,5 +14,13 @@ ADD ./files/nginx.conf /etc/nginx/nginx.conf.template ADD ./files/nginx-mime.types /etc/nginx/mime.types ADD ./files/nginx-entrypoint.sh /entrypoint.sh +RUN chown -R 1001:0 /var/cache/nginx; \ + chmod -R g+w /var/cache/nginx; \ + chown -R 1001:0 /etc/nginx; \ + chmod -R g+w /etc/nginx; \ + chown -R 1001:0 /var/www; \ + chmod -R g+w /var/www; + +USER penpot:penpot ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index d16402ce5..ccc102173 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -1,3 +1,34 @@ +## Common flags: +# demo-users +# email-verification +# log-emails +# log-invitation-tokens +# login-with-github +# login-with-gitlab +# login-with-google +# login-with-ldap +# login-with-oidc +# login-with-password +# prepl-server +# registration +# secure-session-cookies +# smtp +# smtp-debug +# telemetry +# webhooks +## +## You can read more about all available flags and other +## environment variables here: +## https://help.penpot.app/technical-guide/configuration/#advanced-configuration +# +# WARNING: if you're exposing Penpot to the internet, you should remove the flags +# 'disable-secure-session-cookies' and 'disable-email-verification' +x-flags: &penpot-flags + PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies + +x-uri: &penpot-public-uri + PENPOT_PUBLIC_URI: http://localhost:9001 + networks: penpot: @@ -35,7 +66,7 @@ services: image: "penpotapp/frontend:latest" restart: always ports: - - 9001:80 + - 9001:8080 volumes: - penpot_assets:/opt/data/assets @@ -71,26 +102,8 @@ services: # - "traefik.http.routers.penpot-https.tls=true" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" - ## Configuration envronment variables for the frontend container. In this case, the - ## container only needs the `PENPOT_FLAGS`. This environment variable is shared with - ## other services, but not all flags are relevant to all services. - environment: - ## Relevant flags for frontend: - ## - demo-users - ## - login-with-github - ## - login-with-gitlab - ## - login-with-google - ## - login-with-ldap - ## - login-with-oidc - ## - login-with-password - ## - registration - ## - webhooks - ## - ## You can read more about all available flags on: - ## https://help.penpot.app/technical-guide/configuration/#advanced-configuration - - - PENPOT_FLAGS=enable-registration enable-login-with-password + << : *penpot-flags penpot-backend: image: "penpotapp/backend:latest" @@ -110,31 +123,7 @@ services: ## container. environment: - - ## Relevant flags for backend: - ## - demo-users - ## - email-verification - ## - log-emails - ## - log-invitation-tokens - ## - login-with-github - ## - login-with-gitlab - ## - login-with-google - ## - login-with-ldap - ## - login-with-oidc - ## - login-with-password - ## - registration - ## - secure-session-cookies - ## - smtp - ## - smtp-debug - ## - telemetry - ## - webhooks - ## - prepl-server - ## - ## You can read more about all available flags and other - ## environment variables for the backend here: - ## https://help.penpot.app/technical-guide/configuration/#advanced-configuration - - - PENPOT_FLAGS=enable-registration enable-login-with-password disable-email-verification enable-smtp enable-prepl-server + << : [*penpot-flags, *penpot-public-uri] ## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems ## (eg http sessions, or invitations) are derived. @@ -147,70 +136,61 @@ services: ## ## python3 -c "import secrets; print(secrets.token_urlsafe(64))" - # - PENPOT_SECRET_KEY=my-insecure-key + # PENPOT_SECRET_KEY: my-insecure-key ## The PREPL host. Mainly used for external programatic access to penpot backend ## (example: admin). By default it will listen on `localhost` but if you are going to use ## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`. - # - PENPOT_PREPL_HOST=0.0.0.0 - - ## Public URI. If you are going to expose this instance to the internet and use it - ## under a different domain than 'localhost', you will need to adjust it to the final - ## domain. - ## - ## Consider using traefik and set the 'disable-secure-session-cookies' if you are - ## not going to serve penpot under HTTPS. - - - PENPOT_PUBLIC_URI=http://localhost:9001 + # PENPOT_PREPL_HOST: 0.0.0.0 ## Database connection parameters. Don't touch them unless you are using custom ## postgresql connection parameters. - - PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot - - PENPOT_DATABASE_USERNAME=penpot - - PENPOT_DATABASE_PASSWORD=penpot + PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot + PENPOT_DATABASE_USERNAME: penpot + PENPOT_DATABASE_PASSWORD: penpot ## Redis is used for the websockets notifications. Don't touch unless the redis ## container has different parameters or different name. - - PENPOT_REDIS_URI=redis://penpot-redis/0 + PENPOT_REDIS_URI: redis://penpot-redis/0 ## Default configuration for assets storage: using filesystem based with all files ## stored in a docker volume. - - PENPOT_ASSETS_STORAGE_BACKEND=assets-fs - - PENPOT_STORAGE_ASSETS_FS_DIRECTORY=/opt/data/assets + PENPOT_ASSETS_STORAGE_BACKEND: assets-fs + PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets ## Also can be configured to to use a S3 compatible storage ## service like MiniIO. Look below for minio service setup. - # - AWS_ACCESS_KEY_ID= - # - AWS_SECRET_ACCESS_KEY= - # - PENPOT_ASSETS_STORAGE_BACKEND=assets-s3 - # - PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://penpot-minio:9000 - # - PENPOT_STORAGE_ASSETS_S3_BUCKET= + # AWS_ACCESS_KEY_ID: + # AWS_SECRET_ACCESS_KEY: + # PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 + # PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000 + # PENPOT_STORAGE_ASSETS_S3_BUCKET: ## Telemetry. When enabled, a periodical process will send anonymous data about this ## instance. Telemetry data will enable us to learn how the application is used, ## based on real scenarios. If you want to help us, please leave it enabled. You can ## audit what data we send with the code available on github. - - PENPOT_TELEMETRY_ENABLED=true + PENPOT_TELEMETRY_ENABLED: true ## Example SMTP/Email configuration. By default, emails are sent to the mailcatch ## service, but for production usage it is recommended to setup a real SMTP ## provider. Emails are used to confirm user registrations & invitations. Look below ## how the mailcatch service is configured. - - PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com - - PENPOT_SMTP_DEFAULT_REPLY_TO=no-reply@example.com - - PENPOT_SMTP_HOST=penpot-mailcatch - - PENPOT_SMTP_PORT=1025 - - PENPOT_SMTP_USERNAME= - - PENPOT_SMTP_PASSWORD= - - PENPOT_SMTP_TLS=false - - PENPOT_SMTP_SSL=false + PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com + PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com + PENPOT_SMTP_HOST: penpot-mailcatch + PENPOT_SMTP_PORT: 1025 + PENPOT_SMTP_USERNAME: + PENPOT_SMTP_PASSWORD: + PENPOT_SMTP_TLS: false + PENPOT_SMTP_SSL: false penpot-exporter: image: "penpotapp/exporter:latest" @@ -221,10 +201,10 @@ services: environment: # Don't touch it; this uses an internal docker network to # communicate with the frontend. - - PENPOT_PUBLIC_URI=http://penpot-frontend + PENPOT_PUBLIC_URI: http://penpot-frontend ## Redis is used for the websockets notifications. - - PENPOT_REDIS_URI=redis://penpot-redis/0 + PENPOT_REDIS_URI: redis://penpot-redis/0 penpot-postgres: image: "postgres:15" diff --git a/docker/images/files/nginx.conf b/docker/images/files/nginx.conf index 8d0fff0a2..ee2f64175 100644 --- a/docker/images/files/nginx.conf +++ b/docker/images/files/nginx.conf @@ -1,6 +1,5 @@ -user www-data; worker_processes auto; -pid /run/nginx.pid; +pid /tmp/nginx.pid; include /etc/nginx/modules-enabled/*.conf; events { @@ -9,6 +8,12 @@ events { } http { + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + sendfile on; tcp_nopush on; tcp_nodelay on; @@ -38,7 +43,10 @@ http { gzip_types text/plain text/css text/javascript application/javascript application/json application/transit+json; - resolver $PENPOT_INTERNAL_RESOLVER; + proxy_buffer_size 16k; + proxy_busy_buffers_size 24k; # essentially, proxy_buffer_size + 2 small buffers of 4k + proxy_buffers 32 4k; + resolver $PENPOT_INTERNAL_RESOLVER ipv6=off; map $http_upgrade $connection_upgrade { default upgrade; @@ -53,7 +61,7 @@ http { include /etc/nginx/overrides.d/*.conf; server { - listen 80 default_server; + listen 8080 default_server; server_name _; client_max_body_size 100M; diff --git a/exporter/scripts/build b/exporter/scripts/build index 004460584..7ad0aecf5 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -10,7 +10,7 @@ rm -rf target export NODE_ENV=production; # Build the application -clojure -J-Xms100M -J-Xmx1000M -J-XX:+UseSerialGC -M:dev:shadow-cljs release main; +clojure -M:dev:shadow-cljs release main; # Remove source rm -rf target/app; diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index 1c24414c6..9107cbcb7 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -15,8 +15,7 @@ :output-wrapper false} :release - {:closure-defines {goog.debug.LOGGING_ENABLED true} - :compiler-options + {:compiler-options {:fn-invoke-direct true :source-map true :optimizations #shadow/env ["PENPOT_BUILD_OPTIMIZATIONS" :as :keyword :default :simple] diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 4c0088077..6ca84f584 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -26,16 +26,24 @@ (def ^:private schema:config - (sm/define - [:map {:title "config"} - [:public-uri {:optional true} ::sm/uri] - [:host {:optional true} :string] - [:tenant {:optional true} :string] - [:flags {:optional true} ::sm/set-of-keywords] - [:redis-uri {:optional true} :string] - [:tempdir {:optional true} :string] - [:browser-pool-max {:optional true} :int] - [:browser-pool-min {:optional true} :int]])) + [:map {:title "config"} + [:public-uri {:optional true} ::sm/uri] + [:host {:optional true} :string] + [:tenant {:optional true} :string] + [:flags {:optional true} [::sm/set :keyword]] + [:redis-uri {:optional true} :string] + [:tempdir {:optional true} :string] + [:browser-pool-max {:optional true} ::sm/int] + [:browser-pool-min {:optional true} ::sm/int]]) + +(def ^:private decode-config + (sm/decoder schema:config sm/string-transformer)) + +(def ^:private explain-config + (sm/explainer schema:config)) + +(def ^:private valid-config? + (sm/validator schema:config)) (defn- parse-flags [config] @@ -60,15 +68,15 @@ [] (let [env (read-env "penpot") env (d/without-nils env) - data (merge defaults env)] + data (merge defaults env) + data (decode-config data)] - (try - (sm/conform! schema:config data) - (catch :default cause - (if-let [explain (some->> cause ex-data ::sm/explain)] - (println (sm/humanize-explain explain)) - (js/console.error cause)) - (process/exit -1))))) + (when-not (valid-config? data) + (let [explain (explain-config data)] + (println (sm/humanize-explain explain)) + (process/exit -1))) + + data)) (def config (prepare-config)) diff --git a/frontend/.nvmrc b/frontend/.nvmrc index 55d178216..ee09fac75 100644 --- a/frontend/.nvmrc +++ b/frontend/.nvmrc @@ -1 +1 @@ -v14.15.0 +v20.11.1 diff --git a/frontend/deps.edn b/frontend/deps.edn index ec67bf562..f13d93fc2 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -20,8 +20,8 @@ :git/url "https://github.com/funcool/beicon.git"} funcool/rumext - {:git/tag "v2.12" - :git/sha "ab819f5" + {:git/tag "v2.14" + :git/sha "0016623" :git/url "https://github.com/funcool/rumext.git"} instaparse/instaparse {:mvn/version "1.5.0"} diff --git a/frontend/package.json b/frontend/package.json index 047329918..8c9ce3954 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,7 @@ "author": "Kaleidos INC", "private": true, "packageManager": "yarn@4.3.1", - "browserslist": [ - "defaults" - ], + "browserslist": ["defaults"], "type": "module", "repository": { "type": "git", @@ -20,9 +18,9 @@ "build:app:assets": "node ./scripts/build-app-assets.js", "build:storybook": "yarn run build:storybook:assets && yarn run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", - "build:storybook:cljs": "clojure -M:dev:shadow-cljs release storybook", + "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", + "build:renderer": "yarn run wasm-pack build ./renderer --target web --out-dir ../resources/public/js/renderer --release", "e2e:server": "node ./scripts/e2e-server.js", - "e2e:test": "playwright test --project default", "fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj:check": "cljfmt check --parallel=false src/ test/", "fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w", @@ -39,6 +37,7 @@ "token-test:watch": "clojure -M:dev:shadow-cljs watch test-esm", "token-test:nodemon": "nodemon --watch ./target/tests-esm.cjs --exec 'bun run token-test:run'", "token-test": "yarn run token-test:compile && yarn run token-test:run", + "test:e2e": "playwright test --project default", "translations": "node ./scripts/translations.js", "watch": "yarn run watch:app:assets", "watch:app:assets": "node ./scripts/watch.js", @@ -91,6 +90,7 @@ "typescript": "^5.4.5", "vite": "^5.1.4", "vitest": "^1.3.1", + "wasm-pack": "^0.13.0", "watcher": "^2.3.1", "workerpool": "^9.1.1" }, @@ -103,6 +103,7 @@ "highlight.js": "^11.9.0", "js-beautify": "^1.15.1", "jszip": "^3.10.1", + "lodash": "^4.17.21", "luxon": "^3.4.4", "mousetrap": "^1.6.5", "opentype.js": "^1.3.4", @@ -110,6 +111,7 @@ "randomcolor": "^0.6.2", "react": "18.3.1", "react-dom": "18.3.1", + "react-error-boundary": "^4.0.13", "react-virtualized": "^9.22.5", "rxjs": "8.0.0-alpha.14", "sax": "^1.4.1", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 6196826df..03dcb027f 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -56,7 +56,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { timeout: 2 * 60 * 1000, - command: "yarn e2e:server", + command: "yarn run e2e:server", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, }, diff --git a/frontend/playwright/data/assets/get-file-fragment-with-assets-components.json b/frontend/playwright/data/assets/get-file-fragment-with-assets-components.json index 4f8cfb630..053031eb5 100644 --- a/frontend/playwright/data/assets/get-file-fragment-with-assets-components.json +++ b/frontend/playwright/data/assets/get-file-fragment-with-assets-components.json @@ -2,7 +2,7 @@ "~:id": "~u015fda4f-caa6-8103-8004-862a9e4b4d4b", "~:file-id": "~u015fda4f-caa6-8103-8004-862a00dd4f31", "~:created-at": "~m1718718436639", - "~:content": { + "~:data": { "~ue117f7f6-433c-807e-8004-862a38e1823d": { "~:id": "~ue117f7f6-433c-807e-8004-862a38e1823d", "~:name": "Button", @@ -28,4 +28,4 @@ "~:main-instance-page": "~u015fda4f-caa6-8103-8004-862a00ddbe94" } } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/assets/get-file-fragmnet-with-assets-page.json b/frontend/playwright/data/assets/get-file-fragmnet-with-assets-page.json index 99e01ce34..bae8fd54e 100644 --- a/frontend/playwright/data/assets/get-file-fragmnet-with-assets-page.json +++ b/frontend/playwright/data/assets/get-file-fragmnet-with-assets-page.json @@ -2,7 +2,7 @@ "~:id": "~u015fda4f-caa6-8103-8004-862a9e4ad279", "~:file-id": "~u015fda4f-caa6-8103-8004-862a00dd4f31", "~:created-at": "~m1718718436639", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { @@ -627,4 +627,4 @@ "~:id": "~u015fda4f-caa6-8103-8004-862a00ddbe94", "~:name": "Page 1" } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/design/get-file-fragment-multiple-constraints.json b/frontend/playwright/data/design/get-file-fragment-multiple-constraints.json index 1a055d7d1..0fe5f6a2c 100644 --- a/frontend/playwright/data/design/get-file-fragment-multiple-constraints.json +++ b/frontend/playwright/data/design/get-file-fragment-multiple-constraints.json @@ -2,7 +2,7 @@ "~:id": "~u03bff843-920f-81a1-8004-7563acdc8ca1", "~:file-id": "~u03bff843-920f-81a1-8004-756365e1eb6a", "~:created-at": "~m1717592543081", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { @@ -360,4 +360,4 @@ "~:id": "~u03bff843-920f-81a1-8004-756365e1eb6b", "~:name": "Page 1" } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/viewer/get-file-fragment-empty-file.json b/frontend/playwright/data/viewer/get-file-fragment-empty-file.json index 544c559f7..c4fc2086e 100644 --- a/frontend/playwright/data/viewer/get-file-fragment-empty-file.json +++ b/frontend/playwright/data/viewer/get-file-fragment-empty-file.json @@ -2,7 +2,7 @@ "~:id": "~u0515a066-e303-8169-8004-73eb58e899c2", "~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849", "~:created-at": "~m1717493890966", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { @@ -94,4 +94,4 @@ "~:id": "~uc7ce0794-0992-8105-8004-38f28044384a", "~:name": "Page 1" } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/viewer/get-file-fragment-single-board.json b/frontend/playwright/data/viewer/get-file-fragment-single-board.json index 8c1e62a15..cf00a2900 100644 --- a/frontend/playwright/data/viewer/get-file-fragment-single-board.json +++ b/frontend/playwright/data/viewer/get-file-fragment-single-board.json @@ -2,7 +2,7 @@ "~:id": "~udd5cc0bb-91ff-81b9-8004-77dfae2d9e7c", "~:file-id": "~udd5cc0bb-91ff-81b9-8004-77df9cd3edb1", "~:created-at": "~m1717759268004", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { @@ -183,4 +183,4 @@ "~:id": "~udd5cc0bb-91ff-81b9-8004-77df9cd3edb2", "~:name": "Page 1" } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/workspace/get-file-fragment-7760.json b/frontend/playwright/data/workspace/get-file-fragment-7760.json index 0c8011553..c07d48702 100644 --- a/frontend/playwright/data/workspace/get-file-fragment-7760.json +++ b/frontend/playwright/data/workspace/get-file-fragment-7760.json @@ -2,7 +2,7 @@ "~:id": "~ucd90e028-326a-80b4-8004-7cdeefa23ece", "~:file-id": "~ucd90e028-326a-80b4-8004-7cdec16ffad5", "~:created-at": "~m1718094617214", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { diff --git a/frontend/playwright/data/workspace/get-file-fragment-blank.json b/frontend/playwright/data/workspace/get-file-fragment-blank.json index fe357c500..7760aaa92 100644 --- a/frontend/playwright/data/workspace/get-file-fragment-blank.json +++ b/frontend/playwright/data/workspace/get-file-fragment-blank.json @@ -2,7 +2,7 @@ "~:id": "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", "~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849", "~:created-at": "~m1713873823631", - "~:content": { + "~:data": { "~:options": {}, "~:objects": { "~u00000000-0000-0000-0000-000000000000": { @@ -94,4 +94,4 @@ "~:id": "~uc7ce0794-0992-8105-8004-38f28044384a", "~:name": "Page 1" } -} \ No newline at end of file +} diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index 7e5bf6b36..60b740859 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -168,7 +168,7 @@ export class WorkspacePage extends BaseWebSocketPage { async moveSelectionToShape(name) { await this.page.locator("rect.viewport-selrect").hover(); await this.page.mouse.down(); - await this.viewport.getByTestId(name).first().hover({ force: true }); + await this.viewport.getByText(name).first().hover({ force: true }); await this.page.mouse.up(); } diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 066486f50..8ce047fe3 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -177,3 +177,15 @@ test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard- ), ).toBeVisible(); }); + +test("Bug 8784 - Use keyboard arrow to move inside a text input does not change tabs", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + await workspacePage.pageName.click(); + await page.keyboard.press("ArrowLeft"); + + await expect(workspacePage.pageName).toHaveText("Page 1"); +}); diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index a3eeddc82..ef6901f70 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -117,7 +117,7 @@ test("User goes to the Viewer Inspect code, code tab", async ({ page }) => { }); await viewerPage.showCode(); - await viewerPage.page.getByTestId("code").click(); + await viewerPage.page.getByRole("tab", { name: "code" }).click(); await expect( viewerPage.page.getByRole("button", { name: "Copy all code" }), diff --git a/frontend/renderer/.gitignore b/frontend/renderer/.gitignore new file mode 100644 index 000000000..391ed4d66 --- /dev/null +++ b/frontend/renderer/.gitignore @@ -0,0 +1,5 @@ +target/ +debug/ + +**/*.rs.bk + diff --git a/frontend/renderer/Cargo.lock b/frontend/renderer/Cargo.lock new file mode 100644 index 000000000..c14faa4ad --- /dev/null +++ b/frontend/renderer/Cargo.lock @@ -0,0 +1,324 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cc" +version = "1.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "minicov" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "renderer" +version = "0.1.0" +dependencies = [ + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "minicov", + "scoped-tls", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "web-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/frontend/renderer/Cargo.toml b/frontend/renderer/Cargo.toml new file mode 100644 index 000000000..56724cc1a --- /dev/null +++ b/frontend/renderer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "renderer" +version = "0.1.0" +edition = "2021" +repository = "https://github.com/penpot/penpot" +license-file = "../../../../LICENSE" +description = "Wasm-based canvas renderer for Penpot" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2.93" + +[profile.release] +opt-level = "s" + +[dev-dependencies] +wasm-bindgen-test = "0.3.43" diff --git a/frontend/renderer/src/lib.rs b/frontend/renderer/src/lib.rs new file mode 100644 index 000000000..e4b08cfb0 --- /dev/null +++ b/frontend/renderer/src/lib.rs @@ -0,0 +1,36 @@ +use wasm_bindgen::prelude::*; + +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(s: &str); +} + +#[wasm_bindgen] +pub fn print(msg: &str) { + log(msg); +} + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } + + #[wasm_bindgen_test] + fn it_works_in_wasm() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/frontend/resources/images/email/logo-linkedin.png b/frontend/resources/images/email/logo-linkedin.png new file mode 100644 index 000000000..ee24f90db Binary files /dev/null and b/frontend/resources/images/email/logo-linkedin.png differ diff --git a/frontend/resources/images/email/logo-mastodon.png b/frontend/resources/images/email/logo-mastodon.png new file mode 100644 index 000000000..f0a14cf61 Binary files /dev/null and b/frontend/resources/images/email/logo-mastodon.png differ diff --git a/frontend/resources/images/email/logo-x.png b/frontend/resources/images/email/logo-x.png new file mode 100644 index 000000000..cdaf32247 Binary files /dev/null and b/frontend/resources/images/email/logo-x.png differ diff --git a/frontend/resources/images/features/2.3-img-slide-1.gif b/frontend/resources/images/features/2.3-img-slide-1.gif new file mode 100644 index 000000000..9eea1236f Binary files /dev/null and b/frontend/resources/images/features/2.3-img-slide-1.gif differ diff --git a/frontend/resources/images/features/2.3-img-slide-2.gif b/frontend/resources/images/features/2.3-img-slide-2.gif new file mode 100644 index 000000000..200fa5f32 Binary files /dev/null and b/frontend/resources/images/features/2.3-img-slide-2.gif differ diff --git a/frontend/resources/images/features/2.3-slide-0.png b/frontend/resources/images/features/2.3-slide-0.png new file mode 100644 index 000000000..b1db57f33 Binary files /dev/null and b/frontend/resources/images/features/2.3-slide-0.png differ diff --git a/frontend/resources/images/icons/info.svg b/frontend/resources/images/icons/info.svg new file mode 100644 index 000000000..ff916bf3f --- /dev/null +++ b/frontend/resources/images/icons/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/plugins-runtime/index.js b/frontend/resources/plugins-runtime/index.js index bd841634d..4f538445b 100644 --- a/frontend/resources/plugins-runtime/index.js +++ b/frontend/resources/plugins-runtime/index.js @@ -1,144 +1,140 @@ -var Hn = (t, e, r) => { - if (!e.has(t)) - throw TypeError("Cannot " + r); +var zn = (t) => { + throw TypeError(t); }; -var Ee = (t, e, r) => (Hn(t, e, "read from private field"), r ? r.call(t) : e.get(t)), Gr = (t, e, r) => { - if (e.has(t)) - throw TypeError("Cannot add the same private member more than once"); - e instanceof WeakSet ? e.add(t) : e.set(t, r); -}, Br = (t, e, r, n) => (Hn(t, e, "write to private field"), n ? n.call(t, r) : e.set(t, r), r); -const k = globalThis, { - Array: Bs, - Date: Hs, - FinalizationRegistry: kt, - Float32Array: Vs, +var Bn = (t, e, r) => e.has(t) || zn("Cannot " + r); +var Y = (t, e, r) => (Bn(t, e, "read from private field"), r ? r.call(t) : e.get(t)), dr = (t, e, r) => e.has(t) ? zn("Cannot add the same private member more than once") : e instanceof WeakSet ? e.add(t) : e.set(t, r), fr = (t, e, r, n) => (Bn(t, e, "write to private field"), n ? n.call(t, r) : e.set(t, r), r); +const T = globalThis, { + Array: Gs, + Date: Vs, + FinalizationRegistry: At, + Float32Array: Hs, JSON: Ws, - Map: Pe, + Map: Re, Math: qs, - Number: So, - Object: _n, + Number: xo, + Object: vn, Promise: Ks, - Proxy: Cr, + Proxy: Lr, Reflect: Ys, - RegExp: We, - Set: Ct, - String: pe, + RegExp: Xe, + Set: Ot, + String: be, Symbol: St, - WeakMap: Me, - WeakSet: $t + WeakMap: je, + WeakSet: Mt } = globalThis, { // The feral Error constructor is safe for internal use, but must not be // revealed to post-lockdown code in any compartment including the start // compartment since in V8 at least it bears stack inspection capabilities. - Error: ue, + Error: ce, RangeError: Js, - ReferenceError: lt, - SyntaxError: tr, + ReferenceError: Bt, + SyntaxError: sr, TypeError: v, - AggregateError: Hr + AggregateError: Yr } = globalThis, { - assign: $r, - create: Z, - defineProperties: F, - entries: re, + assign: Fr, + create: H, + defineProperties: B, + entries: ge, freeze: y, - getOwnPropertyDescriptor: J, + getOwnPropertyDescriptor: ne, getOwnPropertyDescriptors: Ze, - getOwnPropertyNames: Dt, - getPrototypeOf: j, - is: Nr, - isFrozen: jl, - isSealed: Zl, - isExtensible: zl, - keys: Eo, - prototype: bn, - seal: Gl, + getOwnPropertyNames: It, + getPrototypeOf: V, + is: Dr, + isFrozen: zl, + isSealed: Bl, + isExtensible: Gl, + keys: So, + prototype: _n, + seal: Vl, preventExtensions: Xs, - setPrototypeOf: xo, + setPrototypeOf: Eo, values: ko, - fromEntries: mt -} = _n, { - species: Vr, - toStringTag: qe, - iterator: rr, + fromEntries: yt +} = vn, { + species: Jr, + toStringTag: Qe, + iterator: ar, matchAll: Po, unscopables: Qs, keyFor: ea, for: ta -} = St, { isInteger: ra } = So, { stringify: To } = Ws, { defineProperty: na } = _n, M = (t, e, r) => { +} = St, { isInteger: ra } = xo, { stringify: To } = Ws, { defineProperty: na } = vn, U = (t, e, r) => { const n = na(t, e, r); if (n !== t) throw v( `Please report that the original defineProperty silently failed to set ${To( - pe(e) + be(e) )}. (SES_DEFINE_PROPERTY_FAILED_SILENTLY)` ); return n; }, { - apply: ne, - construct: mr, + apply: ue, + construct: br, get: oa, getOwnPropertyDescriptor: sa, has: Ao, isExtensible: aa, - ownKeys: De, + ownKeys: Ve, preventExtensions: ia, set: Io -} = Ys, { isArray: Et, prototype: _e } = Bs, { prototype: Nt } = Pe, { prototype: Rr } = RegExp, { prototype: nr } = Ct, { prototype: Le } = pe, { prototype: Or } = Me, { prototype: Co } = $t, { prototype: wn } = Function, { prototype: $o } = Ks, { prototype: No } = j( +} = Ys, { isArray: Et, prototype: Pe } = Gs, { prototype: Lt } = Re, { prototype: Ur } = RegExp, { prototype: ir } = Ot, { prototype: ze } = be, { prototype: jr } = je, { prototype: Co } = Mt, { prototype: bn } = Function, { prototype: Ro } = Ks, { prototype: $o } = V( // eslint-disable-next-line no-empty-function, func-names function* () { } -), ca = j(Uint8Array.prototype), { bind: tn } = wn, P = tn.bind(tn.call), oe = P(bn.hasOwnProperty), Ke = P(_e.filter), ut = P(_e.forEach), Mr = P(_e.includes), Rt = P(_e.join), se = ( +), ca = V(Uint8Array.prototype), { bind: sn } = bn, A = sn.bind(sn.call), de = A(_n.hasOwnProperty), et = A(Pe.filter), ft = A(Pe.forEach), Zr = A(Pe.includes), Ft = A(Pe.join), fe = ( /** @type {any} */ - P(_e.map) -), Ro = ( + A(Pe.map) +), No = ( /** @type {any} */ - P(_e.flatMap) -), gr = P(_e.pop), X = P(_e.push), la = P(_e.slice), ua = P(_e.some), Oo = P(_e.sort), da = P(_e[rr]), $e = P(Nt.set), Ue = P(Nt.get), Lr = P(Nt.has), fa = P(Nt.delete), pa = P(Nt.entries), ha = P(Nt[rr]), Sn = P(nr.add); -P(nr.delete); -const Vn = P(nr.forEach), En = P(nr.has), ma = P(nr[rr]), xn = P(Rr.test), kn = P(Rr.exec), ga = P(Rr[Po]), Mo = P(Le.endsWith), Lo = P(Le.includes), ya = P(Le.indexOf); -P(Le.match); -const yr = P(No.next), Fo = P(No.throw), vr = ( + A(Pe.flatMap) +), wr = A(Pe.pop), oe = A(Pe.push), la = A(Pe.slice), ua = A(Pe.some), Oo = A(Pe.sort), da = A(Pe[ar]), he = A(Lt.set), He = A(Lt.get), zr = A(Lt.has), fa = A(Lt.delete), pa = A(Lt.entries), ha = A(Lt[ar]), wn = A(ir.add); +A(ir.delete); +const Gn = A(ir.forEach), xn = A(ir.has), ma = A(ir[ar]), Sn = A(Ur.test), En = A(Ur.exec), ga = A(Ur[Po]), Mo = A(ze.endsWith), Lo = A(ze.includes), ya = A(ze.indexOf); +A(ze.match); +const xr = A($o.next), Fo = A($o.throw), Sr = ( /** @type {any} */ - P(Le.replace) -), va = P(Le.search), Pn = P(Le.slice), Tn = P(Le.split), Do = P(Le.startsWith), _a = P(Le[rr]), ba = P(Or.delete), L = P(Or.get), An = P(Or.has), ie = P(Or.set), Fr = P(Co.add), or = P(Co.has), wa = P(wn.toString), Sa = P(tn); -P($o.catch); + A(ze.replace) +), va = A(ze.search), kn = A(ze.slice), Pn = A(ze.split), Do = A(ze.startsWith), _a = A(ze[ar]), ba = A(jr.delete), z = A(jr.get), kt = A(jr.has), me = A(jr.set), Br = A(Co.add), cr = A(Co.has), wa = A(bn.toString), xa = A(sn); +A(Ro.catch); const Uo = ( /** @type {any} */ - P($o.then) -), Ea = kt && P(kt.prototype.register); -kt && P(kt.prototype.unregister); -const In = y(Z(null)), Ye = (t) => _n(t) === t, Dr = (t) => t instanceof ue, jo = eval, ve = Function, xa = () => { + A(Ro.then) +), Sa = At && A(At.prototype.register); +At && A(At.prototype.unregister); +const Tn = y(H(null)), ke = (t) => vn(t) === t, Gr = (t) => t instanceof ce, jo = eval, Ee = Function, Ea = () => { throw v('Cannot eval with evalTaming set to "noEval" (SES_NO_EVAL)'); -}, He = J(Error("er1"), "stack"), Wr = J(v("er2"), "stack"); +}, Ye = ne(Error("er1"), "stack"), Xr = ne(v("er2"), "stack"); let Zo, zo; -if (He && Wr && He.get) +if (Ye && Xr && Ye.get) if ( // In the v8 case as we understand it, all errors have an own stack // accessor property, but within the same realm, all these accessor // properties have the same getter and have the same setter. // This is therefore the case that we repair. - typeof He.get == "function" && He.get === Wr.get && typeof He.set == "function" && He.set === Wr.set + typeof Ye.get == "function" && Ye.get === Xr.get && typeof Ye.set == "function" && Ye.set === Xr.set ) - Zo = y(He.get), zo = y(He.set); + Zo = y(Ye.get), zo = y(Ye.set); else throw v( "Unexpected Error own stack accessor functions (SES_UNEXPECTED_ERROR_OWN_STACK_ACCESSOR)" ); -const qr = Zo, ka = zo; +const Qr = Zo, ka = zo; function Pa() { return this; } if (Pa()) throw v("SES failed to initialize, sloppy mode (SES_NO_SLOPPY)"); -const { freeze: at } = Object, { apply: Ta } = Reflect, Cn = (t) => (e, ...r) => Ta(t, e, r), Aa = Cn(Array.prototype.push), Wn = Cn(Array.prototype.includes), Ia = Cn(String.prototype.split), nt = JSON.stringify, ir = (t, ...e) => { +const { freeze: lt } = Object, { apply: Ta } = Reflect, An = (t) => (e, ...r) => Ta(t, e, r), Aa = An(Array.prototype.push), Vn = An(Array.prototype.includes), Ia = An(String.prototype.split), at = JSON.stringify, pr = (t, ...e) => { let r = t[0]; for (let n = 0; n < e.length; n += 1) r = `${r}${e[n]}${t[n + 1]}`; throw Error(r); -}, Go = (t, e = !1) => { +}, Bo = (t, e = !1) => { const r = [], n = (c, l, u = void 0) => { - typeof c == "string" || ir`Environment option name ${nt(c)} must be a string.`, typeof l == "string" || ir`Environment option default setting ${nt( + typeof c == "string" || pr`Environment option name ${at(c)} must be a string.`, typeof l == "string" || pr`Environment option default setting ${at( l )} must be a string.`; let d = l; @@ -146,54 +142,54 @@ const { freeze: at } = Object, { apply: Ta } = Reflect, Cn = (t) => (e, ...r) => if (typeof h == "object" && c in h) { e || Aa(r, c); const p = h[c]; - typeof p == "string" || ir`Environment option named ${nt( + typeof p == "string" || pr`Environment option named ${at( c - )}, if present, must have a corresponding string value, got ${nt( + )}, if present, must have a corresponding string value, got ${at( p )}`, d = p; } - return u === void 0 || d === l || Wn(u, d) || ir`Unrecognized ${nt(c)} value ${nt( + return u === void 0 || d === l || Vn(u, d) || pr`Unrecognized ${at(c)} value ${at( d - )}. Expected one of ${nt([l, ...u])}`, d; + )}. Expected one of ${at([l, ...u])}`, d; }; - at(n); + lt(n); const o = (c) => { const l = n(c, ""); - return at(l === "" ? [] : Ia(l, ",")); + return lt(l === "" ? [] : Ia(l, ",")); }; - at(o); - const a = (c, l) => Wn(o(c), l), i = () => at([...r]); - return at(i), at({ + lt(o); + const s = (c, l) => Vn(o(c), l), i = () => lt([...r]); + return lt(i), lt({ getEnvironmentOption: n, getEnvironmentOptionsList: o, - environmentOptionsListHas: a, + environmentOptionsListHas: s, getCapturedEnvironmentOptionNames: i }); }; -at(Go); +lt(Bo); const { - getEnvironmentOption: le, - getEnvironmentOptionsList: Bl, - environmentOptionsListHas: Hl -} = Go(globalThis, !0), _r = (t) => (t = `${t}`, t.length >= 1 && Lo("aeiouAEIOU", t[0]) ? `an ${t}` : `a ${t}`); -y(_r); -const Bo = (t, e = void 0) => { - const r = new Ct(), n = (o, a) => { - switch (typeof a) { + getEnvironmentOption: ve, + getEnvironmentOptionsList: Hl, + environmentOptionsListHas: Wl +} = Bo(globalThis, !0), Er = (t) => (t = `${t}`, t.length >= 1 && Lo("aeiouAEIOU", t[0]) ? `an ${t}` : `a ${t}`); +y(Er); +const Go = (t, e = void 0) => { + const r = new Ot(), n = (o, s) => { + switch (typeof s) { case "object": { - if (a === null) + if (s === null) return null; - if (En(r, a)) + if (xn(r, s)) return "[Seen]"; - if (Sn(r, a), Dr(a)) - return `[${a.name}: ${a.message}]`; - if (qe in a) - return `[${a[qe]}]`; - if (Et(a)) - return a; - const i = Eo(a); + if (wn(r, s), Gr(s)) + return `[${s.name}: ${s.message}]`; + if (Qe in s) + return `[${s[Qe]}]`; + if (Et(s)) + return s; + const i = So(s); if (i.length < 2) - return a; + return s; let c = !0; for (let u = 1; u < i.length; u += 1) if (i[u - 1] >= i[u]) { @@ -201,24 +197,24 @@ const Bo = (t, e = void 0) => { break; } if (c) - return a; + return s; Oo(i); - const l = se(i, (u) => [u, a[u]]); - return mt(l); + const l = fe(i, (u) => [u, s[u]]); + return yt(l); } case "function": - return `[Function ${a.name || ""}]`; + return `[Function ${s.name || ""}]`; case "string": - return Do(a, "[") ? `[${a}]` : a; + return Do(s, "[") ? `[${s}]` : s; case "undefined": case "symbol": - return `[${pe(a)}]`; + return `[${be(s)}]`; case "bigint": - return `[${a}n]`; + return `[${s}n]`; case "number": - return Nr(a, NaN) ? "[NaN]" : a === 1 / 0 ? "[Infinity]" : a === -1 / 0 ? "[-Infinity]" : a; + return Dr(s, NaN) ? "[NaN]" : s === 1 / 0 ? "[Infinity]" : s === -1 / 0 ? "[-Infinity]" : s; default: - return a; + return s; } }; try { @@ -227,252 +223,252 @@ const Bo = (t, e = void 0) => { return "[Something that failed to stringify]"; } }; -y(Bo); -const { isSafeInteger: Ca } = Number, { freeze: vt } = Object, { toStringTag: $a } = Symbol, qn = (t) => { +y(Go); +const { isSafeInteger: Ca } = Number, { freeze: bt } = Object, { toStringTag: Ra } = Symbol, Hn = (t) => { const r = { next: void 0, prev: void 0, data: t }; return r.next = r, r.prev = r, r; -}, Kn = (t, e) => { +}, Wn = (t, e) => { if (t === e) throw TypeError("Cannot splice a cell into itself"); if (e.next !== e || e.prev !== e) throw TypeError("Expected self-linked cell"); const r = e, n = t.next; return r.prev = t, r.next = n, t.next = r, n.prev = r, r; -}, Kr = (t) => { +}, en = (t) => { const { prev: e, next: r } = t; e.next = r, r.prev = e, t.prev = t, t.next = t; -}, Ho = (t) => { +}, Vo = (t) => { if (!Ca(t) || t < 0) throw TypeError("keysBudget must be a safe non-negative integer number"); const e = /* @__PURE__ */ new WeakMap(); let r = 0; - const n = qn(void 0), o = (d) => { + const n = Hn(void 0), o = (d) => { const f = e.get(d); if (!(f === void 0 || f.data === void 0)) - return Kr(f), Kn(n, f), f; - }, a = (d) => o(d) !== void 0; - vt(a); + return en(f), Wn(n, f), f; + }, s = (d) => o(d) !== void 0; + bt(s); const i = (d) => { const f = o(d); return f && f.data && f.data.get(d); }; - vt(i); + bt(i); const c = (d, f) => { if (t < 1) return u; let h = o(d); - if (h === void 0 && (h = qn(void 0), Kn(n, h)), !h.data) + if (h === void 0 && (h = Hn(void 0), Wn(n, h)), !h.data) for (r += 1, h.data = /* @__PURE__ */ new WeakMap(), e.set(d, h); r > t; ) { const p = n.prev; - Kr(p), p.data = void 0, r -= 1; + en(p), p.data = void 0, r -= 1; } return h.data.set(d, f), u; }; - vt(c); + bt(c); const l = (d) => { const f = e.get(d); - return f === void 0 || (Kr(f), e.delete(d), f.data === void 0) ? !1 : (f.data = void 0, r -= 1, !0); + return f === void 0 || (en(f), e.delete(d), f.data === void 0) ? !1 : (f.data = void 0, r -= 1, !0); }; - vt(l); - const u = vt({ - has: a, + bt(l); + const u = bt({ + has: s, get: i, set: c, delete: l, // eslint-disable-next-line jsdoc/check-types [ /** @type {typeof Symbol.toStringTag} */ - $a + Ra ]: "LRUCacheMap" }); return u; }; -vt(Ho); -const { freeze: pr } = Object, { isSafeInteger: Na } = Number, Ra = 1e3, Oa = 100, Vo = (t = Ra, e = Oa) => { - if (!Na(e) || e < 1) +bt(Vo); +const { freeze: vr } = Object, { isSafeInteger: $a } = Number, Na = 1e3, Oa = 100, Ho = (t = Na, e = Oa) => { + if (!$a(e) || e < 1) throw TypeError( "argsPerErrorBudget must be a safe positive integer number" ); - const r = Ho(t), n = (a, i) => { - const c = r.get(a); - c !== void 0 ? (c.length >= e && c.shift(), c.push(i)) : r.set(a, [i]); + const r = Vo(t), n = (s, i) => { + const c = r.get(s); + c !== void 0 ? (c.length >= e && c.shift(), c.push(i)) : r.set(s, [i]); }; - pr(n); - const o = (a) => { - const i = r.get(a); - return r.delete(a), i; + vr(n); + const o = (s) => { + const i = r.get(s); + return r.delete(s), i; }; - return pr(o), pr({ + return vr(o), vr({ addLogArgs: n, takeLogArgsArray: o }); }; -pr(Vo); -const Pt = new Me(), Je = (t, e = void 0) => { +vr(Ho); +const Ct = new je(), Z = (t, e = void 0) => { const r = y({ - toString: y(() => Bo(t, e)) + toString: y(() => Go(t, e)) }); - return ie(Pt, r, t), r; + return me(Ct, r, t), r; }; -y(Je); -const Ma = y(/^[\w:-]( ?[\w:-])*$/), rn = (t, e = void 0) => { - if (typeof t != "string" || !xn(Ma, t)) - return Je(t, e); +y(Z); +const Ma = y(/^[\w:-]( ?[\w:-])*$/), kr = (t, e = void 0) => { + if (typeof t != "string" || !Sn(Ma, t)) + return Z(t, e); const r = y({ toString: y(() => t) }); - return ie(Pt, r, t), r; + return me(Ct, r, t), r; }; -y(rn); -const Ur = new Me(), Wo = ({ template: t, args: e }) => { +y(kr); +const Vr = new je(), Wo = ({ template: t, args: e }) => { const r = [t[0]]; for (let n = 0; n < e.length; n += 1) { const o = e[n]; - let a; - An(Pt, o) ? a = `${o}` : Dr(o) ? a = `(${_r(o.name)})` : a = `(${_r(typeof o)})`, X(r, a, t[n + 1]); + let s; + kt(Ct, o) ? s = `${o}` : Gr(o) ? s = `(${Er(o.name)})` : s = `(${Er(typeof o)})`, oe(r, s, t[n + 1]); } - return Rt(r, ""); + return Ft(r, ""); }, qo = y({ toString() { - const t = L(Ur, this); + const t = z(Vr, this); return t === void 0 ? "[Not a DetailsToken]" : Wo(t); } }); y(qo.toString); -const ft = (t, ...e) => { +const le = (t, ...e) => { const r = y({ __proto__: qo }); - return ie(Ur, r, { template: t, args: e }), /** @type {DetailsToken} */ + return me(Vr, r, { template: t, args: e }), /** @type {DetailsToken} */ /** @type {unknown} */ r; }; -y(ft); -const Ko = (t, ...e) => (e = se( +y(le); +const Ko = (t, ...e) => (e = fe( e, - (r) => An(Pt, r) ? r : Je(r) -), ft(t, ...e)); + (r) => kt(Ct, r) ? r : Z(r) +), le(t, ...e)); y(Ko); const Yo = ({ template: t, args: e }) => { const r = [t[0]]; for (let n = 0; n < e.length; n += 1) { let o = e[n]; - An(Pt, o) && (o = L(Pt, o)); - const a = vr(gr(r) || "", / $/, ""); - a !== "" && X(r, a); - const i = vr(t[n + 1], /^ /, ""); - X(r, o, i); + kt(Ct, o) && (o = z(Ct, o)); + const s = Sr(wr(r) || "", / $/, ""); + s !== "" && oe(r, s); + const i = Sr(t[n + 1], /^ /, ""); + oe(r, o, i); } - return r[r.length - 1] === "" && gr(r), r; -}, hr = new Me(); -let nn = 0; -const Yn = new Me(), Jo = (t, e = t.name) => { - let r = L(Yn, t); - return r !== void 0 || (nn += 1, r = `${e}#${nn}`, ie(Yn, t, r)), r; + return r[r.length - 1] === "" && wr(r), r; +}, _r = new je(); +let an = 0; +const qn = new je(), Jo = (t, e = t.name) => { + let r = z(qn, t); + return r !== void 0 || (an += 1, r = `${e}#${an}`, me(qn, t, r)), r; }, La = (t) => { const e = Ze(t), { name: r, message: n, errors: o = void 0, - cause: a = void 0, + cause: s = void 0, stack: i = void 0, ...c - } = e, l = De(c); + } = e, l = Ve(c); if (l.length >= 1) { for (const d of l) delete t[d]; - const u = Z(bn, c); - $n( + const u = H(_n, c); + Hr( t, - ft`originally with properties ${Je(u)}` + le`originally with properties ${Z(u)}` ); } - for (const u of De(t)) { + for (const u of Ve(t)) { const d = e[u]; - d && oe(d, "get") && M(t, u, { + d && de(d, "get") && U(t, u, { value: t[u] // invoke the getter to convert to data property }); } y(t); -}, on = (t = ft`Assert failed`, e = k.Error, { +}, Le = (t = le`Assert failed`, e = T.Error, { errorName: r = void 0, cause: n = void 0, errors: o = void 0, - sanitize: a = !0 + sanitize: s = !0 } = {}) => { - typeof t == "string" && (t = ft([t])); - const i = L(Ur, t); + typeof t == "string" && (t = le([t])); + const i = z(Vr, t); if (i === void 0) - throw v(`unrecognized details ${Je(t)}`); + throw v(`unrecognized details ${Z(t)}`); const c = Wo(i), l = n && { cause: n }; let u; - return typeof Hr < "u" && e === Hr ? u = Hr(o || [], c, l) : (u = /** @type {ErrorConstructor} */ + return typeof Yr < "u" && e === Yr ? u = Yr(o || [], c, l) : (u = /** @type {ErrorConstructor} */ e( c, l - ), o !== void 0 && M(u, "errors", { + ), o !== void 0 && U(u, "errors", { value: o, writable: !0, enumerable: !1, configurable: !0 - })), ie(hr, u, Yo(i)), r !== void 0 && Jo(u, r), a && La(u), u; + })), me(_r, u, Yo(i)), r !== void 0 && Jo(u, r), s && La(u), u; }; -y(on); -const { addLogArgs: Fa, takeLogArgsArray: Da } = Vo(), sn = new Me(), $n = (t, e) => { - typeof e == "string" && (e = ft([e])); - const r = L(Ur, e); +y(Le); +const { addLogArgs: Fa, takeLogArgsArray: Da } = Ho(), cn = new je(), Hr = (t, e) => { + typeof e == "string" && (e = le([e])); + const r = z(Vr, e); if (r === void 0) - throw v(`unrecognized details ${Je(e)}`); - const n = Yo(r), o = L(sn, t); + throw v(`unrecognized details ${Z(e)}`); + const n = Yo(r), o = z(cn, t); if (o !== void 0) - for (const a of o) - a(t, n); + for (const s of o) + s(t, n); else Fa(t, n); }; -y($n); +y(Hr); const Ua = (t) => { if (!("stack" in t)) return ""; const e = `${t.stack}`, r = ya(e, ` `); - return Do(e, " ") || r === -1 ? e : Pn(e, r + 1); -}, br = { - getStackString: k.getStackString || Ua, + return Do(e, " ") || r === -1 ? e : kn(e, r + 1); +}, Pr = { + getStackString: T.getStackString || Ua, tagError: (t) => Jo(t), resetErrorTagNum: () => { - nn = 0; + an = 0; }, - getMessageLogArgs: (t) => L(hr, t), + getMessageLogArgs: (t) => z(_r, t), takeMessageLogArgs: (t) => { - const e = L(hr, t); - return ba(hr, t), e; + const e = z(_r, t); + return ba(_r, t), e; }, takeNoteLogArgsArray: (t, e) => { const r = Da(t); if (e !== void 0) { - const n = L(sn, t); - n ? X(n, e) : ie(sn, t, [e]); + const n = z(cn, t); + n ? oe(n, e) : me(cn, t, [e]); } return r || []; } }; -y(br); -const jr = (t = void 0, e = !1) => { - const r = e ? Ko : ft, n = r`Check failed`, o = (f = n, h = void 0, p = void 0) => { - const m = on(f, h, p); +y(Pr); +const Wr = (t = void 0, e = !1) => { + const r = e ? Ko : le, n = r`Check failed`, o = (f = n, h = void 0, p = void 0) => { + const m = Le(f, h, p); throw t !== void 0 && t(m), m; }; y(o); - const a = (f, ...h) => o(r(f, ...h)); + const s = (f, ...h) => o(r(f, ...h)); function i(f, h = void 0, p = void 0, m = void 0) { f || o(h, p, m); } const c = (f, h, p = void 0, m = void 0, _ = void 0) => { - Nr(f, h) || o( + Dr(f, h) || o( p || r`Expected ${f} is same as ${h}`, m || Js, _ @@ -481,102 +477,102 @@ const jr = (t = void 0, e = !1) => { y(c); const l = (f, h, p) => { if (typeof f !== h) { - if (typeof h == "string" || a`${Je(h)} must be a string`, p === void 0) { - const m = _r(h); - p = r`${f} must be ${rn(m)}`; + if (typeof h == "string" || s`${Z(h)} must be a string`, p === void 0) { + const m = Er(h); + p = r`${f} must be ${kr(m)}`; } o(p, v); } }; y(l); - const d = $r(i, { - error: on, + const d = Fr(i, { + error: Le, fail: o, equal: c, typeof: l, string: (f, h = void 0) => l(f, "string", h), - note: $n, + note: Hr, details: r, - Fail: a, - quote: Je, - bare: rn, - makeAssert: jr + Fail: s, + quote: Z, + bare: kr, + makeAssert: Wr }); return y(d); }; -y(jr); -const z = jr(), Xo = J( +y(Wr); +const ee = Wr(), Kn = ee.equal, Xo = ne( ca, - qe + Qe ); -z(Xo); +ee(Xo); const Qo = Xo.get; -z(Qo); -const ja = (t) => ne(Qo, t, []) !== void 0, Za = (t) => { - const e = +pe(t); - return ra(e) && pe(e) === t; +ee(Qo); +const ja = (t) => ue(Qo, t, []) !== void 0, Za = (t) => { + const e = +be(t); + return ra(e) && be(e) === t; }, za = (t) => { - Xs(t), ut(De(t), (e) => { - const r = J(t, e); - z(r), Za(e) || M(t, e, { + Xs(t), ft(Ve(t), (e) => { + const r = ne(t, e); + ee(r), Za(e) || U(t, e, { ...r, writable: !1, configurable: !1 }); }); -}, Ga = () => { - if (typeof k.harden == "function") - return k.harden; - const t = new $t(), { harden: e } = { +}, Ba = () => { + if (typeof T.harden == "function") + return T.harden; + const t = new Mt(), { harden: e } = { /** * @template T * @param {T} root * @returns {T} */ harden(r) { - const n = new Ct(); + const n = new Ot(); function o(d) { - if (!Ye(d)) + if (!ke(d)) return; const f = typeof d; if (f !== "object" && f !== "function") throw v(`Unexpected typeof: ${f}`); - or(t, d) || En(n, d) || Sn(n, d); + cr(t, d) || xn(n, d) || wn(n, d); } - const a = (d) => { + const s = (d) => { ja(d) ? za(d) : y(d); - const f = Ze(d), h = j(d); - o(h), ut(De(f), (p) => { + const f = Ze(d), h = V(d); + o(h), ft(Ve(f), (p) => { const m = f[ /** @type {string} */ p ]; - oe(m, "value") ? o(m.value) : (o(m.get), o(m.set)); + de(m, "value") ? o(m.value) : (o(m.get), o(m.set)); }); - }, i = qr === void 0 && ka === void 0 ? ( + }, i = Qr === void 0 && ka === void 0 ? ( // On platforms without v8's error own stack accessor problem, // don't pay for any extra overhead. - a + s ) : (d) => { - if (Dr(d)) { - const f = J(d, "stack"); - f && f.get === qr && f.configurable && M(d, "stack", { + if (Gr(d)) { + const f = ne(d, "stack"); + f && f.get === Qr && f.configurable && U(d, "stack", { // NOTE: Calls getter during harden, which seems dangerous. // But we're only calling the problematic getter whose // hazards we think we understand. // @ts-expect-error TS should know FERAL_STACK_GETTER // cannot be `undefined` here. // See https://github.com/endojs/endo/pull/2232#discussion_r1575179471 - value: ne(qr, d, []) + value: ue(Qr, d, []) }); } - return a(d); + return s(d); }, c = () => { - Vn(n, i); + Gn(n, i); }, l = (d) => { - Fr(t, d); + Br(t, d); }, u = () => { - Vn(n, l); + Gn(n, l); }; return o(r), c(), u(), r; } @@ -648,7 +644,7 @@ const ja = (t) => ne(Qo, t, []) !== void 0, Za = (t) => { harden: "harden", HandledPromise: "HandledPromise" // TODO: Until Promise.delegate (see below). -}, Jn = { +}, Yn = { // *** Constructor Properties of the Global Object Date: "%InitialDate%", Error: "%InitialError%", @@ -687,26 +683,26 @@ const ja = (t) => ne(Qo, t, []) !== void 0, Za = (t) => { // Instead, conditional push below. // AggregateError, ]; -typeof AggregateError < "u" && X(ns, AggregateError); -const an = { +typeof AggregateError < "u" && oe(ns, AggregateError); +const ln = { "[[Proto]]": "%FunctionPrototype%", length: "number", name: "string" // Do not specify "prototype" here, since only Function instances that can // be used as a constructor have a prototype property. For constructors, // since prototype properties are instance-specific, we define it there. -}, Ba = { +}, Ga = { // This property is not mentioned in ECMA 262, but is present in V8 and // necessary for lockdown to succeed. "[[Proto]]": "%AsyncFunctionPrototype%" -}, s = an, Xn = Ba, R = { - get: s, +}, a = ln, Jn = Ga, M = { + get: a, set: "undefined" -}, Ie = { - get: s, - set: s -}, Qn = (t) => t === R || t === Ie; -function ot(t) { +}, Oe = { + get: a, + set: a +}, Xn = (t) => t === M || t === Oe; +function it(t) { return { // Properties of the NativeError Constructors "[[Proto]]": "%SharedError%", @@ -714,7 +710,7 @@ function ot(t) { prototype: t }; } -function st(t) { +function ct(t) { return { // Properties of the NativeError Prototype Objects "[[Proto]]": "%ErrorPrototype%", @@ -728,7 +724,7 @@ function st(t) { cause: !1 }; } -function ge(t) { +function xe(t) { return { // Properties of the TypedArray Constructors "[[Proto]]": "%TypedArray%", @@ -736,7 +732,7 @@ function ge(t) { prototype: t }; } -function ye(t) { +function Se(t) { return { // Properties of the TypedArray Prototype Objects "[[Proto]]": "%TypedArrayPrototype%", @@ -744,7 +740,7 @@ function ye(t) { constructor: t }; } -const eo = { +const Qn = { E: "number", LN10: "number", LN2: "number", @@ -754,40 +750,40 @@ const eo = { SQRT1_2: "number", SQRT2: "number", "@@toStringTag": "string", - abs: s, - acos: s, - acosh: s, - asin: s, - asinh: s, - atan: s, - atanh: s, - atan2: s, - cbrt: s, - ceil: s, - clz32: s, - cos: s, - cosh: s, - exp: s, - expm1: s, - floor: s, - fround: s, - hypot: s, - imul: s, - log: s, - log1p: s, - log10: s, - log2: s, - max: s, - min: s, - pow: s, - round: s, - sign: s, - sin: s, - sinh: s, - sqrt: s, - tan: s, - tanh: s, - trunc: s, + abs: a, + acos: a, + acosh: a, + asin: a, + asinh: a, + atan: a, + atanh: a, + atan2: a, + cbrt: a, + ceil: a, + clz32: a, + cos: a, + cosh: a, + exp: a, + expm1: a, + floor: a, + fround: a, + hypot: a, + imul: a, + log: a, + log1p: a, + log10: a, + log2: a, + max: a, + min: a, + pow: a, + round: a, + sign: a, + sin: a, + sinh: a, + sqrt: a, + tan: a, + tanh: a, + trunc: a, // See https://github.com/Moddable-OpenSource/moddable/issues/523 idiv: !1, // See https://github.com/Moddable-OpenSource/moddable/issues/523 @@ -802,12 +798,12 @@ const eo = { mod: !1, // See https://github.com/Moddable-OpenSource/moddable/issues/523#issuecomment-1942904505 irandom: !1 -}, wr = { +}, Tr = { // ECMA https://tc39.es/ecma262 // The intrinsics object has no prototype to avoid conflicts. "[[Proto]]": null, // %ThrowTypeError% - "%ThrowTypeError%": s, + "%ThrowTypeError%": a, // *** The Global Object // *** Value Properties of the Global Object Infinity: "number", @@ -815,44 +811,44 @@ const eo = { undefined: "undefined", // *** Function Properties of the Global Object // eval - "%UniqueEval%": s, - isFinite: s, - isNaN: s, - parseFloat: s, - parseInt: s, - decodeURI: s, - decodeURIComponent: s, - encodeURI: s, - encodeURIComponent: s, + "%UniqueEval%": a, + isFinite: a, + isNaN: a, + parseFloat: a, + parseInt: a, + decodeURI: a, + decodeURIComponent: a, + encodeURI: a, + encodeURIComponent: a, // *** Fundamental Objects Object: { // Properties of the Object Constructor "[[Proto]]": "%FunctionPrototype%", - assign: s, - create: s, - defineProperties: s, - defineProperty: s, - entries: s, - freeze: s, - fromEntries: s, - getOwnPropertyDescriptor: s, - getOwnPropertyDescriptors: s, - getOwnPropertyNames: s, - getOwnPropertySymbols: s, - getPrototypeOf: s, - hasOwn: s, - is: s, - isExtensible: s, - isFrozen: s, - isSealed: s, - keys: s, - preventExtensions: s, + assign: a, + create: a, + defineProperties: a, + defineProperty: a, + entries: a, + freeze: a, + fromEntries: a, + getOwnPropertyDescriptor: a, + getOwnPropertyDescriptors: a, + getOwnPropertyNames: a, + getOwnPropertySymbols: a, + getPrototypeOf: a, + hasOwn: a, + is: a, + isExtensible: a, + isFrozen: a, + isSealed: a, + keys: a, + preventExtensions: a, prototype: "%ObjectPrototype%", - seal: s, - setPrototypeOf: s, - values: s, + seal: a, + setPrototypeOf: a, + values: a, // https://github.com/tc39/proposal-array-grouping - groupBy: s, + groupBy: a, // Seen on QuickJS __getClass: !1 }, @@ -860,20 +856,20 @@ const eo = { // Properties of the Object Prototype Object "[[Proto]]": null, constructor: "Object", - hasOwnProperty: s, - isPrototypeOf: s, - propertyIsEnumerable: s, - toLocaleString: s, - toString: s, - valueOf: s, + hasOwnProperty: a, + isPrototypeOf: a, + propertyIsEnumerable: a, + toLocaleString: a, + toString: a, + valueOf: a, // Annex B: Additional Properties of the Object.prototype Object // See note in header about the difference between [[Proto]] and --proto-- // special notations. - "--proto--": Ie, - __defineGetter__: s, - __defineSetter__: s, - __lookupGetter__: s, - __lookupSetter__: s + "--proto--": Oe, + __defineGetter__: a, + __defineSetter__: a, + __lookupGetter__: a, + __lookupSetter__: a }, "%UniqueFunction%": { // Properties of the Function Constructor @@ -885,12 +881,12 @@ const eo = { prototype: "%FunctionPrototype%" }, "%FunctionPrototype%": { - apply: s, - bind: s, - call: s, + apply: a, + bind: a, + call: a, constructor: "%InertFunction%", - toString: s, - "@@hasInstance": s, + toString: a, + "@@hasInstance": a, // proposed but not yet std. To be removed if there caller: !1, // proposed but not yet std. To be removed if there @@ -907,8 +903,8 @@ const eo = { }, "%BooleanPrototype%": { constructor: "Boolean", - toString: s, - valueOf: s + toString: a, + valueOf: a }, "%SharedSymbol%": { // Properties of the Symbol Constructor @@ -916,11 +912,11 @@ const eo = { asyncDispose: "symbol", asyncIterator: "symbol", dispose: "symbol", - for: s, + for: a, hasInstance: "symbol", isConcatSpreadable: "symbol", iterator: "symbol", - keyFor: s, + keyFor: a, match: "symbol", matchAll: "symbol", prototype: "%SymbolPrototype%", @@ -941,10 +937,10 @@ const eo = { "%SymbolPrototype%": { // Properties of the Symbol Prototype Object constructor: "%SharedSymbol%", - description: R, - toString: s, - valueOf: s, - "@@toPrimitive": s, + description: M, + toString: a, + valueOf: a, + "@@toPrimitive": a, "@@toStringTag": "string" }, "%InitialError%": { @@ -952,89 +948,89 @@ const eo = { "[[Proto]]": "%FunctionPrototype%", prototype: "%ErrorPrototype%", // Non standard, v8 only, used by tap - captureStackTrace: s, + captureStackTrace: a, // Non standard, v8 only, used by tap, tamed to accessor - stackTraceLimit: Ie, + stackTraceLimit: Oe, // Non standard, v8 only, used by several, tamed to accessor - prepareStackTrace: Ie + prepareStackTrace: Oe }, "%SharedError%": { // Properties of the Error Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%ErrorPrototype%", // Non standard, v8 only, used by tap - captureStackTrace: s, + captureStackTrace: a, // Non standard, v8 only, used by tap, tamed to accessor - stackTraceLimit: Ie, + stackTraceLimit: Oe, // Non standard, v8 only, used by several, tamed to accessor - prepareStackTrace: Ie + prepareStackTrace: Oe }, "%ErrorPrototype%": { constructor: "%SharedError%", message: "string", name: "string", - toString: s, + toString: a, // proposed de-facto, assumed TODO // Seen on FF Nightly 88.0a1 at: !1, // Seen on FF and XS - stack: Ie, + stack: Oe, // Superfluously present in some versions of V8. // https://github.com/tc39/notes/blob/master/meetings/2021-10/oct-26.md#:~:text=However%2C%20Chrome%2093,and%20node%2016.11. cause: !1 }, // NativeError - EvalError: ot("%EvalErrorPrototype%"), - RangeError: ot("%RangeErrorPrototype%"), - ReferenceError: ot("%ReferenceErrorPrototype%"), - SyntaxError: ot("%SyntaxErrorPrototype%"), - TypeError: ot("%TypeErrorPrototype%"), - URIError: ot("%URIErrorPrototype%"), + EvalError: it("%EvalErrorPrototype%"), + RangeError: it("%RangeErrorPrototype%"), + ReferenceError: it("%ReferenceErrorPrototype%"), + SyntaxError: it("%SyntaxErrorPrototype%"), + TypeError: it("%TypeErrorPrototype%"), + URIError: it("%URIErrorPrototype%"), // https://github.com/endojs/endo/issues/550 - AggregateError: ot("%AggregateErrorPrototype%"), - "%EvalErrorPrototype%": st("EvalError"), - "%RangeErrorPrototype%": st("RangeError"), - "%ReferenceErrorPrototype%": st("ReferenceError"), - "%SyntaxErrorPrototype%": st("SyntaxError"), - "%TypeErrorPrototype%": st("TypeError"), - "%URIErrorPrototype%": st("URIError"), + AggregateError: it("%AggregateErrorPrototype%"), + "%EvalErrorPrototype%": ct("EvalError"), + "%RangeErrorPrototype%": ct("RangeError"), + "%ReferenceErrorPrototype%": ct("ReferenceError"), + "%SyntaxErrorPrototype%": ct("SyntaxError"), + "%TypeErrorPrototype%": ct("TypeError"), + "%URIErrorPrototype%": ct("URIError"), // https://github.com/endojs/endo/issues/550 - "%AggregateErrorPrototype%": st("AggregateError"), + "%AggregateErrorPrototype%": ct("AggregateError"), // *** Numbers and Dates Number: { // Properties of the Number Constructor "[[Proto]]": "%FunctionPrototype%", EPSILON: "number", - isFinite: s, - isInteger: s, - isNaN: s, - isSafeInteger: s, + isFinite: a, + isInteger: a, + isNaN: a, + isSafeInteger: a, MAX_SAFE_INTEGER: "number", MAX_VALUE: "number", MIN_SAFE_INTEGER: "number", MIN_VALUE: "number", NaN: "number", NEGATIVE_INFINITY: "number", - parseFloat: s, - parseInt: s, + parseFloat: a, + parseInt: a, POSITIVE_INFINITY: "number", prototype: "%NumberPrototype%" }, "%NumberPrototype%": { // Properties of the Number Prototype Object constructor: "Number", - toExponential: s, - toFixed: s, - toLocaleString: s, - toPrecision: s, - toString: s, - valueOf: s + toExponential: a, + toFixed: a, + toLocaleString: a, + toPrecision: a, + toString: a, + valueOf: a }, BigInt: { // Properties of the BigInt Constructor "[[Proto]]": "%FunctionPrototype%", - asIntN: s, - asUintN: s, + asIntN: a, + asUintN: a, prototype: "%BigIntPrototype%", // See https://github.com/Moddable-OpenSource/moddable/issues/523 bitLength: !1, @@ -1067,174 +1063,174 @@ const eo = { }, "%BigIntPrototype%": { constructor: "BigInt", - toLocaleString: s, - toString: s, - valueOf: s, + toLocaleString: a, + toString: a, + valueOf: a, "@@toStringTag": "string" }, "%InitialMath%": { - ...eo, + ...Qn, // `%InitialMath%.random()` has the standard unsafe behavior - random: s + random: a }, "%SharedMath%": { - ...eo, + ...Qn, // `%SharedMath%.random()` is tamed to always throw - random: s + random: a }, "%InitialDate%": { // Properties of the Date Constructor "[[Proto]]": "%FunctionPrototype%", - now: s, - parse: s, + now: a, + parse: a, prototype: "%DatePrototype%", - UTC: s + UTC: a }, "%SharedDate%": { // Properties of the Date Constructor "[[Proto]]": "%FunctionPrototype%", // `%SharedDate%.now()` is tamed to always throw - now: s, - parse: s, + now: a, + parse: a, prototype: "%DatePrototype%", - UTC: s + UTC: a }, "%DatePrototype%": { constructor: "%SharedDate%", - getDate: s, - getDay: s, - getFullYear: s, - getHours: s, - getMilliseconds: s, - getMinutes: s, - getMonth: s, - getSeconds: s, - getTime: s, - getTimezoneOffset: s, - getUTCDate: s, - getUTCDay: s, - getUTCFullYear: s, - getUTCHours: s, - getUTCMilliseconds: s, - getUTCMinutes: s, - getUTCMonth: s, - getUTCSeconds: s, - setDate: s, - setFullYear: s, - setHours: s, - setMilliseconds: s, - setMinutes: s, - setMonth: s, - setSeconds: s, - setTime: s, - setUTCDate: s, - setUTCFullYear: s, - setUTCHours: s, - setUTCMilliseconds: s, - setUTCMinutes: s, - setUTCMonth: s, - setUTCSeconds: s, - toDateString: s, - toISOString: s, - toJSON: s, - toLocaleDateString: s, - toLocaleString: s, - toLocaleTimeString: s, - toString: s, - toTimeString: s, - toUTCString: s, - valueOf: s, - "@@toPrimitive": s, + getDate: a, + getDay: a, + getFullYear: a, + getHours: a, + getMilliseconds: a, + getMinutes: a, + getMonth: a, + getSeconds: a, + getTime: a, + getTimezoneOffset: a, + getUTCDate: a, + getUTCDay: a, + getUTCFullYear: a, + getUTCHours: a, + getUTCMilliseconds: a, + getUTCMinutes: a, + getUTCMonth: a, + getUTCSeconds: a, + setDate: a, + setFullYear: a, + setHours: a, + setMilliseconds: a, + setMinutes: a, + setMonth: a, + setSeconds: a, + setTime: a, + setUTCDate: a, + setUTCFullYear: a, + setUTCHours: a, + setUTCMilliseconds: a, + setUTCMinutes: a, + setUTCMonth: a, + setUTCSeconds: a, + toDateString: a, + toISOString: a, + toJSON: a, + toLocaleDateString: a, + toLocaleString: a, + toLocaleTimeString: a, + toString: a, + toTimeString: a, + toUTCString: a, + valueOf: a, + "@@toPrimitive": a, // Annex B: Additional Properties of the Date.prototype Object - getYear: s, - setYear: s, - toGMTString: s + getYear: a, + setYear: a, + toGMTString: a }, // Text Processing String: { // Properties of the String Constructor "[[Proto]]": "%FunctionPrototype%", - fromCharCode: s, - fromCodePoint: s, + fromCharCode: a, + fromCodePoint: a, prototype: "%StringPrototype%", - raw: s, + raw: a, // See https://github.com/Moddable-OpenSource/moddable/issues/523 fromArrayBuffer: !1 }, "%StringPrototype%": { // Properties of the String Prototype Object length: "number", - at: s, - charAt: s, - charCodeAt: s, - codePointAt: s, - concat: s, + at: a, + charAt: a, + charCodeAt: a, + codePointAt: a, + concat: a, constructor: "String", - endsWith: s, - includes: s, - indexOf: s, - lastIndexOf: s, - localeCompare: s, - match: s, - matchAll: s, - normalize: s, - padEnd: s, - padStart: s, - repeat: s, - replace: s, - replaceAll: s, + endsWith: a, + includes: a, + indexOf: a, + lastIndexOf: a, + localeCompare: a, + match: a, + matchAll: a, + normalize: a, + padEnd: a, + padStart: a, + repeat: a, + replace: a, + replaceAll: a, // ES2021 - search: s, - slice: s, - split: s, - startsWith: s, - substring: s, - toLocaleLowerCase: s, - toLocaleUpperCase: s, - toLowerCase: s, - toString: s, - toUpperCase: s, - trim: s, - trimEnd: s, - trimStart: s, - valueOf: s, - "@@iterator": s, + search: a, + slice: a, + split: a, + startsWith: a, + substring: a, + toLocaleLowerCase: a, + toLocaleUpperCase: a, + toLowerCase: a, + toString: a, + toUpperCase: a, + trim: a, + trimEnd: a, + trimStart: a, + valueOf: a, + "@@iterator": a, // Annex B: Additional Properties of the String.prototype Object - substr: s, - anchor: s, - big: s, - blink: s, - bold: s, - fixed: s, - fontcolor: s, - fontsize: s, - italics: s, - link: s, - small: s, - strike: s, - sub: s, - sup: s, - trimLeft: s, - trimRight: s, + substr: a, + anchor: a, + big: a, + blink: a, + bold: a, + fixed: a, + fontcolor: a, + fontsize: a, + italics: a, + link: a, + small: a, + strike: a, + sub: a, + sup: a, + trimLeft: a, + trimRight: a, // See https://github.com/Moddable-OpenSource/moddable/issues/523 compare: !1, // https://github.com/tc39/proposal-is-usv-string - isWellFormed: s, - toWellFormed: s, - unicodeSets: s, + isWellFormed: a, + toWellFormed: a, + unicodeSets: a, // Seen on QuickJS __quote: !1 }, "%StringIteratorPrototype%": { "[[Proto]]": "%IteratorPrototype%", - next: s, + next: a, "@@toStringTag": "string" }, "%InitialRegExp%": { // Properties of the RegExp Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%RegExpPrototype%", - "@@species": R, + "@@species": M, // The https://github.com/tc39/proposal-regexp-legacy-features // are all optional, unsafe, and omitted input: !1, @@ -1261,29 +1257,29 @@ const eo = { // Properties of the RegExp Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%RegExpPrototype%", - "@@species": R + "@@species": M }, "%RegExpPrototype%": { // Properties of the RegExp Prototype Object constructor: "%SharedRegExp%", - exec: s, - dotAll: R, - flags: R, - global: R, - hasIndices: R, - ignoreCase: R, - "@@match": s, - "@@matchAll": s, - multiline: R, - "@@replace": s, - "@@search": s, - source: R, - "@@split": s, - sticky: R, - test: s, - toString: s, - unicode: R, - unicodeSets: R, + exec: a, + dotAll: M, + flags: M, + global: M, + hasIndices: M, + ignoreCase: M, + "@@match": a, + "@@matchAll": a, + multiline: M, + "@@replace": a, + "@@search": a, + source: M, + "@@split": a, + sticky: M, + test: a, + toString: a, + unicode: M, + unicodeSets: M, // Annex B: Additional Properties of the RegExp.prototype Object compile: !1 // UNSAFE and suppressed. @@ -1291,61 +1287,61 @@ const eo = { "%RegExpStringIteratorPrototype%": { // The %RegExpStringIteratorPrototype% Object "[[Proto]]": "%IteratorPrototype%", - next: s, + next: a, "@@toStringTag": "string" }, // Indexed Collections Array: { // Properties of the Array Constructor "[[Proto]]": "%FunctionPrototype%", - from: s, - isArray: s, - of: s, + from: a, + isArray: a, + of: a, prototype: "%ArrayPrototype%", - "@@species": R, + "@@species": M, // Stage 3: // https://tc39.es/proposal-relative-indexing-method/ - at: s, + at: a, // https://tc39.es/proposal-array-from-async/ - fromAsync: s + fromAsync: a }, "%ArrayPrototype%": { // Properties of the Array Prototype Object - at: s, + at: a, length: "number", - concat: s, + concat: a, constructor: "Array", - copyWithin: s, - entries: s, - every: s, - fill: s, - filter: s, - find: s, - findIndex: s, - flat: s, - flatMap: s, - forEach: s, - includes: s, - indexOf: s, - join: s, - keys: s, - lastIndexOf: s, - map: s, - pop: s, - push: s, - reduce: s, - reduceRight: s, - reverse: s, - shift: s, - slice: s, - some: s, - sort: s, - splice: s, - toLocaleString: s, - toString: s, - unshift: s, - values: s, - "@@iterator": s, + copyWithin: a, + entries: a, + every: a, + fill: a, + filter: a, + find: a, + findIndex: a, + flat: a, + flatMap: a, + forEach: a, + includes: a, + indexOf: a, + join: a, + keys: a, + lastIndexOf: a, + map: a, + pop: a, + push: a, + reduce: a, + reduceRight: a, + reverse: a, + shift: a, + slice: a, + some: a, + sort: a, + splice: a, + toLocaleString: a, + toString: a, + unshift: a, + values: a, + "@@iterator": a, "@@unscopables": { "[[Proto]]": null, copyWithin: "boolean", @@ -1375,174 +1371,190 @@ const eo = { groupBy: "boolean" }, // See https://github.com/tc39/proposal-array-find-from-last - findLast: s, - findLastIndex: s, + findLast: a, + findLastIndex: a, // https://github.com/tc39/proposal-change-array-by-copy - toReversed: s, - toSorted: s, - toSpliced: s, - with: s, + toReversed: a, + toSorted: a, + toSpliced: a, + with: a, // https://github.com/tc39/proposal-array-grouping - group: s, + group: a, // Not in proposal? Where? - groupToMap: s, + groupToMap: a, // Not in proposal? Where? - groupBy: s + groupBy: a }, "%ArrayIteratorPrototype%": { // The %ArrayIteratorPrototype% Object "[[Proto]]": "%IteratorPrototype%", - next: s, + next: a, "@@toStringTag": "string" }, // *** TypedArray Objects "%TypedArray%": { // Properties of the %TypedArray% Intrinsic Object "[[Proto]]": "%FunctionPrototype%", - from: s, - of: s, + from: a, + of: a, prototype: "%TypedArrayPrototype%", - "@@species": R + "@@species": M }, "%TypedArrayPrototype%": { - at: s, - buffer: R, - byteLength: R, - byteOffset: R, + at: a, + buffer: M, + byteLength: M, + byteOffset: M, constructor: "%TypedArray%", - copyWithin: s, - entries: s, - every: s, - fill: s, - filter: s, - find: s, - findIndex: s, - forEach: s, - includes: s, - indexOf: s, - join: s, - keys: s, - lastIndexOf: s, - length: R, - map: s, - reduce: s, - reduceRight: s, - reverse: s, - set: s, - slice: s, - some: s, - sort: s, - subarray: s, - toLocaleString: s, - toString: s, - values: s, - "@@iterator": s, - "@@toStringTag": R, + copyWithin: a, + entries: a, + every: a, + fill: a, + filter: a, + find: a, + findIndex: a, + forEach: a, + includes: a, + indexOf: a, + join: a, + keys: a, + lastIndexOf: a, + length: M, + map: a, + reduce: a, + reduceRight: a, + reverse: a, + set: a, + slice: a, + some: a, + sort: a, + subarray: a, + toLocaleString: a, + toString: a, + values: a, + "@@iterator": a, + "@@toStringTag": M, // See https://github.com/tc39/proposal-array-find-from-last - findLast: s, - findLastIndex: s, + findLast: a, + findLastIndex: a, // https://github.com/tc39/proposal-change-array-by-copy - toReversed: s, - toSorted: s, - with: s + toReversed: a, + toSorted: a, + with: a }, // The TypedArray Constructors - BigInt64Array: ge("%BigInt64ArrayPrototype%"), - BigUint64Array: ge("%BigUint64ArrayPrototype%"), + BigInt64Array: xe("%BigInt64ArrayPrototype%"), + BigUint64Array: xe("%BigUint64ArrayPrototype%"), // https://github.com/tc39/proposal-float16array - Float16Array: ge("%Float16ArrayPrototype%"), - Float32Array: ge("%Float32ArrayPrototype%"), - Float64Array: ge("%Float64ArrayPrototype%"), - Int16Array: ge("%Int16ArrayPrototype%"), - Int32Array: ge("%Int32ArrayPrototype%"), - Int8Array: ge("%Int8ArrayPrototype%"), - Uint16Array: ge("%Uint16ArrayPrototype%"), - Uint32Array: ge("%Uint32ArrayPrototype%"), - Uint8Array: ge("%Uint8ArrayPrototype%"), - Uint8ClampedArray: ge("%Uint8ClampedArrayPrototype%"), - "%BigInt64ArrayPrototype%": ye("BigInt64Array"), - "%BigUint64ArrayPrototype%": ye("BigUint64Array"), + Float16Array: xe("%Float16ArrayPrototype%"), + Float32Array: xe("%Float32ArrayPrototype%"), + Float64Array: xe("%Float64ArrayPrototype%"), + Int16Array: xe("%Int16ArrayPrototype%"), + Int32Array: xe("%Int32ArrayPrototype%"), + Int8Array: xe("%Int8ArrayPrototype%"), + Uint16Array: xe("%Uint16ArrayPrototype%"), + Uint32Array: xe("%Uint32ArrayPrototype%"), + Uint8ClampedArray: xe("%Uint8ClampedArrayPrototype%"), + Uint8Array: { + ...xe("%Uint8ArrayPrototype%"), + // https://github.com/tc39/proposal-arraybuffer-base64 + fromBase64: a, + // https://github.com/tc39/proposal-arraybuffer-base64 + fromHex: a + }, + "%BigInt64ArrayPrototype%": Se("BigInt64Array"), + "%BigUint64ArrayPrototype%": Se("BigUint64Array"), // https://github.com/tc39/proposal-float16array - "%Float16ArrayPrototype%": ye("Float16Array"), - "%Float32ArrayPrototype%": ye("Float32Array"), - "%Float64ArrayPrototype%": ye("Float64Array"), - "%Int16ArrayPrototype%": ye("Int16Array"), - "%Int32ArrayPrototype%": ye("Int32Array"), - "%Int8ArrayPrototype%": ye("Int8Array"), - "%Uint16ArrayPrototype%": ye("Uint16Array"), - "%Uint32ArrayPrototype%": ye("Uint32Array"), - "%Uint8ArrayPrototype%": ye("Uint8Array"), - "%Uint8ClampedArrayPrototype%": ye("Uint8ClampedArray"), + "%Float16ArrayPrototype%": Se("Float16Array"), + "%Float32ArrayPrototype%": Se("Float32Array"), + "%Float64ArrayPrototype%": Se("Float64Array"), + "%Int16ArrayPrototype%": Se("Int16Array"), + "%Int32ArrayPrototype%": Se("Int32Array"), + "%Int8ArrayPrototype%": Se("Int8Array"), + "%Uint16ArrayPrototype%": Se("Uint16Array"), + "%Uint32ArrayPrototype%": Se("Uint32Array"), + "%Uint8ClampedArrayPrototype%": Se("Uint8ClampedArray"), + "%Uint8ArrayPrototype%": { + ...Se("Uint8Array"), + // https://github.com/tc39/proposal-arraybuffer-base64 + setFromBase64: a, + // https://github.com/tc39/proposal-arraybuffer-base64 + setFromHex: a, + // https://github.com/tc39/proposal-arraybuffer-base64 + toBase64: a, + // https://github.com/tc39/proposal-arraybuffer-base64 + toHex: a + }, // *** Keyed Collections Map: { // Properties of the Map Constructor "[[Proto]]": "%FunctionPrototype%", - "@@species": R, + "@@species": M, prototype: "%MapPrototype%", // https://github.com/tc39/proposal-array-grouping - groupBy: s + groupBy: a }, "%MapPrototype%": { - clear: s, + clear: a, constructor: "Map", - delete: s, - entries: s, - forEach: s, - get: s, - has: s, - keys: s, - set: s, - size: R, - values: s, - "@@iterator": s, + delete: a, + entries: a, + forEach: a, + get: a, + has: a, + keys: a, + set: a, + size: M, + values: a, + "@@iterator": a, "@@toStringTag": "string" }, "%MapIteratorPrototype%": { // The %MapIteratorPrototype% Object "[[Proto]]": "%IteratorPrototype%", - next: s, + next: a, "@@toStringTag": "string" }, Set: { // Properties of the Set Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%SetPrototype%", - "@@species": R, + "@@species": M, // Seen on QuickJS groupBy: !1 }, "%SetPrototype%": { - add: s, - clear: s, + add: a, + clear: a, constructor: "Set", - delete: s, - entries: s, - forEach: s, - has: s, - keys: s, - size: R, - values: s, - "@@iterator": s, + delete: a, + entries: a, + forEach: a, + has: a, + keys: a, + size: M, + values: a, + "@@iterator": a, "@@toStringTag": "string", // See https://github.com/tc39/proposal-set-methods - intersection: s, + intersection: a, // See https://github.com/tc39/proposal-set-methods - union: s, + union: a, // See https://github.com/tc39/proposal-set-methods - difference: s, + difference: a, // See https://github.com/tc39/proposal-set-methods - symmetricDifference: s, + symmetricDifference: a, // See https://github.com/tc39/proposal-set-methods - isSubsetOf: s, + isSubsetOf: a, // See https://github.com/tc39/proposal-set-methods - isSupersetOf: s, + isSupersetOf: a, // See https://github.com/tc39/proposal-set-methods - isDisjointFrom: s + isDisjointFrom: a }, "%SetIteratorPrototype%": { // The %SetIteratorPrototype% Object "[[Proto]]": "%IteratorPrototype%", - next: s, + next: a, "@@toStringTag": "string" }, WeakMap: { @@ -1552,10 +1564,10 @@ const eo = { }, "%WeakMapPrototype%": { constructor: "WeakMap", - delete: s, - get: s, - has: s, - set: s, + delete: a, + get: a, + has: a, + set: a, "@@toStringTag": "string" }, WeakSet: { @@ -1564,39 +1576,39 @@ const eo = { prototype: "%WeakSetPrototype%" }, "%WeakSetPrototype%": { - add: s, + add: a, constructor: "WeakSet", - delete: s, - has: s, + delete: a, + has: a, "@@toStringTag": "string" }, // *** Structured Data ArrayBuffer: { // Properties of the ArrayBuffer Constructor "[[Proto]]": "%FunctionPrototype%", - isView: s, + isView: a, prototype: "%ArrayBufferPrototype%", - "@@species": R, + "@@species": M, // See https://github.com/Moddable-OpenSource/moddable/issues/523 fromString: !1, // See https://github.com/Moddable-OpenSource/moddable/issues/523 fromBigInt: !1 }, "%ArrayBufferPrototype%": { - byteLength: R, + byteLength: M, constructor: "ArrayBuffer", - slice: s, + slice: a, "@@toStringTag": "string", // See https://github.com/Moddable-OpenSource/moddable/issues/523 concat: !1, // See https://github.com/tc39/proposal-resizablearraybuffer - transfer: s, - resize: s, - resizable: R, - maxByteLength: R, + transfer: a, + resize: a, + resizable: M, + maxByteLength: M, // https://github.com/tc39/proposal-arraybuffer-transfer - transferToFixedLength: s, - detached: R + transferToFixedLength: a, + detached: M }, // SharedArrayBuffer Objects SharedArrayBuffer: !1, @@ -1611,46 +1623,46 @@ const eo = { prototype: "%DataViewPrototype%" }, "%DataViewPrototype%": { - buffer: R, - byteLength: R, - byteOffset: R, + buffer: M, + byteLength: M, + byteOffset: M, constructor: "DataView", - getBigInt64: s, - getBigUint64: s, + getBigInt64: a, + getBigUint64: a, // https://github.com/tc39/proposal-float16array - getFloat16: s, - getFloat32: s, - getFloat64: s, - getInt8: s, - getInt16: s, - getInt32: s, - getUint8: s, - getUint16: s, - getUint32: s, - setBigInt64: s, - setBigUint64: s, + getFloat16: a, + getFloat32: a, + getFloat64: a, + getInt8: a, + getInt16: a, + getInt32: a, + getUint8: a, + getUint16: a, + getUint32: a, + setBigInt64: a, + setBigUint64: a, // https://github.com/tc39/proposal-float16array - setFloat16: s, - setFloat32: s, - setFloat64: s, - setInt8: s, - setInt16: s, - setInt32: s, - setUint8: s, - setUint16: s, - setUint32: s, + setFloat16: a, + setFloat32: a, + setFloat64: a, + setInt8: a, + setInt16: a, + setInt32: a, + setUint8: a, + setUint16: a, + setUint32: a, "@@toStringTag": "string" }, // Atomics Atomics: !1, // UNSAFE and suppressed. JSON: { - parse: s, - stringify: s, + parse: a, + stringify: a, "@@toStringTag": "string", // https://github.com/tc39/proposal-json-parse-with-source/ - rawJSON: s, - isRawJSON: s + rawJSON: a, + isRawJSON: a }, // *** Control Abstraction Objects // https://github.com/tc39/proposal-iterator-helpers @@ -1658,41 +1670,41 @@ const eo = { // Properties of the Iterator Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%IteratorPrototype%", - from: s + from: a }, "%IteratorPrototype%": { // The %IteratorPrototype% Object - "@@iterator": s, + "@@iterator": a, // https://github.com/tc39/proposal-iterator-helpers constructor: "Iterator", - map: s, - filter: s, - take: s, - drop: s, - flatMap: s, - reduce: s, - toArray: s, - forEach: s, - some: s, - every: s, - find: s, + map: a, + filter: a, + take: a, + drop: a, + flatMap: a, + reduce: a, + toArray: a, + forEach: a, + some: a, + every: a, + find: a, "@@toStringTag": "string", // https://github.com/tc39/proposal-async-iterator-helpers - toAsync: s, + toAsync: a, // See https://github.com/Moddable-OpenSource/moddable/issues/523#issuecomment-1942904505 "@@dispose": !1 }, // https://github.com/tc39/proposal-iterator-helpers "%WrapForValidIteratorPrototype%": { "[[Proto]]": "%IteratorPrototype%", - next: s, - return: s + next: a, + return: a }, // https://github.com/tc39/proposal-iterator-helpers "%IteratorHelperPrototype%": { "[[Proto]]": "%IteratorPrototype%", - next: s, - return: s, + next: a, + return: a, "@@toStringTag": "string" }, // https://github.com/tc39/proposal-async-iterator-helpers @@ -1700,24 +1712,24 @@ const eo = { // Properties of the Iterator Constructor "[[Proto]]": "%FunctionPrototype%", prototype: "%AsyncIteratorPrototype%", - from: s + from: a }, "%AsyncIteratorPrototype%": { // The %AsyncIteratorPrototype% Object - "@@asyncIterator": s, + "@@asyncIterator": a, // https://github.com/tc39/proposal-async-iterator-helpers constructor: "AsyncIterator", - map: s, - filter: s, - take: s, - drop: s, - flatMap: s, - reduce: s, - toArray: s, - forEach: s, - some: s, - every: s, - find: s, + map: a, + filter: a, + take: a, + drop: a, + flatMap: a, + reduce: a, + toArray: a, + forEach: a, + some: a, + every: a, + find: a, "@@toStringTag": "string", // See https://github.com/Moddable-OpenSource/moddable/issues/523#issuecomment-1942904505 "@@asyncDispose": !1 @@ -1725,14 +1737,14 @@ const eo = { // https://github.com/tc39/proposal-async-iterator-helpers "%WrapForValidAsyncIteratorPrototype%": { "[[Proto]]": "%AsyncIteratorPrototype%", - next: s, - return: s + next: a, + return: a }, // https://github.com/tc39/proposal-async-iterator-helpers "%AsyncIteratorHelperPrototype%": { "[[Proto]]": "%AsyncIteratorPrototype%", - next: s, - return: s, + next: a, + return: a, "@@toStringTag": "string" }, "%InertGeneratorFunction%": { @@ -1767,18 +1779,18 @@ const eo = { // Properties of the Generator Prototype Object "[[Proto]]": "%IteratorPrototype%", constructor: "%Generator%", - next: s, - return: s, - throw: s, + next: a, + return: a, + throw: a, "@@toStringTag": "string" }, "%AsyncGeneratorPrototype%": { // Properties of the AsyncGenerator Prototype Object "[[Proto]]": "%AsyncIteratorPrototype%", constructor: "%AsyncGenerator%", - next: s, - return: s, - throw: s, + next: a, + return: a, + throw: a, "@@toStringTag": "string" }, // TODO: To be replaced with Promise.delegate @@ -1792,41 +1804,41 @@ const eo = { // another whitelist change to update to the current proposed standard. HandledPromise: { "[[Proto]]": "Promise", - applyFunction: s, - applyFunctionSendOnly: s, - applyMethod: s, - applyMethodSendOnly: s, - get: s, - getSendOnly: s, + applyFunction: a, + applyFunctionSendOnly: a, + applyMethod: a, + applyMethodSendOnly: a, + get: a, + getSendOnly: a, prototype: "%PromisePrototype%", - resolve: s + resolve: a }, Promise: { // Properties of the Promise Constructor "[[Proto]]": "%FunctionPrototype%", - all: s, - allSettled: s, + all: a, + allSettled: a, // https://github.com/Agoric/SES-shim/issues/550 - any: s, + any: a, prototype: "%PromisePrototype%", - race: s, - reject: s, - resolve: s, + race: a, + reject: a, + resolve: a, // https://github.com/tc39/proposal-promise-with-resolvers - withResolvers: s, - "@@species": R + withResolvers: a, + "@@species": M }, "%PromisePrototype%": { // Properties of the Promise Prototype Object - catch: s, + catch: a, constructor: "Promise", - finally: s, - then: s, + finally: a, + then: a, "@@toStringTag": "string", // Non-standard, used in node to prevent async_hooks from breaking - "UniqueSymbol(async_id_symbol)": Ie, - "UniqueSymbol(trigger_async_id_symbol)": Ie, - "UniqueSymbol(destroyed)": Ie + "UniqueSymbol(async_id_symbol)": Oe, + "UniqueSymbol(trigger_async_id_symbol)": Oe, + "UniqueSymbol(destroyed)": Oe }, "%InertAsyncFunction%": { // Properties of the AsyncFunction Constructor @@ -1847,95 +1859,95 @@ const eo = { Reflect: { // The Reflect Object // Not a function object. - apply: s, - construct: s, - defineProperty: s, - deleteProperty: s, - get: s, - getOwnPropertyDescriptor: s, - getPrototypeOf: s, - has: s, - isExtensible: s, - ownKeys: s, - preventExtensions: s, - set: s, - setPrototypeOf: s, + apply: a, + construct: a, + defineProperty: a, + deleteProperty: a, + get: a, + getOwnPropertyDescriptor: a, + getPrototypeOf: a, + has: a, + isExtensible: a, + ownKeys: a, + preventExtensions: a, + set: a, + setPrototypeOf: a, "@@toStringTag": "string" }, Proxy: { // Properties of the Proxy Constructor "[[Proto]]": "%FunctionPrototype%", - revocable: s + revocable: a }, // Appendix B // Annex B: Additional Properties of the Global Object - escape: s, - unescape: s, + escape: a, + unescape: a, // Proposed "%UniqueCompartment%": { "[[Proto]]": "%FunctionPrototype%", prototype: "%CompartmentPrototype%", - toString: s + toString: a }, "%InertCompartment%": { "[[Proto]]": "%FunctionPrototype%", prototype: "%CompartmentPrototype%", - toString: s + toString: a }, "%CompartmentPrototype%": { constructor: "%InertCompartment%", - evaluate: s, - globalThis: R, - name: R, - import: Xn, - load: Xn, - importNow: s, - module: s, + evaluate: a, + globalThis: M, + name: M, + import: Jn, + load: Jn, + importNow: a, + module: a, "@@toStringTag": "string" }, - lockdown: s, - harden: { ...s, isFake: "boolean" }, - "%InitialGetStackString%": s -}, Ha = (t) => typeof t == "function"; -function Va(t, e, r) { - if (oe(t, e)) { - const n = J(t, e); - if (!n || !Nr(n.value, r.value) || n.get !== r.get || n.set !== r.set || n.writable !== r.writable || n.enumerable !== r.enumerable || n.configurable !== r.configurable) + lockdown: a, + harden: { ...a, isFake: "boolean" }, + "%InitialGetStackString%": a +}, Va = (t) => typeof t == "function"; +function Ha(t, e, r) { + if (de(t, e)) { + const n = ne(t, e); + if (!n || !Dr(n.value, r.value) || n.get !== r.get || n.set !== r.set || n.writable !== r.writable || n.enumerable !== r.enumerable || n.configurable !== r.configurable) throw v(`Conflicting definitions of ${e}`); } - M(t, e, r); + U(t, e, r); } function Wa(t, e) { - for (const [r, n] of re(e)) - Va(t, r, n); + for (const [r, n] of ge(e)) + Ha(t, r, n); } function os(t, e) { const r = { __proto__: null }; - for (const [n, o] of re(e)) - oe(t, n) && (r[o] = t[n]); + for (const [n, o] of ge(e)) + de(t, n) && (r[o] = t[n]); return r; } const ss = () => { - const t = Z(null); + const t = H(null); let e; const r = (c) => { Wa(t, Ze(c)); }; y(r); const n = () => { - for (const [c, l] of re(t)) { - if (!Ye(l) || !oe(l, "prototype")) + for (const [c, l] of ge(t)) { + if (!ke(l) || !de(l, "prototype")) continue; - const u = wr[c]; + const u = Tr[c]; if (typeof u != "object") throw v(`Expected permit object at whitelist.${c}`); const d = u.prototype; if (!d) throw v(`${c}.prototype property not whitelisted`); - if (typeof d != "string" || !oe(wr, d)) + if (typeof d != "string" || !de(Tr, d)) throw v(`Unrecognized ${c}.prototype whitelist entry`); const f = l.prototype; - if (oe(t, d)) { + if (de(t, d)) { if (t[d] !== f) throw v(`Conflicting bindings of ${d}`); continue; @@ -1944,33 +1956,33 @@ const ss = () => { } }; y(n); - const o = () => (y(t), e = new $t(Ke(ko(t), Ha)), t); + const o = () => (y(t), e = new Mt(et(ko(t), Va)), t); y(o); - const a = (c) => { + const s = (c) => { if (!e) throw v( "isPseudoNative can only be called after finalIntrinsics" ); - return or(e, c); + return cr(e, c); }; - y(a); + y(s); const i = { addIntrinsics: r, completePrototypes: n, finalIntrinsics: o, - isPseudoNative: a + isPseudoNative: s }; - return y(i), r(es), r(os(k, ts)), i; + return y(i), r(es), r(os(T, ts)), i; }, qa = (t) => { const { addIntrinsics: e, finalIntrinsics: r } = ss(); return e(os(t, rs)), r(); }; function Ka(t, e) { let r = !1; - const n = (h, ...p) => (r || (console.groupCollapsed("Removing unpermitted intrinsics"), r = !0), console[h](...p)), o = ["undefined", "boolean", "number", "string", "symbol"], a = new Pe( - St ? se( - Ke( - re(wr["%SharedSymbol%"]), + const n = (h, ...p) => (r || (console.groupCollapsed("Removing unpermitted intrinsics"), r = !0), console[h](...p)), o = ["undefined", "boolean", "number", "string", "symbol"], s = new Re( + St ? fe( + et( + ge(Tr["%SharedSymbol%"]), ([h, p]) => p === "symbol" && typeof St[h] == "symbol" ), ([h]) => [St[h], `@@${h}`] @@ -1979,21 +1991,21 @@ function Ka(t, e) { function i(h, p) { if (typeof p == "string") return p; - const m = Ue(a, p); + const m = He(s, p); if (typeof p == "symbol") { if (m) return m; { const _ = ea(p); - return _ !== void 0 ? `RegisteredSymbol(${_})` : `Unique${pe(p)}`; + return _ !== void 0 ? `RegisteredSymbol(${_})` : `Unique${be(p)}`; } } throw v(`Unexpected property name type ${h} ${p}`); } function c(h, p, m) { - if (!Ye(p)) + if (!ke(p)) throw v(`Object expected: ${h}, ${p}, ${m}`); - const _ = j(p); + const _ = V(p); if (!(_ === null && m === null)) { if (m !== void 0 && typeof m != "string") throw v(`Malformed whitelist permit ${h}.__proto__`); @@ -2008,12 +2020,12 @@ function Ka(t, e) { return !1; if (typeof _ == "string") { if (m === "prototype" || m === "constructor") { - if (oe(t, _)) { + if (de(t, _)) { if (p !== t[_]) throw v(`Does not match whitelist ${h}`); return !0; } - } else if (Mr(o, _)) { + } else if (Zr(o, _)) { if (typeof p !== _) throw v( `At ${h} expected ${_} not ${typeof p}` @@ -2024,81 +2036,81 @@ function Ka(t, e) { throw v(`Unexpected whitelist permit ${_} at ${h}`); } function u(h, p, m, _) { - const S = J(p, m); + const S = ne(p, m); if (!S) throw v(`Property ${m} not found at ${h}`); - if (oe(S, "value")) { - if (Qn(_)) + if (de(S, "value")) { + if (Xn(_)) throw v(`Accessor expected at ${h}`); return l(h, S.value, m, _); } - if (!Qn(_)) + if (!Xn(_)) throw v(`Accessor not expected at ${h}`); return l(`${h}`, S.get, m, _.get) && l(`${h}`, S.set, m, _.set); } function d(h, p, m) { const _ = m === "__proto__" ? "--proto--" : m; - if (oe(p, _)) + if (de(p, _)) return p[_]; - if (typeof h == "function" && oe(an, _)) - return an[_]; + if (typeof h == "function" && de(ln, _)) + return ln[_]; } function f(h, p, m) { if (p == null) return; const _ = m["[[Proto]]"]; c(h, p, _), typeof p == "function" && e(p); - for (const S of De(p)) { - const T = i(h, S), N = `${h}.${T}`, x = d(p, m, T); - if (!x || !u(N, p, S, x)) { - x !== !1 && n("warn", `Removing ${N}`); + for (const S of Ve(p)) { + const x = i(h, S), I = `${h}.${x}`, E = d(p, m, x); + if (!E || !u(I, p, S, E)) { + E !== !1 && n("warn", `Removing ${I}`); try { delete p[S]; - } catch (D) { + } catch (L) { if (S in p) { if (typeof p == "function" && S === "prototype" && (p.prototype = void 0, p.prototype === void 0)) { n( "warn", - `Tolerating undeletable ${N} === undefined` + `Tolerating undeletable ${I} === undefined` ); continue; } - n("error", `failed to delete ${N}`, D); + n("error", `failed to delete ${I}`, L); } else - n("error", `deleting ${N} threw`, D); - throw D; + n("error", `deleting ${I} threw`, L); + throw L; } } } } try { - f("intrinsics", t, wr); + f("intrinsics", t, Tr); } finally { r && console.groupEnd(); } } function Ya() { try { - ve.prototype.constructor("return 1"); + Ee.prototype.constructor("return 1"); } catch { return y({}); } const t = {}; function e(r, n, o) { - let a; + let s; try { - a = (0, eval)(o); + s = (0, eval)(o); } catch (l) { - if (l instanceof tr) + if (l instanceof sr) return; throw l; } - const i = j(a), c = function() { + const i = V(s), c = function() { throw v( "Function.prototype.constructor is not a valid constructor." ); }; - F(c, { + B(c, { prototype: { value: i }, name: { value: r, @@ -2106,9 +2118,9 @@ function Ya() { enumerable: !1, configurable: !0 } - }), F(i, { + }), B(i, { constructor: { value: c } - }), c !== ve.prototype.constructor && xo(c, ve.prototype.constructor), t[n] = c; + }), c !== Ee.prototype.constructor && Eo(c, Ee.prototype.constructor), t[n] = c; } return e("Function", "%InertFunction%", "(function(){})"), e( "GeneratorFunction", @@ -2127,7 +2139,7 @@ function Ya() { function Ja(t = "safe") { if (t !== "safe" && t !== "unsafe") throw v(`unrecognized dateTaming ${t}`); - const e = Hs, r = e.prototype, n = { + const e = Vs, r = e.prototype, n = { /** * `%SharedDate%.now()` throw a `TypeError` starting with "secure mode". * See https://github.com/endojs/endo/issues/910#issuecomment-1581855420 @@ -2138,7 +2150,7 @@ function Ja(t = "safe") { }, o = ({ powers: c = "none" } = {}) => { let l; return c === "original" ? l = function(...d) { - return new.target === void 0 ? ne(e, void 0, d) : mr(e, d, new.target); + return new.target === void 0 ? ue(e, void 0, d) : br(e, d, new.target); } : l = function(...d) { if (new.target === void 0) throw v( @@ -2148,8 +2160,8 @@ function Ja(t = "safe") { throw v( "secure mode Calling new %SharedDate%() with no arguments throws" ); - return mr(e, d, new.target); - }, F(l, { + return br(e, d, new.target); + }, B(l, { length: { value: 7 }, prototype: { value: r, @@ -2170,32 +2182,32 @@ function Ja(t = "safe") { configurable: !0 } }), l; - }, a = o({ powers: "original" }), i = o({ powers: "none" }); - return F(a, { + }, s = o({ powers: "original" }), i = o({ powers: "none" }); + return B(s, { now: { value: e.now, writable: !0, enumerable: !1, configurable: !0 } - }), F(i, { + }), B(i, { now: { value: n.now, writable: !0, enumerable: !1, configurable: !0 } - }), F(r, { + }), B(r, { constructor: { value: i } }), { - "%InitialDate%": a, + "%InitialDate%": s, "%SharedDate%": i }; } function Xa(t = "safe") { if (t !== "safe" && t !== "unsafe") throw v(`unrecognized mathTaming ${t}`); - const e = qs, r = e, { random: n, ...o } = Ze(e), i = Z(bn, { + const e = qs, r = e, { random: n, ...o } = Ze(e), i = H(_n, { ...o, random: { value: { @@ -2220,11 +2232,11 @@ function Xa(t = "safe") { function Qa(t = "safe") { if (t !== "safe" && t !== "unsafe") throw v(`unrecognized regExpTaming ${t}`); - const e = We.prototype, r = (a = {}) => { + const e = Xe.prototype, r = (s = {}) => { const i = function(...l) { - return new.target === void 0 ? We(...l) : mr(We, l, new.target); + return new.target === void 0 ? Xe(...l) : br(Xe, l, new.target); }; - if (F(i, { + if (B(i, { length: { value: 2 }, prototype: { value: e, @@ -2232,20 +2244,20 @@ function Qa(t = "safe") { enumerable: !1, configurable: !1 } - }), Vr) { - const c = J( - We, - Vr + }), Jr) { + const c = ne( + Xe, + Jr ); if (!c) throw v("no RegExp[Symbol.species] descriptor"); - F(i, { - [Vr]: c + B(i, { + [Jr]: c }); } return i; }, n = r(), o = r(); - return t !== "unsafe" && delete e.compile, F(e, { + return t !== "unsafe" && delete e.compile, B(e, { constructor: { value: o } }), { "%InitialRegExp%": n, @@ -2269,7 +2281,7 @@ const ei = { // https://github.com/tc39/proposal-iterator-helpers constructor: !0, // https://github.com/tc39/proposal-iterator-helpers - [qe]: !0 + [Qe]: !0 } }, as = { "%ObjectPrototype%": { @@ -2282,7 +2294,7 @@ const ei = { // set by "Google Analytics" concat: !0, // set by mobx generated code (old TS compiler?) - [rr]: !0 + [ar]: !0 // set by mobx generated code (old TS compiler?) }, // Function.prototype has no 'prototype' property to enable. @@ -2367,7 +2379,7 @@ const ei = { // https://github.com/tc39/proposal-iterator-helpers constructor: !0, // https://github.com/tc39/proposal-iterator-helpers - [qe]: !0 + [Qe]: !0 } }, ti = { ...as, @@ -2424,23 +2436,23 @@ const ei = { "%SetPrototype%": "*" }; function ri(t, e, r = []) { - const n = new Ct(r); + const n = new Ot(r); function o(u, d, f, h) { if ("value" in h && h.configurable) { - const { value: p } = h, m = En(n, f), { get: _, set: S } = J( + const { value: p } = h, m = xn(n, f), { get: _, set: S } = ne( { get [f]() { return p; }, - set [f](T) { + set [f](x) { if (d === this) throw v( - `Cannot assign to read only property '${pe( + `Cannot assign to read only property '${be( f )}' of '${u}'` ); - oe(this, f) ? this[f] = T : (m && console.error(v(`Override property ${f}`)), M(this, f, { - value: T, + de(this, f) ? this[f] = x : (m && console.error(v(`Override property ${f}`)), U(this, f, { + value: x, writable: !0, enumerable: !0, configurable: !0 @@ -2449,12 +2461,12 @@ function ri(t, e, r = []) { }, f ); - M(_, "originalValue", { + U(_, "originalValue", { value: p, writable: !1, enumerable: !1, configurable: !1 - }), M(d, f, { + }), U(d, f, { get: _, set: S, enumerable: h.enumerable, @@ -2462,25 +2474,25 @@ function ri(t, e, r = []) { }); } } - function a(u, d, f) { - const h = J(d, f); + function s(u, d, f) { + const h = ne(d, f); h && o(u, d, f, h); } function i(u, d) { const f = Ze(d); - f && ut(De(f), (h) => o(u, d, h, f[h])); + f && ft(Ve(f), (h) => o(u, d, h, f[h])); } function c(u, d, f) { - for (const h of De(f)) { - const p = J(d, h); + for (const h of Ve(f)) { + const p = ne(d, h); if (!p || p.get || p.set) continue; - const m = `${u}.${pe(h)}`, _ = f[h]; + const m = `${u}.${be(h)}`, _ = f[h]; if (_ === !0) - a(m, d, h); + s(m, d, h); else if (_ === "*") i(m, p.value); - else if (Ye(_)) + else if (ke(_)) c(m, p.value, _); else throw v(`Unexpected override enablement plan ${m}`); @@ -2505,7 +2517,7 @@ function ri(t, e, r = []) { } c("root", t, l); } -const { Fail: cn, quote: Sr } = z, ni = /^(\w*[a-z])Locale([A-Z]\w*)$/, is = { +const { Fail: un, quote: Ar } = ee, ni = /^(\w*[a-z])Locale([A-Z]\w*)$/, is = { // See https://tc39.es/ecma262/#sec-string.prototype.localecompare localeCompare(t) { if (this === null || this === void 0) @@ -2513,7 +2525,7 @@ const { Fail: cn, quote: Sr } = z, ni = /^(\w*[a-z])Locale([A-Z]\w*)$/, is = { 'Cannot localeCompare with null or undefined "this" value' ); const e = `${this}`, r = `${t}`; - return e < r ? -1 : e > r ? 1 : (e === r || cn`expected ${Sr(e)} and ${Sr(r)} to compare`, 0); + return e < r ? -1 : e > r ? 1 : (e === r || un`expected ${Ar(e)} and ${Ar(r)} to compare`, 0); }, toString() { return `${this}`; @@ -2523,22 +2535,22 @@ function ai(t, e = "safe") { if (e !== "safe" && e !== "unsafe") throw v(`unrecognized localeTaming ${e}`); if (e !== "unsafe") { - M(pe.prototype, "localeCompare", { + U(be.prototype, "localeCompare", { value: oi }); - for (const r of Dt(t)) { + for (const r of It(t)) { const n = t[r]; - if (Ye(n)) - for (const o of Dt(n)) { - const a = kn(ni, o); - if (a) { - typeof n[o] == "function" || cn`expected ${Sr(o)} to be a function`; - const i = `${a[1]}${a[2]}`, c = n[i]; - typeof c == "function" || cn`function ${Sr(i)} not found`, M(n, o, { value: c }); + if (ke(n)) + for (const o of It(n)) { + const s = En(ni, o); + if (s) { + typeof n[o] == "function" || un`expected ${Ar(o)} to be a function`; + const i = `${s[1]}${s[2]}`, c = n[i]; + typeof c == "function" || un`function ${Ar(i)} not found`, U(n, o, { value: c }); } } } - M(So.prototype, "toLocaleString", { + U(xo.prototype, "toLocaleString", { value: si }); } @@ -2547,32 +2559,32 @@ const ii = (t) => ({ eval(r) { return typeof r != "string" ? r : t(r); } -}).eval, { Fail: to } = z, ci = (t) => { +}).eval, { Fail: eo } = ee, ci = (t) => { const e = function(n) { - const o = `${gr(arguments) || ""}`, a = `${Rt(arguments, ",")}`; - new ve(a, ""), new ve(o); - const i = `(function anonymous(${a} + const o = `${wr(arguments) || ""}`, s = `${Ft(arguments, ",")}`; + new Ee(s, ""), new Ee(o); + const i = `(function anonymous(${s} ) { ${o} })`; return t(i); }; - return F(e, { + return B(e, { // Ensure that any function created in any evaluator in a realm is an // instance of Function in any evaluator of the same realm. prototype: { - value: ve.prototype, + value: Ee.prototype, writable: !1, enumerable: !1, configurable: !1 } - }), j(ve) === ve.prototype || to`Function prototype is the same accross compartments`, j(e) === ve.prototype || to`Function constructor prototype is the same accross compartments`, e; + }), V(Ee) === Ee.prototype || eo`Function prototype is the same accross compartments`, V(e) === Ee.prototype || eo`Function constructor prototype is the same accross compartments`, e; }, li = (t) => { - M( + U( t, Qs, y( - $r(Z(null), { + Fr(H(null), { set: y(() => { throw v( "Cannot set Symbol.unscopables of a Compartment's globalThis" @@ -2584,8 +2596,8 @@ ${o} ) ); }, cs = (t) => { - for (const [e, r] of re(es)) - M(t, e, { + for (const [e, r] of ge(es)) + U(t, e, { value: r, writable: !1, enumerable: !1, @@ -2595,43 +2607,45 @@ ${o} intrinsics: e, newGlobalPropertyNames: r, makeCompartmentConstructor: n, - markVirtualizedNativeFunction: o + markVirtualizedNativeFunction: o, + parentCompartment: s }) => { - for (const [i, c] of re(ts)) - oe(e, c) && M(t, i, { - value: e[c], + for (const [c, l] of ge(ts)) + de(e, l) && U(t, c, { + value: e[l], writable: !0, enumerable: !1, configurable: !0 }); - for (const [i, c] of re(r)) - oe(e, c) && M(t, i, { - value: e[c], + for (const [c, l] of ge(r)) + de(e, l) && U(t, c, { + value: e[l], writable: !0, enumerable: !1, configurable: !0 }); - const a = { + const i = { globalThis: t }; - a.Compartment = y( + i.Compartment = y( n( n, e, - o + o, + s ) ); - for (const [i, c] of re(a)) - M(t, i, { - value: c, + for (const [c, l] of ge(i)) + U(t, c, { + value: l, writable: !0, enumerable: !1, configurable: !0 - }), typeof c == "function" && o(c); -}, ln = (t, e, r) => { + }), typeof l == "function" && o(l); +}, dn = (t, e, r) => { { const n = y(ii(e)); - r(n), M(t, "eval", { + r(n), U(t, "eval", { value: n, writable: !0, enumerable: !1, @@ -2640,28 +2654,28 @@ ${o} } { const n = y(ci(e)); - r(n), M(t, "Function", { + r(n), U(t, "Function", { value: n, writable: !0, enumerable: !1, configurable: !0 }); } -}, { Fail: ui, quote: us } = z, ds = new Cr( - In, +}, { Fail: ui, quote: us } = ee, ds = new Lr( + Tn, y({ get(t, e) { - ui`Please report unexpected scope handler trap: ${us(pe(e))}`; + ui`Please report unexpected scope handler trap: ${us(be(e))}`; } }) ), di = { get(t, e) { }, set(t, e, r) { - throw lt(`${pe(e)} is not defined`); + throw Bt(`${be(e)} is not defined`); }, has(t, e) { - return e in k; + return e in T; }, // note: this is likely a bug of safari // https://bugs.webkit.org/show_bug.cgi?id=195534 @@ -2671,7 +2685,7 @@ ${o} // See https://github.com/endojs/endo/issues/1510 // TODO: report as bug to v8 or Chrome, and record issue link here. getOwnPropertyDescriptor(t, e) { - const r = us(pe(e)); + const r = us(be(e)); console.warn( `getOwnPropertyDescriptor trap on scopeTerminatorHandler for ${r}`, v().stack @@ -2683,39 +2697,39 @@ ${o} return []; } }, fs = y( - Z( + H( ds, Ze(di) ) -), fi = new Cr( - In, +), fi = new Lr( + Tn, fs ), ps = (t) => { const e = { // inherit scopeTerminator behavior ...fs, // Redirect set properties to the globalObject. - set(o, a, i) { - return Io(t, a, i); + set(o, s, i) { + return Io(t, s, i); }, // Always claim to have a potential property in order to be the recipient of a set - has(o, a) { + has(o, s) { return !0; } }, r = y( - Z( + H( ds, Ze(e) ) ); - return new Cr( - In, + return new Lr( + Tn, r ); }; y(ps); -const { Fail: pi } = z, hi = () => { - const t = Z(null), e = y({ +const { Fail: pi } = ee, hi = () => { + const t = H(null), e = y({ eval: { get() { return delete t.eval, jo; @@ -2727,64 +2741,64 @@ const { Fail: pi } = z, hi = () => { evalScope: t, allowNextEvalToBeUnsafe() { const { revoked: n } = r; - n !== null && pi`a handler did not reset allowNextEvalToBeUnsafe ${n.err}`, F(t, e); + n !== null && pi`a handler did not reset allowNextEvalToBeUnsafe ${n.err}`, B(t, e); }, /** @type {null | { err: any }} */ revoked: null }; return r; -}, ro = "\\s*[@#]\\s*([a-zA-Z][a-zA-Z0-9]*)\\s*=\\s*([^\\s\\*]*)", mi = new We( - `(?:\\s*//${ro}|/\\*${ro}\\s*\\*/)\\s*$` -), Nn = (t) => { +}, to = "\\s*[@#]\\s*([a-zA-Z][a-zA-Z0-9]*)\\s*=\\s*([^\\s\\*]*)", mi = new Xe( + `(?:\\s*//${to}|/\\*${to}\\s*\\*/)\\s*$` +), In = (t) => { let e = ""; for (; t.length > 0; ) { - const r = kn(mi, t); + const r = En(mi, t); if (r === null) break; - t = Pn(t, 0, t.length - r[0].length), r[3] === "sourceURL" ? e = r[4] : r[1] === "sourceURL" && (e = r[2]); + t = kn(t, 0, t.length - r[0].length), r[3] === "sourceURL" ? e = r[4] : r[1] === "sourceURL" && (e = r[2]); } return e; }; -function Rn(t, e) { +function Cn(t, e) { const r = va(t, e); if (r < 0) return -1; const n = t[r] === ` ` ? 1 : 0; - return Tn(Pn(t, 0, r), ` + return Pn(kn(t, 0, r), ` `).length + n; } -const hs = new We("(?:)", "g"), ms = (t) => { - const e = Rn(t, hs); +const hs = new Xe("(?:)", "g"), ms = (t) => { + const e = Cn(t, hs); if (e < 0) return t; - const r = Nn(t); - throw tr( + const r = In(t); + throw sr( `Possible HTML comment rejected at ${r}:${e}. (SES_HTML_COMMENT_REJECTED)` ); -}, gs = (t) => vr(t, hs, (r) => r[0] === "<" ? "< ! --" : "-- >"), ys = new We( +}, gs = (t) => Sr(t, hs, (r) => r[0] === "<" ? "< ! --" : "-- >"), ys = new Xe( "(^|[^.]|\\.\\.\\.)\\bimport(\\s*(?:\\(|/[/*]))", "g" ), vs = (t) => { - const e = Rn(t, ys); + const e = Cn(t, ys); if (e < 0) return t; - const r = Nn(t); - throw tr( + const r = In(t); + throw sr( `Possible import expression rejected at ${r}:${e}. (SES_IMPORT_REJECTED)` ); -}, _s = (t) => vr(t, ys, (r, n, o) => `${n}__import__${o}`), gi = new We( +}, _s = (t) => Sr(t, ys, (r, n, o) => `${n}__import__${o}`), gi = new Xe( "(^|[^.])\\beval(\\s*\\()", "g" ), bs = (t) => { - const e = Rn(t, gi); + const e = Cn(t, gi); if (e < 0) return t; - const r = Nn(t); - throw tr( + const r = In(t); + throw sr( `Possible direct eval expression rejected at ${r}:${e}. (SES_EVAL_REJECTED)` ); -}, ws = (t) => (t = ms(t), t = vs(t), t), Ss = (t, e) => { +}, ws = (t) => (t = ms(t), t = vs(t), t), xs = (t, e) => { for (const r of e) t = r(t); return t; @@ -2796,7 +2810,7 @@ y({ evadeImportExpressionTest: y(_s), rejectSomeDirectEvalExpressions: y(bs), mandatoryTransforms: y(ws), - applyTransforms: y(Ss) + applyTransforms: y(xs) }); const yi = [ // 11.6.2.1 Keywords @@ -2853,9 +2867,9 @@ const yi = [ "false", "this", "arguments" -], vi = /^[a-zA-Z_$][\w$]*$/, no = (t) => t !== "eval" && !Mr(yi, t) && xn(vi, t); -function oo(t, e) { - const r = J(t, e); +], vi = /^[a-zA-Z_$][\w$]*$/, ro = (t) => t !== "eval" && !Zr(yi, t) && Sn(vi, t); +function no(t, e) { + const r = ne(t, e); return r && // // The getters will not have .writable, don't let the falsyness of // 'undefined' trick us: test with === false, not ! . However descriptors @@ -2869,39 +2883,39 @@ function oo(t, e) { // can't have accessors and value properties at the same time, therefore // this check is sufficient. Using explicit own property deal with the // case where Object.prototype has been poisoned. - oe(r, "value"); + de(r, "value"); } const _i = (t, e = {}) => { - const r = Dt(t), n = Dt(e), o = Ke( + const r = It(t), n = It(e), o = et( n, - (i) => no(i) && oo(e, i) + (i) => ro(i) && no(e, i) ); return { - globalObjectConstants: Ke( + globalObjectConstants: et( r, (i) => ( // Can't define a constant: it would prevent a // lookup on the endowments. - !Mr(n, i) && no(i) && oo(t, i) + !Zr(n, i) && ro(i) && no(t, i) ) ), moduleLexicalConstants: o }; }; -function so(t, e) { - return t.length === 0 ? "" : `const {${Rt(t, ",")}} = this.${e};`; +function oo(t, e) { + return t.length === 0 ? "" : `const {${Ft(t, ",")}} = this.${e};`; } const bi = (t) => { const { globalObjectConstants: e, moduleLexicalConstants: r } = _i( t.globalObject, t.moduleLexicals - ), n = so( + ), n = oo( e, "globalObject" - ), o = so( + ), o = oo( r, "moduleLexicals" - ), a = ve(` + ), s = Ee(` with (this.scopeTerminator) { with (this.globalObject) { with (this.moduleLexicals) { @@ -2917,14 +2931,14 @@ const bi = (t) => { } } `); - return ne(a, t, []); -}, { Fail: wi } = z, On = ({ + return ue(s, t, []); +}, { Fail: wi } = ee, Rn = ({ globalObject: t, moduleLexicals: e = {}, globalTransforms: r = [], sloppyGlobalsMode: n = !1 }) => { - const o = n ? ps(t) : fi, a = hi(), { evalScope: i } = a, c = y({ + const o = n ? ps(t) : fi, s = hi(), { evalScope: i } = s, c = y({ evalScope: i, moduleLexicals: e, globalObject: t, @@ -2936,52 +2950,52 @@ const bi = (t) => { }; return { safeEvaluate: (f, h) => { const { localTransforms: p = [] } = h || {}; - u(), f = Ss(f, [ + u(), f = xs(f, [ ...p, ...r, ws ]); let m; try { - return a.allowNextEvalToBeUnsafe(), ne(l, t, [f]); + return s.allowNextEvalToBeUnsafe(), ue(l, t, [f]); } catch (_) { throw m = _, _; } finally { const _ = "eval" in i; - delete i.eval, _ && (a.revoked = { err: m }, wi`handler did not reset allowNextEvalToBeUnsafe ${m}`); + delete i.eval, _ && (s.revoked = { err: m }, wi`handler did not reset allowNextEvalToBeUnsafe ${m}`); } } }; -}, Si = ") { [native code] }"; -let Yr; -const Es = () => { - if (Yr === void 0) { - const t = new $t(); - M(wn, "toString", { +}, xi = ") { [native code] }"; +let tn; +const Ss = () => { + if (tn === void 0) { + const t = new Mt(); + U(bn, "toString", { value: { toString() { const r = wa(this); - return Mo(r, Si) || !or(t, this) ? r : `function ${this.name}() { [native code] }`; + return Mo(r, xi) || !cr(t, this) ? r : `function ${this.name}() { [native code] }`; } }.toString - }), Yr = y( - (r) => Fr(t, r) + }), tn = y( + (r) => Br(t, r) ); } - return Yr; + return tn; }; -function Ei(t = "safe") { +function Si(t = "safe") { if (t !== "safe" && t !== "unsafe") throw v(`unrecognized domainTaming ${t}`); if (t === "unsafe") return; - const e = k.process || void 0; + const e = T.process || void 0; if (typeof e == "object") { - const r = J(e, "domain"); + const r = ne(e, "domain"); if (r !== void 0 && r.get !== void 0) throw v( "SES failed to lockdown, Node.js domains have been initialized (SES_NO_DOMAINS)" ); - M(e, "domain", { + U(e, "domain", { value: null, configurable: !1, writable: !1, @@ -2989,7 +3003,7 @@ function Ei(t = "safe") { }); } } -const Mn = y([ +const $n = y([ ["debug", "debug"], // (fmt?, ...args) verbose level on Chrome ["log", "log"], @@ -3008,7 +3022,7 @@ const Mn = y([ // (fmt?, ...args) but TS typed (...label) ["groupCollapsed", "log"] // (fmt?, ...args) but TS typed (...label) -]), Ln = y([ +]), Nn = y([ ["assert", "error"], // (value, fmt?, ...args) ["timeLog", "log"], @@ -3040,18 +3054,18 @@ const Mn = y([ // (label?) ["timeStamp", void 0] // (label?) -]), xs = y([ - ...Mn, - ...Ln -]), xi = (t, { shouldResetForDebugging: e = !1 } = {}) => { +]), Es = y([ + ...$n, + ...Nn +]), Ei = (t, { shouldResetForDebugging: e = !1 } = {}) => { e && t.resetErrorTagNum(); let r = []; - const n = mt( - se(xs, ([i, c]) => { + const n = yt( + fe(Es, ([i, c]) => { const l = (...u) => { - X(r, [i, ...u]); + oe(r, [i, ...u]); }; - return M(l, "name", { value: i }), [i, y(l)]; + return U(l, "name", { value: i }), [i, y(l)]; }) ); y(n); @@ -3064,108 +3078,108 @@ const Mn = y([ n ), takeLog: o }); }; -y(xi); -const it = { +y(Ei); +const ut = { NOTE: "ERROR_NOTE:", MESSAGE: "ERROR_MESSAGE:", CAUSE: "cause:", ERRORS: "errors:" }; -y(it); -const Fn = (t, e) => { +y(ut); +const On = (t, e) => { if (!t) return; - const { getStackString: r, tagError: n, takeMessageLogArgs: o, takeNoteLogArgsArray: a } = e, i = (S, T) => se(S, (x) => Dr(x) ? (X(T, x), `(${n(x)})`) : x), c = (S, T, N, x, D) => { - const G = n(T), B = N === it.MESSAGE ? `${G}:` : `${G} ${N}`, K = i(x, D); - t[S](B, ...K); - }, l = (S, T, N = void 0) => { - if (T.length === 0) + const { getStackString: r, tagError: n, takeMessageLogArgs: o, takeNoteLogArgsArray: s } = e, i = (S, x) => fe(S, (E) => Gr(E) ? (oe(x, E), `(${n(E)})`) : E), c = (S, x, I, E, L) => { + const $ = n(x), j = I === ut.MESSAGE ? `${$}:` : `${$} ${I}`, F = i(E, L); + t[S](j, ...F); + }, l = (S, x, I = void 0) => { + if (x.length === 0) return; - if (T.length === 1 && N === void 0) { - f(S, T[0]); + if (x.length === 1 && I === void 0) { + f(S, x[0]); return; } - let x; - T.length === 1 ? x = "Nested error" : x = `Nested ${T.length} errors`, N !== void 0 && (x = `${x} under ${N}`), t.group(x); + let E; + x.length === 1 ? E = "Nested error" : E = `Nested ${x.length} errors`, I !== void 0 && (E = `${E} under ${I}`), t.group(E); try { - for (const D of T) - f(S, D); + for (const L of x) + f(S, L); } finally { t.groupEnd(); } - }, u = new $t(), d = (S) => (T, N) => { - const x = []; - c(S, T, it.NOTE, N, x), l(S, x, n(T)); - }, f = (S, T) => { - if (or(u, T)) + }, u = new Mt(), d = (S) => (x, I) => { + const E = []; + c(S, x, ut.NOTE, I, E), l(S, E, n(x)); + }, f = (S, x) => { + if (cr(u, x)) return; - const N = n(T); - Fr(u, T); - const x = [], D = o(T), G = a( - T, + const I = n(x); + Br(u, x); + const E = [], L = o(x), $ = s( + x, d(S) ); - D === void 0 ? t[S](`${N}:`, T.message) : c( + L === void 0 ? t[S](`${I}:`, x.message) : c( S, - T, - it.MESSAGE, - D, - x + x, + ut.MESSAGE, + L, + E ); - let B = r(T); - typeof B == "string" && B.length >= 1 && !Mo(B, ` -`) && (B += ` -`), t[S](B), T.cause && c(S, T, it.CAUSE, [T.cause], x), T.errors && c(S, T, it.ERRORS, T.errors, x); - for (const K of G) - c(S, T, it.NOTE, K, x); - l(S, x, N); - }, h = se(Mn, ([S, T]) => { - const N = (...x) => { - const D = [], G = i(x, D); - t[S](...G), l(S, D); + let j = r(x); + typeof j == "string" && j.length >= 1 && !Mo(j, ` +`) && (j += ` +`), t[S](j), x.cause && c(S, x, ut.CAUSE, [x.cause], E), x.errors && c(S, x, ut.ERRORS, x.errors, E); + for (const F of $) + c(S, x, ut.NOTE, F, E); + l(S, E, I); + }, h = fe($n, ([S, x]) => { + const I = (...E) => { + const L = [], $ = i(E, L); + t[S](...$), l(S, L); }; - return M(N, "name", { value: S }), [S, y(N)]; - }), p = Ke( - Ln, - ([S, T]) => S in t - ), m = se(p, ([S, T]) => { - const N = (...x) => { - t[S](...x); + return U(I, "name", { value: S }), [S, y(I)]; + }), p = et( + Nn, + ([S, x]) => S in t + ), m = fe(p, ([S, x]) => { + const I = (...E) => { + t[S](...E); }; - return M(N, "name", { value: S }), [S, y(N)]; - }), _ = mt([...h, ...m]); + return U(I, "name", { value: S }), [S, y(I)]; + }), _ = yt([...h, ...m]); return ( /** @type {VirtualConsole} */ y(_) ); }; -y(Fn); +y(On); const ki = (t, e, r) => { - const [n, ...o] = Tn(t, e), a = Ro(o, (i) => [e, ...r, i]); - return ["", n, ...a]; + const [n, ...o] = Pn(t, e), s = No(o, (i) => [e, ...r, i]); + return ["", n, ...s]; }, ks = (t) => y((r) => { - const n = [], o = (...l) => (n.length > 0 && (l = Ro( + const n = [], o = (...l) => (n.length > 0 && (l = No( l, (u) => typeof u == "string" && Lo(u, ` `) ? ki(u, ` `, n) : [u] - ), l = [...n, ...l]), r(...l)), a = (l, u) => ({ [l]: (...d) => u(...d) })[l], i = mt([ - ...se(Mn, ([l]) => [ + ), l = [...n, ...l]), r(...l)), s = (l, u) => ({ [l]: (...d) => u(...d) })[l], i = yt([ + ...fe($n, ([l]) => [ l, - a(l, o) + s(l, o) ]), - ...se(Ln, ([l]) => [ + ...fe(Nn, ([l]) => [ l, - a(l, (...u) => o(l, ...u)) + s(l, (...u) => o(l, ...u)) ]) ]); for (const l of ["group", "groupCollapsed"]) - i[l] && (i[l] = a(l, (...u) => { - u.length >= 1 && o(...u), X(n, " "); + i[l] && (i[l] = s(l, (...u) => { + u.length >= 1 && o(...u), oe(n, " "); })); - return i.groupEnd && (i.groupEnd = a("groupEnd", (...l) => { - gr(n); - })), harden(i), Fn( + return i.groupEnd && (i.groupEnd = s("groupEnd", (...l) => { + wr(n); + })), harden(i), On( /** @type {VirtualConsole} */ i, t @@ -3173,98 +3187,97 @@ const ki = (t, e, r) => { }); y(ks); const Pi = (t, e, r = void 0) => { - const n = Ke( - xs, + const n = et( + Es, ([i, c]) => i in t - ), o = se(n, ([i, c]) => [i, y((...u) => { + ), o = fe(n, ([i, c]) => [i, y((...u) => { (c === void 0 || e.canLog(c)) && t[i](...u); - })]), a = mt(o); + })]), s = yt(o); return ( /** @type {VirtualConsole} */ - y(a) + y(s) ); }; y(Pi); -const ao = (t) => { - if (kt === void 0) +const so = (t) => { + if (At === void 0) return; let e = 0; - const r = new Pe(), n = (d) => { + const r = new Re(), n = (d) => { fa(r, d); - }, o = new Me(), a = (d) => { - if (Lr(r, d)) { - const f = Ue(r, d); + }, o = new je(), s = (d) => { + if (zr(r, d)) { + const f = He(r, d); n(d), t(f); } - }, i = new kt(a); + }, i = new At(s); return { rejectionHandledHandler: (d) => { - const f = L(o, d); + const f = z(o, d); n(f); }, unhandledRejectionHandler: (d, f) => { e += 1; const h = e; - $e(r, h, d), ie(o, f, h), Ea(i, f, h, f); + he(r, h, d), me(o, f, h), Sa(i, f, h, f); }, processTerminationHandler: () => { for (const [d, f] of pa(r)) n(d), t(f); } }; -}, Jr = (t) => { +}, rn = (t) => { throw v(t); -}, io = (t, e) => y((...r) => ne(t, e, r)), Ti = (t = "safe", e = "platform", r = "report", n = void 0) => { - t === "safe" || t === "unsafe" || Jr(`unrecognized consoleTaming ${t}`); +}, ao = (t, e) => y((...r) => ue(t, e, r)), Ti = (t = "safe", e = "platform", r = "report", n = void 0) => { + t === "safe" || t === "unsafe" || rn(`unrecognized consoleTaming ${t}`); let o; - n === void 0 ? o = br : o = { - ...br, + n === void 0 ? o = Pr : o = { + ...Pr, getStackString: n }; - const a = ( + const s = ( /** @type {VirtualConsole} */ // eslint-disable-next-line no-nested-ternary - typeof k.console < "u" ? k.console : typeof k.print == "function" ? ( + typeof T.console < "u" ? T.console : typeof T.print == "function" ? ( // Make a good-enough console for eshost (including only functions that // log at a specific level with no special argument interpretation). // https://console.spec.whatwg.org/#logging ((u) => y({ debug: u, log: u, info: u, warn: u, error: u }))( // eslint-disable-next-line no-undef - io(k.print) + ao(T.print) ) ) : void 0 ); - if (a && a.log) + if (s && s.log) for (const u of ["warn", "error"]) - a[u] || M(a, u, { - value: io(a.log, a) + s[u] || U(s, u, { + value: ao(s.log, s) }); const i = ( /** @type {VirtualConsole} */ - t === "unsafe" ? a : Fn(a, o) - ), c = k.process || void 0; + t === "unsafe" ? s : On(s, o) + ), c = T.process || void 0; if (e !== "none" && typeof c == "object" && typeof c.on == "function") { let u; if (e === "platform" || e === "exit") { const { exit: d } = c; - typeof d == "function" || Jr("missing process.exit"), u = () => d(c.exitCode || -1); - } else - e === "abort" && (u = c.abort, typeof u == "function" || Jr("missing process.abort")); + typeof d == "function" || rn("missing process.exit"), u = () => d(c.exitCode || -1); + } else e === "abort" && (u = c.abort, typeof u == "function" || rn("missing process.abort")); c.on("uncaughtException", (d) => { i.error(d), u && u(); }); } if (r !== "none" && typeof c == "object" && typeof c.on == "function") { - const d = ao((f) => { + const d = so((f) => { i.error("SES_UNHANDLED_REJECTION:", f); }); d && (c.on("unhandledRejection", d.unhandledRejectionHandler), c.on("rejectionHandled", d.rejectionHandledHandler), c.on("exit", d.processTerminationHandler)); } - const l = k.window || void 0; + const l = T.window || void 0; if (e !== "none" && typeof l == "object" && typeof l.addEventListener == "function" && l.addEventListener("error", (u) => { u.preventDefault(), i.error(u.error), (e === "exit" || e === "abort") && (l.location.href = "about:blank"); }), r !== "none" && typeof l == "object" && typeof l.addEventListener == "function") { - const d = ao((f) => { + const d = so((f) => { i.error("SES_UNHANDLED_REJECTION:", f); }); d && (l.addEventListener("unhandledrejection", (f) => { @@ -3300,21 +3313,21 @@ const ao = (t) => { "toString" // TODO replace to use only whitelisted info ], Ii = (t) => { - const r = mt(se(Ai, (n) => { + const r = yt(fe(Ai, (n) => { const o = t[n]; - return [n, () => ne(o, t, [])]; + return [n, () => ue(o, t, [])]; })); - return Z(r, {}); -}, Ci = (t) => se(t, Ii), $i = /\/node_modules\//, Ni = /^(?:node:)?internal\//, Ri = /\/packages\/ses\/src\/error\/assert.js$/, Oi = /\/packages\/eventual-send\/src\//, Mi = [ + return H(r, {}); +}, Ci = (t) => fe(t, Ii), Ri = /\/node_modules\//, $i = /^(?:node:)?internal\//, Ni = /\/packages\/ses\/src\/error\/assert.js$/, Oi = /\/packages\/eventual-send\/src\//, Mi = [ + Ri, $i, Ni, - Ri, Oi ], Li = (t) => { if (!t) return !0; for (const e of Mi) - if (xn(e, t)) + if (Sn(e, t)) return !1; return !0; }, Fi = /^((?:.*[( ])?)[:/\w_-]*\/\.\.\.\/(.+)$/, Di = /^((?:.*[( ])?)[:/\w_-]*\/(packages\/.+)$/, Ui = [ @@ -3322,20 +3335,24 @@ const ao = (t) => { Di ], ji = (t) => { for (const e of Ui) { - const r = kn(e, t); + const r = En(e, t); if (r) - return Rt(la(r, 1), ""); + return Ft(la(r, 1), ""); } return t; }, Zi = (t, e, r, n) => { - const o = t.captureStackTrace, a = (p) => n === "verbose" ? !0 : Li(p.getFileName()), i = (p) => { + if (r === "unsafe-debug") + throw v( + "internal: v8+unsafe-debug special case should already be done" + ); + const o = t.captureStackTrace, s = (p) => n === "verbose" ? !0 : Li(p.getFileName()), i = (p) => { let m = `${p}`; return n === "concise" && (m = ji(m)), ` at ${m}`; - }, c = (p, m) => Rt( - se(Ke(m, a), i), + }, c = (p, m) => Ft( + fe(et(m, s), i), "" - ), l = new Me(), u = { + ), l = new je(), u = { // The optional `optFn` argument is for cutting off the bottom of // the stack --- for capturing the stack only above the topmost // call to that function. Since this isn't the "real" captureStackTrace @@ -3343,7 +3360,7 @@ const ao = (t) => { // we cut this one off. captureStackTrace(p, m = u.captureStackTrace) { if (typeof o == "function") { - ne(o, t, [p, m]); + ue(o, t, [p, m]); return; } Io(p, "stack", ""); @@ -3353,32 +3370,32 @@ const ao = (t) => { // string associated with an error. // See https://tc39.es/proposal-error-stacks/ getStackString(p) { - let m = L(l, p); - if (m === void 0 && (p.stack, m = L(l, p), m || (m = { stackString: "" }, ie(l, p, m))), m.stackString !== void 0) + let m = z(l, p); + if (m === void 0 && (p.stack, m = z(l, p), m || (m = { stackString: "" }, me(l, p, m))), m.stackString !== void 0) return m.stackString; const _ = c(p, m.callSites); - return ie(l, p, { stackString: _ }), _; + return me(l, p, { stackString: _ }), _; }, prepareStackTrace(p, m) { if (r === "unsafe") { const _ = c(p, m); - return ie(l, p, { stackString: _ }), `${p}${_}`; + return me(l, p, { stackString: _ }), `${p}${_}`; } else - return ie(l, p, { callSites: m }), ""; + return me(l, p, { callSites: m }), ""; } }, d = u.prepareStackTrace; t.prepareStackTrace = d; - const f = new $t([d]), h = (p) => { - if (or(f, p)) + const f = new Mt([d]), h = (p) => { + if (cr(f, p)) return p; const m = { prepareStackTrace(_, S) { - return ie(l, _, { callSites: S }), p(_, Ci(S)); + return me(l, _, { callSites: S }), p(_, Ci(S)); } }; - return Fr(f, m.prepareStackTrace), m.prepareStackTrace; + return Br(f, m.prepareStackTrace), m.prepareStackTrace; }; - return F(e, { + return B(e, { captureStackTrace: { value: u.captureStackTrace, writable: !0, @@ -3400,22 +3417,23 @@ const ao = (t) => { configurable: !0 } }), u.getStackString; -}, co = J(ue.prototype, "stack"), lo = co && co.get, zi = { +}, io = ne(ce.prototype, "stack"), co = io && io.get, zi = { getStackString(t) { - return typeof lo == "function" ? ne(lo, t, []) : "stack" in t ? `${t.stack}` : ""; + return typeof co == "function" ? ue(co, t, []) : "stack" in t ? `${t.stack}` : ""; } }; -function Gi(t = "safe", e = "concise") { - if (t !== "safe" && t !== "unsafe") +let hr = zi.getStackString; +function Bi(t = "safe", e = "concise") { + if (t !== "safe" && t !== "unsafe" && t !== "unsafe-debug") throw v(`unrecognized errorTaming ${t}`); if (e !== "concise" && e !== "verbose") throw v(`unrecognized stackFiltering ${e}`); - const r = ue.prototype, n = typeof ue.captureStackTrace == "function" ? "v8" : "unknown", { captureStackTrace: o } = ue, a = (u = {}) => { - const d = function(...h) { - let p; - return new.target === void 0 ? p = ne(ue, this, h) : p = mr(ue, h, new.target), n === "v8" && ne(o, ue, [p, d]), p; + const r = ce.prototype, { captureStackTrace: n } = ce, o = typeof n == "function" ? "v8" : "unknown", s = (l = {}) => { + const u = function(...f) { + let h; + return new.target === void 0 ? h = ue(ce, this, f) : h = br(ce, f, new.target), o === "v8" && ue(n, ce, [h, u]), h; }; - return F(d, { + return B(u, { length: { value: 1 }, prototype: { value: r, @@ -3423,22 +3441,22 @@ function Gi(t = "safe", e = "concise") { enumerable: !1, configurable: !1 } - }), d; - }, i = a({ powers: "original" }), c = a({ powers: "none" }); - F(r, { + }), u; + }, i = s({ powers: "original" }), c = s({ powers: "none" }); + B(r, { constructor: { value: c } }); - for (const u of ns) - xo(u, c); - F(i, { + for (const l of ns) + Eo(l, c); + if (B(i, { stackTraceLimit: { get() { - if (typeof ue.stackTraceLimit == "number") - return ue.stackTraceLimit; + if (typeof ce.stackTraceLimit == "number") + return ce.stackTraceLimit; }, - set(u) { - if (typeof u == "number" && typeof ue.stackTraceLimit == "number") { - ue.stackTraceLimit = u; + set(l) { + if (typeof l == "number" && typeof ce.stackTraceLimit == "number") { + ce.stackTraceLimit = l; return; } }, @@ -3446,28 +3464,58 @@ function Gi(t = "safe", e = "concise") { enumerable: !1, configurable: !0 } - }), F(c, { + }), t === "unsafe-debug" && o === "v8") { + B(i, { + prepareStackTrace: { + get() { + return ce.prepareStackTrace; + }, + set(u) { + ce.prepareStackTrace = u; + }, + enumerable: !1, + configurable: !0 + }, + captureStackTrace: { + value: ce.captureStackTrace, + writable: !0, + enumerable: !1, + configurable: !0 + } + }); + const l = Ze(i); + return B(c, { + stackTraceLimit: l.stackTraceLimit, + prepareStackTrace: l.prepareStackTrace, + captureStackTrace: l.captureStackTrace + }), { + "%InitialGetStackString%": hr, + "%InitialError%": i, + "%SharedError%": c + }; + } + return B(c, { stackTraceLimit: { get() { }, - set(u) { + set(l) { }, enumerable: !1, configurable: !0 } - }), n === "v8" && F(c, { + }), o === "v8" && B(c, { prepareStackTrace: { get() { return () => ""; }, - set(u) { + set(l) { }, enumerable: !1, configurable: !0 }, captureStackTrace: { - value: (u, d) => { - M(u, "stack", { + value: (l, u) => { + U(l, "stack", { value: "" }); }, @@ -3475,22 +3523,20 @@ function Gi(t = "safe", e = "concise") { enumerable: !1, configurable: !0 } - }); - let l = zi.getStackString; - return n === "v8" ? l = Zi( - ue, + }), o === "v8" ? hr = Zi( + ce, i, t, e - ) : t === "unsafe" ? F(r, { + ) : t === "unsafe" || t === "unsafe-debug" ? B(r, { stack: { get() { - return l(this); + return hr(this); }, - set(u) { - F(this, { + set(l) { + B(this, { stack: { - value: u, + value: l, writable: !0, enumerable: !0, configurable: !0 @@ -3498,15 +3544,15 @@ function Gi(t = "safe", e = "concise") { }); } } - }) : F(r, { + }) : B(r, { stack: { get() { return `${this}`; }, - set(u) { - F(this, { + set(l) { + B(this, { stack: { - value: u, + value: l, writable: !0, enumerable: !0, configurable: !0 @@ -3515,269 +3561,358 @@ function Gi(t = "safe", e = "concise") { } } }), { - "%InitialGetStackString%": l, + "%InitialGetStackString%": hr, "%InitialError%": i, "%SharedError%": c }; } -const { Fail: Bi, details: un, quote: xe } = z, Hi = () => { -}; -async function Vi(t, e, r) { +const Gi = () => { +}, Vi = async (t, e, r) => { + await null; const n = t(...e); - let o = yr(n); + let o = xr(n); for (; !o.done; ) try { - const a = await o.value; - o = yr(n, a); - } catch (a) { - o = Fo(n, r(a)); + const s = await o.value; + o = xr(n, s); + } catch (s) { + o = Fo(n, r(s)); } return o.value; -} -function Wi(t, e) { +}, Hi = (t, e) => { const r = t(...e); - let n = yr(r); + let n = xr(r); for (; !n.done; ) try { - n = yr(r, n.value); + n = xr(r, n.value); } catch (o) { n = Fo(r, o); } return n.value; -} -const qi = (t, e) => y({ - compartment: t, - specifier: e -}), Ki = (t, e, r) => { - const n = Z(null); +}, Wi = (t, e) => y({ compartment: t, specifier: e }), qi = (t, e, r) => { + const n = H(null); for (const o of t) { - const a = e(o, r); - n[o] = a; + const s = e(o, r); + n[o] = s; } return y(n); -}, uo = (t, e, r, n, o, a, i, c, l) => { - const { resolveHook: u, moduleRecords: d } = L( - t, - r - ), f = Ki( +}, Ut = (t, e, r, n, o, s, i, c, l) => { + const { resolveHook: u } = z(t, r), d = qi( o.imports, u, n - ), h = y({ + ), f = y({ compartment: r, - staticModuleRecord: o, + moduleSource: o, moduleSpecifier: n, - resolvedImports: f, + resolvedImports: d, importMeta: l }); - for (const p of ko(f)) - a(Ut, [ + for (const h of ko(d)) + s(Pt, [ t, e, r, - p, - a, + h, + s, i, c ]); - return $e(d, n, h), h; + return f; }; -function* Yi(t, e, r, n, o, a, i) { - const { importHook: c, importNowHook: l, moduleMap: u, moduleMapHook: d, moduleRecords: f } = L(t, r); - let h = u[n]; - if (h === void 0 && d !== void 0 && (h = d(n)), typeof h == "string") - z.fail( - un`Cannot map module ${xe(n)} to ${xe( - h - )} in parent compartment, not yet implemented`, +function* Ki(t, e, r, n, o, s, i) { + const { + importHook: c, + importNowHook: l, + moduleMap: u, + moduleMapHook: d, + moduleRecords: f, + parentCompartment: h + } = z(t, r); + if (zr(f, n)) + return He(f, n); + let p = u[n]; + if (p === void 0 && d !== void 0 && (p = d(n)), p === void 0) { + const m = s(c, l); + if (m === void 0) { + const _ = s( + "importHook", + "importNowHook" + ); + throw Le( + le`${kr(_)} needed to load module ${Z( + n + )} in compartment ${Z(r.name)}` + ); + } + p = m(n), kt(e, p) || (p = yield p); + } + if (typeof p == "string") + throw Le( + le`Cannot map module ${Z(n)} to ${Z( + p + )} in parent compartment, use {source} module descriptor`, v ); - else if (h !== void 0) { - const m = L(e, h); - m === void 0 && z.fail( - un`Cannot map module ${xe( - n - )} because the value is not a module exports namespace, or is from another realm`, - lt - ); - const _ = yield Ut( - t, - e, - m.compartment, - m.specifier, - o, - a, - i - ); - return $e(f, n, _), _; - } - if (Lr(f, n)) - return Ue(f, n); - const p = yield a( - c, - l - )(n); - if ((p === null || typeof p != "object") && Bi`importHook must return a promise for an object, for module ${xe( - n - )} in compartment ${xe(r.name)}`, p.specifier !== void 0) { - if (p.record !== void 0) { - if (p.compartment !== void 0) - throw v( - "Cannot redirect to an explicit record with a specified compartment" + if (ke(p)) { + let m = z(e, p); + if (m !== void 0 && (p = m), p.namespace !== void 0) { + if (typeof p.namespace == "string") { + const { + compartment: x = h, + namespace: I + } = p; + if (!ke(x) || !kt(t, x)) + throw Le( + le`Invalid compartment in module descriptor for specifier ${Z(n)} in compartment ${Z(r.name)}` + ); + const E = yield Pt( + t, + e, + x, + I, + o, + s, + i ); + return he(f, n, E), E; + } + if (ke(p.namespace)) { + const { namespace: x } = p; + if (m = z(e, x), m !== void 0) + p = m; + else { + const I = It(x), $ = Ut( + t, + e, + r, + n, + { + imports: [], + exports: I, + execute(j) { + for (const F of I) + j[F] = x[F]; + } + }, + o, + s, + i, + void 0 + ); + return he(f, n, $), $; + } + } else + throw Le( + le`Invalid compartment in module descriptor for specifier ${Z(n)} in compartment ${Z(r.name)}` + ); + } + if (p.source !== void 0) + if (typeof p.source == "string") { + const { + source: x, + specifier: I = n, + compartment: E = h, + importMeta: L = void 0 + } = p, $ = yield Pt( + t, + e, + E, + x, + o, + s, + i + ), { moduleSource: j } = $, F = Ut( + t, + e, + r, + I, + j, + o, + s, + i, + L + ); + return he(f, n, F), F; + } else { + const { + source: x, + specifier: I = n, + importMeta: E + } = p, L = Ut( + t, + e, + r, + I, + x, + o, + s, + i, + E + ); + return he(f, n, L), L; + } + if (p.archive !== void 0) + throw Le( + le`Unsupported archive module descriptor for specifier ${Z(n)} in compartment ${Z(r.name)}` + ); + if (p.record !== void 0) { const { - compartment: m = r, - specifier: _ = n, - record: S, - importMeta: T - } = p, N = uo( + compartment: x = r, + specifier: I = n, + record: E, + importMeta: L + } = p, $ = Ut( t, e, - m, - _, - S, + x, + I, + E, o, - a, + s, i, - T + L ); - return $e(f, n, N), N; + return he(f, n, $), he(f, I, $), $; } - if (p.compartment !== void 0) { - if (p.importMeta !== void 0) - throw v( - "Cannot redirect to an implicit record with a specified importMeta" + if (p.compartment !== void 0 && p.specifier !== void 0) { + if (!ke(p.compartment) || !kt(t, p.compartment) || typeof p.specifier != "string") + throw Le( + le`Invalid compartment in module descriptor for specifier ${Z(n)} in compartment ${Z(r.name)}` ); - const m = yield Ut( + const x = yield Pt( t, e, p.compartment, p.specifier, o, - a, + s, i ); - return $e(f, n, m), m; + return he(f, n, x), x; } - throw v("Unnexpected RedirectStaticModuleInterface record shape"); - } - return uo( - t, - e, - r, - n, - p, - o, - a, - i - ); + const S = Ut( + t, + e, + r, + n, + p, + o, + s, + i + ); + return he(f, n, S), S; + } else + throw Le( + le`module descriptor must be a string or object for specifier ${Z( + n + )} in compartment ${Z(r.name)}` + ); } -const Ut = (t, e, r, n, o, a, i) => { - const { name: c } = L( +const Pt = (t, e, r, n, o, s, i) => { + const { name: c } = z( t, r ); - let l = Ue(i, r); - l === void 0 && (l = new Pe(), $e(i, r, l)); - let u = Ue(l, n); - return u !== void 0 || (u = a(Vi, Wi)( - Yi, + let l = He(i, r); + l === void 0 && (l = new Re(), he(i, r, l)); + let u = He(l, n); + return u !== void 0 || (u = s(Vi, Hi)( + Ki, [ t, e, r, n, o, - a, + s, i ], (d) => { - throw z.note( + throw Hr( d, - un`${d.message}, loading ${xe(n)} in compartment ${xe( + le`${d.message}, loading ${Z(n)} in compartment ${Z( c )}` ), d; } - ), $e(l, n, u)), u; -}; -function Ji() { - const t = new Ct(), e = []; - return { enqueueJob: (o, a) => { - Sn( + ), he(l, n, u)), u; +}, Yi = () => { + const t = new Ot(), e = []; + return { enqueueJob: (o, s) => { + wn( t, - Uo(o(...a), Hi, (i) => { - X(e, i); + Uo(o(...s), Gi, (i) => { + oe(e, i); }) ); }, drainQueue: async () => { + await null; for (const o of t) await o; return e; } }; -} -function Ps({ errors: t, errorPrefix: e }) { +}, Ps = ({ errors: t, errorPrefix: e }) => { if (t.length > 0) { - const r = le("COMPARTMENT_LOAD_ERRORS", "", ["verbose"]) === "verbose"; + const r = ve("COMPARTMENT_LOAD_ERRORS", "", ["verbose"]) === "verbose"; throw v( - `${e} (${t.length} underlying failures: ${Rt( - se(t, (n) => n.message + (r ? n.stack : "")), + `${e} (${t.length} underlying failures: ${Ft( + fe(t, (n) => n.message + (r ? n.stack : "")), ", " )}` ); } -} -const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { - const { name: o } = L( +}, Ji = (t, e) => e, Xi = (t, e) => t, lo = async (t, e, r, n) => { + const { name: o } = z( t, r - ), a = new Pe(), { enqueueJob: i, drainQueue: c } = Ji(); - i(Ut, [ + ), s = new Re(), { enqueueJob: i, drainQueue: c } = Yi(); + i(Pt, [ t, e, r, n, i, - Qi, - a + Xi, + s ]); const l = await c(); Ps({ errors: l, - errorPrefix: `Failed to load module ${xe(n)} in package ${xe( + errorPrefix: `Failed to load module ${Z(n)} in package ${Z( o )}` }); -}, ec = (t, e, r, n) => { - const { name: o } = L( +}, Qi = (t, e, r, n) => { + const { name: o } = z( t, r - ), a = new Pe(), i = [], c = (l, u) => { + ), s = new Re(), i = [], c = (l, u) => { try { l(...u); } catch (d) { - X(i, d); + oe(i, d); } }; - c(Ut, [ + c(Pt, [ t, e, r, n, c, - Xi, - a + Ji, + s ]), Ps({ errors: i, - errorPrefix: `Failed to load module ${xe(n)} in package ${xe( + errorPrefix: `Failed to load module ${Z(n)} in package ${Z( o )}` }); -}, { quote: yt } = z, tc = () => { +}, { quote: _t } = ee, ec = () => { let t = !1; - const e = Z(null, { + const e = H(null, { // Make this appear like an ESM module namespace object. - [qe]: { + [Qe]: { value: "Module", writable: !1, enumerable: !1, @@ -3789,11 +3924,11 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { t = !0; }, exportsTarget: e, - exportsProxy: new Cr(e, { + exportsProxy: new Lr(e, { get(r, n, o) { if (!t) throw v( - `Cannot get property ${yt( + `Cannot get property ${_t( n )} of module exports namespace, the module has not yet begun to execute` ); @@ -3801,13 +3936,13 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { }, set(r, n, o) { throw v( - `Cannot set property ${yt(n)} of module exports namespace` + `Cannot set property ${_t(n)} of module exports namespace` ); }, has(r, n) { if (!t) throw v( - `Cannot check property ${yt( + `Cannot check property ${_t( n )}, the module has not yet begun to execute` ); @@ -3815,7 +3950,7 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { }, deleteProperty(r, n) { throw v( - `Cannot delete property ${yt(n)}s of module exports namespace` + `Cannot delete property ${_t(n)}s of module exports namespace` ); }, ownKeys(r) { @@ -3823,12 +3958,12 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { throw v( "Cannot enumerate keys, the module has not yet begun to execute" ); - return De(e); + return Ve(e); }, getOwnPropertyDescriptor(r, n) { if (!t) throw v( - `Cannot get own property descriptor ${yt( + `Cannot get own property descriptor ${_t( n )}, the module has not yet begun to execute` ); @@ -3856,7 +3991,7 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { }, defineProperty(r, n, o) { throw v( - `Cannot define property ${yt(n)} of module exports namespace` + `Cannot define property ${_t(n)} of module exports namespace` ); }, apply(r, n, o) { @@ -3871,33 +4006,33 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { } }) }); -}, Dn = (t, e, r, n) => { +}, Mn = (t, e, r, n) => { const { deferredExports: o } = e; - if (!Lr(o, n)) { - const a = tc(); - ie( + if (!zr(o, n)) { + const s = ec(); + me( r, - a.exportsProxy, - qi(t, n) - ), $e(o, n, a); + s.exportsProxy, + Wi(t, n) + ), he(o, n, s); } - return Ue(o, n); -}, rc = (t, e) => { + return He(o, n); +}, tc = (t, e) => { const { sloppyGlobalsMode: r = !1, __moduleShimLexicals__: n = void 0 } = e; let o; if (n === void 0 && !r) ({ safeEvaluate: o } = t); else { - let { globalTransforms: a } = t; + let { globalTransforms: s } = t; const { globalObject: i } = t; let c; - n !== void 0 && (a = void 0, c = Z( + n !== void 0 && (s = void 0, c = H( null, Ze(n) - )), { safeEvaluate: o } = On({ + )), { safeEvaluate: o } = Rn({ globalObject: i, moduleLexicals: c, - globalTransforms: a, + globalTransforms: s, sloppyGlobalsMode: r }); } @@ -3908,44 +4043,44 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { const { transforms: n = [], __evadeHtmlCommentTest__: o = !1, - __evadeImportExpressionTest__: a = !1, + __evadeImportExpressionTest__: s = !1, __rejectSomeDirectEvalExpressions__: i = !0 // Note default on } = r, c = [...n]; - o === !0 && X(c, gs), a === !0 && X(c, _s), i === !0 && X(c, bs); - const { safeEvaluate: l } = rc( + o === !0 && oe(c, gs), s === !0 && oe(c, _s), i === !0 && oe(c, bs); + const { safeEvaluate: l } = tc( t, r ); return l(e, { localTransforms: c }); -}, { quote: cr } = z, nc = (t, e, r, n, o, a) => { - const { exportsProxy: i, exportsTarget: c, activate: l } = Dn( +}, { quote: mr } = ee, rc = (t, e, r, n, o, s) => { + const { exportsProxy: i, exportsTarget: c, activate: l } = Mn( r, - L(t, r), + z(t, r), n, o - ), u = Z(null); + ), u = H(null); if (e.exports) { if (!Et(e.exports) || ua(e.exports, (f) => typeof f != "string")) throw v( - `SES third-party static module record "exports" property must be an array of strings for module ${o}` + `SES virtual module source "exports" property must be an array of strings for module ${o}` ); - ut(e.exports, (f) => { + ft(e.exports, (f) => { let h = c[f]; const p = []; - M(c, f, { + U(c, f, { get: () => h, set: (S) => { h = S; - for (const T of p) - T(S); + for (const x of p) + x(S); }, enumerable: !0, configurable: !1 }), u[f] = (S) => { - X(p, S), S(h); + oe(p, S), S(h); }; }), u["*"] = (f) => { f(c); @@ -3963,22 +4098,18 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { if (!d.activated) { l(), d.activated = !0; try { - e.execute( - c, - r, - a - ); + e.execute(c, r, s); } catch (f) { throw d.errorFromExecute = f, f; } } } }); -}, oc = (t, e, r, n) => { +}, nc = (t, e, r, n) => { const { compartment: o, - moduleSpecifier: a, - staticModuleRecord: i, + moduleSpecifier: s, + moduleSource: i, importMeta: c } = r, { reexports: l = [], @@ -3988,280 +4119,272 @@ const Xi = (t, e) => e, Qi = (t, e) => t, fo = async (t, e, r, n) => { __reexportMap__: h = {}, __needsImportMeta__: p = !1, __syncModuleFunctor__: m - } = i, _ = L(t, o), { __shimTransforms__: S, importMetaHook: T } = _, { exportsProxy: N, exportsTarget: x, activate: D } = Dn( + } = i, _ = z(t, o), { __shimTransforms__: S, importMetaHook: x } = _, { exportsProxy: I, exportsTarget: E, activate: L } = Mn( o, _, e, - a - ), G = Z(null), B = Z(null), K = Z(null), ze = Z(null), he = Z(null); - c && $r(he, c), p && T && T(a, he); - const Ge = Z(null), rt = Z(null); - ut(re(d), ([me, [H]]) => { - let V = Ge[H]; - if (!V) { - let ee, te = !0, ce = []; - const Y = () => { - if (te) - throw lt(`binding ${cr(H)} not yet initialized`); - return ee; - }, be = y((we) => { - if (!te) + s + ), $ = H(null), j = H(null), F = H(null), J = H(null), X = H(null); + c && Fr(X, c), p && x && x(s, X); + const qe = H(null), st = H(null); + ft(ge(d), ([we, [W]]) => { + let q = qe[W]; + if (!q) { + let ae, ie = !0, ye = []; + const te = () => { + if (ie) + throw Bt(`binding ${mr(W)} not yet initialized`); + return ae; + }, Te = y((Ae) => { + if (!ie) throw v( - `Internal: binding ${cr(H)} already initialized` + `Internal: binding ${mr(W)} already initialized` ); - ee = we; - const Bn = ce; - ce = null, te = !1; - for (const Se of Bn || []) - Se(we); - return we; + ae = Ae; + const Zn = ye; + ye = null, ie = !1; + for (const Ie of Zn || []) + Ie(Ae); + return Ae; }); - V = { - get: Y, - notify: (we) => { - we !== be && (te ? X(ce || [], we) : we(ee)); + q = { + get: te, + notify: (Ae) => { + Ae !== Te && (ie ? oe(ye || [], Ae) : Ae(ae)); } - }, Ge[H] = V, K[H] = be; + }, qe[W] = q, F[W] = Te; } - G[me] = { - get: V.get, + $[we] = { + get: q.get, set: void 0, enumerable: !0, configurable: !1 - }, rt[me] = V.notify; - }), ut( - re(f), - ([me, [H, V]]) => { - let ee = Ge[H]; - if (!ee) { - let te, ce = !0; - const Y = [], be = () => { - if (ce) - throw lt( - `binding ${cr(me)} not yet initialized` + }, st[we] = q.notify; + }), ft( + ge(f), + ([we, [W, q]]) => { + let ae = qe[W]; + if (!ae) { + let ie, ye = !0; + const te = [], Te = () => { + if (ye) + throw Bt( + `binding ${mr(we)} not yet initialized` ); - return te; - }, gt = y((Se) => { - te = Se, ce = !1; - for (const zr of Y) - zr(Se); - }), we = (Se) => { - if (ce) - throw lt(`binding ${cr(H)} not yet initialized`); - te = Se; - for (const zr of Y) - zr(Se); + return ie; + }, vt = y((Ie) => { + ie = Ie, ye = !1; + for (const Kr of te) + Kr(Ie); + }), Ae = (Ie) => { + if (ye) + throw Bt(`binding ${mr(W)} not yet initialized`); + ie = Ie; + for (const Kr of te) + Kr(Ie); }; - ee = { - get: be, - notify: (Se) => { - Se !== gt && (X(Y, Se), ce || Se(te)); + ae = { + get: Te, + notify: (Ie) => { + Ie !== vt && (oe(te, Ie), ye || Ie(ie)); } - }, Ge[H] = ee, V && M(B, H, { - get: be, - set: we, + }, qe[W] = ae, q && U(j, W, { + get: Te, + set: Ae, enumerable: !0, configurable: !1 - }), ze[H] = gt; + }), J[W] = vt; } - G[me] = { - get: ee.get, + $[we] = { + get: ae.get, set: void 0, enumerable: !0, configurable: !1 - }, rt[me] = ee.notify; + }, st[we] = ae.notify; } ); - const Be = (me) => { - me(x); + const Ke = (we) => { + we(E); }; - rt["*"] = Be; - function ar(me) { - const H = Z(null); - H.default = !1; - for (const [V, ee] of me) { - const te = Ue(n, V); - te.execute(); - const { notifiers: ce } = te; - for (const [Y, be] of ee) { - const gt = ce[Y]; - if (!gt) - throw tr( - `The requested module '${V}' does not provide an export named '${Y}'` + st["*"] = Ke; + function ur(we) { + const W = H(null); + W.default = !1; + for (const [q, ae] of we) { + const ie = He(n, q); + ie.execute(); + const { notifiers: ye } = ie; + for (const [te, Te] of ae) { + const vt = ye[te]; + if (!vt) + throw sr( + `The requested module '${q}' does not provide an export named '${te}'` ); - for (const we of be) - gt(we); + for (const Ae of Te) + vt(Ae); } - if (Mr(l, V)) - for (const [Y, be] of re( - ce + if (Zr(l, q)) + for (const [te, Te] of ge( + ye )) - H[Y] === void 0 ? H[Y] = be : H[Y] = !1; - if (h[V]) - for (const [Y, be] of h[V]) - H[be] = ce[Y]; + W[te] === void 0 ? W[te] = Te : W[te] = !1; + if (h[q]) + for (const [te, Te] of h[q]) + W[Te] = ye[te]; } - for (const [V, ee] of re(H)) - if (!rt[V] && ee !== !1) { - rt[V] = ee; - let te; - ee((Y) => te = Y), G[V] = { + for (const [q, ae] of ge(W)) + if (!st[q] && ae !== !1) { + st[q] = ae; + let ie; + ae((te) => ie = te), $[q] = { get() { - return te; + return ie; }, set: void 0, enumerable: !0, configurable: !1 }; } - ut( - Oo(Eo(G)), - (V) => M(x, V, G[V]) - ), y(x), D(); + ft( + Oo(So($)), + (q) => U(E, q, $[q]) + ), y(E), L(); } - let Ot; - m !== void 0 ? Ot = m : Ot = Ts(_, u, { + let Dt; + m !== void 0 ? Dt = m : Dt = Ts(_, u, { globalObject: o.globalThis, transforms: S, - __moduleShimLexicals__: B + __moduleShimLexicals__: j }); - let zn = !1, Gn; - function Gs() { - if (Ot) { - const me = Ot; - Ot = null; + let Un = !1, jn; + function Bs() { + if (Dt) { + const we = Dt; + Dt = null; try { - me( + we( y({ - imports: y(ar), - onceVar: y(K), - liveVar: y(ze), - importMeta: he + imports: y(ur), + onceVar: y(F), + liveVar: y(J), + importMeta: X }) ); - } catch (H) { - zn = !0, Gn = H; + } catch (W) { + Un = !0, jn = W; } } - if (zn) - throw Gn; + if (Un) + throw jn; } return y({ - notifiers: rt, - exportsProxy: N, - execute: Gs + notifiers: st, + exportsProxy: I, + execute: Bs }); -}, { Fail: ct, quote: q } = z, As = (t, e, r, n) => { - const { name: o, moduleRecords: a } = L( +}, { Fail: dt, quote: Q } = ee, As = (t, e, r, n) => { + const { name: o, moduleRecords: s } = z( t, r - ), i = Ue(a, n); + ), i = He(s, n); if (i === void 0) - throw lt( - `Missing link to module ${q(n)} from compartment ${q( + throw Bt( + `Missing link to module ${Q(n)} from compartment ${Q( o )}` ); - return uc(t, e, i); + return lc(t, e, i); }; -function sc(t) { +function oc(t) { return typeof t.__syncModuleProgram__ == "string"; } -function ac(t, e) { +function sc(t, e) { const { __fixedExportMap__: r, __liveExportMap__: n } = t; - Ye(r) || ct`Property '__fixedExportMap__' of a precompiled module record must be an object, got ${q( + ke(r) || dt`Property '__fixedExportMap__' of a precompiled module source must be an object, got ${Q( r - )}, for module ${q(e)}`, Ye(n) || ct`Property '__liveExportMap__' of a precompiled module record must be an object, got ${q( + )}, for module ${Q(e)}`, ke(n) || dt`Property '__liveExportMap__' of a precompiled module source must be an object, got ${Q( n - )}, for module ${q(e)}`; + )}, for module ${Q(e)}`; } -function ic(t) { +function ac(t) { return typeof t.execute == "function"; } -function cc(t, e) { +function ic(t, e) { const { exports: r } = t; - Et(r) || ct`Property 'exports' of a third-party static module record must be an array, got ${q( + Et(r) || dt`Property 'exports' of a third-party module source must be an array, got ${Q( r - )}, for module ${q(e)}`; + )}, for module ${Q(e)}`; } -function lc(t, e) { - Ye(t) || ct`Static module records must be of type object, got ${q( +function cc(t, e) { + ke(t) || dt`Module sources must be of type object, got ${Q( t - )}, for module ${q(e)}`; + )}, for module ${Q(e)}`; const { imports: r, exports: n, reexports: o = [] } = t; - Et(r) || ct`Property 'imports' of a static module record must be an array, got ${q( + Et(r) || dt`Property 'imports' of a module source must be an array, got ${Q( r - )}, for module ${q(e)}`, Et(n) || ct`Property 'exports' of a precompiled module record must be an array, got ${q( + )}, for module ${Q(e)}`, Et(n) || dt`Property 'exports' of a precompiled module source must be an array, got ${Q( n - )}, for module ${q(e)}`, Et(o) || ct`Property 'reexports' of a precompiled module record must be an array if present, got ${q( + )}, for module ${Q(e)}`, Et(o) || dt`Property 'reexports' of a precompiled module source must be an array if present, got ${Q( o - )}, for module ${q(e)}`; + )}, for module ${Q(e)}`; } -const uc = (t, e, r) => { - const { compartment: n, moduleSpecifier: o, resolvedImports: a, staticModuleRecord: i } = r, { instances: c } = L(t, n); - if (Lr(c, o)) - return Ue(c, o); - lc(i, o); - const l = new Pe(); +const lc = (t, e, r) => { + const { compartment: n, moduleSpecifier: o, resolvedImports: s, moduleSource: i } = r, { instances: c } = z(t, n); + if (zr(c, o)) + return He(c, o); + cc(i, o); + const l = new Re(); let u; - if (sc(i)) - ac(i, o), u = oc( + if (oc(i)) + sc(i, o), u = nc( t, e, r, l ); - else if (ic(i)) - cc(i, o), u = nc( + else if (ac(i)) + ic(i, o), u = rc( t, i, n, e, o, - a + s ); else throw v( - `importHook must return a static module record, got ${q( - i - )}` + `importHook must provide a module source, got ${Q(i)}` ); - $e(c, o, u); - for (const [d, f] of re(a)) { + he(c, o, u); + for (const [d, f] of ge(s)) { const h = As( t, e, n, f ); - $e(l, d, h); + he(l, d, h); } return u; -}, { quote: Xr } = z, bt = new Me(), Ce = new Me(), lr = (t) => { - const { importHook: e, resolveHook: r } = L(Ce, t); - if (typeof e != "function" || typeof r != "function") - throw v( - "Compartment must be constructed with an importHook and a resolveHook for it to be able to load modules" - ); -}, Un = function(e = {}, r = {}, n = {}) { +}, jt = new je(), Me = new je(), Ln = function(e = {}, r = {}, n = {}) { throw v( "Compartment.prototype.constructor is not a valid constructor." ); -}, po = (t, e) => { +}, uo = (t, e) => { const { execute: r, exportsProxy: n } = As( - Ce, - bt, + Me, + jt, t, e ); return r(), n; -}, jn = { - constructor: Un, +}, Fn = { + constructor: Ln, get globalThis() { - return L(Ce, this).globalObject; + return z(Me, this).globalObject; }, get name() { - return L(Ce, this).name; + return z(Me, this).name; }, /** * @param {string} source is a JavaScript program grammar construction. @@ -4274,151 +4397,180 @@ const uc = (t, e, r) => { * @param {boolean} [options.__rejectSomeDirectEvalExpressions__] */ evaluate(t, e = {}) { - const r = L(Ce, this); + const r = z(Me, this); return Ts(r, t, e); }, module(t) { if (typeof t != "string") throw v("first argument of module() must be a string"); - lr(this); - const { exportsProxy: e } = Dn( + const { exportsProxy: e } = Mn( this, - L(Ce, this), - bt, + z(Me, this), + jt, t ); return e; }, async import(t) { + const { noNamespaceBox: e } = z(Me, this); if (typeof t != "string") throw v("first argument of import() must be a string"); - return lr(this), Uo( - fo(Ce, bt, this, t), - () => ({ namespace: po( - /** @type {Compartment} */ - this, - t - ) }) + return Uo( + lo(Me, jt, this, t), + () => { + const r = uo( + /** @type {Compartment} */ + this, + t + ); + return e ? r : { namespace: r }; + } ); }, async load(t) { if (typeof t != "string") throw v("first argument of load() must be a string"); - return lr(this), fo(Ce, bt, this, t); + return lo(Me, jt, this, t); }, importNow(t) { if (typeof t != "string") throw v("first argument of importNow() must be a string"); - return lr(this), ec(Ce, bt, this, t), po( + return Qi(Me, jt, this, t), uo( /** @type {Compartment} */ this, t ); } }; -F(jn, { - [qe]: { +B(Fn, { + [Qe]: { value: "Compartment", writable: !1, enumerable: !1, configurable: !0 } }); -F(Un, { - prototype: { value: jn } +B(Ln, { + prototype: { value: Fn } }); -const dn = (t, e, r) => { - function n(o = {}, a = {}, i = {}) { +const uc = (...t) => { + if (t.length === 0) + return {}; + if (t.length === 1 && typeof t[0] == "object" && t[0] !== null && "__options__" in t[0]) { + const { __options__: e, ...r } = t[0]; + return assert( + e === !0, + `Compartment constructor only supports true __options__ sigil, got ${e}` + ), r; + } else { + const [ + e = ( + /** @type {Map} */ + {} + ), + r = ( + /** @type {Map} */ + {} + ), + n = {} + ] = t; + return Kn( + n.modules, + void 0, + "Compartment constructor must receive either a module map argument or modules option, not both" + ), Kn( + n.globals, + void 0, + "Compartment constructor must receive either globals argument or option, not both" + ), { + ...n, + globals: e, + modules: r + }; + } +}, fn = (t, e, r, n = void 0) => { + function o(...s) { if (new.target === void 0) throw v( "Class constructor Compartment cannot be invoked without 'new'" ); const { - name: c = "", - transforms: l = [], - __shimTransforms__: u = [], - resolveHook: d, - importHook: f, - importNowHook: h, - moduleMapHook: p, - importMetaHook: m - } = i, _ = [...l, ...u], S = new Pe(), T = new Pe(), N = new Pe(); - for (const [G, B] of re(a || {})) { - if (typeof B == "string") - throw v( - `Cannot map module ${Xr(G)} to ${Xr( - B - )} in parent compartment` - ); - if (L(bt, B) === void 0) - throw lt( - `Cannot map module ${Xr( - G - )} because it has no known compartment in this realm` - ); - } - const x = {}; - li(x), cs(x); - const { safeEvaluate: D } = On({ - globalObject: x, - globalTransforms: _, + name: i = "", + transforms: c = [], + __shimTransforms__: l = [], + globals: u = {}, + modules: d = {}, + resolveHook: f, + importHook: h, + importNowHook: p, + moduleMapHook: m, + importMetaHook: _, + __noNamespaceBox__: S = !1 + } = uc(...s), x = [...c, ...l], I = { __proto__: null, ...u }, E = { __proto__: null, ...d }, L = new Re(), $ = new Re(), j = new Re(), F = {}; + li(F), cs(F); + const { safeEvaluate: J } = Rn({ + globalObject: F, + globalTransforms: x, sloppyGlobalsMode: !1 }); - ls(x, { + ls(F, { intrinsics: e, newGlobalPropertyNames: rs, makeCompartmentConstructor: t, + parentCompartment: this, markVirtualizedNativeFunction: r - }), ln( - x, - D, + }), dn( + F, + J, r - ), $r(x, o), ie(Ce, this, { - name: `${c}`, - globalTransforms: _, - globalObject: x, - safeEvaluate: D, - resolveHook: d, - importHook: f, - importNowHook: h, - moduleMap: a, - moduleMapHook: p, - importMetaHook: m, - moduleRecords: S, - __shimTransforms__: u, - deferredExports: N, - instances: T + ), Fr(F, I), me(Me, this, { + name: `${i}`, + globalTransforms: x, + globalObject: F, + safeEvaluate: J, + resolveHook: f, + importHook: h, + importNowHook: p, + moduleMap: E, + moduleMapHook: m, + importMetaHook: _, + moduleRecords: L, + __shimTransforms__: l, + deferredExports: j, + instances: $, + parentCompartment: n, + noNamespaceBox: S }); } - return n.prototype = jn, n; + return o.prototype = Fn, o; }; -function Qr(t) { - return j(t).constructor; +function nn(t) { + return V(t).constructor; } function dc() { return arguments; } const fc = () => { - const t = ve.prototype.constructor, e = J(dc(), "callee"), r = e && e.get, n = _a(new pe()), o = j(n), a = Rr[Po] && ga(/./), i = a && j(a), c = da([]), l = j(c), u = j(Vs), d = ha(new Pe()), f = j(d), h = ma(new Ct()), p = j(h), m = j(l); + const t = Ee.prototype.constructor, e = ne(dc(), "callee"), r = e && e.get, n = _a(new be()), o = V(n), s = Ur[Po] && ga(/./), i = s && V(s), c = da([]), l = V(c), u = V(Hs), d = ha(new Re()), f = V(d), h = ma(new Ot()), p = V(h), m = V(l); function* _() { } - const S = Qr(_), T = S.prototype; - async function* N() { + const S = nn(_), x = S.prototype; + async function* I() { } - const x = Qr( - N - ), D = x.prototype, G = D.prototype, B = j(G); - async function K() { + const E = nn( + I + ), L = E.prototype, $ = L.prototype, j = V($); + async function F() { } - const ze = Qr(K), he = { + const J = nn(F), X = { "%InertFunction%": t, "%ArrayIteratorPrototype%": l, - "%InertAsyncFunction%": ze, - "%AsyncGenerator%": D, - "%InertAsyncGeneratorFunction%": x, - "%AsyncGeneratorPrototype%": G, - "%AsyncIteratorPrototype%": B, - "%Generator%": T, + "%InertAsyncFunction%": J, + "%AsyncGenerator%": L, + "%InertAsyncGeneratorFunction%": E, + "%AsyncGeneratorPrototype%": $, + "%AsyncIteratorPrototype%": j, + "%Generator%": x, "%InertGeneratorFunction%": S, "%IteratorPrototype%": m, "%MapIteratorPrototype%": f, @@ -4427,23 +4579,23 @@ const fc = () => { "%StringIteratorPrototype%": o, "%ThrowTypeError%": r, "%TypedArray%": u, - "%InertCompartment%": Un + "%InertCompartment%": Ln }; - return k.Iterator && (he["%IteratorHelperPrototype%"] = j( + return T.Iterator && (X["%IteratorHelperPrototype%"] = V( // eslint-disable-next-line @endo/no-polymorphic-call - k.Iterator.from([]).take(0) - ), he["%WrapForValidIteratorPrototype%"] = j( + T.Iterator.from([]).take(0) + ), X["%WrapForValidIteratorPrototype%"] = V( // eslint-disable-next-line @endo/no-polymorphic-call - k.Iterator.from({ next() { + T.Iterator.from({ next() { } }) - )), k.AsyncIterator && (he["%AsyncIteratorHelperPrototype%"] = j( + )), T.AsyncIterator && (X["%AsyncIteratorHelperPrototype%"] = V( // eslint-disable-next-line @endo/no-polymorphic-call - k.AsyncIterator.from([]).take(0) - ), he["%WrapForValidAsyncIteratorPrototype%"] = j( + T.AsyncIterator.from([]).take(0) + ), X["%WrapForValidAsyncIteratorPrototype%"] = V( // eslint-disable-next-line @endo/no-polymorphic-call - k.AsyncIterator.from({ next() { + T.AsyncIterator.from({ next() { } }) - )), he; + )), X; }, Is = (t, e) => { if (e !== "safe" && e !== "unsafe") throw v(`unrecognized fakeHardenOption ${e}`); @@ -4454,69 +4606,69 @@ const fc = () => { }; y(Is); const pc = () => { - const t = St, e = t.prototype, r = Sa(St, void 0); - F(e, { + const t = St, e = t.prototype, r = xa(St, void 0); + B(e, { constructor: { value: r // leave other `constructor` attributes as is } }); - const n = re( + const n = ge( Ze(t) - ), o = mt( - se(n, ([a, i]) => [ - a, + ), o = yt( + fe(n, ([s, i]) => [ + s, { ...i, configurable: !0 } ]) ); - return F(r, o), { "%SharedSymbol%": r }; + return B(r, o), { "%SharedSymbol%": r }; }, hc = (t) => { try { return t(), !1; } catch { return !0; } -}, ho = (t, e, r) => { +}, fo = (t, e, r) => { if (t === void 0) return !1; - const n = J(t, e); + const n = ne(t, e); if (!n || "value" in n) return !1; - const { get: o, set: a } = n; - if (typeof o != "function" || typeof a != "function" || o() !== r || ne(o, t, []) !== r) + const { get: o, set: s } = n; + if (typeof o != "function" || typeof s != "function" || o() !== r || ue(o, t, []) !== r) return !1; const i = "Seems to be a setter", c = { __proto__: null }; - if (ne(a, c, [i]), c[e] !== i) + if (ue(s, c, [i]), c[e] !== i) return !1; const l = { __proto__: t }; - return ne(a, l, [i]), l[e] !== i || !hc(() => ne(a, t, [r])) || "originalValue" in o || n.configurable === !1 ? !1 : (M(t, e, { + return ue(s, l, [i]), l[e] !== i || !hc(() => ue(s, t, [r])) || "originalValue" in o || n.configurable === !1 ? !1 : (U(t, e, { value: r, writable: !0, enumerable: n.enumerable, configurable: !0 }), !0); }, mc = (t) => { - ho( + fo( t["%IteratorPrototype%"], "constructor", t.Iterator - ), ho( + ), fo( t["%IteratorPrototype%"], - qe, + Qe, "Iterator" ); -}, { Fail: mo, details: go, quote: yo } = z; -let ur, dr; -const gc = Ga(), yc = () => { +}, { Fail: po, details: ho, quote: mo } = ee; +let gr, yr; +const gc = Ba(), yc = () => { let t = !1; try { - t = ve( + t = Ee( "eval", "SES_changed", ` eval("SES_changed = true"); return SES_changed; ` - )(jo, !1), t || delete k.SES_changed; + )(jo, !1), t || delete T.SES_changed; } catch { t = !0; } @@ -4526,209 +4678,218 @@ const gc = Ga(), yc = () => { ); }, Cs = (t = {}) => { const { - errorTaming: e = le("LOCKDOWN_ERROR_TAMING", "safe"), + errorTaming: e = ve("LOCKDOWN_ERROR_TAMING", "safe"), errorTrapping: r = ( /** @type {"platform" | "none" | "report" | "abort" | "exit" | undefined} */ - le("LOCKDOWN_ERROR_TRAPPING", "platform") + ve("LOCKDOWN_ERROR_TRAPPING", "platform") ), unhandledRejectionTrapping: n = ( /** @type {"none" | "report" | undefined} */ - le("LOCKDOWN_UNHANDLED_REJECTION_TRAPPING", "report") + ve("LOCKDOWN_UNHANDLED_REJECTION_TRAPPING", "report") ), - regExpTaming: o = le("LOCKDOWN_REGEXP_TAMING", "safe"), - localeTaming: a = le("LOCKDOWN_LOCALE_TAMING", "safe"), + regExpTaming: o = ve("LOCKDOWN_REGEXP_TAMING", "safe"), + localeTaming: s = ve("LOCKDOWN_LOCALE_TAMING", "safe"), consoleTaming: i = ( /** @type {'unsafe' | 'safe' | undefined} */ - le("LOCKDOWN_CONSOLE_TAMING", "safe") + ve("LOCKDOWN_CONSOLE_TAMING", "safe") ), - overrideTaming: c = le("LOCKDOWN_OVERRIDE_TAMING", "moderate"), - stackFiltering: l = le("LOCKDOWN_STACK_FILTERING", "concise"), - domainTaming: u = le("LOCKDOWN_DOMAIN_TAMING", "safe"), - evalTaming: d = le("LOCKDOWN_EVAL_TAMING", "safeEval"), - overrideDebug: f = Ke( - Tn(le("LOCKDOWN_OVERRIDE_DEBUG", ""), ","), + overrideTaming: c = ve("LOCKDOWN_OVERRIDE_TAMING", "moderate"), + stackFiltering: l = ve("LOCKDOWN_STACK_FILTERING", "concise"), + domainTaming: u = ve("LOCKDOWN_DOMAIN_TAMING", "safe"), + evalTaming: d = ve("LOCKDOWN_EVAL_TAMING", "safeEval"), + overrideDebug: f = et( + Pn(ve("LOCKDOWN_OVERRIDE_DEBUG", ""), ","), /** @param {string} debugName */ - (Be) => Be !== "" + (Ke) => Ke !== "" ), - __hardenTaming__: h = le("LOCKDOWN_HARDEN_TAMING", "safe"), + __hardenTaming__: h = ve("LOCKDOWN_HARDEN_TAMING", "safe"), dateTaming: p = "safe", // deprecated mathTaming: m = "safe", // deprecated ..._ } = t; - d === "unsafeEval" || d === "safeEval" || d === "noEval" || mo`lockdown(): non supported option evalTaming: ${yo(d)}`; - const S = De(_); - if (S.length === 0 || mo`lockdown(): non supported option ${yo(S)}`, ur === void 0 || // eslint-disable-next-line @endo/no-polymorphic-call - z.fail( - go`Already locked down at ${ur} (SES_ALREADY_LOCKED_DOWN)`, + d === "unsafeEval" || d === "safeEval" || d === "noEval" || po`lockdown(): non supported option evalTaming: ${mo(d)}`; + const S = Ve(_); + if (S.length === 0 || po`lockdown(): non supported option ${mo(S)}`, gr === void 0 || // eslint-disable-next-line @endo/no-polymorphic-call + ee.fail( + ho`Already locked down at ${gr} (SES_ALREADY_LOCKED_DOWN)`, v - ), ur = v("Prior lockdown (SES_ALREADY_LOCKED_DOWN)"), ur.stack, yc(), k.Function.prototype.constructor !== k.Function && // @ts-ignore harden is absent on globalThis type def. - typeof k.harden == "function" && // @ts-ignore lockdown is absent on globalThis type def. - typeof k.lockdown == "function" && k.Date.prototype.constructor !== k.Date && typeof k.Date.now == "function" && // @ts-ignore does not recognize that Date constructor is a special + ), gr = v("Prior lockdown (SES_ALREADY_LOCKED_DOWN)"), gr.stack, yc(), T.Function.prototype.constructor !== T.Function && // @ts-ignore harden is absent on globalThis type def. + typeof T.harden == "function" && // @ts-ignore lockdown is absent on globalThis type def. + typeof T.lockdown == "function" && T.Date.prototype.constructor !== T.Date && typeof T.Date.now == "function" && // @ts-ignore does not recognize that Date constructor is a special // Function. // eslint-disable-next-line @endo/no-polymorphic-call - Nr(k.Date.prototype.constructor.now(), NaN)) + Dr(T.Date.prototype.constructor.now(), NaN)) throw v( "Already locked down but not by this SES instance (SES_MULTIPLE_INSTANCES)" ); - Ei(u); - const N = Es(), { addIntrinsics: x, completePrototypes: D, finalIntrinsics: G } = ss(), B = Is(gc, h); - x({ harden: B }), x(Ya()), x(Ja(p)), x(Gi(e, l)), x(Xa(m)), x(Qa(o)), x(pc()), x(fc()), D(); - const K = G(), ze = { __proto__: null }; - typeof k.Buffer == "function" && (ze.Buffer = k.Buffer); - let he; - e !== "unsafe" && (he = K["%InitialGetStackString%"]); - const Ge = Ti( + Si(u); + const I = Ss(), { addIntrinsics: E, completePrototypes: L, finalIntrinsics: $ } = ss(), j = Is(gc, h); + E({ harden: j }), E(Ya()), E(Ja(p)), E(Bi(e, l)), E(Xa(m)), E(Qa(o)), E(pc()), E(fc()), L(); + const F = $(), J = { __proto__: null }; + typeof T.Buffer == "function" && (J.Buffer = T.Buffer); + let X; + e === "safe" && (X = F["%InitialGetStackString%"]); + const qe = Ti( i, r, n, - he + X ); - if (k.console = /** @type {Console} */ - Ge.console, typeof /** @type {any} */ - Ge.console._times == "object" && (ze.SafeMap = j( + if (T.console = /** @type {Console} */ + qe.console, typeof /** @type {any} */ + qe.console._times == "object" && (J.SafeMap = V( // eslint-disable-next-line no-underscore-dangle /** @type {any} */ - Ge.console._times - )), e === "unsafe" && k.assert === z && (k.assert = jr(void 0, !0)), ai(K, a), mc(K), Ka(K, N), cs(k), ls(k, { - intrinsics: K, - newGlobalPropertyNames: Jn, - makeCompartmentConstructor: dn, - markVirtualizedNativeFunction: N + qe.console._times + )), (e === "unsafe" || e === "unsafe-debug") && T.assert === ee && (T.assert = Wr(void 0, !0)), ai(F, s), mc(F), Ka(F, I), cs(T), ls(T, { + intrinsics: F, + newGlobalPropertyNames: Yn, + makeCompartmentConstructor: fn, + markVirtualizedNativeFunction: I }), d === "noEval") - ln( - k, - xa, - N + dn( + T, + Ea, + I ); else if (d === "safeEval") { - const { safeEvaluate: Be } = On({ globalObject: k }); - ln( - k, - Be, - N + const { safeEvaluate: Ke } = Rn({ globalObject: T }); + dn( + T, + Ke, + I ); } return () => { - dr === void 0 || // eslint-disable-next-line @endo/no-polymorphic-call - z.fail( - go`Already locked down at ${dr} (SES_ALREADY_LOCKED_DOWN)`, + yr === void 0 || // eslint-disable-next-line @endo/no-polymorphic-call + ee.fail( + ho`Already locked down at ${yr} (SES_ALREADY_LOCKED_DOWN)`, v - ), dr = v( + ), yr = v( "Prior lockdown (SES_ALREADY_LOCKED_DOWN)" - ), dr.stack, ri(K, c, f); - const Be = { - intrinsics: K, - hostIntrinsics: ze, + ), yr.stack, ri(F, c, f); + const Ke = { + intrinsics: F, + hostIntrinsics: J, globals: { // Harden evaluators - Function: k.Function, - eval: k.eval, + Function: T.Function, + eval: T.eval, // @ts-ignore Compartment does exist on globalThis - Compartment: k.Compartment, + Compartment: T.Compartment, // Harden Symbol - Symbol: k.Symbol + Symbol: T.Symbol } }; - for (const ar of Dt(Jn)) - Be.globals[ar] = k[ar]; - return B(Be), B; + for (const ur of It(Yn)) + Ke.globals[ur] = T[ur]; + return j(Ke), j; }; }; -k.lockdown = (t) => { +T.lockdown = (t) => { const e = Cs(t); - k.harden = e(); + T.harden = e(); }; -k.repairIntrinsics = (t) => { +T.repairIntrinsics = (t) => { const e = Cs(t); - k.hardenIntrinsics = () => { - k.harden = e(); + T.hardenIntrinsics = () => { + T.harden = e(); }; }; -const vc = Es(); -k.Compartment = dn( - dn, - qa(k), +const vc = Ss(); +T.Compartment = fn( + fn, + qa(T), vc ); -k.assert = z; -const _c = ks(br), bc = ta( +T.assert = ee; +const _c = ks(Pr), bc = ta( "MAKE_CAUSAL_CONSOLE_FROM_LOGGER_KEY_FOR_SES_AVA" ); -k[bc] = _c; -const wc = (t, e) => { - let r = { x: 0, y: 0 }, n = { x: 0, y: 0 }, o = { x: 0, y: 0 }; - const a = (l) => { - const { clientX: u, clientY: d } = l, f = u - o.x + n.x, h = d - o.y + n.y; - r = { x: f, y: h }, t.style.transform = `translate(${f}px, ${h}px)`, e == null || e(); - }, i = () => { - document.removeEventListener("mousemove", a), document.removeEventListener("mouseup", i); - }, c = (l) => { - o = { x: l.clientX, y: l.clientY }, n = { x: r.x, y: r.y }, document.addEventListener("mousemove", a), document.addEventListener("mouseup", i); +T[bc] = _c; +const wc = (t, e = t, r) => { + let n = { x: 0, y: 0 }, o = { x: 0, y: 0 }, s = { x: 0, y: 0 }; + const i = (u) => { + const { clientX: d, clientY: f } = u, h = d - s.x + o.x, p = f - s.y + o.y; + n = { x: h, y: p }, e.style.transform = `translate(${h}px, ${p}px)`, r == null || r(); + }, c = () => { + document.removeEventListener("mousemove", i), document.removeEventListener("mouseup", c); + }, l = (u) => { + s = { x: u.clientX, y: u.clientY }, o = { x: n.x, y: n.y }, document.addEventListener("mousemove", i), document.addEventListener("mouseup", c); }; - return t.addEventListener("mousedown", c), i; -}, Sc = ":host{--spacing-4: .25rem;--spacing-8: calc(var(--spacing-4) * 2);--spacing-12: calc(var(--spacing-4) * 3);--spacing-16: calc(var(--spacing-4) * 4);--spacing-20: calc(var(--spacing-4) * 5);--spacing-24: calc(var(--spacing-4) * 6);--spacing-28: calc(var(--spacing-4) * 7);--spacing-32: calc(var(--spacing-4) * 8);--spacing-36: calc(var(--spacing-4) * 9);--spacing-40: calc(var(--spacing-4) * 10);--font-weight-regular: 400;--font-weight-bold: 500;--font-line-height-s: 1.2;--font-line-height-m: 1.4;--font-line-height-l: 1.5;--font-size-s: 12px;--font-size-m: 14px;--font-size-l: 16px}[data-theme]{background-color:var(--color-background-primary);color:var(--color-foreground-secondary)}.wrapper{box-sizing:border-box;display:flex;flex-direction:column;position:fixed;inset-block-start:var(--modal-block-start);inset-inline-end:var(--modal-inline-end);z-index:1000;padding:25px;border-radius:15px;border:2px solid var(--color-background-quaternary);box-shadow:0 0 10px #0000004d}.header{align-items:center;display:flex;justify-content:space-between;border-block-end:2px solid var(--color-background-quaternary);padding-block-end:var(--spacing-4)}button{background:transparent;border:0;cursor:pointer;padding:0}h1{font-size:var(--font-size-s);font-weight:var(--font-weight-bold);margin:0;margin-inline-end:var(--spacing-4);-webkit-user-select:none;user-select:none}iframe{border:none;inline-size:100%;block-size:100%}", Ec = ` + return t.addEventListener("mousedown", l), c; +}, xc = `:host{--spacing-4: .25rem;--spacing-8: calc(var(--spacing-4) * 2);--spacing-12: calc(var(--spacing-4) * 3);--spacing-16: calc(var(--spacing-4) * 4);--spacing-20: calc(var(--spacing-4) * 5);--spacing-24: calc(var(--spacing-4) * 6);--spacing-28: calc(var(--spacing-4) * 7);--spacing-32: calc(var(--spacing-4) * 8);--spacing-36: calc(var(--spacing-4) * 9);--spacing-40: calc(var(--spacing-4) * 10);--font-weight-regular: 400;--font-weight-bold: 500;--font-line-height-s: 1.2;--font-line-height-m: 1.4;--font-line-height-l: 1.5;--font-size-s: 12px;--font-size-m: 14px;--font-size-l: 16px}[data-theme]{background-color:var(--color-background-primary);color:var(--color-foreground-secondary)}::-webkit-resizer{display:none}.wrapper{position:absolute;inset-block-start:var(--modal-block-start);inset-inline-start:var(--modal-inline-start);z-index:1000;padding:10px;border-radius:15px;border:2px solid var(--color-background-quaternary);box-shadow:0 0 10px #0000004d;overflow:hidden;min-inline-size:25px;min-block-size:200px;resize:both}.wrapper:after{content:"";cursor:se-resize;inline-size:1rem;block-size:1rem;background-image:url("data:image/svg+xml,%3csvg%20width='16.022'%20xmlns='http://www.w3.org/2000/svg'%20height='16.022'%20viewBox='-0.011%20-0.011%2016.022%2016.022'%20fill='none'%3e%3cg%20data-testid='Group'%3e%3cg%20data-testid='Path'%3e%3cpath%20d='M.011%2015.917%2015.937-.011'%20class='fills'/%3e%3cg%20class='strokes'%3e%3cpath%20d='M.011%2015.917%2015.937-.011'%20style='fill:%20none;%20stroke-width:%201;%20stroke:%20rgb(111,%20111,%20111);%20stroke-opacity:%201;%20stroke-linecap:%20round;'%20class='stroke-shape'/%3e%3c/g%3e%3c/g%3e%3cg%20data-testid='Path'%3e%3cpath%20d='m11.207%2014.601%203.361-3.401'%20class='fills'/%3e%3cg%20class='strokes'%3e%3cpath%20d='m11.207%2014.601%203.361-3.401'%20style='fill:%20none;%20stroke-width:%201;%20stroke:%20rgb(111,%20111,%20111);%20stroke-opacity:%201;%20stroke-linecap:%20round;'%20class='stroke-shape'/%3e%3c/g%3e%3c/g%3e%3cg%20data-testid='Path'%3e%3cpath%20d='m4.884%2016.004%2011.112-11.17'%20class='fills'/%3e%3cg%20class='strokes'%3e%3cpath%20d='m4.884%2016.004%2011.112-11.17'%20style='fill:%20none;%20stroke-width:%201;%20stroke:%20rgb(111,%20111,%20111);%20stroke-opacity:%201;%20stroke-linecap:%20round;'%20class='stroke-shape'/%3e%3c/g%3e%3c/g%3e%3c/g%3e%3c/svg%3e");background-position:center;right:5px;bottom:5px;pointer-events:none;position:absolute}.inner{padding:10px;cursor:grab;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden;block-size:100%}.inner>*{flex:1}.inner>.header{flex:0}.header{align-items:center;display:flex;justify-content:space-between;border-block-end:2px solid var(--color-background-quaternary);padding-block-end:var(--spacing-4)}button{background:transparent;border:0;cursor:pointer;padding:0}h1{font-size:var(--font-size-s);font-weight:var(--font-weight-bold);margin:0;margin-inline-end:var(--spacing-4);-webkit-user-select:none;user-select:none}iframe{border:none;inline-size:100%;block-size:100%}`, Sc = ` `; -var de, er; -class xc extends HTMLElement { +var re, Ge, or; +class Ec extends HTMLElement { constructor() { super(); - Gr(this, de, null); - Gr(this, er, null); + dr(this, re, null); + dr(this, Ge, null); + dr(this, or, null); this.attachShadow({ mode: "open" }); } setTheme(r) { - Ee(this, de) && Ee(this, de).setAttribute("data-theme", r); + Y(this, re) && Y(this, re).setAttribute("data-theme", r); } disconnectedCallback() { var r; - (r = Ee(this, er)) == null || r.call(this); + (r = Y(this, or)) == null || r.call(this); } calculateZIndex() { - const r = document.querySelectorAll("plugin-modal"), n = Array.from(r).filter((a) => a !== this).map((a) => Number(a.style.zIndex)), o = Math.max(...n, 0); + const r = document.querySelectorAll("plugin-modal"), n = Array.from(r).filter((s) => s !== this).map((s) => Number(s.style.zIndex)), o = Math.max(...n, 0); this.style.zIndex = (o + 1).toString(); } connectedCallback() { - const r = this.getAttribute("title"), n = this.getAttribute("iframe-src"), o = Number(this.getAttribute("width") || "300"), a = Number(this.getAttribute("height") || "400"); + const r = this.getAttribute("title"), n = this.getAttribute("iframe-src"), o = Number(this.getAttribute("width") || "300"), s = Number(this.getAttribute("height") || "400"), i = this.getAttribute("allow-downloads") || !1; if (!r || !n) throw new Error("title and iframe-src attributes are required"); if (!this.shadowRoot) throw new Error("Error creating shadow root"); - Br(this, de, document.createElement("div")), Ee(this, de).classList.add("wrapper"), Ee(this, de).style.inlineSize = `${o}px`, Ee(this, de).style.blockSize = `${a}px`, Br(this, er, wc(Ee(this, de), () => { + fr(this, re, document.createElement("div")), fr(this, Ge, document.createElement("div")), Y(this, Ge).classList.add("inner"), Y(this, re).classList.add("wrapper"), Y(this, re).style.inlineSize = `${o}px`, Y(this, re).style.minInlineSize = `${o}px`, Y(this, re).style.blockSize = `${s}px`, Y(this, re).style.minBlockSize = `${s}px`, Y(this, re).style.maxInlineSize = "90vw", Y(this, re).style.maxBlockSize = "90vh", fr(this, or, wc(Y(this, Ge), Y(this, re), () => { this.calculateZIndex(); })); - const i = document.createElement("div"); - i.classList.add("header"); - const c = document.createElement("h1"); - c.textContent = r, i.appendChild(c); - const l = document.createElement("button"); - l.setAttribute("type", "button"), l.innerHTML = `
${Ec}
`, l.addEventListener("click", () => { + const c = document.createElement("div"); + c.classList.add("header"); + const l = document.createElement("h1"); + l.textContent = r, c.appendChild(l); + const u = document.createElement("button"); + u.setAttribute("type", "button"), u.innerHTML = `
${Sc}
`, u.addEventListener("click", () => { this.shadowRoot && this.shadowRoot.dispatchEvent( new CustomEvent("close", { composed: !0, bubbles: !0 }) ); - }), i.appendChild(l); - const u = document.createElement("iframe"); - u.src = n, u.allow = "", u.sandbox.add( + }), c.appendChild(u); + const d = document.createElement("iframe"); + d.src = n, d.allow = "", d.sandbox.add( "allow-scripts", "allow-forms", "allow-modals", "allow-popups", "allow-popups-to-escape-sandbox", "allow-storage-access-by-user-activation" - ), this.addEventListener("message", (f) => { - u.contentWindow && u.contentWindow.postMessage(f.detail, "*"); - }), this.shadowRoot.appendChild(Ee(this, de)), Ee(this, de).appendChild(i), Ee(this, de).appendChild(u); - const d = document.createElement("style"); - d.textContent = Sc, this.shadowRoot.appendChild(d), this.calculateZIndex(); + ), i && d.sandbox.add("allow-downloads"), d.addEventListener("load", () => { + var h; + (h = this.shadowRoot) == null || h.dispatchEvent( + new CustomEvent("load", { + composed: !0, + bubbles: !0 + }) + ); + }), this.addEventListener("message", (h) => { + d.contentWindow && d.contentWindow.postMessage(h.detail, "*"); + }), this.shadowRoot.appendChild(Y(this, re)), Y(this, re).appendChild(Y(this, Ge)), Y(this, Ge).appendChild(c), Y(this, Ge).appendChild(d); + const f = document.createElement("style"); + f.textContent = xc, this.shadowRoot.appendChild(f), this.calculateZIndex(); } } -de = new WeakMap(), er = new WeakMap(); -customElements.define("plugin-modal", xc); -var O; +re = new WeakMap(), Ge = new WeakMap(), or = new WeakMap(); +customElements.define("plugin-modal", Ec); +var D; (function(t) { t.assertEqual = (o) => o; function e(o) { @@ -4738,41 +4899,41 @@ var O; throw new Error(); } t.assertNever = r, t.arrayToEnum = (o) => { - const a = {}; + const s = {}; for (const i of o) - a[i] = i; - return a; + s[i] = i; + return s; }, t.getValidEnumValues = (o) => { - const a = t.objectKeys(o).filter((c) => typeof o[o[c]] != "number"), i = {}; - for (const c of a) + const s = t.objectKeys(o).filter((c) => typeof o[o[c]] != "number"), i = {}; + for (const c of s) i[c] = o[c]; return t.objectValues(i); - }, t.objectValues = (o) => t.objectKeys(o).map(function(a) { - return o[a]; + }, t.objectValues = (o) => t.objectKeys(o).map(function(s) { + return o[s]; }), t.objectKeys = typeof Object.keys == "function" ? (o) => Object.keys(o) : (o) => { - const a = []; + const s = []; for (const i in o) - Object.prototype.hasOwnProperty.call(o, i) && a.push(i); - return a; - }, t.find = (o, a) => { + Object.prototype.hasOwnProperty.call(o, i) && s.push(i); + return s; + }, t.find = (o, s) => { for (const i of o) - if (a(i)) + if (s(i)) return i; }, t.isInteger = typeof Number.isInteger == "function" ? (o) => Number.isInteger(o) : (o) => typeof o == "number" && isFinite(o) && Math.floor(o) === o; - function n(o, a = " | ") { - return o.map((i) => typeof i == "string" ? `'${i}'` : i).join(a); + function n(o, s = " | ") { + return o.map((i) => typeof i == "string" ? `'${i}'` : i).join(s); } - t.joinValues = n, t.jsonStringifyReplacer = (o, a) => typeof a == "bigint" ? a.toString() : a; -})(O || (O = {})); -var fn; + t.joinValues = n, t.jsonStringifyReplacer = (o, s) => typeof s == "bigint" ? s.toString() : s; +})(D || (D = {})); +var pn; (function(t) { t.mergeShapes = (e, r) => ({ ...e, ...r // second overwrites first }); -})(fn || (fn = {})); -const w = O.arrayToEnum([ +})(pn || (pn = {})); +const w = D.arrayToEnum([ "string", "nan", "number", @@ -4793,7 +4954,7 @@ const w = O.arrayToEnum([ "never", "map", "set" -]), Ve = (t) => { +]), Je = (t) => { switch (typeof t) { case "undefined": return w.undefined; @@ -4814,7 +4975,7 @@ const w = O.arrayToEnum([ default: return w.unknown; } -}, g = O.arrayToEnum([ +}, g = D.arrayToEnum([ "invalid_type", "invalid_literal", "custom", @@ -4832,7 +4993,7 @@ const w = O.arrayToEnum([ "not_multiple_of", "not_finite" ]), kc = (t) => JSON.stringify(t, null, 2).replace(/"([^"]+)":/g, "$1:"); -class fe extends Error { +class _e extends Error { constructor(e) { super(), this.issues = [], this.addIssue = (n) => { this.issues = [...this.issues, n]; @@ -4846,10 +5007,10 @@ class fe extends Error { return this.issues; } format(e) { - const r = e || function(a) { - return a.message; - }, n = { _errors: [] }, o = (a) => { - for (const i of a.issues) + const r = e || function(s) { + return s.message; + }, n = { _errors: [] }, o = (s) => { + for (const i of s.issues) if (i.code === "invalid_union") i.unionErrors.map(o); else if (i.code === "invalid_return_type") @@ -4869,14 +5030,14 @@ class fe extends Error { return o(this), n; } static assert(e) { - if (!(e instanceof fe)) + if (!(e instanceof _e)) throw new Error(`Not a ZodError: ${e}`); } toString() { return this.message; } get message() { - return JSON.stringify(this.issues, O.jsonStringifyReplacer, 2); + return JSON.stringify(this.issues, D.jsonStringifyReplacer, 2); } get isEmpty() { return this.issues.length === 0; @@ -4891,27 +5052,27 @@ class fe extends Error { return this.flatten(); } } -fe.create = (t) => new fe(t); -const Tt = (t, e) => { +_e.create = (t) => new _e(t); +const Rt = (t, e) => { let r; switch (t.code) { case g.invalid_type: t.received === w.undefined ? r = "Required" : r = `Expected ${t.expected}, received ${t.received}`; break; case g.invalid_literal: - r = `Invalid literal value, expected ${JSON.stringify(t.expected, O.jsonStringifyReplacer)}`; + r = `Invalid literal value, expected ${JSON.stringify(t.expected, D.jsonStringifyReplacer)}`; break; case g.unrecognized_keys: - r = `Unrecognized key(s) in object: ${O.joinValues(t.keys, ", ")}`; + r = `Unrecognized key(s) in object: ${D.joinValues(t.keys, ", ")}`; break; case g.invalid_union: r = "Invalid input"; break; case g.invalid_union_discriminator: - r = `Invalid discriminator value. Expected ${O.joinValues(t.options)}`; + r = `Invalid discriminator value. Expected ${D.joinValues(t.options)}`; break; case g.invalid_enum_value: - r = `Invalid enum value. Expected ${O.joinValues(t.options)}, received '${t.received}'`; + r = `Invalid enum value. Expected ${D.joinValues(t.options)}, received '${t.received}'`; break; case g.invalid_arguments: r = "Invalid function arguments"; @@ -4923,7 +5084,7 @@ const Tt = (t, e) => { r = "Invalid date"; break; case g.invalid_string: - typeof t.validation == "object" ? "includes" in t.validation ? (r = `Invalid input: must include "${t.validation.includes}"`, typeof t.validation.position == "number" && (r = `${r} at one or more positions greater than or equal to ${t.validation.position}`)) : "startsWith" in t.validation ? r = `Invalid input: must start with "${t.validation.startsWith}"` : "endsWith" in t.validation ? r = `Invalid input: must end with "${t.validation.endsWith}"` : O.assertNever(t.validation) : t.validation !== "regex" ? r = `Invalid ${t.validation}` : r = "Invalid"; + typeof t.validation == "object" ? "includes" in t.validation ? (r = `Invalid input: must include "${t.validation.includes}"`, typeof t.validation.position == "number" && (r = `${r} at one or more positions greater than or equal to ${t.validation.position}`)) : "startsWith" in t.validation ? r = `Invalid input: must start with "${t.validation.startsWith}"` : "endsWith" in t.validation ? r = `Invalid input: must end with "${t.validation.endsWith}"` : D.assertNever(t.validation) : t.validation !== "regex" ? r = `Invalid ${t.validation}` : r = "Invalid"; break; case g.too_small: t.type === "array" ? r = `Array must contain ${t.exact ? "exactly" : t.inclusive ? "at least" : "more than"} ${t.minimum} element(s)` : t.type === "string" ? r = `String must contain ${t.exact ? "exactly" : t.inclusive ? "at least" : "over"} ${t.minimum} character(s)` : t.type === "number" ? r = `Number must be ${t.exact ? "exactly equal to " : t.inclusive ? "greater than or equal to " : "greater than "}${t.minimum}` : t.type === "date" ? r = `Date must be ${t.exact ? "exactly equal to " : t.inclusive ? "greater than or equal to " : "greater than "}${new Date(Number(t.minimum))}` : r = "Invalid input"; @@ -4944,26 +5105,26 @@ const Tt = (t, e) => { r = "Number must be finite"; break; default: - r = e.defaultError, O.assertNever(t); + r = e.defaultError, D.assertNever(t); } return { message: r }; }; -let $s = Tt; +let Rs = Rt; function Pc(t) { - $s = t; + Rs = t; } -function Er() { - return $s; +function Ir() { + return Rs; } -const xr = (t) => { - const { data: e, path: r, errorMaps: n, issueData: o } = t, a = [...r, ...o.path || []], i = { +const Cr = (t) => { + const { data: e, path: r, errorMaps: n, issueData: o } = t, s = [...r, ...o.path || []], i = { ...o, - path: a + path: s }; if (o.message !== void 0) return { ...o, - path: a, + path: s, message: o.message }; let c = ""; @@ -4972,12 +5133,12 @@ const xr = (t) => { c = u(i, { data: e, defaultError: c }).message; return { ...o, - path: a, + path: s, message: c }; }, Tc = []; function b(t, e) { - const r = Er(), n = xr({ + const r = Ir(), n = Cr({ issueData: e, data: t.data, path: t.path, @@ -4985,13 +5146,13 @@ function b(t, e) { t.common.contextualErrorMap, t.schemaErrorMap, r, - r === Tt ? void 0 : Tt + r === Rt ? void 0 : Rt // then global default map ].filter((o) => !!o) }); t.common.issues.push(n); } -class Q { +class se { constructor() { this.value = "valid"; } @@ -5005,7 +5166,7 @@ class Q { const n = []; for (const o of r) { if (o.status === "aborted") - return I; + return R; o.status === "dirty" && e.dirty(), n.push(o.value); } return { status: e.value, value: n }; @@ -5013,44 +5174,42 @@ class Q { static async mergeObjectAsync(e, r) { const n = []; for (const o of r) { - const a = await o.key, i = await o.value; + const s = await o.key, i = await o.value; n.push({ - key: a, + key: s, value: i }); } - return Q.mergeObjectSync(e, n); + return se.mergeObjectSync(e, n); } static mergeObjectSync(e, r) { const n = {}; for (const o of r) { - const { key: a, value: i } = o; - if (a.status === "aborted" || i.status === "aborted") - return I; - a.status === "dirty" && e.dirty(), i.status === "dirty" && e.dirty(), a.value !== "__proto__" && (typeof i.value < "u" || o.alwaysSet) && (n[a.value] = i.value); + const { key: s, value: i } = o; + if (s.status === "aborted" || i.status === "aborted") + return R; + s.status === "dirty" && e.dirty(), i.status === "dirty" && e.dirty(), s.value !== "__proto__" && (typeof i.value < "u" || o.alwaysSet) && (n[s.value] = i.value); } return { status: e.value, value: n }; } } -const I = Object.freeze({ +const R = Object.freeze({ status: "aborted" -}), wt = (t) => ({ status: "dirty", value: t }), ae = (t) => ({ status: "valid", value: t }), pn = (t) => t.status === "aborted", hn = (t) => t.status === "dirty", jt = (t) => t.status === "valid", Zt = (t) => typeof Promise < "u" && t instanceof Promise; -function kr(t, e, r, n) { - if (typeof e == "function" ? t !== e || !n : !e.has(t)) - throw new TypeError("Cannot read private member from an object whose class did not declare it"); +}), xt = (t) => ({ status: "dirty", value: t }), pe = (t) => ({ status: "valid", value: t }), hn = (t) => t.status === "aborted", mn = (t) => t.status === "dirty", Gt = (t) => t.status === "valid", Vt = (t) => typeof Promise < "u" && t instanceof Promise; +function Rr(t, e, r, n) { + if (typeof e == "function" ? t !== e || !n : !e.has(t)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return e.get(t); } -function Ns(t, e, r, n, o) { - if (typeof e == "function" ? t !== e || !o : !e.has(t)) - throw new TypeError("Cannot write private member to an object whose class did not declare it"); +function $s(t, e, r, n, o) { + if (typeof e == "function" ? t !== e || !o : !e.has(t)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return e.set(t, r), r; } -var E; +var k; (function(t) { t.errToObj = (e) => typeof e == "string" ? { message: e } : e || {}, t.toString = (e) => typeof e == "string" ? e : e == null ? void 0 : e.message; -})(E || (E = {})); -var Lt, Ft; -class Re { +})(k || (k = {})); +var Zt, zt; +class De { constructor(e, r, n, o) { this._cachedPath = [], this.parent = e, this.data = r, this._path = n, this._key = o; } @@ -5058,8 +5217,8 @@ class Re { return this._cachedPath.length || (this._key instanceof Array ? this._cachedPath.push(...this._path, ...this._key) : this._cachedPath.push(...this._path, this._key)), this._cachedPath; } } -const vo = (t, e) => { - if (jt(e)) +const go = (t, e) => { + if (Gt(e)) return { success: !0, data: e.value }; if (!t.common.issues.length) throw new Error("Validation failed but no issues detected."); @@ -5068,12 +5227,12 @@ const vo = (t, e) => { get error() { if (this._error) return this._error; - const r = new fe(t.common.issues); + const r = new _e(t.common.issues); return this._error = r, this._error; } }; }; -function C(t) { +function N(t) { if (!t) return {}; const { errorMap: e, invalid_type_error: r, required_error: n, description: o } = t; @@ -5085,7 +5244,7 @@ function C(t) { return i.code === "invalid_enum_value" ? { message: d ?? c.defaultError } : typeof c.data > "u" ? { message: (l = d ?? n) !== null && l !== void 0 ? l : c.defaultError } : i.code !== "invalid_type" ? { message: c.defaultError } : { message: (u = d ?? r) !== null && u !== void 0 ? u : c.defaultError }; }, description: o }; } -class $ { +class O { constructor(e) { this.spa = this.safeParseAsync, this._def = e, this.parse = this.parse.bind(this), this.safeParse = this.safeParse.bind(this), this.parseAsync = this.parseAsync.bind(this), this.safeParseAsync = this.safeParseAsync.bind(this), this.spa = this.spa.bind(this), this.refine = this.refine.bind(this), this.refinement = this.refinement.bind(this), this.superRefine = this.superRefine.bind(this), this.optional = this.optional.bind(this), this.nullable = this.nullable.bind(this), this.nullish = this.nullish.bind(this), this.array = this.array.bind(this), this.promise = this.promise.bind(this), this.or = this.or.bind(this), this.and = this.and.bind(this), this.transform = this.transform.bind(this), this.brand = this.brand.bind(this), this.default = this.default.bind(this), this.catch = this.catch.bind(this), this.describe = this.describe.bind(this), this.pipe = this.pipe.bind(this), this.readonly = this.readonly.bind(this), this.isNullable = this.isNullable.bind(this), this.isOptional = this.isOptional.bind(this); } @@ -5093,13 +5252,13 @@ class $ { return this._def.description; } _getType(e) { - return Ve(e.data); + return Je(e.data); } _getOrReturnCtx(e, r) { return r || { common: e.parent.common, data: e.data, - parsedType: Ve(e.data), + parsedType: Je(e.data), schemaErrorMap: this._def.errorMap, path: e.path, parent: e.parent @@ -5107,11 +5266,11 @@ class $ { } _processInputParams(e) { return { - status: new Q(), + status: new se(), ctx: { common: e.parent.common, data: e.data, - parsedType: Ve(e.data), + parsedType: Je(e.data), schemaErrorMap: this._def.errorMap, path: e.path, parent: e.parent @@ -5120,7 +5279,7 @@ class $ { } _parseSync(e) { const r = this._parse(e); - if (Zt(r)) + if (Vt(r)) throw new Error("Synchronous parse encountered promise."); return r; } @@ -5146,9 +5305,9 @@ class $ { schemaErrorMap: this._def.errorMap, parent: null, data: e, - parsedType: Ve(e) - }, a = this._parseSync({ data: e, path: o.path, parent: o }); - return vo(o, a); + parsedType: Je(e) + }, s = this._parseSync({ data: e, path: o.path, parent: o }); + return go(o, s); } async parseAsync(e, r) { const n = await this.safeParseAsync(e, r); @@ -5167,14 +5326,14 @@ class $ { schemaErrorMap: this._def.errorMap, parent: null, data: e, - parsedType: Ve(e) - }, o = this._parse({ data: e, path: n.path, parent: n }), a = await (Zt(o) ? o : Promise.resolve(o)); - return vo(n, a); + parsedType: Je(e) + }, o = this._parse({ data: e, path: n.path, parent: n }), s = await (Vt(o) ? o : Promise.resolve(o)); + return go(n, s); } refine(e, r) { const n = (o) => typeof r == "string" || typeof r > "u" ? { message: r } : typeof r == "function" ? r(o) : r; - return this._refinement((o, a) => { - const i = e(o), c = () => a.addIssue({ + return this._refinement((o, s) => { + const i = e(o), c = () => s.addIssue({ code: g.custom, ...n(o) }); @@ -5185,9 +5344,9 @@ class $ { return this._refinement((n, o) => e(n) ? !0 : (o.addIssue(typeof r == "function" ? r(n, o) : r), !1)); } _refinement(e) { - return new Ae({ + return new Ne({ schema: this, - typeName: A.ZodEffects, + typeName: C.ZodEffects, effect: { type: "refinement", refinement: e } }); } @@ -5195,57 +5354,57 @@ class $ { return this._refinement(e); } optional() { - return Ne.create(this, this._def); + return Fe.create(this, this._def); } nullable() { - return tt.create(this, this._def); + return ot.create(this, this._def); } nullish() { return this.nullable().optional(); } array() { - return Te.create(this, this._def); + return $e.create(this, this._def); } promise() { - return It.create(this, this._def); + return Nt.create(this, this._def); } or(e) { - return Ht.create([this, e], this._def); + return Kt.create([this, e], this._def); } and(e) { - return Vt.create(this, e, this._def); + return Yt.create(this, e, this._def); } transform(e) { - return new Ae({ - ...C(this._def), + return new Ne({ + ...N(this._def), schema: this, - typeName: A.ZodEffects, + typeName: C.ZodEffects, effect: { type: "transform", transform: e } }); } default(e) { const r = typeof e == "function" ? e : () => e; - return new Jt({ - ...C(this._def), + return new tr({ + ...N(this._def), innerType: this, defaultValue: r, - typeName: A.ZodDefault + typeName: C.ZodDefault }); } brand() { - return new Zn({ - typeName: A.ZodBranded, + return new Dn({ + typeName: C.ZodBranded, type: this, - ...C(this._def) + ...N(this._def) }); } catch(e) { const r = typeof e == "function" ? e : () => e; - return new Xt({ - ...C(this._def), + return new rr({ + ...N(this._def), innerType: this, catchValue: r, - typeName: A.ZodCatch + typeName: C.ZodCatch }); } describe(e) { @@ -5256,10 +5415,10 @@ class $ { }); } pipe(e) { - return sr.create(this, e); + return lr.create(this, e); } readonly() { - return Qt.create(this); + return nr.create(this); } isOptional() { return this.safeParse(void 0).success; @@ -5268,9 +5427,9 @@ class $ { return this.safeParse(null).success; } } -const Ac = /^c[^\s-]{8,}$/i, Ic = /^[0-9a-z]+$/, Cc = /^[0-9A-HJKMNP-TV-Z]{26}$/, $c = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i, Nc = /^[a-z0-9_-]{21}$/i, Rc = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/, Oc = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i, Mc = "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$"; -let en; -const Lc = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, Fc = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, Dc = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, Rs = "((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))", Uc = new RegExp(`^${Rs}$`); +const Ac = /^c[^\s-]{8,}$/i, Ic = /^[0-9a-z]+$/, Cc = /^[0-9A-HJKMNP-TV-Z]{26}$/, Rc = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i, $c = /^[a-z0-9_-]{21}$/i, Nc = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/, Oc = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i, Mc = "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$"; +let on; +const Lc = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, Fc = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, Dc = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, Ns = "((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))", Uc = new RegExp(`^${Ns}$`); function Os(t) { let e = "([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d"; return t.precision ? e = `${e}\\.\\d{${t.precision}}` : t.precision == null && (e = `${e}(\\.\\d+)?`), e; @@ -5279,199 +5438,198 @@ function jc(t) { return new RegExp(`^${Os(t)}$`); } function Ms(t) { - let e = `${Rs}T${Os(t)}`; + let e = `${Ns}T${Os(t)}`; const r = []; return r.push(t.local ? "Z?" : "Z"), t.offset && r.push("([+-]\\d{2}:?\\d{2})"), e = `${e}(${r.join("|")})`, new RegExp(`^${e}$`); } function Zc(t, e) { return !!((e === "v4" || !e) && Lc.test(t) || (e === "v6" || !e) && Fc.test(t)); } -class ke extends $ { +class Ce extends O { _parse(e) { if (this._def.coerce && (e.data = String(e.data)), this._getType(e) !== w.string) { - const a = this._getOrReturnCtx(e); - return b(a, { + const s = this._getOrReturnCtx(e); + return b(s, { code: g.invalid_type, expected: w.string, - received: a.parsedType - }), I; + received: s.parsedType + }), R; } - const n = new Q(); + const n = new se(); let o; - for (const a of this._def.checks) - if (a.kind === "min") - e.data.length < a.value && (o = this._getOrReturnCtx(e, o), b(o, { + for (const s of this._def.checks) + if (s.kind === "min") + e.data.length < s.value && (o = this._getOrReturnCtx(e, o), b(o, { code: g.too_small, - minimum: a.value, + minimum: s.value, type: "string", inclusive: !0, exact: !1, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "max") - e.data.length > a.value && (o = this._getOrReturnCtx(e, o), b(o, { + else if (s.kind === "max") + e.data.length > s.value && (o = this._getOrReturnCtx(e, o), b(o, { code: g.too_big, - maximum: a.value, + maximum: s.value, type: "string", inclusive: !0, exact: !1, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "length") { - const i = e.data.length > a.value, c = e.data.length < a.value; + else if (s.kind === "length") { + const i = e.data.length > s.value, c = e.data.length < s.value; (i || c) && (o = this._getOrReturnCtx(e, o), i ? b(o, { code: g.too_big, - maximum: a.value, + maximum: s.value, type: "string", inclusive: !0, exact: !0, - message: a.message + message: s.message }) : c && b(o, { code: g.too_small, - minimum: a.value, + minimum: s.value, type: "string", inclusive: !0, exact: !0, - message: a.message + message: s.message }), n.dirty()); - } else if (a.kind === "email") + } else if (s.kind === "email") Oc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "email", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "emoji") - en || (en = new RegExp(Mc, "u")), en.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + else if (s.kind === "emoji") + on || (on = new RegExp(Mc, "u")), on.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "emoji", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "uuid") - $c.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + else if (s.kind === "uuid") + Rc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "uuid", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "nanoid") - Nc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + else if (s.kind === "nanoid") + $c.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "nanoid", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "cuid") + else if (s.kind === "cuid") Ac.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "cuid", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "cuid2") + else if (s.kind === "cuid2") Ic.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "cuid2", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "ulid") + else if (s.kind === "ulid") Cc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { validation: "ulid", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty()); - else if (a.kind === "url") + else if (s.kind === "url") try { new URL(e.data); } catch { o = this._getOrReturnCtx(e, o), b(o, { validation: "url", code: g.invalid_string, - message: a.message + message: s.message }), n.dirty(); } - else - a.kind === "regex" ? (a.regex.lastIndex = 0, a.regex.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - validation: "regex", - code: g.invalid_string, - message: a.message - }), n.dirty())) : a.kind === "trim" ? e.data = e.data.trim() : a.kind === "includes" ? e.data.includes(a.value, a.position) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: { includes: a.value, position: a.position }, - message: a.message - }), n.dirty()) : a.kind === "toLowerCase" ? e.data = e.data.toLowerCase() : a.kind === "toUpperCase" ? e.data = e.data.toUpperCase() : a.kind === "startsWith" ? e.data.startsWith(a.value) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: { startsWith: a.value }, - message: a.message - }), n.dirty()) : a.kind === "endsWith" ? e.data.endsWith(a.value) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: { endsWith: a.value }, - message: a.message - }), n.dirty()) : a.kind === "datetime" ? Ms(a).test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: "datetime", - message: a.message - }), n.dirty()) : a.kind === "date" ? Uc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: "date", - message: a.message - }), n.dirty()) : a.kind === "time" ? jc(a).test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - code: g.invalid_string, - validation: "time", - message: a.message - }), n.dirty()) : a.kind === "duration" ? Rc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - validation: "duration", - code: g.invalid_string, - message: a.message - }), n.dirty()) : a.kind === "ip" ? Zc(e.data, a.version) || (o = this._getOrReturnCtx(e, o), b(o, { - validation: "ip", - code: g.invalid_string, - message: a.message - }), n.dirty()) : a.kind === "base64" ? Dc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { - validation: "base64", - code: g.invalid_string, - message: a.message - }), n.dirty()) : O.assertNever(a); + else s.kind === "regex" ? (s.regex.lastIndex = 0, s.regex.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + validation: "regex", + code: g.invalid_string, + message: s.message + }), n.dirty())) : s.kind === "trim" ? e.data = e.data.trim() : s.kind === "includes" ? e.data.includes(s.value, s.position) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: { includes: s.value, position: s.position }, + message: s.message + }), n.dirty()) : s.kind === "toLowerCase" ? e.data = e.data.toLowerCase() : s.kind === "toUpperCase" ? e.data = e.data.toUpperCase() : s.kind === "startsWith" ? e.data.startsWith(s.value) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: { startsWith: s.value }, + message: s.message + }), n.dirty()) : s.kind === "endsWith" ? e.data.endsWith(s.value) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: { endsWith: s.value }, + message: s.message + }), n.dirty()) : s.kind === "datetime" ? Ms(s).test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: "datetime", + message: s.message + }), n.dirty()) : s.kind === "date" ? Uc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: "date", + message: s.message + }), n.dirty()) : s.kind === "time" ? jc(s).test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + code: g.invalid_string, + validation: "time", + message: s.message + }), n.dirty()) : s.kind === "duration" ? Nc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + validation: "duration", + code: g.invalid_string, + message: s.message + }), n.dirty()) : s.kind === "ip" ? Zc(e.data, s.version) || (o = this._getOrReturnCtx(e, o), b(o, { + validation: "ip", + code: g.invalid_string, + message: s.message + }), n.dirty()) : s.kind === "base64" ? Dc.test(e.data) || (o = this._getOrReturnCtx(e, o), b(o, { + validation: "base64", + code: g.invalid_string, + message: s.message + }), n.dirty()) : D.assertNever(s); return { status: n.value, value: e.data }; } _regex(e, r, n) { return this.refinement((o) => e.test(o), { validation: r, code: g.invalid_string, - ...E.errToObj(n) + ...k.errToObj(n) }); } _addCheck(e) { - return new ke({ + return new Ce({ ...this._def, checks: [...this._def.checks, e] }); } email(e) { - return this._addCheck({ kind: "email", ...E.errToObj(e) }); + return this._addCheck({ kind: "email", ...k.errToObj(e) }); } url(e) { - return this._addCheck({ kind: "url", ...E.errToObj(e) }); + return this._addCheck({ kind: "url", ...k.errToObj(e) }); } emoji(e) { - return this._addCheck({ kind: "emoji", ...E.errToObj(e) }); + return this._addCheck({ kind: "emoji", ...k.errToObj(e) }); } uuid(e) { - return this._addCheck({ kind: "uuid", ...E.errToObj(e) }); + return this._addCheck({ kind: "uuid", ...k.errToObj(e) }); } nanoid(e) { - return this._addCheck({ kind: "nanoid", ...E.errToObj(e) }); + return this._addCheck({ kind: "nanoid", ...k.errToObj(e) }); } cuid(e) { - return this._addCheck({ kind: "cuid", ...E.errToObj(e) }); + return this._addCheck({ kind: "cuid", ...k.errToObj(e) }); } cuid2(e) { - return this._addCheck({ kind: "cuid2", ...E.errToObj(e) }); + return this._addCheck({ kind: "cuid2", ...k.errToObj(e) }); } ulid(e) { - return this._addCheck({ kind: "ulid", ...E.errToObj(e) }); + return this._addCheck({ kind: "ulid", ...k.errToObj(e) }); } base64(e) { - return this._addCheck({ kind: "base64", ...E.errToObj(e) }); + return this._addCheck({ kind: "base64", ...k.errToObj(e) }); } ip(e) { - return this._addCheck({ kind: "ip", ...E.errToObj(e) }); + return this._addCheck({ kind: "ip", ...k.errToObj(e) }); } datetime(e) { var r, n; @@ -5486,7 +5644,7 @@ class ke extends $ { precision: typeof (e == null ? void 0 : e.precision) > "u" ? null : e == null ? void 0 : e.precision, offset: (r = e == null ? void 0 : e.offset) !== null && r !== void 0 ? r : !1, local: (n = e == null ? void 0 : e.local) !== null && n !== void 0 ? n : !1, - ...E.errToObj(e == null ? void 0 : e.message) + ...k.errToObj(e == null ? void 0 : e.message) }); } date(e) { @@ -5500,17 +5658,17 @@ class ke extends $ { }) : this._addCheck({ kind: "time", precision: typeof (e == null ? void 0 : e.precision) > "u" ? null : e == null ? void 0 : e.precision, - ...E.errToObj(e == null ? void 0 : e.message) + ...k.errToObj(e == null ? void 0 : e.message) }); } duration(e) { - return this._addCheck({ kind: "duration", ...E.errToObj(e) }); + return this._addCheck({ kind: "duration", ...k.errToObj(e) }); } regex(e, r) { return this._addCheck({ kind: "regex", regex: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } includes(e, r) { @@ -5518,42 +5676,42 @@ class ke extends $ { kind: "includes", value: e, position: r == null ? void 0 : r.position, - ...E.errToObj(r == null ? void 0 : r.message) + ...k.errToObj(r == null ? void 0 : r.message) }); } startsWith(e, r) { return this._addCheck({ kind: "startsWith", value: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } endsWith(e, r) { return this._addCheck({ kind: "endsWith", value: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } min(e, r) { return this._addCheck({ kind: "min", value: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } max(e, r) { return this._addCheck({ kind: "max", value: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } length(e, r) { return this._addCheck({ kind: "length", value: e, - ...E.errToObj(r) + ...k.errToObj(r) }); } /** @@ -5561,22 +5719,22 @@ class ke extends $ { * @see {@link ZodString.min} */ nonempty(e) { - return this.min(1, E.errToObj(e)); + return this.min(1, k.errToObj(e)); } trim() { - return new ke({ + return new Ce({ ...this._def, checks: [...this._def.checks, { kind: "trim" }] }); } toLowerCase() { - return new ke({ + return new Ce({ ...this._def, checks: [...this._def.checks, { kind: "toLowerCase" }] }); } toUpperCase() { - return new ke({ + return new Ce({ ...this._def, checks: [...this._def.checks, { kind: "toUpperCase" }] }); @@ -5636,78 +5794,78 @@ class ke extends $ { return e; } } -ke.create = (t) => { +Ce.create = (t) => { var e; - return new ke({ + return new Ce({ checks: [], - typeName: A.ZodString, + typeName: C.ZodString, coerce: (e = t == null ? void 0 : t.coerce) !== null && e !== void 0 ? e : !1, - ...C(t) + ...N(t) }); }; function zc(t, e) { - const r = (t.toString().split(".")[1] || "").length, n = (e.toString().split(".")[1] || "").length, o = r > n ? r : n, a = parseInt(t.toFixed(o).replace(".", "")), i = parseInt(e.toFixed(o).replace(".", "")); - return a % i / Math.pow(10, o); + const r = (t.toString().split(".")[1] || "").length, n = (e.toString().split(".")[1] || "").length, o = r > n ? r : n, s = parseInt(t.toFixed(o).replace(".", "")), i = parseInt(e.toFixed(o).replace(".", "")); + return s % i / Math.pow(10, o); } -class Xe extends $ { +class tt extends O { constructor() { super(...arguments), this.min = this.gte, this.max = this.lte, this.step = this.multipleOf; } _parse(e) { if (this._def.coerce && (e.data = Number(e.data)), this._getType(e) !== w.number) { - const a = this._getOrReturnCtx(e); - return b(a, { + const s = this._getOrReturnCtx(e); + return b(s, { code: g.invalid_type, expected: w.number, - received: a.parsedType - }), I; + received: s.parsedType + }), R; } let n; - const o = new Q(); - for (const a of this._def.checks) - a.kind === "int" ? O.isInteger(e.data) || (n = this._getOrReturnCtx(e, n), b(n, { + const o = new se(); + for (const s of this._def.checks) + s.kind === "int" ? D.isInteger(e.data) || (n = this._getOrReturnCtx(e, n), b(n, { code: g.invalid_type, expected: "integer", received: "float", - message: a.message - }), o.dirty()) : a.kind === "min" ? (a.inclusive ? e.data < a.value : e.data <= a.value) && (n = this._getOrReturnCtx(e, n), b(n, { + message: s.message + }), o.dirty()) : s.kind === "min" ? (s.inclusive ? e.data < s.value : e.data <= s.value) && (n = this._getOrReturnCtx(e, n), b(n, { code: g.too_small, - minimum: a.value, + minimum: s.value, type: "number", - inclusive: a.inclusive, + inclusive: s.inclusive, exact: !1, - message: a.message - }), o.dirty()) : a.kind === "max" ? (a.inclusive ? e.data > a.value : e.data >= a.value) && (n = this._getOrReturnCtx(e, n), b(n, { + message: s.message + }), o.dirty()) : s.kind === "max" ? (s.inclusive ? e.data > s.value : e.data >= s.value) && (n = this._getOrReturnCtx(e, n), b(n, { code: g.too_big, - maximum: a.value, + maximum: s.value, type: "number", - inclusive: a.inclusive, + inclusive: s.inclusive, exact: !1, - message: a.message - }), o.dirty()) : a.kind === "multipleOf" ? zc(e.data, a.value) !== 0 && (n = this._getOrReturnCtx(e, n), b(n, { + message: s.message + }), o.dirty()) : s.kind === "multipleOf" ? zc(e.data, s.value) !== 0 && (n = this._getOrReturnCtx(e, n), b(n, { code: g.not_multiple_of, - multipleOf: a.value, - message: a.message - }), o.dirty()) : a.kind === "finite" ? Number.isFinite(e.data) || (n = this._getOrReturnCtx(e, n), b(n, { + multipleOf: s.value, + message: s.message + }), o.dirty()) : s.kind === "finite" ? Number.isFinite(e.data) || (n = this._getOrReturnCtx(e, n), b(n, { code: g.not_finite, - message: a.message - }), o.dirty()) : O.assertNever(a); + message: s.message + }), o.dirty()) : D.assertNever(s); return { status: o.value, value: e.data }; } gte(e, r) { - return this.setLimit("min", e, !0, E.toString(r)); + return this.setLimit("min", e, !0, k.toString(r)); } gt(e, r) { - return this.setLimit("min", e, !1, E.toString(r)); + return this.setLimit("min", e, !1, k.toString(r)); } lte(e, r) { - return this.setLimit("max", e, !0, E.toString(r)); + return this.setLimit("max", e, !0, k.toString(r)); } lt(e, r) { - return this.setLimit("max", e, !1, E.toString(r)); + return this.setLimit("max", e, !1, k.toString(r)); } setLimit(e, r, n, o) { - return new Xe({ + return new tt({ ...this._def, checks: [ ...this._def.checks, @@ -5715,13 +5873,13 @@ class Xe extends $ { kind: e, value: r, inclusive: n, - message: E.toString(o) + message: k.toString(o) } ] }); } _addCheck(e) { - return new Xe({ + return new tt({ ...this._def, checks: [...this._def.checks, e] }); @@ -5729,7 +5887,7 @@ class Xe extends $ { int(e) { return this._addCheck({ kind: "int", - message: E.toString(e) + message: k.toString(e) }); } positive(e) { @@ -5737,7 +5895,7 @@ class Xe extends $ { kind: "min", value: 0, inclusive: !1, - message: E.toString(e) + message: k.toString(e) }); } negative(e) { @@ -5745,7 +5903,7 @@ class Xe extends $ { kind: "max", value: 0, inclusive: !1, - message: E.toString(e) + message: k.toString(e) }); } nonpositive(e) { @@ -5753,7 +5911,7 @@ class Xe extends $ { kind: "max", value: 0, inclusive: !0, - message: E.toString(e) + message: k.toString(e) }); } nonnegative(e) { @@ -5761,20 +5919,20 @@ class Xe extends $ { kind: "min", value: 0, inclusive: !0, - message: E.toString(e) + message: k.toString(e) }); } multipleOf(e, r) { return this._addCheck({ kind: "multipleOf", value: e, - message: E.toString(r) + message: k.toString(r) }); } finite(e) { return this._addCheck({ kind: "finite", - message: E.toString(e) + message: k.toString(e) }); } safe(e) { @@ -5782,12 +5940,12 @@ class Xe extends $ { kind: "min", inclusive: !0, value: Number.MIN_SAFE_INTEGER, - message: E.toString(e) + message: k.toString(e) })._addCheck({ kind: "max", inclusive: !0, value: Number.MAX_SAFE_INTEGER, - message: E.toString(e) + message: k.toString(e) }); } get minValue() { @@ -5803,7 +5961,7 @@ class Xe extends $ { return e; } get isInt() { - return !!this._def.checks.find((e) => e.kind === "int" || e.kind === "multipleOf" && O.isInteger(e.value)); + return !!this._def.checks.find((e) => e.kind === "int" || e.kind === "multipleOf" && D.isInteger(e.value)); } get isFinite() { let e = null, r = null; @@ -5815,61 +5973,61 @@ class Xe extends $ { return Number.isFinite(r) && Number.isFinite(e); } } -Xe.create = (t) => new Xe({ +tt.create = (t) => new tt({ checks: [], - typeName: A.ZodNumber, + typeName: C.ZodNumber, coerce: (t == null ? void 0 : t.coerce) || !1, - ...C(t) + ...N(t) }); -class Qe extends $ { +class rt extends O { constructor() { super(...arguments), this.min = this.gte, this.max = this.lte; } _parse(e) { if (this._def.coerce && (e.data = BigInt(e.data)), this._getType(e) !== w.bigint) { - const a = this._getOrReturnCtx(e); - return b(a, { + const s = this._getOrReturnCtx(e); + return b(s, { code: g.invalid_type, expected: w.bigint, - received: a.parsedType - }), I; + received: s.parsedType + }), R; } let n; - const o = new Q(); - for (const a of this._def.checks) - a.kind === "min" ? (a.inclusive ? e.data < a.value : e.data <= a.value) && (n = this._getOrReturnCtx(e, n), b(n, { + const o = new se(); + for (const s of this._def.checks) + s.kind === "min" ? (s.inclusive ? e.data < s.value : e.data <= s.value) && (n = this._getOrReturnCtx(e, n), b(n, { code: g.too_small, type: "bigint", - minimum: a.value, - inclusive: a.inclusive, - message: a.message - }), o.dirty()) : a.kind === "max" ? (a.inclusive ? e.data > a.value : e.data >= a.value) && (n = this._getOrReturnCtx(e, n), b(n, { + minimum: s.value, + inclusive: s.inclusive, + message: s.message + }), o.dirty()) : s.kind === "max" ? (s.inclusive ? e.data > s.value : e.data >= s.value) && (n = this._getOrReturnCtx(e, n), b(n, { code: g.too_big, type: "bigint", - maximum: a.value, - inclusive: a.inclusive, - message: a.message - }), o.dirty()) : a.kind === "multipleOf" ? e.data % a.value !== BigInt(0) && (n = this._getOrReturnCtx(e, n), b(n, { + maximum: s.value, + inclusive: s.inclusive, + message: s.message + }), o.dirty()) : s.kind === "multipleOf" ? e.data % s.value !== BigInt(0) && (n = this._getOrReturnCtx(e, n), b(n, { code: g.not_multiple_of, - multipleOf: a.value, - message: a.message - }), o.dirty()) : O.assertNever(a); + multipleOf: s.value, + message: s.message + }), o.dirty()) : D.assertNever(s); return { status: o.value, value: e.data }; } gte(e, r) { - return this.setLimit("min", e, !0, E.toString(r)); + return this.setLimit("min", e, !0, k.toString(r)); } gt(e, r) { - return this.setLimit("min", e, !1, E.toString(r)); + return this.setLimit("min", e, !1, k.toString(r)); } lte(e, r) { - return this.setLimit("max", e, !0, E.toString(r)); + return this.setLimit("max", e, !0, k.toString(r)); } lt(e, r) { - return this.setLimit("max", e, !1, E.toString(r)); + return this.setLimit("max", e, !1, k.toString(r)); } setLimit(e, r, n, o) { - return new Qe({ + return new rt({ ...this._def, checks: [ ...this._def.checks, @@ -5877,13 +6035,13 @@ class Qe extends $ { kind: e, value: r, inclusive: n, - message: E.toString(o) + message: k.toString(o) } ] }); } _addCheck(e) { - return new Qe({ + return new rt({ ...this._def, checks: [...this._def.checks, e] }); @@ -5893,7 +6051,7 @@ class Qe extends $ { kind: "min", value: BigInt(0), inclusive: !1, - message: E.toString(e) + message: k.toString(e) }); } negative(e) { @@ -5901,7 +6059,7 @@ class Qe extends $ { kind: "max", value: BigInt(0), inclusive: !1, - message: E.toString(e) + message: k.toString(e) }); } nonpositive(e) { @@ -5909,7 +6067,7 @@ class Qe extends $ { kind: "max", value: BigInt(0), inclusive: !0, - message: E.toString(e) + message: k.toString(e) }); } nonnegative(e) { @@ -5917,14 +6075,14 @@ class Qe extends $ { kind: "min", value: BigInt(0), inclusive: !0, - message: E.toString(e) + message: k.toString(e) }); } multipleOf(e, r) { return this._addCheck({ kind: "multipleOf", value: e, - message: E.toString(r) + message: k.toString(r) }); } get minValue() { @@ -5940,16 +6098,16 @@ class Qe extends $ { return e; } } -Qe.create = (t) => { +rt.create = (t) => { var e; - return new Qe({ + return new rt({ checks: [], - typeName: A.ZodBigInt, + typeName: C.ZodBigInt, coerce: (e = t == null ? void 0 : t.coerce) !== null && e !== void 0 ? e : !1, - ...C(t) + ...N(t) }); }; -class zt extends $ { +class Ht extends O { _parse(e) { if (this._def.coerce && (e.data = !!e.data), this._getType(e) !== w.boolean) { const n = this._getOrReturnCtx(e); @@ -5957,57 +6115,57 @@ class zt extends $ { code: g.invalid_type, expected: w.boolean, received: n.parsedType - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } } -zt.create = (t) => new zt({ - typeName: A.ZodBoolean, +Ht.create = (t) => new Ht({ + typeName: C.ZodBoolean, coerce: (t == null ? void 0 : t.coerce) || !1, - ...C(t) + ...N(t) }); -class pt extends $ { +class mt extends O { _parse(e) { if (this._def.coerce && (e.data = new Date(e.data)), this._getType(e) !== w.date) { - const a = this._getOrReturnCtx(e); - return b(a, { + const s = this._getOrReturnCtx(e); + return b(s, { code: g.invalid_type, expected: w.date, - received: a.parsedType - }), I; + received: s.parsedType + }), R; } if (isNaN(e.data.getTime())) { - const a = this._getOrReturnCtx(e); - return b(a, { + const s = this._getOrReturnCtx(e); + return b(s, { code: g.invalid_date - }), I; + }), R; } - const n = new Q(); + const n = new se(); let o; - for (const a of this._def.checks) - a.kind === "min" ? e.data.getTime() < a.value && (o = this._getOrReturnCtx(e, o), b(o, { + for (const s of this._def.checks) + s.kind === "min" ? e.data.getTime() < s.value && (o = this._getOrReturnCtx(e, o), b(o, { code: g.too_small, - message: a.message, + message: s.message, inclusive: !0, exact: !1, - minimum: a.value, + minimum: s.value, type: "date" - }), n.dirty()) : a.kind === "max" ? e.data.getTime() > a.value && (o = this._getOrReturnCtx(e, o), b(o, { + }), n.dirty()) : s.kind === "max" ? e.data.getTime() > s.value && (o = this._getOrReturnCtx(e, o), b(o, { code: g.too_big, - message: a.message, + message: s.message, inclusive: !0, exact: !1, - maximum: a.value, + maximum: s.value, type: "date" - }), n.dirty()) : O.assertNever(a); + }), n.dirty()) : D.assertNever(s); return { status: n.value, value: new Date(e.data.getTime()) }; } _addCheck(e) { - return new pt({ + return new mt({ ...this._def, checks: [...this._def.checks, e] }); @@ -6016,14 +6174,14 @@ class pt extends $ { return this._addCheck({ kind: "min", value: e.getTime(), - message: E.toString(r) + message: k.toString(r) }); } max(e, r) { return this._addCheck({ kind: "max", value: e.getTime(), - message: E.toString(r) + message: k.toString(r) }); } get minDate() { @@ -6039,13 +6197,13 @@ class pt extends $ { return e != null ? new Date(e) : null; } } -pt.create = (t) => new pt({ +mt.create = (t) => new mt({ checks: [], coerce: (t == null ? void 0 : t.coerce) || !1, - typeName: A.ZodDate, - ...C(t) + typeName: C.ZodDate, + ...N(t) }); -class Pr extends $ { +class $r extends O { _parse(e) { if (this._getType(e) !== w.symbol) { const n = this._getOrReturnCtx(e); @@ -6053,16 +6211,16 @@ class Pr extends $ { code: g.invalid_type, expected: w.symbol, received: n.parsedType - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } } -Pr.create = (t) => new Pr({ - typeName: A.ZodSymbol, - ...C(t) +$r.create = (t) => new $r({ + typeName: C.ZodSymbol, + ...N(t) }); -class Gt extends $ { +class Wt extends O { _parse(e) { if (this._getType(e) !== w.undefined) { const n = this._getOrReturnCtx(e); @@ -6070,16 +6228,16 @@ class Gt extends $ { code: g.invalid_type, expected: w.undefined, received: n.parsedType - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } } -Gt.create = (t) => new Gt({ - typeName: A.ZodUndefined, - ...C(t) +Wt.create = (t) => new Wt({ + typeName: C.ZodUndefined, + ...N(t) }); -class Bt extends $ { +class qt extends O { _parse(e) { if (this._getType(e) !== w.null) { const n = this._getOrReturnCtx(e); @@ -6087,54 +6245,54 @@ class Bt extends $ { code: g.invalid_type, expected: w.null, received: n.parsedType - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } } -Bt.create = (t) => new Bt({ - typeName: A.ZodNull, - ...C(t) +qt.create = (t) => new qt({ + typeName: C.ZodNull, + ...N(t) }); -class At extends $ { +class $t extends O { constructor() { super(...arguments), this._any = !0; } _parse(e) { - return ae(e.data); + return pe(e.data); } } -At.create = (t) => new At({ - typeName: A.ZodAny, - ...C(t) +$t.create = (t) => new $t({ + typeName: C.ZodAny, + ...N(t) }); -class dt extends $ { +class pt extends O { constructor() { super(...arguments), this._unknown = !0; } _parse(e) { - return ae(e.data); + return pe(e.data); } } -dt.create = (t) => new dt({ - typeName: A.ZodUnknown, - ...C(t) +pt.create = (t) => new pt({ + typeName: C.ZodUnknown, + ...N(t) }); -class je extends $ { +class We extends O { _parse(e) { const r = this._getOrReturnCtx(e); return b(r, { code: g.invalid_type, expected: w.never, received: r.parsedType - }), I; + }), R; } } -je.create = (t) => new je({ - typeName: A.ZodNever, - ...C(t) +We.create = (t) => new We({ + typeName: C.ZodNever, + ...N(t) }); -class Tr extends $ { +class Nr extends O { _parse(e) { if (this._getType(e) !== w.undefined) { const n = this._getOrReturnCtx(e); @@ -6142,16 +6300,16 @@ class Tr extends $ { code: g.invalid_type, expected: w.void, received: n.parsedType - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } } -Tr.create = (t) => new Tr({ - typeName: A.ZodVoid, - ...C(t) +Nr.create = (t) => new Nr({ + typeName: C.ZodVoid, + ...N(t) }); -class Te extends $ { +class $e extends O { _parse(e) { const { ctx: r, status: n } = this._processInputParams(e), o = this._def; if (r.parsedType !== w.array) @@ -6159,7 +6317,7 @@ class Te extends $ { code: g.invalid_type, expected: w.array, received: r.parsedType - }), I; + }), R; if (o.exactLength !== null) { const i = r.data.length > o.exactLength.value, c = r.data.length < o.exactLength.value; (i || c) && (b(r, { @@ -6187,68 +6345,67 @@ class Te extends $ { exact: !1, message: o.maxLength.message }), n.dirty()), r.common.async) - return Promise.all([...r.data].map((i, c) => o.type._parseAsync(new Re(r, i, r.path, c)))).then((i) => Q.mergeArray(n, i)); - const a = [...r.data].map((i, c) => o.type._parseSync(new Re(r, i, r.path, c))); - return Q.mergeArray(n, a); + return Promise.all([...r.data].map((i, c) => o.type._parseAsync(new De(r, i, r.path, c)))).then((i) => se.mergeArray(n, i)); + const s = [...r.data].map((i, c) => o.type._parseSync(new De(r, i, r.path, c))); + return se.mergeArray(n, s); } get element() { return this._def.type; } min(e, r) { - return new Te({ + return new $e({ ...this._def, - minLength: { value: e, message: E.toString(r) } + minLength: { value: e, message: k.toString(r) } }); } max(e, r) { - return new Te({ + return new $e({ ...this._def, - maxLength: { value: e, message: E.toString(r) } + maxLength: { value: e, message: k.toString(r) } }); } length(e, r) { - return new Te({ + return new $e({ ...this._def, - exactLength: { value: e, message: E.toString(r) } + exactLength: { value: e, message: k.toString(r) } }); } nonempty(e) { return this.min(1, e); } } -Te.create = (t, e) => new Te({ +$e.create = (t, e) => new $e({ type: t, minLength: null, maxLength: null, exactLength: null, - typeName: A.ZodArray, - ...C(e) + typeName: C.ZodArray, + ...N(e) }); -function _t(t) { - if (t instanceof U) { +function wt(t) { + if (t instanceof G) { const e = {}; for (const r in t.shape) { const n = t.shape[r]; - e[r] = Ne.create(_t(n)); + e[r] = Fe.create(wt(n)); } - return new U({ + return new G({ ...t._def, shape: () => e }); - } else - return t instanceof Te ? new Te({ - ...t._def, - type: _t(t.element) - }) : t instanceof Ne ? Ne.create(_t(t.unwrap())) : t instanceof tt ? tt.create(_t(t.unwrap())) : t instanceof Oe ? Oe.create(t.items.map((e) => _t(e))) : t; + } else return t instanceof $e ? new $e({ + ...t._def, + type: wt(t.element) + }) : t instanceof Fe ? Fe.create(wt(t.unwrap())) : t instanceof ot ? ot.create(wt(t.unwrap())) : t instanceof Ue ? Ue.create(t.items.map((e) => wt(e))) : t; } -class U extends $ { +class G extends O { constructor() { super(...arguments), this._cached = null, this.nonstrict = this.passthrough, this.augment = this.extend; } _getCached() { if (this._cached !== null) return this._cached; - const e = this._def.shape(), r = O.objectKeys(e); + const e = this._def.shape(), r = D.objectKeys(e); return this._cached = { shape: e, keys: r }; } _parse(e) { @@ -6258,22 +6415,22 @@ class U extends $ { code: g.invalid_type, expected: w.object, received: u.parsedType - }), I; + }), R; } - const { status: n, ctx: o } = this._processInputParams(e), { shape: a, keys: i } = this._getCached(), c = []; - if (!(this._def.catchall instanceof je && this._def.unknownKeys === "strip")) + const { status: n, ctx: o } = this._processInputParams(e), { shape: s, keys: i } = this._getCached(), c = []; + if (!(this._def.catchall instanceof We && this._def.unknownKeys === "strip")) for (const u in o.data) i.includes(u) || c.push(u); const l = []; for (const u of i) { - const d = a[u], f = o.data[u]; + const d = s[u], f = o.data[u]; l.push({ key: { status: "valid", value: u }, - value: d._parse(new Re(o, f, o.path, u)), + value: d._parse(new De(o, f, o.path, u)), alwaysSet: u in o.data }); } - if (this._def.catchall instanceof je) { + if (this._def.catchall instanceof We) { const u = this._def.unknownKeys; if (u === "passthrough") for (const d of c) @@ -6286,8 +6443,7 @@ class U extends $ { code: g.unrecognized_keys, keys: c }), n.dirty()); - else if (u !== "strip") - throw new Error("Internal ZodObject error: invalid unknownKeys value."); + else if (u !== "strip") throw new Error("Internal ZodObject error: invalid unknownKeys value."); } else { const u = this._def.catchall; for (const d of c) { @@ -6295,7 +6451,7 @@ class U extends $ { l.push({ key: { status: "valid", value: d }, value: u._parse( - new Re(o, f, o.path, d) + new De(o, f, o.path, d) //, ctx.child(key), value, getParsedType(value) ), alwaysSet: d in o.data @@ -6313,21 +6469,21 @@ class U extends $ { }); } return u; - }).then((u) => Q.mergeObjectSync(n, u)) : Q.mergeObjectSync(n, l); + }).then((u) => se.mergeObjectSync(n, u)) : se.mergeObjectSync(n, l); } get shape() { return this._def.shape(); } strict(e) { - return E.errToObj, new U({ + return k.errToObj, new G({ ...this._def, unknownKeys: "strict", ...e !== void 0 ? { errorMap: (r, n) => { - var o, a, i, c; - const l = (i = (a = (o = this._def).errorMap) === null || a === void 0 ? void 0 : a.call(o, r, n).message) !== null && i !== void 0 ? i : n.defaultError; + var o, s, i, c; + const l = (i = (s = (o = this._def).errorMap) === null || s === void 0 ? void 0 : s.call(o, r, n).message) !== null && i !== void 0 ? i : n.defaultError; return r.code === "unrecognized_keys" ? { - message: (c = E.errToObj(e).message) !== null && c !== void 0 ? c : l + message: (c = k.errToObj(e).message) !== null && c !== void 0 ? c : l } : { message: l }; @@ -6336,13 +6492,13 @@ class U extends $ { }); } strip() { - return new U({ + return new G({ ...this._def, unknownKeys: "strip" }); } passthrough() { - return new U({ + return new G({ ...this._def, unknownKeys: "passthrough" }); @@ -6365,7 +6521,7 @@ class U extends $ { // }) as any; // }; extend(e) { - return new U({ + return new G({ ...this._def, shape: () => ({ ...this._def.shape(), @@ -6379,14 +6535,14 @@ class U extends $ { * upgrade if you are experiencing issues. */ merge(e) { - return new U({ + return new G({ unknownKeys: e._def.unknownKeys, catchall: e._def.catchall, shape: () => ({ ...this._def.shape(), ...e._def.shape() }), - typeName: A.ZodObject + typeName: C.ZodObject }); } // merge< @@ -6449,25 +6605,25 @@ class U extends $ { // return merged; // } catchall(e) { - return new U({ + return new G({ ...this._def, catchall: e }); } pick(e) { const r = {}; - return O.objectKeys(e).forEach((n) => { + return D.objectKeys(e).forEach((n) => { e[n] && this.shape[n] && (r[n] = this.shape[n]); - }), new U({ + }), new G({ ...this._def, shape: () => r }); } omit(e) { const r = {}; - return O.objectKeys(this.shape).forEach((n) => { + return D.objectKeys(this.shape).forEach((n) => { e[n] || (r[n] = this.shape[n]); - }), new U({ + }), new G({ ...this._def, shape: () => r }); @@ -6476,77 +6632,77 @@ class U extends $ { * @deprecated */ deepPartial() { - return _t(this); + return wt(this); } partial(e) { const r = {}; - return O.objectKeys(this.shape).forEach((n) => { + return D.objectKeys(this.shape).forEach((n) => { const o = this.shape[n]; e && !e[n] ? r[n] = o : r[n] = o.optional(); - }), new U({ + }), new G({ ...this._def, shape: () => r }); } required(e) { const r = {}; - return O.objectKeys(this.shape).forEach((n) => { + return D.objectKeys(this.shape).forEach((n) => { if (e && !e[n]) r[n] = this.shape[n]; else { - let a = this.shape[n]; - for (; a instanceof Ne; ) - a = a._def.innerType; - r[n] = a; + let s = this.shape[n]; + for (; s instanceof Fe; ) + s = s._def.innerType; + r[n] = s; } - }), new U({ + }), new G({ ...this._def, shape: () => r }); } keyof() { - return Ls(O.objectKeys(this.shape)); + return Ls(D.objectKeys(this.shape)); } } -U.create = (t, e) => new U({ +G.create = (t, e) => new G({ shape: () => t, unknownKeys: "strip", - catchall: je.create(), - typeName: A.ZodObject, - ...C(e) + catchall: We.create(), + typeName: C.ZodObject, + ...N(e) }); -U.strictCreate = (t, e) => new U({ +G.strictCreate = (t, e) => new G({ shape: () => t, unknownKeys: "strict", - catchall: je.create(), - typeName: A.ZodObject, - ...C(e) + catchall: We.create(), + typeName: C.ZodObject, + ...N(e) }); -U.lazycreate = (t, e) => new U({ +G.lazycreate = (t, e) => new G({ shape: t, unknownKeys: "strip", - catchall: je.create(), - typeName: A.ZodObject, - ...C(e) + catchall: We.create(), + typeName: C.ZodObject, + ...N(e) }); -class Ht extends $ { +class Kt extends O { _parse(e) { const { ctx: r } = this._processInputParams(e), n = this._def.options; - function o(a) { - for (const c of a) + function o(s) { + for (const c of s) if (c.result.status === "valid") return c.result; - for (const c of a) + for (const c of s) if (c.result.status === "dirty") return r.common.issues.push(...c.ctx.common.issues), c.result; - const i = a.map((c) => new fe(c.ctx.common.issues)); + const i = s.map((c) => new _e(c.ctx.common.issues)); return b(r, { code: g.invalid_union, unionErrors: i - }), I; + }), R; } if (r.common.async) - return Promise.all(n.map(async (a) => { + return Promise.all(n.map(async (s) => { const i = { ...r, common: { @@ -6556,7 +6712,7 @@ class Ht extends $ { parent: null }; return { - result: await a._parseAsync({ + result: await s._parseAsync({ data: r.data, path: r.path, parent: i @@ -6565,7 +6721,7 @@ class Ht extends $ { }; })).then(o); { - let a; + let s; const i = []; for (const l of n) { const u = { @@ -6582,28 +6738,28 @@ class Ht extends $ { }); if (d.status === "valid") return d; - d.status === "dirty" && !a && (a = { result: d, ctx: u }), u.common.issues.length && i.push(u.common.issues); + d.status === "dirty" && !s && (s = { result: d, ctx: u }), u.common.issues.length && i.push(u.common.issues); } - if (a) - return r.common.issues.push(...a.ctx.common.issues), a.result; - const c = i.map((l) => new fe(l)); + if (s) + return r.common.issues.push(...s.ctx.common.issues), s.result; + const c = i.map((l) => new _e(l)); return b(r, { code: g.invalid_union, unionErrors: c - }), I; + }), R; } } get options() { return this._def.options; } } -Ht.create = (t, e) => new Ht({ +Kt.create = (t, e) => new Kt({ options: t, - typeName: A.ZodUnion, - ...C(e) + typeName: C.ZodUnion, + ...N(e) }); -const Fe = (t) => t instanceof qt ? Fe(t.schema) : t instanceof Ae ? Fe(t.innerType()) : t instanceof Kt ? [t.value] : t instanceof et ? t.options : t instanceof Yt ? O.objectValues(t.enum) : t instanceof Jt ? Fe(t._def.innerType) : t instanceof Gt ? [void 0] : t instanceof Bt ? [null] : t instanceof Ne ? [void 0, ...Fe(t.unwrap())] : t instanceof tt ? [null, ...Fe(t.unwrap())] : t instanceof Zn || t instanceof Qt ? Fe(t.unwrap()) : t instanceof Xt ? Fe(t._def.innerType) : []; -class Zr extends $ { +const Be = (t) => t instanceof Xt ? Be(t.schema) : t instanceof Ne ? Be(t.innerType()) : t instanceof Qt ? [t.value] : t instanceof nt ? t.options : t instanceof er ? D.objectValues(t.enum) : t instanceof tr ? Be(t._def.innerType) : t instanceof Wt ? [void 0] : t instanceof qt ? [null] : t instanceof Fe ? [void 0, ...Be(t.unwrap())] : t instanceof ot ? [null, ...Be(t.unwrap())] : t instanceof Dn || t instanceof nr ? Be(t.unwrap()) : t instanceof rr ? Be(t._def.innerType) : []; +class qr extends O { _parse(e) { const { ctx: r } = this._processInputParams(e); if (r.parsedType !== w.object) @@ -6611,13 +6767,13 @@ class Zr extends $ { code: g.invalid_type, expected: w.object, received: r.parsedType - }), I; - const n = this.discriminator, o = r.data[n], a = this.optionsMap.get(o); - return a ? r.common.async ? a._parseAsync({ + }), R; + const n = this.discriminator, o = r.data[n], s = this.optionsMap.get(o); + return s ? r.common.async ? s._parseAsync({ data: r.data, path: r.path, parent: r - }) : a._parseSync({ + }) : s._parseSync({ data: r.data, path: r.path, parent: r @@ -6625,7 +6781,7 @@ class Zr extends $ { code: g.invalid_union_discriminator, options: Array.from(this.optionsMap.keys()), path: [n] - }), I); + }), R); } get discriminator() { return this._def.discriminator; @@ -6646,33 +6802,33 @@ class Zr extends $ { */ static create(e, r, n) { const o = /* @__PURE__ */ new Map(); - for (const a of r) { - const i = Fe(a.shape[e]); + for (const s of r) { + const i = Be(s.shape[e]); if (!i.length) throw new Error(`A discriminator value for key \`${e}\` could not be extracted from all schema options`); for (const c of i) { if (o.has(c)) throw new Error(`Discriminator property ${String(e)} has duplicate value ${String(c)}`); - o.set(c, a); + o.set(c, s); } } - return new Zr({ - typeName: A.ZodDiscriminatedUnion, + return new qr({ + typeName: C.ZodDiscriminatedUnion, discriminator: e, options: r, optionsMap: o, - ...C(n) + ...N(n) }); } } -function mn(t, e) { - const r = Ve(t), n = Ve(e); +function gn(t, e) { + const r = Je(t), n = Je(e); if (t === e) return { valid: !0, data: t }; if (r === w.object && n === w.object) { - const o = O.objectKeys(e), a = O.objectKeys(t).filter((c) => o.indexOf(c) !== -1), i = { ...t, ...e }; - for (const c of a) { - const l = mn(t[c], e[c]); + const o = D.objectKeys(e), s = D.objectKeys(t).filter((c) => o.indexOf(c) !== -1), i = { ...t, ...e }; + for (const c of s) { + const l = gn(t[c], e[c]); if (!l.valid) return { valid: !1 }; i[c] = l.data; @@ -6682,25 +6838,24 @@ function mn(t, e) { if (t.length !== e.length) return { valid: !1 }; const o = []; - for (let a = 0; a < t.length; a++) { - const i = t[a], c = e[a], l = mn(i, c); + for (let s = 0; s < t.length; s++) { + const i = t[s], c = e[s], l = gn(i, c); if (!l.valid) return { valid: !1 }; o.push(l.data); } return { valid: !0, data: o }; - } else - return r === w.date && n === w.date && +t == +e ? { valid: !0, data: t } : { valid: !1 }; + } else return r === w.date && n === w.date && +t == +e ? { valid: !0, data: t } : { valid: !1 }; } -class Vt extends $ { +class Yt extends O { _parse(e) { - const { status: r, ctx: n } = this._processInputParams(e), o = (a, i) => { - if (pn(a) || pn(i)) - return I; - const c = mn(a.value, i.value); - return c.valid ? ((hn(a) || hn(i)) && r.dirty(), { status: r.value, value: c.data }) : (b(n, { + const { status: r, ctx: n } = this._processInputParams(e), o = (s, i) => { + if (hn(s) || hn(i)) + return R; + const c = gn(s.value, i.value); + return c.valid ? ((mn(s) || mn(i)) && r.dirty(), { status: r.value, value: c.data }) : (b(n, { code: g.invalid_intersection_types - }), I); + }), R); }; return n.common.async ? Promise.all([ this._def.left._parseAsync({ @@ -6713,7 +6868,7 @@ class Vt extends $ { path: n.path, parent: n }) - ]).then(([a, i]) => o(a, i)) : o(this._def.left._parseSync({ + ]).then(([s, i]) => o(s, i)) : o(this._def.left._parseSync({ data: n.data, path: n.path, parent: n @@ -6724,13 +6879,13 @@ class Vt extends $ { })); } } -Vt.create = (t, e, r) => new Vt({ +Yt.create = (t, e, r) => new Yt({ left: t, right: e, - typeName: A.ZodIntersection, - ...C(r) + typeName: C.ZodIntersection, + ...N(r) }); -class Oe extends $ { +class Ue extends O { _parse(e) { const { status: r, ctx: n } = this._processInputParams(e); if (n.parsedType !== w.array) @@ -6738,7 +6893,7 @@ class Oe extends $ { code: g.invalid_type, expected: w.array, received: n.parsedType - }), I; + }), R; if (n.data.length < this._def.items.length) return b(n, { code: g.too_small, @@ -6746,7 +6901,7 @@ class Oe extends $ { inclusive: !0, exact: !1, type: "array" - }), I; + }), R; !this._def.rest && n.data.length > this._def.items.length && (b(n, { code: g.too_big, maximum: this._def.items.length, @@ -6754,33 +6909,33 @@ class Oe extends $ { exact: !1, type: "array" }), r.dirty()); - const a = [...n.data].map((i, c) => { + const s = [...n.data].map((i, c) => { const l = this._def.items[c] || this._def.rest; - return l ? l._parse(new Re(n, i, n.path, c)) : null; + return l ? l._parse(new De(n, i, n.path, c)) : null; }).filter((i) => !!i); - return n.common.async ? Promise.all(a).then((i) => Q.mergeArray(r, i)) : Q.mergeArray(r, a); + return n.common.async ? Promise.all(s).then((i) => se.mergeArray(r, i)) : se.mergeArray(r, s); } get items() { return this._def.items; } rest(e) { - return new Oe({ + return new Ue({ ...this._def, rest: e }); } } -Oe.create = (t, e) => { +Ue.create = (t, e) => { if (!Array.isArray(t)) throw new Error("You must pass an array of schemas to z.tuple([ ... ])"); - return new Oe({ + return new Ue({ items: t, - typeName: A.ZodTuple, + typeName: C.ZodTuple, rest: null, - ...C(e) + ...N(e) }); }; -class Wt extends $ { +class Jt extends O { get keySchema() { return this._def.keyType; } @@ -6794,34 +6949,34 @@ class Wt extends $ { code: g.invalid_type, expected: w.object, received: n.parsedType - }), I; - const o = [], a = this._def.keyType, i = this._def.valueType; + }), R; + const o = [], s = this._def.keyType, i = this._def.valueType; for (const c in n.data) o.push({ - key: a._parse(new Re(n, c, n.path, c)), - value: i._parse(new Re(n, n.data[c], n.path, c)), + key: s._parse(new De(n, c, n.path, c)), + value: i._parse(new De(n, n.data[c], n.path, c)), alwaysSet: c in n.data }); - return n.common.async ? Q.mergeObjectAsync(r, o) : Q.mergeObjectSync(r, o); + return n.common.async ? se.mergeObjectAsync(r, o) : se.mergeObjectSync(r, o); } get element() { return this._def.valueType; } static create(e, r, n) { - return r instanceof $ ? new Wt({ + return r instanceof O ? new Jt({ keyType: e, valueType: r, - typeName: A.ZodRecord, - ...C(n) - }) : new Wt({ - keyType: ke.create(), + typeName: C.ZodRecord, + ...N(n) + }) : new Jt({ + keyType: Ce.create(), valueType: e, - typeName: A.ZodRecord, - ...C(r) + typeName: C.ZodRecord, + ...N(r) }); } } -class Ar extends $ { +class Or extends O { get keySchema() { return this._def.keyType; } @@ -6835,10 +6990,10 @@ class Ar extends $ { code: g.invalid_type, expected: w.map, received: n.parsedType - }), I; - const o = this._def.keyType, a = this._def.valueType, i = [...n.data.entries()].map(([c, l], u) => ({ - key: o._parse(new Re(n, c, n.path, [u, "key"])), - value: a._parse(new Re(n, l, n.path, [u, "value"])) + }), R; + const o = this._def.keyType, s = this._def.valueType, i = [...n.data.entries()].map(([c, l], u) => ({ + key: o._parse(new De(n, c, n.path, [u, "key"])), + value: s._parse(new De(n, l, n.path, [u, "value"])) })); if (n.common.async) { const c = /* @__PURE__ */ new Map(); @@ -6846,7 +7001,7 @@ class Ar extends $ { for (const l of i) { const u = await l.key, d = await l.value; if (u.status === "aborted" || d.status === "aborted") - return I; + return R; (u.status === "dirty" || d.status === "dirty") && r.dirty(), c.set(u.value, d.value); } return { status: r.value, value: c }; @@ -6856,20 +7011,20 @@ class Ar extends $ { for (const l of i) { const u = l.key, d = l.value; if (u.status === "aborted" || d.status === "aborted") - return I; + return R; (u.status === "dirty" || d.status === "dirty") && r.dirty(), c.set(u.value, d.value); } return { status: r.value, value: c }; } } } -Ar.create = (t, e, r) => new Ar({ +Or.create = (t, e, r) => new Or({ valueType: e, keyType: t, - typeName: A.ZodMap, - ...C(r) + typeName: C.ZodMap, + ...N(r) }); -class ht extends $ { +class gt extends O { _parse(e) { const { status: r, ctx: n } = this._processInputParams(e); if (n.parsedType !== w.set) @@ -6877,7 +7032,7 @@ class ht extends $ { code: g.invalid_type, expected: w.set, received: n.parsedType - }), I; + }), R; const o = this._def; o.minSize !== null && n.data.size < o.minSize.value && (b(n, { code: g.too_small, @@ -6894,29 +7049,29 @@ class ht extends $ { exact: !1, message: o.maxSize.message }), r.dirty()); - const a = this._def.valueType; + const s = this._def.valueType; function i(l) { const u = /* @__PURE__ */ new Set(); for (const d of l) { if (d.status === "aborted") - return I; + return R; d.status === "dirty" && r.dirty(), u.add(d.value); } return { status: r.value, value: u }; } - const c = [...n.data.values()].map((l, u) => a._parse(new Re(n, l, n.path, u))); + const c = [...n.data.values()].map((l, u) => s._parse(new De(n, l, n.path, u))); return n.common.async ? Promise.all(c).then((l) => i(l)) : i(c); } min(e, r) { - return new ht({ + return new gt({ ...this._def, - minSize: { value: e, message: E.toString(r) } + minSize: { value: e, message: k.toString(r) } }); } max(e, r) { - return new ht({ + return new gt({ ...this._def, - maxSize: { value: e, message: E.toString(r) } + maxSize: { value: e, message: k.toString(r) } }); } size(e, r) { @@ -6926,14 +7081,14 @@ class ht extends $ { return this.min(1, e); } } -ht.create = (t, e) => new ht({ +gt.create = (t, e) => new gt({ valueType: t, minSize: null, maxSize: null, - typeName: A.ZodSet, - ...C(e) + typeName: C.ZodSet, + ...N(e) }); -class xt extends $ { +class Tt extends O { constructor() { super(...arguments), this.validate = this.implement; } @@ -6944,16 +7099,16 @@ class xt extends $ { code: g.invalid_type, expected: w.function, received: r.parsedType - }), I; + }), R; function n(c, l) { - return xr({ + return Cr({ data: c, path: r.path, errorMaps: [ r.common.contextualErrorMap, r.schemaErrorMap, - Er(), - Tt + Ir(), + Rt ].filter((u) => !!u), issueData: { code: g.invalid_arguments, @@ -6962,14 +7117,14 @@ class xt extends $ { }); } function o(c, l) { - return xr({ + return Cr({ data: c, path: r.path, errorMaps: [ r.common.contextualErrorMap, r.schemaErrorMap, - Er(), - Tt + Ir(), + Rt ].filter((u) => !!u), issueData: { code: g.invalid_return_type, @@ -6977,26 +7132,26 @@ class xt extends $ { } }); } - const a = { errorMap: r.common.contextualErrorMap }, i = r.data; - if (this._def.returns instanceof It) { + const s = { errorMap: r.common.contextualErrorMap }, i = r.data; + if (this._def.returns instanceof Nt) { const c = this; - return ae(async function(...l) { - const u = new fe([]), d = await c._def.args.parseAsync(l, a).catch((p) => { + return pe(async function(...l) { + const u = new _e([]), d = await c._def.args.parseAsync(l, s).catch((p) => { throw u.addIssue(n(l, p)), u; }), f = await Reflect.apply(i, this, d); - return await c._def.returns._def.type.parseAsync(f, a).catch((p) => { + return await c._def.returns._def.type.parseAsync(f, s).catch((p) => { throw u.addIssue(o(f, p)), u; }); }); } else { const c = this; - return ae(function(...l) { - const u = c._def.args.safeParse(l, a); + return pe(function(...l) { + const u = c._def.args.safeParse(l, s); if (!u.success) - throw new fe([n(l, u.error)]); - const d = Reflect.apply(i, this, u.data), f = c._def.returns.safeParse(d, a); + throw new _e([n(l, u.error)]); + const d = Reflect.apply(i, this, u.data), f = c._def.returns.safeParse(d, s); if (!f.success) - throw new fe([o(d, f.error)]); + throw new _e([o(d, f.error)]); return f.data; }); } @@ -7008,13 +7163,13 @@ class xt extends $ { return this._def.returns; } args(...e) { - return new xt({ + return new Tt({ ...this._def, - args: Oe.create(e).rest(dt.create()) + args: Ue.create(e).rest(pt.create()) }); } returns(e) { - return new xt({ + return new Tt({ ...this._def, returns: e }); @@ -7026,15 +7181,15 @@ class xt extends $ { return this.parse(e); } static create(e, r, n) { - return new xt({ - args: e || Oe.create([]).rest(dt.create()), - returns: r || dt.create(), - typeName: A.ZodFunction, - ...C(n) + return new Tt({ + args: e || Ue.create([]).rest(pt.create()), + returns: r || pt.create(), + typeName: C.ZodFunction, + ...N(n) }); } } -class qt extends $ { +class Xt extends O { get schema() { return this._def.getter(); } @@ -7043,12 +7198,12 @@ class qt extends $ { return this._def.getter()._parse({ data: r.data, path: r.path, parent: r }); } } -qt.create = (t, e) => new qt({ +Xt.create = (t, e) => new Xt({ getter: t, - typeName: A.ZodLazy, - ...C(e) + typeName: C.ZodLazy, + ...N(e) }); -class Kt extends $ { +class Qt extends O { _parse(e) { if (e.data !== this._def.value) { const r = this._getOrReturnCtx(e); @@ -7056,7 +7211,7 @@ class Kt extends $ { received: r.data, code: g.invalid_literal, expected: this._def.value - }), I; + }), R; } return { status: "valid", value: e.data }; } @@ -7064,40 +7219,40 @@ class Kt extends $ { return this._def.value; } } -Kt.create = (t, e) => new Kt({ +Qt.create = (t, e) => new Qt({ value: t, - typeName: A.ZodLiteral, - ...C(e) + typeName: C.ZodLiteral, + ...N(e) }); function Ls(t, e) { - return new et({ + return new nt({ values: t, - typeName: A.ZodEnum, - ...C(e) + typeName: C.ZodEnum, + ...N(e) }); } -class et extends $ { +class nt extends O { constructor() { - super(...arguments), Lt.set(this, void 0); + super(...arguments), Zt.set(this, void 0); } _parse(e) { if (typeof e.data != "string") { const r = this._getOrReturnCtx(e), n = this._def.values; return b(r, { - expected: O.joinValues(n), + expected: D.joinValues(n), received: r.parsedType, code: g.invalid_type - }), I; + }), R; } - if (kr(this, Lt) || Ns(this, Lt, new Set(this._def.values)), !kr(this, Lt).has(e.data)) { + if (Rr(this, Zt) || $s(this, Zt, new Set(this._def.values)), !Rr(this, Zt).has(e.data)) { const r = this._getOrReturnCtx(e), n = this._def.values; return b(r, { received: r.data, code: g.invalid_enum_value, options: n - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } get options() { return this._def.values; @@ -7121,55 +7276,55 @@ class et extends $ { return e; } extract(e, r = this._def) { - return et.create(e, { + return nt.create(e, { ...this._def, ...r }); } exclude(e, r = this._def) { - return et.create(this.options.filter((n) => !e.includes(n)), { + return nt.create(this.options.filter((n) => !e.includes(n)), { ...this._def, ...r }); } } -Lt = /* @__PURE__ */ new WeakMap(); -et.create = Ls; -class Yt extends $ { +Zt = /* @__PURE__ */ new WeakMap(); +nt.create = Ls; +class er extends O { constructor() { - super(...arguments), Ft.set(this, void 0); + super(...arguments), zt.set(this, void 0); } _parse(e) { - const r = O.getValidEnumValues(this._def.values), n = this._getOrReturnCtx(e); + const r = D.getValidEnumValues(this._def.values), n = this._getOrReturnCtx(e); if (n.parsedType !== w.string && n.parsedType !== w.number) { - const o = O.objectValues(r); + const o = D.objectValues(r); return b(n, { - expected: O.joinValues(o), + expected: D.joinValues(o), received: n.parsedType, code: g.invalid_type - }), I; + }), R; } - if (kr(this, Ft) || Ns(this, Ft, new Set(O.getValidEnumValues(this._def.values))), !kr(this, Ft).has(e.data)) { - const o = O.objectValues(r); + if (Rr(this, zt) || $s(this, zt, new Set(D.getValidEnumValues(this._def.values))), !Rr(this, zt).has(e.data)) { + const o = D.objectValues(r); return b(n, { received: n.data, code: g.invalid_enum_value, options: o - }), I; + }), R; } - return ae(e.data); + return pe(e.data); } get enum() { return this._def.values; } } -Ft = /* @__PURE__ */ new WeakMap(); -Yt.create = (t, e) => new Yt({ +zt = /* @__PURE__ */ new WeakMap(); +er.create = (t, e) => new er({ values: t, - typeName: A.ZodNativeEnum, - ...C(e) + typeName: C.ZodNativeEnum, + ...N(e) }); -class It extends $ { +class Nt extends O { unwrap() { return this._def.type; } @@ -7180,28 +7335,28 @@ class It extends $ { code: g.invalid_type, expected: w.promise, received: r.parsedType - }), I; + }), R; const n = r.parsedType === w.promise ? r.data : Promise.resolve(r.data); - return ae(n.then((o) => this._def.type.parseAsync(o, { + return pe(n.then((o) => this._def.type.parseAsync(o, { path: r.path, errorMap: r.common.contextualErrorMap }))); } } -It.create = (t, e) => new It({ +Nt.create = (t, e) => new Nt({ type: t, - typeName: A.ZodPromise, - ...C(e) + typeName: C.ZodPromise, + ...N(e) }); -class Ae extends $ { +class Ne extends O { innerType() { return this._def.schema; } sourceType() { - return this._def.schema._def.typeName === A.ZodEffects ? this._def.schema.sourceType() : this._def.schema; + return this._def.schema._def.typeName === C.ZodEffects ? this._def.schema.sourceType() : this._def.schema; } _parse(e) { - const { status: r, ctx: n } = this._processInputParams(e), o = this._def.effect || null, a = { + const { status: r, ctx: n } = this._processInputParams(e), o = this._def.effect || null, s = { addIssue: (i) => { b(n, i), i.fatal ? r.abort() : r.dirty(); }, @@ -7209,33 +7364,33 @@ class Ae extends $ { return n.path; } }; - if (a.addIssue = a.addIssue.bind(a), o.type === "preprocess") { - const i = o.transform(n.data, a); + if (s.addIssue = s.addIssue.bind(s), o.type === "preprocess") { + const i = o.transform(n.data, s); if (n.common.async) return Promise.resolve(i).then(async (c) => { if (r.value === "aborted") - return I; + return R; const l = await this._def.schema._parseAsync({ data: c, path: n.path, parent: n }); - return l.status === "aborted" ? I : l.status === "dirty" || r.value === "dirty" ? wt(l.value) : l; + return l.status === "aborted" ? R : l.status === "dirty" || r.value === "dirty" ? xt(l.value) : l; }); { if (r.value === "aborted") - return I; + return R; const c = this._def.schema._parseSync({ data: i, path: n.path, parent: n }); - return c.status === "aborted" ? I : c.status === "dirty" || r.value === "dirty" ? wt(c.value) : c; + return c.status === "aborted" ? R : c.status === "dirty" || r.value === "dirty" ? xt(c.value) : c; } } if (o.type === "refinement") { const i = (c) => { - const l = o.refinement(c, a); + const l = o.refinement(c, s); if (n.common.async) return Promise.resolve(l); if (l instanceof Promise) @@ -7248,9 +7403,9 @@ class Ae extends $ { path: n.path, parent: n }); - return c.status === "aborted" ? I : (c.status === "dirty" && r.dirty(), i(c.value), { status: r.value, value: c.value }); + return c.status === "aborted" ? R : (c.status === "dirty" && r.dirty(), i(c.value), { status: r.value, value: c.value }); } else - return this._def.schema._parseAsync({ data: n.data, path: n.path, parent: n }).then((c) => c.status === "aborted" ? I : (c.status === "dirty" && r.dirty(), i(c.value).then(() => ({ status: r.value, value: c.value })))); + return this._def.schema._parseAsync({ data: n.data, path: n.path, parent: n }).then((c) => c.status === "aborted" ? R : (c.status === "dirty" && r.dirty(), i(c.value).then(() => ({ status: r.value, value: c.value })))); } if (o.type === "transform") if (n.common.async === !1) { @@ -7259,56 +7414,56 @@ class Ae extends $ { path: n.path, parent: n }); - if (!jt(i)) + if (!Gt(i)) return i; - const c = o.transform(i.value, a); + const c = o.transform(i.value, s); if (c instanceof Promise) throw new Error("Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead."); return { status: r.value, value: c }; } else - return this._def.schema._parseAsync({ data: n.data, path: n.path, parent: n }).then((i) => jt(i) ? Promise.resolve(o.transform(i.value, a)).then((c) => ({ status: r.value, value: c })) : i); - O.assertNever(o); + return this._def.schema._parseAsync({ data: n.data, path: n.path, parent: n }).then((i) => Gt(i) ? Promise.resolve(o.transform(i.value, s)).then((c) => ({ status: r.value, value: c })) : i); + D.assertNever(o); } } -Ae.create = (t, e, r) => new Ae({ +Ne.create = (t, e, r) => new Ne({ schema: t, - typeName: A.ZodEffects, + typeName: C.ZodEffects, effect: e, - ...C(r) + ...N(r) }); -Ae.createWithPreprocess = (t, e, r) => new Ae({ +Ne.createWithPreprocess = (t, e, r) => new Ne({ schema: e, effect: { type: "preprocess", transform: t }, - typeName: A.ZodEffects, - ...C(r) + typeName: C.ZodEffects, + ...N(r) }); -class Ne extends $ { +class Fe extends O { _parse(e) { - return this._getType(e) === w.undefined ? ae(void 0) : this._def.innerType._parse(e); + return this._getType(e) === w.undefined ? pe(void 0) : this._def.innerType._parse(e); } unwrap() { return this._def.innerType; } } -Ne.create = (t, e) => new Ne({ +Fe.create = (t, e) => new Fe({ innerType: t, - typeName: A.ZodOptional, - ...C(e) + typeName: C.ZodOptional, + ...N(e) }); -class tt extends $ { +class ot extends O { _parse(e) { - return this._getType(e) === w.null ? ae(null) : this._def.innerType._parse(e); + return this._getType(e) === w.null ? pe(null) : this._def.innerType._parse(e); } unwrap() { return this._def.innerType; } } -tt.create = (t, e) => new tt({ +ot.create = (t, e) => new ot({ innerType: t, - typeName: A.ZodNullable, - ...C(e) + typeName: C.ZodNullable, + ...N(e) }); -class Jt extends $ { +class tr extends O { _parse(e) { const { ctx: r } = this._processInputParams(e); let n = r.data; @@ -7322,13 +7477,13 @@ class Jt extends $ { return this._def.innerType; } } -Jt.create = (t, e) => new Jt({ +tr.create = (t, e) => new tr({ innerType: t, - typeName: A.ZodDefault, + typeName: C.ZodDefault, defaultValue: typeof e.default == "function" ? e.default : () => e.default, - ...C(e) + ...N(e) }); -class Xt extends $ { +class rr extends O { _parse(e) { const { ctx: r } = this._processInputParams(e), n = { ...r, @@ -7343,11 +7498,11 @@ class Xt extends $ { ...n } }); - return Zt(o) ? o.then((a) => ({ + return Vt(o) ? o.then((s) => ({ status: "valid", - value: a.status === "valid" ? a.value : this._def.catchValue({ + value: s.status === "valid" ? s.value : this._def.catchValue({ get error() { - return new fe(n.common.issues); + return new _e(n.common.issues); }, input: n.data }) @@ -7355,7 +7510,7 @@ class Xt extends $ { status: "valid", value: o.status === "valid" ? o.value : this._def.catchValue({ get error() { - return new fe(n.common.issues); + return new _e(n.common.issues); }, input: n.data }) @@ -7365,13 +7520,13 @@ class Xt extends $ { return this._def.innerType; } } -Xt.create = (t, e) => new Xt({ +rr.create = (t, e) => new rr({ innerType: t, - typeName: A.ZodCatch, + typeName: C.ZodCatch, catchValue: typeof e.catch == "function" ? e.catch : () => e.catch, - ...C(e) + ...N(e) }); -class Ir extends $ { +class Mr extends O { _parse(e) { if (this._getType(e) !== w.nan) { const n = this._getOrReturnCtx(e); @@ -7379,17 +7534,17 @@ class Ir extends $ { code: g.invalid_type, expected: w.nan, received: n.parsedType - }), I; + }), R; } return { status: "valid", value: e.data }; } } -Ir.create = (t) => new Ir({ - typeName: A.ZodNaN, - ...C(t) +Mr.create = (t) => new Mr({ + typeName: C.ZodNaN, + ...N(t) }); -const Gc = Symbol("zod_brand"); -class Zn extends $ { +const Bc = Symbol("zod_brand"); +class Dn extends O { _parse(e) { const { ctx: r } = this._processInputParams(e), n = r.data; return this._def.type._parse({ @@ -7402,18 +7557,18 @@ class Zn extends $ { return this._def.type; } } -class sr extends $ { +class lr extends O { _parse(e) { const { status: r, ctx: n } = this._processInputParams(e); if (n.common.async) return (async () => { - const a = await this._def.in._parseAsync({ + const s = await this._def.in._parseAsync({ data: n.data, path: n.path, parent: n }); - return a.status === "aborted" ? I : a.status === "dirty" ? (r.dirty(), wt(a.value)) : this._def.out._parseAsync({ - data: a.value, + return s.status === "aborted" ? R : s.status === "dirty" ? (r.dirty(), xt(s.value)) : this._def.out._parseAsync({ + data: s.value, path: n.path, parent: n }); @@ -7424,7 +7579,7 @@ class sr extends $ { path: n.path, parent: n }); - return o.status === "aborted" ? I : o.status === "dirty" ? (r.dirty(), { + return o.status === "aborted" ? R : o.status === "dirty" ? (r.dirty(), { status: "dirty", value: o.value }) : this._def.out._parseSync({ @@ -7435,125 +7590,125 @@ class sr extends $ { } } static create(e, r) { - return new sr({ + return new lr({ in: e, out: r, - typeName: A.ZodPipeline + typeName: C.ZodPipeline }); } } -class Qt extends $ { +class nr extends O { _parse(e) { - const r = this._def.innerType._parse(e), n = (o) => (jt(o) && (o.value = Object.freeze(o.value)), o); - return Zt(r) ? r.then((o) => n(o)) : n(r); + const r = this._def.innerType._parse(e), n = (o) => (Gt(o) && (o.value = Object.freeze(o.value)), o); + return Vt(r) ? r.then((o) => n(o)) : n(r); } unwrap() { return this._def.innerType; } } -Qt.create = (t, e) => new Qt({ +nr.create = (t, e) => new nr({ innerType: t, - typeName: A.ZodReadonly, - ...C(e) + typeName: C.ZodReadonly, + ...N(e) }); function Fs(t, e = {}, r) { - return t ? At.create().superRefine((n, o) => { - var a, i; + return t ? $t.create().superRefine((n, o) => { + var s, i; if (!t(n)) { - const c = typeof e == "function" ? e(n) : typeof e == "string" ? { message: e } : e, l = (i = (a = c.fatal) !== null && a !== void 0 ? a : r) !== null && i !== void 0 ? i : !0, u = typeof c == "string" ? { message: c } : c; + const c = typeof e == "function" ? e(n) : typeof e == "string" ? { message: e } : e, l = (i = (s = c.fatal) !== null && s !== void 0 ? s : r) !== null && i !== void 0 ? i : !0, u = typeof c == "string" ? { message: c } : c; o.addIssue({ code: "custom", ...u, fatal: l }); } - }) : At.create(); + }) : $t.create(); } -const Bc = { - object: U.lazycreate +const Gc = { + object: G.lazycreate }; -var A; +var C; (function(t) { t.ZodString = "ZodString", t.ZodNumber = "ZodNumber", t.ZodNaN = "ZodNaN", t.ZodBigInt = "ZodBigInt", t.ZodBoolean = "ZodBoolean", t.ZodDate = "ZodDate", t.ZodSymbol = "ZodSymbol", t.ZodUndefined = "ZodUndefined", t.ZodNull = "ZodNull", t.ZodAny = "ZodAny", t.ZodUnknown = "ZodUnknown", t.ZodNever = "ZodNever", t.ZodVoid = "ZodVoid", t.ZodArray = "ZodArray", t.ZodObject = "ZodObject", t.ZodUnion = "ZodUnion", t.ZodDiscriminatedUnion = "ZodDiscriminatedUnion", t.ZodIntersection = "ZodIntersection", t.ZodTuple = "ZodTuple", t.ZodRecord = "ZodRecord", t.ZodMap = "ZodMap", t.ZodSet = "ZodSet", t.ZodFunction = "ZodFunction", t.ZodLazy = "ZodLazy", t.ZodLiteral = "ZodLiteral", t.ZodEnum = "ZodEnum", t.ZodEffects = "ZodEffects", t.ZodNativeEnum = "ZodNativeEnum", t.ZodOptional = "ZodOptional", t.ZodNullable = "ZodNullable", t.ZodDefault = "ZodDefault", t.ZodCatch = "ZodCatch", t.ZodPromise = "ZodPromise", t.ZodBranded = "ZodBranded", t.ZodPipeline = "ZodPipeline", t.ZodReadonly = "ZodReadonly"; -})(A || (A = {})); -const Hc = (t, e = { +})(C || (C = {})); +const Vc = (t, e = { message: `Input not instance of ${t.name}` -}) => Fs((r) => r instanceof t, e), Ds = ke.create, Us = Xe.create, Vc = Ir.create, Wc = Qe.create, js = zt.create, qc = pt.create, Kc = Pr.create, Yc = Gt.create, Jc = Bt.create, Xc = At.create, Qc = dt.create, el = je.create, tl = Tr.create, rl = Te.create, nl = U.create, ol = U.strictCreate, sl = Ht.create, al = Zr.create, il = Vt.create, cl = Oe.create, ll = Wt.create, ul = Ar.create, dl = ht.create, fl = xt.create, pl = qt.create, hl = Kt.create, ml = et.create, gl = Yt.create, yl = It.create, _o = Ae.create, vl = Ne.create, _l = tt.create, bl = Ae.createWithPreprocess, wl = sr.create, Sl = () => Ds().optional(), El = () => Us().optional(), xl = () => js().optional(), kl = { - string: (t) => ke.create({ ...t, coerce: !0 }), - number: (t) => Xe.create({ ...t, coerce: !0 }), - boolean: (t) => zt.create({ +}) => Fs((r) => r instanceof t, e), Ds = Ce.create, Us = tt.create, Hc = Mr.create, Wc = rt.create, js = Ht.create, qc = mt.create, Kc = $r.create, Yc = Wt.create, Jc = qt.create, Xc = $t.create, Qc = pt.create, el = We.create, tl = Nr.create, rl = $e.create, nl = G.create, ol = G.strictCreate, sl = Kt.create, al = qr.create, il = Yt.create, cl = Ue.create, ll = Jt.create, ul = Or.create, dl = gt.create, fl = Tt.create, pl = Xt.create, hl = Qt.create, ml = nt.create, gl = er.create, yl = Nt.create, yo = Ne.create, vl = Fe.create, _l = ot.create, bl = Ne.createWithPreprocess, wl = lr.create, xl = () => Ds().optional(), Sl = () => Us().optional(), El = () => js().optional(), kl = { + string: (t) => Ce.create({ ...t, coerce: !0 }), + number: (t) => tt.create({ ...t, coerce: !0 }), + boolean: (t) => Ht.create({ ...t, coerce: !0 }), - bigint: (t) => Qe.create({ ...t, coerce: !0 }), - date: (t) => pt.create({ ...t, coerce: !0 }) -}, Pl = I; -var W = /* @__PURE__ */ Object.freeze({ + bigint: (t) => rt.create({ ...t, coerce: !0 }), + date: (t) => mt.create({ ...t, coerce: !0 }) +}, Pl = R; +var K = /* @__PURE__ */ Object.freeze({ __proto__: null, - defaultErrorMap: Tt, + defaultErrorMap: Rt, setErrorMap: Pc, - getErrorMap: Er, - makeIssue: xr, + getErrorMap: Ir, + makeIssue: Cr, EMPTY_PATH: Tc, addIssueToContext: b, - ParseStatus: Q, - INVALID: I, - DIRTY: wt, - OK: ae, - isAborted: pn, - isDirty: hn, - isValid: jt, - isAsync: Zt, + ParseStatus: se, + INVALID: R, + DIRTY: xt, + OK: pe, + isAborted: hn, + isDirty: mn, + isValid: Gt, + isAsync: Vt, get util() { - return O; + return D; }, get objectUtil() { - return fn; + return pn; }, ZodParsedType: w, - getParsedType: Ve, - ZodType: $, + getParsedType: Je, + ZodType: O, datetimeRegex: Ms, - ZodString: ke, - ZodNumber: Xe, - ZodBigInt: Qe, - ZodBoolean: zt, - ZodDate: pt, - ZodSymbol: Pr, - ZodUndefined: Gt, - ZodNull: Bt, - ZodAny: At, - ZodUnknown: dt, - ZodNever: je, - ZodVoid: Tr, - ZodArray: Te, - ZodObject: U, - ZodUnion: Ht, - ZodDiscriminatedUnion: Zr, - ZodIntersection: Vt, - ZodTuple: Oe, - ZodRecord: Wt, - ZodMap: Ar, - ZodSet: ht, - ZodFunction: xt, - ZodLazy: qt, - ZodLiteral: Kt, - ZodEnum: et, - ZodNativeEnum: Yt, - ZodPromise: It, - ZodEffects: Ae, - ZodTransformer: Ae, - ZodOptional: Ne, - ZodNullable: tt, - ZodDefault: Jt, - ZodCatch: Xt, - ZodNaN: Ir, - BRAND: Gc, - ZodBranded: Zn, - ZodPipeline: sr, - ZodReadonly: Qt, + ZodString: Ce, + ZodNumber: tt, + ZodBigInt: rt, + ZodBoolean: Ht, + ZodDate: mt, + ZodSymbol: $r, + ZodUndefined: Wt, + ZodNull: qt, + ZodAny: $t, + ZodUnknown: pt, + ZodNever: We, + ZodVoid: Nr, + ZodArray: $e, + ZodObject: G, + ZodUnion: Kt, + ZodDiscriminatedUnion: qr, + ZodIntersection: Yt, + ZodTuple: Ue, + ZodRecord: Jt, + ZodMap: Or, + ZodSet: gt, + ZodFunction: Tt, + ZodLazy: Xt, + ZodLiteral: Qt, + ZodEnum: nt, + ZodNativeEnum: er, + ZodPromise: Nt, + ZodEffects: Ne, + ZodTransformer: Ne, + ZodOptional: Fe, + ZodNullable: ot, + ZodDefault: tr, + ZodCatch: rr, + ZodNaN: Mr, + BRAND: Bc, + ZodBranded: Dn, + ZodPipeline: lr, + ZodReadonly: nr, custom: Fs, - Schema: $, - ZodSchema: $, - late: Bc, + Schema: O, + ZodSchema: O, + late: Gc, get ZodFirstPartyTypeKind() { - return A; + return C; }, coerce: kl, any: Xc, @@ -7562,25 +7717,25 @@ var W = /* @__PURE__ */ Object.freeze({ boolean: js, date: qc, discriminatedUnion: al, - effect: _o, + effect: yo, enum: ml, function: fl, - instanceof: Hc, + instanceof: Vc, intersection: il, lazy: pl, literal: hl, map: ul, - nan: Vc, + nan: Hc, nativeEnum: gl, never: el, null: Jc, nullable: _l, number: Us, object: nl, - oboolean: xl, - onumber: El, + oboolean: El, + onumber: Sl, optional: vl, - ostring: Sl, + ostring: xl, pipeline: wl, preprocess: bl, promise: yl, @@ -7589,7 +7744,7 @@ var W = /* @__PURE__ */ Object.freeze({ strictObject: ol, string: Ds, symbol: Kc, - transformer: _o, + transformer: yo, tuple: cl, undefined: Yc, union: sl, @@ -7598,72 +7753,144 @@ var W = /* @__PURE__ */ Object.freeze({ NEVER: Pl, ZodIssueCode: g, quotelessJson: kc, - ZodError: fe + ZodError: _e }); -const Tl = W.object({ - width: W.number().positive(), - height: W.number().positive() -}); -function Al(t, e, r, n) { - const o = document.createElement("plugin-modal"); - o.setTheme(r); - const a = 200, i = 200, c = 335, l = 590, u = { - blockStart: 40, - inlineEnd: 320 - }; - o.style.setProperty( - "--modal-block-start", - `${u.blockStart}px` - ), o.style.setProperty( - "--modal-inline-end", - `${u.inlineEnd}px` - ); - const d = window.innerWidth - u.inlineEnd, f = window.innerHeight - u.blockStart; - let h = Math.min((n == null ? void 0 : n.width) || c, d), p = Math.min((n == null ? void 0 : n.height) || l, f); - return h = Math.max(h, a), p = Math.max(p, i), o.setAttribute("title", t), o.setAttribute("iframe-src", e), o.setAttribute("width", String(h)), o.setAttribute("height", String(p)), document.body.appendChild(o), o; -} -const Il = W.function().args( - W.string(), - W.string(), - W.enum(["dark", "light"]), - Tl.optional() -).implement((t, e, r, n) => Al(t, e, r, n)), Cl = W.object({ - pluginId: W.string(), - name: W.string(), - host: W.string().url(), - code: W.string(), - icon: W.string().optional(), - description: W.string().max(200).optional(), - permissions: W.array( - W.enum([ +const Tl = K.object({ + pluginId: K.string(), + name: K.string(), + host: K.string().url(), + code: K.string(), + icon: K.string().optional(), + description: K.string().max(200).optional(), + permissions: K.array( + K.enum([ "content:read", "content:write", "library:read", "library:write", - "user:read" + "user:read", + "comment:read", + "comment:write", + "allow:downloads" ]) ) }); function Zs(t, e) { return new URL(e, t).toString(); } -function $l(t) { +function Al(t) { return fetch(t).then((e) => e.json()).then((e) => { - if (!Cl.safeParse(e).success) + if (!Tl.safeParse(e).success) throw new Error("Invalid plugin manifest"); return e; }).catch((e) => { throw console.error(e), e; }); } -function Nl(t) { - return fetch(Zs(t.host, t.code)).then((e) => { +function vo(t) { + return !t.host && !t.code.startsWith("http") ? Promise.resolve(t.code) : fetch(Zs(t.host, t.code)).then((e) => { if (e.ok) return e.text(); throw new Error("Failed to load plugin code"); }); } -const Rl = [ +const Il = K.object({ + width: K.number().positive(), + height: K.number().positive() +}); +function Cl(t, e, r, n, o) { + const s = document.createElement("plugin-modal"); + s.setTheme(r); + const i = 200, c = 200, l = 335, u = 590, d = ((n == null ? void 0 : n.width) ?? l) > window.innerWidth ? window.innerWidth - 290 : (n == null ? void 0 : n.width) ?? l, f = { + blockStart: 40, + // To be able to resize the element as expected the position must be absolute from the right. + // This value is the length of the window minus the width of the element plus the width of the design tab. + inlineStart: window.innerWidth - d - 290 + }; + s.style.setProperty( + "--modal-block-start", + `${f.blockStart}px` + ), s.style.setProperty( + "--modal-inline-start", + `${f.inlineStart}px` + ); + const h = window.innerHeight - f.blockStart; + let p = Math.min((n == null ? void 0 : n.width) || l, d), m = Math.min((n == null ? void 0 : n.height) || u, h); + return p = Math.max(p, i), m = Math.max(m, c), s.setAttribute("title", t), s.setAttribute("iframe-src", e), s.setAttribute("width", String(p)), s.setAttribute("height", String(m)), o && s.setAttribute("allow-downloads", "true"), document.body.appendChild(s), s; +} +const Rl = K.function().args( + K.string(), + K.string(), + K.enum(["dark", "light"]), + Il.optional(), + K.boolean().optional() +).implement((t, e, r, n, o) => Cl(t, e, r, n, o)); +async function $l(t, e, r, n) { + let o = await vo(e), s = !1, i = !1, c = null, l = []; + const u = /* @__PURE__ */ new Set(), d = !!e.permissions.find( + ($) => $ === "allow:downloads" + ), f = t.addListener("themechange", ($) => { + c == null || c.setTheme($); + }), h = t.addListener("finish", () => { + _(), t == null || t.removeListener(h); + }); + let p = []; + const m = () => { + L(f), p.forEach(($) => { + L($); + }), l = [], p = []; + }, _ = () => { + m(), u.forEach(clearTimeout), u.clear(), c && (c.removeEventListener("close", _), c.remove(), c = null), i = !0, r(); + }, S = async () => { + if (!s) { + s = !0; + return; + } + m(), o = await vo(e), n(o); + }, x = ($, j, F) => { + const J = t.theme, X = Zs(e.host, j); + (c == null ? void 0 : c.getAttribute("iframe-src")) !== X && (c = Rl($, X, J, F, d), c.setTheme(J), c.addEventListener("close", _, { + once: !0 + }), c.addEventListener("load", S)); + }, I = ($) => { + l.push($); + }, E = ($, j, F) => { + const J = t.addListener( + $, + (...X) => { + i || j(...X); + }, + F + ); + return p.push(J), J; + }, L = ($) => { + t.removeListener($); + }; + return { + close: _, + destroyListener: L, + openModal: x, + getModal: () => c, + registerListener: E, + registerMessageCallback: I, + sendMessage: ($) => { + l.forEach((j) => j($)); + }, + get manifest() { + return e; + }, + get context() { + return t; + }, + get timeouts() { + return u; + }, + get code() { + return o; + } + }; +} +const Nl = [ "finish", "pagechange", "filechange", @@ -7672,263 +7899,377 @@ const Rl = [ "shapechange", "contentsave" ]; -let gn = [], yn = /* @__PURE__ */ new Set([]), Mt = {}; +function Ol(t) { + const e = (n) => { + if (!t.manifest.permissions.includes(n)) + throw new Error(`Permission ${n} is not granted`); + }; + return { + penpot: { + ui: { + open: (n, o, s) => { + t.openModal(n, o, s); + }, + sendMessage(n) { + var s; + const o = new CustomEvent("message", { + detail: n + }); + (s = t.getModal()) == null || s.dispatchEvent(o); + }, + onMessage: (n) => { + K.function().parse(n), t.registerMessageCallback(n); + } + }, + utils: { + geometry: { + center(n) { + return window.app.plugins.public_utils.centerShapes(n); + } + }, + types: { + isBoard(n) { + return n.type === "board"; + }, + isGroup(n) { + return n.type === "group"; + }, + isMask(n) { + return n.type === "group" && n.isMask(); + }, + isBool(n) { + return n.type === "boolean"; + }, + isRectangle(n) { + return n.type === "rectangle"; + }, + isPath(n) { + return n.type === "path"; + }, + isText(n) { + return n.type === "text"; + }, + isEllipse(n) { + return n.type === "ellipse"; + }, + isSVG(n) { + return n.type === "svg-raw"; + } + } + }, + closePlugin: () => { + t.close(); + }, + on(n, o, s) { + return K.enum(Nl).parse(n), K.function().parse(o), e("content:read"), t.registerListener(n, o, s); + }, + off(n) { + t.destroyListener(n); + }, + // Penpot State API + get root() { + return e("content:read"), t.context.root; + }, + get currentFile() { + return e("content:read"), t.context.currentFile; + }, + get currentPage() { + return e("content:read"), t.context.currentPage; + }, + get selection() { + return e("content:read"), t.context.selection; + }, + set selection(n) { + e("content:read"), t.context.selection = n; + }, + get viewport() { + return t.context.viewport; + }, + get history() { + return t.context.history; + }, + get library() { + return e("library:read"), t.context.library; + }, + get fonts() { + return e("content:read"), t.context.fonts; + }, + get currentUser() { + return e("user:read"), t.context.currentUser; + }, + get activeUsers() { + return e("user:read"), t.context.activeUsers; + }, + shapesColors(n) { + return e("content:read"), t.context.shapesColors(n); + }, + replaceColor(n, o, s) { + return e("content:write"), t.context.replaceColor(n, o, s); + }, + get theme() { + return t.context.theme; + }, + createBoard() { + return e("content:write"), t.context.createBoard(); + }, + createRectangle() { + return e("content:write"), t.context.createRectangle(); + }, + createEllipse() { + return e("content:write"), t.context.createEllipse(); + }, + createText(n) { + return e("content:write"), t.context.createText(n); + }, + createPath() { + return e("content:write"), t.context.createPath(); + }, + createBoolean(n, o) { + return e("content:write"), t.context.createBoolean(n, o); + }, + createShapeFromSvg(n) { + return e("content:write"), t.context.createShapeFromSvg(n); + }, + group(n) { + return e("content:write"), t.context.group(n); + }, + ungroup(n, ...o) { + e("content:write"), t.context.ungroup(n, ...o); + }, + uploadMediaUrl(n, o) { + return e("content:write"), t.context.uploadMediaUrl(n, o); + }, + uploadMediaData(n, o, s) { + return e("content:write"), t.context.uploadMediaData(n, o, s); + }, + generateMarkup(n, o) { + return e("content:read"), t.context.generateMarkup(n, o); + }, + generateStyle(n, o) { + return e("content:read"), t.context.generateStyle(n, o); + }, + openViewer() { + e("content:read"), t.context.openViewer(); + }, + createPage() { + return e("content:write"), t.context.createPage(); + }, + openPage(n) { + e("content:read"), t.context.openPage(n); + }, + alignHorizontal(n, o) { + e("content:write"), t.context.alignHorizontal(n, o); + }, + alignVertical(n, o) { + e("content:write"), t.context.alignVertical(n, o); + }, + distributeHorizontal(n) { + e("content:write"), t.context.distributeHorizontal(n); + }, + distributeVertical(n) { + e("content:write"), t.context.distributeVertical(n); + }, + flatten(n) { + return e("content:write"), t.context.flatten(n); + } + } + }; +} +let _o = !1; +const P = { + hardenIntrinsics: () => { + _o || (_o = !0, hardenIntrinsics()); + }, + createCompartment: (t) => new Compartment(t), + harden: (t) => harden(t), + safeReturn(t) { + return t == null ? t : harden(t); + } +}; +function Ml(t) { + P.hardenIntrinsics(); + const e = Ol(t), r = { + get(c, l, u) { + const d = Reflect.get(c, l, u); + return typeof d == "function" ? function(...f) { + const h = d.apply(c, f); + return P.safeReturn(h); + } : P.safeReturn(d); + } + }, n = new Proxy(e.penpot, r), o = (c, l) => { + const u = { + ...l, + credentials: "omit", + headers: { + ...l == null ? void 0 : l.headers, + Authorization: "" + } + }; + return fetch(c, u).then((d) => { + const f = { + ok: d.ok, + status: d.status, + statusText: d.statusText, + url: d.url, + text: d.text.bind(d), + json: d.json.bind(d) + }; + return P.safeReturn(f); + }); + }, s = { + penpot: n, + fetch: P.harden(o), + setTimeout: P.harden( + (...[c, l]) => { + const u = setTimeout(() => { + c(); + }, l); + return t.timeouts.add(u), P.safeReturn(u); + } + ), + clearTimeout: P.harden((c) => { + clearTimeout(c), t.timeouts.delete(c); + }), + /** + * GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS + **/ + isFinite: P.harden(isFinite), + isNaN: P.harden(isNaN), + parseFloat: P.harden(parseFloat), + parseInt: P.harden(parseInt), + decodeURI: P.harden(decodeURI), + decodeURIComponent: P.harden(decodeURIComponent), + encodeURI: P.harden(encodeURI), + encodeURIComponent: P.harden(encodeURIComponent), + Object: P.harden(Object), + Boolean: P.harden(Boolean), + Symbol: P.harden(Symbol), + Number: P.harden(Number), + BigInt: P.harden(BigInt), + Math: P.harden(Math), + Date: P.harden(Date), + String: P.harden(String), + RegExp: P.harden(RegExp), + Array: P.harden(Array), + Int8Array: P.harden(Int8Array), + Uint8Array: P.harden(Uint8Array), + Uint8ClampedArray: P.harden(Uint8ClampedArray), + Int16Array: P.harden(Int16Array), + Uint16Array: P.harden(Uint16Array), + Int32Array: P.harden(Int32Array), + Uint32Array: P.harden(Uint32Array), + BigInt64Array: P.harden(BigInt64Array), + BigUint64Array: P.harden(BigUint64Array), + Float32Array: P.harden(Float32Array), + Float64Array: P.harden(Float64Array), + Map: P.harden(Map), + Set: P.harden(Set), + WeakMap: P.harden(WeakMap), + WeakSet: P.harden(WeakSet), + ArrayBuffer: P.harden(ArrayBuffer), + DataView: P.harden(DataView), + Atomics: P.harden(Atomics), + JSON: P.harden(JSON), + Promise: P.harden(Promise), + Proxy: P.harden(Proxy), + Intl: P.harden(Intl), + // Window properties + console: P.harden(window.console), + devicePixelRatio: P.harden(window.devicePixelRatio), + atob: P.harden(window.atob), + btoa: P.harden(window.btoa), + structuredClone: P.harden(window.structuredClone) + }, i = P.createCompartment(s); + return { + evaluate: () => { + i.evaluate(t.code); + }, + cleanGlobalThis: () => { + Object.keys(s).forEach((c) => { + delete i.globalThis[c]; + }); + }, + compartment: i + }; +} +async function Ll(t, e, r) { + const n = async () => { + try { + s.evaluate(); + } catch (i) { + console.error(i), o.close(); + } + }, o = await $l( + t, + e, + function() { + s.cleanGlobalThis(), r(); + }, + function() { + n(); + } + ), s = Ml(o); + return n(), { + plugin: o, + manifest: e, + compartment: s + }; +} +let ht = [], yn = null; +function Fl(t) { + yn = t; +} +const bo = () => { + ht.forEach((t) => { + t.plugin.close(); + }), ht = []; +}; window.addEventListener("message", (t) => { try { - for (const e of gn) - e(t.data); + for (const e of ht) + e.plugin.sendMessage(t.data); } catch (e) { console.error(e); } }); -function Ol(t) { - yn.forEach((e) => { - e.setTheme(t); - }); -} -function Ml(t, e) { - let r = null; - const n = () => { - Object.entries(Mt).forEach(([, i]) => { - i.forEach((c) => { - t.removeListener(c); - }); - }), r && (yn.delete(r), r.removeEventListener("close", n), r.remove()), gn = [], r = null; - }, o = (i) => { - if (!e.permissions.includes(i)) - throw new Error(`Permission ${i} is not granted`); - }; - return { - ui: { - open: (i, c, l) => { - const u = t.getTheme(); - r = Il( - i, - Zs(e.host, c), - u, - l - ), r.setTheme(u), r.addEventListener("close", n, { - once: !0 - }), yn.add(r); - }, - sendMessage(i) { - const c = new CustomEvent("message", { - detail: i - }); - r == null || r.dispatchEvent(c); - }, - onMessage: (i) => { - W.function().parse(i), gn.push(i); - } - }, - utils: { - geometry: { - center(i) { - return window.app.plugins.public_utils.centerShapes(i); - } - }, - types: { - isFrame(i) { - return i.type === "frame"; - }, - isGroup(i) { - return i.type === "group"; - }, - isMask(i) { - return i.type === "group" && i.isMask(); - }, - isBool(i) { - return i.type === "bool"; - }, - isRectangle(i) { - return i.type === "rect"; - }, - isPath(i) { - return i.type === "path"; - }, - isText(i) { - return i.type === "text"; - }, - isEllipse(i) { - return i.type === "circle"; - }, - isSVG(i) { - return i.type === "svg-raw"; - } - } - }, - closePlugin: n, - on(i, c, l) { - W.enum(Rl).parse(i), W.function().parse(c), o("content:read"); - const u = t.addListener(i, c, l); - return Mt[i] || (Mt[i] = /* @__PURE__ */ new Map()), Mt[i].set(c, u), u; - }, - off(i, c) { - let l; - typeof i == "symbol" ? l = i : c && (l = Mt[i].get(c)), l && t.removeListener(l); - }, - // Penpot State API - get root() { - return o("content:read"), t.root; - }, - get currentPage() { - return o("content:read"), t.currentPage; - }, - get selection() { - return o("content:read"), t.selection; - }, - set selection(i) { - o("content:read"), t.selection = i; - }, - get viewport() { - return t.viewport; - }, - get history() { - return t.history; - }, - get library() { - return o("library:read"), t.library; - }, - get fonts() { - return o("content:read"), t.fonts; - }, - get currentUser() { - return o("user:read"), t.currentUser; - }, - get activeUsers() { - return o("user:read"), t.activeUsers; - }, - getFile() { - return o("content:read"), t.getFile(); - }, - getPage() { - return o("content:read"), t.getPage(); - }, - getSelected() { - return o("content:read"), t.getSelected(); - }, - getSelectedShapes() { - return o("content:read"), t.getSelectedShapes(); - }, - shapesColors(i) { - return o("content:read"), t.shapesColors(i); - }, - replaceColor(i, c, l) { - return o("content:write"), t.replaceColor(i, c, l); - }, - getTheme() { - return t.getTheme(); - }, - createFrame() { - return o("content:write"), t.createFrame(); - }, - createRectangle() { - return o("content:write"), t.createRectangle(); - }, - createEllipse() { - return o("content:write"), t.createEllipse(); - }, - createText(i) { - return o("content:write"), t.createText(i); - }, - createPath() { - return o("content:write"), t.createPath(); - }, - createBoolean(i, c) { - return o("content:write"), t.createBoolean(i, c); - }, - createShapeFromSvg(i) { - return o("content:write"), t.createShapeFromSvg(i); - }, - group(i) { - return o("content:write"), t.group(i); - }, - ungroup(i, ...c) { - o("content:write"), t.ungroup(i, ...c); - }, - uploadMediaUrl(i, c) { - return o("content:write"), t.uploadMediaUrl(i, c); - }, - uploadMediaData(i, c, l) { - return o("content:write"), t.uploadMediaData(i, c, l); - }, - generateMarkup(i, c) { - return o("content:read"), t.generateMarkup(i, c); - }, - generateStyle(i, c) { - return o("content:read"), t.generateStyle(i, c); - }, - openViewer() { - o("content:read"), t.openViewer(); - }, - createPage() { - return o("content:write"), t.createPage(); - }, - openPage(i) { - o("content:read"), t.openPage(i); - } - }; -} -let bo = !1, fr = []; -const Ll = !1; -let vn = null; -function Fl(t) { - vn = t; -} -const zs = async function(t) { +const Dl = async function(t, e) { try { - const e = () => { - fr.forEach((c) => { - c.closePlugin(); - }), fr = []; - }, r = vn && vn(t.pluginId); + const r = yn && yn(t.pluginId); if (!r) return; - r.addListener("themechange", (c) => Ol(c)); - const n = await Nl(t); - bo || (bo = !0, hardenIntrinsics()), fr && !Ll && e(); - const o = Ml(r, t); - fr.push(o), new Compartment({ - penpot: harden(o), - fetch: harden((...c) => { - const l = { - ...c[1], - credentials: "omit" - }; - return fetch(c[0], l); - }), - console: harden(window.console), - Math: harden(Math), - setTimeout: harden( - (...[c, l]) => setTimeout(() => { - c(); - }, l) - ), - clearTimeout: harden((c) => { - clearTimeout(c); - }) - }).evaluate(n); - const i = r.addListener("finish", () => { - e(), r == null || r.removeListener(i); - }); - } catch (e) { - console.error(e); + bo(); + const n = await Ll( + P.harden(r), + t, + () => { + ht = ht.filter((o) => o !== n), e && e(); + } + ); + ht.push(n); + } catch (r) { + bo(), console.error(r); } -}, Dl = async function(t) { - const e = await $l(t); +}, zs = async function(t, e) { + Dl(t, e); +}, Ul = async function(t) { + const e = await Al(t); zs(e); +}, jl = function(t) { + const e = ht.find((r) => r.manifest.pluginId === t); + e && e.plugin.close(); }; console.log("%c[PLUGINS] Loading plugin system", "color: #008d7c"); repairIntrinsics({ evalTaming: "unsafeEval", stackFiltering: "verbose", errorTaming: "unsafe", - consoleTaming: "unsafe" + consoleTaming: "unsafe", + errorTrapping: "none" }); const wo = globalThis; wo.initPluginsRuntime = (t) => { try { - console.log("%c[PLUGINS] Initialize runtime", "color: #008d7c"), Fl(t), wo.ɵcontext = t("TEST"), globalThis.ɵloadPlugin = zs, globalThis.ɵloadPluginByUrl = Dl; + console.log("%c[PLUGINS] Initialize runtime", "color: #008d7c"), Fl(t), wo.ɵcontext = t("TEST"), globalThis.ɵloadPlugin = zs, globalThis.ɵloadPluginByUrl = Ul, globalThis.ɵunloadPlugin = jl; } catch (e) { console.error(e); } diff --git a/frontend/resources/polyfills/dynamicImport.js b/frontend/resources/polyfills/dynamicImport.js new file mode 100644 index 000000000..7e354e13c --- /dev/null +++ b/frontend/resources/polyfills/dynamicImport.js @@ -0,0 +1,5 @@ +if (!('dynamicImport' in window)) { + window.dynamicImport = function(uri) { + return import(uri); + } +}; diff --git a/frontend/resources/styles/common/refactor/color-defs.scss b/frontend/resources/styles/common/refactor/color-defs.scss index ade8d8991..46e6ba113 100644 --- a/frontend/resources/styles/common/refactor/color-defs.scss +++ b/frontend/resources/styles/common/refactor/color-defs.scss @@ -41,9 +41,6 @@ --status-color-info-500: #0e9be9; // used on pixel grid and status widget - //GENERIC - --color-canvas: #e8e9ea; // Not defined on DS - // APP COLORS --app-white: #ffffff; // Used in several places --app-black: #000; // Used on interactions, measurements and editor files diff --git a/frontend/resources/templates/challenge.mustache b/frontend/resources/templates/challenge.mustache new file mode 100644 index 000000000..16bba9b6a --- /dev/null +++ b/frontend/resources/templates/challenge.mustache @@ -0,0 +1,18 @@ + + + + + Penpot - Challenge + + + + + + + diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index 7846bb111..e6ab3c4eb 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -29,7 +29,7 @@ {{/manifest}} - + diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index b8fbd0de5..390dbe1a1 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -115,20 +115,30 @@ export async function compileSassAll(worker) { return path.startsWith("app/main/ui/ds/"); }; + const isOldComponentSystemFile = (path) => { + return path.startsWith("app/main/ui/components/"); + }; + let files = (await fs.readdir(sourceDir, { recursive: true })).filter( isSassFile, ); const appFiles = files .filter((path) => !isDesignSystemFile(path)) + .filter((path) => !isOldComponentSystemFile(path)) .map((path) => ph.join(sourceDir, path)); + const dsFiles = files .filter(isDesignSystemFile) .map((path) => ph.join(sourceDir, path)); + const oldComponentsFiles = files + .filter(isOldComponentSystemFile) + .map((path) => ph.join(sourceDir, path)); + const procs = [compileSass(worker, "resources/styles/main-default.scss", {})]; - for (let path of [...dsFiles, ...appFiles]) { + for (let path of [...oldComponentsFiles, ...dsFiles, ...appFiles]) { const proc = limitFn(() => compileSass(worker, path, { modules: true })); procs.push(proc); } @@ -171,14 +181,16 @@ export async function watch(baseDir, predicate, callback) { } async function readShadowManifest() { + const ts = Date.now(); try { const manifestPath = "resources/public/js/manifest.json"; let content = await fs.readFile(manifestPath, { encoding: "utf8" }); content = JSON.parse(content); const index = { - config: "js/config.js?ts=" + Date.now(), - polyfills: "js/polyfills.js?ts=" + Date.now(), + ts: ts, + config: "js/config.js?ts=" + ts, + polyfills: "js/polyfills.js?ts=" + ts, }; for (let item of content) { @@ -188,12 +200,13 @@ async function readShadowManifest() { return index; } catch (cause) { return { - config: "js/config.js", - polyfills: "js/polyfills.js", - main: "js/main.js", - shared: "js/shared.js", - worker: "js/worker.js", - rasterizer: "js/rasterizer.js", + ts: ts, + config: "js/config.js?ts=" + ts, + polyfills: "js/polyfills.js?ts=" + ts, + main: "js/main.js?ts=" + ts, + shared: "js/shared.js?ts=" + ts, + worker: "js/worker.js?ts=" + ts, + rasterizer: "js/rasterizer.js?ts=" + ts, }; } } @@ -303,7 +316,20 @@ async function readTranslations() { } } - return JSON.stringify(result); + return result; +} + +function filterTranslations(translations, langs = [], keyFilter) { + const filteredEntries = Object.entries(translations) + .filter(([translationKey, _]) => keyFilter(translationKey)) + .map(([translationKey, value]) => { + const langEntries = Object.entries(value).filter(([lang, _]) => + langs.includes(lang), + ); + return [translationKey, Object.fromEntries(langEntries)]; + }); + + return Object.fromEntries(filteredEntries); } async function generateSvgSprite(files, prefix) { @@ -355,7 +381,14 @@ async function generateTemplates() { const isDebug = process.env.NODE_ENV !== "production"; await fs.mkdir("./resources/public/", { recursive: true }); - const translations = await readTranslations(); + let translations = await readTranslations(); + const storybookTranslations = JSON.stringify( + filterTranslations(translations, ["en"], (key) => + key.startsWith("labels."), + ), + ); + translations = JSON.stringify(translations); + const manifest = await readShadowManifest(); let content; @@ -379,8 +412,8 @@ async function generateTemplates() { const pluginRuntimeUri = process.env.PENPOT_PLUGIN_DEV === "true" - ? "http://localhost:4200" - : "./plugins-runtime"; + ? "http://localhost:4200/index.js?ts=" + manifest.ts + : "plugins-runtime/index.js?ts=" + manifest.ts; content = await renderTemplate( "resources/templates/index.mustache", @@ -395,6 +428,13 @@ async function generateTemplates() { await fs.writeFile("./resources/public/index.html", content); + content = await renderTemplate( + "resources/templates/challenge.mustache", + {}, + partials, + ); + await fs.writeFile("./resources/public/challenge.html", content); + content = await renderTemplate( "resources/templates/preview-body.mustache", { @@ -408,6 +448,7 @@ async function generateTemplates() { "resources/templates/preview-head.mustache", { manifest: manifest, + translations: JSON.stringify(storybookTranslations), }, partials, ); diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index 6e26f906d..52672cb52 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -64,6 +64,12 @@ :depends-on #{:shared} :init-fn app.rasterizer/init}} + :js-options + {:entry-keys ["module" "browser" "main"] + :resolve {"penpot/vendor/text-editor-v2" + {:target :file + :file "vendor/text_editor_v2.js"}}} + :compiler-options {:output-feature-set :es2020 :output-wrapper false @@ -94,7 +100,6 @@ :modules {:base {:entries []} - :components {:exports {default app.main.ui.ds/default} :depends-on #{:base}}} @@ -149,6 +154,12 @@ :ns-regexp "^frontend-tests.*-test$" :autorun true + :js-options + {:entry-keys ["module" "browser" "main"] + :resolve {"penpot/vendor/text-editor-v2" + {:target :file + :file "vendor/text_editor_v2.js"}}} + :compiler-options {:output-feature-set :es2020 :output-wrapper false diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 908ae3ed5..8ceacdbb5 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -110,7 +110,8 @@ (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI" "https://penpot.app/privacy")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) -(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot-docs-plugins.pages.dev/technical-guide/plugins/getting-started/#examples")) +(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot-docs-plugins.pages.dev/plugins/getting-started/#examples")) +(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) (defn- normalize-uri [uri-str] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 3cbbe0d70..517376ba2 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -143,4 +143,3 @@ (reinit)))) (set! (.-stackTraceLimit js/Error) 50) - diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index a6fd8f73d..080f193fa 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -109,9 +109,12 @@ file-id file-revn undo-group tags stack-undo? source]}] (dm/assert! - "expect valid vector of changes" - (and (cpc/check-changes! redo-changes) - (cpc/check-changes! undo-changes))) + "expect valid vector of changes for redo-changes" + (cpc/check-changes! redo-changes)) + + (dm/assert! + "expect valid vector of changes for undo-changes" + (cpc/check-changes! undo-changes)) (let [commit-id (or commit-id (uuid/next)) source (d/nilv source :local) diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 0a441068f..f73836d65 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -19,33 +19,31 @@ [potok.v2.core :as ptk])) (def ^:private schema:comment-thread - (sm/define - [:map {:title "CommentThread"} - [:id ::sm/uuid] - [:page-id ::sm/uuid] - [:file-id ::sm/uuid] - [:project-id ::sm/uuid] - [:owner-id ::sm/uuid] - [:page-name :string] - [:file-name :string] - [:seqn :int] - [:content :string] - [:participants ::sm/set-of-uuid] - [:created-at ::sm/inst] - [:modified-at ::sm/inst] - [:position ::gpt/point] - [:count-unread-comments {:optional true} :int] - [:count-comments {:optional true} :int]])) + [:map {:title "CommentThread"} + [:id ::sm/uuid] + [:page-id ::sm/uuid] + [:file-id ::sm/uuid] + [:project-id ::sm/uuid] + [:owner-id ::sm/uuid] + [:page-name :string] + [:file-name :string] + [:seqn :int] + [:content :string] + [:participants ::sm/set-of-uuid] + [:created-at ::sm/inst] + [:modified-at ::sm/inst] + [:position ::gpt/point] + [:count-unread-comments {:optional true} :int] + [:count-comments {:optional true} :int]]) (def ^:private schema:comment - (sm/define - [:map {:title "Comment"} - [:id ::sm/uuid] - [:thread-id ::sm/uuid] - [:owner-id ::sm/uuid] - [:created-at ::sm/inst] - [:modified-at ::sm/inst] - [:content :string]])) + [:map {:title "Comment"} + [:id ::sm/uuid] + [:thread-id ::sm/uuid] + [:owner-id ::sm/uuid] + [:created-at ::sm/inst] + [:modified-at ::sm/inst] + [:content :string]]) (def check-comment-thread! (sm/check-fn schema:comment-thread)) @@ -58,58 +56,63 @@ (declare refresh-comment-thread) (defn created-thread-on-workspace - [{:keys [id comment page-id] :as thread}] - (ptk/reify ::created-thread-on-workspace - ptk/UpdateEvent - (update [_ state] - (let [position (select-keys thread [:position :frame-id])] - (-> state - (update :comment-threads assoc id (dissoc thread :comment)) - (update-in [:workspace-data :pages-index page-id :options :comment-threads-position] assoc id position) - (update :comments-local assoc :open id) - (update :comments-local assoc :options nil) - (update :comments-local dissoc :draft) - (update :workspace-drawing dissoc :comment) - (update-in [:comments id] assoc (:id comment) comment)))) + ([params] + (created-thread-on-workspace params true)) + ([{:keys [id comment page-id] :as thread} open?] + (ptk/reify ::created-thread-on-workspace + ptk/UpdateEvent + (update [_ state] + (let [position (select-keys thread [:position :frame-id])] + (-> state + (update :comment-threads assoc id (dissoc thread :comment)) + (update-in [:workspace-data :pages-index page-id :comment-thread-positions] assoc id position) + (cond-> open? + (update :comments-local assoc :open id)) + (update :comments-local assoc :options nil) + (update :comments-local dissoc :draft) + (update :workspace-drawing dissoc :comment) + (update-in [:comments id] assoc (:id comment) comment)))) - ptk/WatchEvent - (watch [_ _ _] - (rx/of (ptk/data-event ::ev/event - {::ev/name "create-comment-thread" - ::ev/origin "workspace" - :id id - :content-size (count (:content comment))}))))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::ev/event + {::ev/name "create-comment-thread" + ::ev/origin "workspace" + :id id + :content-size (count (:content comment))})))))) (def ^:private schema:create-thread-on-workspace - (sm/define - [:map {:title "created-thread-on-workspace"} - [:page-id ::sm/uuid] - [:file-id ::sm/uuid] - [:position ::gpt/point] - [:content :string]])) + [:map {:title "created-thread-on-workspace"} + [:page-id ::sm/uuid] + [:file-id ::sm/uuid] + [:position ::gpt/point] + [:content :string]]) (defn create-thread-on-workspace - [params] - (dm/assert! (sm/check! schema:create-thread-on-workspace params)) + ([params] + (create-thread-on-workspace params identity true)) + ([params on-thread-created open?] + (dm/assert! (sm/check! schema:create-thread-on-workspace params)) - (ptk/reify ::create-thread-on-workspace - ptk/WatchEvent - (watch [_ state _] - (let [page-id (:current-page-id state) - objects (wsh/lookup-page-objects state page-id) - frame-id (ctst/get-frame-id-by-position objects (:position params)) - params (assoc params :frame-id frame-id)] - (->> (rp/cmd! :create-comment-thread params) - (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)})) - (rx/map created-thread-on-workspace) - (rx/catch (fn [{:keys [type code] :as cause}] - (if (and (= type :restriction) - (= code :max-quote-reached)) - (rx/throw cause) - (rx/throw {:type :comment-error}))))))))) + (ptk/reify ::create-thread-on-workspace + ptk/WatchEvent + (watch [_ state _] + (let [page-id (:current-page-id state) + objects (wsh/lookup-page-objects state page-id) + frame-id (ctst/get-frame-id-by-position objects (:position params)) + params (assoc params :frame-id frame-id)] + (->> (rp/cmd! :create-comment-thread params) + (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)})) + (rx/tap on-thread-created) + (rx/map #(created-thread-on-workspace % open?)) + (rx/catch (fn [{:keys [type code] :as cause}] + (if (and (= type :restriction) + (= code :max-quote-reached)) + (rx/throw cause) + (rx/throw {:type :comment-error})))))))))) (defn created-thread-on-viewer [{:keys [id comment page-id] :as thread}] @@ -119,7 +122,7 @@ (let [position (select-keys thread [:position :frame-id])] (-> state (update :comment-threads assoc id (dissoc thread :comment)) - (update-in [:viewer :pages page-id :options :comment-threads-position] assoc id position) + (update-in [:viewer :pages page-id :comment-thread-positions] assoc id position) (update :comments-local assoc :open id) (update :comments-local assoc :options nil) (update :comments-local dissoc :draft) @@ -136,13 +139,12 @@ (def ^:private schema:create-thread-on-viewer - (sm/define - [:map {:title "created-thread-on-viewer"} - [:page-id ::sm/uuid] - [:file-id ::sm/uuid] - [:frame-id ::sm/uuid] - [:position ::gpt/point] - [:content :string]])) + [:map {:title "created-thread-on-viewer"} + [:page-id ::sm/uuid] + [:file-id ::sm/uuid] + [:frame-id ::sm/uuid] + [:position ::gpt/point] + [:content :string]]) (defn create-thread-on-viewer [params] @@ -261,29 +263,31 @@ (rx/map #(retrieve-comment-threads file-id))))))) (defn delete-comment-thread-on-workspace - [{:keys [id] :as thread}] - (dm/assert! - "expected valid comment thread" - (check-comment-thread! thread)) - (ptk/reify ::delete-comment-thread-on-workspace - ptk/UpdateEvent - (update [_ state] - (let [page-id (:current-page-id state)] - (-> state - (update-in [:workspace-data :pages-index page-id :options :comment-threads-position] dissoc id) - (update :comments dissoc id) - (update :comment-threads dissoc id)))) + ([params] + (delete-comment-thread-on-workspace params identity)) + ([{:keys [id] :as thread} on-delete] + (dm/assert! (uuid? id)) - ptk/WatchEvent - (watch [_ _ _] - (rx/concat - (->> (rp/cmd! :delete-comment-thread {:id id}) - (rx/catch #(rx/throw {:type :comment-error})) - (rx/ignore)) - (rx/of (ptk/data-event ::ev/event - {::ev/name "delete-comment-thread" - ::ev/origin "workspace" - :id id})))))) + (ptk/reify ::delete-comment-thread-on-workspace + ptk/UpdateEvent + (update [_ state] + (let [page-id (:current-page-id state)] + (-> state + (update-in [:workspace-data :pages-index page-id :comment-thread-positions] dissoc id) + (update :comments dissoc id) + (update :comment-threads dissoc id)))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (->> (rp/cmd! :delete-comment-thread {:id id}) + (rx/catch #(rx/throw {:type :comment-error})) + (rx/tap on-delete) + (rx/ignore)) + (rx/of (ptk/data-event ::ev/event + {::ev/name "delete-comment-thread" + ::ev/origin "workspace" + :id id}))))))) (defn delete-comment-thread-on-viewer [{:keys [id] :as thread}] @@ -295,7 +299,7 @@ (update [_ state] (let [page-id (:current-page-id state)] (-> state - (update-in [:viewer :pages page-id :options :comment-threads-position] dissoc id) + (update-in [:viewer :pages page-id :comment-thread-positions] dissoc id) (update :comments dissoc id) (update :comment-threads dissoc id)))) @@ -352,7 +356,7 @@ [file-id] (dm/assert! (uuid? file-id)) (letfn [(set-comment-threds [state comment-thread] - (let [path [:workspace-data :pages-index (:page-id comment-thread) :options :comment-threads-position (:id comment-thread)] + (let [path [:workspace-data :pages-index (:page-id comment-thread) :comment-thread-positions (:id comment-thread)] thread-position (get-in state path)] (cond-> state (nil? thread-position) @@ -469,11 +473,10 @@ (def ^:private schema:create-draft - (sm/define - [:map {:title "create-draft"} - [:page-id ::sm/uuid] - [:file-id ::sm/uuid] - [:position ::gpt/point]])) + [:map {:title "create-draft"} + [:page-id ::sm/uuid] + [:file-id ::sm/uuid] + [:position ::gpt/point]]) (defn create-draft [params] diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 839dd5c29..a9b219f78 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -9,8 +9,8 @@ (:require [app.common.types.components-list :as ctkl] [app.config :as cf] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] @@ -61,7 +61,7 @@ (defn hide-notifications! [] - (st/emit! msg/hide)) + (st/emit! (ntf/hide))) (defn handle-notification [{:keys [message code level] :as params}] @@ -72,16 +72,16 @@ :upgrade-version (when (or (not= (:version params) (:full cf/version)) (true? (:force params))) - (rx/of (msg/dialog + (rx/of (ntf/dialog :content (tr "notifications.by-code.upgrade-version") :controls :inline-actions - :notification-type :inline - :type level + :type :inline + :level level :actions [{:label "Refresh" :callback force-reload!}] :tag :notification))) :maintenance - (rx/of (msg/dialog + (rx/of (ntf/dialog :content (tr "notifications.by-code.maintenance") :controls :inline-actions :type level @@ -89,7 +89,7 @@ :callback hide-notifications!}] :tag :notification)) - (rx/of (msg/dialog + (rx/of (ntf/dialog :content message :controls :close :type level @@ -155,3 +155,18 @@ :files files :binary? binary?})))))))) +;;;;;;;;;;;;;;;;;;;;;; +;; Team Request +;;;;;;;;;;;;;;;;;;;;;; + +(defn create-team-access-request + [params] + (ptk/reify ::create-team-access-request + ptk/WatchEvent + (watch [_ _ _] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error rx/throw}} (meta params)] + (->> (rp/cmd! :create-team-access-request params) + (rx/tap on-success) + (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 7a0e3297a..1bedc29dd 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -941,7 +941,7 @@ (update-in [:dashboard-projects project-id :count] inc))))) (defn create-file - [{:keys [project-id] :as params}] + [{:keys [project-id name] :as params}] (dm/assert! (uuid? project-id)) (ptk/reify ::create-file ev/Event @@ -955,7 +955,7 @@ files (get state :dashboard-files) unames (cfh/get-used-names files) - name (cfh/generate-unique-name unames (str (tr "dashboard.new-file-prefix") " 1")) + name (or name (cfh/generate-unique-name unames (str (tr "dashboard.new-file-prefix") " 1"))) features (-> (features/get-team-enabled-features state) (set/difference cfeat/frontend-only-features)) params (-> params diff --git a/frontend/src/app/main/data/events.cljs b/frontend/src/app/main/data/events.cljs index 1e0cc623f..06ca2def3 100644 --- a/frontend/src/app/main/data/events.cljs +++ b/frontend/src/app/main/data/events.cljs @@ -15,7 +15,7 @@ [app.util.http :as http] [app.util.i18n :as i18n] [app.util.object :as obj] - [app.util.storage :refer [storage]] + [app.util.storage :as storage] [app.util.time :as dt] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] @@ -170,7 +170,7 @@ (let [session (atom nil) stopper (rx/filter (ptk/type? ::initialize) stream) buffer (atom #queue []) - profile (->> (rx/from-atom storage {:emit-current-value? true}) + profile (->> (rx/from-atom storage/user {:emit-current-value? true}) (rx/map :profile) (rx/map :id) (rx/pipe (rxo/distinct-contiguous)))] diff --git a/frontend/src/app/main/data/exports.cljs b/frontend/src/app/main/data/exports.cljs index d77a4a021..ebea22149 100644 --- a/frontend/src/app/main/data/exports.cljs +++ b/frontend/src/app/main/data/exports.cljs @@ -49,31 +49,30 @@ (defn show-workspace-export-dialog - ([] (show-workspace-export-dialog nil)) - ([{:keys [selected]}] - (ptk/reify ::show-workspace-export-dialog - ptk/WatchEvent - (watch [_ state _] - (let [file-id (:current-file-id state) - page-id (:current-page-id state) - selected (or selected (wsh/lookup-selected state page-id {})) + [{:keys [selected origin]}] + (ptk/reify ::show-workspace-export-dialog + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + selected (or selected (wsh/lookup-selected state page-id {})) - shapes (if (seq selected) - (wsh/lookup-shapes state selected) - (reverse (wsh/filter-shapes state #(pos? (count (:exports %)))))) + shapes (if (seq selected) + (wsh/lookup-shapes state selected) + (reverse (wsh/filter-shapes state #(pos? (count (:exports %)))))) - exports (for [shape shapes - export (:exports shape)] - (-> export - (assoc :enabled true) - (assoc :page-id page-id) - (assoc :file-id file-id) - (assoc :object-id (:id shape)) - (assoc :shape (dissoc shape :exports)) - (assoc :name (:name shape))))] + exports (for [shape shapes + export (:exports shape)] + (-> export + (assoc :enabled true) + (assoc :page-id page-id) + (assoc :file-id file-id) + (assoc :object-id (:id shape)) + (assoc :shape (dissoc shape :exports)) + (assoc :name (:name shape))))] - (rx/of (modal/show :export-shapes - {:exports (vec exports)}))))))) + (rx/of (modal/show :export-shapes + {:exports (vec exports) :origin origin})))))) (defn show-viewer-export-dialog [{:keys [shapes page-id file-id share-id exports]}] @@ -90,7 +89,7 @@ (assoc :shape (dissoc shape :exports)) (assoc :name (:name shape)) (cond-> share-id (assoc :share-id share-id))))] - (rx/of (modal/show :export-shapes {:exports (vec exports)})))))) #_TODO + (rx/of (modal/show :export-shapes {:exports (vec exports) :origin "viewer"})))))) #_TODO (defn show-workspace-export-frames-dialog [frames] @@ -108,7 +107,7 @@ :name (:name frame)})] (rx/of (modal/show :export-frames - {:exports (vec exports)})))))) + {:exports (vec exports) :origin "workspace:menu"})))))) (defn- initialize-export-status [exports cmd resource] diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index b7150b033..87f670903 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -13,12 +13,12 @@ [app.common.media :as cm] [app.common.uuid :as uuid] [app.main.data.events :as ev] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.fonts :as fonts] [app.main.repo :as rp] [app.main.store :as st] [app.util.i18n :refer [tr]] - [app.util.storage :refer [storage]] + [app.util.storage :as storage] [app.util.webapi :as wa] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -183,7 +183,7 @@ #(when (not-empty %) (st/emit! - (msg/error + (ntf/error (if (> (count %) 1) (tr "errors.bad-font-plural" (str/join ", " %)) (tr "errors.bad-font" (first %))))))) @@ -335,8 +335,9 @@ (assoc-in state [:workspace-data :recent-fonts] most-recent-fonts))) ptk/EffectEvent (effect [_ state _] - (let [most-recent-fonts (get-in state [:workspace-data :recent-fonts])] - (swap! storage assoc ::recent-fonts most-recent-fonts))))) + (let [most-recent-fonts (get-in state [:workspace-data :recent-fonts])] + ;; FIXME: this should be prefixed by team + (swap! storage/user assoc ::recent-fonts most-recent-fonts))))) (defn load-recent-fonts [fonts] @@ -344,7 +345,7 @@ ptk/UpdateEvent (update [_ state] (let [fonts-map (d/index-by :id fonts) - saved-recent-fonts (->> (::recent-fonts @storage) + saved-recent-fonts (->> (::recent-fonts storage/user) (keep #(get fonts-map (:id %))) (into #{}))] (assoc-in state [:workspace-data :recent-fonts] saved-recent-fonts))))) diff --git a/frontend/src/app/main/data/media.cljs b/frontend/src/app/main/data/media.cljs index e78892bb1..904623505 100644 --- a/frontend/src/app/main/data/media.cljs +++ b/frontend/src/app/main/data/media.cljs @@ -8,7 +8,7 @@ (:require [app.common.exceptions :as ex] [app.common.media :as cm] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.store :as st] [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] @@ -46,14 +46,14 @@ (defn notify-start-loading [] - (st/emit! (msg/show {:content (tr "media.loading") - :notification-type :toast - :type :info + (st/emit! (ntf/show {:content (tr "media.loading") + :type :toast + :level :info :timeout nil}))) (defn notify-finished-loading [] - (st/emit! msg/hide)) + (st/emit! (ntf/hide))) (defn process-error [error] @@ -69,4 +69,4 @@ :else (tr "errors.unexpected-error"))] - (rx/of (msg/error msg)))) + (rx/of (ntf/error msg)))) diff --git a/frontend/src/app/main/data/messages.cljs b/frontend/src/app/main/data/notifications.cljs similarity index 63% rename from frontend/src/app/main/data/messages.cljs rename to frontend/src/app/main/data/notifications.cljs index b02eb7d75..c58fb4c60 100644 --- a/frontend/src/app/main/data/messages.cljs +++ b/frontend/src/app/main/data/notifications.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.main.data.messages +(ns app.main.data.notifications (:require [app.common.data :as d] [app.common.data.macros :as dm] @@ -17,14 +17,14 @@ (def default-timeout 7000) -(def ^:private schema:message - [:map {:title "Message"} - [:type [::sm/one-of #{:success :error :info :warning}]] +(def ^:private schema:notification + [:map {:title "Notification"} + [:level [::sm/one-of #{:success :error :info :warning}]] [:status {:optional true} [::sm/one-of #{:visible :hide}]] [:position {:optional true} [::sm/one-of #{:fixed :floating :inline}]] - [:notification-type {:optional true} + [:type {:optional true} [::sm/one-of #{:inline :context :toast}]] [:controls {:optional true} [::sm/one-of #{:none :close :inline-actions :bottom-actions}]] @@ -43,20 +43,21 @@ [:label :string] [:callback ::sm/fn]]]]]) -(def ^:private valid-message? - (sm/validator schema:message)) +(def ^:private valid-notification? + (sm/validator schema:notification)) (defn show [data] + (dm/assert! - "expected valid message map" - (valid-message? data)) + "expected valid notification map" + (valid-notification? data)) (ptk/reify ::show ptk/UpdateEvent (update [_ state] - (let [message (assoc data :status :visible)] - (assoc state :message message))) + (let [notification (assoc data :status :visible)] + (assoc state :notification notification))) ptk/WatchEvent (watch [_ _ stream] @@ -64,42 +65,39 @@ (let [stopper (rx/filter (ptk/type? ::hide) stream)] (->> stream (rx/filter (ptk/type? :app.util.router/navigate)) - (rx/map (constantly hide)) + (rx/map (fn [_] (hide))) (rx/take-until stopper))) (when (:timeout data) (let [stopper (rx/filter (ptk/type? ::show) stream)] - (->> (rx/of hide) + (->> (rx/of (hide)) (rx/delay (:timeout data)) (rx/take-until stopper)))))))) -(def hide +(defn hide + [& {:keys [tag]}] (ptk/reify ::hide ptk/UpdateEvent (update [_ state] - (dissoc state :message)))) - -(defn hide-tag - [tag] - (ptk/reify ::hide-tag - ptk/WatchEvent - (watch [_ state _] - (let [message (get state :message)] - (when (= (:tag message) tag) - (rx/of hide)))))) + (if (some? tag) + (let [notification (get state :notification)] + (if (= tag (:tag notification)) + (dissoc state :notification) + state)) + (dissoc state :notification))))) (defn error ([content] (show {:content content - :type :error - :notification-type :toast + :level :error + :type :toast :position :fixed}))) (defn info ([content] (info content {})) ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content - :type :info - :notification-type :toast + :level :info + :type :toast :position :fixed :timeout timeout}))) @@ -107,8 +105,8 @@ ([content] (success content {})) ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content - :type :success - :notification-type :toast + :level :success + :type :toast :position :fixed :timeout timeout}))) @@ -116,31 +114,19 @@ ([content] (warn content {})) ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content - :type :warning - :notification-type :toast + :level :warning + :type :toast :position :fixed :timeout timeout}))) (defn dialog - [& {:keys [content controls actions position tag type] - :or {controls :none position :floating type :info}}] + [& {:keys [content controls actions position tag level links] + :or {controls :none position :floating level :info}}] (show (d/without-nils {:content content - :type type + :level level + :links links :position position :controls controls :actions actions :tag tag}))) - -(defn info-dialog - [& {:keys [content controls links actions tag] - :or {controls :none links nil tag nil}}] - (show (d/without-nils - {:content content - :type :info - :position :floating - :notification-type :inline - :controls controls - :links links - :actions actions - :tag tag}))) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs new file mode 100644 index 000000000..ba27e6a0a --- /dev/null +++ b/frontend/src/app/main/data/plugins.cljs @@ -0,0 +1,121 @@ +;; 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.main.data.plugins + (:require + [app.common.data.macros :as dm] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.plugins.register :as preg] + [app.util.globals :as ug] + [app.util.http :as http] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(defn fetch-manifest + [plugin-url] + (->> (http/send! {:method :get + :uri plugin-url + :omit-default-headers true + :response-type :json}) + (rx/map :body) + (rx/map #(preg/parse-manifest plugin-url %)))) + +(defn save-current-plugin + [id] + (ptk/reify ::save-current-plugin + ptk/UpdateEvent + (update [_ state] + (update-in state [:workspace-local :open-plugins] (fnil conj #{}) id)))) + +(defn remove-current-plugin + [id] + (ptk/reify ::remove-current-plugin + ptk/UpdateEvent + (update [_ state] + (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) + +(defn- load-plugin! + [{:keys [plugin-id name description host code icon permissions]}] + (try + (st/emit! (save-current-plugin plugin-id)) + (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :description description + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) + + (catch :default e + (st/emit! (remove-current-plugin plugin-id)) + (.error js/console "Error" e)))) + +(defn open-plugin! + [{:keys [url] :as manifest}] + (if url + ;; If the saved manifest has a URL we fetch the manifest to check + ;; for updates + (->> (fetch-manifest url) + (rx/subs! + (fn [new-manifest] + (let [new-manifest (merge new-manifest (select-keys manifest [:plugin-id]))] + (cond + (not= (:permissions new-manifest) (:permissions manifest)) + (modal/show! + :plugin-permissions-update + {:plugin new-manifest + :on-accept + #(do + (preg/install-plugin! new-manifest) + (load-plugin! new-manifest))}) + + (not= new-manifest manifest) + (do (preg/install-plugin! new-manifest) + (load-plugin! manifest)) + :else + (load-plugin! manifest)))) + (fn [] + ;; Error fetching the manifest we'll load the plugin with the + ;; old manifest + (load-plugin! manifest)))) + (load-plugin! manifest))) + +(defn close-plugin! + [{:keys [plugin-id]}] + (try + (.ɵunloadPlugin ^js ug/global plugin-id) + (catch :default e + (.error js/console "Error" e)))) + +(defn close-current-plugin + [] + (ptk/reify ::close-current-plugin + ptk/EffectEvent + (effect [_ state _] + (let [ids (dm/get-in state [:workspace-local :open-plugins])] + (doseq [id ids] + (close-plugin! (preg/get-plugin id))))))) + +(defn delay-open-plugin + [plugin] + (ptk/reify ::delay-open-plugin + ptk/UpdateEvent + (update [_ state] + (assoc state ::open-plugin (:plugin-id plugin))))) + +(defn check-open-plugin + [] + (ptk/reify ::check-open-plugin + ptk/WatchEvent + (watch [_ state _] + (when-let [pid (::open-plugin state)] + (open-plugin! (preg/get-plugin pid)) + (rx/of #(dissoc % ::open-plugin)))))) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 55ea364a3..34f6e0496 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -129,12 +129,11 @@ (def ^:private schema:shortcuts - (sm/define - [:map-of :keyword - [:map - [:command [:or :string [:vector :any]]] - [:fn {:optional true} fn?] - [:tooltip {:optional true} :string]]])) + [:map-of :keyword + [:map + [:command [:or :string [:vector :any]]] + [:fn {:optional true} fn?] + [:tooltip {:optional true} :string]]]) (def check-shortcuts! (sm/check-fn schema:shortcuts)) diff --git a/frontend/src/app/main/data/shortcuts_impl.js b/frontend/src/app/main/data/shortcuts_impl.js index e381cc150..0dcc27538 100644 --- a/frontend/src/app/main/data/shortcuts_impl.js +++ b/frontend/src/app/main/data/shortcuts_impl.js @@ -24,6 +24,10 @@ target.stopCallback = function (e, element, combo) { return false } + if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { + return false; + } + if ('composedPath' in e && typeof e.composedPath === 'function') { // For open shadow trees, update `element` so that the following check works. const initialEventTarget = e.composedPath()[0]; diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 0d6461979..1c729ac74 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -15,28 +15,30 @@ [app.config :as cf] [app.main.data.events :as ev] [app.main.data.media :as di] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.websocket :as ws] [app.main.features :as features] [app.main.repo :as rp] + [app.plugins.register :as register] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] - [app.util.storage :refer [storage]] + [app.util.storage :as storage] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(declare update-profile-props) + ;; --- SCHEMAS (def ^:private schema:profile - (sm/define - [:map {:title "Profile"} - [:id ::sm/uuid] - [:created-at {:optional true} :any] - [:fullname {:optional true} :string] - [:email {:optional true} :string] - [:lang {:optional true} :string] - [:theme {:optional true} :string]])) + [:map {:title "Profile"} + [:id ::sm/uuid] + [:created-at {:optional true} :any] + [:fullname {:optional true} :string] + [:email {:optional true} :string] + [:lang {:optional true} :string] + [:theme {:optional true} :string]]) (def check-profile! (sm/check-fn schema:profile)) @@ -49,14 +51,14 @@ (defn get-current-team-id [profile] - (let [team-id (::current-team-id @storage)] + (let [team-id (::current-team-id storage/user)] (or team-id (:default-team-id profile)))) (defn set-current-team! [team-id] (if (nil? team-id) - (swap! storage dissoc ::current-team-id) - (swap! storage assoc ::current-team-id team-id))) + (swap! storage/user dissoc ::current-team-id) + (swap! storage/user assoc ::current-team-id team-id))) ;; --- EVENT: fetch-teams @@ -76,9 +78,9 @@ ;; if not, dissoc it from storage. (let [ids (into #{} (map :id) teams)] - (when-let [ctid (::current-team-id @storage)] + (when-let [ctid (::current-team-id storage/user)] (when-not (contains? ids ctid) - (swap! storage dissoc ::current-team-id))))))) + (swap! storage/user dissoc ::current-team-id))))))) (defn fetch-teams [] @@ -129,13 +131,27 @@ (effect [_ state _] (let [profile (:profile state) email (:email profile) - previous-profile (:profile @storage) + previous-profile (:profile storage/user) previous-email (:email previous-profile)] (when profile - (swap! storage assoc :profile profile) + (swap! storage/user assoc :profile profile) (i18n/set-locale! (:lang profile)) (when (not= previous-email email) - (set-current-team! nil))))))) + (set-current-team! nil)) + + (register/init)))))) + +(defn- on-fetch-profile-exception + [cause] + (let [data (ex-data cause)] + (if (and (= :authorization (:type data)) + (= :challenge-required (:code data))) + (let [path (rt/get-current-path) + href (->> path + (js/encodeURIComponent) + (str "/challenge.html?redirect="))] + (rx/of (rt/nav-raw :href href))) + (rx/throw cause)))) (defn fetch-profile [] @@ -143,7 +159,8 @@ ptk/WatchEvent (watch [_ _ _] (->> (rp/cmd! :get-profile) - (rx/map profile-fetched))))) + (rx/map profile-fetched) + (rx/catch on-fetch-profile-exception))))) ;; --- EVENT: login @@ -152,14 +169,27 @@ profile. The profile can proceed from standard login or from accepting invitation, or third party auth signup or singin." [profile] - (letfn [(get-redirect-event [] - (let [team-id (get-current-team-id profile) - redirect-url (:redirect-url @storage)] - (if (some? redirect-url) - (do - (swap! storage dissoc :redirect-url) - (.replace js/location redirect-url)) - (rt/nav' :dashboard-projects {:team-id team-id}))))] + (letfn [(get-redirect-events [] + (let [team-id (get-current-team-id profile) + welcome-file-id (dm/get-in profile [:props :welcome-file-id]) + redirect-href (:login-redirect @storage/session) + current-href (rt/get-current-href)] + + (cond + (some? redirect-href) + (binding [storage/*sync* true] + (swap! storage/session dissoc :login-redirect) + (if (= current-href redirect-href) + (rx/of (rt/reload true)) + (rx/of (rt/nav-raw :href redirect-href)))) + + (some? welcome-file-id) + (rx/of (rt/nav' :workspace {:project-id (:default-project-id profile) + :file-id welcome-file-id}) + (update-profile-props {:welcome-file-id nil})) + + :else + (rx/of (rt/nav' :dashboard-projects {:team-id team-id})))))] (ptk/reify ::logged-in ev/Event @@ -176,10 +206,11 @@ ptk/WatchEvent (watch [_ _ _] (when (is-authenticated? profile) - (->> (rx/of (profile-fetched profile) - (fetch-teams) - (get-redirect-event) - (ws/initialize)) + (->> (rx/concat + (rx/of (profile-fetched profile) + (fetch-teams) + (ws/initialize)) + (get-redirect-events)) (rx/observe-on :async))))))) (declare login-from-register) @@ -233,10 +264,9 @@ (rx/catch on-error)))))) (def ^:private schema:login-with-ldap - (sm/define - [:map - [:email ::sm/email] - [:password :string]])) + [:map {:title "login-with-ldap"} + [:email ::sm/email] + [:password :string]]) (defn login-with-ldap [params] @@ -316,8 +346,7 @@ ptk/EffectEvent (effect [_ _ _] ;; We prefer to keek some stuff in the storage like the current-team-id and the profile - (swap! storage dissoc :redirect-url) - (set-current-team! nil))))) + (swap! storage/user (constantly {})))))) (defn logout ([] (logout {})) @@ -467,6 +496,7 @@ ;; TODO: for the release 1.13 we should skip fetching profile and just use ;; the response value of update-profile-props RPC call + ;; FIXME ptk/WatchEvent (watch [_ _ _] (->> (rp/cmd! :update-profile-props {:props props}) @@ -576,9 +606,8 @@ (def ^:private schema:request-profile-recovery - (sm/define - [:map {:title "request-profile-recovery" :closed true} - [:email ::sm/email]])) + [:map {:title "request-profile-recovery" :closed true} + [:email ::sm/email]]) (defn request-profile-recovery [data] @@ -602,10 +631,9 @@ (def ^:private schema:recover-profile - (sm/define - [:map {:title "recover-profile" :closed true} - [:password :string] - [:token :string]])) + [:map {:title "recover-profile" :closed true} + [:password :string] + [:token :string]]) (defn recover-profile [data] @@ -711,4 +739,4 @@ (tr "errors.generic"))] - (rx/of (msg/warn hint)))))) + (rx/of (ntf/warn hint)))))) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 456413018..d2a9bdd59 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -49,11 +49,10 @@ (def ^:private schema:initialize - (sm/define - [:map {:title "initialize"} - [:file-id ::sm/uuid] - [:share-id {:optional true} [:maybe ::sm/uuid]] - [:page-id {:optional true} ::sm/uuid]])) + [:map {:title "initialize"} + [:file-id ::sm/uuid] + [:share-id {:optional true} [:maybe ::sm/uuid]] + [:page-id {:optional true} ::sm/uuid]]) (defn initialize [{:keys [file-id share-id interactions-show?] :as params}] @@ -102,11 +101,10 @@ (def ^:private schema:fetch-bundle - (sm/define - [:map {:title "fetch-bundle"} - [:page-id ::sm/uuid] - [:file-id ::sm/uuid] - [:share-id {:optional true} ::sm/uuid]])) + [:map {:title "fetch-bundle"} + [:page-id ::sm/uuid] + [:file-id ::sm/uuid] + [:share-id {:optional true} ::sm/uuid]]) (defn- fetch-bundle [{:keys [file-id share-id] :as params}] @@ -134,7 +132,7 @@ (uuid? share-id) (assoc :share-id share-id))] (->> (rp/cmd! :get-file-fragment params) - (rx/map :content) + (rx/map :data) (rx/map #(vector key %)))))] (->> (rp/cmd! :get-view-only-bundle params') diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index fd898824d..5afe5eeeb 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -39,15 +39,15 @@ [app.main.data.comments :as dcm] [app.main.data.events :as ev] [app.main.data.fonts :as df] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] + [app.main.data.plugins :as dp] [app.main.data.users :as du] [app.main.data.workspace.bool :as dwb] [app.main.data.workspace.collapse :as dwco] [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.edition :as dwe] - [app.main.data.workspace.fix-bool-contents :as fbc] [app.main.data.workspace.fix-broken-shapes :as fbs] [app.main.data.workspace.fix-deleted-fonts :as fdf] [app.main.data.workspace.groups :as dwg] @@ -75,17 +75,21 @@ [app.main.repo :as rp] [app.main.streams :as ms] [app.main.worker :as uw] + [app.renderer-v2 :as renderer] [app.util.dom :as dom] [app.util.globals :as ug] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] + [app.util.storage :as storage] [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cljs.spec.alpha :as s] + [clojure.set :as set] [cuerdas.core :as str] - [potok.v2.core :as ptk])) + [potok.v2.core :as ptk] + [promesa.core :as p])) (def default-workspace-local {:zoom 1}) (log/set-level! :debug) @@ -129,7 +133,7 @@ (when (and (not (boolean (-> state :profile :props :v2-info-shown))) (features/active-feature? state "components/v2")) (modal/show :v2-info {})) - (fbc/fix-bool-contents) + (dp/check-open-plugin) (fdf/fix-deleted-fonts) (fbs/fix-broken-shapes))))) @@ -337,6 +341,7 @@ ptk/UpdateEvent (update [_ state] (assoc state + :recent-colors (:recent-colors storage/user) :workspace-ready? false :current-file-id file-id :current-project-id project-id @@ -347,11 +352,14 @@ (log/debug :hint "initialize-file" :file-id file-id) (let [stoper-s (rx/filter (ptk/type? ::finalize-file) stream)] (rx/merge - (rx/of msg/hide + (rx/of (ntf/hide) (features/initialize) (dcm/retrieve-comment-threads file-id) (fetch-bundle project-id file-id)) + (when (contains? cf/flags :renderer-v2) + (rx/of (renderer/init))) + (->> stream (rx/filter dch/commit?) (rx/map deref) @@ -564,7 +572,7 @@ (watch [it state _] (let [page (get-in state [:workspace-data :pages-index id]) changes (-> (pcb/empty-changes it) - (pcb/mod-page page name))] + (pcb/mod-page page {:name name}))] (rx/of (dch/commit-changes changes)))))) @@ -594,7 +602,7 @@ (-> (pcb/empty-changes it) (pcb/with-file-data file-data) (assoc :file-id file-id) - (pcb/mod-plugin-data type id page-id namespace key value))] + (pcb/set-plugin-data type id page-id namespace key value))] (rx/of (dch/commit-changes changes))))))) (declare purge-page) @@ -970,25 +978,27 @@ (map #(gal/align-to-rect % rect axis) selected-objs))) (defn align-objects - [axis] - (dm/assert! - "expected valid align axis value" - (contains? gal/valid-align-axis axis)) + ([axis] + (align-objects axis nil)) + ([axis selected] + (dm/assert! + "expected valid align axis value" + (contains? gal/valid-align-axis axis)) - (ptk/reify ::align-objects - ptk/WatchEvent - (watch [_ state _] - (let [objects (wsh/lookup-page-objects state) - selected (wsh/lookup-selected state) - moved (if (= 1 (count selected)) - (align-object-to-parent objects (first selected) axis) - (align-objects-list objects selected axis)) - undo-id (js/Symbol)] - (when (can-align? selected objects) - (rx/of (dwu/start-undo-transaction undo-id) - (dwt/position-shapes moved) - (ptk/data-event :layout/update {:ids selected}) - (dwu/commit-undo-transaction undo-id))))))) + (ptk/reify ::align-objects + ptk/WatchEvent + (watch [_ state _] + (let [objects (wsh/lookup-page-objects state) + selected (or selected (wsh/lookup-selected state)) + moved (if (= 1 (count selected)) + (align-object-to-parent objects (first selected) axis) + (align-objects-list objects selected axis)) + undo-id (js/Symbol)] + (when (can-align? selected objects) + (rx/of (dwu/start-undo-transaction undo-id) + (dwt/position-shapes moved) + (ptk/data-event :layout/update {:ids selected}) + (dwu/commit-undo-transaction undo-id)))))))) (defn can-distribute? [selected] (cond @@ -997,25 +1007,27 @@ :else true)) (defn distribute-objects - [axis] - (dm/assert! - "expected valid distribute axis value" - (contains? gal/valid-dist-axis axis)) + ([axis] + (distribute-objects axis nil)) + ([axis ids] + (dm/assert! + "expected valid distribute axis value" + (contains? gal/valid-dist-axis axis)) - (ptk/reify ::distribute-objects - ptk/WatchEvent - (watch [_ state _] - (let [page-id (:current-page-id state) - objects (wsh/lookup-page-objects state page-id) - selected (wsh/lookup-selected state) - moved (-> (map #(get objects %) selected) - (gal/distribute-space axis)) - undo-id (js/Symbol)] - (when (can-distribute? selected) - (rx/of (dwu/start-undo-transaction undo-id) - (dwt/position-shapes moved) - (ptk/data-event :layout/update {:ids selected}) - (dwu/commit-undo-transaction undo-id))))))) + (ptk/reify ::distribute-objects + ptk/WatchEvent + (watch [_ state _] + (let [page-id (:current-page-id state) + objects (wsh/lookup-page-objects state page-id) + selected (or ids (wsh/lookup-selected state)) + moved (-> (map #(get objects %) selected) + (gal/distribute-space axis)) + undo-id (js/Symbol)] + (when (can-distribute? selected) + (rx/of (dwu/start-undo-transaction undo-id) + (dwt/position-shapes moved) + (ptk/data-event :layout/update {:ids selected}) + (dwu/commit-undo-transaction undo-id)))))))) ;; --- Shape Proportions @@ -1534,7 +1546,8 @@ (let [objects (wsh/lookup-page-objects state) selected (->> (wsh/lookup-selected state) (cfh/clean-loops objects)) - features (features/get-team-enabled-features state) + features (-> (features/get-team-enabled-features state) + (set/difference cfeat/frontend-only-features)) file-id (:current-file-id state) frame-id (cfh/common-parent-frame objects selected) @@ -1551,15 +1564,40 @@ shapes (->> (cfh/selected-with-children objects selected) (keep (d/getf objects)))] - (->> (rx/from shapes) - (rx/merge-map (partial prepare-object objects frame-id)) - (rx/reduce collect-data initial) - (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) - (rx/map #(t/encode-str % {:type :json-verbose})) - (rx/map wapi/write-to-clipboard) - (rx/catch on-copy-error) - (rx/ignore))))))))) + ;; The clipboard API doesn't handle well asynchronous calls because it expects to use + ;; the clipboard in an user interaction. If you do an async call the callback is outside + ;; the thread of the UI and so Safari blocks the copying event. + ;; We use the API `ClipboardItem` that allows promises to be passed and so the event + ;; will wait for the promise to resolve and everything should work as expected. + ;; This only works in the current versions of the browsers. + (if (some? (unchecked-get ug/global "ClipboardItem")) + (let [resolve-data-promise + (p/create + (fn [resolve reject] + (->> (rx/from shapes) + (rx/merge-map (partial prepare-object objects frame-id)) + (rx/reduce collect-data initial) + (rx/map (partial sort-selected state)) + (rx/map (partial advance-copies state selected)) + (rx/map #(t/encode-str % {:type :json-verbose})) + (rx/map #(wapi/create-blob % "text/plain")) + (rx/subs! resolve reject))))] + (->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise)) + (rx/catch on-copy-error) + (rx/ignore))) + + ;; FIXME: this is to support Firefox versions below 116 that don't support `ClipboardItem` + ;; after the version 116 is less common we could remove this. + ;; https://caniuse.com/?search=ClipboardItem + (->> (rx/from shapes) + (rx/merge-map (partial prepare-object objects frame-id)) + (rx/reduce collect-data initial) + (rx/map (partial sort-selected state)) + (rx/map (partial advance-copies state selected)) + (rx/map #(t/encode-str % {:type :json-verbose})) + (rx/map wapi/write-to-clipboard) + (rx/catch on-copy-error) + (rx/ignore)))))))))) (declare ^:private paste-transit) (declare ^:private paste-text) @@ -1595,7 +1633,7 @@ (on-error [cause] (let [data (ex-data cause)] (if (:not-implemented data) - (rx/of (msg/warn (tr "errors.clipboard-not-implemented"))) + (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) (js/console.error "Clipboard error:" cause)) (rx/empty)))] @@ -1676,17 +1714,19 @@ (def ^:private schema:paste-data - (sm/define - [:map {:title "paste-data"} - [:type [:= :copied-shapes]] - [:features ::sm/set-of-strings] - [:version :int] - [:file-id ::sm/uuid] - [:selected ::sm/set-of-uuid] - [:objects - [:map-of ::sm/uuid :map]] - [:images [:set :map]] - [:position {:optional true} ::gpt/point]])) + [:map {:title "paste-data"} + [:type [:= :copied-shapes]] + [:features ::sm/set-of-strings] + [:version :int] + [:file-id ::sm/uuid] + [:selected ::sm/set-of-uuid] + [:objects + [:map-of ::sm/uuid :map]] + [:images [:set :map]] + [:position {:optional true} ::gpt/point]]) + +(def paste-data-valid? + (sm/lazy-validator schema:paste-data)) (defn- paste-transit [{:keys [images] :as pdata}] @@ -1711,9 +1751,10 @@ (let [file-id (:current-file-id state) features (features/get-team-enabled-features state)] - (sm/validate! schema:paste-data pdata - {:hint "invalid paste data" - :code :invalid-paste-data}) + (when-not (paste-data-valid? pdata) + (ex/raise :type :validation + :code :invalid-paste-data + :hibt "invalid paste data found")) (cfeat/check-paste-features! features (:features pdata)) (if (= file-id (:file-id pdata)) @@ -2062,7 +2103,7 @@ page (wsh/lookup-page state page-id) changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/set-page-option :background (:color color)))] + (pcb/mod-page {:background (:color color)}))] (rx/of (dch/commit-changes changes))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace/assets.cljs b/frontend/src/app/main/data/workspace/assets.cljs index 7db9cc1ce..5a7e528ee 100644 --- a/frontend/src/app/main/data/workspace/assets.cljs +++ b/frontend/src/app/main/data/workspace/assets.cljs @@ -7,22 +7,22 @@ (ns app.main.data.workspace.assets "Workspace assets management events and helpers." (:require - [app.util.storage :refer [storage]])) + [app.util.storage :as storage])) (defn get-current-assets-ordering [] - (let [ordering (::ordering @storage)] + (let [ordering (::ordering storage/user)] (or ordering :asc))) (defn set-current-assets-ordering! [ordering] - (swap! storage assoc ::ordering ordering)) + (swap! storage/user assoc ::ordering ordering)) (defn get-current-assets-list-style [] - (let [list-style (::list-style @storage)] + (let [list-style (::list-style storage/user)] (or list-style :thumbs))) (defn set-current-assets-list-style! [list-style] - (swap! storage assoc ::list-style list-style)) + (swap! storage/user assoc ::list-style list-style)) diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index dc0a44d4a..da8053ab9 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -12,6 +12,9 @@ [app.common.files.helpers :as cfh] [app.common.schema :as sm] [app.common.text :as txt] + [app.common.types.color :as ctc] + [app.common.types.shape :refer [check-stroke!]] + [app.common.types.shape.shadow :refer [check-shadow!]] [app.main.broadcast :as mbc] [app.main.data.events :as ev] [app.main.data.modal :as md] @@ -21,8 +24,7 @@ [app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.undo :as dwu] - [app.util.color :as uc] - [app.util.storage :refer [storage]] + [app.util.storage :as storage] [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) @@ -165,6 +167,15 @@ (defn add-fill [ids color] + + (dm/assert! + "expected a valid color struct" + (ctc/check-color! color)) + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::add-fill ptk/WatchEvent (watch [_ state _] @@ -175,6 +186,15 @@ (defn remove-fill [ids color position] + + (dm/assert! + "expected a valid color struct" + (ctc/check-color! color)) + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::remove-fill ptk/WatchEvent (watch [_ state _] @@ -187,13 +207,21 @@ (defn remove-all-fills [ids color] + + (dm/assert! + "expected a valid color struct" + (ctc/check-color! color)) + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::remove-all-fills ptk/WatchEvent (watch [_ state _] (let [remove-all (fn [shape _] (assoc shape :fills []))] (transform-fill state ids color remove-all))))) - (defn change-hide-fill-on-export [ids hide-fill-on-export] (ptk/reify ::change-hide-fill-on-export @@ -272,17 +300,25 @@ ;; example using the color selection from ;; multiple shapes) let's use the first stop ;; color - attrs (cond-> attrs - (:gradient attrs) (get-in [:gradient :stops 0])) - new-attrs (-> (merge (get-in shape [:shadow index :color]) attrs) - (d/without-nils))] - (assoc-in shape [:shadow index :color] new-attrs)))))))) + attrs (cond-> attrs + (:gradient attrs) + (dm/get-in [:gradient :stops 0])) + + attrs' (-> (dm/get-in shape [:shadow index :color]) + (merge attrs) + (d/without-nils))] + (assoc-in shape [:shadow index :color] attrs')))))))) (defn add-shadow [ids shadow] + + (dm/assert! + "expected a valid shadow struct" + (check-shadow! shadow)) + (dm/assert! "expected a valid coll of uuid's" - (sm/check-coll-of-uuid! ids)) + (every? uuid? ids)) (ptk/reify ::add-shadow ptk/WatchEvent @@ -293,6 +329,15 @@ (defn add-stroke [ids stroke] + + (dm/assert! + "expected a valid stroke struct" + (check-stroke! stroke)) + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::add-stroke ptk/WatchEvent (watch [_ _ _] @@ -301,6 +346,11 @@ (defn remove-stroke [ids position] + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::remove-stroke ptk/WatchEvent (watch [_ _ _] @@ -314,6 +364,11 @@ (defn remove-all-strokes [ids] + + (dm/assert! + "expected a valid coll of uuid's" + (every? uuid? ids)) + (ptk/reify ::remove-all-strokes ptk/WatchEvent (watch [_ _ _] @@ -376,7 +431,7 @@ :on-change handle-change-color} :allow-click-outside true}))))))) -(defn color-att->text +(defn- color-att->text [color] {:fill-color (when (:color color) (str/lower (:color color))) :fill-opacity (:opacity color) @@ -395,26 +450,57 @@ (some? has-color?) (assoc-in [:fills index] parsed-new-color)))) +(def ^:private schema:change-color-operation + [:map + [:prop [:enum :fill :stroke :shadow :content]] + [:shape-id ::sm/uuid] + [:index :int]]) + +(def ^:private schema:change-color-operations + [:vector schema:change-color-operation]) + +(def ^:private check-change-color-operations! + (sm/check-fn schema:change-color-operations)) + (defn change-color-in-selected - [new-color shapes-by-color old-color] + [operations new-color old-color] + + (dm/assert! + "expected valid color operations" + (check-change-color-operations! operations)) + + (dm/assert! + "expected valid color structure" + (ctc/check-color! new-color)) + + (dm/assert! + "expected valid color structure" + (ctc/check-color! old-color)) + (ptk/reify ::change-color-in-selected ptk/WatchEvent (watch [_ _ _] (let [undo-id (js/Symbol)] (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) - (->> (rx/from shapes-by-color) - (rx/map (fn [shape] (case (:prop shape) - :fill (change-fill [(:shape-id shape)] new-color (:index shape)) - :stroke (change-stroke [(:shape-id shape)] new-color (:index shape)) - :shadow (change-shadow [(:shape-id shape)] new-color (:index shape)) - :content (dwt/update-text-with-function - (:shape-id shape) - (partial change-text-color old-color new-color (:index shape))))))) + (->> (rx/from operations) + (rx/map (fn [{:keys [shape-id index] :as operation}] + (case (:prop operation) + :fill (change-fill [shape-id] new-color index) + :stroke (change-stroke [shape-id] new-color index) + :shadow (change-shadow [shape-id] new-color index) + :content (dwt/update-text-with-function + shape-id + (partial change-text-color old-color new-color index)))))) (rx/of (dwu/commit-undo-transaction undo-id))))))) (defn apply-color-from-palette [color stroke?] + + (dm/assert! + "expected valid color structure" + (ctc/check-color! color)) + (ptk/reify ::apply-color-from-palette ptk/WatchEvent (watch [_ state _] @@ -437,9 +523,10 @@ result (cond-> result (not group?) (conj cur))] (recur (rest pending) result))))] + (if stroke? - (rx/of (change-stroke ids (merge uc/empty-color color) 0)) - (rx/of (change-fill ids (merge uc/empty-color color) 0))))))) + (rx/of (change-stroke ids color 0)) + (rx/of (change-fill ids color 0))))))) (declare activate-colorpicker-color) (declare activate-colorpicker-gradient) @@ -448,15 +535,22 @@ (defn apply-color-from-colorpicker [color] + + (dm/assert! + "expected valid color structure" + (ctc/check-color! color)) + (ptk/reify ::apply-color-from-colorpicker ptk/WatchEvent (watch [_ _ _] - (rx/of - (cond - (:image color) (activate-colorpicker-image) - (:color color) (activate-colorpicker-color) - (= :linear (get-in color [:gradient :type])) (activate-colorpicker-gradient :linear-gradient) - (= :radial (get-in color [:gradient :type])) (activate-colorpicker-gradient :radial-gradient)))))) + ;; FIXME: revisit this + (let [gradient-type (dm/get-in color [:gradient :type])] + (rx/of + (cond + (:image color) (activate-colorpicker-image) + (:color color) (activate-colorpicker-color) + (= :linear gradient-type) (activate-colorpicker-gradient :linear-gradient) + (= :radial gradient-type) (activate-colorpicker-gradient :radial-gradient))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -596,7 +690,8 @@ (update :current-color merge changes) (update :current-color materialize-color-components) (update :current-color #(if (not= type :image) (dissoc % :image) %)) - ;; current color can be a library one I'm changing via colorpicker + ;; current color can be a library one + ;; I'm changing via colorpicker (d/dissoc-in [:current-color :id]) (d/dissoc-in [:current-color :file-id]))] (if-let [stop (:editing-stop state)] @@ -614,7 +709,8 @@ :colorpicker :type) formated-color (get-color-from-colorpicker-state (:colorpicker state)) - ;; Type is set to color on closing the colorpicker, but we can can close it while still uploading an image fill + ;; Type is set to color on closing the colorpicker, but we + ;; can can close it while still uploading an image fill ignore-color? (and (= selected-type :color) (nil? (:color formated-color)))] (when (and add-recent? (not ignore-color?)) (rx/of (dwl/add-recent-color formated-color))))))) @@ -686,6 +782,7 @@ (defn select-color [position add-color] + ;; FIXME: revisit (ptk/reify ::select-color ptk/WatchEvent (watch [_ state _] @@ -718,9 +815,9 @@ (defn get-active-color-tab [] - (let [tab (::tab @storage)] + (let [tab (::tab storage/user)] (or tab :ramp))) (defn set-active-color-tab! [tab] - (swap! storage assoc ::tab tab)) + (swap! storage/user assoc ::tab tab)) diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 69e2a77eb..22491abe7 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -132,21 +132,20 @@ (ptk/reify ::update-comment-thread-position ptk/WatchEvent (watch [it state _] - (let [thread-id (:id thread) - page (wsh/lookup-page state) - page-id (:id page) - objects (wsh/lookup-page-objects state page-id) - new-frame-id (if (nil? frame-id) - (ctst/get-frame-id-by-position objects (gpt/point new-x new-y)) - (:frame-id thread)) - thread (assoc thread - :position (gpt/point new-x new-y) - :frame-id new-frame-id) + (let [page (wsh/lookup-page state) + page-id (:id page) + objects (wsh/lookup-page-objects state page-id) + frame-id (if (nil? frame-id) + (ctst/get-frame-id-by-position objects (gpt/point new-x new-y)) + (:frame-id thread)) - changes - (-> (pcb/empty-changes it) - (pcb/with-page page) - (pcb/update-page-option :comment-threads-position assoc thread-id (select-keys thread [:position :frame-id])))] + thread (-> thread + (assoc :position (gpt/point new-x new-y)) + (assoc :frame-id frame-id)) + + changes (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-comment-thread-position thread))] (rx/merge (rx/of (dch/commit-changes changes)) @@ -164,25 +163,28 @@ (ptk/reify ::move-frame-comment-threads ptk/WatchEvent (watch [_ state _] - (let [objects (wsh/lookup-page-objects state) + (let [page (wsh/lookup-page state) + objects (get page :objects) - is-frame? (fn [id] (= :frame (get-in objects [id :type]))) + is-frame? (fn [id] (= :frame (get-in objects [id :type]))) frame-ids? (into #{} (filter is-frame?) ids) - object-modifiers (:workspace-modifiers state) + threads-position-map + (get page :comment-thread-positions) - threads-position-map (:comment-threads-position (wsh/lookup-page-options state)) + object-modifiers + (:workspace-modifiers state) build-move-event (fn [comment-thread] - (let [frame (get objects (:frame-id comment-thread)) + (let [frame (get objects (:frame-id comment-thread)) modifiers (get-in object-modifiers [(:frame-id comment-thread) :modifiers]) - frame' (gsh/transform-shape frame modifiers) - moved (gpt/to-vec (gpt/point (:x frame) (:y frame)) - (gpt/point (:x frame') (:y frame'))) - position (get-in threads-position-map [(:id comment-thread) :position]) - new-x (+ (:x position) (:x moved)) - new-y (+ (:y position) (:y moved))] + frame' (gsh/transform-shape frame modifiers) + moved (gpt/to-vec (gpt/point (:x frame) (:y frame)) + (gpt/point (:x frame') (:y frame'))) + position (get-in threads-position-map [(:id comment-thread) :position]) + new-x (+ (:x position) (:x moved)) + new-y (+ (:y position) (:y moved))] (update-comment-thread-position comment-thread [new-x new-y] (:id frame))))] (->> (:comment-threads state) diff --git a/frontend/src/app/main/data/workspace/fix_bool_contents.cljs b/frontend/src/app/main/data/workspace/fix_bool_contents.cljs deleted file mode 100644 index 5cb1c493a..000000000 --- a/frontend/src/app/main/data/workspace/fix_bool_contents.cljs +++ /dev/null @@ -1,95 +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 app.main.data.workspace.fix-bool-contents - (:require - [app.common.data :as d] - [app.common.geom.shapes :as gsh] - [app.main.data.changes :as dch] - [app.main.data.workspace.shapes :as dwsh] - [app.main.data.workspace.state-helpers :as wsh] - [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) - -;; This event will update the file so the boolean data has a pre-generated path data -;; to increase performance. -;; For new shapes this will be generated in the :reg-objects but we need to do this for -;; old files. - -;; FIXME: Remove me after June 2022 - -(defn fix-bool-contents - "This event will calculate the bool content and update the page. This is kind of a 'addhoc' migration - to fill the optional value 'bool-content'" - [] - - (letfn [(should-migrate-shape? [shape] - (and (= :bool (:type shape)) (not (contains? shape :bool-content)))) - - (should-migrate-component? [component] - (->> (:objects component) - (vals) - (d/seek should-migrate-shape?))) - - (update-shape [shape objects] - (cond-> shape - (should-migrate-shape? shape) - (assoc :bool-content (gsh/calc-bool-content shape objects)))) - - (migrate-component [component] - (-> component - (update - :objects - (fn [objects] - (d/mapm #(update-shape %2 objects) objects))))) - - (update-library - [library] - (-> library - (d/update-in-when - [:data :components] - (fn [components] - (d/mapm #(migrate-component %2) components)))))] - - (ptk/reify ::fix-bool-contents - ptk/UpdateEvent - (update [_ state] - ;; Update (only-local) the imported libraries - (-> state - (d/update-when - :workspace-libraries - (fn [libraries] (d/mapm #(update-library %2) libraries))))) - - ptk/WatchEvent - (watch [it state _] - (let [objects (wsh/lookup-page-objects state) - - ids (into #{} - (comp (filter should-migrate-shape?) (map :id)) - (vals objects)) - - components (->> (wsh/lookup-local-components state) - (vals) - (filter should-migrate-component?)) - - component-changes - (into [] - (map (fn [component] - {:type :mod-component - :id (:id component) - :objects (-> component migrate-component :objects)})) - components)] - - (rx/of (dwsh/update-shapes ids #(update-shape % objects) {:reg-objects? false - :save-undo? false - :ignore-tree true})) - - (if (empty? component-changes) - (rx/empty) - (rx/of (dch/commit-changes {:origin it - :redo-changes component-changes - :undo-changes [] - :save-undo? false})))))))) diff --git a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs index f79db6867..75f7c83d2 100644 --- a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs +++ b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs @@ -6,11 +6,9 @@ (ns app.main.data.workspace.fix-deleted-fonts (:require - [app.common.data :as d] [app.common.files.helpers :as cfh] [app.common.text :as txt] [app.main.data.changes :as dwc] - [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.state-helpers :as wsh] [app.main.fonts :as fonts] [beicon.v2.core :as rx] @@ -22,14 +20,7 @@ ;; - Moving files from one team to another in the same instance ;; - Custom fonts are explicitly deleted in the team area -(defn has-invalid-font-family - [node] - (let [fonts (deref fonts/fontsdb)] - (and - (some? (:font-family node)) - (nil? (get fonts (:font-id node)))))) - -(defn calculate-alternative-font-id +(defn- calculate-alternative-font-id [value] (let [fonts (deref fonts/fontsdb)] (->> (vals fonts) @@ -37,39 +28,44 @@ (first) :id))) -(defn should-fix-deleted-font-shape? +(defn- has-invalid-font-family? + [node] + (let [fonts (deref fonts/fontsdb) + font-family (:font-family node) + alternative-font-id (calculate-alternative-font-id font-family)] + (and (some? font-family) + (nil? (get fonts (:font-id node))) + (some? alternative-font-id)))) + +(defn- should-fix-deleted-font-shape? [shape] (let [text-nodes (txt/node-seq txt/is-text-node? (:content shape))] - (and (cfh/text-shape? shape) (some has-invalid-font-family text-nodes)))) + (and (cfh/text-shape? shape) + (some has-invalid-font-family? text-nodes)))) -(defn should-fix-deleted-font-component? +(defn- should-fix-deleted-font-component? [component] - (->> (:objects component) - (vals) - (d/seek should-fix-deleted-font-shape?))) + (let [xf (comp (map val) + (filter should-fix-deleted-font-shape?))] + (first (sequence xf (:objects component))))) -(defn should-fix-deleted-font-typography? - [typography] - (let [fonts (deref fonts/fontsdb)] - (nil? (get fonts (:font-id typography))))) - -(defn fix-deleted-font +(defn- fix-deleted-font [node] (let [alternative-font-id (calculate-alternative-font-id (:font-family node))] (cond-> node (some? alternative-font-id) (assoc :font-id alternative-font-id)))) -(defn fix-deleted-font-shape +(defn- fix-deleted-font-shape [shape] - (let [transform (partial txt/transform-nodes has-invalid-font-family fix-deleted-font)] + (let [transform (partial txt/transform-nodes has-invalid-font-family? fix-deleted-font)] (update shape :content transform))) -(defn fix-deleted-font-component +(defn- fix-deleted-font-component [component] (update component :objects (fn [objects] - (d/mapm #(fix-deleted-font-shape %2) objects)))) + (update-vals objects fix-deleted-font-shape)))) (defn fix-deleted-font-typography [typography] @@ -77,54 +73,60 @@ (cond-> typography (some? alternative-font-id) (assoc :font-id alternative-font-id)))) +(defn- generate-deleted-font-shape-changes + [{:keys [objects id]}] + (sequence + (comp (map val) + (filter should-fix-deleted-font-shape?) + (map (fn [shape] + {:type :mod-obj + :id (:id shape) + :page-id id + :operations [{:type :set + :attr :content + :val (:content (fix-deleted-font-shape shape))} + {:type :set + :attr :position-data + :val nil}]}))) + objects)) + +(defn- generate-deleted-font-components-changes + [state] + (sequence + (comp (map val) + (filter should-fix-deleted-font-component?) + (map (fn [component] + {:type :mod-component + :id (:id component) + :objects (-> (fix-deleted-font-component component) :objects)}))) + (wsh/lookup-local-components state))) + +(defn- generate-deleted-font-typography-changes + [state] + (sequence + (comp (map val) + (filter has-invalid-font-family?) + (map (fn [typography] + {:type :mod-typography + :typography (fix-deleted-font-typography typography)}))) + (get-in state [:workspace-data :typographies]))) + (defn fix-deleted-fonts [] (ptk/reify ::fix-deleted-fonts ptk/WatchEvent (watch [it state _] - (let [objects (wsh/lookup-page-objects state) - - ids (into #{} - (comp (filter should-fix-deleted-font-shape?) (map :id)) - (vals objects)) - - components (->> (wsh/lookup-local-components state) - (vals) - (filter should-fix-deleted-font-component?)) - - component-changes - (into [] - (map (fn [component] - {:type :mod-component - :id (:id component) - :objects (-> (fix-deleted-font-component component) :objects)})) - components) - - typographies (->> (get-in state [:workspace-data :typographies]) - (vals) - (filter should-fix-deleted-font-typography?)) - - typography-changes - (into [] - (map (fn [typography] - {:type :mod-typography - :typography (fix-deleted-font-typography typography)})) - typographies)] - - (rx/concat - (rx/of (dwsh/update-shapes ids #(fix-deleted-font-shape %) {:reg-objects? false - :save-undo? false - :ignore-tree true})) - (if (empty? component-changes) - (rx/empty) - (rx/of (dwc/commit-changes {:origin it - :redo-changes component-changes - :undo-changes [] - :save-undo? false}))) - - (if (empty? typography-changes) - (rx/empty) - (rx/of (dwc/commit-changes {:origin it - :redo-changes typography-changes - :undo-changes [] - :save-undo? false})))))))) + (let [data (get state :workspace-data) + shape-changes (mapcat generate-deleted-font-shape-changes (vals (:pages-index data))) + components-changes (generate-deleted-font-components-changes state) + typography-changes (generate-deleted-font-typography-changes state) + changes (concat shape-changes + components-changes + typography-changes)] + (if (seq changes) + (rx/of (dwc/commit-changes + {:origin it + :redo-changes (vec changes) + :undo-changes [] + :save-undo? false})) + (rx/empty)))))) diff --git a/frontend/src/app/main/data/workspace/grid.cljs b/frontend/src/app/main/data/workspace/grid.cljs index beaff9e61..3187c925b 100644 --- a/frontend/src/app/main/data/workspace/grid.cljs +++ b/frontend/src/app/main/data/workspace/grid.cljs @@ -6,10 +6,10 @@ (ns app.main.data.workspace.grid (:require - [app.common.colors :as clr] [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] + [app.common.types.grid :as ctg] [app.main.data.changes :as dch] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.state-helpers :as wsh] @@ -20,25 +20,6 @@ ;; Grid ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defonce ^:private default-square-params - {:size 16 - :color {:color clr/info - :opacity 0.4}}) - -(defonce ^:private default-layout-params - {:size 12 - :type :stretch - :item-length nil - :gutter 8 - :margin 0 - :color {:color clr/default-layout - :opacity 0.1}}) - -(defonce default-grid-params - {:square default-square-params - :column default-layout-params - :row default-layout-params}) - (defn add-frame-grid [frame-id] (dm/assert! (uuid? frame-id)) @@ -46,9 +27,9 @@ ptk/WatchEvent (watch [_ state _] (let [page-id (:current-page-id state) - data (get-in state [:workspace-data :pages-index page-id]) - params (or (get-in data [:options :saved-grids :square]) - (:square default-grid-params)) + page (dm/get-in state [:workspace-data :pages-index page-id]) + params (or (dm/get-in page [:default-grids :square]) + (:square ctg/default-grid-params)) grid {:type :square :params params :display true}] @@ -79,4 +60,4 @@ (rx/of (dch/commit-changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/set-page-option [:saved-grids type] params)))))))) + (pcb/set-default-grid type params)))))))) diff --git a/frontend/src/app/main/data/workspace/guides.cljs b/frontend/src/app/main/data/workspace/guides.cljs index 4e2895bb2..6547d5772 100644 --- a/frontend/src/app/main/data/workspace/guides.cljs +++ b/frontend/src/app/main/data/workspace/guides.cljs @@ -17,18 +17,12 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn make-update-guide - [guide] - (fn [other] - (cond-> other - (= (:id other) (:id guide)) - (merge guide)))) - (defn update-guides - [guide] + [{:keys [id] :as guide}] + (dm/assert! "expected valid guide" - (ctp/check-page-guide! guide)) + (ctp/valid-guide? guide)) (ptk/reify ::update-guides ev/Event @@ -41,14 +35,15 @@ changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/update-page-option :guides assoc (:id guide) guide))] + (pcb/set-guide id guide))] (rx/of (dwc/commit-changes changes)))))) (defn remove-guide - [guide] + [{:keys [id] :as guide}] + (dm/assert! "expected valid guide" - (ctp/check-page-guide! guide)) + (ctp/valid-guide? guide)) (ptk/reify ::remove-guide ev/Event @@ -57,7 +52,7 @@ ptk/UpdateEvent (update [_ state] (let [sdisj (fnil disj #{})] - (update-in state [:workspace-guides :hover] sdisj (:id guide)))) + (update-in state [:workspace-guides :hover] sdisj id))) ptk/WatchEvent (watch [it state _] @@ -65,18 +60,22 @@ changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/update-page-option :guides dissoc (:id guide)))] + (pcb/set-guide id nil))] (rx/of (dwc/commit-changes changes)))))) (defn remove-guides [ids] + + (dm/assert! + "expected a set of ids" + (every? uuid? ids)) + (ptk/reify ::remove-guides ptk/WatchEvent (watch [_ state _] - (let [page (wsh/lookup-page state) - guides (get-in page [:options :guides] {}) + (let [{:keys [guides] :as page} (wsh/lookup-page state) guides (-> (select-keys guides ids) (vals))] - (rx/from (->> guides (mapv #(remove-guide %)))))))) + (rx/from (mapv remove-guide guides)))))) (defmethod ptk/resolve ::move-frame-guides [_ args] @@ -105,7 +104,7 @@ guide (update guide :position + (get moved (:axis guide)))] (update-guides guide))) - guides (-> state wsh/lookup-page-options :guides vals)] + guides (-> state wsh/lookup-page :guides vals)] (->> guides (filter (comp frame-ids? :frame-id)) diff --git a/frontend/src/app/main/data/workspace/interactions.cljs b/frontend/src/app/main/data/workspace/interactions.cljs index 2fb10ada8..e8dfd1253 100644 --- a/frontend/src/app/main/data/workspace/interactions.cljs +++ b/frontend/src/app/main/data/workspace/interactions.cljs @@ -43,18 +43,20 @@ (wsh/lookup-page state page-id) (wsh/lookup-page state)) - flows (get-in page [:options :flows] []) - unames (cfh/get-used-names flows) + flows (get page :flows) + unames (cfh/get-used-names (vals flows)) name (or name (cfh/generate-unique-name unames "Flow 1")) - new-flow {:id (or flow-id (uuid/next)) - :name name - :starting-frame starting-frame}] + flow-id (or flow-id (uuid/next)) + + flow {:id flow-id + :name name + :starting-frame starting-frame}] (rx/of (dch/commit-changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/update-page-option :flows ctp/add-flow new-flow))))))))) + (pcb/set-flow flow-id flow))))))))) (defn add-flow-selected-frame [] @@ -79,35 +81,40 @@ (rx/of (dch/commit-changes (-> (pcb/empty-changes it) (pcb/with-page page) - (pcb/update-page-option :flows ctp/remove-flow flow-id))))))))) + (pcb/set-flow flow-id nil))))))))) (defn update-flow [page-id flow-id update-fn] - (dm/assert! (uuid? flow-id)) + + (assert (uuid? flow-id) "expect valid flow-id") + (assert (uuid? page-id) "expect valid page-id") + (ptk/reify ::update-flow ptk/WatchEvent (watch [it state _] (let [page (if page-id (wsh/lookup-page state page-id) - (wsh/lookup-page state))] - (rx/of (dch/commit-changes - (-> (pcb/empty-changes it) - (pcb/with-page page) - (pcb/update-page-option :flows ctp/update-flow flow-id update-fn)))))))) + (wsh/lookup-page state)) + flow (dm/get-in page [:flows flow-id]) + flow (some-> flow update-fn)] + + (when (some? flow) + (rx/of (dch/commit-changes + (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-flow flow-id flow))))))))) (defn rename-flow [flow-id name] - (dm/assert! (uuid? flow-id)) - (dm/assert! (string? name)) + + (assert (uuid? flow-id) "expected valid flow-id") + (assert (string? name) "expected valid name") + (ptk/reify ::rename-flow ptk/WatchEvent - (watch [it state _] + (watch [_ state _] (let [page (wsh/lookup-page state)] - (rx/of (dch/commit-changes - (-> (pcb/empty-changes it) - (pcb/with-page page) - (pcb/update-page-option :flows ctp/update-flow flow-id - #(ctp/rename-flow % name))))))))) + (rx/of (update-flow (:id page) flow-id #(assoc % :name name))))))) (defn start-rename-flow [id] @@ -140,11 +147,15 @@ ptk/WatchEvent (watch [_ state _] (let [page-id (or page-id (:current-page-id state))] - (rx/of (dwsh/update-shapes - [shape-id] - (fn [shape] - (cls/add-new-interaction shape interaction)) - {:page-id page-id})))))) + (rx/of (dwsh/update-shapes [shape-id] + (fn [shape] + (cls/add-new-interaction shape interaction)) + {:page-id page-id}) + + (when (:destination interaction) + (dwsh/update-shapes [(:destination interaction)] + cls/show-in-viewer + {:page-id page-id}))))))) (defn add-new-interaction ([shape] (add-new-interaction shape nil)) @@ -153,24 +164,27 @@ ptk/WatchEvent (watch [_ state _] (let [page-id (:current-page-id state) - objects (wsh/lookup-page-objects state page-id) + page (wsh/lookup-page state page-id) + objects (get page :objects) frame (cfh/get-root-frame objects (:id shape)) - flows (get-in state [:workspace-data - :pages-index - page-id - :options - :flows] []) + + flows (get page :objects) flow (ctp/get-frame-flow flows (:id frame))] (rx/concat - (rx/of (dwsh/update-shapes [(:id shape)] - (fn [shape] - (let [new-interaction (-> ctsi/default-interaction - (ctsi/set-destination destination) - (assoc :position-relative-to (:id shape)))] - (cls/add-new-interaction shape new-interaction))))) - (when (and (not (connected-frame? objects (:id frame))) - (nil? flow)) - (rx/of (add-flow (:id frame)))))))))) + (rx/of (dwsh/update-shapes + [(:id shape)] + (fn [shape] + (let [new-interaction (-> ctsi/default-interaction + (ctsi/set-destination destination) + (assoc :position-relative-to (:id shape)))] + (cls/add-new-interaction shape new-interaction)))) + + (when destination + (dwsh/update-shapes [destination] cls/show-in-viewer)) + + (when (and (not (connected-frame? objects (:id frame))) + (nil? flow)) + (add-flow (:id frame)))))))))) (defn remove-interaction ([shape index] @@ -181,8 +195,7 @@ (watch [_ _ _] (rx/of (dwsh/update-shapes [(:id shape)] (fn [shape] - (update shape :interactions - ctsi/remove-interaction index)) + (update shape :interactions ctsi/remove-interaction index)) {:page-id page-id})))))) (defn update-interaction ([shape index update-fn] @@ -191,11 +204,16 @@ (ptk/reify ::update-interaction ptk/WatchEvent (watch [_ _ _] - (rx/of (dwsh/update-shapes [(:id shape)] - (fn [shape] - (update shape :interactions - ctsi/update-interaction index update-fn)) - options)))))) + (let [interactions (ctsi/update-interaction (:interactions shape) index update-fn) + interaction (nth interactions index)] + (rx/of (dwsh/update-shapes + [(:id shape)] + (fn [shape] + (assoc shape :interactions interactions)) + options) + + (when (some? (:destination interaction)) + (dwsh/update-shapes [(:destination interaction)] cls/show-in-viewer options)))))))) (defn remove-all-interactions-nav-to "Remove all interactions that navigate to the given frame." diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index 85a10efff..1fb219863 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -10,7 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.main.data.events :as ev] - [app.util.storage :refer [storage]] + [app.util.storage :as storage] [clojure.set :as set] [potok.v2.core :as ptk])) @@ -148,7 +148,7 @@ stored in Storage." [layout] (reduce (fn [layout [flag key]] - (condp = (get @storage key ::none) + (condp = (get storage/user key ::none) ::none layout false (disj layout flag) true (conj layout flag))) @@ -159,7 +159,7 @@ "Given a set of layout flags, and persist a subset of them to the Storage." [layout] (doseq [[flag key] layout-flags-persistence-mapping] - (swap! storage assoc key (contains? layout flag)))) + (swap! storage/user assoc key (contains? layout flag)))) (def layout-state-persistence-mapping "A mapping of keys that need to be persisted from `:workspace-global` into Storage." @@ -171,7 +171,7 @@ props that are previously persisted in the Storage." [state] (reduce (fn [state [key skey]] - (let [val (get @storage skey ::none)] + (let [val (get storage/user skey ::none)] (if (= val ::none) state (assoc state key val)))) @@ -185,7 +185,7 @@ (doseq [[key skey] layout-state-persistence-mapping] (let [val (get state key ::does-not-exist)] (if (= val ::does-not-exist) - (swap! storage dissoc skey) - (swap! storage assoc skey val))))) + (swap! storage/user dissoc skey) + (swap! storage/user assoc skey val))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 6d7aafc56..9cdca5929 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -28,8 +28,8 @@ [app.main.data.changes :as dch] [app.main.data.comments :as dc] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.notifications :as-alias dwn] @@ -48,6 +48,7 @@ [app.util.color :as uc] [app.util.i18n :refer [tr]] [app.util.router :as rt] + [app.util.storage :as storage] [app.util.time :as dt] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -115,8 +116,13 @@ (update :id #(or % (uuid/next))) (assoc :name (or (get-in color [:image :name]) (:color color) - (uc/gradient-type->string (get-in color [:gradient :type])))))] - (dm/assert! ::ctc/color color) + (uc/gradient-type->string (get-in color [:gradient :type])))) + (d/without-nils))] + + (dm/assert! + "expect valid color structure" + (ctc/check-color! color)) + (ptk/reify ::add-color ev/Event (-data [_] color) @@ -132,16 +138,21 @@ (defn add-recent-color [color] + (dm/assert! - "expected valid recent color map" + "expected valid recent color structure" (ctc/check-recent-color! color)) (ptk/reify ::add-recent-color - ptk/WatchEvent - (watch [it _ _] - (let [changes (-> (pcb/empty-changes it) - (pcb/add-recent-color color))] - (rx/of (dch/commit-changes changes)))))) + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state)] + (update state :recent-colors ctc/add-recent-color file-id color))) + + ptk/EffectEvent + (effect [_ state _] + (let [recent-colors (:recent-colors state)] + (swap! storage/user assoc :recent-colors recent-colors))))) (def clear-color-for-rename (ptk/reify ::clear-color-for-rename @@ -149,7 +160,7 @@ (update [_ state] (assoc-in state [:workspace-local :color-for-rename] nil)))) -(defn- do-update-color +(defn- update-color* [it state color file-id] (let [data (get state :workspace-data) [path name] (cfh/parse-path-name (:name color)) @@ -165,22 +176,34 @@ (defn update-color [color file-id] + (let [color (d/without-nils color)] - (dm/assert! - "expected valid parameters" - (and (ctc/check-color! color) - (uuid? file-id))) + (dm/assert! + "expected valid color data structure" + (ctc/check-color! color)) - (ptk/reify ::update-color - ptk/WatchEvent - (watch [it state _] - (do-update-color it state color file-id)))) + (dm/assert! + "expected file-id" + (uuid? file-id)) + + (ptk/reify ::update-color + ptk/WatchEvent + (watch [it state _] + (update-color* it state color file-id))))) (defn rename-color [file-id id new-name] - (dm/verify! (uuid? file-id)) - (dm/verify! (uuid? id)) - (dm/verify! (string? new-name)) + (dm/assert! + "expected valid uuid for `id`" + (uuid? id)) + + (dm/assert! + "expected valid uuid for `file-id`" + (uuid? file-id)) + + (dm/assert! + "expected valid string for `new-name`" + (string? new-name)) (ptk/reify ::rename-color ptk/WatchEvent @@ -189,9 +212,10 @@ (if (str/empty? new-name) (rx/empty) (let [data (get state :workspace-data) - object (get-in data [:colors id]) - object (assoc object :name new-name)] - (do-update-color it state object file-id))))))) + color (get-in data [:colors id]) + color (assoc color :name new-name) + color (d/without-nils color)] + (update-color* it state color file-id))))))) (defn delete-color [{:keys [id] :as params}] @@ -227,8 +251,15 @@ (defn rename-media [id new-name] - (dm/verify! (uuid? id)) - (dm/verify! (string? new-name)) + + (dm/assert! + "expected valid uuid for `id`" + (uuid? id)) + + (dm/assert! + "expected valid string for `new-name`" + (string? new-name)) + (ptk/reify ::rename-media ptk/WatchEvent (watch [it state _] @@ -245,8 +276,11 @@ (rx/of (dch/commit-changes changes)))))))) (defn delete-media - [{:keys [id] :as params}] - (dm/assert! (uuid? id)) + [{:keys [id]}] + (dm/assert! + "expected valid uuid for `id`" + (uuid? id)) + (ptk/reify ::delete-media ev/Event (-data [_] {:id id}) @@ -419,8 +453,14 @@ (defn rename-component "Rename the component with the given id, in the current file library." [id new-name] - (dm/verify! (uuid? id)) - (dm/verify! (string? new-name)) + (dm/assert! + "expected an uuid instance" + (uuid? id)) + + (dm/assert! + "expected string for new-name" + (string? new-name)) + (ptk/reify ::rename-component ptk/WatchEvent (watch [it state _] @@ -471,8 +511,11 @@ (defn delete-component "Delete the component with the given id, from the current file library." - [{:keys [id] :as params}] - (dm/assert! (uuid? id)) + [{:keys [id]}] + (dm/assert! + "expected valid uuid for `id`" + (uuid? id)) + (ptk/reify ::delete-component ptk/WatchEvent (watch [it state _] @@ -666,8 +709,15 @@ (defn ext-library-changed [library-id modified-at revn changes] - (dm/assert! (uuid? library-id)) - (dm/assert! (ch/check-changes! changes)) + + (dm/assert! + "expected valid uuid for library-id" + (uuid? library-id)) + + (dm/assert! + "expected valid changes vector" + (ch/check-changes! changes)) + (ptk/reify ::ext-library-changed ptk/UpdateEvent (update [_ state] @@ -1016,7 +1066,7 @@ file)) (rx/concat (rx/of (set-updating-library false) - (msg/hide-tag :sync-dialog)) + (ntf/hide {:tag :sync-dialog})) (when (seq (:redo-changes changes)) (rx/of (dch/commit-changes changes))) (when-not (empty? updated-frames) @@ -1084,12 +1134,12 @@ (sync-file (:current-file-id state) (:id library))) libraries-need-sync)) - (st/emit! msg/hide)) + (st/emit! (ntf/hide))) do-dismiss #(do (st/emit! ignore-sync) - (st/emit! msg/hide))] + (st/emit! (ntf/hide)))] (when (seq libraries-need-sync) - (rx/of (msg/info-dialog + (rx/of (ntf/dialog :content (tr "workspace.updates.there-are-updates") :controls :inline-actions :links [{:label (tr "workspace.updates.more-info") @@ -1106,7 +1156,9 @@ (defn touch-component "Update the modified-at attribute of the component to now" [id] - (dm/verify! (uuid? id)) + (dm/assert! + "expected valid uuid for `id`" + (uuid? id)) (ptk/reify ::touch-component cljs.core/IDeref (-deref [_] [id]) diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index b3f5d48ec..a75012717 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -22,7 +22,7 @@ [app.config :as cf] [app.main.data.changes :as dch] [app.main.data.media :as dmm] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.state-helpers :as wsh] @@ -169,25 +169,25 @@ (handle-media-error (ex-data error) on-error) (cond (= (:code error) :invalid-svg-file) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) (= (:code error) :media-type-not-allowed) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) (= (:code error) :unable-to-access-to-url) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) (= (:code error) :invalid-image) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) (= (:code error) :media-max-file-size-reached) - (rx/of (msg/error (tr "errors.media-too-large"))) + (rx/of (ntf/error (tr "errors.media-too-large"))) (= (:code error) :media-type-mismatch) - (rx/of (msg/error (tr "errors.media-type-mismatch"))) + (rx/of (ntf/error (tr "errors.media-type-mismatch"))) (= (:code error) :unable-to-optimize) - (rx/of (msg/error (:hint error))) + (rx/of (ntf/error (:hint error))) (fn? on-error) (on-error error) @@ -195,19 +195,18 @@ :else (do (.error js/console "ERROR" error) - (rx/of (msg/error (tr "errors.cannot-upload"))))))) + (rx/of (ntf/error (tr "errors.cannot-upload"))))))) (def ^:private schema:process-media-objects - (sm/define - [:map {:title "process-media-objects"} - [:file-id ::sm/uuid] - [:local? :boolean] - [:name {:optional true} :string] - [:data {:optional true} :any] ; FIXME - [:uris {:optional true} [:sequential :string]] - [:mtype {:optional true} :string]])) + [:map {:title "process-media-objects"} + [:file-id ::sm/uuid] + [:local? :boolean] + [:name {:optional true} :string] + [:data {:optional true} :any] ; FIXME + [:uris {:optional true} [:sequential :string]] + [:mtype {:optional true} :string]]) (defn- process-media-objects [{:keys [uris on-error] :as params}] @@ -220,9 +219,9 @@ ptk/WatchEvent (watch [_ _ _] (rx/concat - (rx/of (msg/show {:content (tr "media.loading") - :notification-type :toast - :type :info + (rx/of (ntf/show {:content (tr "media.loading") + :type :toast + :level :info :timeout nil :tag :media-loading})) (->> (if (seq uris) @@ -234,7 +233,7 @@ ;; Every stream has its own sideeffect. We need to ignore the result (rx/ignore) (rx/catch #(handle-media-error % on-error)) - (rx/finalize #(st/emit! (msg/hide-tag :media-loading)))))))) + (rx/finalize #(st/emit! (ntf/hide :tag :media-loading)))))))) ;; Deprecated in components-v2 (defn upload-media-asset @@ -254,8 +253,6 @@ :on-svg #(st/emit! (svg-uploaded % file-id position)))] (process-media-objects params))) - - (defn upload-fill-image [file on-success] (dm/assert! @@ -429,10 +426,9 @@ (def ^:private schema:clone-media-object - (sm/define - [:map {:title "clone-media-object"} - [:file-id ::sm/uuid] - [:object-id ::sm/uuid]])) + [:map {:title "clone-media-object"} + [:file-id ::sm/uuid] + [:object-id ::sm/uuid]]) (defn clone-media-object [{:keys [file-id object-id] :as params}] @@ -450,15 +446,15 @@ :id object-id}] (rx/concat - (rx/of (msg/show {:content (tr "media.loading") - :notification-type :toast - :type :info + (rx/of (ntf/show {:content (tr "media.loading") + :type :toast + :level :info :timeout nil :tag :media-loading})) (->> (rp/cmd! :clone-file-media-object params) (rx/tap on-success) (rx/catch on-error) - (rx/finalize #(st/emit! (msg/hide-tag :media-loading))))))))) + (rx/finalize #(st/emit! (ntf/hide :tag :media-loading))))))))) (defn create-svg-shape [id name svg-string position] diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 932e9ccfa..e602618e1 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -24,6 +24,8 @@ [clojure.set :as set] [potok.v2.core :as ptk])) +;; FIXME: this ns should be renamed to something different + (declare process-message) (declare handle-presence) (declare handle-pointer-update) @@ -196,21 +198,23 @@ (def ^:private schema:handle-file-change - (sm/define - [:map {:title "handle-file-change"} - [:type :keyword] - [:profile-id ::sm/uuid] - [:file-id ::sm/uuid] - [:session-id ::sm/uuid] - [:revn :int] - [:changes ::cpc/changes]])) + [:map {:title "handle-file-change"} + [:type :keyword] + [:profile-id ::sm/uuid] + [:file-id ::sm/uuid] + [:session-id ::sm/uuid] + [:revn :int] + [:changes ::cpc/changes]]) + +(def ^:private check-file-change-params! + (sm/check-fn schema:handle-file-change)) (defn handle-file-change [{:keys [file-id changes revn] :as msg}] (dm/assert! "expected valid parameters" - (sm/check! schema:handle-file-change msg)) + (check-file-change-params! msg)) (ptk/reify ::handle-file-change IDeref @@ -228,23 +232,24 @@ :redo-changes (vec changes) :undo-changes []}))))) -(def ^:private - schema:handle-library-change - (sm/define - [:map {:title "handle-library-change"} - [:type :keyword] - [:profile-id ::sm/uuid] - [:file-id ::sm/uuid] - [:session-id ::sm/uuid] - [:revn :int] - [:modified-at ::sm/inst] - [:changes ::cpc/changes]])) +(def ^:private schema:handle-library-change + [:map {:title "handle-library-change"} + [:type :keyword] + [:profile-id ::sm/uuid] + [:file-id ::sm/uuid] + [:session-id ::sm/uuid] + [:revn :int] + [:modified-at ::sm/inst] + [:changes ::cpc/changes]]) + +(def ^:private check-library-change-params! + (sm/check-fn schema:handle-library-change)) (defn handle-library-change [{:keys [file-id modified-at changes revn] :as msg}] (dm/assert! "expected valid arguments" - (sm/check! schema:handle-library-change msg)) + (check-library-change-params! msg)) (ptk/reify ::handle-library-change ptk/WatchEvent diff --git a/frontend/src/app/main/data/workspace/path/common.cljs b/frontend/src/app/main/data/workspace/path/common.cljs index 8edd06ffe..483302177 100644 --- a/frontend/src/app/main/data/workspace/path/common.cljs +++ b/frontend/src/app/main/data/workspace/path/common.cljs @@ -27,20 +27,19 @@ (def ^:private schema:path-content - (sm/define - [:vector {:title "PathContent"} - [:map {:title "PathContentEntry"} - [:command [::sm/one-of valid-commands]] - ;; FIXME: remove the `?` from prop name - [:relative? {:optional true} :boolean] - [:params {:optional true} - [:map {:title "PathContentEntryParams"} - [:x :double] - [:y :double] - [:c1x {:optional true} :double] - [:c1y {:optional true} :double] - [:c2x {:optional true} :double] - [:c2y {:optional true} :double]]]]])) + [:vector {:title "PathContent"} + [:map {:title "PathContentEntry"} + [:command [::sm/one-of valid-commands]] + ;; FIXME: remove the `?` from prop name + [:relative? {:optional true} :boolean] + [:params {:optional true} + [:map {:title "PathContentEntryParams"} + [:x :double] + [:y :double] + [:c1x {:optional true} :double] + [:c1y {:optional true} :double] + [:c2x {:optional true} :double] + [:c2y {:optional true} :double]]]]]) (def check-path-content! (sm/check-fn schema:path-content)) diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index a91532b0a..36af8c593 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -160,7 +160,7 @@ selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - start-position (apply min #(gpt/distance start-position %) selected-points) + start-position (apply min-key #(gpt/distance start-position %) selected-points) content (st/get-path state :content) points (upg/content->points content)] diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index d6367aefd..e916aec62 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -15,24 +15,27 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn convert-selected-to-path [] - (ptk/reify ::convert-selected-to-path - ptk/WatchEvent - (watch [it state _] - (let [page-id (:current-page-id state) - objects (wsh/lookup-page-objects state) - selected (->> (wsh/lookup-selected state) - (remove #(ctn/has-any-copy-parent? objects (get objects %)))) +(defn convert-selected-to-path + ([] + (convert-selected-to-path nil)) + ([ids] + (ptk/reify ::convert-selected-to-path + ptk/WatchEvent + (watch [it state _] + (let [page-id (:current-page-id state) + objects (wsh/lookup-page-objects state) + selected (->> (or ids (wsh/lookup-selected state)) + (remove #(ctn/has-any-copy-parent? objects (get objects %)))) - children-ids - (into #{} - (mapcat #(cph/get-children-ids objects %)) - selected) + children-ids + (into #{} + (mapcat #(cph/get-children-ids objects %)) + selected) - changes - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects) - (pcb/update-shapes selected #(upsp/convert-to-path % objects)) - (pcb/remove-objects children-ids))] + changes + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/update-shapes selected #(upsp/convert-to-path % objects)) + (pcb/remove-objects children-ids))] - (rx/of (dch/commit-changes changes)))))) + (rx/of (dch/commit-changes changes))))))) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index fecb3f8e0..734d0488c 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -98,8 +98,8 @@ (add-shape shape {})) ([shape {:keys [no-select? no-update-layout?]}] - (dm/verify! - "expected a valid shape" + (dm/assert! + "expected valid shape" (cts/check-shape! shape)) (ptk/reify ::add-shape diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 87346de67..fd045067a 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -6,9 +6,11 @@ (ns app.main.data.workspace.shortcuts (:require + [app.common.data.macros :as dm] [app.main.data.events :as ev] [app.main.data.exports :as de] [app.main.data.modal :as modal] + [app.main.data.plugins :as dpl] [app.main.data.preview :as dp] [app.main.data.shortcuts :as ds] [app.main.data.users :as du] @@ -28,6 +30,7 @@ [app.main.store :as st] [app.main.ui.hooks.resize :as r] [app.util.dom :as dom] + [beicon.v2.core :as rx] [potok.v2.core :as ptk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -44,6 +47,17 @@ (when-not (deref refs/workspace-read-only?) (run! st/emit! events))) +(def esc-pressed + (ptk/reify ::esc-pressed + ptk/WatchEvent + (watch [_ state _] + (rx/of + :interrupt + (let [selection (dm/get-in state [:workspace-local :selected])] + (if (empty? selection) + (dpl/close-current-plugin) + (dw/deselect-all true))))))) + ;; Shortcuts format https://github.com/ccampbell/mousetrap (def base-shortcuts @@ -111,7 +125,7 @@ :escape {:tooltip (ds/esc) :command "escape" :subsections [:edit] - :fn #(st/emit! :interrupt (dw/deselect-all true))} + :fn #(st/emit! esc-pressed)} ;; MODIFY LAYERS @@ -397,7 +411,7 @@ :command (ds/c-mod "shift+e") :subsections [:basics :main-menu] :fn #(st/emit! - (de/show-workspace-export-dialog))} + (de/show-workspace-export-dialog {:origin "workspace:shortcuts"}))} :toggle-snap-ruler-guide {:tooltip (ds/meta-shift "G") :command (ds/c-mod "shift+g") diff --git a/frontend/src/app/main/data/workspace/state_helpers.cljs b/frontend/src/app/main/data/workspace/state_helpers.cljs index 7249a1f3f..6c55e9da8 100644 --- a/frontend/src/app/main/data/workspace/state_helpers.cljs +++ b/frontend/src/app/main/data/workspace/state_helpers.cljs @@ -20,15 +20,12 @@ ([state page-id] (get-in state [:workspace-data :pages-index page-id]))) -(defn lookup-data-objects - [data page-id] - (dm/get-in data [:pages-index page-id :objects])) - (defn lookup-page-objects ([state] (lookup-page-objects state (:current-page-id state))) ([state page-id] - (dm/get-in state [:workspace-data :pages-index page-id :objects]))) + (-> (lookup-page state page-id) + (get :objects)))) (defn lookup-viewer-objects ([state page-id] @@ -45,12 +42,6 @@ (lookup-page-objects state page-id) (lookup-library-objects state file-id page-id)))) -(defn lookup-page-options - ([state] - (lookup-page-options state (:current-page-id state))) - ([state page-id] - (dm/get-in state [:workspace-data :pages-index page-id :options]))) - (defn lookup-local-components ([state] (dm/get-in state [:workspace-data :components]))) diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index 169e2dd3e..6f04e7c66 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -73,7 +73,6 @@ (let [id (d/nilv id (uuid/next)) page-id (:current-page-id state) objects (wsh/lookup-page-objects state page-id) - frame-id (ctst/top-nested-frame objects position) selected (if ignore-selection? #{} (wsh/lookup-selected state)) base (cfh/get-base-shape objects selected) @@ -81,9 +80,16 @@ selected-frame? (and (= 1 (count selected)) (= :frame (dm/get-in objects [selected-id :type]))) + base-id (:parent-id base) + + frame-id (if (or selected-frame? (empty? selected) + (not= :frame (dm/get-in objects [base-id :type]))) + (ctst/top-nested-frame objects position) + base-id) + parent-id (if (or selected-frame? (empty? selected)) frame-id - (:parent-id base)) + base-id) [new-shape new-children] (csvg.shapes-builder/create-svg-shapes id svg-data position objects frame-id parent-id selected true) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 4d9785b67..898c22e3c 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -6,6 +6,7 @@ (ns app.main.data.workspace.texts (:require + ["penpot/vendor/text-editor-v2" :as editor.v2] [app.common.attrs :as attrs] [app.common.data :as d] [app.common.data.macros :as dm] @@ -24,14 +25,26 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.undo :as dwu] + [app.main.features :as features] [app.main.fonts :as fonts] [app.util.router :as rt] [app.util.text-editor :as ted] + [app.util.text.content.styles :as styles] [app.util.timers :as ts] [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) +;; -- V2 Editor Helpers + +(def ^function create-editor editor.v2/create) +(def ^function set-editor-root! editor.v2/setRoot) +(def ^function get-editor-root editor.v2/getRoot) +(def ^function dispose! editor.v2/dispose) + +(declare v2-update-text-shape-content) +(declare v2-update-text-editor-styles) + ;; -- Editor (defn update-editor @@ -186,22 +199,41 @@ [{:keys [attrs shape]}] (shape-current-values shape txt/is-root-node? attrs)) -(defn current-paragraph-values +(defn v2-current-text-values + [{:keys [editor-instance attrs]}] + (let [result (-> (.-currentStyle editor-instance) + (styles/get-styles-from-style-declaration) + (select-keys attrs)) + result (if (empty? result) txt/default-text-attrs result)] + result)) + +(defn v1-current-paragraph-values [{:keys [editor-state attrs shape]}] (if editor-state (-> (ted/get-editor-current-block-data editor-state) (select-keys attrs)) (shape-current-values shape txt/is-paragraph-node? attrs))) -(defn current-text-values - [{:keys [editor-state attrs shape]}] - (if editor-state - (let [result (-> (ted/get-editor-current-inline-styles editor-state) - (select-keys attrs)) - result (if (empty? result) txt/default-text-attrs result)] - result) - (shape-current-values shape txt/is-text-node? attrs))) +(defn current-paragraph-values + [{:keys [editor-state editor-instance attrs shape] :as options}] + (cond + (some? editor-instance) (v2-current-text-values options) + (some? editor-state) (v1-current-paragraph-values options) + :else (shape-current-values shape txt/is-paragraph-node? attrs))) +(defn v1-current-text-values + [{:keys [editor-state attrs]}] + (let [result (-> (ted/get-editor-current-inline-styles editor-state) + (select-keys attrs)) + result (if (empty? result) txt/default-text-attrs result)] + result)) + +(defn current-text-values + [{:keys [editor-state editor-instance attrs shape] :as options}] + (cond + (some? editor-instance) (v2-current-text-values options) + (some? editor-state) (v1-current-text-values options) + :else (shape-current-values shape txt/is-text-node? attrs))) ;; --- TEXT EDITION IMPL @@ -408,7 +440,9 @@ ptk/WatchEvent (watch [_ state _] - (when (nil? (get-in state [:workspace-editor-state id])) + (when (or + (and (features/active-feature? state "text-editor/v2") (nil? (:workspace-editor state))) + (and (not (features/active-feature? state "text-editor/v2")) (nil? (get-in state [:workspace-editor-state id])))) (let [objects (wsh/lookup-page-objects state) shape (get objects id) @@ -430,8 +464,17 @@ (-> shape (dissoc :fills) (d/update-when :content update-content)))] + (rx/of (dwsh/update-shapes shape-ids update-shape))))) - (rx/of (dwsh/update-shapes shape-ids update-shape))))))) + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor/v2") + (let [instance (:workspace-editor state) + styles (some-> (editor.v2/getCurrentStyle instance) + (styles/get-styles-from-style-declaration) + ((comp update-node-fn migrate-node)) + (styles/attrs->styles))] + (editor.v2/applyStylesToSelection instance styles)))))) ;; --- RESIZE UTILS @@ -664,22 +707,36 @@ [id attrs] (ptk/reify ::update-attrs ptk/WatchEvent - (watch [_ _ _] - (rx/concat - (let [attrs (select-keys attrs txt/root-attrs)] - (if-not (empty? attrs) - (rx/of (update-root-attrs {:id id :attrs attrs})) - (rx/empty))) + (watch [_ state _] + (let [text-editor-instance (:workspace-editor state)] + (if (and (features/active-feature? state "text-editor/v2") + (some? text-editor-instance)) + (rx/empty) + (rx/concat + (let [attrs (select-keys attrs txt/root-attrs)] + (if-not (empty? attrs) + (rx/of (update-root-attrs {:id id :attrs attrs})) + (rx/empty))) - (let [attrs (select-keys attrs txt/paragraph-attrs)] - (if-not (empty? attrs) - (rx/of (update-paragraph-attrs {:id id :attrs attrs})) - (rx/empty))) + (let [attrs (select-keys attrs txt/paragraph-attrs)] + (if-not (empty? attrs) + (rx/of (update-paragraph-attrs {:id id :attrs attrs})) + (rx/empty))) - (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) - (rx/of (update-text-attrs {:id id :attrs attrs})) - (rx/empty))))))) + (let [attrs (select-keys attrs txt/text-node-attrs)] + (if-not (empty? attrs) + (rx/of (update-text-attrs {:id id :attrs attrs})) + (rx/empty))) + + (when (features/active-feature? state "text-editor/v2") + (rx/of (v2-update-text-editor-styles id attrs))))))) + + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor/v2") + (let [instance (:workspace-editor state) + styles (styles/attrs->styles attrs)] + (editor.v2/applyStylesToSelection instance styles)))))) (defn update-all-attrs [ids attrs] @@ -773,3 +830,52 @@ (rx/of (update-attrs (:id shape) {:typography-ref-id typ-id :typography-ref-file file-id})))))))) + +;; -- New Editor + +(defn v2-update-text-editor-styles + [id new-styles] + (ptk/reify ::v2-update-text-editor-styles + ptk/UpdateEvent + (update [_ state] + (let [merged-styles (d/merge txt/default-text-attrs + (get-in state [:workspace-global :default-font]) + new-styles)] + (update-in state [:workspace-v2-editor-state id] (fnil merge {}) merged-styles))))) + +(defn v2-update-text-shape-position-data + [shape-id position-data] + (ptk/reify ::v2-update-text-shape-position-data + ptk/UpdateEvent + (update [_ state] + (let [] + (update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))) + +(defn v2-update-text-shape-content + ([id content] + (v2-update-text-shape-content id content false nil)) + ([id content update-name?] + (v2-update-text-shape-content id content update-name? nil)) + ([id content update-name? name] + (ptk/reify ::v2-update-text-shape-content + ptk/WatchEvent + (watch [_ state _] + (let [objects (wsh/lookup-page-objects state) + shape (get objects id) + modifiers (get-in state [:workspace-text-modifier id]) + new-shape? (nil? (:content shape))] + (rx/of + (dwsh/update-shapes + [id] + (fn [shape] + (let [{:keys [width height position-data]} modifiers] + (let [new-shape (-> shape + (assoc :content content) + (cond-> position-data + (assoc :position-data position-data)) + (cond-> (and update-name? (some? name)) + (assoc :name name)) + (cond-> (or (some? width) (some? height)) + (gsh/transform-shape (ctm/change-size shape width height))))] + new-shape))) + {:undo-group (when new-shape? id)}))))))) diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 625c207c6..a043c3811 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -182,6 +182,11 @@ [page-id [event [old-data new-data]]] (let [changes (:changes event) + lookup-data-objects + (fn [data page-id] + (dm/get-in data [:pages-index page-id :objects])) + + extract-ids (fn [{:keys [page-id type] :as change}] (case type @@ -193,8 +198,8 @@ get-frame-ids (fn get-frame-ids [id] - (let [old-objects (wsh/lookup-data-objects old-data page-id) - new-objects (wsh/lookup-data-objects new-data page-id) + (let [old-objects (lookup-data-objects old-data page-id) + new-objects (lookup-data-objects new-data page-id) new-shape (get new-objects id) old-shape (get old-objects id) diff --git a/frontend/src/app/main/data/workspace/undo.cljs b/frontend/src/app/main/data/workspace/undo.cljs index 41f3fe1a1..529965fbb 100644 --- a/frontend/src/app/main/data/workspace/undo.cljs +++ b/frontend/src/app/main/data/workspace/undo.cljs @@ -26,10 +26,9 @@ (def ^:private schema:undo-entry - (sm/define - [:map {:title "undo-entry"} - [:undo-changes [:vector ::cpc/change]] - [:redo-changes [:vector ::cpc/change]]])) + [:map {:title "undo-entry"} + [:undo-changes [:vector ::cpc/change]] + [:redo-changes [:vector ::cpc/change]]]) (def check-undo-entry! (sm/check-fn schema:undo-entry)) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 542b41bce..61ff775a3 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -9,15 +9,14 @@ (:require [app.common.exceptions :as ex] [app.common.pprint :as pp] - [app.common.schema :as-alias sm] - [app.main.data.messages :as msg] + [app.common.schema :as sm] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.store :as st] [app.util.globals :as glob] [app.util.i18n :refer [tr]] [app.util.router :as rt] - [app.util.storage :refer [storage]] [app.util.timers :as ts] [cuerdas.core :as str] [potok.v2.core :as ptk])) @@ -33,8 +32,11 @@ (defn- print-explain! [data] - (when-let [explain (or (ex/explain data) - (:explain data))] + (when-let [{:keys [errors] :as explain} (::sm/explain data)] + (let [errors (mapv #(update % :schema sm/form) errors)] + (pp/pprint errors {:width 100 :level 15 :length 20}))) + + (when-let [explain (:explain data)] (js/console.log explain))) (defn- print-trace! @@ -57,6 +59,14 @@ (print-explain! cause) (print-trace! cause)))) +(defn exception->error-data + [cause] + (let [data (ex-data cause)] + (-> data + (assoc :hint (or (:hint data) (ex-message cause))) + (assoc ::instance cause) + (assoc ::trace (.-stack cause))))) + (defn print-error! [cause] (cond @@ -67,22 +77,14 @@ (print-cause! (ex-message cause) (ex-data cause)) :else - (let [trace (.-stack cause)] - (print-cause! (ex-message cause) - {:hint (ex-message cause) - ::trace trace - ::instance cause})))) + (print-cause! (ex-message cause) (exception->error-data cause)))) (defn on-error "A general purpose error handler." [error] (if (map? error) (ptk/handle-error error) - (let [data (ex-data error) - data (-> data - (assoc :hint (or (:hint data) (ex-message error))) - (assoc ::instance error) - (assoc ::trace (.-stack error)))] + (let [data (exception->error-data error)] (ptk/handle-error data)))) ;; Set the main potok error handler @@ -96,16 +98,23 @@ (print-trace! error) (print-data! error)))) -;; We receive a explicit authentication error; this explicitly clears +;; We receive a explicit authentication error; +;; If the uri is for workspace, dashboard or view assign the +;; exception for the 'Oops' page. Otherwise this explicitly clears ;; all profile data and redirect the user to the login page. This is ;; here and not in app.main.errors because of circular dependency. (defmethod ptk/handle-error :authentication - [_] - (let [msg (tr "errors.auth.unable-to-login") - uri (. (. js/document -location) -href)] - (st/emit! (du/logout {:capture-redirect true})) - (ts/schedule 500 #(st/emit! (msg/warn msg))) - (ts/schedule 1000 #(swap! storage assoc :redirect-url uri)))) + [e] + (let [msg (tr "errors.auth.unable-to-login") + uri (.-href glob/location) + show-oops? (or (str/includes? uri "workspace") + (str/includes? uri "dashboard") + (str/includes? uri "view"))] + (if show-oops? + (st/async-emit! (rt/assign-exception e)) + (do + (st/emit! (du/logout {:capture-redirect true})) + (ts/schedule 500 #(st/emit! (ntf/warn msg))))))) ;; Error that happens on an active business model validation does not ;; passes an validation (example: profile can't leave a team). From @@ -123,9 +132,9 @@ (= code :invalid-paste-data) (let [message (tr "errors.paste-data-validation")] (st/async-emit! - (msg/show {:content message - :notification-type :toast - :type :error + (ntf/show {:content message + :type :toast + :level :error :timeout 3000}))) :else @@ -138,9 +147,9 @@ (defmethod ptk/handle-error :assertion [error] (ts/schedule - #(st/emit! (msg/show {:content "Internal Assertion Error" - :notification-type :toast - :type :error + #(st/emit! (ntf/show {:content "Internal Assertion Error" + :type :toast + :level :error :timeout 3000}))) (print-group! "Internal Assertion Error" @@ -154,9 +163,9 @@ [error] (ts/schedule #(st/emit! - (msg/show {:content "Something wrong has happened (on worker)." - :notification-type :toast - :type :error + (ntf/show {:content "Something wrong has happened (on worker)." + :type :toast + :level :error :timeout 3000}))) (print-group! "Internal Worker Error" @@ -168,18 +177,18 @@ (defmethod ptk/handle-error :svg-parser [_] (ts/schedule - #(st/emit! (msg/show {:content "SVG is invalid or malformed" - :notification-type :toast - :type :error + #(st/emit! (ntf/show {:content "SVG is invalid or malformed" + :type :toast + :level :error :timeout 3000})))) ;; TODO: should be handled in the event and not as general error handler (defmethod ptk/handle-error :comment-error [_] (ts/schedule - #(st/emit! (msg/show {:content "There was an error with the comment" - :notification-type :toast - :type :error + #(st/emit! (ntf/show {:content "There was an error with the comment" + :type :toast + :level :error :timeout 3000})))) ;; That are special case server-errors that should be treated @@ -279,6 +288,7 @@ (let [message (ex-message cause)] (or (= message "Possible side-effect in debug-evaluate") (= message "Unexpected end of input") + (str/starts-with? message "invalid props on component") (str/starts-with? message "Unexpected token ")))) (on-unhandled-error [event] diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 5b0e9bbce..477a58a75 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -109,7 +109,8 @@ (watch [_ _ _] (when *assert* (->> (rx/from cfeat/no-migration-features) - (rx/filter #(not (or (contains? cfeat/backend-only-features %) (= "design-tokens/v1" %)))) + ;; text editor v2 isn't enabled by default even in devenv + (rx/filter #(not (or (contains? cfeat/backend-only-features %) (= "text-editor/v2" %)))) (rx/observe-on :async) (rx/map enable-feature)))) diff --git a/frontend/src/app/main/features/pointer_map.cljs b/frontend/src/app/main/features/pointer_map.cljs index 993427e55..7055c1188 100644 --- a/frontend/src/app/main/features/pointer_map.cljs +++ b/frontend/src/app/main/features/pointer_map.cljs @@ -16,7 +16,7 @@ (letfn [(resolve-pointer [[key val :as kv]] (if (t/pointer? val) (->> (rp/cmd! :get-file-fragment {:file-id id :fragment-id @val}) - (rx/map #(get % :content)) + (rx/map #(get % :data)) (rx/map #(vector key %))) (rx/of kv))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index d563da84e..5b06f449b 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -242,8 +242,8 @@ (defn ready [cb] - (-> (obj/get-in js/document ["fonts" "ready"]) - (p/then cb))) + (let [fonts (obj/get js/document "fonts")] + (p/then (obj/get fonts "ready") cb))) (defn get-default-variant [{:keys [variants]}] diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 8bf9a7c75..e44e920e4 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -26,9 +26,6 @@ (def router (l/derived :router st/state)) -(def message - (l/derived :message st/state)) - (def profile (l/derived :profile st/state)) @@ -189,6 +186,9 @@ (def options-mode-global (l/derived :options-mode workspace-global)) +(def default-font + (l/derived :default-font workspace-global)) + (def inspect-expanded (l/derived :inspect-expanded workspace-local)) @@ -244,9 +244,10 @@ =)) (def workspace-recent-colors - (l/derived (fn [data] - (get data :recent-colors [])) - workspace-data)) + (l/derived (fn [state] + (when-let [file-id (:current-file-id state)] + (dm/get-in state [:recent-colors file-id]))) + st/state)) (def workspace-recent-fonts (l/derived (fn [data] @@ -288,6 +289,9 @@ (dm/get-in data [:pages-index page-id]))) st/state)) +(def workspace-page-flows + (l/derived #(-> % :flows not-empty) workspace-page)) + (defn workspace-page-objects-by-id [page-id] (l/derived #(wsh/lookup-page-objects % page-id) st/state =)) @@ -350,9 +354,6 @@ (into [] (keep (d/getf objects)) children-ids))) workspace-page-objects =)) -(def workspace-page-options - (l/derived :options workspace-page)) - (def workspace-frames (l/derived ctt/get-frames workspace-page-objects =)) @@ -362,6 +363,9 @@ (def workspace-editor-state (l/derived :workspace-editor-state st/state)) +(def workspace-v2-editor-state + (l/derived :workspace-v2-editor-state st/state)) + (def workspace-modifiers (l/derived :workspace-modifiers st/state =)) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index a371a67d3..d58ce0fe9 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -211,7 +211,7 @@ shapes (cfh/get-immediate-children objects) dim (calculate-dimensions objects aspect-ratio) vbox (format-viewbox dim) - bgcolor (dm/get-in data [:options :background] default-color) + bgcolor (get data :background default-color) shape-wrapper (mf/use-memo @@ -232,7 +232,7 @@ :fill "none"} (when include-metadata - [:& export/export-page {:id (:id data) :options (:options data)}]) + [:& export/export-page {:page data}]) (let [shapes (->> shapes (remove cfh/frame-shape?) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index b19edf933..77d4de012 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -17,7 +17,7 @@ [cuerdas.core :as str])) (defn handle-response - [{:keys [status body] :as response}] + [{:keys [status body headers] :as response}] (cond (= 204 status) ;; We need to send "something" so the streams listening downstream can act @@ -40,6 +40,13 @@ {:type :validation :code :request-body-too-large})) + (and (= status 403) + (or (= "cloudflare" (get headers "server")) + (= "challenge" (get headers "cf-mitigated")))) + (rx/throw (ex-info "http error" + {:type :authorization + :code :challenge-required})) + (and (>= status 400) (map? body)) (rx/throw (ex-info "http error" body)) @@ -48,6 +55,7 @@ (ex-info "http error" {:type :unexpected-error :status status + :headers headers :data body})))) (def default-options diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index b9c661c3a..0bc7aa70a 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -9,13 +9,12 @@ [app.main.ui.workspace.tokens.modals.themes :as wtmt] [app.config :as cf] [app.main.refs :as refs] - [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.debug.components-preview :as cm] [app.main.ui.debug.icons-preview :refer [icons-preview]] + [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.frame-preview :as frame-preview] [app.main.ui.icons :as i] - [app.main.ui.messages :as msgs] + [app.main.ui.notifications :as notifications] [app.main.ui.onboarding.newsletter :refer [onboarding-newsletter]] [app.main.ui.onboarding.questions :refer [questions-modal]] [app.main.ui.onboarding.team-choice :refer [onboarding-team-modal]] @@ -23,7 +22,6 @@ [app.main.ui.static :as static] [app.util.dom :as dom] [app.util.i18n :refer [tr]] - [app.util.router :as rt] [rumext.v2 :as mf])) (def auth-page @@ -44,17 +42,35 @@ (def workspace-page (mf/lazy-component app.main.ui.workspace/workspace)) -(mf/defc on-main-error - [{:keys [error] :as props}] - (mf/with-effect - (st/emit! (rt/assign-exception error))) - [:span "Internal application error"]) - (mf/defc main-page - {::mf/wrap [#(mf/catch % {:fallback on-main-error})] - ::mf/props :obj} + {::mf/props :obj + ::mf/private true} [{:keys [route profile]}] - (let [{:keys [data params]} route] + (let [{:keys [data params]} route + props (get profile :props) + show-question-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :onboarding-questions))) + + show-newsletter-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :newsletter-updates)) + (contains? props :onboarding-questions)) + + show-team-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :onboarding-team-id)) + (contains? props :newsletter-updates)) + + show-release-modal? + (and (contains? cf/flags :onboarding) + (:onboarding-viewed props) + (not= (:release-notes-viewed props) (:main cf/version)) + (not= "0.0" (:main cf/version)))] + [:& (mf/provider ctx/current-route) {:value route} (case (:name data) (:auth-login @@ -90,56 +106,33 @@ :dashboard-team-webhooks :dashboard-team-settings) [:? - #_[:& app.main.ui.releases/release-notes-modal {:version "1.19"}] + #_[:& app.main.ui.releases/release-notes-modal {:version "2.3"}] #_[:& app.main.ui.onboarding/onboarding-templates-modal] #_[:& app.main.ui.onboarding/onboarding-modal] #_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal] - (when-let [props (get profile :props)] - (let [show-question-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :onboarding-questions))) - show-newsletter-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :newsletter-updates)) - (contains? props :onboarding-questions)) + (cond + show-question-modal? + [:& questions-modal] - show-team-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :onboarding-team-id)) - (contains? props :newsletter-updates)) + show-newsletter-modal? + [:& onboarding-newsletter] - show-release-modal? - (and (contains? cf/flags :onboarding) - (:onboarding-viewed props) - (not= (:release-notes-viewed props) (:main cf/version)) - (not= "0.0" (:main cf/version)))] + show-team-modal? + [:& onboarding-team-modal {:go-to-team? true}] - (cond - show-question-modal? - [:& questions-modal] - - show-newsletter-modal? - [:& onboarding-newsletter] - - show-team-modal? - [:& onboarding-team-modal] - - show-release-modal? - [:& release-notes-modal {:version (:main cf/version)}]))) + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}]) [:& dashboard-page {:route route :profile profile}]] :viewer (let [{:keys [query-params path-params]} route - {:keys [index share-id section page-id interactions-mode frame-id] + {:keys [index share-id section page-id interactions-mode frame-id share] :or {section :interactions interactions-mode :show-on-click}} query-params {:keys [file-id]} path-params] [:? {} (if (:token query-params) - [:> static/error-container {} + [:> static/error-container* {} [:div.image i/detach] [:div.main-message (tr "viewer.breaking-change.message")] [:div.desc-message (tr "viewer.breaking-change.description")]] @@ -155,7 +148,8 @@ :hide false :show true :show-on-click false) - :frame-id frame-id}])]) + :frame-id frame-id + :share share}])]) :workspace (let [project-id (some-> params :path :project-id uuid) @@ -163,18 +157,26 @@ page-id (some-> params :query :page-id uuid) layout (some-> params :query :layout keyword)] [:? {} + (when (cf/external-feature-flag "onboarding-03" "test") + (cond + show-question-modal? + [:& questions-modal] + + show-newsletter-modal? + [:& onboarding-newsletter] + + show-team-modal? + [:& onboarding-team-modal {:go-to-team? false}] + + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}])) + [:& workspace-page {:project-id project-id :file-id file-id :page-id page-id :layout-name layout :key file-id}]]) - - :debug-components-preview - [:div.debug-preview - [:h1 "Components preview"] - [:& cm/components-preview]] - :frame-preview [:& frame-preview/frame-preview] @@ -193,8 +195,8 @@ [:& (mf/provider ctx/current-route) {:value route} [:& (mf/provider ctx/current-profile) {:value profile} (if edata - [:& static/exception-page {:data edata :route route}] - [:* - [:& msgs/notifications-hub] + [:> static/exception-page* {:data edata :route route}] + [:> error-boundary* {:fallback static/internal-error*} + [:& notifications/current-notification] (when route [:& main-page {:route route :profile profile}])])]])) diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index c22ec0902..1b5fb62b4 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -8,37 +8,17 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.config :as cf] [app.main.data.users :as du] [app.main.store :as st] [app.main.ui.auth.login :refer [login-page]] [app.main.ui.auth.recovery :refer [recovery-page]] [app.main.ui.auth.recovery-request :refer [recovery-request-page]] - [app.main.ui.auth.register :refer [register-page register-success-page register-validate-page]] + [app.main.ui.auth.register :refer [register-page register-success-page register-validate-page terms-register]] [app.main.ui.icons :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(mf/defc terms-login - [] - (let [show-all? (and cf/terms-of-service-uri cf/privacy-policy-uri) - show-terms? (some? cf/terms-of-service-uri) - show-privacy? (some? cf/privacy-policy-uri)] - - (when show-all? - [:div {:class (stl/css :terms-login)} - (when show-terms? - [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :auth-link)} - (tr "auth.terms-of-service")]) - - (when show-all? - [:span {:class (stl/css :and-text)} - (dm/str " " (tr "labels.and") " ")]) - - (when show-privacy? - [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :auth-link)} - (tr "auth.privacy-policy")])]))) (mf/defc auth {::mf/props :obj} @@ -86,4 +66,4 @@ [:& recovery-page {:params params}]) (when (= section :auth-register) - [:& terms-login])]])) + [:& terms-register])]])) diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index 569fa7b9c..4b3caeefc 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -72,23 +72,3 @@ fill: var(--main-icon-foreground); } } - -.terms-login { - @include bodySmallTypography; - display: flex; - gap: $s-4; - justify-content: center; - width: 100%; -} - -.and-text { - border-bottom: $s-1 solid transparent; - color: var(--title-foreground-color); -} - -.auth-link { - color: var(--link-foreground-color); - &:hover { - text-decoration: underline; - } -} diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 27add1c2e..901f0dd58 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -10,7 +10,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.config :as cf] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.repo :as rp] [app.main.store :as st] @@ -23,6 +23,7 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as k] [app.util.router :as rt] + [app.util.storage :as s] [beicon.v2.core :as rx] [rumext.v2 :as mf])) @@ -37,30 +38,46 @@ {::mf/props :obj} [] [:& context-notification - {:type :warning + {:level :warning :content (tr "auth.demo-warning")}]) (defn create-demo-profile [] (st/emit! (du/create-demo-profile))) +(defn- store-login-redirect + [save-login-redirect] + (binding [s/*sync* true] + (if (some? save-login-redirect) + ;; Save the current login raw uri for later redirect user back to + ;; the same page, we need it to be synchronous because the user is + ;; going to be redirected instantly to the oidc provider uri + (swap! s/session assoc :login-redirect (rt/get-current-href)) + ;; Clean the login redirect + (swap! s/session dissoc :login-redirect)))) + (defn- login-with-oidc [event provider params] (dom/prevent-default event) + + (store-login-redirect (:save-login-redirect params)) + + ;; FIXME: this code should be probably moved outside of the UI (->> (rp/cmd! :login-with-oidc (assoc params :provider provider)) (rx/subs! (fn [{:keys [redirect-uri] :as rsp}] (if redirect-uri - (.replace js/location redirect-uri) + (st/emit! (rt/nav-raw :uri redirect-uri)) (log/error :hint "unexpected response from OIDC method" :resp (pr-str rsp)))) - (fn [{:keys [type code] :as error}] - (cond - (and (= type :restriction) - (= code :provider-not-configured)) - (st/emit! (msg/error (tr "errors.auth-provider-not-configured"))) + (fn [cause] + (let [{:keys [type code] :as error} (ex-data cause)] + (cond + (and (= type :restriction) + (= code :provider-not-configured)) + (st/emit! (ntf/error (tr "errors.auth-provider-not-configured"))) - :else - (st/emit! (msg/error (tr "errors.generic")))))))) + :else + (st/emit! (ntf/error (tr "errors.generic"))))))))) (def ^:private schema:login-form [:map {:title "LoginForm"} @@ -70,7 +87,7 @@ [:string {:min 1}]]]) (mf/defc login-form - [{:keys [params on-success-callback origin] :as props}] + [{:keys [params on-success-callback on-recovery-request origin] :as props}] (let [initial (mf/with-memo [params] params) error (mf/use-state false) form (fm/use-form :schema schema:login-form @@ -86,7 +103,7 @@ (and (= :restriction (:type cause)) (= :ldap-not-initialized (:code cause))) - (st/emit! (msg/error (tr "errors.ldap-disabled"))) + (st/emit! (ntf/error (tr "errors.ldap-disabled"))) (and (= :restriction (:type cause)) (= :admin-only-profile (:code cause))) @@ -118,6 +135,7 @@ on-submit (mf/use-callback (fn [form _event] + (store-login-redirect (:save-login-redirect params)) (reset! error nil) (let [params (with-meta (:clean-data @form) {:on-error on-error @@ -138,16 +156,18 @@ :on-success on-success})] (st/emit! (du/login-with-ldap params))))) - on-recovery-request + default-recovery-req (mf/use-fn - #(st/emit! (rt/nav :auth-recovery-request)))] + #(st/emit! (rt/nav :auth-recovery-request))) + + on-recovery-request (or on-recovery-request + default-recovery-req)] [:* (when-let [message @error] [:& context-notification - {:type :error + {:level :error :content message - :data-testid "login-banner" :role "alert"}]) [:& fm/form {:on-submit on-submit @@ -243,7 +263,7 @@ (tr "auth.login-with-oidc-submit")]))) (mf/defc login-methods - [{:keys [params on-success-callback origin] :as props}] + [{:keys [params on-success-callback on-recovery-request origin] :as props}] [:* (when show-alt-login-buttons? [:* @@ -257,7 +277,7 @@ (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password) (contains? cf/flags :login-with-ldap)) - [:& login-form {:params params :on-success-callback on-success-callback :origin origin}])]) + [:& login-form {:params params :on-success-callback on-success-callback :on-recovery-request on-recovery-request :origin origin}])]) (mf/defc login-page [{:keys [params] :as props}] diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index 6ec730c5b..cc567d310 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -8,7 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.schema :as sm] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] @@ -29,11 +29,11 @@ (defn- on-error [_form _error] - (st/emit! (msg/error (tr "errors.invalid-recovery-token")))) + (st/emit! (ntf/error (tr "errors.invalid-recovery-token")))) (defn- on-success [_] - (st/emit! (msg/info (tr "auth.notifications.password-changed-successfully")) + (st/emit! (ntf/info (tr "auth.notifications.password-changed-successfully")) (rt/nav :auth-login))) (defn- on-submit diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index c409a318c..afb240647 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -8,7 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.schema :as sm] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] @@ -30,7 +30,7 @@ default-success-finish (mf/use-fn - #(st/emit! (msg/info (tr "auth.notifications.recovery-token-sent")))) + #(st/emit! (ntf/info (tr "auth.notifications.recovery-token-sent")))) on-success (mf/use-fn @@ -47,14 +47,14 @@ (let [code (-> cause ex-data :code)] (case code :profile-not-verified - (rx/of (msg/error (tr "auth.notifications.profile-not-verified"))) + (rx/of (ntf/error (tr "auth.notifications.profile-not-verified"))) :profile-is-muted - (rx/of (msg/error (tr "errors.profile-is-muted"))) + (rx/of (ntf/error (tr "errors.profile-is-muted"))) (:email-has-permanent-bounces :email-has-complaints) - (rx/of (msg/error (tr "errors.email-has-permanent-bounces" (:email data)))) + (rx/of (ntf/error (tr "errors.email-has-permanent-bounces" (:email data)))) (rx/throw cause))))) @@ -102,3 +102,16 @@ :class (stl/css :go-back-link) :data-testid "go-back-link"} (tr "labels.go-back")]]])) + + +(mf/defc recovery-sent-page + {::mf/props :obj} + [{:keys [email]}] + [:div {:class (stl/css :auth-form-wrapper :register-success)} + [:div {:class (stl/css :auth-title-wrapper)} + [:h2 {:class (stl/css :auth-title)} + (tr "auth.check-mail")] + [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery")]] + [:div {:class (stl/css :notification-text-email)} email] + [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery-check")]]) + diff --git a/frontend/src/app/main/ui/auth/recovery_request.scss b/frontend/src/app/main/ui/auth/recovery_request.scss index e78e21b6d..8b384e59d 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.scss +++ b/frontend/src/app/main/ui/auth/recovery_request.scss @@ -10,3 +10,10 @@ .fields-row { margin-bottom: $s-8; } + +.notification-text-email { + @include medTitleTipography; + font-size: $fs-20; + color: var(--register-confirmation-color); + margin-inline: $s-36; +} diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index e85e3def9..98cee17f1 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -7,9 +7,10 @@ (ns app.main.ui.auth.register (:require-macros [app.main.style :as stl]) (:require + [app.common.data.macros :as dm] [app.common.schema :as sm] [app.config :as cf] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.repo :as rp] [app.main.store :as st] @@ -19,7 +20,7 @@ [app.main.ui.icons :as i] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] - [app.util.storage :as sto] + [app.util.storage :as storage] [beicon.v2.core :as rx] [rumext.v2 :as mf])) @@ -38,7 +39,8 @@ form (fm/use-form :schema schema:register-form :initial initial) - submitted? (mf/use-state false) + submitted? + (mf/use-state false) on-error (mf/use-fn @@ -46,22 +48,22 @@ (let [{:keys [type code] :as edata} (ex-data cause)] (condp = [type code] [:restriction :registration-disabled] - (st/emit! (msg/error (tr "errors.registration-disabled"))) + (st/emit! (ntf/error (tr "errors.registration-disabled"))) [:restriction :email-domain-is-not-allowed] - (st/emit! (msg/error (tr "errors.email-domain-not-allowed"))) + (st/emit! (ntf/error (tr "errors.email-domain-not-allowed"))) [:restriction :email-has-permanent-bounces] - (st/emit! (msg/error (tr "errors.email-has-permanent-bounces" (:email edata)))) + (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata)))) [:restriction :email-has-complaints] - (st/emit! (msg/error (tr "errors.email-has-permanent-bounces" (:email edata)))) + (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata)))) [:validation :email-as-password] (swap! form assoc-in [:errors :password] {:code "errors.email-as-password"}) - (st/emit! (msg/error (tr "errors.generic"))))))) + (st/emit! (ntf/error (tr "errors.generic"))))))) on-submit (mf/use-fn @@ -103,12 +105,14 @@ (mf/defc register-methods {::mf/props :obj} - [{:keys [params on-success-callback]}] + [{:keys [params hide-separator on-success-callback]}] [:* (when login/show-alt-login-buttons? [:& login/login-buttons {:params params}]) - [:hr {:class (stl/css :separator)}] - [:& register-form {:params params :on-success-callback on-success-callback}]]) + (when (or login/show-alt-login-buttons? (false? hide-separator)) + [:hr {:class (stl/css :separator)}]) + (when (contains? cf/flags :login-with-password) + [:& register-form {:params params :on-success-callback on-success-callback}])]) (mf/defc register-page {::mf/props :obj} @@ -173,7 +177,9 @@ ::mf/private true} [{:keys [params on-success-callback]}] (let [form (fm/use-form :schema schema:register-validate-form :initial params) - submitted? (mf/use-state false) + + submitted? + (mf/use-state false) on-success (mf/use-fn @@ -192,20 +198,26 @@ :else (do - (swap! sto/storage assoc ::email (:email params)) + (swap! storage/user assoc ::email (:email params)) (st/emit! (rt/nav :auth-register-success))))))) on-error (mf/use-fn (fn [_] - (st/emit! (msg/error (tr "errors.generic"))))) + (st/emit! (ntf/error (tr "errors.generic"))))) on-submit (mf/use-fn (mf/deps on-success on-error) (fn [form _] (reset! submitted? true) - (let [params (:clean-data @form)] + (let [create-welcome-file? + (cf/external-feature-flag "onboarding-03" "test") + + params + (cond-> (:clean-data @form) + create-welcome-file? (assoc :create-welcome-file true))] + (->> (rp/cmd! :register-profile params) (rx/finalize #(reset! submitted? false)) (rx/subs! on-success on-error)))))] @@ -251,14 +263,37 @@ (mf/defc register-success-page {::mf/props :obj} - [] - (let [email (::email @sto/storage)] + [{:keys [params]}] + (let [email (or (:email params) (::email storage/user))] [:div {:class (stl/css :auth-form-wrapper :register-success)} - [:h1 {:class (stl/css :logo-container)} - [:a {:href "#/" :title "Penpot" :class (stl/css :logo-btn)} i/logo]] + (when-not (:hide-logo params) + [:h1 {:class (stl/css :logo-container)} + [:a {:href "#/" :title "Penpot" :class (stl/css :logo-btn)} i/logo]]) [:div {:class (stl/css :auth-title-wrapper)} [:h2 {:class (stl/css :auth-title)} (tr "auth.check-mail")] [:div {:class (stl/css :notification-text)} (tr "auth.verification-email-sent")]] [:div {:class (stl/css :notification-text-email)} email] [:div {:class (stl/css :notification-text)} (tr "auth.check-your-email")]])) + + +(mf/defc terms-register + [] + (let [show-all? (and cf/terms-of-service-uri cf/privacy-policy-uri) + show-terms? (some? cf/terms-of-service-uri) + show-privacy? (some? cf/privacy-policy-uri)] + + (when show-all? + [:div {:class (stl/css :terms-register)} + (when show-terms? + [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :auth-link)} + (tr "auth.terms-of-service")]) + + (when show-all? + [:span {:class (stl/css :and-text)} + (dm/str " " (tr "labels.and") " ")]) + + (when show-privacy? + [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :auth-link)} + (tr "auth.privacy-policy")])]))) + diff --git a/frontend/src/app/main/ui/auth/register.scss b/frontend/src/app/main/ui/auth/register.scss index 0f0497442..0309cd44a 100644 --- a/frontend/src/app/main/ui/auth/register.scss +++ b/frontend/src/app/main/ui/auth/register.scss @@ -66,3 +66,23 @@ width: $s-120; margin-block-end: $s-24; } + +.terms-register { + @include bodySmallTypography; + display: flex; + gap: $s-4; + justify-content: center; + width: 100%; +} + +.and-text { + border-bottom: $s-1 solid transparent; + color: var(--title-foreground-color); +} + +.auth-link { + color: var(--link-foreground-color); + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 81d92ede5..9e8bdbbd5 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,7 +6,7 @@ (ns app.main.ui.auth.verify-token (:require - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.repo :as rp] [app.main.store :as st] @@ -24,13 +24,13 @@ (defmethod handle-token :verify-email [data] (let [msg (tr "dashboard.notifications.email-verified-successfully")] - (ts/schedule 1000 #(st/emit! (msg/success msg))) + (ts/schedule 1000 #(st/emit! (ntf/success msg))) (st/emit! (du/login-from-token data)))) (defmethod handle-token :change-email [_data] (let [msg (tr "dashboard.notifications.email-changed-successfully")] - (ts/schedule 100 #(st/emit! (msg/success msg))) + (ts/schedule 100 #(st/emit! (ntf/success msg))) (st/emit! (rt/nav :settings-profile) (du/fetch-profile)))) @@ -43,7 +43,7 @@ (case (:state tdata) :created (st/emit! - (msg/success (tr "auth.notifications.team-invitation-accepted")) + (ntf/success (tr "auth.notifications.team-invitation-accepted")) (du/fetch-profile) (rt/nav :dashboard-projects {:team-id (:team-id tdata)})) @@ -56,7 +56,7 @@ [_tdata] (st/emit! (rt/nav :auth-login) - (msg/warn (tr "errors.unexpected-token")))) + (ntf/warn (tr "errors.unexpected-token")))) (mf/defc verify-token [{:keys [route] :as props}] @@ -79,17 +79,17 @@ (= :email-already-exists code) (let [msg (tr "errors.email-already-exists")] - (ts/schedule 100 #(st/emit! (msg/error msg))) + (ts/schedule 100 #(st/emit! (ntf/error msg))) (st/emit! (rt/nav :auth-login))) (= :email-already-validated code) (let [msg (tr "errors.email-already-validated")] - (ts/schedule 100 #(st/emit! (msg/warn msg))) + (ts/schedule 100 #(st/emit! (ntf/warn msg))) (st/emit! (rt/nav :auth-login))) :else (let [msg (tr "errors.generic")] - (ts/schedule 100 #(st/emit! (msg/error msg))) + (ts/schedule 100 #(st/emit! (ntf/error msg))) (st/emit! (rt/nav :auth-login))))))))) (if @bad-token diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 5427b29f1..6fa55cc07 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -35,6 +35,7 @@ on-focus (unchecked-get props "on-focus") on-blur (unchecked-get props "on-blur") placeholder (unchecked-get props "placeholder") + max-length (unchecked-get props "max-length") on-change (unchecked-get props "on-change") on-esc (unchecked-get props "on-esc") on-ctrl-enter (unchecked-get props "on-ctrl-enter") @@ -88,7 +89,8 @@ :on-blur on-blur :value value :placeholder placeholder - :on-change on-change*}])) + :on-change on-change* + :max-length max-length}])) (mf/defc reply-form [{:keys [thread] :as props}] @@ -128,7 +130,8 @@ :on-focus on-focus :select-on-focus? false :on-ctrl-enter on-submit - :on-change on-change}] + :on-change on-change + :max-length 750}] (when (or @show-buttons? (seq @content)) [:div {:class (stl/css :buttons-wrapper)} [:input.btn-secondary @@ -196,7 +199,8 @@ :select-on-focus? false :on-esc on-esc :on-change on-change - :on-ctrl-enter on-submit}] + :on-ctrl-enter on-submit + :max-length 750}] [:div {:class (stl/css :buttons-wrapper)} [:input {:on-click on-esc @@ -233,7 +237,8 @@ :select-on-focus true :select-on-focus? false :on-ctrl-enter on-submit* - :on-change on-change}] + :on-change on-change + :max-length 750}] [:div {:class (stl/css :buttons-wrapper)} [:input {:type "button" :value "Cancel" diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index 156a1b651..8f9daef57 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -96,14 +96,17 @@ [:ul {:class list-class :role "menu"} children])) (mf/defc dropdown-menu - {::mf/wrap-props false} + {::mf/props :obj} [props] (assert (fn? (gobj/get props "on-close")) "missing `on-close` prop") (assert (boolean? (gobj/get props "show")) "missing `show` prop") (let [ids (obj/get props "ids") - ids (d/nilv ids (->> (obj/get props "children") - (keep #(obj/get-in % ["props" "id"]))))] + ids (or ids + (->> (obj/get props "children") + (keep (fn [o] + (let [props (obj/get o "props")] + (obj/get props "id"))))))] (when (gobj/get props "show") (mf/element dropdown-menu' diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index eef34a8cf..2e673e4c0 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -16,10 +16,10 @@ [app.util.forms :as fm] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [app.util.object :as obj] [cljs.core :as c] [cuerdas.core :as str] - [rumext.v2 :as mf])) + [rumext.v2 :as mf] + [rumext.v2.util :as mfu])) (def form-ctx (mf/create-context nil)) (def use-form fm/use-form) @@ -102,7 +102,7 @@ (cond-> (and value is-checkbox?) (assoc :default-checked value)) (cond-> (and touched? (:message error)) (assoc "aria-invalid" "true" "aria-describedby" (dm/str "error-" input-name))) - (obj/map->obj obj/prop-key-fn)) + (mfu/map->props)) checked? (and is-checkbox? (= value true)) show-valid? (and show-success? touched? (not error)) @@ -205,7 +205,7 @@ :on-blur on-blur ;; :placeholder label :on-change on-change) - (obj/map->obj obj/prop-key-fn))] + (mfu/map->props))] [:div {:class (dm/str klass " " (stl/css :textarea-wrapper))} [:label {:class (stl/css :textarea-label)} label] @@ -420,7 +420,7 @@ (into [] (distinct) (conj coll item))) (mf/defc multi-input - [{:keys [form label class name trim valid-item-fn caution-item-fn on-submit] :as props}] + [{:keys [form label class name trim valid-item-fn caution-item-fn on-submit invite-email] :as props}] (let [form (or form (mf/use-ctx form-ctx)) input-name (get props :name) touched? (get-in @form [:touched input-name]) @@ -483,7 +483,8 @@ ;; Empty values means "submit" the form (whent some items have been added (when (and (kbd/enter? event) (str/empty? @value) (not-empty @items)) - (on-submit form)) + (when (fn? on-submit) + (on-submit form event))) ;; If we have a string in the input we add it only if valid (when (and (valid-item-fn val) (not (str/empty? @value))) @@ -528,6 +529,12 @@ values (filterv #(:valid %) values)] (update-form! values))) + (mf/with-effect [] + (when invite-email + (swap! items conj-dedup {:text (str/trim invite-email) + :valid (valid-item-fn invite-email) + :caution (caution-item-fn invite-email)}))) + [:div {:class klass} [:input {:id (name input-name) :class in-klass diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 4adec8d15..a15822104 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -8,10 +8,15 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.spec :as us] [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] + [app.main.data.events :as ev] + [app.main.data.modal :as modal] + [app.main.data.notifications :as notif] + [app.main.data.plugins :as dp] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -25,11 +30,16 @@ [app.main.ui.dashboard.team :refer [team-settings-page team-members-page team-invitations-page team-webhooks-page]] [app.main.ui.dashboard.templates :refer [templates-section]] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.plugins] + [app.plugins.register :as preg] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.router :as rt] + [beicon.v2.core :as rx] [goog.events :as events] [okulary.core :as l] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn ^boolean uuid-str? @@ -42,9 +52,7 @@ (let [search-term (get-in route [:params :query :search-term]) team-id (get-in route [:params :path :team-id]) project-id (get-in route [:params :path :project-id])] - (cond-> - {:search-term search-term} - + (cond-> {:search-term search-term} (uuid-str? team-id) (assoc :team-id (uuid team-id)) @@ -52,7 +60,7 @@ (assoc :project-id (uuid project-id))))) (mf/defc dashboard-content - [{:keys [team projects project section search-term profile] :as props}] + [{:keys [team projects project section search-term profile invite-email] :as props}] (let [container (mf/use-ref) content-width (mf/use-state 0) project-id (:id project) @@ -84,10 +92,10 @@ (mf/use-effect on-resize) - [:div {:class (stl/css :dashboard-content) :style {:pointer-events (when file-menu-open? "none")} - :on-click clear-selected-fn :ref container} + :on-click clear-selected-fn + :ref container} (case section :dashboard-projects [:* @@ -129,7 +137,7 @@ [:& libraries-page {:team team}] :dashboard-team-members - [:& team-members-page {:team team :profile profile}] + [:& team-members-page {:team team :profile profile :invite-email invite-email}] :dashboard-team-invitations [:& team-invitations-page {:team team}] @@ -145,21 +153,88 @@ (def dashboard-initialized (l/derived :current-team-id st/state)) +(defn use-plugin-register + [plugin-url team-id project-id] + + (let [navegate-file! + (fn [plugin {:keys [project-id id data]}] + (st/emit! + (dp/delay-open-plugin plugin) + (rt/nav :workspace + {:project-id project-id :file-id id} + {:page-id (dm/get-in data [:pages 0])}))) + + create-file! + (fn [plugin] + (st/emit! + (modal/hide) + (let [data + (with-meta + {:project-id project-id + :name (dm/str "Try plugin: " (:name plugin))} + {:on-success (partial navegate-file! plugin)})] + (-> (dd/create-file data) + (with-meta {::ev/origin "plugin-try-out"}))))) + + open-try-out-dialog + (fn [plugin] + (modal/show + :plugin-try-out + {:plugin plugin + :on-accept #(create-file! plugin) + :on-close #(modal/hide!)})) + + open-permissions-dialog + (fn [plugin] + (modal/show! + :plugin-permissions + {:plugin plugin + :on-accept + #(do (preg/install-plugin! plugin) + (st/emit! (modal/hide) + (rt/nav :dashboard-projects {:team-id team-id}) + (open-try-out-dialog plugin))) + :on-close + #(st/emit! (modal/hide) + (rt/nav :dashboard-projects {:team-id team-id}))}))] + + (mf/with-layout-effect + [plugin-url team-id project-id] + (when plugin-url + (->> (dp/fetch-manifest plugin-url) + (rx/subs! + (fn [plugin] + (if plugin + (do + (st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) + (open-permissions-dialog plugin)) + (st/emit! (notif/error "Cannot parser the plugin manifest")))) + (fn [_] + (st/emit! (notif/error "The plugin URL is incorrect"))))))))) + (mf/defc dashboard - [{:keys [route profile] :as props}] + {::mf/props :obj} + [{:keys [route profile]}] (let [section (get-in route [:data :name]) params (parse-params route) project-id (:project-id params) + team-id (:team-id params) search-term (:search-term params) + plugin-url (-> route :query-params :plugin) + + invite-email (-> route :query-params :invite-email) + teams (mf/deref refs/teams) team (get teams team-id) projects (mf/deref refs/dashboard-projects) project (get projects project-id) + default-project (->> projects vals (d/seek :is-default)) + initialized? (mf/deref dashboard-initialized)] (hooks/use-shortcuts ::dashboard sc/shortcuts) @@ -178,15 +253,17 @@ (fn [] (events/unlistenByKey key)))) + (use-plugin-register plugin-url team-id (:id default-project)) + [:& (mf/provider ctx/current-team-id) {:value team-id} [:& (mf/provider ctx/current-project-id) {:value project-id} - ;; NOTE: dashboard events and other related functions assumes - ;; that the team is a implicit context variable that is - ;; available using react context or accessing - ;; the :current-team-id on the state. We set the key to the - ;; team-id because we want to completely refresh all the - ;; components on team change. Many components assumes that the - ;; team is already set so don't put the team into mf/deps. + ;; NOTE: dashboard events and other related functions assumes + ;; that the team is a implicit context variable that is + ;; available using react context or accessing + ;; the :current-team-id on the state. We set the key to the + ;; team-id because we want to completely refresh all the + ;; components on team change. Many components assumes that the + ;; team is already set so don't put the team into mf/deps. (when (and team initialized?) [:main {:class (stl/css :dashboard) :key (:id team)} @@ -204,5 +281,5 @@ :project project :section section :search-term search-term - :team team}])])]])) - + :team team + :invite-email invite-email}])])]])) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index f270e1efb..8d6e01f7b 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -9,8 +9,8 @@ [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]] @@ -88,12 +88,12 @@ on-duplicate (fn [_] (apply st/emit! (map dd/duplicate-file files)) - (st/emit! (msg/success (tr "dashboard.success-duplicate-file" (i18n/c (count files)))))) + (st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c (count files)))))) on-delete-accept (fn [_] (apply st/emit! (map dd/delete-file files)) - (st/emit! (msg/success (tr "dashboard.success-delete-file" (i18n/c (count files)))) + (st/emit! (ntf/success (tr "dashboard.success-delete-file" (i18n/c (count files)))) (dd/clear-selected-files))) on-delete @@ -126,8 +126,8 @@ on-move-success (fn [team-id project-id] (if multi? - (st/emit! (msg/success (tr "dashboard.success-move-files"))) - (st/emit! (msg/success (tr "dashboard.success-move-file")))) + (st/emit! (ntf/success (tr "dashboard.success-move-files"))) + (st/emit! (ntf/success (tr "dashboard.success-move-file")))) (if (or navigate? (not= team-id current-team-id)) (st/emit! (dd/go-to-files team-id project-id)) (st/emit! (dd/fetch-recent-files team-id) diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 514be108d..519599243 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -180,12 +180,12 @@ :on-selected on-selected}]] [:& context-notification {:content (tr "dashboard.fonts.hero-text2") - :type :default + :level :default :is-html true}] (when problematic-fonts? [:& context-notification {:content (tr "dashboard.fonts.warning-text") - :type :warning + :level :warning :is-html true}])]] [:* diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 46b4cdefd..15245d39c 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -13,7 +13,7 @@ [app.common.logging :as log] [app.config :as cf] [app.main.data.dashboard :as dd] - [app.main.data.messages :as msg] + [app.main.data.notifications :as ntf] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.rasterizer :as thr] @@ -560,7 +560,7 @@ on-drop-success (fn [] - (st/emit! (msg/success (tr "dashboard.success-move-file")) + (st/emit! (ntf/success (tr "dashboard.success-move-file")) (dd/fetch-recent-files (:id team)) (dd/clear-selected-files))) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index bc72e3f29..9acd80050 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -12,8 +12,8 @@ [app.common.logging :as log] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.errors :as errors] [app.main.features :as features] [app.main.store :as st] @@ -366,7 +366,7 @@ (reset! template-finished* true) (errors/print-error! cause) (rx/of (modal/hide) - (msg/error (tr "dashboard.libraries-and-templates.import-error"))))) + (ntf/error (tr "dashboard.libraries-and-templates.import-error"))))) continue-entries (mf/use-fn @@ -481,19 +481,19 @@ [:div {:class (stl/css :modal-content)} (when (and (= :analyzing status) errors?) [:& context-notification - {:type :warning + {:level :warning :content (tr "dashboard.import.import-warning")}]) (when (and (= :importing status) (not ^boolean pending-import?)) (cond errors? [:& context-notification - {:type :warning + {:level :warning :content (tr "dashboard.import.import-warning")}] :else [:& context-notification - {:type (if (zero? success-num) :warning :success) + {:level (if (zero? success-num) :warning :success) :content (tr "dashboard.import.import-message" (i18n/c success-num))}])) (for [entry entries] diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs index 2f886686f..a8eb4621d 100644 --- a/frontend/src/app/main/ui/dashboard/project_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs @@ -7,8 +7,8 @@ (ns app.main.ui.dashboard.project-menu (:require [app.main.data.dashboard :as dd] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]] @@ -30,7 +30,7 @@ on-duplicate-success (fn [new-project] - (st/emit! (msg/success (tr "dashboard.success-duplicate-project")) + (st/emit! (ntf/success (tr "dashboard.success-duplicate-project")) (rt/nav :dashboard-files {:team-id (:team-id new-project) :project-id (:id new-project)}))) @@ -51,12 +51,12 @@ (fn [team-id] (let [data {:id (:id project) :team-id team-id} mdata {:on-success #(on-move-success team-id)}] - #(st/emit! (msg/success (tr "dashboard.success-move-project")) + #(st/emit! (ntf/success (tr "dashboard.success-move-project")) (dd/move-project (with-meta data mdata))))) delete-fn (fn [_] - (st/emit! (msg/success (tr "dashboard.success-delete-project")) + (st/emit! (ntf/success (tr "dashboard.success-delete-project")) (dd/delete-project project) (dd/go-to-projects (:team-id project)))) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index deacac12a..46e8828e0 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -11,7 +11,6 @@ [app.main.data.dashboard :as dd] [app.main.data.events :as ev] [app.main.data.modal :as modal] - [app.main.data.users :as du] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.grid :refer [line-grid]] @@ -24,6 +23,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.router :as rt] + [app.util.storage :as storage] [app.util.time :as dt] [cuerdas.core :as str] [okulary.core :as l] @@ -54,24 +54,25 @@ :data-testid "new-project-button"} (tr "dashboard.new-project")]])) -(mf/defc team-hero - {::mf/wrap [mf/memo]} - [{:keys [team close-fn] :as props}] +(mf/defc team-hero* + {::mf/wrap [mf/memo] + ::mf/props :obj} + [{:keys [team on-close]}] (let [on-nav-members-click (mf/use-fn #(st/emit! (dd/go-to-team-members))) - on-invite-click + on-invite (mf/use-fn (mf/deps team) (fn [] (st/emit! (modal/show {:type :invite-members :team team :origin :hero})))) - on-close-click + on-close' (mf/use-fn - (mf/deps close-fn) + (mf/deps on-close) (fn [event] (dom/prevent-default event) - (close-fn)))] + (on-close event)))] [:div {:class (stl/css :team-hero)} [:div {:class (stl/css :img-wrapper)} @@ -85,11 +86,11 @@ [:a {:on-click on-nav-members-click} (tr "dasboard.team-hero.management")]] [:button {:class (stl/css :btn-primary :invite) - :on-click on-invite-click} + :on-click on-invite} (tr "onboarding.choice.team-up.invite-members")]] [:button {:class (stl/css :close) - :on-click on-close-click + :on-click on-close' :aria-label (tr "labels.close")} close-icon]])) @@ -292,26 +293,27 @@ (sort-by :modified-at) (reverse)) recent-map (mf/deref recent-files-ref) - props (some-> profile (get :props {})) you-owner? (get-in team [:permissions :is-owner]) you-admin? (get-in team [:permissions :is-admin]) can-invite? (or you-owner? you-admin?) - team-hero? (and can-invite? - (:team-hero? props true) - (not (:is-default team))) + + show-team-hero* (mf/use-state #(get storage/global ::show-team-hero true)) + show-team-hero? (deref show-team-hero*) is-my-penpot (= (:default-team-id profile) (:id team)) + is-defalt-team? (:is-default team) team-id (:id team) - close-banner + on-close (mf/use-fn (fn [] - (st/emit! (du/update-profile-props {:team-hero? false}) - (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero" - ::ev/origin "dashboard"})))) + (reset! show-team-hero* false) + (st/emit! (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero" + ::ev/origin "dashboard"}))))] - show-team-hero? (and (not is-my-penpot) team-hero?)] + (mf/with-effect [show-team-hero?] + (swap! storage/global assoc ::show-team-hero show-team-hero?)) (mf/with-effect [team] (let [tname (if (:is-default team) @@ -328,13 +330,18 @@ [:& header] [:div {:class (stl/css :projects-container)} [:* - (when team-hero? - [:& team-hero {:team team :close-fn close-banner}]) + (when (and show-team-hero? + can-invite? + (not is-defalt-team?)) + [:> team-hero* {:team team :on-close on-close}]) [:div {:class (stl/css-case :dashboard-container true :no-bg true :dashboard-projects true - :with-team-hero show-team-hero?)} + :with-team-hero (and (not is-my-penpot) + (not is-defalt-team?) + show-team-hero? + can-invite?))} (for [{:keys [id] :as project} projects] (let [files (when recent-map (->> (vals recent-map) diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index 401d33494..862fc700a 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -19,7 +19,8 @@ (mf/defc search-page [{:keys [team search-term] :as props}] - (let [result (mf/deref refs/dashboard-search-result) + (let [search-term (or search-term "") + result (mf/deref refs/dashboard-search-result) [rowref limit] (hooks/use-dynamic-grid-item-width)] (mf/use-effect diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 245145f44..0f3565bdb 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -13,8 +13,8 @@ [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.refs :as refs] [app.main.store :as st] @@ -149,7 +149,7 @@ on-drop-success (mf/use-fn (mf/deps (:id item)) - #(st/emit! (msg/success (tr "dashboard.success-move-file")) + #(st/emit! (ntf/success (tr "dashboard.success-move-file")) (dd/go-to-files (:id item)))) on-drop @@ -362,13 +362,13 @@ (fn [{:keys [code] :as error}] (condp = code :no-enough-members-for-leave - (rx/of (msg/error (tr "errors.team-leave.insufficient-members"))) + (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) :member-does-not-exist - (rx/of (msg/error (tr "errors.team-leave.member-does-not-exists"))) + (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) :owner-cant-leave-team - (rx/of (msg/error (tr "errors.team-leave.owner-cant-leave"))) + (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) (rx/throw error))) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 85c7d67ed..3770fb568 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -10,12 +10,11 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.common.spec :as us] [app.config :as cfg] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.refs :as refs] [app.main.store :as st] @@ -30,7 +29,6 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [cljs.spec.alpha :as s] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -61,7 +59,7 @@ (mf/defc header {::mf/wrap [mf/memo] ::mf/wrap-props false} - [{:keys [section team]}] + [{:keys [section team invite-email]}] (let [on-nav-members (mf/use-fn #(st/emit! (dd/go-to-team-members))) on-nav-settings (mf/use-fn #(st/emit! (dd/go-to-team-settings))) on-nav-invitations (mf/use-fn #(st/emit! (dd/go-to-team-invitations))) @@ -79,7 +77,12 @@ (fn [] (st/emit! (modal/show {:type :invite-members :team team - :origin :team}))))] + :origin :team + :invite-email invite-email}))))] + + (mf/with-effect [] + (when invite-email + (on-invite-member))) [:header {:class (stl/css :dashboard-header :team) :data-testid "dashboard-header"} [:div {:class (stl/css :dashboard-title)} @@ -124,24 +127,17 @@ ] (filterv identity))) -(s/def ::emails (s/and ::us/set-of-valid-emails d/not-empty?)) -(s/def ::role ::us/keyword) -(s/def ::team-id ::us/uuid) - -(s/def ::invite-member-form - (s/keys :req-un [::role ::emails ::team-id])) - (def ^:private schema:invite-member-form [:map {:title "InviteMemberForm"} [:role :keyword] - [:emails [::sm/set {:kind ::sm/email :min 1}]] + [:emails [::sm/set {:min 1} ::sm/email]] [:team-id ::sm/uuid]]) (mf/defc invite-members-modal {::mf/register modal/components ::mf/register-as :invite-members ::mf/wrap-props false} - [{:keys [team origin]}] + [{:keys [team origin invite-email]}] (let [members-map (mf/deref refs/dashboard-team-members) perms (:permissions team) @@ -162,7 +158,7 @@ on-success (fn [_form {:keys [total]}] (when (pos? total) - (st/emit! (msg/success (tr "notifications.invitation-email-sent")))) + (st/emit! (ntf/success (tr "notifications.invitation-email-sent")))) (st/emit! (modal/hide) (dd/fetch-team-invitations))) @@ -173,16 +169,24 @@ (cond (and (= :validation type) (= :profile-is-muted code)) - (st/emit! (msg/error (tr "errors.profile-is-muted")) + (st/emit! (ntf/error (tr "errors.profile-is-muted")) (modal/hide)) + (and (= :validation type) + (= :max-invitations-by-request code)) + (swap! error-text (tr "errors.maximum-invitations-by-request-reached" (:threshold error))) + + (and (= :restriction type) + (= :max-quote-reached code)) + (swap! error-text (tr "errors.max-quote-reached" (:target error))) + (or (= :member-is-muted code) (= :email-has-permanent-bounces code) (= :email-has-complaints code)) (swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error))) :else - (st/emit! (msg/error (tr "errors.generic")) + (st/emit! (ntf/error (tr "errors.generic")) (modal/hide))))) on-submit @@ -192,7 +196,8 @@ :on-error (partial on-error form)}] (st/emit! (-> (dd/invite-team-members (with-meta params mdata)) (with-meta {::ev/origin origin})) - (dd/fetch-team-invitations))))] + (dd/fetch-team-invitations) + (dd/fetch-team-members (:id team)))))] [:div {:class (stl/css-case :modal-team-container true @@ -203,11 +208,11 @@ (when-not (= "" @error-text) [:& context-notification {:content @error-text - :type :error}]) + :level :error}]) (when (some current-data-emails current-members-emails) [:& context-notification {:content (tr "modals.invite-member.repeated-invitation") - :type :warning}]) + :level :warning}]) [:div {:class (stl/css :role-select)} [:p {:class (stl/css :role-title)} @@ -220,10 +225,10 @@ :name :emails :auto-focus? true :trim true - :valid-item-fn us/parse-email + :valid-item-fn sm/parse-email :caution-item-fn current-members-emails :label (tr "modals.invite-member.emails") - :on-submit on-submit}]] + :invite-email invite-email}]] [:div {:class (stl/css :action-buttons)} [:> fm/submit-button* @@ -368,13 +373,13 @@ (condp = code :no-enough-members-for-leave - (rx/of (msg/error (tr "errors.team-leave.insufficient-members"))) + (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) :member-does-not-exist - (rx/of (msg/error (tr "errors.team-leave.member-does-not-exists"))) + (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) :owner-cant-leave-team - (rx/of (msg/error (tr "errors.team-leave.owner-cant-leave"))) + (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) (rx/throw error)))) @@ -497,7 +502,7 @@ (mf/defc team-members-page {::mf/wrap-props false} - [{:keys [team profile]}] + [{:keys [team profile invite-email]}] (let [members-map (mf/deref refs/dashboard-team-members)] (mf/with-effect [team] @@ -511,7 +516,7 @@ (st/emit! (dd/fetch-team-members (:id team)))) [:* - [:& header {:section :dashboard-team-members :team team}] + [:& header {:section :dashboard-team-members :team team :invite-email invite-email}] [:section {:class (stl/css :dashboard-container :dashboard-team-members)} [:& team-members {:profile profile @@ -580,16 +585,16 @@ (cond (and (= :validation type) (= :profile-is-muted code)) - (rx/of (msg/error (tr "errors.profile-is-muted"))) + (rx/of (ntf/error (tr "errors.profile-is-muted"))) (and (= :validation type) (= :member-is-muted code)) - (rx/of (msg/error (tr "errors.member-is-muted"))) + (rx/of (ntf/error (tr "errors.member-is-muted"))) (and (= :restriction type) (or (= :email-has-permanent-bounces code) (= :email-has-complaints code))) - (rx/of (msg/error (tr "errors.email-has-permanent-bounces" email))) + (rx/of (ntf/error (tr "errors.email-has-permanent-bounces" email))) :else (rx/throw cause))))) @@ -605,7 +610,7 @@ on-resend-success (mf/use-fn (fn [] - (st/emit! (msg/success (tr "notifications.invitation-email-sent")) + (st/emit! (ntf/success (tr "notifications.invitation-email-sent")) (modal/hide) (dd/fetch-team-invitations)))) @@ -626,7 +631,7 @@ on-copy-success (mf/use-fn (fn [] - (st/emit! (msg/success (tr "notifications.invitation-link-copied")) + (st/emit! (ntf/success (tr "notifications.invitation-link-copied")) (modal/hide)))) on-copy @@ -788,7 +793,7 @@ (fn [_] (let [message (tr "dashboard.webhooks.create.success")] (st/emit! (dd/fetch-team-webhooks) - (msg/success message) + (ntf/success message) (modal/hide))))) on-error diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index cc0f37c9f..cf8796b75 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -10,8 +10,8 @@ [app.common.schema :as sm] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as msg] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as i] @@ -29,22 +29,22 @@ (defn- on-create-success [_form response] (let [msg "Team created successfully"] - (st/emit! (msg/success msg) + (st/emit! (ntf/success msg) (modal/hide) (rt/nav :dashboard-projects {:team-id (:id response)})))) (defn- on-update-success [_form _response] (let [msg "Team created successfully"] - (st/emit! (msg/success msg) + (st/emit! (ntf/success msg) (modal/hide)))) (defn- on-error [form _response] (let [id (get-in @form [:clean-data :id])] (if id - (rx/of (msg/error "Error on updating team.")) - (rx/of (msg/error "Error on creating team."))))) + (rx/of (ntf/error "Error on updating team.")) + (rx/of (ntf/error "Error on creating team."))))) (defn- on-create-submit [form] diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index 8927ff053..f410c332d 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -12,7 +12,6 @@ [app.main.data.dashboard :as dd] [app.main.data.events :as ev] [app.main.data.modal :as modal] - [app.main.data.users :as du] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.icons :as i] @@ -20,6 +19,7 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.router :as rt] + [app.util.storage :as storage] [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -60,17 +60,11 @@ :template template :on-finish-import on-finish})))) -(mf/defc title - {::mf/wrap-props false} - [{:keys [collapsed]}] - (let [on-click - (mf/use-fn - (mf/deps collapsed) - (fn [_event] - (let [props {:builtin-templates-collapsed-status (not collapsed)}] - (st/emit! (du/update-profile-props props))))) - - on-key-down +(mf/defc title* + {::mf/props :obj + ::mf/private true} + [{:keys [on-click is-collapsed]}] + (let [on-key-down (mf/use-fn (mf/deps on-click) (fn [event] @@ -86,7 +80,7 @@ :on-key-down on-key-down} [:span {:class (stl/css :title-text)} (tr "dashboard.libraries-and-templates")] - (if ^boolean collapsed + (if ^boolean is-collapsed [:span {:class (stl/css :title-icon :title-icon-collapsed)} arrow-icon] [:span {:class (stl/css :title-icon)} @@ -168,7 +162,9 @@ [{:keys [default-project-id profile project-id team-id]}] (let [templates (mf/deref builtin-templates) templates (mf/with-memo [templates] - (filterv #(not= (:id %) "tutorial-for-beginners") templates)) + (filterv #(and + (not= (:id %) "welcome") + (not= (:id %) "tutorial-for-beginners")) templates)) route (mf/deref refs/route) route-name (get-in route [:data :name]) @@ -178,8 +174,12 @@ "dashboard-project") (name route-name)) - props (:props profile) - collapsed (:builtin-templates-collapsed-status props false) + collapsed* (mf/use-state + #(get storage/global ::collapsed)) + collapsed (deref collapsed*) + + + can-move (mf/use-state {:left false :right true}) total (count templates) @@ -190,19 +190,22 @@ move-left (fn [] (dom/scroll-by! (mf/ref-val content-ref) -300 0)) move-right (fn [] (dom/scroll-by! (mf/ref-val content-ref) 300 0)) - update-can-move - (fn [scroll-left scroll-available client-width] - (reset! can-move {:left (> scroll-left 0) - :right (> scroll-available client-width)})) + on-toggle-collapse + (mf/use-fn + (fn [_event] + (swap! collapsed* not))) on-scroll (mf/use-fn (fn [e] - (let [scroll (dom/get-target-scroll e) - scroll-left (:scroll-left scroll) + (let [scroll (dom/get-target-scroll e) + scroll-left (:scroll-left scroll) scroll-available (- (:scroll-width scroll) scroll-left) - client-rect (dom/get-client-size (dom/get-target e))] - (update-can-move scroll-left scroll-available (unchecked-get client-rect "width"))))) + client-rect (dom/get-client-size (dom/get-target e)) + client-width (unchecked-get client-rect "width")] + + (reset! can-move {:left (> scroll-left 0) + :right (> scroll-available client-width)})))) on-move-left (mf/use-fn #(move-left)) @@ -226,15 +229,18 @@ (let [content (mf/ref-val content-ref)] (when (and (some? content) (some? templates)) (dom/scroll-to content #js {:behavior "instant" :left 0 :top 0}) - (.dispatchEvent content (js/Event. "scroll"))))) + (dom/dispatch-event content (dom/event "scroll"))))) (mf/with-effect [profile collapsed] + (swap! storage/global assoc ::collapsed collapsed) + (when (and profile (not collapsed)) (st/emit! (dd/fetch-builtin-templates)))) [:div {:class (stl/css-case :dashboard-templates-section true :collapsed collapsed)} - [:& title {:collapsed collapsed}] + [:> title* {:on-click on-toggle-collapse + :is-collapsed collapsed}] [:div {:class (stl/css :content) :on-scroll on-scroll diff --git a/frontend/src/app/main/ui/debug/components_preview.cljs b/frontend/src/app/main/ui/debug/components_preview.cljs deleted file mode 100644 index 9fd0788b7..000000000 --- a/frontend/src/app/main/ui/debug/components_preview.cljs +++ /dev/null @@ -1,270 +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 app.main.ui.debug.components-preview - (:require-macros [app.main.style :as stl]) - (:require - [app.common.data :as d] - [app.main.data.users :as du] - [app.main.refs :as refs] - [app.main.store :as st] - [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] - [app.main.ui.components.search-bar :refer [search-bar]] - [app.main.ui.components.tab-container :refer [tab-container tab-element]] - [app.main.ui.components.title-bar :refer [title-bar]] - [app.main.ui.icons :as i] - [app.util.dom :as dom] - [rumext.v2 :as mf])) - -(mf/defc component-wrapper - {::mf/wrap-props false} - [props] - (let [children (unchecked-get props "children") - title (unchecked-get props "title")] - [:div {:class (stl/css :component)} - [:h4 {:class (stl/css :component-name)} title] - children])) - -(mf/defc components-preview - {::mf/wrap-props false} - [] - (let [profile (mf/deref refs/profile) - initial (mf/with-memo [profile] - (update profile :lang #(or % ""))) - initial-theme (:theme initial) - on-change (fn [event] - (let [theme (dom/event->value event) - data (assoc initial :theme theme)] - (st/emit! (du/update-profile data)))) - colors ["var(--color-background-primary)" - "var(--color-background-secondary)" - "var(--color-background-tertiary)" - "var(--color-background-quaternary)" - "var(--color-foreground-primary)" - "var(--color-foreground-secondary)" - "var(--color-accent-primary)" - "var(--color-accent-primary-muted)" - "var(--color-accent-secondary)" - "var(--color-accent-tertiary)"] - - ;; COMPONENTS FNs - state* (mf/use-state {:collapsed? true - :tab-selected :first - :input-value "" - :radio-selected "first"}) - state (deref state*) - - collapsed? (:collapsed? state) - toggle-collapsed - (mf/use-fn #(swap! state* update :collapsed? not)) - - tab-selected (:tab-selected state) - set-tab (mf/use-fn #(swap! state* assoc :tab-selected %)) - - input-value (:input-value state) - radio-selected (:radio-selected state) - - set-radio-selected (mf/use-fn #(swap! state* assoc :radio-selected %)) - - update-search - (mf/use-fn - (fn [value _event] - (swap! state* assoc :input-value value))) - - - on-btn-click (mf/use-fn #(prn "eyy"))] - - [:section.debug-components-preview - [:div {:class (stl/css :themes-row)} - [:h2 "Themes"] - [:select {:label "Select theme color" - :name :theme - :default "default" - :value initial-theme - :on-change on-change} - [:option {:label "Penpot Dark (default)" :value "default"}] - [:option {:label "Penpot Light" :value "light"}]] - [:div {:class (stl/css :wrapper)} - (for [color colors] - [:div {:class (stl/css :color-wrapper)} - [:span (d/name color)] - [:div {:key color - :style {:background color} - :class (stl/css :rect)}]])]] - - [:div {:class (stl/css :components-row)} - [:h2 {:class (stl/css :title)} "Components"] - [:div {:class (stl/css :components-wrapper)} - [:div {:class (stl/css :components-group)} - [:h3 "Titles"] - [:& component-wrapper - {:title "Title"} - [:& title-bar {:collapsable false - :title "Title"}]] - [:& component-wrapper - {:title "Title and action button"} - [:& title-bar {:collapsable false - :title "Title" - :on-btn-click on-btn-click - :btn-children i/add}]] - [:& component-wrapper - {:title "Collapsed title and action button"} - [:& title-bar {:collapsable true - :collapsed collapsed? - :on-collapsed toggle-collapsed - :title "Title" - :on-btn-click on-btn-click - :btn-children i/add}]] - [:& component-wrapper - {:title "Collapsed title and children"} - [:& title-bar {:collapsable true - :collapsed collapsed? - :on-collapsed toggle-collapsed - :title "Title"} - [:& tab-container {:on-change-tab set-tab - :selected tab-selected} - [:& tab-element {:id :first - :title "A tab"}] - [:& tab-element {:id :second - :title "B tab"}]]]]] - - [:div {:class (stl/css :components-group)} - [:h3 "Tabs component"] - [:& component-wrapper - {:title "2 tab component"} - [:& tab-container {:on-change-tab set-tab - :selected tab-selected} - [:& tab-element {:id :first :title "First tab"} - [:div "This is first tab content"]] - - [:& tab-element {:id :second :title "Second tab"} - [:div "This is second tab content"]]]] - [:& component-wrapper - {:title "3 tab component"} - [:& tab-container {:on-change-tab set-tab - :selected tab-selected} - [:& tab-element {:id :first :title "First tab"} - [:div "This is first tab content"]] - - [:& tab-element {:id :second - :title "Second tab"} - [:div "This is second tab content"]] - [:& tab-element {:id :third - :title "Third tab"} - [:div "This is third tab content"]]]]] - - [:div {:class (stl/css :components-group)} - [:h3 "Search bar"] - [:& component-wrapper - {:title "Search bar only"} - [:& search-bar {:on-change update-search - :value input-value - :placeholder "Test value"}]] - [:& component-wrapper - {:title "Search and button"} - [:& search-bar {:on-change update-search - :value input-value - :placeholder "Test value"} - [:button {:class (stl/css :button-secondary) - :on-click on-btn-click} - "X"]]]] - - [:div {:class (stl/css :components-group)} - [:h3 "Radio buttons"] - [:& component-wrapper - {:title "Two radio buttons (toggle)"} - [:& radio-buttons {:selected radio-selected - :on-change set-radio-selected - :name "listing-style"} - [:& radio-button {:icon i/view-as-list - :value "first" - :id :list}] - [:& radio-button {:icon i/flex-grid - :value "second" - :id :grid}]]] - [:& component-wrapper - {:title "Three radio buttons"} - [:& radio-buttons {:selected radio-selected - :on-change set-radio-selected - :name "listing-style"} - [:& radio-button {:icon i/view-as-list - :value "first" - :id :first}] - [:& radio-button {:icon i/flex-grid - :value "second" - :id :second}] - - [:& radio-button {:icon i/add - :value "third" - :id :third}]]] - - [:& component-wrapper - {:title "Four radio buttons"} - [:& radio-buttons {:selected radio-selected - :on-change set-radio-selected - :name "listing-style"} - [:& radio-button {:icon i/view-as-list - :value "first" - :id :first}] - [:& radio-button {:icon i/flex-grid - :value "second" - :id :second}] - - [:& radio-button {:icon i/add - :value "third" - :id :third}] - - [:& radio-button {:icon i/board - :value "forth" - :id :forth}]]]] - [:div {:class (stl/css :components-group)} - [:h3 "Buttons"] - [:& component-wrapper - {:title "Button primary"} - [:button {:class (stl/css :button-primary)} - "Primary"]] - [:& component-wrapper - {:title "Button primary with icon"} - [:button {:class (stl/css :button-primary)} - i/add]] - - [:& component-wrapper - {:title "Button secondary"} - [:button {:class (stl/css :button-secondary)} - "secondary"]] - [:& component-wrapper - {:title "Button secondary with icon"} - [:button {:class (stl/css :button-secondary)} - i/add]] - - [:& component-wrapper - {:title "Button tertiary"} - [:button {:class (stl/css :button-tertiary)} - "tertiary"]] - [:& component-wrapper - {:title "Button tertiary with icon"} - [:button {:class (stl/css :button-tertiary)} - i/add]]] - [:div {:class (stl/css :components-group)} - [:h3 "Inputs"] - [:& component-wrapper - {:title "Only input"} - [:div {:class (stl/css :input-wrapper)} - [:input {:class (stl/css :basic-input) - :placeholder "----"}]]] - [:& component-wrapper - {:title "Input with label"} - [:div {:class (stl/css :input-wrapper)} - [:span {:class (stl/css :input-label)} "label"] - [:input {:class (stl/css :basic-input) - :placeholder "----"}]]] - [:& component-wrapper - {:title "Input with icon"} - [:div {:class (stl/css :input-wrapper)} - [:span {:class (stl/css :input-label)} - i/add] - [:input {:class (stl/css :basic-input) - :placeholder "----"}]]]]]]])) diff --git a/frontend/src/app/main/ui/debug/components_preview.scss b/frontend/src/app/main/ui/debug/components_preview.scss deleted file mode 100644 index 8a087c9ee..000000000 --- a/frontend/src/app/main/ui/debug/components_preview.scss +++ /dev/null @@ -1,99 +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 - -@import "refactor/common-refactor.scss"; - -.themes-row { - width: 100%; - padding: $s-20; - color: var(--color-foreground-primary); - background: var(--color-background-secondary); - .wrapper { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: $s-40; - background-color: var(--color-background-primary); - width: 100%; - padding: $s-20; - .rect { - display: flex; - justify-content: center; - align-items: center; - border: $s-1 solid var(--color-foreground-primary); - padding: $s-20; - height: $s-96; - min-width: $s-152; - } - } -} -.color-wrapper { - display: grid; - grid-template-rows: auto $s-96; -} - -.components-row { - color: var(--color-foreground-primary); - background: var(--color-background-secondary); - height: 100%; - padding: 0 $s-20; - .title { - padding: $s-20; - } - .components-wrapper { - padding: $s-20; - display: flex; - flex-wrap: wrap; - gap: $s-20; - .components-group { - @include flexCenter; - justify-content: flex-start; - flex-direction: column; - border-radius: $s-8; - h3 { - @include bodySmallTypography; - font-size: $fs-24; - width: 100%; - } - .component { - display: flex; - flex-direction: column; - gap: $s-8; - width: $s-240; - max-height: $s-80; - margin-bottom: $s-16; - .component-name { - @include uppercaseTitleTipography; - font-weight: bold; - } - } - } - .button-primary { - @extend .button-primary; - height: $s-32; - svg { - @extend .button-icon; - } - } - .button-secondary { - @extend .button-secondary; - height: $s-32; - svg { - @extend .button-icon; - } - } - .button-tertiary { - @extend .button-tertiary; - height: $s-32; - svg { - @extend .button-icon; - } - } - .input-wrapper { - @extend .input-element; - @include bodySmallTypography; - } - } -} diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index 85268cf8d..84a70e0e3 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -6,16 +6,24 @@ (ns app.main.ui.ds (:require + [app.config :as cf] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.forms.input :refer [input*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg* raw-svg-list]] [app.main.ui.ds.foundations.typography :refer [typography-list]] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] + [app.main.ui.ds.notifications.toast :refer [toast*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.ds.storybook :as sb])) + [app.main.ui.ds.storybook :as sb] + [app.util.i18n :as i18n])) + + +(i18n/init! cf/translations) (def default "A export used for storybook" @@ -26,7 +34,10 @@ :Input input* :Loader loader* :RawSvg raw-svg* + :Select select* :Text text* + :TabSwitcher tab-switcher* + :Toast toast* ;; meta / misc :meta #js {:icons (clj->js (sort icon-list)) :svgs (clj->js (sort raw-svg-list)) diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss index a424603d1..e8a856074 100644 --- a/frontend/src/app/main/ui/ds/_borders.scss +++ b/frontend/src/app/main/ui/ds/_borders.scss @@ -8,5 +8,6 @@ // TODO: create actual tokens once we have them from design $br-8: px2rem(8); +$br-circle: 50%; $b-1: px2rem(1); diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index f27838b6a..63ad1f93b 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -7,4 +7,8 @@ @use "./utils.scss" as *; // TODO: create actual tokens once we have them from design +$sz-16: px2rem(16); $sz-32: px2rem(32); +$sz-36: px2rem(36); +$sz-224: px2rem(224); +$sz-400: px2rem(400); diff --git a/frontend/src/app/main/ui/ds/buttons/button.cljs b/frontend/src/app/main/ui/ds/buttons/button.cljs index 9dfb2c9b4..cfb30409d 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/button.cljs @@ -12,13 +12,18 @@ [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]] [rumext.v2 :as mf])) -(def button-variants (set '("primary" "secondary" "ghost" "destructive"))) +(def ^:private schema:button + [:map + [:class {:optional true} :string] + [:icon {:optional true} + [:and :string [:fn #(contains? icon-list %)]]] + [:variant {:optional true} + [:maybe [:enum "primary" "secondary" "ghost" "destructive"]]]]) (mf/defc button* - {::mf/props :obj} + {::mf/props :obj + ::mf/schema schema:button} [{:keys [variant icon children class] :rest props}] - (assert (or (nil? variant) (contains? button-variants variant) "expected valid variant")) - (assert (or (nil? icon) (contains? icon-list icon) "expected valid icon id")) (let [variant (or variant "primary") class (dm/str class " " (stl/css-case :button true :button-primary (= variant "primary") diff --git a/frontend/src/app/main/ui/ds/buttons/buttons.mdx b/frontend/src/app/main/ui/ds/buttons/buttons.mdx index 3bc00dc93..ebbfa61a7 100644 --- a/frontend/src/app/main/ui/ds/buttons/buttons.mdx +++ b/frontend/src/app/main/ui/ds/buttons/buttons.mdx @@ -2,7 +2,7 @@ import { Canvas, Meta } from '@storybook/blocks'; import * as ButtonStories from "./button.stories"; import * as IconButtonStories from "./icon_button.stories"; - + # Buttons diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 1a80f9b19..dadb285af 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -14,12 +14,20 @@ (def button-variants (set '("primary" "secondary" "ghost" "destructive"))) + +(def ^:private schema:icon-button + [:map + [:class {:optional true} :string] + [:icon {:optional true} + [:and :string [:fn #(contains? icon-list %)]]] + [:aria-label :string] + [:variant {:optional true} + [:maybe [:enum "primary" "secondary" "ghost" "destructive"]]]]) + (mf/defc icon-button* - {::mf/props :obj} + {::mf/props :obj + ::mf/schema schema:icon-button} [{:keys [class icon variant aria-label] :rest props}] - (assert (contains? icon-list icon) "expected valid icon id") - (assert (or (not variant) (contains? button-variants variant)) "expected valid variant") - (assert (some? aria-label) "aria-label must be provided") (let [variant (or variant "primary") class (dm/str class " " (stl/css-case :icon-button true :icon-button-primary (= variant "primary") diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index aa21f26d2..914b29108 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -9,6 +9,8 @@ $mint-150: #7efff5; $mint-250: #00d1b8; $mint-700: #426158; +$mint-150-60: #7efff599; +$mint-250-10: #00d1b81a; $green-200: #a7e8d9; $green-500: #2d9f8f; @@ -19,8 +21,7 @@ $orange-500: #fe4811; $orange-950: #440806; $red-200: #ffcada; -$red-500: #ff3277; -$red-700: #c80857; +$red-400: #c80857; $red-950: #500124; $pink-400: #ff6fe0; @@ -29,6 +30,8 @@ $purple-200: #e1d2f5; $purple-400: #bb97d8; $purple-600: #8c33eb; $purple-700: #6911d4; +$purple-600-10: #8c33eb1a; +$purple-700-60: #6911d499; $blue-200: #bae3fd; $blue-500: #0e9be9; @@ -38,28 +41,36 @@ $cobalt-700: #1345aa; $black: #000; $gray-950: #18181a; +$gray-950-60: #18181a99; +$gray-950-90: #18181ae6; $gray-900: #212426; $gray-800: #2e3434; $gray-200: #e8eaee; $gray-100: #eef0f2; $gray-50: #f3f4f6; $white: #fff; +$white-60: #ffffff99; +$white-90: #ffffffe6; $blue-teal-700: #495e74; $grayish-blue-500: #8f9da3; +$grayish-red: #bfbfbf; + :global(.light) { --color-accent-primary: #{$purple-700}; --color-accent-primary-muted: #{$purple-200}; --color-accent-secondary: #{$cobalt-700}; --color-accent-tertiary: #{$purple-600}; --color-accent-quaternary: #{$pink-400}; + --color-accent-overlay: #{$purple-600-10}; + --color-accent-select: #{$purple-700-60}; --color-accent-success: #{$green-500}; --color-background-success: #{$green-200}; --color-accent-warning: #{$orange-500}; --color-background-warning: #{$orange-200}; - --color-accent-error: #{$red-500}; + --color-accent-error: #{$red-400}; --color-background-error: #{$red-200}; --color-accent-info: #{$blue-500}; --color-background-info: #{$blue-200}; @@ -73,6 +84,9 @@ $grayish-blue-500: #8f9da3; --color-foreground-secondary: #{$blue-teal-700}; --color-shadow: #{color.change($blue-teal-700, $alpha: 0.2)}; + --color-overlay-default: #{$white-60}; + --color-overlay-onboarding: #{$white-90}; + --color-canvas: #{$grayish-red}; } :global(.default) { @@ -81,12 +95,14 @@ $grayish-blue-500: #8f9da3; --color-accent-secondary: #{$purple-400}; --color-accent-tertiary: #{$mint-250}; --color-accent-quaternary: #{$pink-400}; + --color-accent-overlay: #{$mint-250-10}; + --color-accent-select: #{$mint-150-60}; --color-accent-success: #{$green-500}; --color-background-success: #{$green-950}; --color-accent-warning: #{$orange-500}; --color-background-warning: #{$orange-950}; - --color-accent-error: #{$red-700}; + --color-accent-error: #{$red-400}; --color-background-error: #{$red-950}; --color-accent-info: #{$blue-500}; --color-background-info: #{$blue-950}; @@ -100,4 +116,7 @@ $grayish-blue-500: #8f9da3; --color-foreground-secondary: #{$grayish-blue-500}; --color-shadow: #{color.change($black, $alpha: 0.6)}; + --color-overlay-default: #{$gray-950-60}; + --color-overlay-onboarding: #{$gray-950-90}; + --color-canvas: #{$grayish-red}; } diff --git a/frontend/src/app/main/ui/ds/forms/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs similarity index 80% rename from frontend/src/app/main/ui/ds/forms/input.cljs rename to frontend/src/app/main/ui/ds/controls/input.cljs index 6b97e5449..9d0eaa765 100644 --- a/frontend/src/app/main/ui/ds/forms/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.main.ui.ds.forms.input +(ns app.main.ui.ds.controls.input (:require-macros [app.common.data.macros :as dm] [app.main.style :as stl]) @@ -13,10 +13,18 @@ [app.util.dom :as dom] [rumext.v2 :as mf])) +(def ^:private schema:input + [:map + [:class {:optional true} :string] + [:icon {:optional true} + [:and :string [:fn #(contains? icon-list %)]]] + [:type {:optional true} :string] + [:ref {:optional true} some?]]) + (mf/defc input* - {::mf/props :obj} + {::mf/props :obj + ::mf/schema schema:input} [{:keys [icon class type ref] :rest props}] - (assert (or (nil? icon) (contains? icon-list icon))) (let [ref (or ref (mf/use-ref)) type (or type "text") icon-class (stl/css-case :input true diff --git a/frontend/src/app/main/ui/ds/forms/input.mdx b/frontend/src/app/main/ui/ds/controls/input.mdx similarity index 97% rename from frontend/src/app/main/ui/ds/forms/input.mdx rename to frontend/src/app/main/ui/ds/controls/input.mdx index 2d6d9946a..1ecb0e937 100644 --- a/frontend/src/app/main/ui/ds/forms/input.mdx +++ b/frontend/src/app/main/ui/ds/controls/input.mdx @@ -1,7 +1,7 @@ import { Canvas, Meta } from '@storybook/blocks'; import * as InputStories from "./input.stories"; - + # Input diff --git a/frontend/src/app/main/ui/ds/forms/input.scss b/frontend/src/app/main/ui/ds/controls/input.scss similarity index 83% rename from frontend/src/app/main/ui/ds/forms/input.scss rename to frontend/src/app/main/ui/ds/controls/input.scss index 027e79878..312729c9d 100644 --- a/frontend/src/app/main/ui/ds/forms/input.scss +++ b/frontend/src/app/main/ui/ds/controls/input.scss @@ -1,3 +1,9 @@ +// 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 + @use "../_borders.scss" as *; @use "../_sizes.scss" as *; @use "../typography.scss" as *; @@ -51,7 +57,7 @@ } &::selection { - background: var(--color-accent-primary-muted); + background: var(--color-accent-select); } &::placeholder { diff --git a/frontend/src/app/main/ui/ds/forms/input.stories.jsx b/frontend/src/app/main/ui/ds/controls/input.stories.jsx similarity index 97% rename from frontend/src/app/main/ui/ds/forms/input.stories.jsx rename to frontend/src/app/main/ui/ds/controls/input.stories.jsx index 2f0122283..0e23bffe7 100644 --- a/frontend/src/app/main/ui/ds/forms/input.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/input.stories.jsx @@ -11,7 +11,7 @@ const { Input } = Components; const { icons } = Components.meta; export default { - title: "Forms/Input", + title: "Controls/Input", component: Components.Input, argTypes: { icon: { diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs new file mode 100644 index 000000000..e50e6a45f --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -0,0 +1,244 @@ +;; 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.main.ui.ds.controls.select + (:require-macros + [app.common.data.macros :as dm] + [app.main.style :as stl]) + (:require + [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i] + [app.util.array :as array] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [rumext.v2 :as mf])) + +(mf/defc option* + {::mf/props :obj + ::mf/private true} + [{:keys [id label icon aria-label on-click selected set-ref focused] :rest props}] + [:> :li {:value id + :class (stl/css-case :option true + :option-with-icon (some? icon) + :option-current focused) + :aria-selected selected + + :ref (fn [node] + (set-ref node id)) + :role "option" + :id id + :on-click on-click + :data-id id} + + (when (some? icon) + [:> icon* + {:id icon + :size "s" + :class (stl/css :option-icon) + :aria-hidden (when label true) + :aria-label (when (not label) aria-label)}]) + + [:span {:class (stl/css :option-text)} label] + (when selected + [:> icon* + {:id i/tick + :size "s" + :class (stl/css :option-check) + :aria-hidden (when label true)}])]) + +(mf/defc options-dropdown* + {::mf/props :obj + ::mf/private true} + [{:keys [set-ref on-click options selected focused] :rest props}] + (let [props (mf/spread-props props + {:class (stl/css :option-list) + :tab-index "-1" + :role "listbox"})] + [:> "ul" props + (for [option ^js options] + (let [id (obj/get option "id") + label (obj/get option "label") + aria-label (obj/get option "aria-label") + icon (obj/get option "icon")] + [:> option* {:selected (= id selected) + :key id + :id id + :label label + :icon icon + :aria-label aria-label + :set-ref set-ref + :focused (= id focused) + :on-click on-click}]))])) + +(def ^:private schema:select-option + [:and + [:map {:title "option"} + [:id :string] + [:icon {:optional true} + [:and :string [:fn #(contains? icon-list %)]]] + [:label {:optional true} :string] + [:aria-label {:optional true} :string]] + [:fn {:error/message "invalid data: missing required props"} + (fn [option] + (or (and (contains? option :icon) + (or (contains? option :label) + (contains? option :aria-label))) + (contains? option :label)))]]) + +(defn- get-option + [options id] + (or (array/find #(= id (obj/get % "id")) options) + (aget options 0))) + +(defn- get-selected-option-id + [options default] + (let [option (get-option options default)] + (obj/get option "id"))) + +(defn- handle-focus-change + [options focused* new-index options-nodes-refs] + (let [option (aget options new-index) + id (obj/get option "id") + nodes (mf/ref-val options-nodes-refs) + node (obj/get nodes id)] + (reset! focused* id) + (dom/scroll-into-view-if-needed! node))) + +(defn- handle-selection + [focused* selected* open*] + (when-let [focused (deref focused*)] + (reset! selected* focused)) + (reset! open* false) + (reset! focused* nil)) + +(def ^:private schema:select + [:map + [:options [:vector {:min 1} schema:select-option]] + [:class {:optional true} :string] + [:disabled {:optional true} :boolean] + [:default-selected {:optional true} :string] + [:on-change {:optional true} fn?]]) + +(mf/defc select* + {::mf/props :obj + ::mf/schema schema:select} + [{:keys [options class disabled default-selected on-change] :rest props}] + (let [open* (mf/use-state false) + open (deref open*) + on-click + (mf/use-fn + (mf/deps disabled) + (fn [event] + (dom/stop-propagation event) + (when-not disabled + (swap! open* not)))) + + selected* (mf/use-state #(get-selected-option-id options default-selected)) + selected (deref selected*) + + focused* (mf/use-state nil) + focused (deref focused*) + + on-option-click + (mf/use-fn + (mf/deps on-change) + (fn [event] + (let [node (dom/get-current-target event) + id (dom/get-data node "id")] + (reset! selected* id) + (reset! focused* nil) + (reset! open* false) + (when (fn? on-change) + (on-change id))))) + + options-nodes-refs (mf/use-ref nil) + options-ref (mf/use-ref nil) + + set-ref + (mf/use-fn + (fn [node id] + (let [refs (or (mf/ref-val options-nodes-refs) #js {}) + refs (if node + (obj/set! refs id node) + (obj/unset! refs id))] + (mf/set-ref-val! options-nodes-refs refs)))) + + on-blur + (mf/use-fn + (fn [event] + (let [click-outside (nil? (.-relatedTarget event))] + (when click-outside + (reset! focused* nil) + (reset! open* false))))) + + on-key-down + (mf/use-fn + (mf/deps focused disabled) + (fn [event] + (when-not disabled + (let [options (mf/ref-val options-ref) + len (alength options) + index (array/find-index #(= (deref focused*) (obj/get % "id")) options)] + (dom/stop-propagation event) + (cond + (kbd/home? event) + (handle-focus-change options focused* 0 options-nodes-refs) + + (kbd/up-arrow? event) + (handle-focus-change options focused* (mod (- index 1) len) options-nodes-refs) + + (kbd/down-arrow? event) + (handle-focus-change options focused* (mod (+ index 1) len) options-nodes-refs) + + (or (kbd/space? event) (kbd/enter? event)) + (when (deref open*) + (dom/prevent-default event) + (handle-selection focused* selected* open*)) + + (kbd/esc? event) + (do (reset! open* false) + (reset! focused* nil))))))) + + class (dm/str class " " (stl/css :select)) + + props (mf/spread-props props {:class class + :role "combobox" + :aria-controls "listbox" + :aria-haspopup "listbox" + :aria-activedescendant focused + :aria-expanded open + :on-key-down on-key-down + :disabled disabled + :on-click on-click + :on-blur on-blur}) + + selected-option (get-option options selected) + label (obj/get selected-option "label") + icon (obj/get selected-option "icon")] + + (mf/with-effect [options] + (mf/set-ref-val! options-ref options)) + + [:div {:class (stl/css :select-wrapper)} + [:> :button props + [:span {:class (stl/css-case :select-header true + :header-icon (some? icon))} + (when icon + [:> icon* {:id icon + :size "s" + :aria-hidden true}]) + [:span {:class (stl/css :header-label)} + label]] + [:> icon* {:id i/arrow + :class (stl/css :arrow) + :size "s" + :aria-hidden true}]] + (when open + [:> options-dropdown* {:on-click on-option-click + :options options + :selected selected + :focused focused + :set-ref set-ref}])])) diff --git a/frontend/src/app/main/ui/ds/controls/select.mdx b/frontend/src/app/main/ui/ds/controls/select.mdx new file mode 100644 index 000000000..2bc21a8c6 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/select.mdx @@ -0,0 +1,63 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as SelectStories from "./select.stories"; + + + +# Select + +Select lets users choose one option from an options menu. + +## Variants + +**Text**: We will use this variant when there are enough space and icons don't add any useful context. + + + +**Icon and text**: We will use this variant when there are enough space and icons add any useful context. + + +## Technical notes + +### Icons + +Each option of `select*` may accept an `icon`, which must contain an [icon ID](../foundations/assets/icon.mdx). +These are available in the `app.main.ds.foundations.assets.icon` namespace. + + +```clj +(ns app.main.ui.foo + (:require + [app.main.ui.ds.foundations.assets.icon :as i])) +``` + +```clj +[:> select* + {:options [{ :label "Code" + :id "option-code" + :icon i/fill-content } + { :label "Design" + :id "option-design" + :icon i/pentool } + { :label "Menu" + :id "option-menu" } + ]}] +``` + + + +## Usage guidelines (design) + +### Where to use + +Used in a wide range of applications in the app, +to select among available text-based options, +sometimes with icons that offers additional context. + +### When to use + +Consider using select when you have 5 or more options to choose from. + +### Interaction / Behavior + +When the user clicks on the clickable area, a list of +options appears. When an option is chosen, the list is closed. \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/select.scss b/frontend/src/app/main/ui/ds/controls/select.scss new file mode 100644 index 000000000..ff2cbe507 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/select.scss @@ -0,0 +1,147 @@ +// 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 + +@use "../_borders.scss" as *; +@use "../_sizes.scss" as *; +@use "../typography.scss" as *; + +.select-wrapper { + --select-icon-fg-color: var(--color-foreground-secondary); + --select-fg-color: var(--color-foreground-primary); + --select-bg-color: var(--color-background-tertiary); + --select-outline-color: none; + --select-border-color: none; + --select-dropdown-border-color: var(--color-background-quaternary); + + &:hover { + --select-bg-color: var(--color-background-quaternary); + } + + @include use-typography("body-small"); + position: relative; + display: grid; + grid-template-rows: auto; + gap: var(--sp-xxs); + width: 100%; +} + +.select { + &:focus-visible { + --select-outline-color: var(--color-accent-primary); + } + + &:disabled { + --select-bg-color: var(--color-background-primary); + --select-border-color: var(--color-background-quaternary); + --select-fg-color: var(--color-foreground-secondary); + } + + display: grid; + grid-template-columns: 1fr auto; + gap: var(--sp-xs); + height: $sz-32; + width: 100%; + padding: var(--sp-s); + border: none; + border-radius: $br-8; + outline: $b-1 solid var(--select-outline-color); + border: $b-1 solid var(--select-border-color); + background: var(--select-bg-color); + color: var(--select-fg-color); + appearance: none; +} + +.arrow { + color: var(--select-icon-fg-color); + transform: rotate(90deg); +} + +.select-header { + display: grid; + justify-items: start; + gap: var(--sp-xs); +} + +.header-label { + @include use-typography("body-small"); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + min-width: 0; + padding-inline-start: var(--sp-xxs); + text-align: left; + color: var(--select-fg-color); +} + +.header-icon { + grid-template-columns: auto 1fr; + color: var(--select-icon-fg-color); +} + +.option-list { + --options-dropdown-bg-color: var(--color-background-tertiary); + position: absolute; + right: 0; + top: $sz-36; + width: 100%; + background-color: var(--options-dropdown-bg-color); + border-radius: $br-8; + border: $b-1 solid var(--select-dropdown-border-color); + padding-block: var(--sp-xs); + margin-block-end: 0; + max-height: $sz-400; + overflow-y: auto; + overflow-x: hidden; +} + +.option { + --select-option-fg-color: var(--color-foreground-primary); + --select-option-bg-color: unset; + + &:hover { + --select-option-bg-color: var(--color-background-quaternary); + } + + &[aria-selected="true"] { + --select-option-bg-color: var(--color-background-quaternary); + } + + display: grid; + align-items: center; + justify-items: start; + grid-template-columns: 1fr auto; + gap: var(--sp-xs); + width: 100%; + height: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + outline: $b-1 solid var(--select-outline-color); + outline-offset: -1px; + background-color: var(--select-option-bg-color); +} + +.option-with-icon { + grid-template-columns: auto 1fr auto; +} + +.option-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + min-width: 0; + padding-inline-start: var(--sp-xxs); +} + +.option-icon { + color: var(--select-icon-fg-color); +} + +.option-current { + --select-option-outline-color: var(--color-accent-primary); + outline: $b-1 solid var(--select-option-outline-color); +} diff --git a/frontend/src/app/main/ui/ds/controls/select.stories.jsx b/frontend/src/app/main/ui/ds/controls/select.stories.jsx new file mode 100644 index 000000000..03f488e8e --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/select.stories.jsx @@ -0,0 +1,65 @@ +// 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 + +import * as React from "react"; +import Components from "@target/components"; + +const { Select } = Components; + +export default { + title: "Controls/Select", + component: Select, + argTypes: { + disabled: { control: "boolean" }, + }, + args: { + disabled: false, + options: [ + { + label: "Code", + id: "option-code", + }, + { + label: "Design", + id: "option-design", + }, + { + label: "Menu", + id: "opeion-menu", + }, + ], + defaultSelected: "option-code", + }, + parameters: { + controls: { + exclude: ["options", "defaultSelected"], + }, + }, + render: ({ ...args }) =>