Compare commits

..

41 Commits

Author SHA1 Message Date
Alejandro Alonso
d108ad904e Merge remote-tracking branch 'origin/main' into staging 2024-09-11 12:05:21 +02:00
Alejandro Alonso
6564736d3e 🐛 Fix challenge redirect with parameters 2024-09-11 12:03:32 +02:00
Alejandro Alonso
d01cd70c6b Merge remote-tracking branch 'origin/main' into staging 2024-09-11 11:34:04 +02:00
Alejandro
ea7768117c Merge pull request #5082 from penpot/superalex-fix-challenge-redirect-with-parameters
🐛 Fix challenge redirect with parameters
2024-09-11 11:33:45 +02:00
Alejandro Alonso
5bfb39cdf6 🐛 Fix challenge redirect with parameters 2024-09-11 11:23:19 +02:00
Pablo Alba
29f1c2bdad Merge pull request #5080 from penpot/niwinz-oidc-fix-limits-issues
🐛 Fix oidc auth internal limits issue
2024-09-10 16:41:44 +02:00
Andrey Antukh
e79f9ba40f 🐛 Increase token limit 2024-09-10 12:39:54 +02:00
Andrey Antukh
452aabdec6 🐛 Don't send user props on auth token after oidc login 2024-09-10 12:39:54 +02:00
Aitor Moreno
860e32d965 Merge pull request #5070 from penpot/superalex-fix-onboarding
🐛 Fix onboarding questions
2024-09-09 10:19:03 +02:00
Andrey Antukh
495f9dfa84 Merge pull request #5064 from penpot/superalex-release-notes-2.2
 Release notes for 2.2
2024-09-09 09:54:50 +02:00
Alejandro
133ca33cb5 Merge pull request #5076 from penpot/niwinz-exporter-config-parse
🐛 Fix issues on parsing configuration on exporter
2024-09-09 09:54:12 +02:00
Andrey Antukh
1c69a9fd8a 🐛 Fix config parsing on exporter 2024-09-09 09:47:55 +02:00
Andrey Antukh
15faa57e01 🐛 Fix decoding on sm/set schema 2024-09-09 09:46:50 +02:00
Alejandro
f5510234cf Merge pull request #5066 from penpot/eva-fix-frame-row-menu
🐛 Fix guides submenu visualization
2024-09-09 06:49:05 +02:00
Alejandro Alonso
5b0331611d Merge remote-tracking branch 'origin/main' into staging 2024-09-06 13:50:09 +02:00
Alejandro
f8f1c58f61 Merge pull request #5069 from penpot/niwinz-storybook-build-fix
🐛 Fix storybook build related to commonjs to esm module conversion issue
2024-09-06 13:43:04 +02:00
Andrey Antukh
3f34aa92fa Add support for optional human challenge 2024-09-06 13:39:53 +02:00
Alejandro Alonso
c99102e49b 🐛 Fix onboarding questions 2024-09-06 13:18:39 +02:00
Andrey Antukh
d583661e58 🐛 Fix storybook build related to commonjs to esm module conversion issue 2024-09-06 12:31:48 +02:00
Andrey Antukh
ffa326e08f Merge pull request #5067 from penpot/alotor-fix-validation
🐛 Fix problem when dismissing shared library update
2024-09-06 11:31:22 +02:00
alonso.torres
03040ed40b 🐛 Fix problem when dismissing shared library update 2024-09-06 11:02:02 +02:00
Alejandro Alonso
5e89cd1cb3 Release notes for 2.2 2024-09-06 10:49:20 +02:00
Eva Marco
bf202473e9 🐛 Fix guides submenu visualization 2024-09-06 09:47:09 +02:00
Andrey Antukh
886c0c596f Merge pull request #5060 from penpot/alotor-bugfixes
Bugfixes
2024-09-05 15:32:44 +02:00
alonso.torres
b15b394c65 🐛 Fix problem when creating a component instance from grid layout 2024-09-05 15:11:04 +02:00
alonso.torres
caf78a6b4d 🐛 Fix layer panel overflowing 2024-09-05 11:50:16 +02:00
alonso.torres
6a161267ba 🐛 Fix problem with overlay positions in viewer 2024-09-05 10:48:00 +02:00
alonso.torres
a180c33a32 🐛 Fix problem with SVG import 2024-09-05 09:26:22 +02:00
Alejandro
ea8febdb7d Merge pull request #5056 from penpot/niwinz-refactor-recent-colors
♻️ Refactor recent colors and local storage abstraction
2024-09-05 09:07:26 +02:00
Alejandro
f765cc8dbc Merge pull request #5011 from penpot/palba-testab-start-workspace
A/B test start directly at the workspace
2024-09-05 07:05:57 +02:00
Pablo Alba
81b7972347 🎉 Test A/B for start in workspace 2024-09-04 17:19:39 +02:00
Andrey Antukh
1281670c61 Clear storage on user logout 2024-09-04 16:20:00 +02:00
Andrey Antukh
b8c6103858 Add performance enhancements for util/storage abstraction layer 2024-09-04 16:20:00 +02:00
Andrey Antukh
b2c0bed84c Add efficiency improvements to use-resize-hook 2024-09-04 16:20:00 +02:00
Andrey Antukh
9619fcbc1f Make efficiency improvements to use-shared-state hook 2024-09-04 16:20:00 +02:00
Andrey Antukh
e9c55e9eb4 Make recent colors to be stored locally instead of on file 2024-09-04 16:20:00 +02:00
Andrey Antukh
488d034a58 Merge pull request #5055 from penpot/eva-fix-webhook-checkbox
🐛  Fix webhook checkbox position
2024-09-04 14:15:37 +02:00
Eva Marco
8d66275187 🐛 Fix webhook checkbox position 2024-09-04 12:51:30 +02:00
Alejandro
59063e861c Merge pull request #5053 from penpot/niwinz-update-file-refactor
♻️ Refactor file-update for make it more reusable
2024-09-04 12:30:36 +02:00
Andrey Antukh
9da891e9b0 📎 Enable auto-file-snapshot feature scripts/repl 2024-09-04 12:18:31 +02:00
Andrey Antukh
a6de12323e ♻️ Refactor file-update for make it more reusable 2024-09-04 12:18:31 +02:00
46 changed files with 949 additions and 462 deletions

