Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ca2b20e4 | ||
|
|
a2ce5efe69 | ||
|
|
15a896e050 | ||
|
|
8145eb89d7 | ||
|
|
a6485b93b7 | ||
|
|
47f1ca9627 | ||
|
|
f252ffb201 | ||
|
|
0768ef1b8f | ||
|
|
d9ba107da2 | ||
|
|
407b664910 | ||
|
|
31145f2805 | ||
|
|
33192cfdb8 | ||
|
|
471699960f | ||
|
|
7458a35f31 | ||
|
|
2ef22ecd08 | ||
|
|
9c60d1cdf9 | ||
|
|
080dc4b93c | ||
|
|
f0966070eb | ||
|
|
b1d053893c | ||
|
|
c2fb9f4c6f | ||
|
|
19a26e46dc | ||
|
|
efd4a11ae2 | ||
|
|
68bd8152b8 | ||
|
|
9e47a70adf | ||
|
|
fae73a198c | ||
|
|
6be1023c0a | ||
|
|
9bfee99672 | ||
|
|
7ca98ddf21 | ||
|
|
15157c54b1 | ||
|
|
240f658c3a | ||
|
|
31bc7e7c86 | ||
|
|
b3a5e6710f | ||
|
|
232b29cd89 | ||
|
|
da0704081f | ||
|
|
066b1235a6 | ||
|
|
141694dc8d | ||
|
|
85c1de4bda | ||
|
|
151aedcf91 | ||
|
|
5513daf17d | ||
|
|
fde0f3c182 | ||
|
|
d3ad15f19a | ||
|
|
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 | ||
|
|
53f55444cd |
45
CHANGES.md
@@ -1,5 +1,33 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.4.3
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix errors from editable select on measures menu [Taiga #9888](https://tree.taiga.io/project/penpot/issue/9888)
|
||||
- Fix exception on importing some templates from templates slider
|
||||
- Consolidate adding share button to workspace
|
||||
- Fix problem when pasting text [Taiga #9929](https://tree.taiga.io/project/penpot/issue/9929)
|
||||
- Fix incorrect media reference handling on component instantiation
|
||||
|
||||
|
||||
## 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
|
||||
@@ -11,25 +39,28 @@
|
||||
(penpot). Because of that, the default NGINX listen port is now 8080 instead of 80, so
|
||||
you will have to modify your infrastructure to apply this change.
|
||||
|
||||
- Redis 7.2 is explicitly pinned in our example docker-compose.yml file. This is done because,
|
||||
starting with the next versions, Redis is no longer distributed under an open-source license.
|
||||
On-premise users are obviously free to upgrade to the version they are using or a more modern one.
|
||||
Keep in mind that if you were using a version other than 7.2, you may have to recreate the volume
|
||||
associated with the Redis container because the 7.2 storage format may not be compatible with what
|
||||
you already have stored on the volume, and Redis may not start. In the near future, we will evaluate
|
||||
- Redis 7.2 is explicitly pinned in our example docker-compose.yml file. This is done because,
|
||||
starting with the next versions, Redis is no longer distributed under an open-source license.
|
||||
On-premise users are obviously free to upgrade to the version they are using or a more modern one.
|
||||
Keep in mind that if you were using a version other than 7.2, you may have to recreate the volume
|
||||
associated with the Redis container because the 7.2 storage format may not be compatible with what
|
||||
you already have stored on the volume, and Redis may not start. In the near future, we will evaluate
|
||||
whether to move to an open-source version of Redis (such as https://valkey.io/).
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
### :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)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -114,37 +114,13 @@ Debug Main Page
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Import binfile:</legend>
|
||||
<desc>Import penpot file in binary
|
||||
format. If <strong>overwrite</strong> is checked, all files will
|
||||
be overwritten using the same ids found in the file instead of
|
||||
generating a new ones.</desc>
|
||||
<desc>Import penpot file in binary format.</desc>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" action="/dbg/file/import">
|
||||
<div class="row">
|
||||
<input type="file" name="file" value="" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Overwrite?</label>
|
||||
<input type="checkbox" name="overwrite" />
|
||||
<br />
|
||||
<small>
|
||||
Instead of creating a new file with all relations remapped,
|
||||
reuses all ids and updates/overwrites the objects that are
|
||||
already exists on the database.
|
||||
<strong>Warning, this operation should be used with caution.</strong>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Migrate?</label>
|
||||
<input type="checkbox" name="migrate" />
|
||||
<br />
|
||||
<small>
|
||||
Applies the file migrations on the importation process.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" name="upload" value="Upload" />
|
||||
</div>
|
||||
|
||||
@@ -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})))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.validate :as fval]
|
||||
[app.common.logging :as l]
|
||||
@@ -29,8 +30,9 @@
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
[clojure.set :as set]
|
||||
[clojure.walk :as walk]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -52,6 +54,20 @@
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn parse-file-format
|
||||
[template]
|
||||
(assert (fs/path? template) "expected InputStream for `template`")
|
||||
|
||||
(with-open [^java.lang.AutoCloseable input (io/input-stream template)]
|
||||
(let [buffer (byte-array 4)]
|
||||
(io/read-to-buffer input buffer)
|
||||
(if (and (= (aget buffer 0) 80)
|
||||
(= (aget buffer 1) 75)
|
||||
(= (aget buffer 2) 3)
|
||||
(= (aget buffer 3) 4))
|
||||
:binfile-v3
|
||||
:binfile-v1))))
|
||||
|
||||
(def xf-map-id
|
||||
(map :id))
|
||||
|
||||
@@ -225,40 +241,65 @@
|
||||
:data nil}
|
||||
{::sql/columns [:media-id :file-id :revn]}))
|
||||
|
||||
(def ^:private sql:get-missing-media-references
|
||||
"SELECT fmo.*
|
||||
FROM file_media_object AS fmo
|
||||
WHERE fmo.id = ANY(?::uuid[])
|
||||
AND file_id != ?")
|
||||
|
||||
(def ^:private
|
||||
xform:collect-media-id
|
||||
(comp
|
||||
(map :objects)
|
||||
(mapcat vals)
|
||||
(mapcat (fn [obj]
|
||||
;; NOTE: because of some bug, we ended with
|
||||
;; many shape types having the ability to
|
||||
;; have fill-image attribute (which initially
|
||||
;; designed for :path shapes).
|
||||
(sequence
|
||||
(keep :id)
|
||||
(concat [(:fill-image obj)
|
||||
(:metadata obj)]
|
||||
(map :fill-image (:fills obj))
|
||||
(map :stroke-image (:strokes obj))
|
||||
(->> (:content obj)
|
||||
(tree-seq map? :children)
|
||||
(mapcat :fills)
|
||||
(map :fill-image))))))))
|
||||
(defn update-media-references!
|
||||
"Given a file and a coll of media-refs, check if all provided
|
||||
references are correct or fix them in-place"
|
||||
[{:keys [::db/conn] :as cfg} {file-id :id :as file} media-refs]
|
||||
(let [missing-index
|
||||
(reduce (fn [result {:keys [id] :as fmo}]
|
||||
(assoc result id
|
||||
(-> fmo
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :file-id file-id)
|
||||
(dissoc :created-at)
|
||||
(dissoc :deleted-at))))
|
||||
{}
|
||||
(db/exec! conn [sql:get-missing-media-references
|
||||
(->> (into #{} xf-map-id media-refs)
|
||||
(db/create-array conn "uuid"))
|
||||
file-id]))
|
||||
|
||||
(defn collect-used-media
|
||||
"Given a fdata (file data), returns all media references."
|
||||
[data]
|
||||
(-> #{}
|
||||
(into xform:collect-media-id (vals (:pages-index data)))
|
||||
(into xform:collect-media-id (vals (:components data)))
|
||||
(into (keys (:media data)))))
|
||||
lookup-index
|
||||
(fn [id]
|
||||
(if-let [mobj (get missing-index id)]
|
||||
(do
|
||||
(l/trc :hint "lookup index"
|
||||
:file-id (str file-id)
|
||||
:snap-id (str (:snapshot-id file))
|
||||
:id (str id)
|
||||
:result (str (get mobj :id)))
|
||||
(get mobj :id))
|
||||
|
||||
id))
|
||||
|
||||
update-shapes
|
||||
(fn [data {:keys [page-id shape-id]}]
|
||||
(d/update-in-when data [:pages-index page-id :objects shape-id] cfh/relink-media-refs lookup-index))
|
||||
|
||||
file
|
||||
(update file :data #(reduce update-shapes % media-refs))]
|
||||
|
||||
(doseq [[old-id item] missing-index]
|
||||
(l/dbg :hint "create missing references"
|
||||
:file-id (str file-id)
|
||||
:snap-id (str (:snapshot-id file))
|
||||
:old-id (str old-id)
|
||||
:id (str (:id item)))
|
||||
(db/insert! conn :file-media-object item
|
||||
{::db/return-keys false}))
|
||||
|
||||
file))
|
||||
|
||||
(defn get-file-media
|
||||
[cfg {:keys [data id] :as file}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids (collect-used-media data)
|
||||
(let [ids (cfh/collect-used-media data)
|
||||
ids (db/create-array conn "uuid" ids)
|
||||
sql (str "SELECT * FROM file_media_object WHERE id = ANY(?)")]
|
||||
|
||||
@@ -311,48 +352,7 @@
|
||||
replace the old :component-file reference with the new
|
||||
ones, using the provided file-index."
|
||||
[data]
|
||||
(letfn [(process-map-form [form]
|
||||
(cond-> form
|
||||
;; Relink image shapes
|
||||
(and (map? (:metadata form))
|
||||
(= :image (:type form)))
|
||||
(update-in [:metadata :id] lookup-index)
|
||||
|
||||
;; Relink paths with fill image
|
||||
(map? (:fill-image form))
|
||||
(update-in [:fill-image :id] lookup-index)
|
||||
|
||||
;; This covers old shapes and the new :fills.
|
||||
(uuid? (:fill-color-ref-file form))
|
||||
(update :fill-color-ref-file lookup-index)
|
||||
|
||||
;; This covers the old shapes and the new :strokes
|
||||
(uuid? (:stroke-color-ref-file form))
|
||||
(update :stroke-color-ref-file lookup-index)
|
||||
|
||||
;; This covers all text shapes that have typography referenced
|
||||
(uuid? (:typography-ref-file form))
|
||||
(update :typography-ref-file lookup-index)
|
||||
|
||||
;; This covers the component instance links
|
||||
(uuid? (:component-file form))
|
||||
(update :component-file lookup-index)
|
||||
|
||||
;; This covers the shadows and grids (they have directly
|
||||
;; the :file-id prop)
|
||||
(uuid? (:file-id form))
|
||||
(update :file-id lookup-index)))
|
||||
|
||||
(process-form [form]
|
||||
(if (map? form)
|
||||
(try
|
||||
(process-map-form form)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "failed form" :form (pr-str form) ::l/sync? true)
|
||||
(throw cause)))
|
||||
form))]
|
||||
|
||||
(walk/postwalk process-form data)))
|
||||
(cfh/relink-media-refs data lookup-index))
|
||||
|
||||
(defn- relink-media
|
||||
"A function responsible of process the :media attr of file data and
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
(defmulti write-section ::section)
|
||||
|
||||
(defn write-export!
|
||||
[{:keys [::include-libraries ::embed-assets] :as cfg}]
|
||||
[{:keys [::bfc/include-libraries ::bfc/embed-assets] :as cfg}]
|
||||
(when (and include-libraries embed-assets)
|
||||
(throw (IllegalArgumentException.
|
||||
"the `include-libraries` and `embed-assets` are mutally excluding options")))
|
||||
@@ -323,7 +323,7 @@
|
||||
[:v1/metadata :v1/files :v1/rels :v1/sobjects]))))
|
||||
|
||||
(defmethod write-section :v1/metadata
|
||||
[{:keys [::output ::ids ::include-libraries] :as cfg}]
|
||||
[{:keys [::output ::bfc/ids ::bfc/include-libraries] :as cfg}]
|
||||
(if-let [fids (get-files cfg ids)]
|
||||
(let [lids (when include-libraries
|
||||
(bfc/get-libraries cfg ids))
|
||||
@@ -335,7 +335,7 @@
|
||||
:hint "unable to retrieve files for export")))
|
||||
|
||||
(defmethod write-section :v1/files
|
||||
[{:keys [::output ::embed-assets ::include-libraries] :as cfg}]
|
||||
[{:keys [::output ::bfc/embed-assets ::bfc/include-libraries] :as cfg}]
|
||||
|
||||
;; Initialize SIDS with empty vector
|
||||
(vswap! bfc/*state* assoc :sids [])
|
||||
@@ -382,7 +382,7 @@
|
||||
(vswap! bfc/*state* update :sids into bfc/xf-map-media-id thumbnails))))
|
||||
|
||||
(defmethod write-section :v1/rels
|
||||
[{:keys [::output ::include-libraries] :as cfg}]
|
||||
[{:keys [::output ::bfc/include-libraries] :as cfg}]
|
||||
(let [ids (-> bfc/*state* deref :files set)
|
||||
rels (when include-libraries
|
||||
(bfc/get-files-rels cfg ids))]
|
||||
@@ -421,15 +421,15 @@
|
||||
(defmulti read-import ::version)
|
||||
(defmulti read-section ::section)
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::input io/input-stream?)
|
||||
(s/def ::bfc/profile-id ::us/uuid)
|
||||
(s/def ::bfc/project-id ::us/uuid)
|
||||
(s/def ::bfc/input io/input-stream?)
|
||||
(s/def ::overwrite? (s/nilable ::us/boolean))
|
||||
(s/def ::ignore-index-errors? (s/nilable ::us/boolean))
|
||||
|
||||
;; FIXME: replace with schema
|
||||
(s/def ::read-import-options
|
||||
(s/keys :req [::db/pool ::sto/storage ::project-id ::profile-id ::input]
|
||||
(s/keys :req [::db/pool ::sto/storage ::bfc/project-id ::bfc/profile-id ::bfc/input]
|
||||
:opt [::overwrite? ::ignore-index-errors?]))
|
||||
|
||||
(defn read-import!
|
||||
@@ -439,7 +439,7 @@
|
||||
|
||||
`::bfc/overwrite`: if true, instead of creating new files and remapping id references,
|
||||
it reuses all ids and updates existing objects; defaults to `false`."
|
||||
[{:keys [::input ::bfc/timestamp] :or {timestamp (dt/now)} :as options}]
|
||||
[{:keys [::bfc/input ::bfc/timestamp] :or {timestamp (dt/now)} :as options}]
|
||||
|
||||
(dm/assert!
|
||||
"expected input stream"
|
||||
@@ -453,7 +453,7 @@
|
||||
(read-import (assoc options ::version version ::bfc/timestamp timestamp))))
|
||||
|
||||
(defn- read-import-v1
|
||||
[{:keys [::db/conn ::project-id ::profile-id ::input] :as cfg}]
|
||||
[{:keys [::db/conn ::bfc/project-id ::bfc/profile-id ::bfc/input] :as cfg}]
|
||||
|
||||
(bfc/disable-database-timeouts! cfg)
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
(let [options (-> cfg
|
||||
(assoc ::bfc/features features)
|
||||
(assoc ::section section)
|
||||
(assoc ::input input))]
|
||||
(assoc ::bfc/input input))]
|
||||
(binding [bfc/*options* options]
|
||||
(events/tap :progress {:op :import :section section})
|
||||
(read-section options))))
|
||||
@@ -491,7 +491,7 @@
|
||||
(db/tx-run! options read-import-v1))
|
||||
|
||||
(defmethod read-section :v1/metadata
|
||||
[{:keys [::input]}]
|
||||
[{:keys [::bfc/input]}]
|
||||
(let [{:keys [version files]} (read-obj! input)]
|
||||
(l/dbg :hint "metadata readed"
|
||||
:version (:full version)
|
||||
@@ -509,7 +509,7 @@
|
||||
thumbnails))
|
||||
|
||||
(defmethod read-section :v1/files
|
||||
[{:keys [::db/conn ::input ::project-id ::bfc/overwrite ::name] :as system}]
|
||||
[{:keys [::db/conn ::bfc/input ::bfc/project-id ::bfc/overwrite ::bfc/name] :as system}]
|
||||
|
||||
(doseq [[idx expected-file-id] (d/enumerate (-> bfc/*state* deref :files))]
|
||||
(let [file (read-obj! input)
|
||||
@@ -576,7 +576,7 @@
|
||||
file-id'))))
|
||||
|
||||
(defmethod read-section :v1/rels
|
||||
[{:keys [::db/conn ::input ::bfc/timestamp]}]
|
||||
[{:keys [::db/conn ::bfc/input ::bfc/timestamp]}]
|
||||
(let [rels (read-obj! input)
|
||||
ids (into #{} (-> bfc/*state* deref :files))]
|
||||
;; Insert all file relations
|
||||
@@ -600,7 +600,7 @@
|
||||
::l/sync? true))))))
|
||||
|
||||
(defmethod read-section :v1/sobjects
|
||||
[{:keys [::db/conn ::input ::bfc/overwrite ::bfc/timestamp] :as cfg}]
|
||||
[{:keys [::db/conn ::bfc/input ::bfc/overwrite ::bfc/timestamp] :as cfg}]
|
||||
(let [storage (sto/resolve cfg)
|
||||
ids (read-obj! input)
|
||||
thumb? (into #{} (map :media-id) (:thumbnails @bfc/*state*))]
|
||||
@@ -674,17 +674,17 @@
|
||||
"Do the exportation of a specified file in custom penpot binary
|
||||
format. There are some options available for customize the output:
|
||||
|
||||
`::include-libraries`: additionally to the specified file, all the
|
||||
`::bfc/include-libraries`: additionally to the specified file, all the
|
||||
linked libraries also will be included (including transitive
|
||||
dependencies).
|
||||
|
||||
`::embed-assets`: instead of including the libraries, embed in the
|
||||
`::bfc/embed-assets`: instead of including the libraries, embed in the
|
||||
same file library all assets used from external libraries."
|
||||
|
||||
[{:keys [::ids] :as cfg} output]
|
||||
[{:keys [::bfc/ids] :as cfg} output]
|
||||
|
||||
(dm/assert!
|
||||
"expected a set of uuid's for `::ids` parameter"
|
||||
"expected a set of uuid's for `::bfc/ids` parameter"
|
||||
(and (set? ids)
|
||||
(every? uuid? ids)))
|
||||
|
||||
@@ -719,12 +719,12 @@
|
||||
:cause @cs)))))
|
||||
|
||||
(defn import-files!
|
||||
[{:keys [::input] :as cfg}]
|
||||
[{:keys [::bfc/input] :as cfg}]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid profile-id and project-id on `cfg`"
|
||||
(and (uuid? (::profile-id cfg))
|
||||
(uuid? (::project-id cfg))))
|
||||
(and (uuid? (::bfc/profile-id cfg))
|
||||
(uuid? (::bfc/project-id cfg))))
|
||||
|
||||
(dm/assert!
|
||||
"expected instance of jio/IOFactory for `input`"
|
||||
@@ -738,7 +738,7 @@
|
||||
(try
|
||||
(binding [*position* (atom 0)]
|
||||
(pu/with-open [input (io/input-stream input)]
|
||||
(read-import! (assoc cfg ::input input))))
|
||||
(read-import! (assoc cfg ::bfc/input input))))
|
||||
|
||||
(catch ZstdIOException cause
|
||||
(ex/raise :type :validation
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
(.closeEntry output))
|
||||
|
||||
(defn- get-file
|
||||
[{:keys [::embed-assets ::include-libraries] :as cfg} file-id]
|
||||
[{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id]
|
||||
|
||||
(when (and include-libraries embed-assets)
|
||||
(throw (IllegalArgumentException.
|
||||
@@ -330,7 +330,7 @@
|
||||
(write-entry! output path color)))))
|
||||
|
||||
(defn- export-files
|
||||
[{:keys [::ids ::include-libraries ::output] :as cfg}]
|
||||
[{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}]
|
||||
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
|
||||
rels (if include-libraries
|
||||
(->> (bfc/get-files-rels cfg ids)
|
||||
@@ -509,7 +509,7 @@
|
||||
(json/read reader :key-fn json/read-kebab-key)))
|
||||
|
||||
(defn- read-file
|
||||
[{:keys [::input ::file-id]}]
|
||||
[{:keys [::bfc/input ::file-id]}]
|
||||
(let [path (str "files/" file-id ".json")
|
||||
entry (get-zip-entry input path)]
|
||||
(-> (read-entry input entry)
|
||||
@@ -517,7 +517,7 @@
|
||||
(validate-file))))
|
||||
|
||||
(defn- read-file-plugin-data
|
||||
[{:keys [::input ::file-id]}]
|
||||
[{:keys [::bfc/input ::file-id]}]
|
||||
(let [path (str "files/" file-id "/plugin-data.json")
|
||||
entry (get-zip-entry* input path)]
|
||||
(some->> entry
|
||||
@@ -526,7 +526,7 @@
|
||||
(validate-plugin-data))))
|
||||
|
||||
(defn- read-file-media
|
||||
[{:keys [::input ::file-id ::entries]}]
|
||||
[{:keys [::bfc/input ::file-id ::entries]}]
|
||||
(->> (keep (match-media-entry-fn file-id) entries)
|
||||
(reduce (fn [result {:keys [id entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -540,7 +540,7 @@
|
||||
(not-empty)))
|
||||
|
||||
(defn- read-file-colors
|
||||
[{:keys [::input ::file-id ::entries]}]
|
||||
[{:keys [::bfc/input ::file-id ::entries]}]
|
||||
(->> (keep (match-color-entry-fn file-id) entries)
|
||||
(reduce (fn [result {:keys [id entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -553,7 +553,7 @@
|
||||
(not-empty)))
|
||||
|
||||
(defn- read-file-components
|
||||
[{:keys [::input ::file-id ::entries]}]
|
||||
[{:keys [::bfc/input ::file-id ::entries]}]
|
||||
(->> (keep (match-component-entry-fn file-id) entries)
|
||||
(reduce (fn [result {:keys [id entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -566,7 +566,7 @@
|
||||
(not-empty)))
|
||||
|
||||
(defn- read-file-typographies
|
||||
[{:keys [::input ::file-id ::entries]}]
|
||||
[{:keys [::bfc/input ::file-id ::entries]}]
|
||||
(->> (keep (match-typography-entry-fn file-id) entries)
|
||||
(reduce (fn [result {:keys [id entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -579,7 +579,7 @@
|
||||
(not-empty)))
|
||||
|
||||
(defn- read-file-shapes
|
||||
[{:keys [::input ::file-id ::page-id ::entries] :as cfg}]
|
||||
[{:keys [::bfc/input ::file-id ::page-id ::entries] :as cfg}]
|
||||
(->> (keep (match-shape-entry-fn file-id page-id) entries)
|
||||
(reduce (fn [result {:keys [id entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -592,7 +592,7 @@
|
||||
(not-empty)))
|
||||
|
||||
(defn- read-file-pages
|
||||
[{:keys [::input ::file-id ::entries] :as cfg}]
|
||||
[{:keys [::bfc/input ::file-id ::entries] :as cfg}]
|
||||
(->> (keep (match-page-entry-fn file-id) entries)
|
||||
(keep (fn [{:keys [id entry]}]
|
||||
(let [page (->> (read-entry input entry)
|
||||
@@ -608,7 +608,7 @@
|
||||
(d/ordered-map))))
|
||||
|
||||
(defn- read-file-thumbnails
|
||||
[{:keys [::input ::file-id ::entries] :as cfg}]
|
||||
[{:keys [::bfc/input ::file-id ::entries] :as cfg}]
|
||||
(->> (keep (match-thumbnail-entry-fn file-id) entries)
|
||||
(reduce (fn [result {:keys [page-id frame-id tag entry]}]
|
||||
(let [object (->> (read-entry input entry)
|
||||
@@ -638,7 +638,7 @@
|
||||
:plugin-data plugin-data}))
|
||||
|
||||
(defn- import-file
|
||||
[{:keys [::db/conn ::project-id ::file-id ::file-name] :as cfg}]
|
||||
[{:keys [::db/conn ::bfc/project-id ::file-id ::file-name] :as cfg}]
|
||||
(let [file-id' (bfc/lookup-index file-id)
|
||||
file (read-file cfg)
|
||||
media (read-file-media cfg)
|
||||
@@ -714,7 +714,7 @@
|
||||
:library-file-id libr-id})))))
|
||||
|
||||
(defn- import-storage-objects
|
||||
[{:keys [::input ::entries ::bfc/timestamp] :as cfg}]
|
||||
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
|
||||
(events/tap :progress {:section :storage-objects})
|
||||
|
||||
(let [storage (sto/resolve cfg)
|
||||
@@ -810,7 +810,7 @@
|
||||
{::db/on-conflict-do-nothing? (::bfc/overwrite cfg)}))))
|
||||
|
||||
(defn- import-files
|
||||
[{:keys [::bfc/timestamp ::input ::name] :or {timestamp (dt/now)} :as cfg}]
|
||||
[{:keys [::bfc/timestamp ::bfc/input ::bfc/name] :or {timestamp (dt/now)} :as cfg}]
|
||||
|
||||
(dm/assert!
|
||||
"expected zip file"
|
||||
@@ -878,17 +878,17 @@
|
||||
"Do the exportation of a specified file in custom penpot binary
|
||||
format. There are some options available for customize the output:
|
||||
|
||||
`::include-libraries`: additionally to the specified file, all the
|
||||
`::bfc/include-libraries`: additionally to the specified file, all the
|
||||
linked libraries also will be included (including transitive
|
||||
dependencies).
|
||||
|
||||
`::embed-assets`: instead of including the libraries, embed in the
|
||||
`::bfc/embed-assets`: instead of including the libraries, embed in the
|
||||
same file library all assets used from external libraries."
|
||||
|
||||
[{:keys [::ids] :as cfg} output]
|
||||
[{:keys [::bfc/ids] :as cfg} output]
|
||||
|
||||
(dm/assert!
|
||||
"expected a set of uuid's for `::ids` parameter"
|
||||
"expected a set of uuid's for `::bfc/ids` parameter"
|
||||
(and (set? ids)
|
||||
(every? uuid? ids)))
|
||||
|
||||
@@ -930,14 +930,13 @@
|
||||
:aborted @ab
|
||||
:cause @cs)))))
|
||||
|
||||
|
||||
(defn import-files!
|
||||
[{:keys [::input] :as cfg}]
|
||||
[{:keys [::bfc/input] :as cfg}]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid profile-id and project-id on `cfg`"
|
||||
(and (uuid? (::profile-id cfg))
|
||||
(uuid? (::project-id cfg))))
|
||||
(and (uuid? (::bfc/profile-id cfg))
|
||||
(uuid? (::bfc/project-id cfg))))
|
||||
|
||||
(dm/assert!
|
||||
"expected instance of jio/IOFactory for `input`"
|
||||
@@ -950,7 +949,7 @@
|
||||
(l/info :hint "import: started" :id (str id))
|
||||
(try
|
||||
(with-open [input (ZipFile. (fs/file input))]
|
||||
(import-files (assoc cfg ::input input)))
|
||||
(import-files (assoc cfg ::bfc/input input)))
|
||||
|
||||
(catch Throwable cause
|
||||
(vreset! cs cause)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
(def default-params
|
||||
{::port 6060
|
||||
::host "0.0.0.0"
|
||||
::max-body-size (* 1024 1024 30) ; default 30 MiB
|
||||
::max-multipart-body-size (* 1024 1024 120)}) ; default 120 MiB
|
||||
::max-body-size 31457280 ; default 30 MiB
|
||||
::max-multipart-body-size 367001600}) ; default 350 MiB
|
||||
|
||||
(defmethod ig/expand-key ::server
|
||||
[k v]
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
(ns app.http.debug
|
||||
(:refer-clojure :exclude [error-handler])
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as bf.v1]
|
||||
[app.binfile.v3 :as bf.v3]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
@@ -280,23 +282,23 @@
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments))
|
||||
|
||||
(let [path (tmp/tempfile :prefix "penpot.export.")]
|
||||
(let [path (tmp/tempfile :prefix "penpot.export." :min-age "30m")]
|
||||
(with-open [output (io/output-stream path)]
|
||||
(-> cfg
|
||||
(assoc ::bf.v1/ids file-ids)
|
||||
(assoc ::bf.v1/embed-assets embed?)
|
||||
(assoc ::bf.v1/include-libraries libs?)
|
||||
(bf.v1/export-files! output)))
|
||||
(assoc ::bfc/ids file-ids)
|
||||
(assoc ::bfc/embed-assets embed?)
|
||||
(assoc ::bfc/include-libraries libs?)
|
||||
(bf.v3/export-files! output)))
|
||||
|
||||
(if clone?
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
cfg (assoc cfg
|
||||
::bf.v1/overwrite false
|
||||
::bf.v1/profile-id profile-id
|
||||
::bf.v1/project-id project-id
|
||||
::bf.v1/input path)]
|
||||
(bf.v1/import-files! cfg)
|
||||
::bfc/overwrite false
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path)]
|
||||
(bf.v3/import-files! cfg)
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "OK CLONED"})
|
||||
@@ -315,23 +317,24 @@
|
||||
:hint "missing upload file"))
|
||||
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
overwrite? (contains? params :overwrite)
|
||||
migrate? (contains? params :migrate)]
|
||||
project-id (:default-project-id profile)]
|
||||
|
||||
(when-not project-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-project
|
||||
:hint "project not found"))
|
||||
|
||||
(let [path (-> params :file :path)
|
||||
cfg (assoc cfg
|
||||
::bf.v1/overwrite overwrite?
|
||||
::bf.v1/migrate migrate?
|
||||
::bf.v1/profile-id profile-id
|
||||
::bf.v1/project-id project-id
|
||||
::bf.v1/input path)]
|
||||
(bf.v1/import-files! cfg)
|
||||
(let [path (-> params :file :path)
|
||||
format (bfc/parse-file-format path)
|
||||
cfg (assoc cfg
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path)]
|
||||
|
||||
(if (= format :binfile-v3)
|
||||
(bf.v3/import-files! cfg)
|
||||
(bf.v1/import-files! cfg))
|
||||
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "OK"})))
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
(catch Throwable cause
|
||||
(events/tap :error (errors/handle' cause request))
|
||||
(when-not (ex/instance? java.io.EOFException cause)
|
||||
(l/err :hint "unexpected error on processing sse response" :cause cause)))
|
||||
(binding [l/*context* (errors/request->context request)]
|
||||
(l/err :hint "unexpected error on processing sse response" :cause cause))))
|
||||
(finally
|
||||
(sp/close! events/*channel*)
|
||||
(px/await! listener)))))))}))
|
||||
|
||||
@@ -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) "!")
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.rpc.commands.binfile
|
||||
(:refer-clojure :exclude [assert])
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as bf.v1]
|
||||
[app.binfile.v3 :as bf.v3]
|
||||
[app.common.logging :as l]
|
||||
@@ -46,9 +47,9 @@
|
||||
(fn [_ output-stream]
|
||||
(try
|
||||
(-> cfg
|
||||
(assoc ::bf.v1/ids #{file-id})
|
||||
(assoc ::bf.v1/embed-assets embed-assets)
|
||||
(assoc ::bf.v1/include-libraries include-libraries)
|
||||
(assoc ::bfc/ids #{file-id})
|
||||
(assoc ::bfc/embed-assets embed-assets)
|
||||
(assoc ::bfc/include-libraries include-libraries)
|
||||
(bf.v1/export-files! output-stream))
|
||||
(catch Throwable cause
|
||||
(l/err :hint "exception on exporting file"
|
||||
@@ -61,9 +62,9 @@
|
||||
(fn [_ output-stream]
|
||||
(try
|
||||
(-> cfg
|
||||
(assoc ::bf.v3/ids #{file-id})
|
||||
(assoc ::bf.v3/embed-assets embed-assets)
|
||||
(assoc ::bf.v3/include-libraries include-libraries)
|
||||
(assoc ::bfc/ids #{file-id})
|
||||
(assoc ::bfc/embed-assets embed-assets)
|
||||
(assoc ::bfc/include-libraries include-libraries)
|
||||
(bf.v3/export-files! output-stream))
|
||||
(catch Throwable cause
|
||||
(l/err :hint "exception on exporting file"
|
||||
@@ -93,10 +94,10 @@
|
||||
(defn- import-binfile-v1
|
||||
[{:keys [::wrk/executor] :as cfg} {:keys [project-id profile-id name file]}]
|
||||
(let [cfg (-> cfg
|
||||
(assoc ::bf.v1/project-id project-id)
|
||||
(assoc ::bf.v1/profile-id profile-id)
|
||||
(assoc ::bf.v1/name name)
|
||||
(assoc ::bf.v1/input (:path file)))]
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name)
|
||||
(assoc ::bfc/input (:path file)))]
|
||||
|
||||
;; NOTE: the importation process performs some operations that are
|
||||
;; not very friendly with virtual threads, and for avoid
|
||||
@@ -107,10 +108,10 @@
|
||||
(defn- import-binfile-v3
|
||||
[{:keys [::wrk/executor] :as cfg} {:keys [project-id profile-id name file]}]
|
||||
(let [cfg (-> cfg
|
||||
(assoc ::bf.v3/project-id project-id)
|
||||
(assoc ::bf.v3/profile-id profile-id)
|
||||
(assoc ::bf.v3/name name)
|
||||
(assoc ::bf.v3/input (:path file)))]
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name)
|
||||
(assoc ::bfc/input (:path file)))]
|
||||
;; NOTE: the importation process performs some operations that are
|
||||
;; not very friendly with virtual threads, and for avoid
|
||||
;; unexpected blocking of other concurrent operations we dispatch
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.rpc.commands.files-update
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
@@ -223,15 +224,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)
|
||||
@@ -424,19 +416,37 @@
|
||||
(l/error :hint "file validation error"
|
||||
:cause cause))))
|
||||
|
||||
|
||||
(defn- process-changes-and-validate
|
||||
[cfg file changes skip-validate]
|
||||
(let [;; WARNING: this ruins performance; maybe we need to find
|
||||
;; some other way to do general validation
|
||||
libs (when (and (or (contains? cf/flags :file-validation)
|
||||
(contains? cf/flags :soft-file-validation))
|
||||
(not skip-validate))
|
||||
(get-file-libraries cfg file))
|
||||
libs
|
||||
(when (and (or (contains? cf/flags :file-validation)
|
||||
(contains? cf/flags :soft-file-validation))
|
||||
(not skip-validate))
|
||||
(get-file-libraries cfg file))
|
||||
|
||||
file (-> (files/check-version! file)
|
||||
(update :revn inc)
|
||||
(update :data cpc/process-changes changes)
|
||||
(update :data d/without-nils))]
|
||||
|
||||
;; The main purpose of this atom is provide a contextual state
|
||||
;; for the changes subsystem where optionally some hints can
|
||||
;; be provided for the changes processing. Right now we are
|
||||
;; using it for notify about the existence of media refs when
|
||||
;; a new shape is added.
|
||||
state
|
||||
(atom {})
|
||||
|
||||
file
|
||||
(binding [cpc/*state* state]
|
||||
(-> (files/check-version! file)
|
||||
(update :revn inc)
|
||||
(update :data cpc/process-changes changes)
|
||||
(update :data d/without-nils)))
|
||||
|
||||
file
|
||||
(if-let [media-refs (-> @state :media-refs not-empty)]
|
||||
(bfc/update-media-references! cfg file media-refs)
|
||||
file)]
|
||||
|
||||
(binding [pmap/*tracked* nil]
|
||||
(when (contains? cf/flags :soft-file-validation)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as bf.v1]
|
||||
[app.binfile.v3 :as bf.v3]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.schema :as sm]
|
||||
@@ -25,6 +26,7 @@
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.setup.templates :as tmpl]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
@@ -67,7 +69,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))
|
||||
@@ -400,11 +402,20 @@
|
||||
;; that are not very friendly with virtual threads, and for
|
||||
;; avoid unexpected blocking of other concurrent operations
|
||||
;; we dispatch that operation to a dedicated executor.
|
||||
(let [cfg (-> cfg
|
||||
(assoc ::bf.v1/project-id project-id)
|
||||
(assoc ::bf.v1/profile-id profile-id)
|
||||
(assoc ::bf.v1/input template))
|
||||
result (px/invoke! executor (partial bf.v1/import-files! cfg))]
|
||||
(let [template (tmp/tempfile-from template
|
||||
:prefix "penpot.template."
|
||||
:suffix ""
|
||||
:min-age "30m")
|
||||
format (bfc/parse-file-format template)
|
||||
|
||||
cfg (-> cfg
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/input template))
|
||||
|
||||
result (if (= format :binfile-v3)
|
||||
(px/invoke! executor (partial bf.v3/import-files! cfg))
|
||||
(px/invoke! executor (partial bf.v1/import-files! cfg)))]
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at (dt/now)}
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
(::setup/templates cfg))]
|
||||
(let [dest (fs/join fs/*cwd* "builtin-templates")
|
||||
path (or (:path template) (fs/join dest template-id))]
|
||||
|
||||
(if (fs/exists? path)
|
||||
(io/input-stream path)
|
||||
(let [resp (http/req! cfg
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
fixes all not propertly referenced file-media-object for a file"
|
||||
[{:keys [id data] :as file} & _]
|
||||
(let [conn (db/get-connection h/*system*)
|
||||
used (bfc/collect-used-media data)
|
||||
used (cfh/collect-used-media data)
|
||||
ids (db/create-array conn "uuid" used)
|
||||
sql (str "SELECT * FROM file_media_object WHERE id = ANY(?)")
|
||||
rows (db/exec! conn [sql ids])
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
[promesa.exec.csp :as sp])
|
||||
(:import
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.nio.file.Files))
|
||||
|
||||
(def default-tmp-dir "/tmp/penpot")
|
||||
@@ -86,3 +89,12 @@
|
||||
(fs/delete-on-exit! path)
|
||||
(sp/offer! queue [path (some-> min-age dt/duration)])
|
||||
path))
|
||||
|
||||
(defn tempfile-from
|
||||
"Create a new tempfile from from consuming the stream"
|
||||
[input & {:as options}]
|
||||
(let [path (tempfile options)]
|
||||
(with-open [^InputStream input (io/input-stream input)]
|
||||
(with-open [^OutputStream output (io/output-stream path)]
|
||||
(io/copy input output)))
|
||||
path))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
file is eligible to be garbage collected after some period of
|
||||
inactivity (the default threshold is 72h)."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.validate :as cfv]
|
||||
[app.common.logging :as l]
|
||||
@@ -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 cfh/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)))))
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns backend-tests.binfile-test
|
||||
"Internal binfile test, no RPC involved"
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.pprint :as pp]
|
||||
@@ -93,15 +94,15 @@
|
||||
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::v3/ids #{(:id file)})
|
||||
(assoc ::v3/embed-assets false)
|
||||
(assoc ::v3/include-libraries false))
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
(let [result (-> th/*system*
|
||||
(assoc ::v3/project-id (:default-project-id profile))
|
||||
(assoc ::v3/profile-id (:id profile))
|
||||
(assoc ::v3/input output)
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
(t/is (= (count result) 1))
|
||||
(t/is (every? uuid? result)))))
|
||||
|
||||
@@ -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)
|
||||
@@ -1336,3 +1332,500 @@
|
||||
(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)))))))
|
||||
|
||||
|
||||
|
||||
|
||||
(defn add-file-media-object
|
||||
[& {:keys [profile-id file-id]}]
|
||||
(let [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 profile-id
|
||||
:file-id file-id
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))
|
||||
|
||||
|
||||
|
||||
(t/deftest file-gc-with-media-assets-and-absorb-library
|
||||
(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})
|
||||
|
||||
fmedia (add-file-media-object :profile-id (:id profile) :file-id (:id file-1))
|
||||
|
||||
|
||||
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]))
|
||||
|
||||
fills
|
||||
[{:fill-image
|
||||
{:id (:id fmedia)
|
||||
:name "test"
|
||||
:width 200
|
||||
:height 200}}]]
|
||||
|
||||
;; 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
|
||||
:fills fills
|
||||
: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
|
||||
:fills fills
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id file-1)
|
||||
:component-id c-id})}])
|
||||
|
||||
;; Check that file media object references are present for both objects
|
||||
;; the original one and the instance.
|
||||
(let [rows (th/db-exec! ["SELECT * FROM file_media_object ORDER BY created_at ASC"])]
|
||||
(t/is (= 2 (count rows)))
|
||||
(t/is (= (:id file-1) (:file-id (get rows 0))))
|
||||
(t/is (= (:id file-2) (:file-id (get rows 1))))
|
||||
(t/is (every? (comp nil? :deleted-at) rows)))
|
||||
|
||||
;; Check if the underlying media reference on shape is different
|
||||
;; from the instantiation
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-2)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
fill (get-in result [:data :pages-index f2-page-id :objects s-id-2 :fills 0 :fill-image])]
|
||||
(t/is (some? fill))
|
||||
(t/is (not= (:id fill) (:id fmedia)))))
|
||||
|
||||
;; 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)})))
|
||||
|
||||
;; Now proceed to delete file and absorb it
|
||||
(let [data {::th/type :delete-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))
|
||||
|
||||
(th/run-task! :delete-object
|
||||
{:object :file
|
||||
:deleted-at (dt/now)
|
||||
:id (:id file-1)})
|
||||
|
||||
;; Check that file media object references are marked all for deletion
|
||||
(let [rows (th/db-exec! ["SELECT * FROM file_media_object ORDER BY created_at ASC"])]
|
||||
;; (pp/pprint rows)
|
||||
(t/is (= 2 (count rows)))
|
||||
|
||||
(t/is (= (:id file-1) (:file-id (get rows 0))))
|
||||
(t/is (some? (:deleted-at (get rows 0))))
|
||||
|
||||
(t/is (= (:id file-2) (:file-id (get rows 1))))
|
||||
(t/is (nil? (:deleted-at (get rows 1)))))
|
||||
|
||||
(th/run-task! :objects-gc
|
||||
{:min-age 0})
|
||||
|
||||
(let [rows (th/db-exec! ["SELECT * FROM file_media_object ORDER BY created_at ASC"])]
|
||||
(t/is (= 1 (count rows)))
|
||||
|
||||
(t/is (= (:id file-2) (:file-id (get rows 0))))
|
||||
(t/is (nil? (:deleted-at (get rows 0)))))))
|
||||
|
||||
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -484,6 +484,11 @@
|
||||
modification."
|
||||
nil)
|
||||
|
||||
(def ^:dynamic *state*
|
||||
"A general purpose state to signal some out of order operations
|
||||
to the processor backend."
|
||||
nil)
|
||||
|
||||
(defmulti process-change (fn [_ change] (:type change)))
|
||||
(defmulti process-operation (fn [_ op] (:type op)))
|
||||
|
||||
@@ -617,12 +622,38 @@
|
||||
|
||||
;; --- Shape / Obj
|
||||
|
||||
;; The main purpose of this is ensure that all created shapes has
|
||||
;; valid media references; so for make sure of it, we analyze each
|
||||
;; shape added via `:add-obj` change for media usage, and if shape has
|
||||
;; media refs, we put that media refs on the check list (on the
|
||||
;; *state*) which will subsequently be processed and all incorrect
|
||||
;; references will be corrected. The media ref is anything that can
|
||||
;; be pointing to a file-media-object on the shape, per example we
|
||||
;; have fill-image, stroke-image, etc.
|
||||
|
||||
(defn- collect-shape-media-refs
|
||||
[state obj page-id]
|
||||
(let [media-refs
|
||||
(-> (cfh/collect-shape-media-refs obj)
|
||||
(not-empty))
|
||||
|
||||
xform
|
||||
(map (fn [id]
|
||||
{:page-id page-id
|
||||
:shape-id (:id obj)
|
||||
:id id}))]
|
||||
|
||||
(update state :media-refs into xform media-refs)))
|
||||
|
||||
(defmethod process-change :add-obj
|
||||
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
|
||||
(let [update-container
|
||||
(fn [container]
|
||||
(ctst/add-shape id obj container frame-id parent-id index ignore-touched))]
|
||||
|
||||
(when *state*
|
||||
(swap! *state* collect-shape-media-refs obj page-id))
|
||||
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id] update-container)
|
||||
(d/update-in-when data [:components component-id] update-container))))
|
||||
@@ -876,7 +907,7 @@
|
||||
(letfn [(update-fn [data]
|
||||
(if (some? value)
|
||||
(assoc-in data [:plugin-data namespace key] value)
|
||||
(update-in data [:plugin-data namespace] dissoc key)))]
|
||||
(d/update-in-when data [:plugin-data namespace] dissoc key)))]
|
||||
|
||||
(case object-type
|
||||
:file
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
(ns app.common.files.defaults)
|
||||
|
||||
(def version 57)
|
||||
(def version 65)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.set :as set]
|
||||
[clojure.walk :as walk]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
#?(:clj (set! *warn-on-reflection* true))
|
||||
@@ -533,6 +534,86 @@
|
||||
(get-position-on-parent objects)
|
||||
inc))
|
||||
|
||||
(defn collect-shape-media-refs
|
||||
"Collect all media refs on the provided shape. Returns a set of ids"
|
||||
[shape]
|
||||
(sequence
|
||||
(keep :id)
|
||||
;; NOTE: because of some bug, we ended with
|
||||
;; many shape types having the ability to
|
||||
;; have fill-image attribute (which initially
|
||||
;; designed for :path shapes).
|
||||
(concat [(:fill-image shape)
|
||||
(:metadata shape)]
|
||||
(map :fill-image (:fills shape))
|
||||
(map :stroke-image (:strokes shape))
|
||||
(->> (:content shape)
|
||||
(tree-seq map? :children)
|
||||
(mapcat :fills)
|
||||
(map :fill-image)))))
|
||||
|
||||
(def ^:private
|
||||
xform:collect-media-refs
|
||||
"A transducer for collect media-id usage across a container (page or
|
||||
component)"
|
||||
(comp
|
||||
(map :objects)
|
||||
(mapcat vals)
|
||||
(mapcat collect-shape-media-refs)))
|
||||
|
||||
(defn collect-used-media
|
||||
"Given a fdata (file data), returns all media references used in the
|
||||
file data"
|
||||
[data]
|
||||
(-> #{}
|
||||
(into xform:collect-media-refs (vals (:pages-index data)))
|
||||
(into xform:collect-media-refs (vals (:components data)))
|
||||
(into (keys (:media data)))))
|
||||
|
||||
(defn relink-media-refs
|
||||
"A function responsible to analyze all file data and replace the
|
||||
old :component-file reference with the new ones, using the provided
|
||||
file-index."
|
||||
[data lookup-index]
|
||||
(letfn [(process-map-form [form]
|
||||
(cond-> form
|
||||
;; Relink image shapes
|
||||
(and (map? (:metadata form))
|
||||
(= :image (:type form)))
|
||||
(update-in [:metadata :id] lookup-index)
|
||||
|
||||
;; Relink paths with fill image
|
||||
(map? (:fill-image form))
|
||||
(update-in [:fill-image :id] lookup-index)
|
||||
|
||||
;; This covers old shapes and the new :fills.
|
||||
(uuid? (:fill-color-ref-file form))
|
||||
(update :fill-color-ref-file lookup-index)
|
||||
|
||||
;; This covers the old shapes and the new :strokes
|
||||
(uuid? (:stroke-color-ref-file form))
|
||||
(update :stroke-color-ref-file lookup-index)
|
||||
|
||||
;; This covers all text shapes that have typography referenced
|
||||
(uuid? (:typography-ref-file form))
|
||||
(update :typography-ref-file lookup-index)
|
||||
|
||||
;; This covers the component instance links
|
||||
(uuid? (:component-file form))
|
||||
(update :component-file lookup-index)
|
||||
|
||||
;; This covers the shadows and grids (they have directly
|
||||
;; the :file-id prop)
|
||||
(uuid? (:file-id form))
|
||||
(update :file-id lookup-index)))
|
||||
|
||||
(process-form [form]
|
||||
(if (map? form)
|
||||
(process-map-form form)
|
||||
form))]
|
||||
|
||||
(walk/postwalk process-form data)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SHAPES ORGANIZATION (PATH MANAGEMENT)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[app.common.text :as txt]
|
||||
[app.common.types.color :as ctc]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.shape.shadow :as ctss]
|
||||
@@ -1130,6 +1131,72 @@
|
||||
(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))))
|
||||
|
||||
(defn migrate-up-62
|
||||
[data]
|
||||
(let [xform-cycles-ids
|
||||
(comp (filter #(= (:id %) (:shape-ref %)))
|
||||
(map :id))
|
||||
|
||||
remove-cycles
|
||||
(fn [objects]
|
||||
(let [cycles-ids (into #{} xform-cycles-ids (vals objects))
|
||||
to-detach (->> cycles-ids
|
||||
(map #(get objects %))
|
||||
(map #(ctn/get-head-shape objects %))
|
||||
(map :id)
|
||||
distinct
|
||||
(mapcat #(ctn/get-children-in-instance objects %))
|
||||
(map :id)
|
||||
set)]
|
||||
|
||||
(reduce-kv (fn [objects id shape]
|
||||
(if (contains? to-detach id)
|
||||
(assoc objects id (ctk/detach-shape shape))
|
||||
objects))
|
||||
objects
|
||||
objects)))
|
||||
|
||||
update-component
|
||||
(fn [component]
|
||||
;; we only have encounter this on deleted components,
|
||||
;; so the relevant objects are inside the component
|
||||
(d/update-when component :objects remove-cycles))]
|
||||
|
||||
(update data :components update-vals update-component)))
|
||||
|
||||
(defn migrate-up-65
|
||||
[data]
|
||||
(let [update-object
|
||||
(fn [object]
|
||||
(d/update-when object :plugin-data d/without-nils))
|
||||
|
||||
update-page
|
||||
(fn [page]
|
||||
(-> (update-object page)
|
||||
(update :objects update-vals update-object)))]
|
||||
|
||||
(-> data
|
||||
(update-object)
|
||||
(d/update-when :pages-index update-vals update-page)
|
||||
(d/update-when :colors update-vals update-object)
|
||||
(d/update-when :typographies update-vals update-object)
|
||||
(d/update-when :components update-vals update-object))))
|
||||
|
||||
(def migrations
|
||||
"A vector of all applicable migrations"
|
||||
[{:id 2 :migrate-up migrate-up-2}
|
||||
@@ -1178,5 +1245,7 @@
|
||||
{: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}
|
||||
{:id 62 :migrate-up migrate-up-62}
|
||||
{:id 65 :migrate-up migrate-up-65}])
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -29,6 +29,15 @@ x-flags: &penpot-flags
|
||||
x-uri: &penpot-public-uri
|
||||
PENPOT_PUBLIC_URI: http://localhost:9001
|
||||
|
||||
x-body-size: &penpot-http-body-size
|
||||
# Max body size (30MiB); Used for plain requests, should never be
|
||||
# greater than multi-part size
|
||||
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280
|
||||
|
||||
# Max multipart body size (350MiB)
|
||||
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
|
||||
|
||||
|
||||
networks:
|
||||
penpot:
|
||||
|
||||
@@ -103,7 +112,7 @@ services:
|
||||
# - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt"
|
||||
|
||||
environment:
|
||||
<< : *penpot-flags
|
||||
<< : [*penpot-flags, *penpot-http-body-size]
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:latest"
|
||||
@@ -123,7 +132,7 @@ services:
|
||||
## container.
|
||||
|
||||
environment:
|
||||
<< : [*penpot-flags, *penpot-public-uri]
|
||||
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size]
|
||||
|
||||
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
|
||||
## (eg http sessions, or invitations) are derived.
|
||||
@@ -201,7 +210,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
|
||||
@@ -261,5 +270,3 @@ services:
|
||||
# ports:
|
||||
# - 9000:9000
|
||||
# - 9001:9001
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ update_flags /var/www/app/js/config.js
|
||||
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060};
|
||||
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061};
|
||||
export PENPOT_INTERNAL_RESOLVER=${PENPOT_INTERNAL_RESOLVER:-127.0.0.11};
|
||||
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE:-367001600}; # Default to 350MiB
|
||||
|
||||
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_INTERNAL_RESOLVER" < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_INTERNAL_RESOLVER,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
|
||||
< /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
|
||||
exec "$@";
|
||||
|
||||
@@ -64,7 +64,7 @@ http {
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 100M;
|
||||
client_max_body_size $PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE;
|
||||
charset utf-8;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
@@ -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 |
@@ -7,6 +7,14 @@ title: 2. Create a Plugin
|
||||
|
||||
This guide covers the creation of a Penpot plugin. Penpot offers two ways to kickstart your development:
|
||||
|
||||
<p class="advice">
|
||||
Have you got an idea for a new plugin? Great! But first take a look at <a
|
||||
href="https://penpot.app/penpothub/plugins">the plugin overview</a> to see if already
|
||||
exists, and consider joining efforts with other developers. This does not imply that we
|
||||
won't accept plugins that do similar things, since anything can be improved and done in
|
||||
different ways.
|
||||
</p>
|
||||
|
||||
1. Using a Template:
|
||||
|
||||
- **Typescript template**: Using the <a target="_blank" href="https://github.com/penpot/penpot-plugin-starter-template">Penpot Plugin Starter Template</a>: A basic template with the required files for quickstarting your plugin. This template uses Typescript and Vite.
|
||||
|
||||
@@ -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 ###
|
||||
@@ -405,6 +405,14 @@ where users will access the application:
|
||||
PENPOT_PUBLIC_URI: http://localhost:9001
|
||||
```
|
||||
|
||||
<p class="advice">
|
||||
If you plan to serve Penpot under different domain than `localhost` without HTTPS,
|
||||
you need to disable the `secure` flag on cookies, with the `disable-secure-session-cookies` flag.
|
||||
This is a configuration NOT recommended for production environments.
|
||||
</p>
|
||||
|
||||
Check all the [flags](#other-flags) to fully customize your instance.
|
||||
|
||||
## Frontend ##
|
||||
|
||||
In comparison with backend, frontend only has a small number of runtime configuration
|
||||
@@ -424,8 +432,8 @@ To connect the frontend to the exporter and backend, you need to fill out these
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
PENPOT_BACKEND_URI: http://your-penpot-backend
|
||||
PENPOT_EXPORTER_URI: http://your-penpot-exporter
|
||||
PENPOT_BACKEND_URI: http://your-penpot-backend:6060
|
||||
PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061
|
||||
```
|
||||
|
||||
These variables are used for generate correct nginx.conf file on container startup.
|
||||
@@ -480,3 +488,4 @@ __Since version 2.0.0__
|
||||
[2]: /technical-guide/getting-started#configure-penpot-with-docker
|
||||
[3]: /technical-guide/developer/common#dev-environment
|
||||
[4]: https://github.com/penpot/penpot/blob/main/docker/images/files/nginx.conf
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -214,6 +227,9 @@ docker compose -f docker-compose.yaml pull
|
||||
|
||||
This will fetch the latest images. When you do <code class="language-bash">docker compose up</code> again, the containers will be recreated with the latest version.
|
||||
|
||||
<p class="advice">
|
||||
It is strongly recommended to update the Penpot version in small increments, rather than updating between two distant versions.
|
||||
</p>
|
||||
|
||||
**Important: Upgrade from version 1.x to 2.0**
|
||||
|
||||
@@ -261,7 +277,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 +303,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
|
||||
},
|
||||
|
||||
@@ -22,3 +22,22 @@ test("Bug 7549 - User clicks on color swatch to display the color picker next to
|
||||
const distance = swatchBox.x - (pickerBox.x + pickerBox.width);
|
||||
expect(distance).toBeLessThan(60);
|
||||
});
|
||||
|
||||
// Fix for https://tree.taiga.io/project/penpot/issue/9900
|
||||
test("Bug 9900 - Color picker has no inputs for HSV values", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
|
||||
await workspacePage.goToWorkspace();
|
||||
const swatch = workspacePage.page.getByRole("button", { name: "E8E9EA" });
|
||||
await swatch.click();
|
||||
|
||||
const HSVA = await workspacePage.page.getByLabel("HSVA");
|
||||
await HSVA.click();
|
||||
|
||||
await workspacePage.page.getByLabel("H", { exact: true }).isVisible();
|
||||
await workspacePage.page.getByLabel("S", { exact: true }).isVisible();
|
||||
await workspacePage.page.getByLabel("V", { exact: true }).isVisible();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -71,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();
|
||||
});
|
||||
|
||||
@@ -225,3 +225,16 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
|
||||
page.getByTestId("children-6ad3e6b9-c5a0-80cf-8005-283bbe378bcb"),
|
||||
).toHaveText(["CBCDEF"]);
|
||||
});
|
||||
|
||||
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.goToWorkspace();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate(() => navigator.clipboard.writeText("Lorem ipsum dolor"));
|
||||
await workspacePage.viewport.click({ button: "right" });
|
||||
await page.getByText("PasteCtrlV").click();
|
||||
await workspacePage.viewport
|
||||
.getByRole("textbox")
|
||||
.getByText("Lorem ipsum dolor");
|
||||
});
|
||||
|
||||
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 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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: 214 B |
@@ -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%;
|
||||
|
||||
@@ -508,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)
|
||||
|
||||
|
||||