Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca98ddf21 | ||
|
|
15157c54b1 | ||
|
|
232b29cd89 | ||
|
|
da0704081f | ||
|
|
066b1235a6 | ||
|
|
141694dc8d | ||
|
|
151aedcf91 | ||
|
|
5513daf17d | ||
|
|
fde0f3c182 | ||
|
|
7b408e4db1 | ||
|
|
b8fd829f9d | ||
|
|
089a66881c | ||
|
|
667b5fb6ee | ||
|
|
f0f89151c5 | ||
|
|
1221d60357 | ||
|
|
f553fa10d8 | ||
|
|
96947b0219 | ||
|
|
e2900d9012 | ||
|
|
1f0e470419 | ||
|
|
079a945c2f | ||
|
|
542d709541 | ||
|
|
4f1d5a19e4 | ||
|
|
91b0c47244 | ||
|
|
a7a49e4b39 | ||
|
|
ba81b2b14d | ||
|
|
423c237d42 | ||
|
|
5a55884b9f | ||
|
|
38fd343c53 | ||
|
|
94976aa2b1 | ||
|
|
5247d217ab | ||
|
|
40693e6857 | ||
|
|
5c428b5aa5 | ||
|
|
e92ddee33a | ||
|
|
c121f459ba | ||
|
|
698a258290 | ||
|
|
aa023d847d | ||
|
|
53f57dad0b | ||
|
|
d7d7535ab4 | ||
|
|
accc662e1c | ||
|
|
1efc1516e2 | ||
|
|
b5d731ca72 | ||
|
|
e380289e34 | ||
|
|
b22323a484 | ||
|
|
58dd23f9c7 | ||
|
|
54e7551d56 | ||
|
|
404297f837 | ||
|
|
33f853ff2e | ||
|
|
d16513be9d | ||
|
|
ad077696b0 | ||
|
|
1cbeafe85c | ||
|
|
76c8523f44 | ||
|
|
f277d8b125 | ||
|
|
60af8d0bcb | ||
|
|
d652ed8e68 | ||
|
|
09d73a2f51 | ||
|
|
7d4535ebd4 | ||
|
|
a5a53219bf | ||
|
|
8716f81765 | ||
|
|
7aa46a1f62 | ||
|
|
d62eb3d3f4 | ||
|
|
e4c427609d | ||
|
|
883a26845a | ||
|
|
bcdf5d86ae | ||
|
|
3eab9da74e | ||
|
|
2813fda136 | ||
|
|
a0022a804b | ||
|
|
068acb4303 | ||
|
|
d6f98a6c79 | ||
|
|
7b6c2da6da | ||
|
|
affed049ee | ||
|
|
377f636b8e | ||
|
|
2d512ef273 | ||
|
|
b8ebbe8c3c | ||
|
|
09c184200d | ||
|
|
bbe0b22a8b | ||
|
|
8603085a69 | ||
|
|
29ec44482d | ||
|
|
fd4d4ec6e3 | ||
|
|
0945dd2920 | ||
|
|
664cacbe9d | ||
|
|
08516ac7ca | ||
|
|
24e51eef5b | ||
|
|
ee62016c34 | ||
|
|
7c10f20b95 | ||
|
|
4958da63e5 | ||
|
|
f39a994fed | ||
|
|
5ef59d5e2e | ||
|
|
98221c6b51 | ||
|
|
74713cde63 | ||
|
|
a5084c35b5 | ||
|
|
cdce1df919 | ||
|
|
c75b886548 | ||
|
|
abd41e825e | ||
|
|
97b9a7d31c | ||
|
|
dbeebf181f | ||
|
|
0eec09acbf | ||
|
|
81e250e27d | ||
|
|
9f1f8cc80c | ||
|
|
2440c81b42 | ||
|
|
ada078abab | ||
|
|
31319a0d04 | ||
|
|
b9cb415507 | ||
|
|
36121d862d | ||
|
|
d8964a69bc | ||
|
|
39da7d7ab6 | ||
|
|
1bb25bb89d | ||
|
|
b0a3f2b72a | ||
|
|
f2f3d9f7eb | ||
|
|
cf72b35e73 | ||
|
|
fe8d9cf159 | ||
|
|
4cfe33bc5c | ||
|
|
e5d8bc91fb | ||
|
|
ce1ba3f28f | ||
|
|
257d72ee9d | ||
|
|
0766b341bd | ||
|
|
4ef631fd6a | ||
|
|
a923d39603 | ||
|
|
2f79d71262 | ||
|
|
4881bf3619 | ||
|
|
69df69c4bb | ||
|
|
2c36a4076f | ||
|
|
3ac6f59b7b | ||
|
|
c68a0d3967 | ||
|
|
aeb1ac41da | ||
|
|
b58830260c | ||
|
|
4114d9b56f | ||
|
|
553b9eb4bb | ||
|
|
12e97c73f3 | ||
|
|
bd1286aace | ||
|
|
630f42f7ac | ||
|
|
bf40cd98e8 | ||
|
|
ec7f8a6aa7 | ||
|
|
4d3192546c | ||
|
|
2184926bbb | ||
|
|
fffc3b1b58 | ||
|
|
e0cc999345 | ||
|
|
8e836f79fb | ||
|
|
093a58b9ec | ||
|
|
5e2b847202 | ||
|
|
ef207cfe70 | ||
|
|
f72c37a198 | ||
|
|
dbf3d0d7c1 | ||
|
|
903c8c021d | ||
|
|
9b8ef0a2e5 | ||
|
|
22e64c1c81 | ||
|
|
14c917d003 | ||
|
|
4377a0dcc4 | ||
|
|
db6ca6f905 | ||
|
|
ede8ee6a78 | ||
|
|
37b50497f3 | ||
|
|
e3d2b99acc | ||
|
|
7101b94557 | ||
|
|
5ffab1953d | ||
|
|
75e7cfb69e | ||
|
|
65b7e5c3a5 | ||
|
|
30a06249ff | ||
|
|
59ca09c24e | ||
|
|
1aeafdfca7 | ||
|
|
a714085523 | ||
|
|
eccc4226c7 | ||
|
|
4d6d85b3de | ||
|
|
c607b61af6 | ||
|
|
e16ec9c719 | ||
|
|
59e5656bd7 | ||
|
|
723eef9565 | ||
|
|
8448036d67 | ||
|
|
e1c9691567 | ||
|
|
577b731b22 | ||
|
|
ef3588d05f | ||
|
|
d4893523bc | ||
|
|
f10792619d | ||
|
|
7a0702650a | ||
|
|
ee1230c488 | ||
|
|
ede1176606 | ||
|
|
9506606e15 | ||
|
|
7e5f93ca3d | ||
|
|
6655563aba | ||
|
|
8ee9b45243 | ||
|
|
caa6897f81 | ||
|
|
660bc1a4dd | ||
|
|
3ddd45e99b | ||
|
|
9b71e04e1c | ||
|
|
39620fe9c4 | ||
|
|
db7c1fc7dd | ||
|
|
c89abf56ac | ||
|
|
d22f6e37c9 | ||
|
|
c1d3e4cd6e | ||
|
|
2164593757 | ||
|
|
53f55444cd | ||
|
|
5cc678ddc3 | ||
|
|
64c8741233 | ||
|
|
0c586551c4 |
25
CHANGES.md
@@ -1,5 +1,22 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.4.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix detach when top copy is dangling and nested copy is not [Taiga #9699](https://tree.taiga.io/project/penpot/issue/9699)
|
||||
- Fix problem in plugins with `replaceColor` method [#174](https://github.com/penpot/penpot-plugins/issues/174)
|
||||
- Fix issue with recursive commponents [Taiga #9903](https://tree.taiga.io/project/penpot/issue/9903)
|
||||
- Fix missing methods reference on API Docs
|
||||
- Fix memory usage issue on file-gc asynchronous task (related to snapshots feature)
|
||||
|
||||
## 2.4.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix error when importing files with touched components [Taiga #9625](https://tree.taiga.io/project/penpot/issue/9625)
|
||||
- Fix problem when changing color libraries [Plugins #184](https://github.com/penpot/penpot-plugins/issues/184)
|
||||
|
||||
## 2.4.0
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
@@ -23,12 +40,16 @@
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590)
|
||||
- File history versions management [Taiga](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
|
||||
- Viewer role for team members [Taiga #1056](https://tree.taiga.io/project/penpot/us/1056) & [Taiga #6590](https://tree.taiga.io/project/penpot/us/6590)
|
||||
- File history versions management [Taiga #187](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
|
||||
- Rename selected layer via keyboard shortcut and context menu option [Taiga #8882](https://tree.taiga.io/project/penpot/us/8882)
|
||||
- New .penpot file format [Taiga #8657](https://tree.taiga.io/project/penpot/us/8657)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with some texts desynchronization [Taiga #9379](https://tree.taiga.io/project/penpot/issue/9379)
|
||||
- Fix problem with reoder grid layers [#5446](https://github.com/penpot/penpot/issues/5446)
|
||||
- Fix problem with swap component style [#9542](https://tree.taiga.io/project/penpot/issue/9542)
|
||||
|
||||
## 2.3.3
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
(:require
|
||||
[buddy.hashers :as hashers]))
|
||||
|
||||
(def default-params
|
||||
(def ^:private default-options
|
||||
{:alg :argon2id
|
||||
:memory 32768 ;; 32 MiB
|
||||
:iterations 3
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password default-params))
|
||||
(hashers/derive password default-options))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
(try
|
||||
(hashers/verify attempt password)
|
||||
(hashers/verify attempt password default-options)
|
||||
(catch Throwable _
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
:rpc-rlimit-config "resources/rlimit.edn"
|
||||
:rpc-climit-config "resources/climit.edn"
|
||||
|
||||
:auto-file-snapshot-total 10
|
||||
:auto-file-snapshot-every 5
|
||||
:auto-file-snapshot-timeout "3h"
|
||||
|
||||
@@ -101,7 +100,6 @@
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE
|
||||
|
||||
[:auto-file-snapshot-total {:optional true} ::sm/int]
|
||||
[:auto-file-snapshot-every {:optional true} ::sm/int]
|
||||
[:auto-file-snapshot-timeout {:optional true} ::dt/duration]
|
||||
|
||||
|
||||
@@ -270,19 +270,17 @@
|
||||
:else (throw (IllegalArgumentException. "unable to resolve connectable"))))
|
||||
|
||||
(def ^:private params-mapping
|
||||
{::return-keys? :return-keys
|
||||
::return-keys :return-keys})
|
||||
{::return-keys :return-keys})
|
||||
|
||||
(defn rename-opts
|
||||
[opts]
|
||||
(set/rename-keys opts params-mapping))
|
||||
|
||||
(def ^:private default-insert-opts
|
||||
{:builder-fn sql/as-kebab-maps
|
||||
:return-keys true})
|
||||
(assoc sql/default-opts :return-keys true))
|
||||
|
||||
(def ^:private default-opts
|
||||
{:builder-fn sql/as-kebab-maps})
|
||||
sql/default-opts)
|
||||
|
||||
(defn exec!
|
||||
([ds sv] (exec! ds sv nil))
|
||||
@@ -333,7 +331,7 @@
|
||||
(defn update!
|
||||
"A helper that build an UPDATE SQL statement and executes it.
|
||||
|
||||
Given a connectable object, a table name, a hash map of columns and
|
||||
Given a connectable object, a table name, a hash map of columns and
|
||||
values to set, and either a hash map of columns and values to search
|
||||
on or a vector of a SQL where clause and parameters, perform an
|
||||
update on the table.
|
||||
@@ -413,10 +411,20 @@
|
||||
:hint "database object not found"))
|
||||
row))
|
||||
|
||||
(def ^:private default-plan-opts
|
||||
(-> default-opts
|
||||
(assoc :fetch-size 1)
|
||||
(assoc :concurrency :read-only)
|
||||
(assoc :cursors :close)
|
||||
(assoc :result-type :forward-only)))
|
||||
|
||||
(defn plan
|
||||
[ds sql]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql sql/default-opts)))
|
||||
([ds sql]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql default-plan-opts)))
|
||||
([ds sql opts]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql (merge default-plan-opts opts)))))
|
||||
|
||||
(defn cursor
|
||||
"Return a lazy seq of rows using server side cursors"
|
||||
|
||||
@@ -15,14 +15,15 @@
|
||||
(defn kebab-case [s] (str/replace s #"_" "-"))
|
||||
(defn snake-case [s] (str/replace s #"-" "_"))
|
||||
|
||||
(def default-opts
|
||||
{:table-fn snake-case
|
||||
:column-fn snake-case})
|
||||
|
||||
(defn as-kebab-maps
|
||||
[rs opts]
|
||||
(jdbc-opt/as-unqualified-modified-maps rs (assoc opts :label-fn kebab-case)))
|
||||
|
||||
(def default-opts
|
||||
{:table-fn snake-case
|
||||
:column-fn snake-case
|
||||
:builder-fn as-kebab-maps})
|
||||
|
||||
(defn insert
|
||||
([table key-map]
|
||||
(insert table key-map nil))
|
||||
|
||||
@@ -226,8 +226,8 @@
|
||||
[:priority {:optional true} [:enum :high :low]]
|
||||
[:extra-data {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private valid-context?
|
||||
(sm/validator schema:context))
|
||||
(def ^:private check-context
|
||||
(sm/check-fn schema:context))
|
||||
|
||||
(defn template-factory
|
||||
[& {:keys [id schema]}]
|
||||
@@ -236,10 +236,8 @@
|
||||
(sm/check-fn schema)
|
||||
(constantly nil))]
|
||||
(fn [context]
|
||||
(assert (valid-context? context) "expected a valid context")
|
||||
(check-fn context)
|
||||
|
||||
(let [email (build-email-template id context)]
|
||||
(let [context (-> context check-context check-fn)
|
||||
email (build-email-template id context)]
|
||||
(when-not email
|
||||
(ex/raise :type :internal
|
||||
:code :email-template-does-not-exists
|
||||
@@ -271,7 +269,7 @@
|
||||
"Schedule an already defined email to be sent using asynchronously
|
||||
using worker task."
|
||||
[{:keys [::conn ::factory] :as context}]
|
||||
(assert (db/connection? conn) "expected a valid database connection")
|
||||
(assert (db/connectable? conn) "expected a valid database connection or pool")
|
||||
|
||||
(let [email (if factory
|
||||
(factory context)
|
||||
@@ -348,7 +346,7 @@
|
||||
[:subject ::sm/text]
|
||||
[:content ::sm/text]])
|
||||
|
||||
(def feedback
|
||||
(def user-feedback
|
||||
"A profile feedback email."
|
||||
(template-factory
|
||||
:id ::feedback
|
||||
|
||||
@@ -349,7 +349,6 @@
|
||||
: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)
|
||||
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
|
||||
@@ -405,10 +404,6 @@
|
||||
{::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)
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
(merge {:viewed-tutorial? false
|
||||
:viewed-walkthrough? false
|
||||
:nudge {:big 10 :small 1}
|
||||
:v2-info-shown true})
|
||||
:v2-info-shown true
|
||||
:release-notes-viewed (:main cf/version)})
|
||||
(db/tjson))
|
||||
|
||||
password (or (:password params) "!")
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
;; --- GENERAL PURPOSE INTERNAL HELPERS
|
||||
|
||||
(defn- decode-row
|
||||
[{:keys [participants position] :as row}]
|
||||
[{:keys [participants position mentions] :as row}]
|
||||
(cond-> row
|
||||
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))
|
||||
(db/pgarray? mentions) (assoc :mentions (db/decode-pgarray mentions #{}))))
|
||||
|
||||
(def xf-decode-row
|
||||
(map decode-row))
|
||||
@@ -461,8 +462,9 @@
|
||||
:thread-id thread-id
|
||||
:owner-id profile-id
|
||||
:content content})
|
||||
props {:file-id file-id
|
||||
:share-id nil}]
|
||||
comment (decode-row comment)
|
||||
props {:file-id file-id
|
||||
:share-id nil}]
|
||||
|
||||
;; Update thread modified-at attribute and assoc the current
|
||||
;; profile to the participant set.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(declare ^:private send-feedback!)
|
||||
(declare ^:private send-user-feedback!)
|
||||
|
||||
(def ^:private schema:send-user-feedback
|
||||
[:map {:title "send-user-feedback"}
|
||||
@@ -34,14 +34,16 @@
|
||||
:hint "feedback not enabled"))
|
||||
|
||||
(let [profile (profile/get-profile pool profile-id)]
|
||||
(send-feedback! pool profile params)
|
||||
(send-user-feedback! pool profile params)
|
||||
nil))
|
||||
|
||||
(defn- send-feedback!
|
||||
(defn- send-user-feedback!
|
||||
[pool profile params]
|
||||
(let [dest (cf/get :feedback-destination)]
|
||||
(let [dest (or (cf/get :user-feedback-destination)
|
||||
;; LEGACY
|
||||
(cf/get :feedback-destination))]
|
||||
(eml/send! {::eml/conn pool
|
||||
::eml/factory eml/feedback
|
||||
::eml/factory eml/user-feedback
|
||||
:from dest
|
||||
:to dest
|
||||
:profile profile
|
||||
|
||||
@@ -698,11 +698,7 @@
|
||||
|
||||
(defn get-team-recent-files
|
||||
[conn team-id]
|
||||
(->> (db/exec! conn [sql:team-recent-files team-id])
|
||||
(mapv (fn [row]
|
||||
(if-let [media-id (:thumbnail-id row)]
|
||||
(assoc row :thumbnail-uri (resolve-public-uri media-id))
|
||||
(dissoc row :media-id))))))
|
||||
(db/exec! conn [sql:team-recent-files team-id]))
|
||||
|
||||
(def ^:private schema:get-team-recent-files
|
||||
[:map {:title "get-team-recent-files"}
|
||||
|
||||
@@ -28,13 +28,19 @@
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def sql:get-file-snapshots
|
||||
"SELECT id, label, revn, created_at, created_by, profile_id
|
||||
FROM file_change
|
||||
WHERE file_id = ?
|
||||
AND data IS NOT NULL
|
||||
AND (deleted_at IS NULL OR deleted_at > now())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20")
|
||||
"WITH changes AS (
|
||||
SELECT id, label, revn, created_at, created_by, profile_id
|
||||
FROM file_change
|
||||
WHERE file_id = ?
|
||||
AND data IS NOT NULL
|
||||
AND (deleted_at IS NULL OR deleted_at > now())
|
||||
), versions AS (
|
||||
(SELECT * FROM changes WHERE created_by = 'system' LIMIT 1000)
|
||||
UNION ALL
|
||||
(SELECT * FROM changes WHERE created_by != 'system' LIMIT 1000)
|
||||
)
|
||||
SELECT * FROM versions
|
||||
ORDER BY created_at DESC;")
|
||||
|
||||
(defn get-file-snapshots
|
||||
[conn file-id]
|
||||
|
||||
@@ -50,8 +50,7 @@
|
||||
" where file_id=? and tag=? and deleted_at is null")
|
||||
res (db/exec! conn [sql file-id tag])]
|
||||
(->> res
|
||||
(d/index-by :object-id (fn [row]
|
||||
(files/resolve-public-uri (:media-id row))))
|
||||
(d/index-by :object-id :media-id)
|
||||
(d/without-nils))))
|
||||
|
||||
(defn- get-object-thumbnails
|
||||
@@ -62,8 +61,7 @@
|
||||
" where file_id=? and deleted_at is null")
|
||||
res (db/exec! conn [sql file-id])]
|
||||
(->> res
|
||||
(d/index-by :object-id (fn [row]
|
||||
(files/resolve-public-uri (:media-id row))))
|
||||
(d/index-by :object-id :media-id)
|
||||
(d/without-nils))))
|
||||
|
||||
([conn file-id object-ids]
|
||||
@@ -75,8 +73,7 @@
|
||||
res (db/exec! conn [sql file-id ids])]
|
||||
|
||||
(->> res
|
||||
(d/index-by :object-id (fn [row]
|
||||
(files/resolve-public-uri (:media-id row))))
|
||||
(d/index-by :object-id :media-id)
|
||||
(d/without-nils)))))
|
||||
|
||||
(sv/defmethod ::get-file-object-thumbnails
|
||||
@@ -127,8 +124,11 @@
|
||||
(if-let [frame (-> frames first)]
|
||||
(let [frame-id (:id frame)
|
||||
object-id (thc/fmt-object-id (:id file) page-id frame-id "frame")
|
||||
frame (if-let [thumb (get thumbnails object-id)]
|
||||
(assoc frame :thumbnail thumb :shapes [])
|
||||
|
||||
frame (if-let [media-id (get thumbnails object-id)]
|
||||
(-> frame
|
||||
(assoc :thumbnail-id media-id)
|
||||
(assoc :shapes []))
|
||||
(dissoc frame :thumbnail))
|
||||
|
||||
children-ids
|
||||
@@ -402,7 +402,10 @@
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
;; TODO For now we check read permissions instead of write,
|
||||
;; to allow viewer users to update thumbnails. We might
|
||||
;; review this approach on the future.
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(let [media (create-file-thumbnail! cfg params)]
|
||||
{:uri (files/resolve-public-uri (:id media))
|
||||
|
||||
@@ -223,15 +223,6 @@
|
||||
(let [storage (sto/resolve cfg ::db/reuse-conn true)]
|
||||
(some->> (:data-ref-id file) (sto/touch-object! storage))))
|
||||
|
||||
(-> cfg
|
||||
(assoc ::wrk/task :file-xlog-gc)
|
||||
(assoc ::wrk/label (str "xlog:" (:id file)))
|
||||
(assoc ::wrk/params {:file-id (:id file)})
|
||||
(assoc ::wrk/delay (dt/duration "5m"))
|
||||
(assoc ::wrk/dedupe true)
|
||||
(assoc ::wrk/priority 1)
|
||||
(wrk/submit!))
|
||||
|
||||
(persist-file! cfg file)
|
||||
|
||||
(let [params (assoc params :file file)
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true}
|
||||
{::db/return-keys? false}))
|
||||
{::db/return-keys false}))
|
||||
|
||||
(doseq [params (sequence (comp
|
||||
(map #(bfc/remap-id % :file-id))
|
||||
|
||||
@@ -60,15 +60,25 @@
|
||||
(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 [{:keys [::db/conn] :as cfg}]
|
||||
;; We get the minimal file for proper checking if
|
||||
;; file is not already deleted
|
||||
(let [_ (files/get-minimal-file conn file-id)
|
||||
mobj (create-file-media-object cfg params)]
|
||||
|
||||
(db/update! conn :file
|
||||
{:modified-at (dt/now)
|
||||
:has-media-trimmed false}
|
||||
{:id file-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(with-meta mobj
|
||||
{::audit/replace-props
|
||||
{:name (:name params)
|
||||
:file-id file-id
|
||||
:is-local (:is-local params)
|
||||
:size (:size content)
|
||||
:mtype (:mtype content)}})))))
|
||||
|
||||
(defn- big-enough-for-thumbnail?
|
||||
"Checks if the provided image info is big enough for
|
||||
@@ -142,20 +152,14 @@
|
||||
:always
|
||||
(assoc ::image (process-main-image info)))))
|
||||
|
||||
(defn create-file-media-object
|
||||
[{:keys [::sto/storage ::db/conn ::wrk/executor]}
|
||||
(defn- create-file-media-object
|
||||
[{:keys [::sto/storage ::db/conn ::wrk/executor] :as cfg}
|
||||
{:keys [id file-id is-local name content]}]
|
||||
|
||||
(let [result (px/invoke! executor (partial process-image content))
|
||||
image (sto/put-object! storage (::image result))
|
||||
thumb (when-let [params (::thumb result)]
|
||||
(sto/put-object! storage params))]
|
||||
|
||||
(db/update! conn :file
|
||||
{:modified-at (dt/now)
|
||||
:has-media-trimmed false}
|
||||
{:id file-id})
|
||||
|
||||
(db/exec-one! conn [sql:create-file-media-object
|
||||
(or id (uuid/next))
|
||||
file-id is-local name
|
||||
@@ -182,7 +186,18 @@
|
||||
::sm/params schema:create-file-media-object-from-url}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(create-file-media-object-from-url cfg (assoc params :profile-id profile-id)))
|
||||
;; We get the minimal file for proper checking if file is not
|
||||
;; already deleted
|
||||
(let [_ (files/get-minimal-file cfg file-id)
|
||||
mobj (create-file-media-object-from-url cfg (assoc params :profile-id profile-id))]
|
||||
|
||||
(db/update! pool :file
|
||||
{:modified-at (dt/now)
|
||||
:has-media-trimmed false}
|
||||
{:id file-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
mobj))
|
||||
|
||||
(defn download-image
|
||||
[{:keys [::http/client]} uri]
|
||||
|
||||
@@ -422,7 +422,9 @@
|
||||
:deleted-at deleted-at
|
||||
:id profile-id}})
|
||||
|
||||
(rph/with-transform {} (session/delete-fn cfg)))))
|
||||
|
||||
(-> (rph/wrap nil)
|
||||
(rph/with-transform (session/delete-fn cfg))))))
|
||||
|
||||
|
||||
;; --- HELPERS
|
||||
@@ -431,8 +433,11 @@
|
||||
"WITH owner_teams AS (
|
||||
SELECT tpr.team_id AS id
|
||||
FROM team_profile_rel AS tpr
|
||||
JOIN team AS t ON (t.id = tpr.team_id)
|
||||
WHERE tpr.is_owner IS TRUE
|
||||
AND tpr.profile_id = ?
|
||||
AND (t.deleted_at IS NULL OR
|
||||
t.deleted_at > now())
|
||||
)
|
||||
SELECT tpr.team_id AS id,
|
||||
count(tpr.profile_id) - 1 AS participants
|
||||
|
||||
@@ -166,23 +166,26 @@
|
||||
;; invited team.
|
||||
(let [props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}
|
||||
:invitation-id (:id invitation)}]
|
||||
|
||||
accept-invitation-event
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc ::audit/name "accept-team-invitation")
|
||||
(assoc ::audit/props props))
|
||||
(audit/submit!
|
||||
cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc ::audit/name "accept-team-invitation")
|
||||
(assoc ::audit/props props)))
|
||||
|
||||
accept-invitation-from-event
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc ::audit/profile-id (:created-by invitation))
|
||||
(assoc ::audit/name "accept-team-invitation-from")
|
||||
(assoc ::audit/props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile))))]
|
||||
|
||||
(audit/submit! cfg accept-invitation-event)
|
||||
(audit/submit! cfg accept-invitation-from-event)
|
||||
;; NOTE: Backward compatibility; old invitations can
|
||||
;; have the `created-by` to be nil; so in this case we
|
||||
;; don't submit this event to the audit-log
|
||||
(when-let [created-by (:created-by invitation)]
|
||||
(audit/submit!
|
||||
cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc ::audit/profile-id created-by)
|
||||
(assoc ::audit/name "accept-team-invitation-from")
|
||||
(assoc ::audit/props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile))))))
|
||||
|
||||
(accept-invitation cfg claims invitation profile)
|
||||
(assoc claims :state :created))
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
(let [params (:query-params request)
|
||||
pstyle (:type params "js")
|
||||
context (assoc context :param-style pstyle)]
|
||||
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
|
||||
(tmpl/render context))}))
|
||||
@@ -207,7 +208,7 @@
|
||||
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [methods] :as cfg}]
|
||||
[_ {:keys [::rpc/methods] :as cfg}]
|
||||
[(let [context (prepare-doc-context methods)]
|
||||
[["/_doc"
|
||||
{:handler (doc-handler context)
|
||||
|
||||
@@ -74,8 +74,7 @@
|
||||
|
||||
(defmethod ig/assert-key ::props
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected valid database pool")
|
||||
(assert (string? (::key params)) "expected valid key string"))
|
||||
(assert (db/pool? (::db/pool params)) "expected valid database pool"))
|
||||
|
||||
(defmethod ig/init-key ::props
|
||||
[_ {:keys [::db/pool ::key] :as cfg}]
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.set :as set]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(declare ^:private get-file)
|
||||
@@ -53,16 +52,21 @@
|
||||
RETURNING id")
|
||||
|
||||
(def ^:private xf:collect-used-media
|
||||
(comp (map :data) (mapcat bfc/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] :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)))))
|
||||
(let [xform (comp
|
||||
(map (partial decode-file cfg))
|
||||
xf:collect-used-media)
|
||||
|
||||
used (->> (db/plan conn [sql:get-snapshots id])
|
||||
(transduce xform conj #{}))
|
||||
used (into used xf:collect-used-media [file])
|
||||
|
||||
ids (db/create-array conn "uuid" used)
|
||||
unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids])
|
||||
(into #{} (map :id)))]
|
||||
@@ -145,51 +149,47 @@
|
||||
AND f.deleted_at IS null
|
||||
ORDER BY f.modified_at ASC")
|
||||
|
||||
(def ^:private xf:map-id (map :id))
|
||||
|
||||
(defn- get-used-components
|
||||
"Given a file and a set of components marked for deletion, return a
|
||||
filtered set of component ids that are still un use"
|
||||
[components library-id {:keys [data]}]
|
||||
(filter #(ctf/used-in? data library-id % :component) components))
|
||||
|
||||
(defn- clean-deleted-components!
|
||||
"Performs the garbage collection of unreferenced deleted components."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [data] :as file}]
|
||||
(let [file-id (:id file)
|
||||
|
||||
get-used-components
|
||||
(fn [data components]
|
||||
;; Find which of the components are used in the file.
|
||||
(into #{}
|
||||
(filter #(ctf/used-in? data file-id % :component))
|
||||
components))
|
||||
deleted-components
|
||||
(ctkl/deleted-components-seq data)
|
||||
|
||||
get-unused-components
|
||||
(fn [components files]
|
||||
;; Find and return a set of unused components (on all files).
|
||||
(reduce (fn [components {:keys [data]}]
|
||||
(if (seq components)
|
||||
(->> (get-used-components data components)
|
||||
(set/difference components))
|
||||
(reduced components)))
|
||||
xform
|
||||
(mapcat (partial get-used-components deleted-components file-id))
|
||||
|
||||
components
|
||||
files))
|
||||
used-remote
|
||||
(->> (db/plan conn [sql:get-files-for-library file-id])
|
||||
(transduce (comp (map (partial decode-file cfg)) xform) conj #{}))
|
||||
|
||||
process-fdata
|
||||
(fn [data unused]
|
||||
(reduce (fn [data id]
|
||||
(l/trc :hint "delete component"
|
||||
:component-id (str id)
|
||||
:file-id (str file-id))
|
||||
(ctkl/delete-component data id))
|
||||
data
|
||||
unused))
|
||||
used-local
|
||||
(into #{} xform [file])
|
||||
|
||||
deleted (into #{} (ctkl/deleted-components-seq data))
|
||||
|
||||
unused (->> (db/cursor conn [sql:get-files-for-library file-id] {:chunk-size 1})
|
||||
(map (partial decode-file cfg))
|
||||
(cons file)
|
||||
(get-unused-components deleted)
|
||||
(mapv :id)
|
||||
(set))
|
||||
|
||||
file (update file :data process-fdata unused)]
|
||||
unused
|
||||
(transduce xf:map-id disj
|
||||
(into #{} xf:map-id deleted-components)
|
||||
(concat used-remote used-local))
|
||||
|
||||
file
|
||||
(update file :data
|
||||
(fn [data]
|
||||
(reduce (fn [data id]
|
||||
(l/trc :hint "delete component"
|
||||
:component-id (str id)
|
||||
:file-id (str file-id))
|
||||
(ctkl/delete-component data id))
|
||||
data
|
||||
unused)))]
|
||||
|
||||
(l/dbg :hint "clean" :rel "components" :file-id (str file-id) :total (count unused))
|
||||
file))
|
||||
|
||||
@@ -1,64 +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.tasks.file-xlog-gc
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
;; Get the latest available snapshots without exceeding the total
|
||||
;; snapshot limit
|
||||
(def ^:private sql:get-latest-snapshots
|
||||
"SELECT fch.id, fch.created_at
|
||||
FROM file_change AS fch
|
||||
WHERE fch.file_id = ?
|
||||
AND fch.created_by = 'system'
|
||||
AND fch.data IS NOT NULL
|
||||
AND fch.deleted_at > now()
|
||||
ORDER BY fch.created_at DESC
|
||||
LIMIT ?")
|
||||
|
||||
;; Mark all snapshots that are outside the allowed total threshold
|
||||
;; available for the GC
|
||||
(def ^:private sql:delete-snapshots
|
||||
"UPDATE file_change
|
||||
SET deleted_at = now()
|
||||
WHERE file_id = ?
|
||||
AND deleted_at > now()
|
||||
AND data IS NOT NULL
|
||||
AND created_by = 'system'
|
||||
AND created_at < ?")
|
||||
|
||||
(defn- get-alive-snapshots
|
||||
[conn file-id]
|
||||
(let [total (cf/get :auto-file-snapshot-total 10)
|
||||
snapshots (db/exec! conn [sql:get-latest-snapshots file-id total])]
|
||||
(not-empty snapshots)))
|
||||
|
||||
(defn- delete-old-snapshots!
|
||||
[{:keys [::db/conn] :as cfg} file-id]
|
||||
(when-let [snapshots (get-alive-snapshots conn file-id)]
|
||||
(let [last-date (-> snapshots peek :created-at)
|
||||
result (db/exec-one! conn [sql:delete-snapshots file-id last-date])]
|
||||
(l/inf :hint "delete old file snapshots"
|
||||
:file-id (str file-id)
|
||||
:current (count snapshots)
|
||||
:deleted (db/get-update-count result)))))
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [props] :as task}]
|
||||
(let [file-id (:file-id props)]
|
||||
(assert (uuid? file-id) "expected file-id on props")
|
||||
(-> cfg
|
||||
(assoc ::db/rollback (:rollback props false))
|
||||
(db/tx-run! delete-old-snapshots! file-id)))))
|
||||
@@ -62,7 +62,7 @@
|
||||
(def default
|
||||
{:database-uri "postgresql://postgres/penpot_test"
|
||||
:redis-uri "redis://redis/1"
|
||||
:file-snapshot-every 1})
|
||||
:auto-file-snapshot-every 1})
|
||||
|
||||
(def config
|
||||
(cf/read-config :prefix "penpot-test"
|
||||
|
||||
@@ -383,8 +383,19 @@
|
||||
;; as deleted.
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; This only clears fragments, the file media objects still referenced because
|
||||
;; snapshots are preserved
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 3 (:processed res))))
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; Mark all snapshots to be a non-snapshot file change
|
||||
(th/db-exec! ["update file_change set data = null where file_id = ?" (:id file)])
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
|
||||
|
||||
;; Rerun the file-gc and objects-gc
|
||||
(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))))
|
||||
|
||||
;; Now that file-gc have deleted the file-media-object usage,
|
||||
;; lets execute the touched-gc task, we should see that two of
|
||||
@@ -417,20 +428,6 @@
|
||||
|
||||
;; (th/print-result! out)
|
||||
(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
|
||||
:vern 0
|
||||
:components-v2 true
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))]
|
||||
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
@@ -550,8 +547,20 @@
|
||||
;; as deleted.
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; This only removes unused fragments, file media are still
|
||||
;; referenced on snapshots.
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 7 (:processed res))))
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; Mark all snapshots to be a non-snapshot file change
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
|
||||
(th/db-exec! ["update file_change set data = null where file_id = ?" (:id file)])
|
||||
|
||||
;; Rerun file-gc and objects-gc task for the same file once all snapshots are
|
||||
;; "expired/deleted"
|
||||
(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 (= 6 (:processed res))))
|
||||
|
||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
||||
:deleted-at nil})]
|
||||
@@ -591,20 +600,7 @@
|
||||
|
||||
;; (th/print-result! out)
|
||||
(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)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))]
|
||||
(:result out)))]
|
||||
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
@@ -1090,8 +1086,7 @@
|
||||
(t/is (contains? result :file-id))
|
||||
|
||||
(t/is (= (:id file) (:file-id result)))
|
||||
(t/is (str/starts-with? (get-in result [:page :objects frame1-id :thumbnail])
|
||||
"http://localhost:3449/assets/by-id/"))
|
||||
(t/is (uuid? (get-in result [:page :objects frame1-id :thumbnail-id])))
|
||||
(t/is (= [] (get-in result [:page :objects frame1-id :shapes]))))
|
||||
|
||||
;; Delete thumbnail data
|
||||
@@ -1337,3 +1332,329 @@
|
||||
(t/is (every? #(bytes? (:data %)) rows))
|
||||
(t/is (every? #(nil? (:data-ref-id %)) rows))
|
||||
(t/is (every? #(nil? (:data-backend %)) rows)))))
|
||||
|
||||
(t/deftest file-gc-with-components-1
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
|
||||
s-id-1 (uuid/random)
|
||||
s-id-2 (uuid/random)
|
||||
c-id (uuid/random)
|
||||
|
||||
page-id (first (get-in file [:data :pages]))]
|
||||
|
||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
||||
:deleted-at nil})]
|
||||
(t/is (= (count rows) 1)))
|
||||
|
||||
;; Update file inserting new component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id s-id-1
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-1
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id file)
|
||||
:component-id c-id})}
|
||||
|
||||
{:type :add-obj
|
||||
:page-id page-id
|
||||
:id s-id-2
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-2
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id file)
|
||||
:component-id c-id})}
|
||||
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "Board"
|
||||
:main-instance-id s-id-1
|
||||
:main-instance-page page-id
|
||||
:id c-id
|
||||
:anotation nil}])
|
||||
|
||||
;; Run the file-gc task immediately without forced min-age
|
||||
(t/is (false? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
;; Run the task again
|
||||
(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)})]
|
||||
(t/is (true? (:has-media-trimmed row))))
|
||||
|
||||
;; Check that component exists
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
|
||||
(t/is (some? component))
|
||||
(t/is (nil? (:objects component)))))
|
||||
|
||||
;; Now proceed to delete a component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-component
|
||||
:id c-id}
|
||||
{:type :del-obj
|
||||
:page-id page-id
|
||||
:id s-id-1
|
||||
:ignore-touched true}])
|
||||
|
||||
;; ;; Check that component is marked as deleted
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Re-run the file-gc task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(let [row (th/db-get :file {:id (:id file)})]
|
||||
(t/is (true? (:has-media-trimmed row))))
|
||||
|
||||
;; Check that component is still there after file-gc task
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Now delete the last instance using deleted component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-obj
|
||||
:page-id page-id
|
||||
:id s-id-2
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Now, we have deleted the usage of component if we pass file-gc,
|
||||
;; that component should be deleted
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
components (get-in result [:data :components])]
|
||||
(t/is (not (contains? components c-id)))))))
|
||||
|
||||
(t/deftest file-gc-with-components-2
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
file-1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
|
||||
file-2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
|
||||
rel (th/link-file-to-library*
|
||||
{:file-id (:id file-2)
|
||||
:library-id (:id file-1)})
|
||||
|
||||
s-id-1 (uuid/random)
|
||||
s-id-2 (uuid/random)
|
||||
c-id (uuid/random)
|
||||
|
||||
f1-page-id (first (get-in file-1 [:data :pages]))
|
||||
f2-page-id (first (get-in file-2 [:data :pages]))]
|
||||
|
||||
;; Update file library inserting new component
|
||||
(update-file!
|
||||
:file-id (:id file-1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id f1-page-id
|
||||
:id s-id-1
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-1
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id file-1)
|
||||
:component-id c-id})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "Board"
|
||||
:main-instance-id s-id-1
|
||||
:main-instance-page f1-page-id
|
||||
:id c-id
|
||||
:anotation nil}])
|
||||
|
||||
;; Instanciate a component in a different file
|
||||
(update-file!
|
||||
:file-id (:id file-2)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id f2-page-id
|
||||
:id s-id-2
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-2
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id file-1)
|
||||
:component-id c-id})}])
|
||||
|
||||
;; Run the file-gc on file and library
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
|
||||
|
||||
;; Check that component exists
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
|
||||
(t/is (some? component))
|
||||
(t/is (nil? (:objects component)))))
|
||||
|
||||
;; Now proceed to delete a component
|
||||
(update-file!
|
||||
:file-id (:id file-1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-component
|
||||
:id c-id}
|
||||
{:type :del-obj
|
||||
:page-id f1-page-id
|
||||
:id s-id-1
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Check that component is marked as deleted
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Re-run the file-gc task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
(t/is (false? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
|
||||
|
||||
;; Check that component is still there after file-gc task
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Now delete the last instance using deleted component
|
||||
(update-file!
|
||||
:file-id (:id file-2)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-obj
|
||||
:page-id f2-page-id
|
||||
:id s-id-2
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Mark
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file-1)])
|
||||
|
||||
;; Now, we have deleted the usage of component if we pass file-gc,
|
||||
;; that component should be deleted
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
components (get-in result [:data :components])]
|
||||
(t/is (not (contains? components c-id)))))))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.storage :as sto]
|
||||
[app.util.time :as dt]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
@@ -245,3 +246,35 @@
|
||||
(t/is (= "image/jpeg" (:mtype result)))
|
||||
(t/is (uuid? (:media-id result)))
|
||||
(t/is (uuid? (:thumbnail-id result))))))
|
||||
|
||||
|
||||
(t/deftest media-object-upload-command-when-file-is-deleted
|
||||
(let [prof (th/create-profile* 1)
|
||||
proj (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
|
||||
_ (th/db-update! :file
|
||||
{:deleted-at (dt/now)}
|
||||
{:id (:id file)})
|
||||
|
||||
mfile {:filename "sample.jpg"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
|
||||
params {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found)))))
|
||||
|
||||
@@ -203,7 +203,24 @@
|
||||
edata (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type edata) :validation))
|
||||
(t/is (= (:code edata) :owner-teams-with-people))))))
|
||||
(t/is (= (:code edata) :owner-teams-with-people)))
|
||||
|
||||
(let [params {::th/type :delete-team
|
||||
::rpc/profile-id (:id prof1)
|
||||
:id (:id team1)}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
|
||||
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
|
||||
(t/is (dt/instant? (:deleted-at team)))))
|
||||
|
||||
;; Request profile to be deleted
|
||||
(let [params {::th/type :delete-profile
|
||||
::rpc/profile-id (:id prof1)}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest profile-deletion-3
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
@@ -291,7 +308,7 @@
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
|
||||
(t/is (= {} (:result out)))
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; query files after profile soft deletion
|
||||
@@ -336,7 +353,7 @@
|
||||
::rpc/profile-id (:id prof1)}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (= {} (:result out)))
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(th/run-pending-tasks!)
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
(ns app.common.files.defaults)
|
||||
|
||||
(def version 57)
|
||||
(def version 59)
|
||||
|
||||
@@ -1130,6 +1130,21 @@
|
||||
(update :pages-index dissoc nil)
|
||||
(update :pages-index update-vals update-page))))
|
||||
|
||||
(defn migrate-up-59
|
||||
[data]
|
||||
(letfn [(fix-touched [elem]
|
||||
(cond-> elem (string? elem) keyword))
|
||||
|
||||
(update-shape [shape]
|
||||
(d/update-when shape :touched #(into #{} (map fix-touched) %)))
|
||||
|
||||
(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))))
|
||||
|
||||
(def migrations
|
||||
"A vector of all applicable migrations"
|
||||
[{:id 2 :migrate-up migrate-up-2}
|
||||
@@ -1178,5 +1193,6 @@
|
||||
{:id 54 :migrate-up migrate-up-54}
|
||||
{:id 55 :migrate-up migrate-up-55}
|
||||
{:id 56 :migrate-up migrate-up-56}
|
||||
{:id 57 :migrate-up migrate-up-57}])
|
||||
{:id 57 :migrate-up migrate-up-57}
|
||||
{:id 59 :migrate-up migrate-up-59}])
|
||||
|
||||
|
||||
@@ -320,6 +320,35 @@
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes shape-ids detach-shape))))))
|
||||
|
||||
|
||||
(defmethod repair-error :shape-ref-cycle
|
||||
[_ {:keys [shape args] :as error} file-data _]
|
||||
(let [repair-component
|
||||
(fn [component]
|
||||
(let [objects (:objects component) ;; we only have encounter this on deleted components,
|
||||
;; so the relevant objects are inside the component
|
||||
to-detach (->> (:cycles-ids args)
|
||||
(map #(get objects %))
|
||||
(map #(ctn/get-head-shape objects %))
|
||||
(map :id)
|
||||
distinct
|
||||
(mapcat #(ctn/get-children-in-instance objects %))
|
||||
(map :id)
|
||||
set)]
|
||||
|
||||
(update component :objects
|
||||
(fn [objects]
|
||||
(reduce-kv (fn [acc k v]
|
||||
(if (contains? to-detach k)
|
||||
(assoc acc k (ctk/detach-shape v))
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
objects)))))]
|
||||
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
|
||||
(defmethod repair-error :shape-ref-in-main
|
||||
[_ {:keys [shape page-id] :as error} file-data _]
|
||||
(let [repair-shape
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
:component-nil-objects-not-allowed
|
||||
:instance-head-not-frame
|
||||
:misplaced-slot
|
||||
:missing-slot})
|
||||
:missing-slot
|
||||
:shape-ref-cycle})
|
||||
|
||||
(def ^:private schema:error
|
||||
[:map {:title "ValidationError"}
|
||||
@@ -482,6 +483,18 @@
|
||||
"This deleted component has children with the same swap slot"
|
||||
component file nil))))
|
||||
|
||||
(defn check-ref-cycles
|
||||
[component file]
|
||||
(let [cycles-ids (->> component
|
||||
:objects
|
||||
vals
|
||||
(filter #(= (:id %) (:shape-ref %)))
|
||||
(map :id))]
|
||||
|
||||
(when (seq cycles-ids)
|
||||
(report-error :shape-ref-cycle
|
||||
"This deleted component has shapes with shape-ref pointing to self"
|
||||
component file nil :cycles-ids cycles-ids))))
|
||||
|
||||
(defn- check-component
|
||||
"Validate semantic coherence of a component. Report all errors found."
|
||||
@@ -491,7 +504,8 @@
|
||||
"Objects list cannot be nil"
|
||||
component file nil))
|
||||
(when (:deleted component)
|
||||
(check-component-duplicate-swap-slot component file)))
|
||||
(check-component-duplicate-swap-slot component file)
|
||||
(check-ref-cycles component file)))
|
||||
|
||||
(defn- get-orphan-shapes
|
||||
[{:keys [objects] :as page}]
|
||||
|
||||
@@ -304,7 +304,12 @@
|
||||
(and (some? (ctk/get-swap-slot ref-shape))
|
||||
(nil? (ctk/get-swap-slot shape))
|
||||
(not= (:id shape) shape-id))
|
||||
(pcb/update-shapes [(:id shape)] #(ctk/set-swap-slot % (ctk/get-swap-slot ref-shape))))))]
|
||||
(pcb/update-shapes [(:id shape)] #(ctk/set-swap-slot % (ctk/get-swap-slot ref-shape)))
|
||||
|
||||
;; If we can't get the ref-shape (e.g. it's in an external library not linked),
|
||||
;: we can't do a suitable advance. So it's better to detach the shape
|
||||
(nil? ref-shape)
|
||||
(pcb/update-shapes [(:id shape)] ctk/detach-shape))))]
|
||||
|
||||
(reduce skip-near changes children)))
|
||||
|
||||
|
||||
@@ -304,7 +304,9 @@
|
||||
(->> ids
|
||||
(mapcat #(ctn/get-child-heads objects %))
|
||||
(map :id)))
|
||||
cell (or cell (ctl/get-cell-by-index parent to-index))]
|
||||
|
||||
index-cell-data (when to-index (ctl/get-cell-by-index parent to-index))
|
||||
cell (or cell (and index-cell-data [(:row index-cell-data) (:column index-cell-data)]))]
|
||||
|
||||
(-> changes
|
||||
(pcb/with-page-id page-id)
|
||||
@@ -409,12 +411,14 @@
|
||||
;; Resize parent containers that need to
|
||||
(pcb/resize-parents parents))))
|
||||
|
||||
(defn change-show-in-viewer [shape hide?]
|
||||
(defn change-show-in-viewer
|
||||
[shape hide?]
|
||||
(assoc shape :hide-in-viewer hide?))
|
||||
|
||||
(defn add-new-interaction [shape interaction]
|
||||
(-> shape
|
||||
(update :interactions ctsi/add-interaction interaction)))
|
||||
(defn add-new-interaction
|
||||
[shape interaction]
|
||||
(update shape :interactions ctsi/add-interaction interaction))
|
||||
|
||||
(defn show-in-viewer [shape]
|
||||
(defn show-in-viewer
|
||||
[shape]
|
||||
(dissoc shape :hide-in-viewer))
|
||||
|
||||
@@ -335,24 +335,28 @@
|
||||
(true? (= (:id component) (:id ref-component)))))
|
||||
|
||||
(defn find-swap-slot
|
||||
[shape container file libraries]
|
||||
(if-let [swap-slot (ctk/get-swap-slot shape)]
|
||||
swap-slot
|
||||
(let [ref-shape (find-ref-shape file
|
||||
container
|
||||
libraries
|
||||
shape
|
||||
:include-deleted? true
|
||||
:with-context? true)
|
||||
shape-meta (meta ref-shape)
|
||||
ref-file (:file shape-meta)
|
||||
ref-container (:container shape-meta)]
|
||||
(when ref-shape
|
||||
(if-let [swap-slot (ctk/get-swap-slot ref-shape)]
|
||||
swap-slot
|
||||
(if (ctk/main-instance? ref-shape)
|
||||
(:id shape)
|
||||
(find-swap-slot ref-shape ref-container ref-file libraries)))))))
|
||||
([shape container file libraries]
|
||||
(find-swap-slot shape container file libraries #{}))
|
||||
([shape container file libraries viewed-ids]
|
||||
(if (contains? viewed-ids (:id shape)) ;; prevent cycles
|
||||
nil
|
||||
(if-let [swap-slot (ctk/get-swap-slot shape)]
|
||||
swap-slot
|
||||
(let [ref-shape (find-ref-shape file
|
||||
container
|
||||
libraries
|
||||
shape
|
||||
:include-deleted? true
|
||||
:with-context? true)
|
||||
shape-meta (meta ref-shape)
|
||||
ref-file (:file shape-meta)
|
||||
ref-container (:container shape-meta)]
|
||||
(when ref-shape
|
||||
(if-let [swap-slot (ctk/get-swap-slot ref-shape)]
|
||||
swap-slot
|
||||
(if (ctk/main-instance? ref-shape)
|
||||
(:id shape)
|
||||
(find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape)))))))))))
|
||||
|
||||
(defn match-swap-slot?
|
||||
[shape-main shape-inst container-inst container-main file libraries]
|
||||
@@ -738,16 +742,20 @@
|
||||
(:component-id shape) "@"
|
||||
:else "-")
|
||||
|
||||
(when (and (:component-file shape) component-file)
|
||||
(when (:component-file shape)
|
||||
(str/format "<%s> "
|
||||
(if (= (:id component-file) (:id file))
|
||||
"local"
|
||||
(:name component-file))))
|
||||
(if component-file
|
||||
(if (= (:id component-file) (:id file))
|
||||
"local"
|
||||
(:name component-file))
|
||||
(if show-ids
|
||||
(str/format "¿%s?" (:component-file shape))
|
||||
"?"))))
|
||||
|
||||
(or (:name component-shape)
|
||||
(str/format "?%s"
|
||||
(when show-ids
|
||||
(str " " (:shape-ref shape)))))
|
||||
(if show-ids
|
||||
(str/format "¿%s?" (:shape-ref shape))
|
||||
"?"))
|
||||
|
||||
(when (and show-ids component-shape)
|
||||
(str/format " %s" (:id component-shape)))
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
[:id ::sm/uuid]
|
||||
[:axis [::sm/one-of #{:x :y}]]
|
||||
[:position ::sm/safe-number]
|
||||
[:frame-id {:optional true} ::sm/uuid]])
|
||||
[:frame-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
(def schema:guides
|
||||
[:map-of {:gen/max 2} ::sm/uuid schema:guide])
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
[:main-instance {:optional true} :boolean]
|
||||
[:remote-synced {:optional true} :boolean]
|
||||
[:shape-ref {:optional true} ::sm/uuid]
|
||||
[:touched {:optional true} [:maybe [:set :keyword]]]
|
||||
[:blocked {:optional true} :boolean]
|
||||
[:collapsed {:optional true} :boolean]
|
||||
[:locked {:optional true} :boolean]
|
||||
|
||||
@@ -1479,7 +1479,7 @@
|
||||
(defn get-cell-by-index
|
||||
[parent to-index]
|
||||
(let [cells (get-cells parent {:sort? true :remove-empty? true})
|
||||
to-index (- (count cells) to-index)]
|
||||
to-index (- (count cells) to-index 1)]
|
||||
(nth cells to-index nil)))
|
||||
|
||||
(defn add-children-to-index
|
||||
|
||||
@@ -103,6 +103,83 @@
|
||||
(t/is (= (:shape-ref copy-nested-ellipse) (thi/id :nested-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-advance-in-library
|
||||
(let [;; ==== Setup
|
||||
library (setup-file)
|
||||
file (-> (thf/sample-file :file2)
|
||||
(thc/instantiate-component :c-big-board
|
||||
:copy-big-board
|
||||
:library library
|
||||
:children-labels [:copy-h-board-with-ellipse
|
||||
:copy-nested-h-ellipse
|
||||
:copy-nested-ellipse]))
|
||||
page (thf/current-page file)
|
||||
|
||||
;; ==== Action
|
||||
changes (cll/generate-detach-instance (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
page
|
||||
{(:id file) file
|
||||
(:id library) library}
|
||||
(thi/id :copy-big-board))
|
||||
file' (thf/apply-changes file changes)
|
||||
|
||||
;; ==== Get
|
||||
copy-h-board-with-ellipse (ths/get-shape file' :copy-h-board-with-ellipse)
|
||||
copy-nested-h-ellipse (ths/get-shape file' :copy-nested-h-ellipse)
|
||||
copy-nested-ellipse (ths/get-shape file' :copy-nested-ellipse)]
|
||||
|
||||
;; ==== Check
|
||||
|
||||
;; It should the same as above, but in an external library.
|
||||
(thf/dump-file file)
|
||||
(t/is (ctk/instance-root? copy-h-board-with-ellipse))
|
||||
(t/is (= (:shape-ref copy-h-board-with-ellipse) (thi/id :board-with-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-h-board-with-ellipse)))
|
||||
|
||||
(t/is (ctk/instance-head? copy-nested-h-ellipse))
|
||||
(t/is (= (:shape-ref copy-nested-h-ellipse) (thi/id :nested-h-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-h-ellipse)))
|
||||
|
||||
(t/is (not (ctk/instance-head? copy-nested-ellipse)))
|
||||
(t/is (= (:shape-ref copy-nested-ellipse) (thi/id :nested-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-advance-in-broken-library
|
||||
(let [;; ==== Setup
|
||||
library (setup-file)
|
||||
file (-> (thf/sample-file :file2)
|
||||
(thc/instantiate-component :c-big-board
|
||||
:copy-big-board
|
||||
:library library
|
||||
:children-labels [:copy-h-board-with-ellipse
|
||||
:copy-nested-h-ellipse
|
||||
:copy-nested-ellipse]))
|
||||
page (thf/current-page file)
|
||||
|
||||
;; ==== Action
|
||||
changes (cll/generate-detach-instance (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
page
|
||||
{(:id file) file}
|
||||
(thi/id :copy-big-board))
|
||||
file' (thf/apply-changes file changes)
|
||||
|
||||
;; ==== Get
|
||||
copy-h-board-with-ellipse (ths/get-shape file' :copy-h-board-with-ellipse)
|
||||
copy-nested-h-ellipse (ths/get-shape file' :copy-nested-h-ellipse)
|
||||
copy-nested-ellipse (ths/get-shape file' :copy-nested-ellipse)]
|
||||
|
||||
;; ==== Check
|
||||
|
||||
;; If the main component cannot be found, because it's in a library that is
|
||||
;; not available, the nested copies should be detached too.
|
||||
(t/is (not (ctk/in-component-copy? copy-h-board-with-ellipse)))
|
||||
(t/is (not (ctk/in-component-copy? copy-nested-h-ellipse)))
|
||||
(t/is (not (ctk/in-component-copy? copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-dont-advance-when-swapped-copy
|
||||
(let [;; ==== Setup
|
||||
file (-> (setup-file)
|
||||
|
||||
@@ -43,7 +43,6 @@ services:
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
- PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
# SMTP setup
|
||||
- PENPOT_SMTP_ENABLED=true
|
||||
- PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com
|
||||
|
||||
@@ -90,6 +90,7 @@ http {
|
||||
proxy_hide_header x-amz-meta-server-side-encryption;
|
||||
proxy_hide_header x-amz-server-side-encryption;
|
||||
proxy_pass $redirect_uri;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
add_header x-internal-redirect "$redirect_uri";
|
||||
add_header x-cache-control "$redirect_cache_control";
|
||||
|
||||
@@ -201,7 +201,7 @@ 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:8080
|
||||
|
||||
## Redis is used for the websockets notifications.
|
||||
PENPOT_REDIS_URI: redis://penpot-redis/0
|
||||
|
||||
@@ -92,6 +92,7 @@ http {
|
||||
proxy_hide_header x-amz-request-id;
|
||||
proxy_hide_header x-amz-meta-server-side-encryption;
|
||||
proxy_hide_header x-amz-server-side-encryption;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_pass $redirect_uri;
|
||||
|
||||
add_header x-internal-redirect "$redirect_uri";
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.0 KiB |
BIN
docs/img/styling/blend-opacity.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
docs/img/workspace-basics/history-actions.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
docs/img/workspace-basics/history-autosaved.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
docs/img/workspace-basics/history-pin.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
docs/img/workspace-basics/history-restore.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/img/workspace-basics/history-save.webp
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
docs/img/workspace-basics/history-view.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -216,3 +216,9 @@ Success! - Published to example-plugin-penpot.surge.sh
|
||||
```
|
||||
|
||||
5. Done!
|
||||
|
||||
## 3.5. Submitting to Penpot
|
||||
|
||||
To make your finished plugin available in our catalog, submit in on the [plugin submission page](https://penpot.app/penpothub/plugins/create-plugin). Once it becomes available any Penpot user will be able to install and use it.
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ flags (that just enables or disables something). All flags are set in a single
|
||||
format: <code class="language-bash"><enable|disable>-\<flag-name></code>. For example:
|
||||
|
||||
```bash
|
||||
PENPOT_FLAGS: enable-smpt disable-registration disable-email-verification
|
||||
PENPOT_FLAGS: enable-smtp disable-registration disable-email-verification
|
||||
```
|
||||
|
||||
### Registration ###
|
||||
|
||||
@@ -4,7 +4,8 @@ title: 1. Self-hosting Guide
|
||||
|
||||
# Self-hosting Guide
|
||||
|
||||
This guide explains how to get your own Penpot instance, running on a machine you control, to test it, use it by you or your team, or even customize and extend it any way you like.
|
||||
This guide explains how to get your own Penpot instance, running on a machine you control,
|
||||
to test it, use it by you or your team, or even customize and extend it any way you like.
|
||||
|
||||
If you need more context you can look at the <a
|
||||
href="https://community.penpot.app/t/self-hosting-penpot-i/2336" target="_blank">post
|
||||
@@ -14,18 +15,30 @@ about self-hosting</a> in Penpot community.
|
||||
href="https://design.penpot.app">our SaaS offer</a> for Penpot and your
|
||||
self-hosted Penpot platform!**
|
||||
|
||||
There are two main options for creating a Penpot instance:
|
||||
There are three main options for creating a Penpot instance:
|
||||
|
||||
1. Using the platform of our partner <a href="https://elest.io/open-source/penpot" target="_blank">Elestio</a>.
|
||||
2. Using <a href="https://docker.com" target="_blank">Docker</a> tool.
|
||||
3. Using <a href="https://kubernetes.io/" target="_blank">Kubernetes</a>.
|
||||
|
||||
<p class="advice">
|
||||
The recommended way is to use Elestio, since it's simpler, fully automatic and still greatly flexible. Use Docker if you already know the tool, if need full control of the process or have extra requirements and do not want to depend on any external provider, or need to do any special customization.
|
||||
The recommended way is to use Elestio, since it's simpler, fully automatic and still greatly flexible.
|
||||
Use Docker if you already know the tool, if need full control of the process or have extra requirements
|
||||
and do not want to depend on any external provider, or need to do any special customization.
|
||||
</p>
|
||||
|
||||
Or you can try <a href="#unofficial-self-host-options">other options</a>,
|
||||
offered by Penpot community.
|
||||
|
||||
## Recommended settings
|
||||
To self-host Penpot, you’ll need a server with the following specifications:
|
||||
|
||||
* **CPU:** 1-2 CPUs
|
||||
* **RAM:** 4 GiB of RAM
|
||||
* **Disk Space:** Disk requirements depend on your usage. Disk usage primarily involves the database and any files uploaded by users.
|
||||
|
||||
This setup should be sufficient for a smooth experience with typical usage (your mileage may vary).
|
||||
|
||||
## Install with Elestio
|
||||
|
||||
This section explains how to get Penpot up and running using <a href="https://elest.io/open-source/penpot"
|
||||
@@ -261,7 +274,7 @@ itself.
|
||||
|
||||
This section details everything you need to know to get Penpot up and running in
|
||||
production environments using a Kubernetes cluster of your choice. To do this, we have
|
||||
created a <a href="https://helm.sh/" target="_blank">Helm<a> repository with everything
|
||||
created a <a href="https://helm.sh/" target="_blank">Helm</a> repository with everything
|
||||
you need.
|
||||
|
||||
Therefore, your prerequisite will be to have a Kubernetes cluster on which we can install
|
||||
@@ -287,7 +300,7 @@ in turn have its own release name.
|
||||
With these concepts in mind, we can now explain Helm like this:
|
||||
|
||||
> Helm installs charts into Kubernetes clusters, creating a new release for each
|
||||
> installation. And to find new charts, you can search Helm chart repositories.
|
||||
> installation. To find new charts, you can search Helm chart repositories.
|
||||
|
||||
|
||||
### Install Helm
|
||||
|
||||
@@ -20,6 +20,8 @@ machine.
|
||||
|
||||
* In the [Install with Docker][2] section, you can find the official Docker installation guide.
|
||||
|
||||
* In the [Install with Kubernetes][7] section, you can find the official Kubernetes installation guide.
|
||||
|
||||
* In the [Configuration][3] section, you can find all the customization options you can set up after installing.
|
||||
|
||||
* Or you can try other, not supported by Penpot, [Unofficial options][4].
|
||||
@@ -28,9 +30,11 @@ machine.
|
||||
|
||||
The [Integration Guide][5] explains how to connect Penpot with external apps, so they get notified
|
||||
when certain events occur and may create your own interconnections and collaboration features.
|
||||
|
||||
## Developing Penpot
|
||||
|
||||
Also, if you are a developer, you can get into the code, to explore it, learn how it is made, or extend it and contribute with new functionality. For this, we have a different Docker installation.
|
||||
Also, if you are a developer, you can get into the code, to explore it, learn how it is made,
|
||||
or extend it and contribute with new functionality. For this, we have a different Docker installation.
|
||||
In the [Developer Guide][6] you can find how to setup a development environment and many other dev-oriented documentation.
|
||||
|
||||
[1]: /technical-guide/getting-started/#install-with-elestio
|
||||
@@ -39,3 +43,4 @@ In the [Developer Guide][6] you can find how to setup a development environment
|
||||
[4]: /technical-guide/getting-started/#unofficial-self-host-options
|
||||
[5]: /technical-guide/integration/
|
||||
[6]: /technical-guide/developer/
|
||||
[7]: /technical-guide/getting-started/#install-with-kubernetes
|
||||
|
||||
@@ -5,45 +5,25 @@ title: 14· Import/export files
|
||||
<h1 id="import-export">Import and export files</h1>
|
||||
<p class="main-paragraph">You can export Penpot files to your computer and import them from your computer to your projects.</p>
|
||||
|
||||
<h2 id="penpot-formats">Penpot file formats</h2>
|
||||
<p>There are two different formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
|
||||
<h3>Penpot file (.penpot).</h3>
|
||||
<p>The fast one. Binary Penpot specific.</p>
|
||||
<ul>
|
||||
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
|
||||
<li>❌ It can be opened only in Penpot.</li>
|
||||
<li>❌ Not transparent, code difficult to explore.</li>
|
||||
</ul>
|
||||
<h3>Standard file (.zip).</h3>
|
||||
<p>The open one. A compressed file that includes SVG and JSON.</p>
|
||||
<ul>
|
||||
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
|
||||
<li>✅ Allows some automations and integrations.</li>
|
||||
<li>✅ Is a transparent, existing, open standard format.</li>
|
||||
<li>❌ Highly inefficient in terms of memory and transfer time when exporting and importing (this is because SVG).</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="files-export">Export Penpot files</h2>
|
||||
<p>Exporting files is useful for many reasons. Sometimes you want to have a backup of your files and sometimes it is useful to share Penpot files with a user that does not belong to one of your teams, or you want to have a backup of your files outside Penpot, both SaaS (design.penpot.app) or at a self-hosted instance.</p>
|
||||
|
||||
<h3 id="export-penpot-files">How to export Penpot files</h3>
|
||||
<h4>Export a single file</h4>
|
||||
<p>You can download (export) files from the workspace and from the dashboard.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>From the <a href="/user-guide/the-interface/#interface-workspace">workspace</a></strong>: Select the download option at the main menu.
|
||||
<figure><img src="/img/import-export/export-card.webp" alt="Export penpot file" /></figure>
|
||||
</li>
|
||||
<li>
|
||||
<strong>From the <a href="/user-guide/the-interface/#interface-dashboard">dashboard</a></strong>: Select the download option at the file card menu.
|
||||
<figure><img src="/img/import-export/export-menu.webp" alt="Export penpot file" /></figure>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>From the <a href="/user-guide/the-interface/#interface-dashboard">dashboard</a></strong>: Select the download option at the file card menu.
|
||||
<figure><img src="/img/import-export/export-card.webp" alt="Export penpot file" /></figure>
|
||||
</p>
|
||||
<p>
|
||||
<strong>From the <a href="/user-guide/the-interface/#interface-workspace">workspace</a></strong>: Select the download option at the main menu.
|
||||
<figure><img src="/img/import-export/export-menu.webp" alt="Export penpot file" /></figure>
|
||||
</p>
|
||||
|
||||
<h4>Export multiple files</h4>
|
||||
<p>Select multiple files to export them at the same time. An overlay will show you the progress of the different exports.</p>
|
||||
<figure>
|
||||
<video title="Export multiple files" muted="" playsinline="" controls="" width="100%" poster="/img/import-export/export-multiple.webp" height="auto">
|
||||
<video title="Export multiple files" muted="" playsinline="" controls="" width="auto" poster="/img/import-export/export-multiple.webp" height="auto">
|
||||
<source src="/img/import-export/export-multiple.mp4" type="video/mp4">
|
||||
</video>
|
||||
</figure>
|
||||
@@ -63,4 +43,27 @@ title: 14· Import/export files
|
||||
<p>The import option is at the projects menu. Press “Import files” and then select one or more .penpot files to import. You can import a .zip file as well.</p>
|
||||
<figure><img src="/img/import-export/import-menu.webp" alt="Import penpot file" /></figure>
|
||||
<p>Right before importing the files to your project, you’ll still have the opportunity to review the items to be imported, have the information about the ones that can not be imported and also the chance to discard files.</p>
|
||||
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure
|
||||
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure>
|
||||
|
||||
<h2 id="penpot-formats">Penpot file format</h2>
|
||||
<p>Penpot export to a unique format that streamline the import and export of files and assets by being more efficient and interoperable.</p>
|
||||
<p>Unlike other design tools, <strong>Penpot's format is built on standard languages</strong>. The exported file is essentially a ZIP archive containing binary assets (such as bitmap and vector images) alongside a readable JSON structure. By avoiding proprietary formats, Penpot empowers users with autonomy from specific tools while enabling seamless third-party integrations.</p>
|
||||
|
||||
<h3>Deprecated Penpot file formats</h3>
|
||||
<p class="advice">These formats can only be exported from version 2.3 or earlier versions, but can be imported to any Penpot version</p>
|
||||
<p>There are two different deprecated Penpot file formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
|
||||
<h4>[Deprecated] Penpot file (.penpot).</h4>
|
||||
<p>The fast one. Binary Penpot specific.</p>
|
||||
<ul>
|
||||
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
|
||||
<li>❌ It can be opened only in Penpot.</li>
|
||||
<li>❌ Not transparent, code difficult to explore.</li>
|
||||
</ul>
|
||||
<h4>[Deprecated] Standard file (.zip).</h4>
|
||||
<p>The open one. A compressed file that includes SVG and JSON.</p>
|
||||
<ul>
|
||||
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
|
||||
<li>✅ Allows some automations and integrations.</li>
|
||||
<li>✅ Is a transparent, existing, open standard format.</li>
|
||||
<li>❌ Highly inefficient in terms of memory and transfer time when exporting and importing (this is because SVG).</li>
|
||||
</ul>
|
||||
@@ -424,11 +424,6 @@ title: Shortcuts
|
||||
<td style="text-align: center;"><kbd>Alt</kbd><kbd>P</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⌥</kbd><kbd>P</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>History</td>
|
||||
<td style="text-align: center;"><kbd>Alt</kbd><kbd>H</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⌥</kbd><kbd>H</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Layers</td>
|
||||
<td style="text-align: center;"><kbd>Alt</kbd><kbd>L</kbd></td>
|
||||
|
||||
@@ -124,10 +124,10 @@ title: 06· Styling
|
||||
<li><strong>Square</strong> - Adds a rectangular ending to the end of the path.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="radius">Corner radius</h2>
|
||||
<p>You can set values for corner radius to rectangles and images. There’s also the option to edit each corner individually.</p>
|
||||
<h2 id="radius">Border radius</h2>
|
||||
<p>You can customize the border radius of rectangles and images, with the option to customize each corner individually.</p>
|
||||
<figure>
|
||||
<video title="Corner radius" muted="" playsinline="" controls="" width="100%" poster="/img/styling/corners.webp" height="auto">
|
||||
<video title="Border radius" muted="" playsinline="" controls="" width="100%" poster="/img/styling/corners.webp" height="auto">
|
||||
<source src="/img/styling/corners.mp4" type="video/mp4">
|
||||
</video>
|
||||
</figure>
|
||||
@@ -155,4 +155,30 @@ title: 06· Styling
|
||||
<video title="Apply blur to a layer" muted="" playsinline="" controls="" width="100%" poster="/img/styling/blur.webp" height="auto">
|
||||
<source src="/img/styling/blur.mp4" type="video/mp4">
|
||||
</video>
|
||||
</figure>
|
||||
</figure>
|
||||
|
||||
<h2 id="blend">Opacity and blend</h2>
|
||||
<p>Set the overal opacity for layers and their blend mode.</p>
|
||||
<p>Blend allows you to control how a layer interacts with the layers beneath it, determining how pixels from the current layer are combined with pixels in the underlying layers. Use blend to achive various effects, such as shading, highlights, or creative visual styles.</p>
|
||||
<figure>
|
||||
<img alt="Layer blend and opacity" src="/img/styling/blend-opacity.webp"/>
|
||||
</figure>
|
||||
<p>Blend options available:</p>
|
||||
<ul>
|
||||
<li><strong>Normal</strong></li>
|
||||
<li><strong>Darken</strong></li>
|
||||
<li><strong>Multiply</strong></li>
|
||||
<li><strong>Color burn</strong></li>
|
||||
<li><strong>Lighten</strong></li>
|
||||
<li><strong>Screen</strong></li>
|
||||
<li><strong>Color dodge</strong></li>
|
||||
<li><strong>Overlay</strong></li>
|
||||
<li><strong>Soft light</strong></li>
|
||||
<li><strong>Hard light</strong></li>
|
||||
<li><strong>Difference</strong></li>
|
||||
<li><strong>Exclusion</strong></li>
|
||||
<li><strong>Hue</strong></li>
|
||||
<li><strong>Saturation</strong></li>
|
||||
<li><strong>Color</strong></li>
|
||||
<li><strong>Luminosity</strong></li>
|
||||
</ul>
|
||||
@@ -36,9 +36,10 @@ member is allowed to do depends on their permissions.</p>
|
||||
<h3>Team roles</h3>
|
||||
<p>These are the team roles currently available at Penpot:</p>
|
||||
<ul>
|
||||
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have permissions to change every other member role, including transfering ownership. Owners can update team settings, invite members and delete teams.</strong></li>
|
||||
<li><strong>Admin:</strong> Permissions to change every other member role except owners. Can invite members and update team settings.</strong></li>
|
||||
<li><strong>Editor:</strong> Without permissions to change member roles, invite members or update team settings.</strong></li>
|
||||
<li><strong>Viewer:</strong> Viewers can view, comment on and inspect files but will not be able to edit them, nor do they have permissions to manage team settings.</strong></li>
|
||||
<li><strong>Editor:</strong> Editors can create, import, edit and manage files and libraries, but do not have permissions to manage team settings.</strong></li>
|
||||
<li><strong>Admin:</strong> Admins have the same permissions as editors, with the added ability to change every other member's role except owners. They can invite members and update team settings.</strong></li>
|
||||
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have all the permissions of admins, with the additional ability to change any member's role, including transferring ownership. Owners can update team settings, invite members and delete teams.</strong></li>
|
||||
</ul>
|
||||
<figure><img src="/img/teams/teams-permissions.webp" alt="Team members" /></figure>
|
||||
<p class="advice">More team roles will be eventually available, as well as fine grained permissions management to control members access and actions.</p>
|
||||
|
||||
@@ -199,26 +199,58 @@ geometric structure. In Penpot there are three types of guides:
|
||||
<img src="/img/workspace-basics/shortcuts.webp" alt="Shortcuts panel" />
|
||||
</figure>
|
||||
|
||||
<h2 id="history">History</h2>
|
||||
<p>The history panel keeps track of the latest changes on an opened file.</p>
|
||||
<h2 id="history">File history versions</h2>
|
||||
<p>The history panel keeps track of the latest changes on an opened file as well as the different versions of the file, making it easier to track changes, revert to previous states and collaborate.</p>
|
||||
|
||||
<h4>View history</h4>
|
||||
<p>To view the recent history of a file at the workspace press <kbd>Ctrl/⌘</kbd> + <kbd>H</kbd> or click at the history icon on the toolbar at the left.</p>
|
||||
<p>At the history you can see items with information about the last changes. At first sight you have object type (rectangle, text, image...) and type of change (New, Modified, Deleted...). If you press the item further details are shown.</p>
|
||||
<h3>View history</h3>
|
||||
<p>To view the recent history of a file at the workspace click the history icon on the navbar at the left:</p>
|
||||
<ul>
|
||||
<li>To see the history of file versions go to the <strong>History</strong> tab.</li>
|
||||
<li>To see the history of item changes go to the <strong>Actions</strong> tab.</li>
|
||||
</ul>
|
||||
<figure>
|
||||
<img src="/img/workspace-basics/history.webp" alt="History panel" />
|
||||
<img src="/img/workspace-basics/history-view.webp" alt="History versions button" />
|
||||
</figure>
|
||||
<p><strong>Note:</strong> History panel is still in a very early state and shows only a limited list of changes at a current browser tab session. Refreshing the browser means refreshing the History as well. Eventually, Penpot will have a proper version history capacity.</p>
|
||||
|
||||
<h4>Navigate history</h4>
|
||||
<p>To navigate through the history press <kbd>Ctrl/⌘</kbd> + <kbd>Z</kbd> to go backwards and <kbd>Ctrl/⌘</kbd> + <kbd>Shift/⇧</kbd> + <kbd>Z</kbd> to go forward.</p>
|
||||
<p>You can also press any item of the history list to get to this specific state.</p>
|
||||
<h3>History panel</h3>
|
||||
<p>At the History panel, you can save the current version of your file, as well as access previous versions for up to 7 days.</p>
|
||||
|
||||
<h4>Restore versions</h4>
|
||||
<p>All saved versions of the file—whether manually saved, autosaved, or pinned—can be restored, reverting the file back to its state at the selected time.</p>
|
||||
<figure>
|
||||
<video title="Navigate history" muted="" playsinline="" controls="" width="auto" poster="/img/workspace-basics/history-navigate.webp" height="auto">
|
||||
<source src="/img/workspace-basics/history-navigate.mp4" type="video/mp4">
|
||||
</video>
|
||||
<img src="/img/workspace-basics/history-restore.webp" alt="Restore versions" />
|
||||
</figure>
|
||||
|
||||
<h4>Saved versions</h4>
|
||||
<p>You can save the current version of your file by clicking the pin icon at the History tab. This will allow the version to be named and it will add it to your list of versions.</p>
|
||||
<figure>
|
||||
<img src="/img/workspace-basics/history-save.webp" alt="Saved versions" />
|
||||
</figure>
|
||||
|
||||
<h4>Autosaved versions</h4>
|
||||
<p>When you start working on a file, Penpot will start to automatically save versions of that file across time so that you can later restore them as needed.</p>
|
||||
<p>In the History tab, if you click on the autosaved versions, you’ll see a list of the exact date and time when the version was automatically saved.</p>
|
||||
<figure>
|
||||
<img src="/img/workspace-basics/history-autosaved.webp" alt="Autosaved versions" />
|
||||
</figure>
|
||||
|
||||
<h4>Pinned versions</h4>
|
||||
<p>File versions can also be pinned. Pinning a file version will allow you to name it, making it easier to access at the History tab. Pinned file versions will be saved forever and can be renamed, restored or deleted at any time.</p>
|
||||
<figure>
|
||||
<img src="/img/workspace-basics/history-pin.webp" alt="Pin versions" />
|
||||
</figure>
|
||||
|
||||
<h3>Actions panel</h3>
|
||||
<p>At the Actions panel, you have the object type (rectangle, text, image...) and type of change (New, Modified, Deleted...). If you press the item, it will be reverted to its state before that specific action was performed.</p>
|
||||
<figure>
|
||||
<img src="/img/workspace-basics/history-actions.webp" alt="Actions panel" />
|
||||
</figure>
|
||||
<p class="advice">The Actions panel shows only a limited list of changes at a current browser tab session. Refreshing the browser means refreshing the history of actions as well.</p>
|
||||
|
||||
<h4>Navigate actions</h4>
|
||||
<p>To navigate through the actions press <kbd>Ctrl/⌘</kbd> + <kbd>Z</kbd> to go backwards and <kbd>Ctrl/⌘</kbd> + <kbd>Shift/⇧</kbd> + <kbd>Z</kbd> to go forward.</p>
|
||||
<p>You can also press any item of the actions list to get to this specific state.</p>
|
||||
|
||||
<h2 id="comments">Comments</h2>
|
||||
<p>Comments allow the team to have one priceless conversation getting and providing feedback right over the designs and prototypes.<p>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
{:extra-paths ["dev"]
|
||||
:extra-deps
|
||||
{thheller/shadow-cljs {:mvn/version "2.28.18"}
|
||||
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
|
||||
org.clojure/tools.namespace {:mvn/version "RELEASE"}
|
||||
criterium/criterium {:mvn/version "RELEASE"}
|
||||
cider/cider-nrepl {:mvn/version "0.48.0"}}}
|
||||
|
||||
:shadow-cljs
|
||||
|
||||
34
frontend/dev/user.clj
Normal file
@@ -0,0 +1,34 @@
|
||||
;; 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 user
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.pprint :as pp]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.tools.namespace.repl :as repl]
|
||||
[clojure.pprint :refer [pprint print-table]]
|
||||
[clojure.repl :refer :all]
|
||||
[clojure.walk :refer [macroexpand-all]]
|
||||
[criterium.core :as crit]))
|
||||
|
||||
;; --- Benchmarking Tools
|
||||
|
||||
(defmacro run-quick-bench
|
||||
[& exprs]
|
||||
`(crit/with-progress-reporting (crit/quick-bench (do ~@exprs) :verbose)))
|
||||
|
||||
(defmacro run-quick-bench'
|
||||
[& exprs]
|
||||
`(crit/quick-bench (do ~@exprs)))
|
||||
|
||||
(defmacro run-bench
|
||||
[& exprs]
|
||||
`(crit/with-progress-reporting (crit/bench (do ~@exprs) :verbose)))
|
||||
|
||||
(defmacro run-bench'
|
||||
[& exprs]
|
||||
`(crit/bench (do ~@exprs)))
|
||||
3392
frontend/externs/main.txt
Normal file
1
frontend/externs/worker.txt
Symbolic link
@@ -0,0 +1 @@
|
||||
main.txt
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"~:is-admin": true,
|
||||
"~:email": "bar@example.com",
|
||||
"~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3",
|
||||
"~:name": "Han Solo",
|
||||
"~:fullname": "Han Solo",
|
||||
"~:is-owner": true,
|
||||
"~:modified-at": "~m1713533116365",
|
||||
"~:can-edit": true,
|
||||
"~:is-active": true,
|
||||
"~:id": "~u1e162163-87b7-805b-8005-5fd05514b6d3",
|
||||
"~:profile-id": "~u1e162163-87b7-805b-8005-5fd05514b6d3",
|
||||
"~:created-at": "~m1733324626956"
|
||||
},
|
||||
{
|
||||
"~:is-admin": true,
|
||||
"~:email": "foo@example.com",
|
||||
"~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3",
|
||||
"~:name": "Princesa Leia",
|
||||
"~:fullname": "Princesa Leia",
|
||||
"~:is-owner": false,
|
||||
"~:modified-at": "~m1713533116365",
|
||||
"~:can-edit": true,
|
||||
"~:is-active": true,
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:profile-id": "~uf56647eb-19a7-8115-8003-b6bc939ecd1b",
|
||||
"~:created-at": "~m1713533116365"
|
||||
}
|
||||
]
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-owner": false,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true
|
||||
},
|
||||
|
||||
@@ -52,3 +52,20 @@ test("Lists files in the drafts page", async ({ page }) => {
|
||||
dashboardPage.page.getByRole("button", { name: /New File 2/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Bug 9443, Admin can not demote owner", async ({ page }) => {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"dashboard/get-team-members-admin.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamMembersSection();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Members" })).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Admin" })).toBeVisible();
|
||||
await expect(page.getByText("Owner")).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Owner" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ test("Save and restore version", async ({ page }) => {
|
||||
"workspace/versions-snapshot-1.json",
|
||||
);
|
||||
|
||||
await page.getByLabel("History (Alt+H)").click();
|
||||
await page.getByLabel("History").click();
|
||||
|
||||
await workspacePage.mockRPC(
|
||||
"create-file-snapshot",
|
||||
@@ -58,10 +58,11 @@ test("Save and restore version", async ({ page }) => {
|
||||
await page.getByRole("textbox").press("Enter");
|
||||
|
||||
await page
|
||||
.locator("li")
|
||||
.filter({ hasText: "INIT" })
|
||||
.getByRole("button")
|
||||
.click();
|
||||
.getByLabel("History", { exact: true })
|
||||
.locator("div")
|
||||
.nth(3)
|
||||
.hover();
|
||||
await page.getByRole("button", { name: "Open version menu" }).click();
|
||||
await page.getByRole("button", { name: "Restore" }).click();
|
||||
|
||||
await workspacePage.mockRPC(
|
||||
@@ -70,4 +71,7 @@ test("Save and restore version", async ({ page }) => {
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Restore" }).click();
|
||||
|
||||
// check that the history panel is closed after restore
|
||||
await expect(page.getByRole("tab", { name: "design" })).toBeVisible();
|
||||
});
|
||||
|
||||
BIN
frontend/resources/images/features/2.4-format.gif
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
frontend/resources/images/features/2.4-history.gif
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
frontend/resources/images/features/2.4-slide-0.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/resources/images/features/2.4-viewer.gif
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.88 1.5H2.945c-.798 0-1.445.647-1.445 1.445v1.74M4.88 14.5H2.945c-.798 0-1.445-.842-1.445-1.64v-1.74m13-6.24V2.945c0-.798-.647-1.445-1.445-1.445H11.12m3.38 9.62v1.935c0 .798-.647 1.445-1.445 1.445H11.12"/>
|
||||
</svg>
|
||||
<path d="M3.5 3.5h-2m2 0v-2m0 2h9m-9 0v9m9-9v-2m0 2h2m-2 0v9m0 0h2m-2 0v2m0-2h-9m0 0v2m0-2h-2"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 214 B |
@@ -41,13 +41,6 @@ body {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
// Firefox-only hack
|
||||
@-moz-document url-prefix() {
|
||||
* {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -237,13 +237,18 @@ async function renderTemplate(path, context = {}, partials = {}) {
|
||||
return mustache.render(content, context, partials);
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
link(href, title, text) {
|
||||
return `<a href="${href}" target="_blank">${text}</a>`;
|
||||
const extension = {
|
||||
useNewRenderer: true,
|
||||
renderer: {
|
||||
link(token) {
|
||||
const href = token.href;
|
||||
const text = token.text;
|
||||
return `<a href="${href}" target="_blank">${text}</a>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
marked.use(extension);
|
||||
|
||||
async function readTranslations() {
|
||||
const langs = [
|
||||
@@ -503,6 +508,7 @@ export async function compileStyles() {
|
||||
const start = process.hrtime();
|
||||
|
||||
log.info("init: compile styles");
|
||||
|
||||
let result = await compileSassAll(worker);
|
||||
result = concatSass(result);
|
||||
|
||||
|
||||
6
frontend/scripts/repl
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export OPTIONS="-A:dev -J-XX:-OmitStackTraceInFastThrow";
|
||||
|
||||
set -ex
|
||||
exec clojure $OPTIONS -M -m rebel-readline.main
|
||||
@@ -24,15 +24,22 @@ async function compileSassAll() {
|
||||
async function compileSass(path) {
|
||||
const start = process.hrtime();
|
||||
log.info("changed:", path);
|
||||
const result = await h.compileSass(worker, path, { modules: true });
|
||||
sass.index[result.outputPath] = result.css;
|
||||
|
||||
const output = h.concatSass(sass);
|
||||
try {
|
||||
const result = await h.compileSass(worker, path, { modules: true });
|
||||
sass.index[result.outputPath] = result.css;
|
||||
|
||||
await fs.writeFile("./resources/public/css/main.css", output);
|
||||
const output = h.concatSass(sass);
|
||||
|
||||
const end = process.hrtime(start);
|
||||
log.info("done:", `(${ppt(end)})`);
|
||||
await fs.writeFile("./resources/public/css/main.css", output);
|
||||
|
||||
const end = process.hrtime(start);
|
||||
log.info("done:", `(${ppt(end)})`);
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
const end = process.hrtime(start);
|
||||
log.error("error:", `(${ppt(end)})`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir("./resources/public/css/", { recursive: true });
|
||||
|
||||
@@ -143,6 +143,16 @@
|
||||
(let [f (obj/get global "externalSessionId")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
(defn external-context-info
|
||||
[]
|
||||
(let [f (obj/get global "externalContextInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
(defn initialize-external-context-info
|
||||
[]
|
||||
(let [f (obj/get global "initializeExternalConfigInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
;; --- Helper Functions
|
||||
|
||||
(defn ^boolean check-browser? [candidate]
|
||||
|
||||
@@ -30,20 +30,21 @@
|
||||
[app.util.i18n :as i18n]
|
||||
[app.util.theme :as theme]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[debug]
|
||||
[features]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(log/setup! {:app :info})
|
||||
(log/set-level! :debug)
|
||||
|
||||
(when (= :browser cf/target)
|
||||
(log/info :version (:full cf/version)
|
||||
:asserts *assert*
|
||||
:build-date cf/build-date
|
||||
:public-uri (dm/str cf/public-uri))
|
||||
(log/info :flags (str/join "," (map name cf/flags))))
|
||||
(log/inf :version (:full cf/version)
|
||||
:asserts *assert*
|
||||
:build-date cf/build-date
|
||||
:public-uri (dm/str cf/public-uri))
|
||||
(doseq [flag cf/flags]
|
||||
(log/dbg :hint "flag enabled" :flag (name flag))))
|
||||
|
||||
(declare reinit)
|
||||
|
||||
|
||||
@@ -195,13 +195,16 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [share-id (-> state :viewer-local :share-id)]
|
||||
(->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id})
|
||||
(rx/catch (fn [{:keys [type code] :as cause}]
|
||||
(if (and (= type :restriction)
|
||||
(= code :max-quote-reached))
|
||||
(rx/throw cause)
|
||||
(rx/throw {:type :comment-error}))))
|
||||
(rx/ignore))))))
|
||||
(rx/concat
|
||||
(when is-resolved (rx/of
|
||||
(ptk/event ::ev/event {::ev/name "resolve-comment-thread" :thread-id id})))
|
||||
(->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id})
|
||||
(rx/catch (fn [{:keys [type code] :as cause}]
|
||||
(if (and (= type :restriction)
|
||||
(= code :max-quote-reached))
|
||||
(rx/throw cause)
|
||||
(rx/throw {:type :comment-error}))))
|
||||
(rx/ignore)))))))
|
||||
|
||||
(defn add-comment
|
||||
[thread content]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.team :as ctt]
|
||||
[app.config :as cf]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.features :as features]
|
||||
@@ -75,15 +74,13 @@
|
||||
(watch [_ _ _]
|
||||
(case code
|
||||
:upgrade-version
|
||||
(when (or (not= (:version params) (:full cf/version))
|
||||
(true? (:force params)))
|
||||
(rx/of (ntf/dialog
|
||||
:content (tr "notifications.by-code.upgrade-version")
|
||||
:controls :inline-actions
|
||||
:type :inline
|
||||
:level level
|
||||
:actions [{:label "Refresh" :callback force-reload!}]
|
||||
:tag :notification)))
|
||||
(rx/of (ntf/dialog
|
||||
:content (tr "notifications.by-code.upgrade-version")
|
||||
:controls :inline-actions
|
||||
:type :inline
|
||||
:level level
|
||||
:actions [{:label "Refresh" :callback force-reload!}]
|
||||
:tag :notification))
|
||||
|
||||
:maintenance
|
||||
(rx/of (ntf/dialog
|
||||
@@ -227,6 +224,11 @@
|
||||
[{:keys [team-id team-name change]}]
|
||||
(dm/assert! (uuid? team-id))
|
||||
(ptk/reify ::team-membership-change
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
;; FIXME: Remove on 2.5
|
||||
(assoc state :current-team-id (dm/get-in state [:profile :default-team-id])))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (= :removed change)
|
||||
|
||||
@@ -450,7 +450,9 @@
|
||||
(ptk/reify ::update-team
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc-in state [:teams id :name] name))
|
||||
(-> state
|
||||
(assoc-in [:teams id :name] name)
|
||||
(assoc-in [:team :name] name)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
@@ -1120,6 +1122,7 @@
|
||||
(ptk/reify ::go-to-projects-1
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
;; FIXME: Remove on 2.5
|
||||
(assoc state :current-team-id team-id))
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
["ua-parser-js" :as ua]
|
||||
[app.common.data :as d]
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.main.repo :as rp]
|
||||
@@ -93,6 +94,11 @@
|
||||
data
|
||||
data))
|
||||
|
||||
(defn add-external-context-info
|
||||
[context]
|
||||
(let [external-context-info (json/->clj (cf/external-context-info))]
|
||||
(merge context external-context-info)))
|
||||
|
||||
(defn- process-event-by-proto
|
||||
[event]
|
||||
(let [data (d/deep-merge (-data event) (meta event))
|
||||
@@ -102,6 +108,7 @@
|
||||
(assoc :event-origin (::origin data))
|
||||
(assoc :event-namespace (namespace type))
|
||||
(assoc :event-symbol ev-name)
|
||||
(add-external-context-info)
|
||||
(d/without-nils))
|
||||
props (-> data d/without-qualified simplify-props)]
|
||||
|
||||
@@ -119,6 +126,7 @@
|
||||
(let [type (::type data "action")
|
||||
context (-> (::context data)
|
||||
(assoc :event-origin (::origin data))
|
||||
(add-external-context-info)
|
||||
(d/without-nils))
|
||||
props (-> data d/without-qualified simplify-props)]
|
||||
{:type type
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.main.data.exports.assets
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.data.workspace.state-helpers :as wsh]
|
||||
@@ -247,6 +248,12 @@
|
||||
(rx/map #(clear-export-state @resource-id))
|
||||
(rx/take-until (rx/delay 6000 stopper))))))))
|
||||
|
||||
(defn request-export
|
||||
[{:keys [exports] :as params}]
|
||||
(if (= 1 (count exports))
|
||||
(request-simple-export (assoc params :export (first exports)))
|
||||
(request-multiple-export params)))
|
||||
|
||||
(defn retry-last-export
|
||||
[]
|
||||
(ptk/reify ::retry-last-export
|
||||
@@ -256,3 +263,16 @@
|
||||
(when (seq params)
|
||||
(rx/of (request-multiple-export params)))))))
|
||||
|
||||
(defn export-shapes-event
|
||||
[exports origin]
|
||||
(let [types (reduce (fn [counts {:keys [type]}]
|
||||
(if (#{:png :pdf :svg :jpeg} type)
|
||||
(update counts type inc)
|
||||
counts))
|
||||
{:png 0, :pdf 0, :svg 0, :jpeg 0}
|
||||
exports)]
|
||||
(ptk/event
|
||||
::ev/event (merge types
|
||||
{::ev/name "export-shapes"
|
||||
::ev/origin origin
|
||||
:num-shapes (count exports)}))))
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(when (is-authenticated? profile)
|
||||
(cf/initialize-external-context-info)
|
||||
(->> (rx/concat
|
||||
(rx/of (profile-fetched profile)
|
||||
(fetch-teams)
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.state-helpers :as wsh]
|
||||
[app.main.data.workspace.texts :as dwtxt]
|
||||
[app.main.data.workspace.thumbnails :as dwth]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
@@ -83,6 +84,7 @@
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.router :as rt]
|
||||
[app.util.storage :as storage]
|
||||
[app.util.text.content :as tc]
|
||||
[app.util.timers :as tm]
|
||||
[app.util.webapi :as wapi]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -199,18 +201,21 @@
|
||||
|
||||
;; Load libraries
|
||||
(->> (rp/cmd! :get-file-libraries {:file-id file-id})
|
||||
(rx/mapcat identity)
|
||||
(rx/merge-map
|
||||
(fn [{:keys [id synced-at]}]
|
||||
(->> (rp/cmd! :get-file {:id id :features features})
|
||||
(rx/map #(assoc % :synced-at synced-at)))))
|
||||
(rx/merge-map fpmap/resolve-file)
|
||||
(rx/merge-map
|
||||
(fn [{:keys [id] :as file}]
|
||||
(->> (rp/cmd! :get-file-object-thumbnails {:file-id id :tag "component"})
|
||||
(rx/map #(assoc file :thumbnails %)))))
|
||||
(rx/reduce conj [])
|
||||
(rx/map libraries-fetched)))
|
||||
(rx/mapcat (fn [libraries]
|
||||
(rx/merge
|
||||
(->> (rx/from libraries)
|
||||
(rx/merge-map
|
||||
(fn [{:keys [id synced-at]}]
|
||||
(->> (rp/cmd! :get-file {:id id :features features})
|
||||
(rx/map #(assoc % :synced-at synced-at)))))
|
||||
(rx/merge-map fpmap/resolve-file)
|
||||
(rx/reduce conj [])
|
||||
(rx/map libraries-fetched))
|
||||
(->> (rx/from libraries)
|
||||
(rx/map :id)
|
||||
(rx/mapcat (fn [file-id]
|
||||
(rp/cmd! :get-file-object-thumbnails {:file-id file-id :tag "component"})))
|
||||
(rx/map dwl/library-thumbnails-fetched)))))))
|
||||
|
||||
(rx/of (with-meta (workspace-initialized)
|
||||
{:file-id file-id})))
|
||||
@@ -1636,6 +1641,7 @@
|
||||
(rx/ignore))))))))))
|
||||
|
||||
(declare ^:private paste-transit)
|
||||
(declare ^:private paste-html-text)
|
||||
(declare ^:private paste-text)
|
||||
(declare ^:private paste-image)
|
||||
(declare ^:private paste-svg-text)
|
||||
@@ -1703,6 +1709,7 @@
|
||||
(let [pdata (wapi/read-from-paste-event event)
|
||||
image-data (some-> pdata wapi/extract-images)
|
||||
text-data (some-> pdata wapi/extract-text)
|
||||
html-data (some-> pdata wapi/extract-html-text)
|
||||
transit-data (ex/ignoring (some-> text-data t/decode-str))]
|
||||
(cond
|
||||
(and (string? text-data) (re-find #"<svg\s" text-data))
|
||||
@@ -1715,6 +1722,9 @@
|
||||
(coll? transit-data)
|
||||
(rx/of (paste-transit (assoc transit-data :in-viewport in-viewport?)))
|
||||
|
||||
(string? html-data)
|
||||
(rx/of (paste-html-text html-data text-data))
|
||||
|
||||
(string? text-data)
|
||||
(rx/of (paste-text text-data))
|
||||
|
||||
@@ -2013,6 +2023,8 @@
|
||||
(map :id)
|
||||
(pcb/resize-parents changes))
|
||||
|
||||
orig-shapes (map (d/getf all-objects) selected)
|
||||
|
||||
selected (into (d/ordered-set)
|
||||
(comp
|
||||
(filter add-obj?)
|
||||
@@ -2025,13 +2037,22 @@
|
||||
(some? drop-cell)
|
||||
(pcb/update-shapes [parent-id]
|
||||
#(ctl/add-children-to-cell % selected all-objects drop-cell)))
|
||||
|
||||
undo-id (js/Symbol)]
|
||||
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(dws/select-shapes selected)
|
||||
(ptk/data-event :layout/update {:ids [frame-id]})
|
||||
(dwu/commit-undo-transaction undo-id)))))))
|
||||
(rx/concat
|
||||
(->> (filter ctk/instance-head? orig-shapes)
|
||||
(map (fn [{:keys [component-file]}]
|
||||
(ptk/event ::ev/event
|
||||
{::ev/name "use-library-component"
|
||||
::ev/origin "paste"
|
||||
:external-library (not= file-id component-file)})))
|
||||
(rx/from))
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(dws/select-shapes selected)
|
||||
(ptk/data-event :layout/update {:ids [frame-id]})
|
||||
(dwu/commit-undo-transaction undo-id))))))))
|
||||
|
||||
(defn as-content [text]
|
||||
(let [paragraphs (->> (str/lines text)
|
||||
@@ -2056,6 +2077,34 @@
|
||||
:else
|
||||
(deref ms/mouse-position)))
|
||||
|
||||
(defn- paste-html-text
|
||||
[html text]
|
||||
(dm/assert! (string? html))
|
||||
(ptk/reify ::paste-html-text
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [root (dwtxt/create-root-from-html html)
|
||||
content (tc/dom->cljs root)
|
||||
|
||||
id (uuid/next)
|
||||
width (max 8 (min (* 7 (count text)) 700))
|
||||
height 16
|
||||
{:keys [x y]} (calculate-paste-position state)
|
||||
|
||||
shape {:id id
|
||||
:type :text
|
||||
:name (txt/generate-shape-name text)
|
||||
:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height
|
||||
:grow-type (if (> (count text) 100) :auto-height :auto-width)
|
||||
:content content}
|
||||
undo-id (js/Symbol)]
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(dwsh/create-and-add-shape :text x y shape)
|
||||
(dwu/commit-undo-transaction undo-id))))))
|
||||
|
||||
(defn- paste-text
|
||||
[text]
|
||||
(dm/assert! (string? text))
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
[app.common.types.shape.interactions :as ctsi]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.state-helpers :as wsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
@@ -168,7 +169,8 @@
|
||||
objects (get page :objects)
|
||||
frame (cfh/get-root-frame objects (:id shape))
|
||||
|
||||
flows (get page :objects)
|
||||
first? (not-any? #(seq (:interactions %)) (vals objects))
|
||||
flows (get page :flows)
|
||||
flow (ctp/get-frame-flow flows (:id frame))]
|
||||
(rx/concat
|
||||
(rx/of (dwsh/update-shapes
|
||||
@@ -177,14 +179,17 @@
|
||||
(let [new-interaction (-> ctsi/default-interaction
|
||||
(ctsi/set-destination destination)
|
||||
(assoc :position-relative-to (:id shape)))]
|
||||
(cls/add-new-interaction shape new-interaction))))
|
||||
(cls/add-new-interaction shape new-interaction)))))
|
||||
|
||||
(when destination
|
||||
(dwsh/update-shapes [destination] cls/show-in-viewer))
|
||||
(when destination
|
||||
(rx/of (dwsh/update-shapes [destination] cls/show-in-viewer)))
|
||||
|
||||
(when (and (not (connected-frame? objects (:id frame)))
|
||||
(nil? flow))
|
||||
(add-flow (:id frame))))))))))
|
||||
(when (and (not (connected-frame? objects (:id frame)))
|
||||
(nil? flow))
|
||||
(rx/of (add-flow (:id frame))))
|
||||
(when first?
|
||||
;; When the first interaction of the page is created we emit the event "create-prototype"
|
||||
(rx/of (ptk/event ::ev/event {::ev/name "create-prototype"})))))))))
|
||||
|
||||
(defn remove-interaction
|
||||
([shape index]
|
||||
|
||||
@@ -191,6 +191,25 @@
|
||||
(watch [it state _]
|
||||
(update-color* it state color file-id)))))
|
||||
|
||||
(defn update-color-data
|
||||
"Update color data without affecting the path location"
|
||||
[color file-id]
|
||||
(let [color (d/without-nils color)]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid color data structure"
|
||||
(ctc/check-color! color))
|
||||
|
||||
(dm/assert!
|
||||
"expected file-id"
|
||||
(uuid? file-id))
|
||||
|
||||
(ptk/reify ::update-color-data
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [color (assoc color :name (dm/str (:path color) "/" (:name color)))]
|
||||
(update-color* it state color file-id))))))
|
||||
|
||||
(defn rename-color
|
||||
[file-id id new-name]
|
||||
(dm/assert!
|
||||
@@ -586,7 +605,7 @@
|
||||
in the given file library. Then selects the newly created instance."
|
||||
([file-id component-id position]
|
||||
(instantiate-component file-id component-id position nil))
|
||||
([file-id component-id position {:keys [start-move? initial-point id-ref]}]
|
||||
([file-id component-id position {:keys [start-move? initial-point id-ref origin]}]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? component-id))
|
||||
(dm/assert! (gpt/point? position))
|
||||
@@ -600,6 +619,8 @@
|
||||
changes (-> (pcb/empty-changes it (:id page))
|
||||
(pcb/with-objects objects))
|
||||
|
||||
current-file-id (:current-file-id state)
|
||||
|
||||
[new-shape changes]
|
||||
(cll/generate-instantiate-component changes
|
||||
objects
|
||||
@@ -608,12 +629,18 @@
|
||||
position
|
||||
page
|
||||
libraries)
|
||||
|
||||
undo-id (js/Symbol)]
|
||||
|
||||
(when id-ref
|
||||
(reset! id-ref (:id new-shape)))
|
||||
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(rx/of (ptk/event
|
||||
::ev/event
|
||||
{::ev/name "use-library-component"
|
||||
::ev/origin origin
|
||||
:external-library (not= file-id current-file-id)})
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(ptk/data-event :layout/update {:ids [(:id new-shape)]})
|
||||
(dws/select-shapes (d/ordered-set (:id new-shape)))
|
||||
@@ -881,11 +908,15 @@
|
||||
(rx/of
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(update-component shape-id undo-group)
|
||||
(sync-file current-file-id file-id :components (:component-id shape) undo-group)
|
||||
|
||||
;; These two calls are necessary for properly sync thumbnails
|
||||
;; when a main component does not live in the same page
|
||||
(update-component-thumbnail-sync state component-id file-id "frame")
|
||||
(update-component-thumbnail-sync state component-id file-id "component")
|
||||
|
||||
(sync-file current-file-id file-id :components component-id undo-group)
|
||||
(when (not current-file?)
|
||||
(sync-file file-id file-id :components (:component-id shape) undo-group))
|
||||
(sync-file file-id file-id :components component-id undo-group))
|
||||
(dwu/commit-undo-transaction undo-id)))))))
|
||||
|
||||
(defn launch-component-sync
|
||||
@@ -937,9 +968,9 @@
|
||||
;; in the grid creating new rows/columns to make space
|
||||
(let [file (wsh/get-file state file-id)
|
||||
libraries (wsh/get-libraries state)
|
||||
page (wsh/lookup-page state)
|
||||
objects (wsh/lookup-page-objects state)
|
||||
parent (get objects (:parent-id shape))
|
||||
page (wsh/lookup-page state)
|
||||
objects (wsh/lookup-page-objects state)
|
||||
parent (get objects (:parent-id shape))
|
||||
|
||||
;; If the target parent is a grid layout we need to pass the target cell
|
||||
target-cell (when (ctl/grid-layout? parent)
|
||||
|
||||
@@ -456,22 +456,30 @@
|
||||
|
||||
id-duplicated (first new-ids)
|
||||
|
||||
frames (into #{}
|
||||
(map #(get-in objects [% :frame-id]))
|
||||
ids)
|
||||
undo-id (js/Symbol)]
|
||||
|
||||
frames (into #{}
|
||||
(map #(get-in objects [% :frame-id]))
|
||||
ids)
|
||||
undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
(->> (map (d/getf objects) ids)
|
||||
(filter ctk/instance-head?)
|
||||
(map (fn [{:keys [component-file]}]
|
||||
(ptk/event ::ev/event
|
||||
{::ev/name "use-library-component"
|
||||
::ev/origin "duplicate"
|
||||
:external-library (not= file-id component-file)})))
|
||||
(rx/from))
|
||||
;; Warning: This order is important for the focus mode.
|
||||
(->> (rx/of
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(when change-selection?
|
||||
(select-shapes new-ids))
|
||||
(ptk/data-event :layout/update {:ids frames})
|
||||
(memorize-duplicated id-original id-duplicated)
|
||||
(dwu/commit-undo-transaction undo-id))
|
||||
(rx/tap #(when (some? return-ref)
|
||||
(reset! return-ref id-duplicated))))))))))
|
||||
(->> (rx/of
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(when change-selection?
|
||||
(select-shapes new-ids))
|
||||
(ptk/data-event :layout/update {:ids frames})
|
||||
(memorize-duplicated id-original id-duplicated)
|
||||
(dwu/commit-undo-transaction undo-id))
|
||||
(rx/tap #(when (some? return-ref)
|
||||
(reset! return-ref id-duplicated)))))))))))
|
||||
|
||||
(defn duplicate-selected
|
||||
([move-delta?]
|
||||
|
||||
@@ -447,11 +447,6 @@
|
||||
:subsections [:panels]
|
||||
:fn #(st/emit! (dw/go-to-layout :assets))}
|
||||
|
||||
:toggle-history {:tooltip (ds/alt "H")
|
||||
:command (ds/a-mod "h")
|
||||
:subsections [:panels]
|
||||
:fn #(emit-when-no-readonly (dw/go-to-layout :document-history))}
|
||||
|
||||
:toggle-colorpalette {:tooltip (ds/alt "P")
|
||||
:command (ds/a-mod "p")
|
||||
:subsections [:panels]
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
;; -- V2 Editor Helpers
|
||||
|
||||
(def ^function create-root-from-string editor.v2/createRootFromString)
|
||||
(def ^function create-root-from-html editor.v2/createRootFromHTML)
|
||||
(def ^function create-editor editor.v2/create)
|
||||
(def ^function set-editor-root! editor.v2/setRoot)
|
||||
(def ^function get-editor-root editor.v2/getRoot)
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
(defonce queue
|
||||
(q/create find-request (/ 1000 30)))
|
||||
|
||||
(defn clear-queue!
|
||||
[]
|
||||
(l/dbg :hint "clearing thumbnail queue")
|
||||
(q/clear! queue))
|
||||
|
||||
;; This function first renders the HTML calling `render/render-frame` that
|
||||
;; returns HTML as a string, then we send that data to the iframe rasterizer
|
||||
;; that returns the image as a Blob. Finally we create a URI for that blob.
|
||||
@@ -168,7 +173,7 @@
|
||||
(.error js/console cause)
|
||||
(rx/empty)))
|
||||
|
||||
(rx/tap #(l/trc :hint "thumbnail updated" :elapsed (dm/str (tp) "ms")))
|
||||
(rx/tap #(l/dbg :hint "thumbnail updated" :elapsed (dm/str (tp) "ms")))
|
||||
|
||||
;; We cancel all the stream if user starts editing while
|
||||
;; thumbnail is generating
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.thumbnails :as th]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[app.util.time :as dt]
|
||||
@@ -126,11 +127,13 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/concat
|
||||
(rx/of ::dwp/force-persist)
|
||||
(rx/of ::dwp/force-persist
|
||||
(dw/remove-layout-flag :document-history))
|
||||
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
|
||||
(rx/filter #(or (nil? %) (= :saved %)))
|
||||
(rx/take 1)
|
||||
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
|
||||
(rx/tap #(th/clear-queue!))
|
||||
(rx/map #(dw/initialize-file project-id file-id)))
|
||||
(case origin
|
||||
:version
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.config :as cf]
|
||||
[app.main.data.workspace.state-helpers :as wsh]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.workspace.tokens.token-set :as wtts]
|
||||
@@ -582,7 +583,8 @@
|
||||
[object-id]
|
||||
(l/derived
|
||||
(fn [state]
|
||||
(dm/get-in state [:workspace-thumbnails object-id]))
|
||||
(some-> (dm/get-in state [:workspace-thumbnails object-id])
|
||||
(cf/resolve-media)))
|
||||
st/state))
|
||||
|
||||
(def workspace-text-modifier
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
[{:keys [shape]}]
|
||||
(let [thumbnails? (mf/use-ctx muc/render-thumbnails)
|
||||
childs (mapv (d/getf objects) (:shapes shape))]
|
||||
(if (and thumbnails? (some? (:thumbnail shape)))
|
||||
(if (and thumbnails? (some? (:thumbnail-id shape)))
|
||||
[:& frame/frame-thumbnail {:shape shape :bounds (:children-bounds shape)}]
|
||||
[:& frame-shape {:shape shape :childs childs}])))))
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
;; used to render thumbnails on assets panel.
|
||||
(mf/defc component-svg
|
||||
{::mf/wrap [mf/memo #(mf/deferred % ts/idle-then-raf)]}
|
||||
[{:keys [objects root-shape show-grids? zoom class] :or {zoom 1} :as props}]
|
||||
[{:keys [objects root-shape show-grids? is-hidden zoom class] :or {zoom 1} :as props}]
|
||||
(when root-shape
|
||||
(let [root-shape-id (:id root-shape)
|
||||
include-metadata (mf/use-ctx export/include-metadata-ctx)
|
||||
@@ -381,13 +381,14 @@
|
||||
:xmlns:penpot (when include-metadata "https://penpot.app/xmlns")
|
||||
:fill "none"}
|
||||
|
||||
[:*
|
||||
[:> shape-container {:shape root-shape'}
|
||||
[:& (mf/provider muc/is-component?) {:value true}
|
||||
[:& root-shape-wrapper {:shape root-shape' :view-box vbox}]]]
|
||||
(when-not is-hidden
|
||||
[:*
|
||||
[:> shape-container {:shape root-shape'}
|
||||
[:& (mf/provider muc/is-component?) {:value true}
|
||||
[:& root-shape-wrapper {:shape root-shape' :view-box vbox}]]]
|
||||
|
||||
(when show-grids?
|
||||
[:& empty-grids {:root-shape-id root-shape-id :objects objects}])]])))
|
||||
(when show-grids?
|
||||
[:& empty-grids {:root-shape-id root-shape-id :objects objects}])])])))
|
||||
|
||||
(mf/defc component-svg-thumbnail
|
||||
{::mf/wrap [mf/memo #(mf/deferred % ts/idle-then-raf)]}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
|
||||
show-release-modal?
|
||||
(and (contains? cf/flags :onboarding)
|
||||
(not (contains? cf/flags :hide-release-modal))
|
||||
(:onboarding-viewed props)
|
||||
(not= (:release-notes-viewed props) (:main cf/version))
|
||||
(not= "0.0" (:main cf/version)))]
|
||||
@@ -106,7 +107,7 @@
|
||||
:dashboard-team-webhooks
|
||||
:dashboard-team-settings)
|
||||
[:?
|
||||
#_[:& app.main.ui.releases/release-notes-modal {:version "2.3"}]
|
||||
#_[:& app.main.ui.releases/release-notes-modal {:version "2.4"}]
|
||||
#_[:& app.main.ui.onboarding/onboarding-templates-modal]
|
||||
#_[:& app.main.ui.onboarding/onboarding-modal]
|
||||
#_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal]
|
||||
|
||||