View File

@@ -71,10 +71,16 @@
### :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)
## 2.1.5

View File

@@ -36,4 +36,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.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/binary-files/Flex-Layout-Playground.penpot"}
{:id "welcome"
:name "Welcome"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/welcome.penpot"}]

View File

@@ -24,7 +24,7 @@ export PENPOT_FLAGS="\
enable-rpc-climit \
enable-rpc-rlimit \
enable-soft-rpc-rlimit \
enable-file-snapshot \
enable-auto-file-snapshot \
enable-webhooks \
enable-access-tokens \
enable-tiered-file-data-storage \

View File

@@ -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)})]

View File

@@ -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
@@ -241,6 +243,7 @@
params (d/without-nils params)
token (tokens/generate (::setup/props cfg) params)]
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
@@ -350,7 +353,7 @@
:extra-data ptoken})))
(defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token fullname theme] :as params}]
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}]
(let [theme (when (= theme "light") theme)
claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
params (-> claims
@@ -380,8 +383,13 @@
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)
@@ -418,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"}
@@ -427,10 +436,12 @@
(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)
@@ -462,7 +473,8 @@
[:map {:title "register-profile"}
[:token schema:token]
[:fullname [::sm/word-string {:max 100}]]
[:theme {:optional true} [:string {:max 10}]]])
[:theme {:optional true} [:string {:max 10}]]
[:create-welcome-file {:optional true} :boolean]])
(sv/defmethod ::register-profile
{::rpc/auth false

View File

@@ -1058,7 +1058,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

View File

@@ -38,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
@@ -97,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.
@@ -147,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)
@@ -161,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))
@@ -177,90 +173,126 @@
{: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}]
"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 (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
(fn []
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(update-file-data! cfg file
process-changes-and-validate
changes skip-validate))))]
file (px/invoke! executor (partial update-file-data cfg file changes skip-validate))
features (db/create-array conn "text" (:features file))]
;; NOTE: if file was offloaded, we need to touch the referenced
;; storage object because on this update operation the data will
;; be overwritted.
(when (= "objects-storage" (:data-backend file))
(when (feat.fdata/offloaded? file)
(let [storage (sto/resolve cfg ::db/reuse-conn true)]
(sto/touch-object! storage (:data-ref-id file))))
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at created-at
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:label (::snapshot-label file)
:data (::snapshot-data file)
:features (db/create-array conn "text" (:features file))
:changes (blob/encode changes)}
{::db/return-keys false})
(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)
@@ -268,20 +300,95 @@
:features features
:data-backend nil
:data-ref-id nil
:modified-at created-at
: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]
@@ -298,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)
@@ -376,15 +434,7 @@
(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."
@@ -426,8 +476,7 @@
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

View File

@@ -396,8 +396,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
@@ -416,6 +416,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))))
@@ -437,7 +438,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

View File

@@ -360,27 +360,31 @@
[:map {:title "update-profile-props"}
[:props [:map-of :keyword :any]]]))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::sql/for-update true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id})
(filter-props props)))
(sv/defmethod ::update-profile-props
{::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

View File

@@ -30,7 +30,7 @@
(def ^:private schema:verify-token
[:map {:title "verify-token"}
[:token [:string {:max 1000}]]])
[:token [:string {:max 5000}]]])
(sv/defmethod ::verify-token
{::rpc/auth false
@@ -82,16 +82,8 @@
(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))))
(let [profile (profile/get-profile conn profile-id)]
(assoc claims :profile profile)))
;; --- Team Invitation

View File

@@ -0,0 +1,64 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.setup.welcome-file
(:require
[app.common.logging :as l]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files-update :as fupdate]
[app.rpc.commands.management :as management]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.setup :as-alias setup]
[app.setup.templates :as tmpl]
[app.worker :as-alias wrk]))
(def ^:private page-id #uuid "2c6952ee-d00e-8160-8004-d2250b7210cb")
(def ^:private shape-id #uuid "765e9f82-c44e-802e-8004-d72a10b7b445")
(def ^:private update-path
[:data :pages-index page-id :objects shape-id
:content :children 0 :children 0 :children 0])
(def ^:private sql:mark-file-object-thumbnails-deleted
"UPDATE file_tagged_object_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(def ^:private sql:mark-file-thumbnail-deleted
"UPDATE file_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(defn- update-welcome-shape
[_ file name]
(let [text (str "Welcome to Penpot, " name "!")]
(-> file
(update-in update-path assoc :text text)
(update-in [:data :pages-index page-id :objects shape-id] assoc :name "Welcome to Penpot!")
(update-in [:data :pages-index page-id :objects shape-id] dissoc :position-data))))
(defn create-welcome-file
[cfg {:keys [id fullname] :as profile}]
(try
(let [cfg (dissoc cfg ::db/conn)
params {:profile-id (:id profile)
:project-id (:default-project-id profile)}
template-stream (tmpl/get-template-stream cfg "welcome")
file-id (-> (management/clone-template cfg params template-stream)
first)]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(fupdate/update-file! cfg file-id update-welcome-shape fullname)
(profile/update-profile-props cfg id {:welcome-file-id file-id})
(db/exec-one! conn [sql:mark-file-object-thumbnails-deleted file-id])
(db/exec-one! conn [sql:mark-file-thumbnail-deleted file-id]))))
(catch Throwable cause
(l/error :hint "unexpected error on create welcome file " :cause cause))))

View File

@@ -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))

View File

@@ -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!)

View File

@@ -190,10 +190,9 @@
[: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"}
@@ -656,18 +655,10 @@
[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

View File

@@ -607,13 +607,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

View File

@@ -476,7 +476,7 @@
::oapi/type "string"
::oapi/format "email"}})
(def non-empty-strings-xf
(def xf:filter-word-strings
(comp
(filter string?)
(remove str/empty?)
@@ -489,11 +489,8 @@
: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)
(fn [{:keys [kind max min] :as props} children _]
(let [kind (or (last children) kind)
pred
(cond
@@ -501,9 +498,6 @@
(nil? kind) any?
:else (validator kind))
encode-child
(encoder kind string-transformer)
pred
(cond
(and max min)
@@ -531,31 +525,49 @@
(fn [value]
(every? pred value)))
decode
decode-string-child
(decoder kind string-transformer)
decode-string
(fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)]
(into #{} xform 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))
encode-string
(fn [o]
(if (set? o)
(str/join ", " (map encode-child o))
o))]
{:pred pred
:type-properties
{:title "set"
:description "Set of Strings"
:error/message "should be a set of strings"
:gen/gen (-> kind sg/generator sg/set)
:decode/string decode
:decode/json decode
:decode/string decode-string
:decode/json decode-json
:encode/string encode-string
:encode/json encode-json
::oapi/type "array"
@@ -569,21 +581,14 @@
: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)
(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))
encode-child
(encoder kind string-transformer)
pred
(cond
(and max min)
@@ -611,15 +616,31 @@
(fn [value]
(every? pred value)))
decode
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)]
(into [] xform 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-child o))
(str/join ", " (map encode-string-child o))
o))]
{:pred pred
@@ -628,8 +649,8 @@
:description "Set of Strings"
:error/message "should be a set of strings"
:gen/gen (-> kind sg/generator sg/set)
:decode/string decode
:decode/json decode
:decode/string decode-string
:decode/json decode-json
:encode/string encode-string
::oapi/type "array"
::oapi/format "set"
@@ -646,7 +667,7 @@
:gen/gen (-> :string sg/generator sg/set)
:decode/string (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)]
(into #{} non-empty-strings-xf v)))
(into #{} xf:filter-word-strings v)))
::oapi/type "array"
::oapi/format "set"
::oapi/items {:type "string"}
@@ -662,7 +683,7 @@
:gen/gen (-> :keyword sg/generator sg/set)
:decode/string (fn [v]
(let [v (if (string? v) (str/split v #"[\s,]+") v)]
(into #{} (comp non-empty-strings-xf (map keyword)) v)))
(into #{} (comp xf:filter-word-strings (map keyword)) v)))
::oapi/type "array"
::oapi/format "set"
::oapi/items {:type "string" :format "keyword"}

View File

@@ -107,17 +107,16 @@
[::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)
(def check-color!
(sm/check-fn schema:color))
(def valid-color?
(sm/lazy-validator schema:color))
(def check-recent-color!
(sm/check-fn schema:recent-color))
(def valid-recent-color?
(sm/lazy-validator schema:recent-color))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
@@ -392,13 +391,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]

View File

@@ -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

View File

@@ -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))

View File

@@ -96,6 +96,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",

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Penpot - Challenge</title>
<link rel="icon" href="images/favicon.png" />
<script>
var params = new URL(document.location.toString()).searchParams;
var redirectPath = params.get("redirect");
setTimeout(() => {
location.href = "/#" + redirectPath;
}, 100);
</script>
</head>
<body>
</body>
</html>

View File

@@ -415,6 +415,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",
{

View File

@@ -21,10 +21,12 @@
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[app.util.storage :refer [storage]]
[app.util.storage :as s]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(declare update-profile-props)
;; --- SCHEMAS
(def ^:private
@@ -49,14 +51,14 @@
(defn get-current-team-id
[profile]
(let [team-id (::current-team-id @storage)]
(let [team-id (::current-team-id @s/storage)]
(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! s/storage dissoc ::current-team-id)
(swap! s/storage 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 @s/storage)]
(when-not (contains? ids ctid)
(swap! storage dissoc ::current-team-id)))))))
(swap! s/storage dissoc ::current-team-id)))))))
(defn fetch-teams
[]
@@ -129,21 +131,34 @@
(effect [_ state _]
(let [profile (:profile state)
email (:email profile)
previous-profile (:profile @storage)
previous-profile (:profile @s/storage)
previous-email (:email previous-profile)]
(when profile
(swap! storage assoc :profile profile)
(swap! s/storage assoc :profile profile)
(i18n/set-locale! (:lang profile))
(when (not= previous-email email)
(set-current-team! nil)))))))
(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
(str "/challenge.html?redirect=")
(js/encodeURIComponent))]
(rx/of (rt/nav-raw href)))
(rx/throw cause))))
(defn fetch-profile
[]
(ptk/reify ::fetch-profile
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :get-profile)
(rx/map profile-fetched)))))
(rx/map profile-fetched)
(rx/catch on-fetch-profile-exception)))))
;; --- EVENT: login
@@ -152,9 +167,15 @@
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)]
(rt/nav' :dashboard-projects {:team-id team-id})))]
(letfn [(get-redirect-events []
(let [team-id (get-current-team-id profile)
welcome-file-id (get-in profile [:props :welcome-file-id])]
(if (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}))
(rx/of (rt/nav' :dashboard-projects {:team-id team-id})))))]
(ptk/reify ::logged-in
ev/Event
@@ -171,10 +192,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)
@@ -311,7 +333,7 @@
ptk/EffectEvent
(effect [_ _ _]
;; We prefer to keek some stuff in the storage like the current-team-id and the profile
(set-current-team! nil)))))
(swap! s/storage (constantly {}))))))
(defn logout
([] (logout {}))

View File

@@ -79,6 +79,7 @@
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[app.util.storage :refer [storage]]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@@ -335,6 +336,7 @@
ptk/UpdateEvent
(update [_ state]
(assoc state
:recent-colors (:recent-colors @storage)
:workspace-ready? false
:current-file-id file-id
:current-project-id project-id

View File

@@ -48,6 +48,7 @@
[app.util.color :as uc]
[app.util.i18n :refer [tr]]
[app.util.router :as rt]
[app.util.storage :as s]
[app.util.time :as dt]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@@ -132,16 +133,21 @@
(defn add-recent-color
[color]
(dm/assert!
"expected valid recent color map"
(ctc/check-recent-color! color))
(ctc/valid-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! s/storage assoc :recent-colors recent-colors)))))
(def clear-color-for-rename
(ptk/reify ::clear-color-for-rename
@@ -168,8 +174,11 @@
(dm/assert!
"expected valid parameters"
(and (ctc/check-color! color)
(uuid? file-id)))
(ctc/valid-color? color))
(dm/assert!
"expected file-id"
(uuid? file-id))
(ptk/reify ::update-color
ptk/WatchEvent

View File

@@ -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)

View File

@@ -236,9 +236,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]

View File

@@ -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

View File

@@ -44,7 +44,30 @@
(mf/defc main-page
{::mf/props :obj}
[{: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
@@ -84,42 +107,19 @@
#_[:& 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
@@ -154,6 +154,20 @@
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

View File

@@ -39,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
@@ -176,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
@@ -208,7 +211,13 @@
(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)))))]

View File

@@ -519,8 +519,10 @@
@include bodySmallTypography;
color: var(--modal-title-foreground-color);
}
.custom-input-checkbox {
// TODO: This fix is temporary, the error is caused by the
// cascading order of the compiled css files.
// https://tree.taiga.io/project/penpot/task/8658
.custom-input-checkbox.custom-input-checkbox {
align-items: flex-start;
}

View File

@@ -168,7 +168,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])

View File

@@ -294,19 +294,21 @@
`key` for new values."
[key default]
(let [id (mf/use-id)
state (mf/use-state (get @storage key default))
state* (mf/use-state #(get @storage key default))
state (deref state*)
stream (mf/with-memo [id]
(->> mbc/stream
(rx/filter #(not= (:id %) id))
(rx/filter #(= (:type %) key))
(rx/map deref)))]
(mf/with-effect [@state key id]
(mbc/emit! id key @state)
(swap! storage assoc key @state))
(mf/with-effect [state key id]
(mbc/emit! id key state)
(swap! storage assoc key state))
(use-stream stream (partial reset! state))
state))
(use-stream stream (partial reset! state*))
state*))
(defonce ^:private intersection-subject (rx/subject))
(defonce ^:private intersection-observer

View File

@@ -6,6 +6,7 @@
(ns app.main.ui.hooks.resize
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.logging :as log]
@@ -20,6 +21,15 @@
(def last-resize-type nil)
(defn- get-initial-state
[initial file-id key]
(let [saved (dm/get-in @storage [::state file-id key])]
(d/nilv saved initial)))
(defn- update-persistent-state
[data file-id key size]
(update-in data [::state file-id] assoc key size))
(defn set-resize-type! [type]
(set! last-resize-type type))
@@ -28,26 +38,28 @@
(use-resize-hook key initial min-val max-val axis negate? resize-type nil))
([key initial min-val max-val axis negate? resize-type on-change-size]
(let [current-file-id (mf/use-ctx ctx/current-file-id)
size-state (mf/use-state (or (get-in @storage [::saved-resize current-file-id key]) initial))
parent-ref (mf/use-ref nil)
(let [file-id (mf/use-ctx ctx/current-file-id)
dragging-ref (mf/use-ref false)
current-size* (mf/use-state #(get-initial-state initial file-id key))
current-size (deref current-size*)
parent-ref (mf/use-ref nil)
dragging-ref (mf/use-ref false)
start-size-ref (mf/use-ref nil)
start-ref (mf/use-ref nil)
start-ref (mf/use-ref nil)
on-pointer-down
(mf/use-callback
(mf/deps @size-state)
(mf/use-fn
(mf/deps current-size)
(fn [event]
(dom/capture-pointer event)
(mf/set-ref-val! start-size-ref @size-state)
(mf/set-ref-val! start-size-ref current-size)
(mf/set-ref-val! dragging-ref true)
(mf/set-ref-val! start-ref (dom/get-client-position event))
(set! last-resize-type resize-type)))
on-lost-pointer-capture
(mf/use-callback
(mf/use-fn
(fn [event]
(dom/release-pointer event)
(mf/set-ref-val! start-size-ref nil)
@@ -56,40 +68,39 @@
(set! last-resize-type nil)))
on-pointer-move
(mf/use-callback
(mf/deps min-val max-val negate?)
(mf/use-fn
(mf/deps min-val max-val negate? file-id key)
(fn [event]
(when (mf/ref-val dragging-ref)
(let [start (mf/ref-val start-ref)
pos (dom/get-client-position event)
pos (dom/get-client-position event)
delta (-> (gpt/to-vec start pos)
(cond-> negate? gpt/negate)
(get axis))
start-size (mf/ref-val start-size-ref)
new-size (-> (+ start-size delta) (max min-val) (min max-val))]
(reset! size-state new-size)
(swap! storage assoc-in [::saved-resize current-file-id key] new-size)
(when on-change-size (on-change-size new-size))))))
(reset! current-size* new-size)
(swap! storage update-persistent-state file-id key new-size)))))
set-size
(mf/use-callback
(mf/deps on-change-size)
(mf/use-fn
(mf/deps on-change-size file-id key)
(fn [new-size]
(let [new-size (mth/clamp new-size min-val max-val)]
(reset! size-state new-size)
(swap! storage assoc-in [::saved-resize current-file-id key] new-size)
(when on-change-size (on-change-size new-size)))))]
(reset! current-size* new-size)
(swap! storage update-persistent-state file-id key new-size))))]
(mf/use-effect
(fn []
(when on-change-size (on-change-size @size-state))))
(mf/with-effect [on-change-size current-size]
(when on-change-size
(on-change-size current-size)))
{:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:parent-ref parent-ref
:set-size set-size
:size @size-state})))
:size current-size})))
(defn use-resize-observer
[callback]

View File

@@ -217,9 +217,9 @@
[:team-size
[:enum "more-than-50" "31-50" "11-30" "2-10" "freelancer" "personal-project"]]
[:role
[:enum "designer" "developer" "student-teacher" "graphic-design" "marketing" "manager" "other"]]
[:enum "ux" "developer" "student-teacher" "designer" "marketing" "manager" "other"]]
[:responsability
[:enum "team-leader" "team-member" "freelancer" "ceo-founder" "director" "student-teacher" "other"]]
[:enum "team-leader" "team-member" "freelancer" "ceo-founder" "director" "other"]]
[:role-other {:optional true} [::sm/text {:max 512}]]
[:responsability-other {:optional true} [::sm/text {:max 512}]]]

View File

@@ -66,7 +66,7 @@
(mf/defc team-form-step-2
{::mf/props :obj}
[{:keys [name on-back]}]
[{:keys [name on-back go-to-team?]}]
(let [initial (mf/use-memo
#(do {:role "editor"
:name name}))
@@ -85,7 +85,8 @@
(let [team-id (:id response)]
(st/emit! (du/update-profile-props {:onboarding-team-id team-id
:onboarding-viewed true})
(rt/nav :dashboard-projects {:team-id team-id})))))
(when go-to-team?
(rt/nav :dashboard-projects {:team-id team-id}))))))
on-error
(mf/use-fn
@@ -240,7 +241,7 @@
(mf/defc onboarding-team-modal
{::mf/props :obj}
[]
[{:keys [go-to-team?]}]
(let [name* (mf/use-state nil)
name (deref name*)
@@ -262,6 +263,6 @@
[:& left-sidebar]
[:div {:class (stl/css :separator)}]
(if name
[:& team-form-step-2 {:name name :on-back on-back}]
[:& team-form-step-2 {:name name :on-back on-back :go-to-team? go-to-team?}]
[:& team-form-step-1 {:on-submit on-submit}])]]))

View File

@@ -28,6 +28,7 @@
[app.main.ui.releases.v1-9]
[app.main.ui.releases.v2-0]
[app.main.ui.releases.v2-1]
[app.main.ui.releases.v2-2]
[app.util.object :as obj]
[app.util.timers :as tm]
[rumext.v2 :as mf]))
@@ -92,4 +93,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.1")))
(rc/render-release-notes (assoc params :version "2.2")))

View File

@@ -0,0 +1,51 @@
;; 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.releases.v2-2
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.2"
[{:keys [slide klass finish version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.0-intro-image.png"
:class (stl/css :start-image)
:border "0"
:alt "A graphic illustration with Penpot style"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"What's new in Penpot? "]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:p {:class (stl/css :feature-content)}
"This Penpot 2.2 release focuses on internal changes that are laying out the ground for the upcoming plugin system and substantial performance improvements."]
[:p {:class (stl/css :feature-content)}
"This version also adds full JSON API interoperability and the brand-new Penpots Storybook!"]
[:p {:class (stl/css :feature-content)}
"Self-hosted Penpot installations will benefit from better file data storage and Penpot admins can now use the improved automatic snapshotting process when recovering old files."]
[:p {:class (stl/css :feature-content)}
"Thanks again to our awesome community for their amazing contributions to this release!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click finish} "Let's go"]]]]]])))

View File

@@ -0,0 +1,79 @@
// 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";
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: $s-324 1fr;
height: $s-480;
width: $s-888;
border-radius: $br-8;
background-color: var(--modal-background-color);
border: $s-2 solid var(--modal-border-color);
}
.start-image {
width: $s-324;
border-radius: $br-8 0 0 $br-8;
}
.modal-content {
padding: $s-40;
display: grid;
grid-template-rows: auto 1fr $s-32;
gap: $s-24;
}
.modal-header {
display: grid;
gap: $s-8;
}
.version-tag {
@include flexCenter;
@include headlineSmallTypography;
height: $s-32;
width: $s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: $br-8;
}
.modal-title {
@include headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: $s-16;
width: $s-440;
}
.feature-content {
@include bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: $s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -427,7 +427,8 @@
(let [childs (mapv #(get objects %) (:shapes (unchecked-get props "shape")))
props (obj/merge! #js {} props
#js {:childs childs
:objects objects})]
:objects objects
:all-objects all-objects})]
(when (not-empty childs)
[:> group-wrapper props])))))

View File

@@ -199,7 +199,6 @@
.height {
@extend .input-element;
@include bodySmallTypography;
width: $s-108;
.icon-text {
padding-top: $s-1;
}
@@ -208,7 +207,6 @@
.margin {
@extend .input-element;
@include bodySmallTypography;
width: $s-108;
.icon {
&.rotated svg {
transform: rotate(90deg);

View File

@@ -55,6 +55,7 @@
overflow-x: hidden;
overflow-y: overlay;
scrollbar-gutter: stable;
max-width: var(--width);
}
.pages-list {

View File

@@ -0,0 +1,29 @@
;; 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.util.functions
"A functions helpers"
(:require
["lodash/debounce.js" :as lodash-debounce]))
;; NOTE: this is needed because depending on the type of the build and
;; target execution evironment (browser, esm), the real export can be
;; different. All this issue is about the commonjs and esm
;; interop/conversion, because the js ecosystem decided that is should
;; work this way.
;;
;; In this concrete case, lodash exposes commonjs module which works
;; ok on browser build but for ESM build it is converted in the best
;; effort to esm module, exporting the module.exports as the default
;; property. This is why on ESM builds we need to look on .-default
;; property.
(def ^:private ext-debounce
(or (.-default lodash-debounce)
lodash-debounce))
(defn debounce
[f timeout]
(ext-debounce f timeout #{:leading false :trailing true}))

View File

@@ -16,6 +16,7 @@
[app.util.globals :as globals]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as e]
[potok.v2.core :as ptk]
[reitit.core :as r]))
@@ -149,6 +150,20 @@
[]
(set! (.-href globals/location) "/"))
(defn nav-raw
[href]
(ptk/reify ::nav-raw
ptk/EffectEvent
(effect [_ _ _]
(set! (.-href globals/location) href))))
(defn get-current-path
[]
(let [hash (.-hash globals/location)]
(if (str/starts-with? hash "#")
(subs hash 1)
hash)))
;; --- History API
(defn initialize-history

View File

@@ -8,40 +8,77 @@
(:require
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.util.functions :as fns]
[app.util.globals :as g]
[app.util.timers :as tm]))
[cuerdas.core :as str]))
(defn- persist
[storage prev curr]
(run! (fn [key]
(let [prev* (get prev key)
curr* (get curr key)]
(when (not= curr* prev*)
(tm/schedule-on-idle
#(if (some? curr*)
(.setItem ^js storage (t/encode-str key) (t/encode-str curr*))
(.removeItem ^js storage (t/encode-str key)))))))
;; Using ex/ignoring because can receive a DOMException like this when
;; importing the code as a library: Failed to read the 'localStorage'
;; property from 'Window': Storage is disabled inside 'data:' URLs.
(defonce ^:private local-storage
(ex/ignoring (unchecked-get g/global "localStorage")))
(into #{} (concat (keys curr)
(keys prev)))))
(defn- encode-key
[k]
(assert (keyword? k) "key must be keyword")
(let [kns (namespace k)
kn (name k)]
(str "penpot:" kns "/" kn)))
(defn- decode-key
[k]
(when (str/starts-with? k "penpot:")
(let [k (subs k 7)]
(if (str/starts-with? k "/")
(keyword (subs k 1))
(let [[kns kn] (str/split k "/" 2)]
(keyword kns kn))))))
(defn- lookup-by-index
[result index]
(try
(let [key (.key ^js local-storage index)
key' (decode-key key)]
(if key'
(let [val (.getItem ^js local-storage key)]
(assoc! result key' (t/decode-str val)))
result))
(catch :default _
result)))
(defn- load
[storage]
(when storage
(let [len (.-length ^js storage)]
(reduce (fn [res index]
(let [key (.key ^js storage index)
val (.getItem ^js storage key)]
(try
(assoc res (t/decode-str key) (t/decode-str val))
(catch :default _e
res))))
{}
(range len)))))
[]
(when (some? local-storage)
(let [length (.-length ^js local-storage)]
(loop [index 0
result (transient {})]
(if (< index length)
(recur (inc index)
(lookup-by-index result index))
(persistent! result))))))
;; Using ex/ignoring because can receive a DOMException like this when importing the code as a library:
;; Failed to read the 'localStorage' property from 'Window': Storage is disabled inside 'data:' URLs.
(defonce storage (atom (load (ex/ignoring (unchecked-get g/global "localStorage")))))
(defonce ^:private latest-state (load))
(add-watch storage :persistence #(persist js/localStorage %3 %4))
(defn- on-change*
[curr-state]
(let [prev-state latest-state]
(try
(run! (fn [key]
(let [prev-val (get prev-state key)
curr-val (get curr-state key)]
(when-not (identical? curr-val prev-val)
(if (some? curr-val)
(.setItem ^js local-storage (encode-key key) (t/encode-str curr-val))
(.removeItem ^js local-storage (encode-key key))))))
(into #{} (concat (keys curr-state)
(keys prev-state))))
(finally
(set! latest-state curr-state)))))
(defonce on-change
(fns/debounce on-change* 2000))
(defonce storage (atom latest-state))
(add-watch storage :persistence
(fn [_ _ _ curr-state]
(on-change curr-state)))

View File

@@ -6647,6 +6647,7 @@ __metadata:
js-beautify: "npm:^1.15.1"
jsdom: "npm:^24.1.0"
jszip: "npm:^3.10.1"
lodash: "npm:^4.17.21"
luxon: "npm:^3.4.4"
map-stream: "npm:0.0.7"
marked: "npm:^12.0.2"