From 6f93b4192038a782e4f5c245a48e2d16f4391b3b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Oct 2023 19:31:41 +0200 Subject: [PATCH] :tada: Add features assignation for teams --- .clj-kondo/config.edn | 2 +- backend/dev/user.clj | 6 +- backend/scripts/repl | 5 +- backend/src/app/db.clj | 60 +- backend/src/app/features/components_v2.clj | 677 ++++++++++++++++++ backend/src/app/features/fdata.clj | 48 ++ backend/src/app/migrations.clj | 2 + .../migrations/sql/0106-mod-team-table.sql | 1 + backend/src/app/rpc/commands/auth.clj | 10 +- backend/src/app/rpc/commands/binfile.clj | 297 ++++---- backend/src/app/rpc/commands/files.clj | 301 ++++---- backend/src/app/rpc/commands/files_create.clj | 100 ++- .../src/app/rpc/commands/files_thumbnails.clj | 33 +- backend/src/app/rpc/commands/files_update.clj | 253 +++---- backend/src/app/rpc/commands/ldap.clj | 20 +- backend/src/app/rpc/commands/management.clj | 113 ++- backend/src/app/rpc/commands/media.clj | 12 +- backend/src/app/rpc/commands/teams.clj | 232 +++--- backend/src/app/rpc/commands/viewer.clj | 3 +- backend/src/app/srepl/helpers.clj | 28 +- backend/src/app/srepl/main.clj | 80 ++- backend/src/app/storage/tmp.clj | 2 +- backend/src/app/tasks/file_gc.clj | 2 +- backend/src/app/util/pointer_map.clj | 2 +- backend/test/backend_tests/helpers.clj | 176 ++--- .../rpc_cond_middleware_test.clj | 7 +- backend/test/backend_tests/rpc_file_test.clj | 23 +- .../backend_tests/rpc_management_test.clj | 4 +- common/deps.edn | 4 +- common/src/app/common/features.cljc | 236 ++++++ common/src/app/common/files/defaults.cljc | 2 +- common/src/app/common/files/features.cljc | 17 - common/src/app/common/files/migrations.cljc | 13 + .../src/app/common/pages/changes_builder.cljc | 4 +- common/src/app/common/svg.cljc | 4 +- .../src/app/common/types/components_list.cljc | 6 +- common/src/app/common/types/file.cljc | 11 +- common/src/app/common/types/page.cljc | 6 +- common/src/app/common/types/shape_tree.cljc | 36 +- frontend/src/app/main/data/common.cljs | 5 +- frontend/src/app/main/data/dashboard.cljs | 107 +-- frontend/src/app/main/data/users.cljs | 41 +- frontend/src/app/main/data/viewer.cljs | 7 +- frontend/src/app/main/data/workspace.cljs | 296 +++----- .../app/main/data/workspace/libraries.cljs | 21 +- .../app/main/data/workspace/persistence.cljs | 16 +- .../src/app/main/data/workspace/shapes.cljs | 2 +- .../app/main/data/workspace/svg_upload.cljs | 42 +- frontend/src/app/main/errors.cljs | 21 +- frontend/src/app/main/features.cljs | 165 +++-- frontend/src/app/main/refs.cljs | 4 - frontend/src/app/main/ui.cljs | 2 +- frontend/src/app/main/ui/dashboard.cljs | 2 +- .../src/app/main/ui/dashboard/export.cljs | 2 +- frontend/src/app/main/ui/dashboard/grid.cljs | 29 +- .../src/app/main/ui/dashboard/libraries.cljs | 2 +- .../src/app/main/ui/dashboard/projects.cljs | 2 +- .../src/app/main/ui/dashboard/sidebar.cljs | 2 +- frontend/src/app/main/ui/dashboard/team.cljs | 13 +- frontend/src/app/main/ui/modal.cljs | 2 +- .../src/app/main/ui/settings/options.cljs | 2 +- frontend/src/app/main/ui/workspace.cljs | 51 +- .../app/main/ui/workspace/context_menu.cljs | 2 +- .../src/app/main/ui/workspace/libraries.cljs | 2 +- .../options/menus/layout_container.cljs | 6 +- frontend/src/app/render.cljs | 4 +- frontend/src/debug.cljs | 10 +- frontend/src/features.cljs | 23 +- .../test/frontend_tests/helpers/pages.cljs | 2 +- frontend/translations/cs.po | 29 - frontend/translations/de.po | 29 - frontend/translations/en.po | 40 +- frontend/translations/es.po | 36 +- frontend/translations/eu.po | 29 - frontend/translations/he.po | 26 - frontend/translations/id.po | 29 - frontend/translations/lv.po | 28 - frontend/translations/nl.po | 29 - frontend/translations/pl.po | 29 - frontend/translations/pt_BR.po | 29 - frontend/translations/pt_PT.po | 29 - frontend/translations/ro.po | 29 - frontend/translations/tr.po | 29 - frontend/translations/zh_CN.po | 24 - 84 files changed, 2390 insertions(+), 1777 deletions(-) create mode 100644 backend/src/app/features/components_v2.clj create mode 100644 backend/src/app/features/fdata.clj create mode 100644 backend/src/app/migrations/sql/0106-mod-team-table.sql create mode 100644 common/src/app/common/features.cljc delete mode 100644 common/src/app/common/files/features.cljc diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 011356929..801ee88f7 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -5,6 +5,7 @@ promesa.exec.csp/go-loop clojure.core/loop rumext.v2/defc clojure.core/defn rumext.v2/fnc clojure.core/fn + promesa.util/with-open clojure.core/with-open app.common.data/export clojure.core/def app.common.data.macros/get-in clojure.core/get-in app.common.data.macros/with-open clojure.core/with-open @@ -62,4 +63,3 @@ :exclude-destructured-keys-in-fn-args false } }} - diff --git a/backend/dev/user.clj b/backend/dev/user.clj index bbbcac780..be13bb80f 100644 --- a/backend/dev/user.clj +++ b/backend/dev/user.clj @@ -21,7 +21,7 @@ [app.common.transit :as t] [app.common.types.file :as ctf] [app.common.uuid :as uuid] - [app.config :as cfg] + [app.config :as cf] [app.main :as main] [app.srepl.helpers :as srepl.helpers] [app.srepl.main :as srepl] @@ -96,7 +96,9 @@ (try (alter-var-root #'system (fn [sys] (when sys (ig/halt! sys)) - (-> (merge main/system-config main/worker-config) + (-> main/system-config + (cond-> (contains? cf/flags :backend-worker) + (merge main/worker-config)) (ig/prep) (ig/init)))) :started diff --git a/backend/scripts/repl b/backend/scripts/repl index 647698165..351accaac 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -10,9 +10,10 @@ export PENPOT_FLAGS="\ enable-login-with-google \ enable-login-with-github \ enable-login-with-gitlab \ + disable-backend-worker \ enable-backend-asserts \ - enable-fdata-storage-pointer-map \ - enable-fdata-storage-objets-map \ + enable-feature-fdata-pointer-map \ + enable-feature-fdata-objects-map \ enable-audit-log \ enable-transit-readable-response \ enable-demo-users \ diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index ba705d480..a2a5d059b 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -231,60 +231,76 @@ `(jdbc/with-transaction ~@args))) (defn open - [pool] - (jdbc/get-connection pool)) + [system-or-pool] + (if (pool? system-or-pool) + (jdbc/get-connection system-or-pool) + (if (map? system-or-pool) + (open (::pool system-or-pool)) + (ex/raise :type :internal + :code :unable-resolve-pool)))) -(defn- resolve-connectable +(defn get-connection + [cfg-or-conn] + (if (connection? cfg-or-conn) + cfg-or-conn + (if (map? cfg-or-conn) + (get-connection (::conn cfg-or-conn)) + (ex/raise :type :internal + :code :unable-resolve-connection + :hint "expected conn or system map")))) + +(defn- get-connectable [o] - (if (connection? o) - o - (if (pool? o) - o - (or (::conn o) (::pool o))))) - + (cond + (connection? o) o + (pool? o) o + (map? o) (get-connectable (or (:conn o) (::pool o))) + :else (ex/raise :type :internal + :code :unable-resolve-connectable + :hint "expected conn, pool or system"))) (def ^:private default-opts {:builder-fn sql/as-kebab-maps}) (defn exec! ([ds sv] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (jdbc/execute! sv default-opts))) ([ds sv opts] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (jdbc/execute! sv (merge default-opts opts))))) (defn exec-one! ([ds sv] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (jdbc/execute-one! sv default-opts))) ([ds sv opts] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (jdbc/execute-one! sv (-> (merge default-opts opts) (assoc :return-keys (::return-keys? opts false))))))) (defn insert! [ds table params & {:as opts}] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (exec-one! (sql/insert table params opts) (merge {::return-keys? true} opts)))) (defn insert-multi! [ds table cols rows & {:as opts}] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (exec! (sql/insert-multi table cols rows opts) (merge {::return-keys? true} opts)))) (defn update! [ds table params where & {:as opts}] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (exec-one! (sql/update table params where opts) (merge {::return-keys? true} opts)))) (defn delete! [ds table params & {:as opts}] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (exec-one! (sql/delete table params opts) (merge {::return-keys? true} opts)))) @@ -318,7 +334,7 @@ (defn plan [ds sql] - (-> (resolve-connectable ds) + (-> (get-connectable ds) (jdbc/plan sql sql/default-opts))) (defn get-by-id @@ -422,12 +438,16 @@ (release! conn sp) result) (catch Throwable cause - (rollback! sp) + (rollback! conn sp) (throw cause)))) (::pool cfg) (with-atomic [conn (::pool cfg)] - (f (assoc cfg ::conn conn))) + (let [result (f (assoc cfg ::conn conn))] + (when (::rollback cfg) + (l/dbg :hint "explicit rollback requested") + (rollback! conn)) + result)) :else (throw (IllegalArgumentException. "invalid arguments")))) diff --git a/backend/src/app/features/components_v2.clj b/backend/src/app/features/components_v2.clj new file mode 100644 index 000000000..f0748e1a7 --- /dev/null +++ b/backend/src/app/features/components_v2.clj @@ -0,0 +1,677 @@ +;; 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.features.components-v2 + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.features :as cfeat] + [app.common.files.libraries-helpers :as cflh] + [app.common.files.migrations :as pmg] + [app.common.files.shapes-helpers :as cfsh] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.logging :as l] + [app.common.pages.changes :as cp] + [app.common.pages.changes-builder :as pcb] + [app.common.pages.helpers :as cph] + [app.common.svg :as csvg] + [app.common.svg.shapes-builder :as sbuilder] + [app.common.types.component :as ctk] + [app.common.types.components-list :as ctkl] + [app.common.types.container :as ctn] + [app.common.types.file :as ctf] + [app.common.types.pages-list :as ctpl] + [app.common.types.shape :as cts] + [app.common.types.shape-tree :as ctst] + [app.common.uuid :as uuid] + [app.db :as db] + [app.media :as media] + [app.rpc.commands.files :as files] + [app.rpc.commands.media :as cmd.media] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [app.util.blob :as blob] + [app.util.objects-map :as omap] + [app.util.pointer-map :as pmap] + [app.util.time :as dt] + [buddy.core.codecs :as bc] + [cuerdas.core :as str] + [datoteka.io :as io] + [promesa.exec.semaphore :as ps])) + +;; - What about use of svgo on converting graphics to components + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; END PROMESA HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:dynamic *system* nil) +(def ^:dynamic *stats* nil) +(def ^:dynamic *semaphore* nil) +(def ^:dynamic *skip-on-error* true) + +(def grid-gap 50) + +(defn- prepare-file-data + "Apply some specific migrations or fixes to things that are allowed in v1 but not in v2, + or that are the result of old bugs." + [file-data libraries] + (let [detached-ids (volatile! #{}) + + detach-shape + (fn [container shape] + ; Detach a shape. If it's inside a component, add it to detached-ids, for further use. + (let [is-component? (let [root-shape (ctst/get-shape container (:id container))] + (and (some? root-shape) (nil? (:parent-id root-shape))))] + (when is-component? + (vswap! detached-ids conj (:id shape))) + (ctk/detach-shape shape))) + + fix-orphan-shapes + (fn [file-data] + ; Find shapes that are not listed in their parent's children list. + ; Remove them, and also their children + (letfn [(fix-container [container] + (reduce fix-shape container (ctn/shapes-seq container))) + + (fix-shape + [container shape] + (if-not (or (= (:id shape) uuid/zero) + (nil? (:parent-id shape))) + (let [parent (ctst/get-shape container (:parent-id shape)) + exists? (d/index-of (:shapes parent) (:id shape))] + (if (nil? exists?) + (let [ids (cph/get-children-ids-with-self (:objects container) (:id shape))] + (update container :objects #(reduce dissoc % ids))) + container)) + container))] + + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container)))) + + remove-nested-roots + (fn [file-data] + ; Remove :component-root in head shapes that are nested. + (letfn [(fix-container [container] + (update container :objects update-vals (partial fix-shape container))) + + (fix-shape [container shape] + (let [parent (ctst/get-shape container (:parent-id shape))] + (if (and (ctk/instance-root? shape) + (ctn/in-any-component? (:objects container) parent)) + (dissoc shape :component-root) + shape)))] + + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container)))) + + add-not-nested-roots + (fn [file-data] + ; Add :component-root in head shapes that are not nested. + (letfn [(fix-container [container] + (update container :objects update-vals (partial fix-shape container))) + + (fix-shape [container shape] + (let [parent (ctst/get-shape container (:parent-id shape))] + (if (and (ctk/subinstance-head? shape) + (not (ctn/in-any-component? (:objects container) parent))) + (assoc shape :component-root true) + shape)))] + + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container)))) + + fix-orphan-copies + (fn [file-data] + ; Detach shapes that were inside a copy (have :shape-ref) but now they aren't. + (letfn [(fix-container [container] + (update container :objects update-vals (partial fix-shape container))) + + (fix-shape [container shape] + (let [parent (ctst/get-shape container (:parent-id shape))] + (if (and (ctk/in-component-copy? shape) + (not (ctk/instance-head? shape)) + (not (ctk/in-component-copy? parent))) + (detach-shape container shape) + shape)))] + + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container)))) + + remap-refs + (fn [file-data] + ; Remap shape-refs so that they point to the near main. + ; At the same time, if there are any dangling ref, detach the shape and its children. + (letfn [(fix-container [container] + (reduce fix-shape container (ctn/shapes-seq container))) + + (fix-shape [container shape] + (if (ctk/in-component-copy? shape) + ; First look for the direct shape. + (let [root (ctn/get-component-shape (:objects container) shape) + libraries (assoc-in libraries [(:id file-data) :data] file-data) + library (get libraries (:component-file root)) + component (ctkl/get-component (:data library) (:component-id root) true) + direct-shape (ctf/get-component-shape (:data library) component (:shape-ref shape))] + (if (some? direct-shape) + ; If it exists, there is nothing else to do. + container + ; If not found, find the near shape. + (let [near-shape (d/seek #(= (:shape-ref %) (:shape-ref shape)) + (ctf/get-component-shapes (:data library) component))] + (if (some? near-shape) + ; If found, update the ref to point to the near shape. + (ctn/update-shape container (:id shape) #(assoc % :shape-ref (:id near-shape))) + ; If not found, it may be a fostered component. Try to locate a direct shape + ; in the head component. + (let [head (ctn/get-head-shape (:objects container) shape) + library-2 (get libraries (:component-file head)) + component-2 (ctkl/get-component (:data library-2) (:component-id head) true) + direct-shape-2 (ctf/get-component-shape (:data library-2) component-2 (:shape-ref shape))] + (if (some? direct-shape-2) + ; If it exists, there is nothing else to do. + container + ; If not found, detach shape and all children (stopping if a nested instance is reached) + (let [children (ctn/get-children-in-instance (:objects container) (:id shape))] + (reduce #(ctn/update-shape %1 (:id %2) (partial detach-shape %1)) + container + children)))))))) + container))] + + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container)))) + + fix-copies-of-detached + (fn [file-data] + ; Find any copy that is referencing a detached shape inside a component, and + ; undo the nested copy, converting it into a direct copy. + (letfn [(fix-container [container] + (update container :objects update-vals fix-shape)) + + (fix-shape [shape] + (cond-> shape + (@detached-ids (:shape-ref shape)) + (dissoc shape + :component-id + :component-file + :component-root)))] + (-> file-data + (update :pages-index update-vals fix-container) + (update :components update-vals fix-container))))] + + (-> file-data + (fix-orphan-shapes) + (remove-nested-roots) + (add-not-nested-roots) + (fix-orphan-copies) + (remap-refs) + (fix-copies-of-detached)))) + +(defn- migrate-components + "If there is any component in the file library, add a new 'Library + backup', generate main instances for all components there and remove + shapes from library components. Mark the file with + the :components-v2 option." + [file-data libraries] + (let [components (ctkl/components-seq file-data)] + (if (empty? components) + (assoc-in file-data [:options :components-v2] true) + (let [[file-data page-id start-pos] + (ctf/get-or-add-library-page file-data grid-gap) + + migrate-component-shape + (fn [shape delta component-file component-id] + (cond-> shape + (nil? (:parent-id shape)) + (assoc :parent-id uuid/zero + :main-instance true + :component-root true + :component-file component-file + :component-id component-id + :type :frame ; Old groups must be converted + :fills [] ; to frames and conform to spec + :hide-in-viewer true + :rx 0 + :ry 0) + + (nil? (:frame-id shape)) + (assoc :frame-id uuid/zero) + + :always + (gsh/move delta))) + + add-main-instance + (fn [file-data component position] + (let [shapes (cph/get-children-with-self (:objects component) + (:id component)) + + root-shape (first shapes) + orig-pos (gpt/point (:x root-shape) (:y root-shape)) + delta (gpt/subtract position orig-pos) + + xf-shape (map #(migrate-component-shape % + delta + (:id file-data) + (:id component))) + new-shapes + (into [] xf-shape shapes) + + add-shapes + (fn [page] + (reduce (fn [page shape] + (ctst/add-shape (:id shape) + shape + page + (:frame-id shape) + (:parent-id shape) + nil ; <- As shapes are ordered, we can safely add each + true)) ; one at the end of the parent's children list. + page + new-shapes)) + + update-component + (fn [component] + (-> component + (assoc :main-instance-id (:id root-shape) + :main-instance-page page-id) + (dissoc :objects)))] + + (-> file-data + (ctpl/update-page page-id add-shapes) + (ctkl/update-component (:id component) update-component)))) + + add-instance-grid + (fn [fdata] + (let [components (->> fdata + (ctkl/components-seq) + (sort-by :name) + (reverse)) + positions (ctst/generate-shape-grid + (map (partial ctf/get-component-root fdata) components) + start-pos + grid-gap)] + (reduce (fn [result [component position]] + (add-main-instance result component position)) + fdata + (d/zip components positions))))] + + (when (some? *stats*) + (let [total (count components)] + (swap! *stats* (fn [stats] + (-> stats + (update :processed/components (fnil + 0) total) + (assoc :current/components total)))))) + + (-> file-data + (prepare-file-data libraries) + (add-instance-grid)))))) + +(defn- create-shapes-for-bitmap + "Convert a media object that contains a bitmap image into shapes, + one shape of type :image and one group that contains it." + [{:keys [name width height id mtype]} position] + (let [group-shape (cts/setup-shape + {:type :frame + :x (:x position) + :y (:y position) + :width width + :height height + :name name + :frame-id uuid/zero + :parent-id uuid/zero}) + + img-shape (cts/setup-shape + {:type :image + :x (:x position) + :y (:y position) + :width width + :height height + :metadata {:id id + :width width + :height height + :mtype mtype} + :name name + :frame-id uuid/zero + :parent-id (:id group-shape)})] + [group-shape [img-shape]])) + +(defn- parse-datauri + [data] + (let [[mtype b64-data] (str/split data ";base64," 2) + mtype (subs mtype (inc (str/index-of mtype ":"))) + data (-> b64-data bc/str->bytes bc/b64->bytes)] + [mtype data])) + +(defn- extract-name + [href] + (let [query-idx (d/nilv (str/last-index-of href "?") 0) + href (if (> query-idx 0) (subs href 0 query-idx) href) + filename (->> (str/split href "/") (last)) + ext-idx (str/last-index-of filename ".")] + (if (> ext-idx 0) (subs filename 0 ext-idx) filename))) + +(defn- collect-and-persist-images + [svg-data file-id] + (letfn [(process-image [{:keys [href] :as item}] + (try + (let [item (if (str/starts-with? href "data:") + (let [[mtype data] (parse-datauri href) + size (alength data) + path (tmp/tempfile :prefix "penpot.media.download.") + written (io/write-to-file! data path :size size)] + + (when (not= written size) + (ex/raise :type :internal + :code :mismatch-write-size + :hint "unexpected state: unable to write to file")) + + (-> item + (assoc :size size) + (assoc :path path) + (assoc :filename "tempfile") + (assoc :mtype mtype))) + + (let [result (cmd.media/download-image *system* href)] + (-> (merge item result) + (assoc :name (extract-name href)))))] + + ;; The media processing adds the data to the + ;; input map and returns it. + (media/run {:cmd :info :input item})) + + (catch Throwable cause + (l/warn :hint "unexpected exception on processing internal image shape (skiping)" + :cause cause) + (when-not *skip-on-error* + (throw cause))))) + + (persist-image [acc {:keys [path size width height mtype href] :as item}] + (let [storage (::sto/storage *system*) + conn (::db/conn *system*) + hash (sto/calculate-hash path) + content (-> (sto/content path size) + (sto/wrap-with-hash hash)) + params {::sto/content content + ::sto/deduplicate? true + ::sto/touched-at (:ts item) + :content-type mtype + :bucket "file-media-object"} + image (sto/put-object! storage params) + fmo-id (uuid/next)] + + (db/exec-one! conn + [cmd.media/sql:create-file-media-object + fmo-id + file-id true (:name item "image") + (:id image) + nil + width + height + mtype]) + + (assoc acc href {:id fmo-id + :mtype mtype + :width width + :height height}))) + ] + + (let [images (->> (csvg/collect-images svg-data) + (transduce (keep process-image) + (completing persist-image) {}))] + (assoc svg-data :image-data images)))) + +(defn- get-svg-content + [id] + (let [storage (::sto/storage *system*) + conn (::db/conn *system*) + fmobject (db/get conn :file-media-object {:id id}) + sobject (sto/get-object storage (:media-id fmobject))] + + (with-open [stream (sto/get-object-data storage sobject)] + (slurp stream)))) + +(defn- create-shapes-for-svg + [{:keys [id] :as mobj} file-id objects position] + (let [svg-text (get-svg-content id) + svg-data (-> (csvg/parse svg-text) + (assoc :name (:name mobj)) + (collect-and-persist-images file-id))] + (sbuilder/create-svg-shapes svg-data position objects uuid/zero nil #{} false))) + +(defn- process-media-object + [fdata page-id mobj position] + (let [page (ctpl/get-page fdata page-id) + file-id (get fdata :id) + + [shape children] + (if (= (:mtype mobj) "image/svg+xml") + (create-shapes-for-svg mobj file-id (:objects page) position) + (create-shapes-for-bitmap mobj position)) + + changes + (-> (pcb/empty-changes nil) + (pcb/set-save-undo? false) + (pcb/with-page page) + (pcb/with-objects (:objects page)) + (pcb/with-library-data fdata) + (pcb/delete-media (:id mobj)) + (pcb/add-objects (cons shape children))) + + ;; NOTE: this is a workaround for `generate-add-component`, it + ;; is needed because that function always starts from empty + ;; changes; so in this case we need manually add all shapes to + ;; the page and then use that page for the + ;; `generate-add-component` function + page + (reduce (fn [page shape] + (ctst/add-shape (:id shape) + shape + page + uuid/zero + uuid/zero + nil + true)) + page + (cons shape children)) + + [_ _ changes2] + (cflh/generate-add-component nil + [shape] + (:objects page) + (:id page) + file-id + true + nil + cfsh/prepare-create-artboard-from-selection) + changes (pcb/concat-changes changes changes2)] + + (cp/process-changes fdata (:redo-changes changes) false))) + +(defn- migrate-graphics + [fdata] + (let [[fdata page-id position] + (ctf/get-or-add-library-page fdata grid-gap) + + media (->> (vals (:media fdata)) + (map (fn [{:keys [width height] :as media}] + (let [points (-> (grc/make-rect 0 0 width height) + (grc/rect->points))] + (assoc media :points points))))) + + ;; FIXME: think about what to do with existing media entries ?? + grid (ctst/generate-shape-grid media position grid-gap)] + + (when (some? *stats*) + (let [total (count media)] + (swap! *stats* (fn [stats] + (-> stats + (update :processed/graphics (fnil + 0) total) + (assoc :current/graphics total)))))) + + (->> (d/zip media grid) + (reduce (fn [fdata [mobj position]] + (try + (process-media-object fdata page-id mobj position) + (catch Throwable cause + (l/warn :hint "unable to process file media object (skiping)" + :file-id (str (:id fdata)) + :id (str (:id mobj)) + :cause cause) + + (if-not *skip-on-error* + (throw cause) + fdata)))) + fdata)))) + +(defn- migrate-file-data + [fdata libs] + (let [migrated? (dm/get-in fdata [:options :components-v2])] + (if migrated? + fdata + (let [fdata (migrate-components fdata libs) + fdata (migrate-graphics fdata)] + (update fdata :options assoc :components-v2 true))))) + +(defn- process-file + [{:keys [id] :as file}] + (let [conn (::db/conn *system*)] + (binding [pmap/*tracked* (atom {}) + pmap/*load-fn* (partial files/load-pointer conn id) + cfeat/*wrap-with-pointer-map-fn* + (if (contains? (:features file) "fdata/pointer-map") pmap/wrap identity) + cfeat/*wrap-with-objects-map-fn* + (if (contains? (:features file) "fdata/objectd-map") omap/wrap identity)] + + (let [libs (sequence + (map (fn [{:keys [id] :as lib}] + (binding [pmap/*load-fn* (partial files/load-pointer conn id)] + (-> (db/get conn :file {:id id}) + (files/decode-row) + (files/process-pointers deref) ; ensure all pointers resolved + (pmg/migrate-file))))) + (files/get-file-libraries conn id)) + + libs (-> (d/index-by :id libs) + (assoc (:id file) file)) + + file (-> file + (update :data blob/decode) + (update :data assoc :id id) + (update :data migrate-file-data libs) + (update :features conj "components/v2"))] + + (when (contains? (:features file) "fdata/pointer-map") + (files/persist-pointers! conn id)) + + (db/update! conn :file + {:data (blob/encode (:data file)) + :features (db/create-array conn "text" (:features file)) + :revn (:revn file)} + {:id (:id file)}) + + (dissoc file :data))))) + +(defn migrate-file! + [system file-id] + (let [tpoint (dt/tpoint) + file-id (if (string? file-id) + (parse-uuid file-id) + file-id)] + (try + (l/dbg :hint "migrate:file:start" :file-id (str file-id)) + (let [system (update system ::sto/storage media/configure-assets-storage)] + (db/tx-run! system + (fn [{:keys [::db/conn] :as system}] + (binding [*system* system] + (-> (db/get conn :file {:id file-id}) + (update :features db/decode-pgarray #{}) + (process-file)))))) + + (finally + (let [elapsed (tpoint) + stats (some-> *stats* deref)] + (l/dbg :hint "migrate:file:end" + :file-id (str file-id) + :components (:current/components stats 0) + :graphics (:current/graphics stats 0) + :elapsed (dt/format-duration elapsed)) + + (when (some? *stats*) + (swap! *stats* (fn [stats] + (let [elapsed (inst-ms elapsed) + completed (inc (get stats :processed/files 0)) + total (+ (get stats :elapsed/total-by-file 0) elapsed) + avg (/ (double elapsed) completed)] + (-> stats + (update :elapsed/max-by-file (fnil max 0) elapsed) + (assoc :elapsed/avg-by-file avg) + (assoc :elapsed/total-by-file total) + (assoc :processed/files completed))))))))))) + +(defn migrate-team! + [system team-id] + (let [tpoint (dt/tpoint) + team-id (if (string? team-id) + (parse-uuid team-id) + team-id)] + (l/dbg :hint "migrate:team:start" :team-id (dm/str team-id)) + (try + (db/tx-run! system + (fn [{:keys [::db/conn] :as system}] + ;; Lock the team + (db/exec-one! conn ["SET idle_in_transaction_session_timeout = 0"]) + (db/exec-one! conn ["UPDATE team SET features = array_append(features, 'ephimeral/v2-migration') WHERE id = ?" team-id]) + + (let [{:keys [features] :as team} (-> (db/get conn :team {:id team-id}) + (update :features db/decode-pgarray #{}))] + (if (contains? features "components/v2") + (l/dbg :hint "team already migrated") + (let [sql (str/concat + "SELECT f.id FROM file AS f " + " JOIN project AS p ON (p.id = f.project_id) " + "WHERE p.team_id = ? AND f.deleted_at IS NULL AND p.deleted_at IS NULL " + "FOR UPDATE") + + rows (->> (db/exec! conn [sql team-id]) + (map :id))] + + (run! (partial migrate-file! system) rows) + (some-> *stats* (swap! assoc :current/files (count rows))) + + (let [features (-> features + (conj "components/v2") + (conj "layout/grid") + (conj "styles/v2"))] + (db/update! conn :team + {:features (db/create-array conn "text" features)} + {:id team-id}))))))) + (finally + (some-> *semaphore* ps/release!) + (let [elapsed (tpoint) + stats (some-> *stats* deref)] + (l/dbg :hint "migrate:team:end" + :team-id (dm/str team-id) + :files (:current/files stats 0) + :elapsed (dt/format-duration elapsed)) + (when (some? *stats*) + (swap! *stats* (fn [stats] + (let [elapsed (inst-ms elapsed) + completed (inc (get stats :processed/teams 0)) + total (+ (get stats :elapsed/total-by-team 0) elapsed) + avg (/ (double elapsed) completed)] + (-> stats + (update :elapsed/max-by-team (fnil max 0) elapsed) + (assoc :elapsed/avg-by-team avg) + (assoc :elapsed/total-by-team total) + (assoc :processed/teams completed))))))))))) diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj new file mode 100644 index 000000000..1c8349396 --- /dev/null +++ b/backend/src/app/features/fdata.clj @@ -0,0 +1,48 @@ +;; 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.features.fdata + "A `fdata/*` related feature migration helpers" + (:require + [app.util.objects-map :as omap] + [app.util.pointer-map :as pmap])) + +(defn enable-objects-map + [file] + (-> file + (update :data (fn [data] + (-> data + (update :pages-index update-vals #(update % :objects omap/wrap)) + (update :components update-vals #(update % :objects omap/wrap))))) + (update :features conj "fdata/objects-map"))) + +(defn enable-pointer-map + [file] + (-> file + (update :data (fn [data] + (-> data + (update :pages-index update-vals pmap/wrap) + (update :components pmap/wrap)))) + + (update :features conj "fdata/pointer-map"))) + +;; (defn enable-shape-data-type +;; [file] +;; (letfn [(update-object [object] +;; (-> object +;; (d/update-when :selrect grc/make-rect) +;; (d/update-when :svg-viewbox grc/make-rect) +;; (cts/map->Shape))) + +;; (update-container [container] +;; (d/update-when container :objects update-vals update-object))] + +;; (-> file +;; (update :data (fn [data] +;; (-> data +;; (update :pages-index update-vals update-container) +;; (update :components update-vals update-container)))) +;; (update :features conj "fdata/shape-data-type")))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index e3ed09cbd..98194b359 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -333,6 +333,8 @@ {:name "0106-mod-file-object-thumbnail-table" :fn (mg/resource "app/migrations/sql/0106-mod-file-object-thumbnail-table.sql")} + {:name "0106-mod-team-table" + :fn (mg/resource "app/migrations/sql/0106-mod-team-table.sql")} ]) (defn apply-migrations! diff --git a/backend/src/app/migrations/sql/0106-mod-team-table.sql b/backend/src/app/migrations/sql/0106-mod-team-table.sql new file mode 100644 index 000000000..6cfe89a5c --- /dev/null +++ b/backend/src/app/migrations/sql/0106-mod-team-table.sql @@ -0,0 +1 @@ +ALTER TABLE team ADD COLUMN features text[] NULL DEFAULT null; diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index d765a5598..13d05bf0f 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.logging :as l] [app.common.schema :as sm] [app.common.uuid :as uuid] @@ -291,9 +292,12 @@ (defn create-profile-rels! [conn {:keys [id] :as profile}] - (let [team (teams/create-team conn {:profile-id id - :name "Default" - :is-default true})] + (let [features (cfeat/get-enabled-features cf/flags) + team (teams/create-team conn + {:profile-id id + :name "Default" + :features features + :is-default true})] (-> (db/update! conn :profile {:default-team-id (:id team) :default-project-id (:default-project-id team)} diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 3d5ca7316..1b11c8ab4 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -8,10 +8,9 @@ (:refer-clojure :exclude [assert]) (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.files.defaults :as cfd] - [app.common.files.features :as ffeat] [app.common.files.migrations :as pmg] [app.common.fressian :as fres] [app.common.logging :as l] @@ -20,26 +19,30 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.features.components-v2 :as features.components-v2] + [app.features.fdata :as features.fdata] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.projects :as projects] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.storage :as sto] [app.storage.tmp :as tmp] [app.tasks.file-gc] [app.util.blob :as blob] - [app.util.objects-map :as omap] [app.util.pointer-map :as pmap] [app.util.services :as sv] [app.util.time :as dt] + [clojure.set :as set] [clojure.spec.alpha :as s] [clojure.walk :as walk] [cuerdas.core :as str] [datoteka.io :as io] + [promesa.util :as pu] [yetti.adapter :as yt] [yetti.response :as yrs]) (:import @@ -320,7 +323,7 @@ (defn- get-file-media [{:keys [::db/pool]} {:keys [data id] :as file}] - (dm/with-open [conn (db/open pool)] + (pu/with-open [conn (db/open pool)] (let [ids (app.tasks.file-gc/collect-used-media data) ids (db/create-array conn "uuid" ids) sql (str "SELECT * FROM file_media_object WHERE id = ANY(?)")] @@ -354,7 +357,7 @@ (defn- get-libraries [{:keys [::db/pool]} ids] - (dm/with-open [conn (db/open pool)] + (pu/with-open [conn (db/open pool)] (let [ids (db/create-array conn "uuid" ids)] (map :id (db/exec! pool [sql:file-libraries ids]))))) @@ -366,7 +369,7 @@ " WHERE flr.file_id = ANY(?)")] (db/exec! conn [sql ids]))))) -(defn- create-or-update-file +(defn- create-or-update-file! [conn params] (let [sql (str "INSERT INTO file (id, project_id, name, revn, is_shared, data, created_at, modified_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " @@ -388,6 +391,7 @@ (def ^:dynamic *options* nil) ;; --- EXPORT WRITER + (defn- embed-file-assets [data cfg file-id] (letfn [(walk-map-form [form state] @@ -472,19 +476,19 @@ (defmethod write-export :default [{:keys [::output] :as options}] (write-header! output :v1) - (with-open [output (zstd-output-stream output :level 12)] - (with-open [output (io/data-output-stream output)] - (binding [*state* (volatile! {})] - (run! (fn [section] - (l/debug :hint "write section" :section section ::l/sync? true) - (write-label! output section) - (let [options (-> options - (assoc ::output output) - (assoc ::section section))] - (binding [*options* options] - (write-section options)))) + (pu/with-open [output (zstd-output-stream output :level 12) + output (io/data-output-stream output)] + (binding [*state* (volatile! {})] + (run! (fn [section] + (l/dbg :hint "write section" :section section ::l/sync? true) + (write-label! output section) + (let [options (-> options + (assoc ::output output) + (assoc ::section section))] + (binding [*options* options] + (write-section options)))) - [:v1/metadata :v1/files :v1/rels :v1/sobjects]))))) + [:v1/metadata :v1/files :v1/rels :v1/sobjects])))) (defmethod write-section :v1/metadata [{:keys [::output ::file-ids ::include-libraries?] :as cfg}] @@ -506,23 +510,24 @@ (doseq [file-id (-> *state* deref :files)] (let [detach? (and (not embed-assets?) (not include-libraries?)) - file (cond-> (get-file cfg file-id) - detach? - (-> (ctf/detach-external-references file-id) - (dissoc :libraries)) - embed-assets? - (update :data embed-file-assets cfg file-id)) + file (cond-> (get-file cfg file-id) + detach? + (-> (ctf/detach-external-references file-id) + (dissoc :libraries)) + embed-assets? + (update :data embed-file-assets cfg file-id)) - media (get-file-media cfg file)] + media (get-file-media cfg file)] - (l/debug :hint "write penpot file" - :id file-id - :name (:name file) - :media (count media) - ::l/sync? true) + (l/dbg :hint "write penpot file" + :id file-id + :name (:name file) + :features (:features file) + :media (count media) + ::l/sync? true) (doseq [item media] - (l/debug :hint "write penpot file media object" :id (:id item) ::l/sync? true)) + (l/dbg :hint "write penpot file media object" :id (:id item) ::l/sync? true)) (doto output (write-obj! file) @@ -535,7 +540,7 @@ (let [ids (-> *state* deref :files) rels (when include-libraries? (get-library-relations cfg ids))] - (l/debug :hint "found rels" :total (count rels) ::l/sync? true) + (l/dbg :hint "found rels" :total (count rels) ::l/sync? true) (write-obj! output rels))) (defmethod write-section :v1/sobjects @@ -543,21 +548,21 @@ (let [sids (-> *state* deref :sids) storage (media/configure-assets-storage storage)] - (l/debug :hint "found sobjects" - :items (count sids) - ::l/sync? true) + (l/dbg :hint "found sobjects" + :items (count sids) + ::l/sync? true) ;; Write all collected storage objects (write-obj! output sids) (doseq [id sids] (let [{:keys [size] :as obj} (sto/get-object storage id)] - (l/debug :hint "write sobject" :id id ::l/sync? true) + (l/dbg :hint "write sobject" :id id ::l/sync? true) (doto output (write-uuid! id) (write-obj! (meta obj))) - (with-open [^InputStream stream (sto/get-object-data storage obj)] + (pu/with-open [stream (sto/get-object-data storage obj)] (let [written (write-stream! output stream size)] (when (not= written size) (ex/raise :type :validation @@ -574,15 +579,16 @@ (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 ::overwrite? (s/nilable ::us/boolean)) -(s/def ::migrate? (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 ::input] - :opt [::overwrite? ::migrate? ::ignore-index-errors?])) + (s/keys :req [::db/pool ::sto/storage ::project-id ::profile-id ::input] + :opt [::overwrite? ::ignore-index-errors?])) (defn read-import! "Do the importation of the specified resource in penpot custom binary @@ -592,9 +598,6 @@ `::overwrite?`: if true, instead of creating new files and remapping id references, it reuses all ids and updates existing objects; defaults to `false`. - `::migrate?`: if true, applies the migration before persisting the - file data; defaults to `false`. - `::ignore-index-errors?`: if true, do not fail on index lookup errors, can happen with broken files; defaults to: `false`. " @@ -604,53 +607,95 @@ (let [version (read-header! input)] (read-import (assoc options ::version version ::timestamp timestamp)))) -(defmethod read-import :v1 - [{:keys [::db/pool ::input] :as options}] - (with-open [input (zstd-input-stream input)] - (with-open [input (io/data-input-stream input)] - (db/with-atomic [conn pool] - (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED;"]) - (binding [*state* (volatile! {:media [] :index {}})] - (run! (fn [section] - (l/debug :hint "reading section" :section section ::l/sync? true) - (assert-read-label! input section) - (let [options (-> options - (assoc ::section section) - (assoc ::input input) - (assoc ::db/conn conn))] - (binding [*options* options] - (read-section options)))) - [:v1/metadata :v1/files :v1/rels :v1/sobjects]) +(defn- read-import-v1 + [{:keys [::db/conn ::project-id ::profile-id ::input] :as options}] + (db/exec-one! conn ["SET idle_in_transaction_session_timeout = 0"]) + (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]) - ;; Knowing that the ids of the created files are in - ;; index, just lookup them and return it as a set - (let [files (-> *state* deref :files)] - (into #{} (keep #(get-in @*state* [:index %])) files))))))) + (pu/with-open [input (zstd-input-stream input) + input (io/data-input-stream input)] + (binding [*state* (volatile! {:media [] :index {}})] + (let [team (teams/get-team options + :profile-id profile-id + :project-id project-id) + features (cfeat/get-team-enabled-features cf/flags team)] + + ;; Process all sections + (run! (fn [section] + (l/dbg :hint "reading section" :section section ::l/sync? true) + (assert-read-label! input section) + (let [options (-> options + (assoc ::enabled-features features) + (assoc ::section section) + (assoc ::input input))] + (binding [*options* options] + (read-section options)))) + [:v1/metadata :v1/files :v1/rels :v1/sobjects]) + + ;; Run all pending migrations + (doseq [[feature file-id] (-> *state* deref :pending-to-migrate)] + (case feature + "components/v2" + (features.components-v2/migrate-file! options file-id) + + "fdata/shape-data-type" + nil + + ;; "fdata/shape-data-type" + ;; (features.fdata/enable-objects-map + (ex/raise :type :internal + :code :no-migration-defined + :hint (str/ffmt "no migation for feature '%' on file importation" feature) + :feature feature))) + + ;; Knowing that the ids of the created files are in index, + ;; just lookup them and return it as a set + (let [files (-> *state* deref :files)] + (into #{} (keep #(get-in @*state* [:index %])) files)))))) + +(defmethod read-import :v1 + [options] + (db/tx-run! options read-import-v1)) (defmethod read-section :v1/metadata [{:keys [::input]}] (let [{:keys [version files]} (read-obj! input)] - (l/debug :hint "metadata readed" :version (:full version) :files files ::l/sync? true) + (l/dbg :hint "metadata readed" :version (:full version) :files files ::l/sync? true) (vswap! *state* update :index update-index files) (vswap! *state* assoc :version version :files files))) (defn- postprocess-file - [data] - (let [omap-wrap ffeat/*wrap-with-objects-map-fn* - pmap-wrap ffeat/*wrap-with-pointer-map-fn*] - (-> data - (update :pages-index update-vals #(update % :objects omap-wrap)) - (update :pages-index update-vals pmap-wrap) - (update :components update-vals #(d/update-when % :objects omap-wrap)) - (update :components pmap-wrap)))) + [file] + (cond-> file + (and (contains? cfeat/*current* "fdata/objects-map") + (not (contains? cfeat/*previous* "fdata/objects-map"))) + (features.fdata/enable-objects-map) + + (and (contains? cfeat/*current* "fdata/pointer-map") + (not (contains? cfeat/*previous* "fdata/pointer-map"))) + (features.fdata/enable-pointer-map))) (defmethod read-section :v1/files - [{:keys [::db/conn ::input ::migrate? ::project-id ::timestamp ::overwrite?]}] + [{:keys [::db/conn ::input ::project-id ::enabled-features ::timestamp ::overwrite?]}] + (doseq [expected-file-id (-> *state* deref :files)] - (let [file (read-obj! input) - media' (read-obj! input) - file-id (:id file) - features (files/get-default-features)] + (let [file (read-obj! input) + media' (read-obj! input) + + file-id (:id file) + file-id' (lookup-index file-id) + + features (-> enabled-features + (set/difference cfeat/frontend-only-features) + (set/union (cfeat/check-supported-features! (:features file)))) + ] + + ;; All features that are enabled and requires explicit migration + ;; are added to the state for a posterior migration step + (doseq [feature (-> enabled-features + (set/difference cfeat/no-migration-features) + (set/difference (:features file)))] + (vswap! *state* update :pending-to-migrate (fnil conj []) [feature file-id'])) (when (not= file-id expected-file-id) (ex/raise :type :validation @@ -667,59 +712,54 @@ (l/dbg :hint "update media references" ::l/sync? true) (vswap! *state* update :media into (map #(update % :id lookup-index)) media') - (binding [ffeat/*current* features - ffeat/*wrap-with-objects-map-fn* (if (features "storage/objects-map") omap/wrap identity) - ffeat/*wrap-with-pointer-map-fn* (if (features "storage/pointer-map") pmap/wrap identity) + (binding [cfeat/*current* features + cfeat/*previous* (:features file) pmap/*tracked* (atom {})] (l/dbg :hint "processing file" :id file-id - :features features + :features (:features file) :version (-> file :data :version) ::l/sync? true) - (let [file-id' (lookup-index file-id) - data (-> (:data file) - (assoc :id file-id')) + (let [params (-> file + (assoc :id file-id') + (assoc :features features) + (assoc :project-id project-id) + (assoc :created-at timestamp) + (assoc :modified-at timestamp) + (update :data (fn [data] + (-> data + (assoc :id file-id') + (cond-> (> (:version data) cfd/version) + (assoc :version cfd/version)) - data (if (> (:version data) cfd/version) - (assoc data :version cfd/version) - data) + ;; FIXME: We're temporarily activating all + ;; migrations because a problem in the + ;; environments messed up with the version + ;; numbers When this problem is fixed delete + ;; the following line + (assoc :version 0) + (update :pages-index relink-shapes) + (update :components relink-shapes) + (update :media relink-media) + (pmg/migrate-data)))) + (postprocess-file) + (update :features #(db/create-array conn "text" %)) + (update :data blob/encode))] - ;; FIXME - ;; We're temporarily activating all migrations because a problem in - ;; the environments messed up with the version numbers - ;; When this problem is fixed delete the following line - data (-> data (assoc :version 0)) - - data (-> data - - (cond-> migrate? (pmg/migrate-data)) - (update :pages-index relink-shapes) - (update :components relink-shapes) - (update :media relink-media) - (postprocess-file)) - - params {:id file-id' - :project-id project-id - :features (db/create-array conn "text" features) - :name (:name file) - :revn (:revn file) - :is-shared (:is-shared file) - :data (blob/encode data) - :created-at timestamp - :modified-at timestamp}] - - (l/debug :hint "create file" :id file-id' ::l/sync? true) + (l/dbg :hint "create file" :id file-id' ::l/sync? true) (if overwrite? - (create-or-update-file conn params) + (create-or-update-file! conn params) (db/insert! conn :file params)) (files/persist-pointers! conn file-id') (when overwrite? - (db/delete! conn :file-thumbnail {:file-id file-id'}))))))) + (db/delete! conn :file-thumbnail {:file-id file-id'})) + + file-id'))))) (defmethod read-section :v1/rels [{:keys [::db/conn ::input ::timestamp]}] @@ -734,10 +774,10 @@ (if (contains? ids library-file-id) (do - (l/debug :hint "create file library link" - :file-id (:file-id rel) - :lib-id (:library-file-id rel) - ::l/sync? true) + (l/dbg :hint "create file library link" + :file-id (:file-id rel) + :lib-id (:library-file-id rel) + ::l/sync? true) (db/insert! conn :file-library-rel rel)) (l/warn :hint "ignoring file library link" @@ -759,7 +799,7 @@ :code :inconsistent-penpot-file :hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)")) - (l/debug :hint "readed storage object" :id id ::l/sync? true) + (l/dbg :hint "readed storage object" :id id ::l/sync? true) (let [[size resource] (read-stream! input) hash (sto/calculate-hash resource) @@ -773,14 +813,14 @@ sobject (sto/put-object! storage params)] - (l/debug :hint "persisted storage object" :id id :new-id (:id sobject) ::l/sync? true) + (l/dbg :hint "persisted storage object" :id id :new-id (:id sobject) ::l/sync? true) (vswap! *state* update :index assoc id (:id sobject))))) (doseq [item (:media @*state*)] - (l/debug :hint "inserting file media object" - :id (:id item) - :file-id (:file-id item) - ::l/sync? true) + (l/dbg :hint "inserting file media object" + :id (:id item) + :file-id (:file-id item) + ::l/sync? true) (let [file-id (lookup-index (:file-id item))] (if (= file-id (:file-id item)) @@ -886,7 +926,7 @@ cs (volatile! nil)] (try (l/info :hint "start exportation" :export-id id) - (dm/with-open [output (io/output-stream output)] + (pu/with-open [output (io/output-stream output)] (binding [*position* (atom 0)] (write-export! (assoc cfg ::output output)))) @@ -909,7 +949,7 @@ (defn export-to-tmpfile! [cfg] (let [path (tmp/tempfile :prefix "penpot.export.")] - (dm/with-open [output (io/output-stream path)] + (pu/with-open [output (io/output-stream path)] (export! cfg output) path))) @@ -921,7 +961,7 @@ (l/info :hint "import: started" :import-id id) (try (binding [*position* (atom 0)] - (dm/with-open [input (io/input-stream input)] + (pu/with-open [input (io/input-stream input)] (read-import! (assoc cfg ::input input)))) (catch Throwable cause @@ -980,6 +1020,7 @@ (let [ids (import! (assoc cfg ::input (:path file) ::project-id project-id + ::profile-id profile-id ::ignore-index-errors? true))] (db/update! conn :project diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index a095cbfdf..28ff8ad61 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -9,11 +9,11 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.files.migrations :as pmg] [app.common.pages.helpers :as cph] [app.common.schema :as sm] [app.common.schema.desc-js-like :as-alias smdj] - [app.common.schema.generators :as sg] [app.common.spec :as us] [app.common.types.components-list :as ctkl] [app.common.types.file :as ctf] @@ -43,23 +43,6 @@ (when media-id (str (cf/get :public-uri) "/assets/by-id/" media-id))) -(def supported-features - #{"storage/objects-map" - "storage/pointer-map" - "internal/shape-record" - "internal/geom-record" - "components/v2"}) - -(defn get-default-features - [] - (cond-> #{"internal/shape-record" - "internal/geom-record"} - (contains? cf/flags :fdata-storage-pointer-map) - (conj "storage/pointer-map") - - (contains? cf/flags :fdata-storage-objects-map) - (conj "storage/objects-map"))) - ;; --- SPECS (s/def ::features ::us/set-of-strings) @@ -181,28 +164,10 @@ :code :object-not-found :hint "not found")))) -;; --- HELPERS - -(defn get-team-id - [conn project-id] - (:team-id (db/get-by-id conn :project project-id {:columns [:team-id]}))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; FEATURES: pointer-map ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn check-features-compatibility! - "Function responsible to check if provided features are supported by - the current backend" - [features] - (let [not-supported (set/difference features supported-features)] - (when (seq not-supported) - (ex/raise :type :restriction - :code :features-not-supported - :feature (first not-supported) - :hint (format "features %s not supported" (str/join "," (map name not-supported))))) - features)) - (defn load-pointer [conn file-id id] (let [row (db/get conn :file-data-fragment @@ -253,73 +218,16 @@ (into #{} (comp (filter pmap/pointer-map?) (map pmap/get-id))))) -(declare get-file-libraries) - -;; FIXME: file locking -(defn- process-components-v2-feature - "A special case handling of the components/v2 feature." - [conn {:keys [features data] :as file}] - (let [libraries (-> (->> (get-file-libraries conn (:id file)) ; This may be slow, but it's executed only once, - (map #(db/get conn :file {:id (:id %)})) ; in the migration to components-v2 - (map #(update % :data blob/decode)) - (d/index-by :id)) - (assoc (:id file) file)) - data (ctf/migrate-to-components-v2 data libraries) - features (conj features "components/v2")] - (-> file - (assoc ::pmg/migrated true) - (assoc :features features) - (assoc :data data)))) - -(defn handle-file-features! - [conn {:keys [features] :as file} client-features] - - ;; Check features compatibility between the currently supported features on - ;; the current backend instance and the file retrieved from the database - (check-features-compatibility! features) - - (cond-> file - (and (contains? features "components/v2") - (not (contains? client-features "components/v2"))) - (as-> file (ex/raise :type :restriction - :code :feature-mismatch - :feature "components/v2" - :hint "file has 'components/v2' feature enabled but frontend didn't specifies it" - :file-id (:id file))) - - ;; This operation is needed because the components migration generates a new - ;; page with random id which is returned to the client; without persisting - ;; the migration this can cause that two simultaneous clients can have a - ;; different view of the file data and end persisting two pages with main - ;; components and breaking the whole file." - (and (contains? client-features "components/v2") - (not (contains? features "components/v2"))) - (as-> file (process-components-v2-feature conn file)) - - ;; This operation is needed for backward comapatibility with frontends that - ;; does not support pointer-map resolution mechanism; this just resolves the - ;; pointers on backend and return a complete file. - (and (contains? features "storage/pointer-map") - (not (contains? client-features "storage/pointer-map"))) - (process-pointers deref))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUERY COMMANDS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; --- COMMAND QUERY: get-file (by id) -(def schema:features - [:schema - {:title "FileFeatures" - ::smdj/inline true - :gen/gen (sg/subseq supported-features)} - ::sm/set-of-strings]) - (def schema:file [:map {:title "File"} [:id ::sm/uuid] - [:features schema:features] + [:features ::cfeat/features] [:has-media-trimmed :boolean] [:comment-thread-seqn {:min 0} :int] [:name :string] @@ -341,18 +249,21 @@ (def schema:get-file [:map {:title "get-file"} - [:features {:optional true} schema:features] + [:features {:optional true} ::cfeat/features] [:id ::sm/uuid] [:project-id {:optional true} ::sm/uuid]]) (defn get-file - ([conn id client-features] - (get-file conn id client-features nil)) - ([conn id client-features project-id] - ;; here we check if client requested features are supported - (check-features-compatibility! client-features) + ([conn id] (get-file conn id nil)) + ([conn id project-id] + + (dm/assert! + "expected raw connection" + (db/connection? conn)) + (binding [pmap/*load-fn* (partial load-pointer conn id) - pmap/*tracked* (atom {})] + pmap/*tracked* (atom {}) + cfeat/*new* (atom #{})] (let [params (merge {:id id} (when (some? project-id) @@ -360,22 +271,21 @@ file (-> (db/get conn :file params) (decode-row) - (pmg/migrate-file)) - - file (handle-file-features! conn file client-features)] + (pmg/migrate-file))] ;; NOTE: when file is migrated, we break the rule of no perform ;; mutations on get operations and update the file with all ;; migrations applied - (when (pmg/migrated? file) - (let [features (db/create-array conn "text" (:features file))] + (if (pmg/migrated? file) + (let [features (set/union (deref cfeat/*new*) (:features file))] (db/update! conn :file {:data (blob/encode (:data file)) - :features features} + :features (db/create-array conn "text" features)} {:id id}) - (persist-pointers! conn id))) + (persist-pointers! conn id) + (assoc file :features features)) - file)))) + file))))) (defn get-minimal-file [{:keys [::db/pool] :as cfg} id] @@ -392,14 +302,32 @@ ::cond/key-fn get-file-etag ::sm/params schema:get-file ::sm/result schema:file-with-permissions} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id features project-id] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id id)] - (check-read-permissions! perms) - (let [file (-> (get-file conn id features project-id) - (assoc :permissions perms))] - (vary-meta file assoc ::cond/key (get-file-etag params file)))))) + [cfg {:keys [::rpc/profile-id id project-id] :as params}] + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (let [perms (get-permissions conn profile-id id)] + (check-read-permissions! perms) + (let [team (teams/get-team cfg + :profile-id profile-id + :project-id project-id + :file-id id) + file (-> (get-file conn id project-id) + (assoc :permissions perms)) + + _ (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) + + ;; This operation is needed for backward comapatibility with frontends that + ;; does not support pointer-map resolution mechanism; this just resolves the + ;; pointers on backend and return a complete file. + file (if (and (contains? (:features file) "fdata/pointer-map") + (not (contains? (:features params) "fdata/pointer-map"))) + (binding [pmap/*load-fn* (partial load-pointer conn id)] + (process-pointers file deref)) + file)] + + (vary-meta file assoc ::cond/key (get-file-etag params file))))))) ;; --- COMMAND QUERY: get-file-fragment (by id) @@ -422,7 +350,7 @@ (update :content blob/decode))) (sv/defmethod ::get-file-fragment - "Retrieve a file by its ID. Only authenticated users." + "Retrieve a file fragment by its ID. Only authenticated users." {::doc/added "1.17" ::sm/params schema:get-file-fragment ::sm/result schema:file-fragment} @@ -477,7 +405,6 @@ (projects/check-read-permissions! conn profile-id project-id) (get-project-files conn project-id))) - ;; --- COMMAND QUERY: has-file-libraries (declare get-has-file-libraries) @@ -528,30 +455,41 @@ (update page :objects update-vals #(dissoc % :thumbnail))) (defn get-page - [conn {:keys [file-id page-id object-id features]}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id page-id object-id] :as params}] (when (and (uuid? object-id) (not (uuid? page-id))) (ex/raise :type :validation :code :params-validation :hint "page-id is required when object-id is provided")) - (let [file (get-file conn file-id features) - page-id (or page-id (-> file :data :pages first)) - page (dm/get-in file [:data :pages-index page-id]) - page (if (pmap/pointer-map? page) + (let [team (teams/get-team cfg + :profile-id profile-id + :file-id file-id) + + file (get-file conn file-id) + + _ (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) + + page (binding [pmap/*load-fn* (partial load-pointer conn file-id)] + (let [page-id (or page-id (-> file :data :pages first)) + page (dm/get-in file [:data :pages-index page-id])] + (if (pmap/pointer-map? page) (deref page) - page)] + page)))] + (cond-> (prune-thumbnails page) (uuid? object-id) (prune-objects object-id)))) (def schema:get-page - [:map {:title "GetPage"} + [:map {:title "get-page"} [:file-id ::sm/uuid] [:page-id {:optional true} ::sm/uuid] [:share-id {:optional true} ::sm/uuid] [:object-id {:optional true} ::sm/uuid] - [:features {:optional true} schema:features]]) + [:features {:optional true} ::cfeat/features]]) (sv/defmethod ::get-page "Retrieves the page data from file and returns it. If no page-id is @@ -565,12 +503,11 @@ Mainly used for rendering purposes." {::doc/added "1.17" ::sm/params schema:get-page} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}] - (dm/with-open [conn (db/open pool)] - (let [perms (get-permissions conn profile-id file-id share-id)] - (check-read-permissions! perms) - (binding [pmap/*load-fn* (partial load-pointer conn file-id)] - (get-page conn params))))) + [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (check-read-permissions! conn profile-id file-id share-id) + (get-page cfg (assoc params :profile-id profile-id))))) ;; --- COMMAND QUERY: get-team-shared-files @@ -593,6 +530,7 @@ and p.team_id = ? order by f.modified_at desc") +;; FIXME: i'm not sure about feature handling here... ??? (defn get-team-shared-files [conn team-id] (letfn [(assets-sample [assets limit] @@ -626,19 +564,19 @@ (map #(assoc % :library-summary (library-summary %))) (map #(dissoc % :data))))))) -(s/def ::get-team-shared-files - (s/keys :req [::rpc/profile-id] - :req-un [::team-id])) +(def ^:private schema:get-team-shared-files + [:map {:title "get-team-shared-files"} + [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-shared-files "Get all file (libraries) for the specified team." - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-team-shared-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (teams/check-read-permissions! conn profile-id team-id) (get-team-shared-files conn team-id))) - ;; --- COMMAND QUERY: get-file-libraries (def ^:private sql:get-file-libraries @@ -669,17 +607,20 @@ [conn file-id] (into [] (comp + ;; FIXME: :is-indirect set to false to all rows looks + ;; completly useless (map #(assoc % :is-indirect false)) (map decode-row)) (db/exec! conn [sql:get-file-libraries file-id]))) -(s/def ::get-file-libraries - (s/keys :req [::rpc/profile-id] - :req-un [::file-id])) +(def ^:private schema:get-file-libraries + [:map {:title "get-file-libraries"} + [:file-id ::sm/uuid]]) (sv/defmethod ::get-file-libraries "Get libraries used by the specified file." - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-file-libraries} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id file-id) @@ -700,12 +641,14 @@ [conn file-id] (db/exec! conn [sql:library-using-files file-id])) -(s/def ::get-library-file-references - (s/keys :req [::rpc/profile-id] :req-un [::file-id])) +(def ^:private schema:get-library-file-references + [:map {:title "get-library-file-references"} + [:file-id ::sm/uuid]]) (sv/defmethod ::get-library-file-references "Returns all the file references that use specified file (library) id." - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-library-file-references} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id file-id) @@ -745,12 +688,13 @@ (assoc :thumbnail-uri (resolve-public-uri media-id))) (dissoc row :media-id)))))) -(s/def ::get-team-recent-files - (s/keys :req [::rpc/profile-id] - :req-un [::team-id])) +(def ^:private schema:get-team-recent-files + [:map {:title "get-team-recent-files"} + [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-recent-files - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-team-recent-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (teams/check-read-permissions! conn profile-id team-id) @@ -763,15 +707,26 @@ "Retrieve a file summary by its ID. Only authenticated users." {::doc/added "1.20" ::sm/params schema:get-file} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id features project-id] :as params}] - (db/with-atomic [conn pool] - (check-read-permissions! conn profile-id id) - (let [file (get-file conn id features project-id)] - {:name (:name file) - :components-count (count (ctkl/components-seq (:data file))) - :graphics-count (count (get-in file [:data :media] [])) - :colors-count (count (get-in file [:data :colors] [])) - :typography-count (count (get-in file [:data :typographies] []))}))) + [cfg {:keys [::rpc/profile-id id project-id] :as params}] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (check-read-permissions! conn profile-id id) + (let [team (teams/get-team cfg + :profile-id profile-id + :project-id project-id + :file-id id) + + file (get-file conn id project-id)] + + (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) + + {:name (:name file) + :components-count (count (ctkl/components-seq (:data file))) + :graphics-count (count (get-in file [:data :media] [])) + :colors-count (count (get-in file [:data :colors] [])) + :typography-count (count (get-in file [:data :typographies] []))})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MUTATION COMMANDS @@ -927,13 +882,15 @@ [conn {:keys [file-id library-id] :as params}] (db/exec-one! conn [sql:link-file-to-library file-id library-id])) -(s/def ::link-file-to-library - (s/keys :req [::rpc/profile-id] - :req-un [::file-id ::library-id])) +(def ^:private schema:link-file-to-library + [:map {:title "link-file-to-library"} + [:file-id ::sm/uuid] + [:library-id ::sm/uuid]]) (sv/defmethod ::link-file-to-library {::doc/added "1.17" - ::webhooks/event? true} + ::webhooks/event? true + ::sm/params schema:link-file-to-library} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (when (= file-id library-id) (ex/raise :type :validation @@ -952,13 +909,15 @@ {:file-id file-id :library-file-id library-id})) -(s/def ::unlink-file-from-library - (s/keys :req [::rpc/profile-id] - :req-un [::file-id ::library-id])) +(def ^:private schema:unlink-file-to-library + [:map {:title "unlink-file-to-library"} + [:file-id ::sm/uuid] + [:library-id ::sm/uuid]]) (sv/defmethod ::unlink-file-from-library {::doc/added "1.17" - ::webhooks/event? true} + ::webhooks/event? true + ::sm/params schema:unlink-file-to-library} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (db/with-atomic [conn pool] (check-edition-permissions! conn profile-id file-id) @@ -974,15 +933,15 @@ {:file-id file-id :library-file-id library-id})) -(s/def ::update-file-library-sync-status - (s/keys :req [::rpc/profile-id] - :req-un [::file-id ::library-id])) - -;; TODO: improve naming +(def ^:private schema:update-file-library-sync-status + [:map {:title "update-file-library-sync-status"} + [:file-id ::sm/uuid] + [:library-id ::sm/uuid]]) (sv/defmethod ::update-file-library-sync-status "Update the synchronization status of a file->library link" - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:update-file-library-sync-status} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (db/with-atomic [conn pool] (check-edition-permissions! conn profile-id file-id) diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index 8f279d577..184c50a49 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -7,15 +7,18 @@ (ns app.rpc.commands.files-create (:require [app.common.data :as d] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] + [app.common.schema :as sm] [app.common.types.file :as ctf] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.projects :as projects] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] @@ -24,7 +27,7 @@ [app.util.pointer-map :as pmap] [app.util.services :as sv] [app.util.time :as dt] - [clojure.spec.alpha :as s])) + [clojure.set :as set])) (defn create-file-role! [conn {:keys [file-id profile-id role]}] @@ -34,27 +37,27 @@ (db/insert! conn :file-profile-rel)))) (defn create-file - [conn {:keys [id name project-id is-shared revn - modified-at deleted-at create-page - ignore-sync-until features] - :or {is-shared false revn 0 create-page true} - :as params}] + [{:keys [::db/conn] :as cfg} + {:keys [id name project-id is-shared revn + modified-at deleted-at create-page + ignore-sync-until features] + :or {is-shared false revn 0 create-page true} + :as params}] (let [id (or id (uuid/next)) - features (->> features - (into (files/get-default-features)) - (files/check-features-compatibility!)) pointers (atom {}) data (binding [pmap/*tracked* pointers - ffeat/*current* features - ffeat/*wrap-with-objects-map-fn* (if (features "storate/objects-map") omap/wrap identity) - ffeat/*wrap-with-pointer-map-fn* (if (features "storage/pointer-map") pmap/wrap identity)] + cfeat/*current* features + cfeat/*wrap-with-objects-map-fn* (if (features "fdata/objects-map") omap/wrap identity) + cfeat/*wrap-with-pointer-map-fn* (if (features "fdata/pointer-map") pmap/wrap identity)] (if create-page (ctf/make-file-data id) (ctf/make-file-data id nil))) - features (db/create-array conn "text" features) + features (->> (set/difference features cfeat/frontend-only-features) + (db/create-array conn "text")) + file (db/insert! conn :file (d/without-nils {:id id @@ -80,29 +83,58 @@ (files/decode-row file))) -(s/def ::create-file - (s/keys :req [::rpc/profile-id] - :req-un [::files/name - ::files/project-id] - :opt-un [::files/id - ::files/is-shared - ::files/features])) +(def ^:private schema:create-file + [:map {:title "create-file"} + [:name :string] + [:project-id ::sm/uuid] + [:id {:optional true} ::sm/uuid] + [:is-shared {:optional true} :boolean] + [:features {:optional true} ::cfeat/features]]) (sv/defmethod ::create-file {::doc/added "1.17" ::doc/module :files - ::webhooks/event? true} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}] - (db/with-atomic [conn pool] - (projects/check-edition-permissions! conn profile-id project-id) - (let [team-id (files/get-team-id conn project-id) - params (assoc params :profile-id profile-id)] + ::webhooks/event? true + ::sm/params schema:create-file} + [cfg {:keys [::rpc/profile-id project-id] :as params}] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (projects/check-edition-permissions! conn profile-id project-id) + (let [team (teams/get-team cfg + :profile-id profile-id + :project-id project-id) + team-id (:id team) - (run! (partial quotes/check-quote! conn) - (list {::quotes/id ::quotes/files-per-project - ::quotes/team-id team-id - ::quotes/profile-id profile-id - ::quotes/project-id project-id})) + ;; When we create files, we only need to respect the team + ;; features, because some features can be enabled + ;; globally, but the team is still not migrated properly. + features (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params))) - (-> (create-file conn params) - (vary-meta assoc ::audit/props {:team-id team-id}))))) + ;; We also include all no migration features declared by + ;; client; that enables the ability to enable a runtime + ;; feature on frontend and make it permanent on file + features (-> (:features params #{}) + (set/intersection cfeat/no-migration-features) + (set/union features)) + + params (-> params + (assoc :profile-id profile-id) + (assoc :features features))] + + (run! (partial quotes/check-quote! conn) + (list {::quotes/id ::quotes/files-per-project + ::quotes/team-id team-id + ::quotes/profile-id profile-id + ::quotes/project-id project-id})) + + ;; When newly computed features does not match exactly with + ;; the features defined on team row, we update it. + (when (not= features (:features team)) + (let [features (db/create-array conn "text" features)] + (db/update! conn :team + {:features features} + {:id team-id}))) + + (-> (create-file cfg params) + (vary-meta assoc ::audit/props {:team-id team-id})))))) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 9c6e68d9a..d247df3bc 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -9,12 +9,14 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.geom.shapes :as gsh] [app.common.pages.helpers :as cph] [app.common.schema :as sm] [app.common.spec :as us] [app.common.thumbnails :as thc] [app.common.types.shape-tree :as ctt] + [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.loggers.audit :as-alias audit] @@ -22,6 +24,7 @@ [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.teams :as teams] [app.rpc.cond :as-alias cond] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] @@ -237,7 +240,7 @@ (def ^:private schema:get-file-data-for-thumbnail [:map {:title "get-file-data-for-thumbnail"} [:file-id ::sm/uuid] - [:features {:optional true} files/schema:features]]) + [:features {:optional true} ::cfeat/features]]) (def ^:private schema:partial-file [:map {:title "PartialFile"} @@ -252,17 +255,23 @@ ::doc/module :files ::sm/params schema:get-file-data-for-thumbnail ::sm/result schema:partial-file} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id features] :as props}] - (dm/with-open [conn (db/open pool)] - (files/check-read-permissions! conn profile-id file-id) - ;; NOTE: we force here the "storage/pointer-map" feature, because - ;; it used internally only and is independent if user supports it - ;; or not. - (let [feat (into #{"storage/pointer-map"} features) - file (files/get-file conn file-id feat)] - {:file-id file-id - :revn (:revn file) - :page (get-file-data-for-thumbnail conn file)}))) + [cfg {:keys [::rpc/profile-id file-id] :as params}] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-read-permissions! conn profile-id file-id) + + (let [team (teams/get-team cfg + :profile-id profile-id + :file-id file-id) + + file (files/get-file conn file-id)] + + (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) + + {:file-id file-id + :revn (:revn file) + :page (get-file-data-for-thumbnail conn file)})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MUTATION COMMANDS diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 10d52261a..63d352a9d 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -8,18 +8,17 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] [app.common.files.migrations :as pmg] [app.common.files.validate :as val] [app.common.logging :as l] [app.common.pages :as cp] [app.common.pages.changes :as cpc] [app.common.schema :as sm] - [app.common.schema.generators :as smg] - [app.common.types.file :as ctf] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.features.fdata :refer [enable-pointer-map enable-objects-map]] [app.loggers.audit :as audit] [app.loggers.webhooks :as webhooks] [app.metrics :as mtx] @@ -27,43 +26,42 @@ [app.rpc :as-alias rpc] [app.rpc.climit :as climit] [app.rpc.commands.files :as files] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.util.blob :as blob] [app.util.objects-map :as omap] [app.util.pointer-map :as pmap] [app.util.services :as sv] - [app.util.time :as dt])) + [app.util.time :as dt] + [clojure.set :as set])) ;; --- SCHEMA -(sm/def! ::changes +(def ^:private schema:changes [:vector ::cpc/change]) -(sm/def! ::change-with-metadata +(def ^:private schema:change-with-metadata [:map {:title "ChangeWithMetadata"} - [:changes ::changes] + [:changes schema:changes] [:hint-origin {:optional true} :keyword] [:hint-events {:optional true} [:vector :string]]]) -(sm/def! ::update-file-params - [:map {:title "UpdateFileParams"} +(def ^:private schema:update-file + [:map {:title "update-file"} [:id ::sm/uuid] [:session-id ::sm/uuid] [:revn {:min 0} :int] - [:features {:optional true - :gen/max 3 - :gen/gen (smg/subseq files/supported-features)} - ::sm/set-of-strings] - [:changes {:optional true} ::changes] + [:features {:optional true} ::cfeat/features] + [:changes {:optional true} schema:changes] [:changes-with-metadata {:optional true} - [:vector ::change-with-metadata]] + [:vector schema:change-with-metadata]] [:skip-validate {:optional true} :boolean]]) -(sm/def! ::update-file-result - [:vector {:title "UpdateFileResults"} - [:map {:title "UpdateFileResult"} - [:changes ::changes] +(def ^:private schema:update-file-result + [:vector {:title "update-file-result"} + [:map + [:changes schema:changes] [:file-id ::sm/uuid] [:id ::sm/uuid] [:revn {:min 0} :int] @@ -112,7 +110,7 @@ (fn [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] (binding [pmap/*tracked* (atom {}) pmap/*load-fn* (partial files/load-pointer conn id) - ffeat/*wrap-with-pointer-map-fn* pmap/wrap] + cfeat/*wrap-with-pointer-map-fn* pmap/wrap] (let [result (f cfg file)] (files/persist-pointers! conn id) result)))) @@ -120,7 +118,7 @@ (defn- wrap-with-objects-map-context [f] (fn [cfg file] - (binding [ffeat/*wrap-with-objects-map-fn* omap/wrap] + (binding [cfeat/*wrap-with-objects-map-fn* omap/wrap] (f cfg file)))) (declare get-lagged-changes) @@ -141,81 +139,95 @@ ::webhooks/batch-timeout (dt/duration "2m") ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) - ::sm/params ::update-file-params - ::sm/result ::update-file-result - + ::sm/params schema:update-file + ::sm/result schema:update-file-result ::doc/module :files ::doc/added "1.17"} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}] - (db/with-atomic [conn pool] - (files/check-edition-permissions! conn profile-id id) - (db/xact-lock! conn id) + [cfg {:keys [::rpc/profile-id id] :as params}] + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-edition-permissions! conn profile-id id) + (db/xact-lock! conn id) - (let [cfg (assoc cfg ::db/conn conn) - params (assoc params :profile-id profile-id) - tpoint (dt/tpoint)] - (-> (update-file cfg params) - (rph/with-defer #(let [elapsed (tpoint)] - (l/trace :hint "update-file" :time (dt/format-duration elapsed)))))))) + (let [file (get-file conn id) + team (teams/get-team cfg + :profile-id profile-id + :team-id (:team-id file)) + + features (-> (cfeat/get-team-enabled-features cf/flags team) + (cfeat/check-client-features! (:features params)) + (cfeat/check-file-features! (:features file) (:features params))) + + params (assoc params + :profile-id profile-id + :features features + :team team + :file file) + + tpoint (dt/tpoint)] + + ;; When newly computed features does not match exactly with + ;; the features defined on team row, we update it. + (when (not= features (:features team)) + (let [features (db/create-array conn "text" features)] + (db/update! conn :team + {:features features} + {:id (:id team)}))) + + (-> (update-file cfg params) + (rph/with-defer #(let [elapsed (tpoint)] + (l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))) (defn update-file - [{:keys [::db/conn ::mtx/metrics] :as cfg} {:keys [profile-id id changes changes-with-metadata skip-validate] :as params}] - (let [file (get-file conn id) - features (->> (concat (:features file) - (:features params)) - (into (files/get-default-features)) - (files/check-features-compatibility!))] + [{:keys [::db/conn ::mtx/metrics] :as cfg} {:keys [id file features changes changes-with-metadata skip-validate] :as params}] + (binding [cfeat/*current* features + cfeat/*previous* (:features file)] + (let [update-fn (cond-> update-file* + (contains? features "fdata/pointer-map") + (wrap-with-pointer-map-context) - (files/check-edition-permissions! conn profile-id (:id file)) + (contains? features "fdata/objects-map") + (wrap-with-objects-map-context)) - (binding [ffeat/*current* features - ffeat/*previous* (:features file)] + ;; TODO: this ruins performance. + ;; We must find some other way to do general validation. + libraries (when (and (contains? cf/flags :file-validation) + (not skip-validate)) + (let [libs (->> (files/get-file-libraries conn (:id file)) + (map #(get-file conn (:id %))) + (map #(update % :data blob/decode)) + (d/index-by :id))] + (assoc libs (:id file) file))) - (let [update-fn (cond-> update-file* - (contains? features "storage/pointer-map") - (wrap-with-pointer-map-context) + changes (if changes-with-metadata + (->> changes-with-metadata (mapcat :changes) vec) + (vec changes)) - (contains? features "storage/objects-map") - (wrap-with-objects-map-context)) + features (-> features + (set/difference cfeat/frontend-only-features) + (set/union (:features file)))] - file (assoc file :features features) + (when (> (:revn params) + (:revn file)) + (ex/raise :type :validation + :code :revn-conflict + :hint "The incoming revision number is greater that stored version." + :context {:incoming-revn (:revn params) + :stored-revn (:revn file)})) - ;; TODO: this ruins performance. - ;; We must find some other way to do general validation. - libraries (when (and (cf/flags :file-validation) - (not skip-validate)) - (-> (->> (files/get-file-libraries conn (:id file)) - (map #(get-file conn (:id %))) - (map #(update % :data blob/decode)) - (d/index-by :id)) - (assoc (:id file) file))) + (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) - changes (if changes-with-metadata - (->> changes-with-metadata (mapcat :changes) vec) - (vec changes)) - - params (-> params - (assoc :file file) - (assoc :libraries libraries) - (assoc :changes changes) - (assoc ::created-at (dt/now)))] - - (when (> (:revn params) - (:revn file)) - (ex/raise :type :validation - :code :revn-conflict - :hint "The incoming revision number is greater that stored version." - :context {:incoming-revn (:revn params) - :stored-revn (:revn file)})) - - (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) - - (when (not= features (:features file)) - (let [features (db/create-array conn "text" features)] - (db/update! conn :file - {:features features} - {:id id}))) + (when (not= features (:features file)) + (let [features (db/create-array conn "text" features)] + (db/update! conn :file + {:features features} + {:id id}))) + (let [file (assoc file :features features) + params (-> params + (assoc :file file) + (assoc :libraries libraries) + (assoc :changes changes) + (assoc ::created-at (dt/now)))] (-> (update-fn cfg params) (vary-meta assoc ::audit/replace-props {:id (:id file) @@ -230,7 +242,7 @@ ;; to be executed on a separated executor for avoid to do the ;; CPU intensive operation on vthread. file (-> (climit/configure cfg :update-file) - (climit/submit! (partial update-file-data conn file libraries changes skip-validate)))] + (climit/submit! (partial update-file-data file libraries changes skip-validate)))] (db/insert! conn :file-change {:id (uuid/next) @@ -264,39 +276,36 @@ (get-lagged-changes conn params)))) (defn- update-file-data - [conn file libraries changes skip-validate] + [file libraries changes skip-validate] (let [validate (fn [file] (when (and (cf/flags :file-validation) (not skip-validate)) (val/validate-file file libraries :throw? true))) - - do-migrate-v2 (fn [file] - ;; When migrating to components-v2 we need the libraries even - ;; if the validations are disabled. - (let [libraries (or (seq libraries) - (-> (->> (files/get-file-libraries conn (:id file)) - (map #(get-file conn (:id %))) - (map #(update % :data blob/decode)) - (d/index-by :id)) - (assoc (:id file) file)))] - (ctf/migrate-to-components-v2 file libraries)))] - (-> file - (update :revn inc) - (update :data (fn [data] - (cond-> data - :always - (-> (blob/decode) - (assoc :id (:id file)) - (pmg/migrate-data)) + file (-> file + (update :revn inc) + (update :data (fn [data] + (cond-> data + :always + (-> (blob/decode) + (assoc :id (:id file)) + (pmg/migrate-data)) - (and (contains? ffeat/*current* "components/v2") - (not (contains? ffeat/*previous* "components/v2"))) - (do-migrate-v2) + :always + (cp/process-changes changes)))) + (d/tap-r validate)) - :always - (cp/process-changes changes)))) - (d/tap-r validate) - (update :data blob/encode)))) + file (if (and (contains? cfeat/*current* "fdata/objects-map") + (not (contains? cfeat/*previous* "fdata/objects-map"))) + (enable-objects-map file) + file) + + file (if (and (contains? cfeat/*current* "fdata/pointer-map") + (not (contains? cfeat/*previous* "fdata/pointer-map"))) + (enable-pointer-map file) + file) + ] + + (update file :data blob/encode))) (defn- take-snapshot? "Defines the rule when file `data` snapshot should be saved." @@ -325,7 +334,7 @@ (vec))) (defn- send-notifications! - [{:keys [::db/conn] :as cfg} {:keys [file changes session-id] :as params}] + [cfg {:keys [file team changes session-id] :as params}] (let [lchanges (filter library-change? changes) msgbus (::mbus/msgbus cfg)] @@ -339,14 +348,12 @@ :changes changes}) (when (and (:is-shared file) (seq lchanges)) - (let [team-id (or (:team-id file) - (files/get-team-id conn (:project-id file)))] - (mbus/pub! msgbus - :topic team-id - :message {:type :library-change - :profile-id (:profile-id params) - :file-id (:id file) - :session-id session-id - :revn (:revn file) - :modified-at (dt/now) - :changes lchanges}))))) + (mbus/pub! msgbus + :topic (:id team) + :message {:type :library-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id session-id + :revn (:revn file) + :modified-at (dt/now) + :changes lchanges})))) diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index c166f7c1a..afcb48420 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -78,13 +78,13 @@ ::audit/profile-id (:id profile)})))))) (defn- login-or-register - [{:keys [::db/pool] :as cfg} info] - (db/with-atomic [conn pool] - (or (some->> (:email info) - (profile/get-profile-by-email conn) - (profile/decode-row)) - (->> (assoc info :is-active true :is-demo false) - (auth/create-profile! conn) - (auth/create-profile-rels! conn) - (profile/strip-private-attrs))))) - + [cfg info] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (or (some->> (:email info) + (profile/get-profile-by-email conn) + (profile/decode-row)) + (->> (assoc info :is-active true :is-demo false) + (auth/create-profile! conn) + (auth/create-profile-rels! conn) + (profile/strip-private-attrs)))))) diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index ead66317b..aa23de4ce 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -9,9 +9,9 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.files.migrations :as pmg] [app.common.schema :as sm] - [app.common.spec :as us] [app.common.uuid :as uuid] [app.db :as db] [app.loggers.webhooks :as-alias webhooks] @@ -27,7 +27,6 @@ [app.util.pointer-map :as pmap] [app.util.services :as sv] [app.util.time :as dt] - [clojure.spec.alpha :as s] [clojure.walk :as walk] [promesa.exec :as px])) @@ -35,21 +34,16 @@ (declare duplicate-file) -(s/def ::id ::us/uuid) -(s/def ::project-id ::us/uuid) -(s/def ::file-id ::us/uuid) -(s/def ::team-id ::us/uuid) -(s/def ::name ::us/string) - -(s/def ::duplicate-file - (s/keys :req [::rpc/profile-id] - :req-un [::file-id] - :opt-un [::name])) +(def ^:private schema:duplicate-file + [:map {:title "duplicate-file"} + [:file-id ::sm/uuid] + [:name {:optional true} :string]]) (sv/defmethod ::duplicate-file "Duplicate a single file in the same team." {::doc/added "1.16" - ::webhooks/event? true} + ::webhooks/event? true + ::sm/params schema:duplicate-file} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (db/with-atomic [conn pool] (duplicate-file conn (assoc params :profile-id profile-id)))) @@ -125,14 +119,14 @@ (files/persist-pointers! conn file-id) data))))))) -(def sql:retrieve-used-libraries +(def sql:get-used-libraries "select flr.* from file_library_rel as flr inner join file as l on (flr.library_file_id = l.id) where flr.file_id = ? and l.deleted_at is null") -(def sql:retrieve-used-media-objects +(def sql:get-used-media-objects "select fmo.* from file_media_object as fmo inner join storage_object as so on (fmo.media_id = so.id) @@ -141,8 +135,8 @@ (defn duplicate-file* [conn {:keys [profile-id file index project-id name flibs fmeds]} {:keys [reset-shared-flag]}] - (let [flibs (or flibs (db/exec! conn [sql:retrieve-used-libraries (:id file)])) - fmeds (or fmeds (db/exec! conn [sql:retrieve-used-media-objects (:id file)])) + (let [flibs (or flibs (db/exec! conn [sql:get-used-libraries (:id file)])) + fmeds (or fmeds (db/exec! conn [sql:get-used-media-objects (:id file)])) ;; memo uniform creation/modification date now (dt/now) @@ -216,15 +210,16 @@ (declare duplicate-project) -(s/def ::duplicate-project - (s/keys :req [::rpc/profile-id] - :req-un [::project-id] - :opt-un [::name])) +(def ^:private schema:duplicate-project + [:map {:title "duplicate-project"} + [:project-id ::sm/uuid] + [:name {:optional true} :string]]) (sv/defmethod ::duplicate-project "Duplicate an entire project with all the files" {::doc/added "1.16" - ::webhooks/event? true} + ::webhooks/event? true + ::sm/params schema:duplicate-project} [{:keys [::db/pool] :as cfg} params] (db/with-atomic [conn pool] (duplicate-project conn (assoc params :profile-id (::rpc/profile-id params))))) @@ -275,7 +270,7 @@ ;; --- COMMAND: Move file -(def sql:retrieve-files +(def sql:get-files "select id, project_id from file where id = ANY(?)") (def sql:move-files @@ -297,14 +292,19 @@ and rel.library_file_id = br.library_file_id") (defn move-files - [conn {:keys [profile-id ids project-id] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id ids project-id] :as params}] (let [fids (db/create-array conn "uuid" ids) - files (db/exec! conn [sql:retrieve-files fids]) + files (db/exec! conn [sql:get-files fids]) source (into #{} (map :project-id) files) pids (->> (conj source project-id) (db/create-array conn "uuid"))] + (when (contains? source project-id) + (ex/raise :type :validation + :code :cant-move-to-same-project + :hint "Unable to move a file to the same project")) + ;; Check if we have permissions on the destination project (proj/check-edition-permissions! conn profile-id project-id) @@ -312,10 +312,10 @@ (doseq [project-id source] (proj/check-edition-permissions! conn profile-id project-id)) - (when (contains? source project-id) - (ex/raise :type :validation - :code :cant-move-to-same-project - :hint "Unable to move a file to the same project")) + ;; Check the team compatibility + (let [orig-team (teams/get-team cfg :profile-id profile-id :project-id (first source)) + dest-team (teams/get-team cfg :profile-id profile-id :project-id project-id)] + (cfeat/check-teams-compatibility! orig-team dest-team)) ;; move all files to the project (db/exec-one! conn [sql:move-files project-id fids]) @@ -337,36 +337,41 @@ nil)) -(s/def ::ids (s/every ::us/uuid :kind set?)) -(s/def ::move-files - (s/keys :req [::rpc/profile-id] - :req-un [::ids ::project-id])) +(def ^:private schema:move-files + [:map {:title "move-files"} + [:ids ::sm/set-of-uuid] + [:project-id ::sm/uuid]]) (sv/defmethod ::move-files "Move a set of files from one project to other." {::doc/added "1.16" - ::webhooks/event? true} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] - (db/with-atomic [conn pool] - (move-files conn (assoc params :profile-id profile-id)))) + ::webhooks/event? true + ::sm/params schema:move-files} + [cfg {:keys [::rpc/profile-id] :as params}] + (db/tx-run! cfg #(move-files % (assoc params :profile-id profile-id)))) ;; --- COMMAND: Move project (defn move-project - [conn {:keys [profile-id team-id project-id] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}] (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]}) pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]}) (map :id) (db/create-array conn "uuid"))] - (teams/check-edition-permissions! conn profile-id (:team-id project)) - (teams/check-edition-permissions! conn profile-id team-id) - (when (= team-id (:team-id project)) (ex/raise :type :validation :code :cant-move-to-same-team :hint "Unable to move a project to same team")) + (teams/check-edition-permissions! conn profile-id (:team-id project)) + (teams/check-edition-permissions! conn profile-id team-id) + + ;; Check the teams compatibility + (let [orig-team (teams/get-team cfg :profile-id profile-id :team-id (:team-id project)) + dest-team (teams/get-team cfg :profile-id profile-id :team-id team-id)] + (cfeat/check-teams-compatibility! orig-team dest-team)) + ;; move project to the destination team (db/update! conn :project {:team-id team-id} @@ -377,17 +382,18 @@ nil)) -(s/def ::move-project - (s/keys :req [::rpc/profile-id] - :req-un [::team-id ::project-id])) +(def ^:private schema:move-project + [:map {:title "move-project"} + [:team-id ::sm/uuid] + [:project-id ::sm/uuid]]) (sv/defmethod ::move-project - "Move projects between teams." + "Move projects between teams" {::doc/added "1.16" - ::webhooks/event? true} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] - (db/with-atomic [conn pool] - (move-project conn (assoc params :profile-id profile-id)))) + ::webhooks/event? true + ::sm/params schema:move-project} + [cfg {:keys [::rpc/profile-id] :as params}] + (db/tx-run! cfg #(move-project % (assoc params :profile-id profile-id)))) ;; --- COMMAND: Clone Template @@ -409,6 +415,7 @@ (dissoc ::db/conn) (assoc ::binfile/input template) (assoc ::binfile/project-id (:id project)) + (assoc ::binfile/profile-id profile-id) (assoc ::binfile/ignore-index-errors? true) (assoc ::binfile/migrate? true) (binfile/import!)))) @@ -430,14 +437,6 @@ ;; --- COMMAND: Get list of builtin templates -(s/def ::retrieve-list-of-builtin-templates any?) - -(sv/defmethod ::retrieve-list-of-builtin-templates - {::doc/added "1.10" - ::doc/deprecated "1.19"} - [cfg _params] - (mapv #(select-keys % [:id :name]) (::setup/templates cfg))) - (sv/defmethod ::get-builtin-templates {::doc/added "1.19"} [cfg _params] diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 93885adf3..ef13c969f 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -60,7 +60,7 @@ (files/check-edition-permissions! pool profile-id file-id) (media/validate-media-type! content) (media/validate-media-size! content) - (let [object (create-file-media-object cfg params) + (let [object (db/run! cfg #(create-file-media-object % params)) props {:name (:name params) :file-id file-id :is-local (:is-local params) @@ -142,7 +142,7 @@ (assoc ::image (process-main-image info))))) (defn create-file-media-object - [{:keys [::sto/storage ::db/pool] :as cfg} + [{:keys [::sto/storage ::db/conn] :as cfg} {:keys [id file-id is-local name content]}] (let [result (-> (climit/configure cfg :process-image) @@ -152,7 +152,7 @@ thumb (when-let [params (::thumb result)] (sto/put-object! storage params))] - (db/exec-one! pool [sql:create-file-media-object + (db/exec-one! conn [sql:create-file-media-object (or id (uuid/next)) file-id is-local name (:id image) @@ -176,9 +176,9 @@ [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (let [cfg (update cfg ::sto/storage media/configure-assets-storage)] (files/check-edition-permissions! pool profile-id file-id) - (create-file-media-object-from-url cfg params))) + (db/run! cfg #(create-file-media-object-from-url % params)))) -(defn- download-image +(defn download-image [{:keys [::http/client]} uri] (letfn [(parse-and-validate [{:keys [headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) @@ -209,7 +209,6 @@ {:method :get :uri uri} {:response-type :input-stream :sync? true}) {:keys [size mtype]} (parse-and-validate response) - path (tmp/tempfile :prefix "penpot.media.download.") written (io/write-to-file! body path :size size)] @@ -223,7 +222,6 @@ :path path :mtype mtype}))) - (defn- create-file-media-object-from-url [cfg {:keys [url name] :as params}] (let [content (download-image cfg url) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index e846f0b34..e59cae681 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.logging :as l] [app.common.schema :as sm] [app.common.spec :as us] @@ -79,20 +80,25 @@ (def check-read-permissions! (perms/make-check-fn has-read-permissions?)) +(defn decode-row + [{:keys [features] :as row}] + (when row + (cond-> row + features (assoc :features (db/decode-pgarray features #{}))))) + ;; --- Query: Teams -(declare retrieve-teams) +(declare get-teams) -(def counter (volatile! 0)) - -(s/def ::get-teams - (s/keys :req [::rpc/profile-id])) +(def ^:private schema:get-teams + [:map {:title "get-teams"}]) (sv/defmethod ::get-teams - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] - (retrieve-teams conn profile-id))) + (get-teams conn profile-id))) (def sql:teams "select t.*, @@ -119,37 +125,65 @@ (dissoc :is-owner :is-admin :can-edit) (assoc :permissions permissions)))) -(defn retrieve-teams +(defn get-teams [conn profile-id] (let [profile (profile/get-profile conn profile-id)] (->> (db/exec! conn [sql:teams (:default-team-id profile) profile-id]) - (mapv process-permissions)))) + (map decode-row) + (map process-permissions) + (vec)))) ;; --- Query: Team (by ID) -(declare retrieve-team) +(declare get-team) -(s/def ::get-team - (s/keys :req [::rpc/profile-id] - :req-un [::id])) +(def ^:private schema:get-team + [:map {:title "get-team"} + [:id ::sm/uuid]]) (sv/defmethod ::get-team - {::doc/added "1.17"} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] - (dm/with-open [conn (db/open pool)] - (retrieve-team conn profile-id id))) + {::doc/added "1.17" + ::sm/params schema:get-team} + [cfg {:keys [::rpc/profile-id id]}] + (db/tx-run! cfg #(get-team % :profile-id profile-id :team-id id))) -(defn retrieve-team - [conn profile-id team-id] - (let [profile (profile/get-profile conn profile-id) - sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?") - result (db/exec-one! conn [sql (:default-team-id profile) profile-id team-id])] +(defn get-team + [conn & {:keys [profile-id team-id project-id file-id] :as params}] + (dm/assert! + "profile-id is mandatory" + (uuid? profile-id)) + + (let [{:keys [default-team-id] :as profile} (profile/get-profile conn profile-id) + result (cond + (some? team-id) + (let [sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?")] + (db/exec-one! conn [sql default-team-id profile-id team-id])) + + (some? project-id) + (let [sql (str "WITH teams AS (" sql:teams ") " + "SELECT t.* FROM teams AS t " + " JOIN project AS p ON (p.team_id = t.id) " + " WHERE p.id=?")] + (db/exec-one! conn [sql default-team-id profile-id project-id])) + + (some? file-id) + (let [sql (str "WITH teams AS (" sql:teams ") " + "SELECT t.* FROM teams AS t " + " JOIN project AS p ON (p.team_id = t.id) " + " JOIN file AS f ON (f.project_id = p.id) " + " WHERE f.id=?")] + (db/exec-one! conn [sql default-team-id profile-id file-id])) + + :else + (throw (IllegalArgumentException. "invalid arguments")))] (when-not result (ex/raise :type :not-found :code :team-does-not-exist)) - (process-permissions result))) + (-> result + (decode-row) + (process-permissions)))) ;; --- Query: Team Members @@ -165,44 +199,48 @@ join profile as p on (p.id = tp.profile_id) where tp.team_id = ?") -(defn retrieve-team-members +(defn get-team-members [conn team-id] (db/exec! conn [sql:team-members team-id])) -(s/def ::team-id ::us/uuid) -(s/def ::get-team-members - (s/keys :req [::rpc/profile-id] - :req-un [::team-id])) +(def ^:private schema:get-team-memebrs + [:map {:title "get-team-members"} + [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-members - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-team-memebrs} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id team-id) - (retrieve-team-members conn team-id))) - + (get-team-members conn team-id))) ;; --- Query: Team Users -(declare retrieve-users) -(declare retrieve-team-for-file) +(declare get-users) +(declare get-team-for-file) -(s/def ::get-team-users - (s/and (s/keys :req [::rpc/profile-id] - :opt-un [::team-id ::file-id]) - #(or (:team-id %) (:file-id %)))) +(def ^:private schema:get-team-users + [:and {:title "get-team-users"} + [:map + [:team-id {:optional true} ::sm/uuid] + [:file-id {:optional true} ::sm/uuid]] + [:fn #(or (contains? % :team-id) + (contains? % :file-id))]]) (sv/defmethod ::get-team-users - {::doc/added "1.17"} + "Get team users by team-id or by file-id" + {::doc/added "1.17" + ::sm/params schema:get-team-users} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id]}] (dm/with-open [conn (db/open pool)] (if team-id (do (check-read-permissions! conn profile-id team-id) - (retrieve-users conn team-id)) - (let [{team-id :id} (retrieve-team-for-file conn file-id)] + (get-users conn team-id)) + (let [{team-id :id} (get-team-for-file conn file-id)] (check-read-permissions! conn profile-id team-id) - (retrieve-users conn team-id))))) + (get-users conn team-id))))) ;; This is a similar query to team members but can contain more data ;; because some user can be explicitly added to project or file (not @@ -233,44 +271,44 @@ join file as f on (p.id = f.project_id) where f.id = ?") -(defn retrieve-users +(defn get-users [conn team-id] (db/exec! conn [sql:team-users team-id team-id team-id])) -(defn retrieve-team-for-file +(defn get-team-for-file [conn file-id] (->> [sql:team-by-file file-id] (db/exec-one! conn))) ;; --- Query: Team Stats -(declare retrieve-team-stats) +(declare get-team-stats) -(s/def ::get-team-stats - (s/keys :req [::rpc/profile-id] - :req-un [::team-id])) +(def ^:private schema:get-team-stats + [:map {:title "get-team-stats"} + [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-stats - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-team-stats} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id team-id) - (retrieve-team-stats conn team-id))) + (get-team-stats conn team-id))) (def sql:team-stats "select (select count(*) from project where team_id = ?) as projects, (select count(*) from file as f join project as p on (p.id = f.project_id) where p.team_id = ?) as files") -(defn retrieve-team-stats +(defn get-team-stats [conn team-id] (db/exec-one! conn [sql:team-stats team-id team-id])) - ;; --- Query: Team invitations -(s/def ::get-team-invitations - (s/keys :req [::rpc/profile-id] - :req-un [::team-id])) +(def ^:private schema:get-team-invitations + [:map {:title "get-team-invitations"} + [:team-id ::sm/uuid]]) (def sql:team-invitations "select email_to as email, role, (valid_until < now()) as expired @@ -282,7 +320,8 @@ (mapv #(update % :role keyword)))) (sv/defmethod ::get-team-invitations - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:get-team-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id team-id) @@ -297,40 +336,50 @@ (declare ^:private create-team-role) (declare ^:private create-team-default-project) -(s/def ::create-team - (s/keys :req [::rpc/profile-id] - :req-un [::name] - :opt-un [::id])) +(def ^:private schema:create-team + [:map {:title "create-team"} + [:name :string] + [:features {:optional true} ::cfeat/features] + [:id {:optional true} ::sm/uuid]]) (sv/defmethod ::create-team - {::doc/added "1.17"} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] - (db/with-atomic [conn pool] - (quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile - ::quotes/profile-id profile-id}) + {::doc/added "1.17" + ::sm/params schema:create-team} + [cfg {:keys [::rpc/profile-id] :as params}] + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile + ::quotes/profile-id profile-id}) - (create-team conn (assoc params :profile-id profile-id)))) + (let [features (-> (cfeat/get-enabled-features cf/flags) + (cfeat/check-client-features! (:features params)))] + (create-team cfg (assoc params + :profile-id profile-id + :features features)))))) (defn create-team "This is a complete team creation process, it creates the team object and all related objects (default role and default project)." - [conn params] - (let [team (create-team* conn params) + [cfg-or-conn params] + (let [conn (db/get-connection cfg-or-conn) + team (create-team* conn params) params (assoc params - :team-id (:id team) - :role :owner) + :team-id (:id team) + :role :owner) project (create-team-default-project conn params)] (create-team-role conn params) (assoc team :default-project-id (:id project)))) (defn- create-team* - [conn {:keys [id name is-default] :as params}] + [conn {:keys [id name is-default features] :as params}] (let [id (or id (uuid/next)) - is-default (if (boolean? is-default) is-default false)] - (db/insert! conn :team - {:id id - :name name - :is-default is-default}))) + is-default (if (boolean? is-default) is-default false) + features (db/create-array conn "text" features) + team (db/insert! conn :team + {:id id + :name name + :features features + :is-default is-default})] + (decode-row team))) (defn- create-team-role [conn {:keys [profile-id team-id role] :as params}] @@ -396,7 +445,7 @@ (defn leave-team [conn {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) - members (retrieve-team-members conn id)] + members (get-team-members conn id)] (cond ;; we can only proceed if there are more members in the team @@ -480,10 +529,15 @@ (s/def ::team-id ::us/uuid) (s/def ::member-id ::us/uuid) +(s/def ::role #{:owner :admin :editor}) + ;; Temporarily disabled viewer role ;; https://tree.taiga.io/project/penpot/issue/1083 -;; (s/def ::role #{:owner :admin :editor :viewer}) -(s/def ::role #{:owner :admin :editor}) +(def valid-roles + #{:owner :admin :editor #_:viewer}) + +(def schema:role + [::sm/one-of valid-roles]) (defn role->params [role] @@ -500,7 +554,7 @@ ;; convenience, if this becomes a bottleneck or problematic, ;; we will change it to more efficient fetch mechanisms. (let [perms (get-permissions conn profile-id team-id) - members (retrieve-team-members conn team-id) + members (get-team-members conn team-id) member (d/seek #(= member-id (:id %)) members) is-owner? (:is-owner perms) @@ -596,7 +650,7 @@ (defn update-team-photo [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}] - (let [team (retrieve-team pool profile-id team-id) + (let [team (get-team pool profile-id team-id) photo (profile/upload-photo cfg params)] (db/with-atomic [conn pool] @@ -784,14 +838,24 @@ (s/merge ::create-team (s/keys :req-un [::emails ::role]))) + +(def ^:private schema:create-team-with-invitations + [:map {:title "create-team-with-invitations"} + [:name :string] + [:features {:optional true} ::cfeat/features] + [:id {:optional true} ::sm/uuid] + [:emails ::sm/set-of-emails] + [:role schema:role]]) + (sv/defmethod ::create-team-with-invitations - {::doc/added "1.17"} + {::doc/added "1.17" + ::sm/params schema:create-team-with-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id emails role] :as params}] (db/with-atomic [conn pool] (let [params (assoc params :profile-id profile-id) - team (create-team conn params) - profile (db/get-by-id conn :profile profile-id) - cfg (assoc cfg ::db/conn conn)] + cfg (assoc cfg ::db/conn conn) + team (create-team cfg params) + profile (db/get-by-id conn :profile profile-id)] ;; Create invitations for all provided emails. (->> emails diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 6ee197734..0e776da2b 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -8,6 +8,7 @@ (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.schema :as sm] [app.db :as db] [app.rpc :as-alias rpc] @@ -83,7 +84,7 @@ [:map {:title "get-view-only-bundle"} [:file-id ::sm/uuid] [:share-id {:optional true} ::sm/uuid] - [:features {:optional true} files/schema:features]]) + [:features {:optional true} ::cfeat/features]]) (sv/defmethod ::get-view-only-bundle {::rpc/auth false diff --git a/backend/src/app/srepl/helpers.clj b/backend/src/app/srepl/helpers.clj index d0eb2abdb..69442490b 100644 --- a/backend/src/app/srepl/helpers.clj +++ b/backend/src/app/srepl/helpers.clj @@ -12,7 +12,7 @@ [app.auth :refer [derive-password]] [app.common.data :as d] [app.common.exceptions :as ex] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] [app.common.files.migrations :as pmg] [app.common.logging :as l] [app.common.pages :as cp] @@ -100,10 +100,10 @@ (binding [*conn* conn pmap/*tracked* (atom {}) pmap/*load-fn* (partial files/load-pointer conn id) - ffeat/*wrap-with-pointer-map-fn* - (if (contains? (:features file) "storage/pointer-map") pmap/wrap identity) - ffeat/*wrap-with-objects-map-fn* - (if (contains? (:features file) "storage/objectd-map") omap/wrap identity)] + cfeat/*wrap-with-pointer-map-fn* + (if (contains? (:features file) "fdata/pointer-map") pmap/wrap identity) + cfeat/*wrap-with-objects-map-fn* + (if (contains? (:features file) "fdata/objectd-map") omap/wrap identity)] (let [file (-> file (update :data blob/decode) (cond-> migrate? (update :data pmg/migrate-data)) @@ -118,7 +118,7 @@ :features features} {:id id}) - (when (contains? (:features file) "storage/pointer-map") + (when (contains? (:features file) "fdata/pointer-map") (files/persist-pointers! conn id)))) (dissoc file :data)))))) @@ -161,10 +161,10 @@ (binding [*conn* conn pmap/*tracked* (atom {}) pmap/*load-fn* (partial files/load-pointer conn (:id file)) - ffeat/*wrap-with-pointer-map-fn* - (if (contains? (:features file) "storage/pointer-map") pmap/wrap identity) - ffeat/*wrap-with-objects-map-fn* - (if (contains? (:features file) "storage/objects-map") omap/wrap identity)] + cfeat/*wrap-with-pointer-map-fn* + (if (contains? (:features file) "fdata/pointer-map") pmap/wrap identity) + cfeat/*wrap-with-objects-map-fn* + (if (contains? (:features file) "fdata/objects-map") omap/wrap identity)] (try (on-file file) (catch Throwable cause @@ -209,10 +209,10 @@ (binding [*conn* conn pmap/*tracked* (atom {}) pmap/*load-fn* (partial files/load-pointer conn (:id file)) - ffeat/*wrap-with-pointer-map-fn* - (if (contains? (:features file) "storage/pointer-map") pmap/wrap identity) - ffeat/*wrap-with-objects-map-fn* - (if (contains? (:features file) "storage/objectd-map") omap/wrap identity)] + cfeat/*wrap-with-pointer-map-fn* + (if (contains? (:features file) "fdata/pointer-map") pmap/wrap identity) + cfeat/*wrap-with-objects-map-fn* + (if (contains? (:features file) "fdata/objectd-map") omap/wrap identity)] (on-file file)) (catch Throwable cause ((or on-error on-error*) cause file)))) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 413c67d27..da7f21120 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -10,16 +10,18 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.features :as cfeat] [app.common.logging :as l] [app.common.pprint :as p] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.features.fdata :as features.fdata] [app.msgbus :as mbus] [app.rpc.commands.auth :as auth] - [app.rpc.commands.profile :as profile] [app.rpc.commands.files-snapshot :as fsnap] + [app.rpc.commands.profile :as profile] [app.srepl.fixes :as f] [app.srepl.helpers :as h] [app.storage :as sto] @@ -110,41 +112,57 @@ (defn enable-objects-map-feature-on-file! [system & {:keys [save? id]}] - (letfn [(update-file [{:keys [features] :as file}] - (if (contains? features "storage/objects-map") - file - (-> file - (update :data migrate) - (update :features conj "storage/objects-map")))) - - (migrate [data] - (-> data - (update :pages-index update-vals #(update % :objects omap/wrap)) - (update :components update-vals #(update % :objects omap/wrap))))] - - (h/update-file! system - :id id - :update-fn update-file - :save? save?))) + (h/update-file! system + :id id + :update-fn features.fdata/enable-objects-map + :save? save?)) (defn enable-pointer-map-feature-on-file! [system & {:keys [save? id]}] - (letfn [(update-file [{:keys [features] :as file}] - (if (contains? features "storage/pointer-map") - file - (-> file - (update :data migrate) - (update :features conj "storage/pointer-map")))) + (h/update-file! system + :id id + :update-fn features.fdata/enable-pointer-map + :save? save?)) - (migrate [data] - (-> data - (update :pages-index update-vals pmap/wrap) - (update :components pmap/wrap)))] +(defn enable-team-feature! + [system team-id feature] + (dm/verify! + "feature should be supported" + (contains? cfeat/supported-features feature)) - (h/update-file! system - :id id - :update-fn update-file - :save? save?))) + (let [team-id (if (string? team-id) + (parse-uuid team-id) + team-id)] + (db/tx-run! system + (fn [{:keys [::db/conn]}] + (let [team (-> (db/get conn :team {:id team-id}) + (update :features db/decode-pgarray #{})) + features (conj (:features team) feature)] + (when (not= features (:features team)) + (db/update! conn :team + {:features (db/create-array conn "text" features)} + {:id team-id}) + :enabled)))))) + +(defn disable-team-feature! + [system team-id feature] + (dm/verify! + "feature should be supported" + (contains? cfeat/supported-features feature)) + + (let [team-id (if (string? team-id) + (parse-uuid team-id) + team-id)] + (db/tx-run! system + (fn [{:keys [::db/conn]}] + (let [team (-> (db/get conn :team {:id team-id}) + (update :features db/decode-pgarray #{})) + features (disj (:features team) feature)] + (when (not= features (:features team)) + (db/update! conn :team + {:features (db/create-array conn "text" features)} + {:id team-id}) + :disabled)))))) (defn enable-storage-features-on-file! [system & {:as params}] diff --git a/backend/src/app/storage/tmp.clj b/backend/src/app/storage/tmp.clj index 057e82dad..3c1f46af8 100644 --- a/backend/src/app/storage/tmp.clj +++ b/backend/src/app/storage/tmp.clj @@ -29,7 +29,7 @@ (defmethod ig/prep-key ::cleaner [_ cfg] - (assoc cfg ::min-age (dt/duration "30m"))) + (assoc cfg ::min-age (dt/duration "60m"))) (defmethod ig/init-key ::cleaner [_ cfg] diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 71cb00b1d..358dc8da3 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -298,7 +298,7 @@ (clean-file-thumbnails! cfg id revn) (clean-deleted-components! conn id data) - (when (contains? features "storage/pointer-map") + (when (contains? features "fdata/pointer-map") (clean-data-fragments! conn id data)) ;; Mark file as trimmed diff --git a/backend/src/app/util/pointer_map.clj b/backend/src/app/util/pointer_map.clj index cfe79cd3a..c5d9b5309 100644 --- a/backend/src/app/util/pointer_map.clj +++ b/backend/src/app/util/pointer_map.clj @@ -73,7 +73,7 @@ IPointerMap (load! [_] - (l/trace :hint "pointer-map:load" :id id) + (l/trace :hint "pointer-map:load" :id (str id)) (when-not *load-fn* (throw (UnsupportedOperationException. "load is not supported when *load-fn* is not bind"))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 20c7941a9..d09db68f7 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -10,11 +10,12 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.features :as cfeat] [app.common.flags :as flags] [app.common.pages :as cp] [app.common.pprint :as pp] - [app.common.spec :as us] [app.common.schema :as sm] + [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -66,8 +67,9 @@ :enable-email-verification :enable-smtp :enable-quotes - :enable-fdata-storage-pointer-map - :enable-fdata-storage-objets-map + :enable-feature-fdata-pointer-map + :enable-feature-fdata-objets-map + :enable-feature-components-v2 :disable-file-validation]) (def test-init-sql @@ -206,65 +208,72 @@ ;; --- FACTORIES (defn create-profile* - ([i] (create-profile* *pool* i {})) - ([i params] (create-profile* *pool* i params)) - ([pool i params] + ([i] (create-profile* *system* i {})) + ([i params] (create-profile* *system* i params)) + ([system i params] (let [params (merge {:id (mk-uuid "profile" i) :fullname (str "Profile " i) :email (str "profile" i ".test@nodomain.com") :password "123123" :is-demo false} params)] - (dm/with-open [conn (db/open pool)] - (->> params - (cmd.auth/create-profile! conn) - (cmd.auth/create-profile-rels! conn)))))) + (db/run! system + (fn [{:keys [::db/conn]}] + (->> params + (cmd.auth/create-profile! conn) + (cmd.auth/create-profile-rels! conn))))))) (defn create-project* - ([i params] (create-project* *pool* i params)) - ([pool i {:keys [profile-id team-id] :as params}] + ([i params] (create-project* *system* i params)) + ([system i {:keys [profile-id team-id] :as params}] (us/assert uuid? profile-id) (us/assert uuid? team-id) - (dm/with-open [conn (db/open pool)] - (->> (merge {:id (mk-uuid "project" i) - :name (str "project" i)} - params) - (#'teams/create-project conn))))) + + (db/run! system + (fn [{:keys [::db/conn]}] + (->> (merge {:id (mk-uuid "project" i) + :name (str "project" i)} + params) + (#'teams/create-project conn)))))) (defn create-file* ([i params] - (create-file* *pool* i params)) - ([pool i {:keys [profile-id project-id] :as params}] - (us/assert uuid? profile-id) - (us/assert uuid? project-id) - (db/with-atomic [conn (db/open pool)] - (files.create/create-file conn - (merge {:id (mk-uuid "file" i) - :name (str "file" i) - :components-v2 true} - params))))) + (create-file* *system* i params)) + ([system i {:keys [profile-id project-id] :as params}] + (dm/assert! "expected uuid" (uuid? profile-id)) + (dm/assert! "expected uuid" (uuid? project-id)) + (db/run! system + (fn [system] + (let [features (cfeat/get-enabled-features cf/flags)] + (files.create/create-file system + (merge {:id (mk-uuid "file" i) + :name (str "file" i) + :features features} + params))))))) (defn mark-file-deleted* - ([params] (mark-file-deleted* *pool* params)) + ([params] (mark-file-deleted* *system* params)) ([conn {:keys [id] :as params}] (#'files/mark-file-deleted! conn {:id id}))) (defn create-team* - ([i params] (create-team* *pool* i params)) - ([pool i {:keys [profile-id] :as params}] + ([i params] (create-team* *system* i params)) + ([system i {:keys [profile-id] :as params}] (us/assert uuid? profile-id) - (dm/with-open [conn (db/open pool)] - (let [id (mk-uuid "team" i)] + (dm/with-open [conn (db/open system)] + (let [id (mk-uuid "team" i) + features (cfeat/get-enabled-features cf/flags)] (teams/create-team conn {:id id :profile-id profile-id + :features features :name (str "team" i)}))))) (defn create-file-media-object* - ([params] (create-file-media-object* *pool* params)) - ([pool {:keys [name width height mtype file-id is-local media-id] + ([params] (create-file-media-object* *system* params)) + ([system {:keys [name width height mtype file-id is-local media-id] :or {name "sample" width 100 height 100 mtype "image/svg+xml" is-local true}}] - (dm/with-open [conn (db/open pool)] + (dm/with-open [conn (db/open system)] (db/insert! conn :file-media-object {:id (uuid/next) :file-id file-id @@ -276,14 +285,14 @@ :mtype mtype})))) (defn link-file-to-library* - ([params] (link-file-to-library* *pool* params)) - ([pool {:keys [file-id library-id] :as params}] - (dm/with-open [conn (db/open pool)] + ([params] (link-file-to-library* *system* params)) + ([system {:keys [file-id library-id] :as params}] + (dm/with-open [conn (db/open system)] (#'files/link-file-to-library conn {:file-id file-id :library-id library-id})))) (defn create-complaint-for - [pool {:keys [id created-at type]}] - (dm/with-open [conn (db/open pool)] + [system {:keys [id created-at type]}] + (dm/with-open [conn (db/open system)] (db/insert! conn :profile-complaint-report {:profile-id id :created-at (or created-at (dt/now)) @@ -291,8 +300,8 @@ :content (db/tjson {})}))) (defn create-global-complaint-for - [pool {:keys [email type created-at]}] - (dm/with-open [conn (db/open pool)] + [system {:keys [email type created-at]}] + (dm/with-open [conn (db/open system)] (db/insert! conn :global-complaint-report {:email email :type (name type) @@ -300,71 +309,72 @@ :content (db/tjson {})}))) (defn create-team-role* - ([params] (create-team-role* *pool* params)) - ([pool {:keys [team-id profile-id role] :or {role :owner}}] - (dm/with-open [conn (db/open pool)] + ([params] (create-team-role* *system* params)) + ([system {:keys [team-id profile-id role] :or {role :owner}}] + (dm/with-open [conn (db/open system)] (#'teams/create-team-role conn {:team-id team-id :profile-id profile-id :role role})))) (defn create-project-role* - ([params] (create-project-role* *pool* params)) - ([pool {:keys [project-id profile-id role] :or {role :owner}}] - (dm/with-open [conn (db/open pool)] + ([params] (create-project-role* *system* params)) + ([system {:keys [project-id profile-id role] :or {role :owner}}] + (dm/with-open [conn (db/open system)] (#'teams/create-project-role conn {:project-id project-id :profile-id profile-id :role role})))) (defn create-file-role* - ([params] (create-file-role* *pool* params)) - ([pool {:keys [file-id profile-id role] :or {role :owner}}] - (dm/with-open [conn (db/open pool)] + ([params] (create-file-role* *system* params)) + ([system {:keys [file-id profile-id role] :or {role :owner}}] + (dm/with-open [conn (db/open system)] (files.create/create-file-role! conn {:file-id file-id :profile-id profile-id :role role})))) (defn update-file* - ([params] (update-file* *pool* params)) - ([pool {:keys [file-id changes session-id profile-id revn] + ([params] (update-file* *system* params)) + ([system {:keys [file-id changes session-id profile-id revn] :or {session-id (uuid/next) revn 0}}] - (dm/with-open [conn (db/open pool)] - (let [features #{"components/v2"} - cfg (-> (select-keys *system* [::mbus/msgbus ::mtx/metrics]) - (assoc ::db/conn conn))] - (files.update/update-file cfg - {:id file-id - :revn revn - :features features - :changes changes - :session-id session-id - :profile-id profile-id}))))) + (db/tx-run! system (fn [{:keys [::db/conn] :as system}] + (let [file (files.update/get-file conn file-id)] + (files.update/update-file system + {:id file-id + :revn revn + :file file + :features (:features file) + :changes changes + :session-id session-id + :profile-id profile-id})))))) (declare command!) (defn update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}] - (let [params {::type :update-file - ::rpc/profile-id profile-id - :id file-id - :session-id (uuid/random) - :revn revn - :components-v2 true - :changes changes} - out (command! params)] + (let [features (cfeat/get-enabled-features cf/flags) + params {::type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :features features + :changes changes} + out (command! params)] (t/is (nil? (:error out))) (:result out))) (defn create-webhook* - ([params] (create-webhook* *pool* params)) - ([pool {:keys [team-id id uri mtype is-active] - :or {is-active true - mtype "application/json" - uri "http://example.com/webhook"}}] - (db/insert! pool :webhook - {:id (or id (uuid/next)) - :team-id team-id - :uri uri - :is-active is-active - :mtype mtype}))) + ([params] (create-webhook* *system* params)) + ([system {:keys [team-id id uri mtype is-active] + :or {is-active true + mtype "application/json" + uri "http://example.com/webhook"}}] + (db/run! system (fn [{:keys [::db/conn]}] + (db/insert! conn :webhook + {:id (or id (uuid/next)) + :team-id team-id + :uri uri + :is-active is-active + :mtype mtype}))))) ;; --- RPC HELPERS diff --git a/backend/test/backend_tests/rpc_cond_middleware_test.clj b/backend/test/backend_tests/rpc_cond_middleware_test.clj index dfbee87d8..4246257a5 100644 --- a/backend/test/backend_tests/rpc_cond_middleware_test.clj +++ b/backend/test/backend_tests/rpc_cond_middleware_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.rpc-cond-middleware-test (:require + [app.common.features :as cfeat] [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] @@ -27,7 +28,9 @@ :project-id (:id project)}) params {::th/type :get-file :id (:id file1) - ::rpc/profile-id (:id profile)}] + ::rpc/profile-id (:id profile) + :features cfeat/supported-features + }] (binding [cond/*enabled* true] (let [{:keys [error result]} (th/command! params)] @@ -36,7 +39,7 @@ (t/is (contains? (meta result) :app.http/headers)) (t/is (contains? (meta result) :app.rpc.cond/key)) - (let [etag (-> result meta :app.http/headers (get "etag")) + (let [etag (-> result meta :app.http/headers (get "etag")) {:keys [error result]} (th/command! (assoc params ::cond/key etag))] (t/is (nil? error)) (t/is (fn? result)) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index c4c2860fb..02a394153 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -6,9 +6,14 @@ (ns backend-tests.rpc-file-test (:require + [app.common.features :as cfeat] + [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.types.shape :as cts] [app.common.uuid :as uuid] + [app.db :as db] + [app.db.sql :as sql] + [app.http :as http] [app.rpc :as-alias rpc] [app.storage :as sto] [app.util.time :as dt] @@ -127,7 +132,7 @@ :id file-id :session-id (uuid/random) :revn revn - :components-v2 true + :features cfeat/supported-features :changes changes} out (th/command! params)] ;; (th/print-result! out) @@ -248,7 +253,7 @@ :id file-id :session-id (uuid/random) :revn revn - :components-v2 true + :features cfeat/supported-features :changes changes} out (th/command! params)] ;; (th/print-result! out) @@ -596,10 +601,11 @@ (let [data {::th/type :get-page ::rpc/profile-id (:id prof) :file-id (:id file) - :components-v2 true} + :features cfeat/supported-features} {:keys [error result] :as out} (th/command! data)] ;; (th/print-result! out) + (t/is (nil? error)) (t/is (map? result)) (t/is (contains? result :objects)) (t/is (contains? (:objects result) frame1-id)) @@ -614,7 +620,7 @@ ::rpc/profile-id (:id prof) :file-id (:id file) :page-id page-id - :components-v2 true} + :features cfeat/supported-features} {:keys [error result] :as out} (th/command! data)] ;; (th/print-result! out) (t/is (map? result)) @@ -631,7 +637,7 @@ :file-id (:id file) :page-id page-id :object-id frame1-id - :components-v2 true} + :features cfeat/supported-features} {:keys [error result] :as out} (th/command! data)] ;; (th/print-result! out) (t/is (nil? error)) @@ -648,7 +654,7 @@ ::rpc/profile-id (:id prof) :file-id (:id file) :object-id frame1-id - :components-v2 true} + :features cfeat/supported-features} out (th/command! data)] ;; (th/print-result! out) @@ -675,9 +681,10 @@ (let [data {::th/type :get-file-data-for-thumbnail ::rpc/profile-id (:id prof) :file-id (:id file) - :components-v2 true} + :features cfeat/supported-features} {:keys [error result] :as out} (th/command! data)] ;; (th/print-result! out) + (t/is (nil? error)) (t/is (map? result)) (t/is (contains? result :page)) (t/is (contains? result :revn)) @@ -702,7 +709,7 @@ (let [data {::th/type :get-file-data-for-thumbnail ::rpc/profile-id (:id prof) :file-id (:id file) - :components-v2 true} + :features cfeat/supported-features} {:keys [error result] :as out} (th/command! data)] ;; (th/print-result! out) (t/is (map? result)) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 82eb350b4..5988e2f2b 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -622,9 +622,9 @@ (t/is (uuid? (first result))) (t/is (= 1 (count result)))))) -(t/deftest retrieve-list-of-buitin-templates +(t/deftest get-list-of-buitin-templates (let [prof (th/create-profile* 1 {:is-active true}) - data {::th/type :retrieve-list-of-builtin-templates + data {::th/type :get-builtin-templates ::rpc/profile-id (:id prof)} out (th/command! data)] ;; (th/print-result! out) diff --git a/common/deps.edn b/common/deps.edn index 168253588..09d1e91be 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -28,7 +28,9 @@ funcool/tubax {:mvn/version "2021.05.20-0"} funcool/cuerdas {:mvn/version "2022.06.16-403"} - funcool/promesa {:mvn/version "11.0.678"} + funcool/promesa {:git/sha "658c429c56c11c33da7594fa2ef53f4e6afedac4" + :git/url "https://github.com/funcool/promesa"} + funcool/datoteka {:mvn/version "3.0.66" :exclusions [funcool/promesa]} diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc new file mode 100644 index 000000000..b672f463a --- /dev/null +++ b/common/src/app/common/features.cljc @@ -0,0 +1,236 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.features + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.schema.desc-js-like :as-alias smdj] + [app.common.schema.generators :as smg] + [clojure.set :as set] + [cuerdas.core :as str])) + +;; The default behavior when a user interacts with penpot and runtime +;; and global features: +;; +;; - If user enables on runtime a frontend-only feature, this feature +;; and creates and/or modifies files, the feature is only availble +;; until next refresh (it is not persistent) +;; +;; - If user enables on runtime a non-migration feature, on modifying +;; a file or creating a new one, the feature becomes persistent on +;; the file and the team. All the other files of the team eventually +;; will have that feature assigned (on file modification) +;; +;; - If user enables on runtime a migration feature, that feature will +;; be ignored until a migration is explicitly executed or team +;; explicitly marked with that feature. +;; +;; The features stored on the file works as metadata information about +;; features enabled on the file and for compatibility check when a +;; user opens the file. The features stored on global, runtime or team +;; works as activators. + +(def ^:dynamic *previous* #{}) +(def ^:dynamic *current* #{}) +(def ^:dynamic *new* nil) + +(def ^:dynamic *wrap-with-objects-map-fn* identity) +(def ^:dynamic *wrap-with-pointer-map-fn* identity) + +;; A set of supported features +(def supported-features + #{"fdata/objects-map" + "fdata/pointer-map" + "fdata/shape-data-type" + "components/v2" + "styles/v2" + "layout/grid"}) + +;; A set of features enabled by default for each file, they are +;; implicit and are enabled by default and can't be disabled +(def default-enabled-features + #{"fdata/shape-data-type"}) + +;; A set of features which only affects on frontend and can be enabled +;; and disabled freely by the user any time. This features does not +;; persist on file features field but can be permanently enabled on +;; team feature field +(def frontend-only-features + #{"styles/v2"}) + +;; Features that are mainly backend only or there are a proper +;; fallback when frontend reports no support for it +(def backend-only-features + #{"fdata/objects-map" + "fdata/pointer-map"}) + +;; This is a set of features that does not require an explicit +;; migration like components/v2 or the migration is not mandatory to +;; be applied (per example backend can operate in both modes with or +;; without migration applied) +(def no-migration-features + (-> #{"fdata/objects-map" + "fdata/pointer-map" + "layout/grid"} + (into frontend-only-features))) + +(sm/def! ::features + [:schema + {:title "FileFeatures" + ::smdj/inline true + :gen/gen (smg/subseq supported-features)} + ::sm/set-of-strings]) + +(defn- flag->feature + "Translate a flag to a feature name" + [flag] + (case flag + :feature-components-v2 "components/v2" + :feature-new-css-system "styles/v2" + :feature-grid-layout "layout/grid" + :feature-fdata-objects-map "fdata/objects-map" + :feature-fdata-pointer-map "fdata/pointer-map" + nil)) + +(def xf-supported-features + (filter (partial contains? supported-features))) + +(def xf-remove-ephimeral + (remove #(str/starts-with? % "ephimeral/"))) + +(def xf-flag-to-feature + (keep flag->feature)) + +(defn get-enabled-features + "Get the globally enabled fratures set." + [flags] + (into default-enabled-features xf-flag-to-feature flags)) + +(defn get-team-enabled-features + "Get the team enabled features. + + Team features are defined as: all features found on team plus all + no-migration features enabled globally." + [flags team] + (let [enabled-features (into #{} xf-flag-to-feature flags) + team-features (into #{} xf-remove-ephimeral (:features team))] + (-> enabled-features + (set/intersection no-migration-features) + (set/union default-enabled-features) + (set/union team-features)))) + +(defn check-client-features! + "Function used for check feature compability between currently enabled + features set on backend with the enabledq featured set by the + frontend client" + [enabled-features client-features] + (when (set? client-features) + (let [not-supported (-> enabled-features + (set/difference client-features) + (set/difference backend-only-features))] + (when (seq not-supported) + (ex/raise :type :restriction + :code :feature-not-supported + :feature (first not-supported) + :hint (str/ffmt "client declares no support for '%' features" + (str/join "," not-supported))))) + + (let [not-supported (set/difference client-features supported-features)] + (when (seq not-supported) + (ex/raise :type :restriction + :code :feature-not-supported + :feature (first not-supported) + :hint (str/ffmt "backend does not support '%' features requested by client" + (str/join "," not-supported)))))) + + enabled-features) + +(defn check-supported-features! + "Check if a given set of features are supported by this + backend. Usually used for check if imported file features are + supported by the current backend" + [enabled-features] + (let [not-supported (set/difference enabled-features supported-features)] + (when (seq not-supported) + (ex/raise :type :restriction + :code :features-mismatch + :feature (first not-supported) + :hint (str/ffmt "features '%' not supported" + (str/join "," not-supported))))) + enabled-features) + +(defn check-file-features! + "Function used for check feature compability between currently + enabled features set on backend with the provided featured set by + the penpot file" + ([enabled-features file-features] + (check-file-features! enabled-features file-features #{})) + ([enabled-features file-features client-features] + (let [file-features (into #{} xf-remove-ephimeral file-features)] + (let [not-supported (-> enabled-features + (set/union client-features) + (set/difference file-features) + ;; NOTE: we don't want to raise a feature-mismatch + ;; exception for features which don't require an + ;; explicit file migration process or has no real + ;; effect on file data structure + (set/difference no-migration-features))] + (when (seq not-supported) + (ex/raise :type :restriction + :code :file-feature-mismatch + :feature (first not-supported) + :hint (str/ffmt "enabled features '%' not present in file (missing migration)" + (str/join "," not-supported))))) + + (check-supported-features! file-features) + + (let [not-supported (-> file-features + (set/difference enabled-features) + (set/difference client-features) + (set/difference frontend-only-features))] + (when (seq not-supported) + (ex/raise :type :restriction + :code :feature-mismatch + :feature (first not-supported) + :hint (str/ffmt "file features '%' not enabled" + (str/join "," not-supported)))))) + + enabled-features)) + +(defn check-teams-compatibility! + [{source-features :features} {destination-features :features}] + (when (contains? source-features "ephimeral/migration") + (ex/raise :type :restriction + :code :migration-in-progress + :hint "the source team is in migration process")) + + (when (contains? destination-features "ephimeral/migration") + (ex/raise :type :restriction + :code :migration-in-progress + :hint "the destination team is in migration process")) + + (let [not-supported (-> (or source-features #{}) + (set/difference destination-features) + (set/difference no-migration-features) + (seq))] + (when not-supported + (ex/raise :type :restriction + :code :team-feature-mismatch + :feature (first not-supported) + :hint (str/ffmt "the destination team does not have support '%' features" + (str/join "," not-supported))))) + + (let [not-supported (-> (or destination-features #{}) + (set/difference source-features) + (set/difference no-migration-features) + (seq))] + (when not-supported + (ex/raise :type :restriction + :code :team-feature-mismatch + :feature (first not-supported) + :hint (str/ffmt "the source team does not have support '%' features" + (str/join "," not-supported)))))) diff --git a/common/src/app/common/files/defaults.cljc b/common/src/app/common/files/defaults.cljc index 177dac915..c1d1ce741 100644 --- a/common/src/app/common/files/defaults.cljc +++ b/common/src/app/common/files/defaults.cljc @@ -6,4 +6,4 @@ (ns app.common.files.defaults) -(def version 34) +(def version 35) diff --git a/common/src/app/common/files/features.cljc b/common/src/app/common/files/features.cljc deleted file mode 100644 index 34d40800e..000000000 --- a/common/src/app/common/files/features.cljc +++ /dev/null @@ -1,17 +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.common.files.features) - -;; A set of enabled by default file features. Will be used in feature -;; negotiation on obtaining files from backend. - -(def enabled #{}) - -(def ^:dynamic *previous* #{}) -(def ^:dynamic *current* #{}) -(def ^:dynamic *wrap-with-objects-map-fn* identity) -(def ^:dynamic *wrap-with-pointer-map-fn* identity) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 61aa62df1..63111dc14 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.features :as cfeat] [app.common.files.defaults :refer [version]] [app.common.geom.matrix :as gmt] [app.common.geom.rect :as grc] @@ -441,6 +442,7 @@ (defmethod migrate 25 [data] + (some-> cfeat/*new* (swap! conj "fdata/shape-data-type")) (letfn [(update-object [object] (-> object (d/update-when :selrect grc/make-rect) @@ -594,6 +596,7 @@ (defmethod migrate 32 [data] + (some-> cfeat/*new* (swap! conj "fdata/shape-data-type")) (letfn [(update-object [object] (as-> object object (if (contains? object :svg-attrs) @@ -638,3 +641,13 @@ (update :pages-index update-vals update-container) (update :components update-vals update-container)))) + +;; NOTE: We need to repeat this migration for correct feature handling + +(defmethod migrate 35 + [data] + (-> data + (assoc :version 25) + (migrate) + (assoc :version 35))) + diff --git a/common/src/app/common/pages/changes_builder.cljc b/common/src/app/common/pages/changes_builder.cljc index 9a9754a5d..92182f9d5 100644 --- a/common/src/app/common/pages/changes_builder.cljc +++ b/common/src/app/common/pages/changes_builder.cljc @@ -8,7 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] @@ -75,7 +75,7 @@ (defn with-objects [changes objects] - (let [fdata (binding [ffeat/*current* #{"components/v2"}] + (let [fdata (binding [cfeat/*current* #{"components/v2"}] (ctf/make-file-data (uuid/next) uuid/zero)) fdata (assoc-in fdata [:pages-index uuid/zero :objects] objects)] (vary-meta changes assoc diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc index 35e8bb79c..481869c4b 100644 --- a/common/src/app/common/svg.cljc +++ b/common/src/app/common/svg.cljc @@ -1045,7 +1045,9 @@ (let [redfn (fn [acc {:keys [tag attrs]}] (cond-> acc (= :image tag) - (conj (or (:href attrs) (:xlink:href attrs)))))] + (conj {:href (or (:href attrs) (:xlink:href attrs)) + :width (d/parse-integer (:width attrs) 0) + :height (d/parse-integer (:height attrs) 0)})))] (reduce-nodes redfn [] svg-data ))) #?(:cljs diff --git a/common/src/app/common/types/components_list.cljc b/common/src/app/common/types/components_list.cljc index 39dbdc43e..d3eab792a 100644 --- a/common/src/app/common/types/components_list.cljc +++ b/common/src/app/common/types/components_list.cljc @@ -8,7 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] [app.common.time :as dt] [app.common.types.component :as ctk])) @@ -40,7 +40,7 @@ (cond-> (update-in fdata [:components id] assoc :main-instance-id main-instance-id :main-instance-page main-instance-page) annotation (update-in [:components id] assoc :annotation annotation)) - (let [wrap-object-fn ffeat/*wrap-with-objects-map-fn*] + (let [wrap-object-fn cfeat/*wrap-with-objects-map-fn*] (assoc-in fdata [:components id :objects] (->> shapes (d/index-by :id) @@ -48,7 +48,7 @@ (defn mod-component [file-data {:keys [id name path main-instance-id main-instance-page objects annotation]}] - (let [wrap-objects-fn ffeat/*wrap-with-objects-map-fn*] + (let [wrap-objects-fn cfeat/*wrap-with-objects-map-fn*] (d/update-in-when file-data [:components id] (fn [component] (let [objects (some-> objects wrap-objects-fn)] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 90261f66a..f90d991a6 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -8,8 +8,8 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.features :as cfeat] [app.common.files.defaults :refer [version]] - [app.common.files.features :as ffeat] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logging :as l] @@ -57,7 +57,6 @@ [:map-of {:gen/max 2} ::sm/uuid ::cty/typography]] [:media {:optional true} [:map-of {:gen/max 5} ::sm/uuid ::media-object]]]) - (def file-data? (sm/pred-fn ::data)) @@ -82,11 +81,11 @@ (let [page (when (some? page-id) (ctp/make-empty-page page-id "Page 1"))] - (cond-> (assoc empty-file-data :id file-id) + (cond-> (assoc empty-file-data :id file-id :version version) (some? page-id) (ctpl/add-page page) - (contains? ffeat/*current* "components/v2") + (contains? cfeat/*current* "components/v2") (assoc-in [:options :components-v2] true))))) ;; Helpers @@ -610,7 +609,7 @@ (-> file-data (update :pages-index update-vals fix-container) (update :components update-vals fix-container)))) - + fix-copies-of-detached (fn [file-data] ; Find any copy that is referencing a detached shape inside a component, and @@ -1012,7 +1011,7 @@ (-> libs-to-show (add-component library-id component-id)))))) ;; (find-used-components-cumulative page root) - + libs-to-show components)) libs-to-show diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 628c531c1..177198c7b 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -7,7 +7,7 @@ (ns app.common.types.page (:require [app.common.data :as d] - [app.common.files.features :as ffeat] + [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.types.color :as-alias ctc] [app.common.types.grid :as ctg] @@ -74,8 +74,8 @@ (defn make-empty-page [id name] - (let [wrap-objects-fn ffeat/*wrap-with-objects-map-fn* - wrap-pointer-fn ffeat/*wrap-with-pointer-map-fn*] + (let [wrap-objects-fn cfeat/*wrap-with-objects-map-fn* + wrap-pointer-fn cfeat/*wrap-with-pointer-map-fn*] (-> empty-page-data (assoc :id id) (assoc :name name) diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index a8a6cc1d4..686ed3353 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -428,24 +428,22 @@ (defn generate-shape-grid "Generate a sequence of positions that lays out the list of shapes in a grid of equal-sized rows and columns." - [shapes start-pos gap] - (let [shapes-bounds (map gsh/bounding-box shapes) + [shapes start-position gap] + (when (seq shapes) + (let [bounds (map gsh/bounding-box shapes) - grid-size (mth/ceil (mth/sqrt (count shapes))) - row-size (+ (apply max (map :height shapes-bounds)) - gap) - column-size (+ (apply max (map :width shapes-bounds)) - gap) + grid-size (-> shapes count mth/sqrt mth/ceil) + row-size (+ (reduce d/max ##-Inf (map :height bounds)) gap) + column-size (+ (reduce d/max ##-Inf (map :width bounds)) gap) - next-pos (fn [position] - (let [counter (inc (:counter (meta position))) - row (quot counter grid-size) - column (mod counter grid-size) - new-pos (gpt/add start-pos - (gpt/point (* column column-size) - (* row row-size)))] - (with-meta new-pos - {:counter counter})))] - (iterate next-pos - (with-meta start-pos - {:counter 0})))) + get-next (fn get-next + [counter] + (let [row (quot counter grid-size) + column (mod counter grid-size) + position (->> (gpt/point (* column column-size) + (* row row-size)) + (gpt/add start-position))] + (lazy-seq + (cons position (get-next (inc counter))))))] + + (get-next 0)))) diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 4a61292b2..fe822526a 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -7,7 +7,6 @@ (ns app.main.data.common "A general purpose events." (:require - [app.common.files.features :as ffeat] [app.common.types.components-list :as ctkl] [app.config :as cf] [app.main.data.messages :as msg] @@ -90,9 +89,7 @@ (ptk/reify ::show-shared-dialog ptk/WatchEvent (watch [_ state _] - (let [features (cond-> ffeat/enabled - (features/active-feature? state :components-v2) - (conj "components/v2")) + (let [features (features/get-team-enabled-features state) data (:workspace-data state) file (:workspace-file state)] (->> (if (and data file) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 2f2c1cb48..52e9ea67a 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.features :as cfeat] [app.common.files.helpers :as cfh] [app.common.schema :as sm] [app.common.uri :as u] @@ -28,6 +29,7 @@ [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.core :as rx] + [clojure.set :as set] [potok.core :as ptk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -43,11 +45,10 @@ (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (du/set-current-team! id) (let [prev-team-id (:current-team-id state)] (cond-> state (not= prev-team-id id) - (-> (assoc :current-team-id id) + (-> (dissoc :current-team-id) (dissoc :dashboard-files) (dissoc :dashboard-projects) (dissoc :dashboard-shared-files) @@ -58,27 +59,36 @@ ptk/WatchEvent (watch [_ state stream] - (rx/concat - (rx/of (features/initialize)) - (rx/merge - ;; fetch teams must be first in case the team doesn't exist - (ptk/watch (du/fetch-teams) state stream) - (ptk/watch (df/load-team-fonts id) state stream) - (ptk/watch (fetch-projects) state stream) - (ptk/watch (fetch-team-members) state stream) - (ptk/watch (du/fetch-users {:team-id id}) state stream) + (let [stoper-s (rx/filter (ptk/type? ::finalize) stream) + profile-id (:profile-id state)] - (let [stoper (rx/filter (ptk/type? ::finalize) stream) - profile-id (:profile-id state)] - (->> stream - (rx/filter (ptk/type? ::dws/message)) - (rx/map deref) - (rx/filter (fn [{:keys [subs-id type] :as msg}] - (and (or (= subs-id uuid/zero) - (= subs-id profile-id)) - (= :notification type)))) - (rx/map handle-notification) - (rx/take-until stoper)))))))) + (->> (rx/merge + ;; fetch teams must be first in case the team doesn't exist + (ptk/watch (du/fetch-teams) state stream) + (ptk/watch (df/load-team-fonts id) state stream) + (ptk/watch (fetch-projects id) state stream) + (ptk/watch (fetch-team-members id) state stream) + (ptk/watch (du/fetch-users {:team-id id}) state stream) + + (->> stream + (rx/filter (ptk/type? ::dws/message)) + (rx/map deref) + (rx/filter (fn [{:keys [subs-id type] :as msg}] + (and (or (= subs-id uuid/zero) + (= subs-id profile-id)) + (= :notification type)))) + (rx/map handle-notification)) + + ;; Once the teams are fecthed, initialize features related + ;; to currently active team + (->> stream + (rx/filter (ptk/type? ::du/teams-fetched)) + (rx/observe-on :async) + (rx/mapcat deref) + (rx/filter #(= id (:id %))) + (rx/map du/set-current-team))) + + (rx/take-until stoper-s)))))) (defn finalize [params] @@ -98,13 +108,12 @@ (assoc state :dashboard-team-members (d/index-by :id members))))) (defn fetch-team-members - [] + [team-id] (ptk/reify ::fetch-team-members ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (->> (rp/cmd! :get-team-members {:team-id team-id}) - (rx/map team-members-fetched)))))) + (watch [_ _ _] + (->> (rp/cmd! :get-team-members {:team-id team-id}) + (rx/map team-members-fetched))))) ;; --- EVENT: fetch-team-stats @@ -116,13 +125,12 @@ (assoc state :dashboard-team-stats stats)))) (defn fetch-team-stats - [] + [team-id] (ptk/reify ::fetch-team-stats ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (->> (rp/cmd! :get-team-stats {:team-id team-id}) - (rx/map team-stats-fetched)))))) + (watch [_ _ _] + (->> (rp/cmd! :get-team-stats {:team-id team-id}) + (rx/map team-stats-fetched))))) ;; --- EVENT: fetch-team-invitations @@ -171,13 +179,12 @@ (assoc state :dashboard-projects projects))))) (defn fetch-projects - [] + [team-id] (ptk/reify ::fetch-projects ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (->> (rp/cmd! :get-projects {:team-id team-id}) - (rx/map projects-fetched)))))) + (watch [_ _ _] + (->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map projects-fetched))))) ;; --- EVENT: search @@ -344,11 +351,12 @@ (dm/assert! (string? name)) (ptk/reify ::create-team ptk/WatchEvent - (watch [_ _ _] + (watch [_ state _] (let [{:keys [on-success on-error] :or {on-success identity - on-error rx/throw}} (meta params)] - (->> (rp/cmd! :create-team {:name name}) + on-error rx/throw}} (meta params) + features (features/get-enabled-features state)] + (->> (rp/cmd! :create-team {:name name :features features}) (rx/tap on-success) (rx/map team-created) (rx/catch on-error)))))) @@ -359,13 +367,15 @@ [{:keys [name emails role] :as params}] (ptk/reify ::create-team-with-invitations ptk/WatchEvent - (watch [_ _ _] + (watch [_ state _] (let [{:keys [on-success on-error] :or {on-success identity on-error rx/throw}} (meta params) - params {:name name - :emails #{emails} - :role role}] + features (features/get-enabled-features state)] + params {:name name + :emails #{emails} + :role role + :features features} (->> (rp/cmd! :create-team-with-invitations params) (rx/tap on-success) (rx/map team-created) @@ -419,7 +429,7 @@ params (assoc params :team-id team-id)] (->> (rp/cmd! :update-team-member-role params) (rx/mapcat (fn [_] - (rx/of (fetch-team-members) + (rx/of (fetch-team-members team-id) (du/fetch-teams))))))))) (defn delete-team-member @@ -432,7 +442,7 @@ params (assoc params :team-id team-id)] (->> (rp/cmd! :delete-team-member params) (rx/mapcat (fn [_] - (rx/of (fetch-team-members) + (rx/of (fetch-team-members team-id) (du/fetch-teams))))))))) (defn leave-team @@ -846,9 +856,8 @@ files (get state :dashboard-files) unames (cfh/get-used-names files) name (cfh/generate-unique-name unames (str (tr "dashboard.new-file-prefix") " 1")) - features (cond-> #{} - (features/active-feature? state :components-v2) - (conj "components/v2")) + features (-> (features/get-team-enabled-features state) + (set/difference cfeat/frontend-only-features)) params (-> params (assoc :name name) (assoc :features features))] diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index ddf8d7400..4a5cfc613 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -16,6 +16,7 @@ [app.main.data.events :as ev] [app.main.data.media :as di] [app.main.data.websocket :as ws] + [app.main.features :as features] [app.main.repo :as rp] [app.util.i18n :as i18n] [app.util.router :as rt] @@ -56,21 +57,20 @@ (defn teams-fetched [teams] - (let [teams (d/index-by :id teams) - ids (into #{} (keys teams))] + (ptk/reify ::teams-fetched + IDeref + (-deref [_] teams) - (ptk/reify ::teams-fetched - IDeref - (-deref [_] teams) + ptk/UpdateEvent + (update [_ state] + (assoc state :teams (d/index-by :id teams))) - ptk/UpdateEvent - (update [_ state] - (assoc state :teams teams)) + ptk/EffectEvent + (effect [_ _ _] + ;; Check if current team-id is part of available teams + ;; if not, dissoc it from storage. - ptk/EffectEvent - (effect [_ _ _] - ;; Check if current team-id is part of available teams - ;; if not, dissoc it from storage. + (let [ids (into #{} (map :id) teams)] (when-let [ctid (::current-team-id @storage)] (when-not (contains? ids ctid) (swap! storage dissoc ::current-team-id))))))) @@ -83,6 +83,23 @@ (->> (rp/cmd! :get-teams) (rx/map teams-fetched))))) +(defn set-current-team + [team] + (ptk/reify ::set-current-team + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :team team) + (assoc :current-team-id (:id team)))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (features/initialize (:features team #{})))) + + ptk/EffectEvent + (effect [_ _ _] + (set-current-team! (:id team))))) + ;; --- EVENT: fetch-profile (declare logout) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 68e958aa1..b786d1439 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] [app.common.geom.point :as gpt] [app.common.pages.helpers :as cph] [app.common.schema :as sm] @@ -108,12 +107,8 @@ (ptk/reify ::fetch-bundle ptk/WatchEvent (watch [_ state _] - (let [features (cond-> ffeat/enabled - (features/active-feature? state :components-v2) - (conj "components/v2") + (let [features (features/get-team-enabled-features state) - :always - (conj "storage/pointer-map")) params' (cond-> {:file-id file-id :features features} (uuid? share-id) (assoc :share-id share-id)) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 6e4f6e7c3..da98bad08 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -9,17 +9,13 @@ [app.common.attrs :as attrs] [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] [app.common.files.helpers :as cfh] - [app.common.files.libraries-helpers :as cflh] - [app.common.files.shapes-helpers :as cfsh] [app.common.geom.align :as gal] [app.common.geom.point :as gpt] [app.common.geom.proportions :as gpp] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.grid-layout :as gslg] - [app.common.logging :as log] [app.common.pages.changes-builder :as pcb] [app.common.pages.helpers :as cph] [app.common.text :as txt] @@ -27,8 +23,6 @@ [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] - [app.common.types.file :as ctf] - [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.types.shape.layout :as ctl] @@ -39,7 +33,6 @@ [app.main.data.events :as ev] [app.main.data.fonts :as df] [app.main.data.messages :as msg] - [app.main.data.modal :as modal] [app.main.data.users :as du] [app.main.data.workspace.bool :as dwb] [app.main.data.workspace.changes :as dch] @@ -94,18 +87,12 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (declare ^:private workspace-initialized) -(declare ^:private remove-graphics) (declare ^:private libraries-fetched) ;; --- Initialize Workspace (defn initialize-layout [lname] - ;; (dm/assert! - ;; "expected valid layout" - ;; (and (keyword? lname) - ;; (contains? layout/presets lname))) - (ptk/reify ::initialize-layout ptk/UpdateEvent (update [_ state] @@ -129,18 +116,10 @@ (assoc :workspace-ready? true))) ptk/WatchEvent - (watch [_ state _] - (let [file (:workspace-data state) - has-graphics? (-> file :media seq) - components-v2 (features/active-feature? state :components-v2)] - (rx/merge - (rx/of (fbc/fix-bool-contents) - (fdf/fix-deleted-fonts) - (fbs/fix-broken-shapes)) - - (if (and has-graphics? components-v2) - (rx/of (remove-graphics (:id file) (:name file))) - (rx/empty))))))) + (watch [_ _ _] + (rx/of (fbc/fix-bool-contents) + (fdf/fix-deleted-fonts) + (fbs/fix-broken-shapes))))) (defn- workspace-data-loaded [data] @@ -171,39 +150,43 @@ (assoc data :pages-index pages-index)))))) (defn- bundle-fetched - [features [{:keys [id data] :as file} thumbnails project users comments-users]] + [{:keys [features file thumbnails project team team-users comments-users]}] (ptk/reify ::bundle-fetched ptk/UpdateEvent (update [_ state] (-> state + (assoc :users (d/index-by :id team-users)) (assoc :workspace-thumbnails thumbnails) (assoc :workspace-file (dissoc file :data)) (assoc :workspace-project project) - (assoc :current-team-id (:team-id project)) - (assoc :users (d/index-by :id users)) (assoc :current-file-comments-users (d/index-by :id comments-users)))) ptk/WatchEvent (watch [_ _ stream] - (let [team-id (:team-id project) - stoper (rx/filter (ptk/type? ::bundle-fetched) stream)] + (let [team-id (:id team) + file-id (:id file) + file-data (:data file) + stoper-s (rx/filter (ptk/type? ::bundle-fetched) stream)] + (->> (rx/concat ;; Initialize notifications - (rx/of (dwn/initialize team-id id) + (rx/of (dwn/initialize team-id file-id) (dwsl/initialize)) ;; Load team fonts. We must ensure custom fonts are ;; fully loadad before mark workspace as initialized (rx/merge (->> stream - (rx/filter (ptk/type? :app.main.data.fonts/team-fonts-loaded)) + (rx/filter (ptk/type? ::df/team-fonts-loaded)) (rx/take 1) (rx/ignore)) (rx/of (df/load-team-fonts team-id)) + ;; FIXME: move to bundle fetch stages + ;; Load main file - (->> (resolve-file-data id data) + (->> (resolve-file-data file-id file-data) (rx/mapcat (fn [{:keys [pages-index] :as data}] (->> (rx/from (seq pages-index)) (rx/mapcat @@ -217,7 +200,7 @@ (rx/map workspace-data-loaded)) ;; Load libraries - (->> (rp/cmd! :get-file-libraries {:file-id id}) + (->> (rp/cmd! :get-file-libraries {:file-id file-id}) (rx/mapcat identity) (rx/merge-map (fn [{:keys [id synced-at]}] @@ -233,8 +216,10 @@ (rx/map #(assoc file :thumbnails %))))) (rx/reduce conj []) (rx/map libraries-fetched))) - (rx/of (with-meta (workspace-initialized) {:file-id id}))) - (rx/take-until stoper)))))) + + (rx/of (with-meta (workspace-initialized) + {:file-id file-id}))) + (rx/take-until stoper-s)))))) (defn- libraries-fetched [libraries] @@ -255,7 +240,7 @@ (rx/concat (rx/timer 1000) (rx/of (dwl/notify-sync-file file-id)))))))) -(defn- fetch-thumbnail-blob-uri +(defn- datauri->blob-uri [uri] (->> (http/send! {:uri uri :response-type :blob @@ -263,47 +248,86 @@ (rx/map :body) (rx/map (fn [blob] (wapi/create-uri blob))))) -(defn- fetch-thumbnail-blobs +(defn- fetch-file-object-thumbnails [file-id] (->> (rp/cmd! :get-file-object-thumbnails {:file-id file-id}) (rx/mapcat (fn [thumbnails] - (->> (rx/from thumbnails) - (rx/mapcat (fn [[k v]] - ;; we only need to fetch the thumbnail if - ;; it is a data:uri, otherwise we can just - ;; use the value as is. - (if (.startsWith v "data:") - (->> (fetch-thumbnail-blob-uri v) - (rx/map (fn [uri] [k uri]))) - (rx/of [k v]))))))) + (->> (rx/from thumbnails) + (rx/mapcat (fn [[k v]] + ;; we only need to fetch the thumbnail if + ;; it is a data:uri, otherwise we can just + ;; use the value as is. + (if (str/starts-with? v "data:") + (->> (datauri->blob-uri v) + (rx/map (fn [uri] [k uri]))) + (rx/of [k v]))))))) (rx/reduce conj {}))) -(defn- fetch-bundle +(defn- fetch-bundle-stage-1 [project-id file-id] - (ptk/reify ::fetch-bundle + (ptk/reify ::fetch-bundle-stage-1 + ptk/WatchEvent + (watch [_ _ stream] + (->> (rp/cmd! :get-project {:id project-id}) + (rx/mapcat (fn [project] + (->> (rp/cmd! :get-team {:id (:team-id project)}) + (rx/mapcat (fn [team] + (let [bundle {:team team + :project project + :file-id file-id + :project-id project-id}] + (rx/of (du/set-current-team team) + (ptk/data-event ::bundle-stage-1 bundle)))))))) + (rx/take-until + (rx/filter (ptk/type? ::fetch-bundle) stream)))))) + +(defn- fetch-bundle-stage-2 + [{:keys [file-id project-id] :as bundle}] + (ptk/reify ::fetch-bundle-stage-2 ptk/WatchEvent (watch [_ state stream] - (let [features (cond-> ffeat/enabled - (features/active-feature? state :components-v2) - (conj "components/v2") - - ;; We still put the feature here and not in the - ;; ffeat/enabled var because the pointers map is only - ;; supported on workspace bundle fetching mechanism. - :always - (conj "storage/pointer-map")) + (let [features (features/get-team-enabled-features state) ;; WTF is this? - share-id (-> state :viewer-local :share-id) - stoper (rx/filter (ptk/type? ::fetch-bundle) stream)] + share-id (-> state :viewer-local :share-id)] (->> (rx/zip (rp/cmd! :get-file {:id file-id :features features :project-id project-id}) - (fetch-thumbnail-blobs file-id) - (rp/cmd! :get-project {:id project-id}) + (fetch-file-object-thumbnails file-id) (rp/cmd! :get-team-users {:file-id file-id}) (rp/cmd! :get-profiles-for-file-comments {:file-id file-id :share-id share-id})) (rx/take 1) - (rx/map (partial bundle-fetched features)) - (rx/take-until stoper)))))) + (rx/map (fn [[file thumbnails team-users comments-users]] + (let [bundle (-> bundle + (assoc :file file) + (assoc :thumbnails thumbnails) + (assoc :team-users team-users) + (assoc :comments-users comments-users))] + (ptk/data-event ::bundle-stage-2 bundle)))) + (rx/take-until + (rx/filter (ptk/type? ::fetch-bundle) stream))))))) + +(defn- fetch-bundle + "Multi-stage file bundle fetch coordinator" + [project-id file-id] + (ptk/reify ::fetch-bundle + ptk/WatchEvent + (watch [_ _ stream] + (->> (rx/merge + (rx/of (fetch-bundle-stage-1 project-id file-id)) + + (->> stream + (rx/filter (ptk/type? ::bundle-stage-1)) + (rx/observe-on :async) + (rx/map deref) + (rx/map fetch-bundle-stage-2)) + + (->> stream + (rx/filter (ptk/type? ::bundle-stage-2)) + (rx/observe-on :async) + (rx/map deref) + (rx/map bundle-fetched))) + + (rx/take-until + (rx/filter (ptk/type? ::fetch-bundle) stream)))))) (defn initialize-file [project-id file-id] @@ -322,7 +346,6 @@ ptk/WatchEvent (watch [_ _ _] (rx/of msg/hide - (features/initialize) (dcm/retrieve-comment-threads file-id) (dwp/initialize-file-persistence file-id) (fetch-bundle project-id file-id))) @@ -548,7 +571,7 @@ (ptk/reify ::delete-page ptk/WatchEvent (watch [it state _] - (let [components-v2 (features/active-feature? state :components-v2) + (let [components-v2 (features/active-feature? state "components/v2") file-id (:current-file-id state) file (wsh/get-file state file-id) pages (get-in state [:workspace-data :pages]) @@ -1326,7 +1349,7 @@ ptk/WatchEvent (watch [_ state _] - (let [components-v2 (features/active-feature? state :components-v2)] + (let [components-v2 (features/active-feature? state "components/v2")] (if components-v2 (rx/of (go-to-main-instance nil component-id)) (let [project-id (get-in state [:workspace-project :id]) @@ -1341,7 +1364,7 @@ ptk/EffectEvent (effect [_ state _] - (let [components-v2 (features/active-feature? state :components-v2) + (let [components-v2 (features/active-feature? state "components/v2") wrapper-id (str "component-shape-id-" component-id)] (when-not components-v2 (tm/schedule-on-idle #(dom/scroll-into-view-if-needed! (dom/get-element wrapper-id)))))))) @@ -2007,143 +2030,6 @@ (rx/of (dch/commit-changes changes)))))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Remove graphics -;; TODO: this should be deprecated and removed together with components-v2 -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- initialize-remove-graphics - [total] - (ptk/reify ::initialize-remove-graphics - ptk/UpdateEvent - (update [_ state] - (assoc state :remove-graphics {:total total - :current nil - :error false - :completed false})))) - -(defn- update-remove-graphics - [current] - (ptk/reify ::update-remove-graphics - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:remove-graphics :current] current)))) - -(defn- error-in-remove-graphics - [] - (ptk/reify ::error-in-remove-graphics - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:remove-graphics :error] true)))) - -(defn clear-remove-graphics - [] - (ptk/reify ::clear-remove-graphics - ptk/UpdateEvent - (update [_ state] - (dissoc state :remove-graphics)))) - -(defn- complete-remove-graphics - [] - (ptk/reify ::complete-remove-graphics - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:remove-graphics :completed] true)) - - ptk/WatchEvent - (watch [_ state _] - (when-not (get-in state [:remove-graphics :error]) - (rx/of (modal/hide)))))) - -(defn- remove-graphic - [it file-data page [index [media-obj pos]]] - (let [process-shapes - (fn [[shape children]] - (let [changes1 (-> (pcb/empty-changes it) - (pcb/set-save-undo? false) - (pcb/with-page page) - (pcb/with-objects (:objects page)) - (pcb/with-library-data file-data) - (pcb/delete-media (:id media-obj)) - (pcb/add-objects (cons shape children))) - - page' (reduce (fn [page shape] - (ctst/add-shape (:id shape) - shape - page - uuid/zero - uuid/zero - nil - true)) - page - (cons shape children)) - - [_ _ changes2] (cflh/generate-add-component it - [shape] - (:objects page') - (:id page) - (:id file-data) - true - nil - cfsh/prepare-create-artboard-from-selection) - - changes (pcb/concat-changes changes1 changes2)] - - (dch/commit-changes changes))) - - shapes (if (= (:mtype media-obj) "image/svg+xml") - (->> (dwm/load-and-parse-svg media-obj) - (rx/mapcat (partial dwm/create-shapes-svg (:id file-data) (:objects page) pos))) - (dwm/create-shapes-img pos media-obj :wrapper-type :frame))] - - (->> (rx/concat - (rx/of (update-remove-graphics index)) - (rx/map process-shapes shapes)) - (rx/catch #(do - (log/error :msg (str "Error removing " (:name media-obj)) - :hint (ex-message %) - :error %) - (js/console.log (.-stack %)) - (rx/of (error-in-remove-graphics))))))) - -(defn- remove-graphics - [file-id file-name] - (ptk/reify ::remove-graphics - ptk/WatchEvent - (watch [it state stream] - (let [file-data (wsh/get-file state file-id) - - grid-gap 50 - - [file-data' page-id start-pos] - (ctf/get-or-add-library-page file-data grid-gap) - - new-page? (nil? (ctpl/get-page file-data page-id)) - page (ctpl/get-page file-data' page-id) - media (vals (:media file-data')) - - media-points - (map #(assoc % :points (-> (grc/make-rect 0 0 (:width %) (:height %)) - (grc/rect->points))) - media) - - shape-grid - (ctst/generate-shape-grid media-points start-pos grid-gap) - - stoper (rx/filter (ptk/type? ::finalize-file) stream)] - - (rx/concat - (rx/of (modal/show {:type :remove-graphics-dialog :file-name file-name}) - (initialize-remove-graphics (count media))) - (when new-page? - (rx/of (dch/commit-changes (-> (pcb/empty-changes it) - (pcb/set-save-undo? false) - (pcb/add-page (:id page) page))))) - (->> (rx/mapcat (partial remove-graphic it file-data' page) - (rx/from (d/enumerate (d/zip media shape-grid)))) - (rx/take-until stoper)) - (rx/of (complete-remove-graphics))))))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Read only ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 0b28dc6c8..a7c56a38b 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] [app.common.files.libraries-helpers :as cflh] [app.common.files.shapes-helpers :as cfsh] [app.common.geom.point :as gpt] @@ -328,7 +327,7 @@ selected (->> (wsh/lookup-selected state) (cph/clean-loops objects) (remove #(ctn/has-any-copy-parent? objects (get objects %)))) ;; We don't want to change the structure of component copies - components-v2 (features/active-feature? state :components-v2)] + components-v2 (features/active-feature? state "components/v2")] (rx/of (add-component2 selected components-v2)))))) (defn add-multiple-components @@ -337,7 +336,7 @@ (ptk/reify ::add-multiple-components ptk/WatchEvent (watch [_ state _] - (let [components-v2 (features/active-feature? state :components-v2) + (let [components-v2 (features/active-feature? state "components/v2") objects (wsh/lookup-page-objects state) selected (->> (wsh/lookup-selected state) (cph/clean-loops objects) @@ -364,7 +363,7 @@ (rx/empty) (let [data (get state :workspace-data) [path name] (cph/parse-path-name new-name) - components-v2 (features/active-feature? state :components-v2) + components-v2 (features/active-feature? state "components/v2") update-fn (fn [component] @@ -411,7 +410,7 @@ component (ctkl/get-component (:data library) component-id) new-name (:name component) - components-v2 (features/active-feature? state :components-v2) + components-v2 (features/active-feature? state "components/v2") main-instance-page (when components-v2 (ctf/get-component-page (:data library) component)) @@ -447,7 +446,7 @@ ptk/WatchEvent (watch [it state _] (let [data (get state :workspace-data)] - (if (features/active-feature? state :components-v2) + (if (features/active-feature? state "components/v2") (let [component (ctkl/get-component data id) page-id (:main-instance-page component) root-id (:main-instance-id component)] @@ -639,7 +638,7 @@ container (cph/get-container file :page page-id) components-v2 - (features/active-feature? state :components-v2) + (features/active-feature? state "components/v2") changes (-> (pcb/empty-changes it) @@ -686,7 +685,7 @@ local-file (wsh/get-local-file state) container (cph/get-container local-file :page page-id) shape (ctn/get-shape container id) - components-v2 (features/active-feature? state :components-v2)] + components-v2 (features/active-feature? state "components/v2")] (when (ctk/instance-head? shape) (let [libraries (wsh/get-libraries state) @@ -1016,7 +1015,7 @@ (ptk/reify ::watch-component-changes ptk/WatchEvent (watch [_ state stream] - (let [components-v2? (features/active-feature? state :components-v2) + (let [components-v2? (features/active-feature? state "components/v2") stopper-s (->> stream @@ -1138,9 +1137,7 @@ ptk/WatchEvent (watch [_ state _] - (let [features (cond-> ffeat/enabled - (features/active-feature? state :components-v2) - (conj "components/v2"))] + (let [features (features/get-team-enabled-features state)] (rx/merge (->> (rp/cmd! :link-file-to-library {:file-id file-id :library-id library-id}) (rx/ignore)) diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs index 28a7d713a..ad06eccfe 100644 --- a/frontend/src/app/main/data/workspace/persistence.cljs +++ b/frontend/src/app/main/data/workspace/persistence.cljs @@ -139,19 +139,15 @@ (ptk/reify ::persist-changes ptk/WatchEvent (watch [_ state _] - (let [;; this features set does not includes the ffeat/enabled - ;; because they are already available on the backend and - ;; this request provides a set of features to enable in - ;; this request. - features (cond-> #{} - (features/active-feature? state :components-v2) - (conj "components/v2")) - sid (:session-id state) + (let [sid (:session-id state) + + features (features/get-team-enabled-features state) params {:id file-id :revn file-revn :session-id sid :changes-with-metadata (into [] changes) - :features features}] + :features features + }] (->> (rp/cmd! :update-file params) (rx/mapcat (fn [lagged] @@ -209,7 +205,7 @@ ptk/WatchEvent (watch [_ state _] (let [features (cond-> #{} - (features/active-feature? state :components-v2) + (features/active-feature? state "components/v2") (conj "components/v2")) sid (:session-id state) file (dm/get-in state [:workspace-libraries file-id]) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 1c04b5228..d0e090d42 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -104,7 +104,7 @@ page (wsh/lookup-page state page-id) objects (wsh/lookup-page-objects state page-id) - components-v2 (features/active-feature? state :components-v2) + components-v2 (features/active-feature? state "components/v2") ids (cph/clean-loops objects ids) diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index b932de976..8acbd823f 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -23,37 +23,41 @@ [cuerdas.core :as str] [potok.core :as ptk])) -(defn extract-name [url] - (let [query-idx (str/last-index-of url "?") - url (if (> query-idx 0) (subs url 0 query-idx) url) - filename (->> (str/split url "/") (last)) +(defn extract-name [href] + (let [query-idx (str/last-index-of href "?") + href (if (> query-idx 0) (subs href 0 query-idx) href) + filename (->> (str/split href "/") (last)) ext-idx (str/last-index-of filename ".")] (if (> ext-idx 0) (subs filename 0 ext-idx) filename))) (defn upload-images "Extract all bitmap images inside the svg data, and upload them, associated to the file. - Return a map { }." + Return a map { }." [svg-data file-id] (->> (rx/from (csvg/collect-images svg-data)) - (rx/map (fn [uri] - (merge - {:file-id file-id - :is-local true - :url uri} - (if (str/starts-with? uri "data:") - {:name "image" - :content (wapi/data-uri->blob uri)} - {:name (extract-name uri)})))) - (rx/mapcat (fn [uri-data] - (->> (rp/cmd! (if (contains? uri-data :content) + (rx/map (fn [{:keys [href] :as item}] + (let [item (-> item + (assoc :file-id file-id) + (assoc :is-local true) + (assoc :name "image"))] + (if (str/starts-with? href "data:") + (assoc item :content (wapi/data-uri->blob href)) + (-> item + (assoc :name (extract-name href)) + (assoc :url href)))))) + (rx/mapcat (fn [item] + ;; TODO: :create-file-media-object-from-url is + ;; deprecated and this should be resolved in + ;; frontend + (->> (rp/cmd! (if (contains? item :content) :upload-file-media-object :create-file-media-object-from-url) - uri-data) + (dissoc item :href)) ;; When the image uploaded fail we skip the shape ;; returning `nil` will afterward not create the shape. (rx/catch #(rx/of nil)) - (rx/map #(vector (:url uri-data) %))))) - (rx/reduce (fn [acc [url image]] (assoc acc url image)) {}))) + (rx/map #(vector (:href item) %))))) + (rx/reduce conj {}))) (defn add-svg-shapes [svg-data position] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 7bd072645..c7e83c3b3 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -165,6 +165,25 @@ (defmethod ptk/handle-error :restriction [{:keys [code] :as error}] (cond + (= :migration-in-progress code) + (let [message (tr "errors.migration-in-progress" (:feature error)) + on-accept (constantly nil)] + (st/emit! (modal/show {:type :alert :message message :on-accept on-accept}))) + + (= :team-feature-mismatch code) + (let [message (tr "errors.team-feature-mismatch" (:feature error)) + on-accept (constantly nil)] + (st/emit! (modal/show {:type :alert :message message :on-accept on-accept}))) + + (= :file-feature-mismatch code) + (let [message (tr "errors.file-feature-mismatch" (:feature error)) + team-id (:current-team-id @st/state) + project-id (:current-project-id @st/state) + on-accept #(if (and project-id team-id) + (st/emit! (rt/nav :dashboard-files {:team-id team-id :project-id project-id})) + (set! (.-href glob/location) ""))] + (st/emit! (modal/show {:type :alert :message message :on-accept on-accept}))) + (= :feature-mismatch code) (let [message (tr "errors.feature-mismatch" (:feature error)) team-id (:current-team-id @st/state) @@ -174,7 +193,7 @@ (set! (.-href glob/location) ""))] (st/emit! (modal/show {:type :alert :message message :on-accept on-accept}))) - (= :features-not-supported code) + (= :feature-not-supported code) (let [message (tr "errors.feature-not-supported" (:feature error)) team-id (:current-team-id @st/state) project-id (:current-project-id @st/state) diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 85c753e05..890f32e15 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -5,103 +5,116 @@ ;; Copyright (c) KALEIDOS INC (ns app.main.features + "A thin, frontend centric abstraction layer and collection of + helpers for `app.common.features` namespace." (:require - [app.common.data :as d] + [app.common.features :as cfeat] [app.common.logging :as log] [app.config :as cf] [app.main.store :as st] - [app.util.timers :as tm] [beicon.core :as rx] + [clojure.set :as set] [cuerdas.core :as str] [okulary.core :as l] [potok.core :as ptk] [rumext.v2 :as mf])) -(log/set-level! :warn) +(log/set-level! :trace) -(def available-features - #{:components-v2 :new-css-system :grid-layout}) +(def global-enabled-features + (cfeat/get-enabled-features cf/flags)) -(defn- toggle-feature +(defn get-enabled-features + [state] + (-> (get state :features/runtime #{}) + (set/union global-enabled-features))) + +(defn get-team-enabled-features + [state] + (-> global-enabled-features + (set/union (get state :features/runtime #{})) + (set/intersection cfeat/no-migration-features) + (set/union (get state :features/team #{})))) + +(def features-ref + (l/derived get-team-enabled-features st/state =)) + +(defn active-feature? + "Given a state and feature, check if feature is enabled" + [state feature] + (assert (contains? cfeat/supported-features feature) "not supported feature") + (or (contains? (get state :features/runtime) feature) + (if (contains? cfeat/no-migration-features feature) + (or (contains? global-enabled-features feature) + (contains? (get state :features/team) feature)) + (contains? (get state :features/team state) feature)))) + +(defn use-feature + "A react hook that checks if feature is currently enabled" + [feature] + (assert (contains? cfeat/supported-features feature) "Not supported feature") + (let [enabled-features (mf/deref features-ref)] + (contains? enabled-features feature))) + +(defn toggle-feature + "An event constructor for runtime feature toggle. + + Warning: if a feature is active globally or by team, it can't be + disabled." [feature] (ptk/reify ::toggle-feature ptk/UpdateEvent (update [_ state] - (let [features (or (:features state) #{})] - (if (contains? features feature) - (do - (log/debug :hint "feature disabled" :feature (d/name feature)) - (assoc state :features (disj features feature))) - (do - (log/debug :hint "feature enabled" :feature (d/name feature)) - (assoc state :features (conj features feature)))))))) + (assert (contains? cfeat/supported-features feature) "not supported feature") + (update state :features/runtime (fn [features] + (if (contains? features feature) + (do + (log/trc :hint "feature disabled" :feature feature) + (disj features feature)) + (do + (log/trc :hint "feature enabled" :feature feature) + (conj features feature)))))))) -(defn- enable-feature +(defn enable-feature [feature] (ptk/reify ::enable-feature ptk/UpdateEvent (update [_ state] - (let [features (or (:features state) #{})] - (if (contains? features feature) - state - (do - (log/debug :hint "feature enabled" :feature (d/name feature)) - (assoc state :features (conj features feature)))))))) - -(defn toggle-feature! - [feature] - (assert (contains? available-features feature) "Not supported feature") - (tm/schedule-on-idle #(st/emit! (toggle-feature feature)))) - -(defn enable-feature! - [feature] - (assert (contains? available-features feature) "Not supported feature") - (tm/schedule-on-idle #(st/emit! (enable-feature feature)))) - -(defn active-feature? - ([feature] - (active-feature? @st/state feature)) - ([state feature] - (assert (contains? available-features feature) "Not supported feature") - (contains? (get state :features) feature))) - -(def features - (l/derived :features st/state)) - -(defn active-feature - [feature] - (l/derived #(contains? % feature) features)) - -(defn use-feature - [feature] - (assert (contains? available-features feature) "Not supported feature") - (let [active-feature-ref (mf/use-memo (mf/deps feature) #(active-feature feature)) - active-feature? (mf/deref active-feature-ref)] - active-feature?)) + (assert (contains? cfeat/supported-features feature) "not supported feature") + (if (active-feature? state feature) + state + (do + (log/trc :hint "feature enabled" :feature feature) + (update state :features/runtime (fnil conj #{}) feature)))))) (defn initialize - [] - (ptk/reify ::initialize - ptk/WatchEvent - (watch [_ _ _] - (log/trace :hint "event:initialize" :fn "features") - (rx/concat - ;; Enable all features set on the configuration - (->> (rx/from cf/flags) - (rx/map name) - (rx/map (fn [flag] - (when (str/starts-with? flag "frontend-feature-") - (subs flag 17)))) - (rx/filter some?) - (rx/map keyword) - (rx/map enable-feature)) + ([] (initialize #{})) + ([team-features] + (assert (set? team-features) "expected a set of features") + (assert (every? string? team-features) "expected a set of strings") + + (ptk/reify ::initialize + ptk/UpdateEvent + (update [_ state] + (let [runtime-features (get state :features/runtime #{}) + team-features (into cfeat/default-enabled-features + cfeat/xf-supported-features + team-features)] + (-> state + (assoc :features/runtime runtime-features) + (assoc :features/team team-features)))) + + ptk/WatchEvent + (watch [_ _ _] + (when *assert* + (->> (rx/from cfeat/no-migration-features) + (rx/filter #(not (contains? cfeat/backend-only-features %))) + (rx/observe-on :async) + (rx/map enable-feature)))) + + ptk/EffectEvent + (effect [_ state _] + (log/trc :hint "initialized features" + :team (str/join "," (:features/team state)) + :runtime (str/join "," (:features/runtime state))))))) - ;; Enable the rest of available configuration if we are on development - ;; environemnt (aka devenv). - (when *assert* - ;; By default, all features disabled, except in development - ;; environment, that are enabled except components-v2 and new css - (->> (rx/from available-features) - (rx/filter #(not= % :components-v2)) - (rx/filter #(not= % :new-css-system)) - (rx/map enable-feature))))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index f35efd969..40384c2b3 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -425,10 +425,6 @@ ids))) st/state =)) -;; Remove this when deprecating components-v2 -(def remove-graphics - (l/derived :remove-graphics st/state)) - ;; ---- Viewer refs (defn lookup-viewer-objects-by-id diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 49879769c..20042e041 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -40,7 +40,7 @@ {::mf/wrap [#(mf/catch % {:fallback on-main-error})]} [{:keys [route profile]}] (let [{:keys [data params]} route - new-css-system (features/use-feature :new-css-system)] + new-css-system (features/use-feature "styles/v2")] [:& (mf/provider ctx/current-route) {:value route} [:& (mf/provider ctx/new-css-system) {:value new-css-system} (case (:name data) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 2747d9cd2..85c236b38 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -157,7 +157,7 @@ (mf/with-effect [profile team-id] (st/emit! (dd/initialize {:id team-id})) (fn [] - (dd/finalize {:id team-id}))) + (st/emit! (dd/finalize {:id team-id})))) (mf/with-effect [] (let [key (events/listen goog/global "keydown" diff --git a/frontend/src/app/main/ui/dashboard/export.cljs b/frontend/src/app/main/ui/dashboard/export.cljs index f0d3f8242..2f7335d5f 100644 --- a/frontend/src/app/main/ui/dashboard/export.cljs +++ b/frontend/src/app/main/ui/dashboard/export.cljs @@ -60,7 +60,7 @@ ::mf/register-as :export ::mf/wrap-props false} [{:keys [team-id files has-libraries? binary?]}] - (let [components-v2 (features/use-feature :components-v2) + (let [components-v2 (features/use-feature "components/v2") state* (mf/use-state #(let [files (mapv (fn [file] (assoc file :loading? true)) files)] {:status :prepare diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 898ae44d7..4720e0fe2 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.dashboard.grid (:require [app.common.data.macros :as dm] - [app.common.files.features :as ffeat] [app.common.geom.point :as gpt] [app.common.logging :as log] [app.main.data.dashboard :as dd] @@ -51,22 +50,18 @@ (defn- ask-for-thumbnail "Creates some hooks to handle the files thumbnails cache" [file-id revn] - (let [features (cond-> ffeat/enabled - (features/active-feature? :components-v2) - (conj "components/v2"))] - - (->> (wrk/ask! {:cmd :thumbnails/generate-for-file - :revn revn - :file-id file-id - :features features}) - (rx/mapcat (fn [{:keys [fonts] :as result}] - (->> (fonts/render-font-styles fonts) - (rx/map (fn [styles] - (assoc result - :styles styles - :width 250)))))) - (rx/mapcat thr/render) - (rx/mapcat (partial persist-thumbnail file-id revn))))) + (->> (wrk/ask! {:cmd :thumbnails/generate-for-file + :revn revn + :file-id file-id + :features (features/get-team-enabled-features @st/state)}) + (rx/mapcat (fn [{:keys [fonts] :as result}] + (->> (fonts/render-font-styles fonts) + (rx/map (fn [styles] + (assoc result + :styles styles + :width 250)))))) + (rx/mapcat thr/render) + (rx/mapcat (partial persist-thumbnail file-id revn)))) (mf/defc grid-item-thumbnail {::mf/wrap-props false} diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs index c52ab6ce2..84070ad55 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.cljs +++ b/frontend/src/app/main/ui/dashboard/libraries.cljs @@ -33,7 +33,7 @@ (sort-by :modified-at) (reverse)))) - components-v2 (features/use-feature :components-v2) + components-v2 (features/use-feature "components/v2") width (mf/use-state nil) rowref (mf/use-ref) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 3c58700ae..7ffcad547 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -255,7 +255,7 @@ (fn [] (st/emit! (dd/fetch-files {:project-id project-id}) (dd/fetch-recent-files (:id team)) - (dd/fetch-projects) + (dd/fetch-projects (:id team)) (dd/clear-selected-files))))] (mf/with-effect diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 2e066120f..ca1fc82a7 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -336,7 +336,7 @@ on-leave-as-owner-clicked (fn [] - (st/emit! (dd/fetch-team-members) + (st/emit! (dd/fetch-team-members (:id team)) (modal/show {:type :leave-and-reassign :profile profile diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 5ce90cf3b..2d8c298aa 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -355,7 +355,7 @@ (mf/use-fn (mf/deps profile team on-leave-accepted) (fn [] - (st/emit! (dd/fetch-team-members) + (st/emit! (dd/fetch-team-members (:id team)) (modal/show {:type :leave-and-reassign :profile profile @@ -452,8 +452,8 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [] - (st/emit! (dd/fetch-team-members))) + (mf/with-effect [team] + (st/emit! (dd/fetch-team-members (:id team)))) [:* [:& header {:section :dashboard-team-members :team team}] @@ -992,9 +992,10 @@ (:name team))))) - (mf/with-effect [] - (st/emit! (dd/fetch-team-members) - (dd/fetch-team-stats))) + (mf/with-effect [team] + (let [team-id (:id team)] + (st/emit! (dd/fetch-team-members team-id) + (dd/fetch-team-stats team-id)))) [:* [:& header {:section :dashboard-team-settings :team team}] diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 15ded2950..1562543aa 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -89,7 +89,7 @@ {::mf/wrap-props false} [] (let [modal (mf/deref modal-ref) - new-css-system (features/use-feature :new-css-system)] + new-css-system (features/use-feature "styles/v2")] (when modal [:& (mf/provider ctx/new-css-system) {:value new-css-system} [:& modal-wrapper {:data modal diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 64f52e006..3d3424a02 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -42,7 +42,7 @@ (update profile :lang #(or % ""))) form (fm/use-form :spec ::options-form :initial initial) - new-css-system (features/use-feature :new-css-system)] + new-css-system (features/use-feature "styles/v2")] [:& fm/form {:class "options-form" :on-submit on-submit diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0d1eef4a2..99546edeb 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -38,7 +38,6 @@ [app.util.dom :as dom] [app.util.globals :as globals] [app.util.i18n :as i18n :refer [tr]] - [app.util.router :as rt] [goog.events :as events] [okulary.core :as l] [rumext.v2 :as mf])) @@ -177,8 +176,8 @@ (make-file-ready-ref file-id)) file-ready? (mf/deref file-ready*) - components-v2? (features/use-feature :components-v2) - new-css-system (features/use-feature :new-css-system) + components-v2? (features/use-feature "components/v2") + new-css-system (features/use-feature "styles/v2") background-color (:background-color wglobal)] @@ -236,49 +235,3 @@ :wglobal wglobal :layout layout}] [:& workspace-loader])])]]]]]]])) - -(mf/defc remove-graphics-dialog - {::mf/register modal/components - ::mf/register-as :remove-graphics-dialog} - [{:keys [] :as ctx}] - (let [remove-state (mf/deref refs/remove-graphics) - project (mf/deref refs/workspace-project) - close #(modal/hide!) - reload-file #(dom/reload-current-window) - nav-out #(st/emit! (rt/navigate :dashboard-files - {:team-id (:team-id project) - :project-id (:id project)}))] - (mf/use-effect - (fn [] - #(st/emit! (dw/clear-remove-graphics)))) - - [:div.modal-overlay - [:div.modal-container.remove-graphics-dialog - [:div.modal-header - [:div.modal-header-title - [:h2 (tr "workspace.remove-graphics.title" (:file-name ctx))]] - (if (and (:completed remove-state) (:error remove-state)) - [:div.modal-close-button - {:on-click close} i/close] - [:div.modal-close-button - {:on-click nav-out} - i/close])] - (if-not (and (:completed remove-state) (:error remove-state)) - [:div.modal-content - [:p (tr "workspace.remove-graphics.text1")] - [:p (tr "workspace.remove-graphics.text2")] - [:p.progress-message (tr "workspace.remove-graphics.progress" - (:current remove-state) - (:total remove-state))]] - [:* - [:div.modal-content - [:p.error-message [:span i/close] (tr "workspace.remove-graphics.error-msg")] - [:p (tr "workspace.remove-graphics.error-hint")]] - [:div.modal-footer - [:div.action-buttons - [:input.button-secondary {:type "button" - :value (tr "labels.close") - :on-click close}] - [:input.button-primary {:type "button" - :value (tr "labels.reload-file") - :on-click reload-file}]]]])]])) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 1d17e3dea..3461424fa 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -443,7 +443,7 @@ (mf/defc context-menu-component [{:keys [shapes]}] - (let [components-v2 (features/use-feature :components-v2) + (let [components-v2 (features/use-feature "components/v2") single? (= (count shapes) 1) objects (deref refs/workspace-page-objects) any-in-copy? (some true? (map #(ctn/has-any-copy-parent? objects %) shapes)) diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index ba7f845df..c8b222d0b 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -665,7 +665,7 @@ {::mf/register modal/components ::mf/register-as :libraries-dialog} [{:keys [starting-tab] :as props :or {starting-tab :libraries}}] - (let [new-css-system (features/use-feature :new-css-system) + (let [new-css-system (features/use-feature "styles/v2") project (mf/deref refs/workspace-project) file-data (mf/deref refs/workspace-data) file (mf/deref ref:workspace-file) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 95f766ab2..1e78a482b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1207,6 +1207,8 @@ grid-justify-content-row (:layout-justify-content values) grid-justify-content-column (:layout-align-content values) + grid-enabled? (features/use-feature "layout/grid") + set-justify-grid (mf/use-fn (mf/deps ids) @@ -1225,7 +1227,7 @@ :class (stl/css-case :title-spacing-layout (not has-layout?))} (if (and (not multiple) (:layout values)) [:div {:class (stl/css :title-actions)} - (when (features/active-feature? :grid-layout) + (when ^boolean grid-enabled? [:div {:class (stl/css :layout-options)} [:& radio-buttons {:selected (d/name layout-type) :on-change toggle-layout-style @@ -1317,7 +1319,7 @@ [:* [:span "Layout"] - (if (features/active-feature? :grid-layout) + (if ^boolean grid-enabled? [:div.title-actions [:div.layout-btns [:button {:on-click set-flex diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index 009faf319..485d72081 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -100,7 +100,7 @@ (mf/defc object-svg [{:keys [page-id file-id share-id object-id render-embed?]}] - (let [components-v2 (feat/use-feature :components-v2) + (let [components-v2 (feat/use-feature "components/v2") fetch-state (mf/use-fn (mf/deps file-id page-id share-id object-id components-v2) (fn [] @@ -141,7 +141,7 @@ (mf/defc objects-svg [{:keys [page-id file-id share-id object-ids render-embed?]}] - (let [components-v2 (feat/use-feature :components-v2) + (let [components-v2 (feat/use-feature "components/v2") fetch-state (mf/use-fn (mf/deps file-id page-id share-id components-v2) (fn [] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index f2d3afb1a..662421c39 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -99,21 +99,21 @@ (rf result input))))) (defn prettify - "Prepare x fror cleaner output when logged." + "Prepare x for cleaner output when logged." [x] (cond (map? x) (d/mapm #(prettify %2) x) (vector? x) (mapv prettify x) (seq? x) (map prettify x) - (set? x) (into #{} (map prettify x)) + (set? x) (into #{} (map prettify) x) (number? x) (mth/precision x 4) - (uuid? x) (str "#uuid " x) + (uuid? x) (str/concat "#uuid " x) :else x)) (defn ^:export logjs ([str] (tap (partial logjs str))) ([str val] - (js/console.log str (clj->js (prettify val))) + (js/console.log str (clj->js (prettify val) :keyword-fn (fn [v] (str/concat v)))) val)) (when (exists? js/window) @@ -403,7 +403,7 @@ ptk/WatchEvent (watch [_ state _] (let [features (cond-> #{} - (features/active-feature? state :components-v2) + (features/active-feature? state "components/v2") (conj "components/v2")) sid (:session-id state) file (get state :workspace-file) diff --git a/frontend/src/features.cljs b/frontend/src/features.cljs index ebce21ba7..05cf13af4 100644 --- a/frontend/src/features.cljs +++ b/frontend/src/features.cljs @@ -7,18 +7,23 @@ ;; This namespace is only to export the functions for toggle features (ns features (:require - [app.main.features :as features])) - -(defn ^:export components-v2 [] - (features/toggle-feature! :components-v2) - nil) + [app.main.features :as features] + [app.main.store :as st] + [app.util.timers :as tm])) (defn ^:export is-components-v2 [] - (let [active? (features/active-feature :components-v2)] - @active?)) + (features/active-feature? @st/state "components/v2")) (defn ^:export new-css-system [] - (features/toggle-feature! :new-css-system)) + (tm/schedule-on-idle #(st/emit! (features/toggle-feature "styles/v2"))) + nil) (defn ^:export grid [] - (features/toggle-feature! :grid-layout)) + (tm/schedule-on-idle #(st/emit! (features/toggle-feature "layout/grid"))) + nil) + +(defn ^:export get-enabled [] + (clj->js (features/get-enabled-features @st/state))) + +(defn ^:export get-team-enabled [] + (clj->js (features/get-team-enabled-features @st/state))) diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index fbf5a7109..a8ee98cdd 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -38,7 +38,7 @@ :pages [] :pages-index {}} :workspace-libraries {} - :features {:components-v2 true}}) + :features/team #{"components/v2"}}) (def ^:private idmap (atom {})) diff --git a/frontend/translations/cs.po b/frontend/translations/cs.po index 9fd1b4d5a..2612d0923 100644 --- a/frontend/translations/cs.po +++ b/frontend/translations/cs.po @@ -4174,35 +4174,6 @@ msgstr "Oddělit uzly (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Přichytit uzly (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Chcete-li to zkusit znovu, můžete tento soubor znovu načíst. Pokud problém " -"přetrvává, doporučujeme vám podívat se na seznam a zvážit odstranění " -"poškozené grafiky." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Některé grafiky nebylo možné aktualizovat." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Převádí se %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Grafiky knihovny jsou od nynějška komponenty, díky čemuž budou mnohem " -"výkonnější." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Tato aktualizace je jednorázová." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Aktualizace %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Přidat flexibilní rozložení" diff --git a/frontend/translations/de.po b/frontend/translations/de.po index d19ec42b2..fdf11608d 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -4453,35 +4453,6 @@ msgstr "Ankerpunkte trennen (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "An Ankerpunkten ausrichten (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Um es erneut zu versuchen, können Sie diese Datei neu laden. Wenn das " -"Problem weiterhin besteht, empfehlen wir Ihnen, einen Blick auf die Liste " -"zu werfen und zu überlegen, ob Sie defekte Grafiken löschen wollen." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Einige Grafiken konnten nicht aktualisiert werden." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Konvertieren von %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Von nun an sind Grafiken in der Bibliothek auch Komponenten. Das macht sie " -"viel leistungsfähiger." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Diese Aktualisierung ist eine einmalige Aktion." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Aktualisierung von %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Flex-Layout hinzufügen" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 468fb6633..09683b188 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -879,10 +879,21 @@ msgstr "" "Looks like you are opening a file that has the feature '%s' enabled but " "your penpot frontend does not supports it or has it disabled." +#: src/app/main/errors.cljs +msgid "errors.file-feature-mismatch" +msgstr "" +"It seems that there is a mismatch between the enabled features and the " +"features of the file you are trying to open. Migrations for '%s' need " +"to be applied before the file can be opened." + #: src/app/main/errors.cljs msgid "errors.feature-not-supported" msgstr "Feature '%s' is not supported." +#: src/app/main/errors.cljs +msgid "errors.team-feature-mismatch" +msgstr "Detected incompatible feature '%s'" + #: src/app/main/ui/auth/verify_token.cljs, src/app/main/ui/settings/feedback.cljs, src/app/main/ui/dashboard/team.cljs msgid "errors.generic" msgstr "Something wrong has happened." @@ -4561,35 +4572,6 @@ msgstr "Separate nodes (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Snap nodes (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"To try it again, you can reload this file. If the problem persists, we " -"suggest you to take a look at the list and consider to delete broken " -"graphics." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Some graphics could not be updated." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Converting %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Library Graphics are Components from now on, which will make them much more " -"powerful." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "This update is a one time action." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Updating %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Add flex layout" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 59eb67272..9e145e578 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -903,6 +903,13 @@ msgstr "" "pero la aplicacion web de penpot que esta usando no tiene soporte para ella " "o esta deshabilitada." +#: src/app/main/errors.cljs +msgid "errors.file-feature-mismatch" +msgstr "" +"Parece que hay discordancia entre las features habilitadas y las features " +"del fichero que se esta intentando abrir. Falta aplicar migraciones para " +"'%s' antes de poder abrir el fichero." + #: src/app/main/errors.cljs msgid "errors.feature-not-supported" msgstr "Caracteristica no soportada: '%s'." @@ -4650,35 +4657,6 @@ msgstr "Separar nodos (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Alinear nodos (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Para intentarlo de nuevo, puedes recargar este archivo. Si el problema " -"persiste, te sugerimos que compruebes la lista y consideres borrar los " -"gráficos que estén mal." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Algunos gráficos no han podido ser actualizados." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Convirtiendo %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Desde ahora los gráficos de la librería serán componentes, lo cual los hará " -"mucho más potentes." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Esta actualización sólo ocurrirá una vez." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Actualizando %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Añadir flex layout" diff --git a/frontend/translations/eu.po b/frontend/translations/eu.po index 9a315f8e9..3a2f28761 100644 --- a/frontend/translations/eu.po +++ b/frontend/translations/eu.po @@ -4173,35 +4173,6 @@ msgstr "Banatu nodoak (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Atxikitu nodoak (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Berriz saiatzeko, fitxategi hau berriz kargatu dezakezu. Hala ere arazoa " -"izaten jarraitzen baduzu, begiratu zerrenda eta ezabatu apurtutako " -"grafikoak." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Grafiko batzuk ezin izan dira eguneratu." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Bihurtzen %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Liburutegiko grafikoak osagaiak izango dira orain, horrek ahaltsuago egingo " -"ditu." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Eguneraketa hau behin bakarrik gertatuko da." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Eguneratzen %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Gehitu flex diseinua" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index c920034e5..fa55972c8 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -4457,32 +4457,6 @@ msgstr "הפרדת מפרקים (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "הצמדת מפרקים (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"כדי לנסות שוב, אפשר לרענן את הקובץ הזה. אם הבעיה נמשכת, אנו ממליצים לך " -"להביט ברשימה ולשקול למחוק גרפיקה פגומה." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "לא ניתן לעדכן חלק מהגרפיקה." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "מתבצעת המרה %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "גרפיקות ספרייה הן רכיבים מעתה ואילך, מה שהופך אותן להרבה יותר עוצמתיות." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "העדכון הזה הוא חד־פעמי." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "%s מתעדכן…" - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "הוספת פריסת flex" diff --git a/frontend/translations/id.po b/frontend/translations/id.po index bbfc013ba..ecfc1fca0 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -4585,35 +4585,6 @@ msgstr "Simpul terpisah (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Tancap simpul (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Untuk mencoba lagi, Anda dapat memuat ulang berkas ini. Jika masalah tetap " -"ada, kami menyarankan Anda untuk melihat daftar dan mempertimbangkan untuk " -"menghapus grafis yang rusak." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Beberapa grafis tidak dapat diperbarui." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Mengubah %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Grafis Pustaka itu Komponen dari sekarang, yang akan membuatnya lebih " -"berdaya." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Pembaruan ini adalah tindakan satu kali." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Memperbarui %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Tambahkan tata letak flex" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 18ae5f4a5..0a7bec7dc 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -4568,34 +4568,6 @@ msgstr "Atdalīt mezglus (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Pieķert mezglus (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Lai to mēģinātu vēlreiz, varat atkārtoti ielādēt šo failu. Ja problēma " -"joprojām pastāv, ieteicams apskatīt sarakstu un dzēst bojātās grafikas." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Dažas grafikas nevar atjaunināt." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "%s/%s pārvēršana" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Bibliotēkas grafikas turpmāk sauksies Komponentes, kas padarīs tās daudz " -"jaudīgākas." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Šis atjauninājums ir vienreizēja darbība." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Notiek %s atjaunināšana..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Pievienot elastīgo izkārtojumu" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 9aaa71271..c123421e8 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -4537,35 +4537,6 @@ msgstr "Verschillende knooppunten (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Snap knooppunten (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Om het opnieuw te proberen, kun je dit bestand opnieuw laden. Als het " -"probleem zich blijft voordoen, raden we aan de lijst te bekijken en te " -"overwegen om kapotte afbeeldingen te verwijderen." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Sommige afbeeldingen kunnen niet worden bijgewerkt." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "%s/%s converteren" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Bibliotheekafbeeldingen zijn vanaf nu componenten, waardoor ze veel " -"krachtiger worden." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Deze update is een eenmalige actie." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "%s bijwerken..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Flex-indeling toevoegen" diff --git a/frontend/translations/pl.po b/frontend/translations/pl.po index ea1a6d686..a2aeb90fc 100644 --- a/frontend/translations/pl.po +++ b/frontend/translations/pl.po @@ -4011,35 +4011,6 @@ msgstr "Rozłącz węzły (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Przyciągnij węzły (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Aby spróbować ponownie, możesz ponownie załadować ten plik. Jeśli problem " -"będzie się powtarzał, sugerujemy przejrzenie listy i rozważenie usunięcia " -"uszkodzonej grafiki." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Niektórych grafik nie udało się zaktualizować." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Konwersja %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Grafika biblioteczna jest od teraz komponentami, co sprawi, że będą " -"znacznie potężniejsze." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Ta aktualizacja jest działaniem jednorazowym." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Aktualizowanie %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Dodaj układ flex" diff --git a/frontend/translations/pt_BR.po b/frontend/translations/pt_BR.po index d01c20597..c4833963a 100644 --- a/frontend/translations/pt_BR.po +++ b/frontend/translations/pt_BR.po @@ -3994,35 +3994,6 @@ msgstr "Separar pontos (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Aderir aos pontos (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Para tentar novamente, recarregue este arquivo. Se o problema persistir, " -"sugerimos olhar a lista e considerar excluir gráficos que não estejam " -"funcionando." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Alguns gráficos não puderam ser atualizados." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Convertendo %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"A partir de agora os gráficos da biblioteca são Componentes, o que os " -"tornarão bem mais poderosos." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Essa atualização acontecerá apenas uma vez." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Atualizando %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Adicionar Flex Layout" diff --git a/frontend/translations/pt_PT.po b/frontend/translations/pt_PT.po index 49f1bc84a..39c020abc 100644 --- a/frontend/translations/pt_PT.po +++ b/frontend/translations/pt_PT.po @@ -4588,35 +4588,6 @@ msgstr "Separar nós (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Ajustar nós (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Para tentar de novo, podes recarregar este ficheiro. Se o problema " -"persistir, sugerimos que observes a lista e consideres em apagar os " -"gráficos problemáticos." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Não foi possível atualizar alguns gráficos." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "A converter %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"A partir de agora, os Gráficos da biblioteca passarão a ser Componentes, o " -"que os tornará mais poderosos." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Esta atualização só ocorrerá uma única vez." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "A atualizar %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Adicionar layout flex" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index ad69d16c6..e999f1746 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -4626,35 +4626,6 @@ msgstr "Separă noduri (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Trage noduri (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Pentru a încerca din nou, puteți reîncărca acest fișier. Dacă problema " -"persistă, vă sugerăm să aruncați o privire pe listă și să luați în " -"considerare ștergerea graficii rupte." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Unele elemente grafice nu au putut fi actualizate." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "Se convertește %s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Bibliotecile Grafice sunt Componente de acum înainte, ceea ce le va face " -"mult mai puternice." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Această actualizare este o acțiune unică." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "Actualizare %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Adăugați aspect flexibil" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 4e58e5f96..214c1682f 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -4087,35 +4087,6 @@ msgstr "Düğümleri ayır (%s)" msgid "workspace.path.actions.snap-nodes" msgstr "Düğümleri tuttur (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "" -"Tekrar denemek için bu dosyayı yeniden yükleyebilirsiniz. Sorun devam " -"ederse, listeye bir göz atmanızı ve bozuk grafikleri silmeyi düşünmenizi " -"öneririz." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "Bazı grafikler güncellenemedi." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "%s/%s dönüştürülüyor" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "" -"Kütüphane Grafikleri bundan böyle Bileşenlerdir ve bu da onları çok daha " -"güçlü kılacaktır." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "Bu güncelleme tek seferlik bir işlemdir." - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "%s güncelleniyor..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "Düzen esnekliği ekle" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index d34e47a33..521ca95be 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -4069,30 +4069,6 @@ msgstr "拆分节点(%s)" msgid "workspace.path.actions.snap-nodes" msgstr "对接节点 (%s)" -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-hint" -msgstr "要重试,您可以重新加载此文件。如果问题仍然存在,我们建议您查看列表并考虑删除损坏的图形。" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.error-msg" -msgstr "某些图形无法更新。" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.progress" -msgstr "转换%s/%s" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text1" -msgstr "从现在开始,库图形是组件,这将使它们更加强大。" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.text2" -msgstr "此更新是一次性操作。" - -#: src/app/main/ui/workspace.cljs -msgid "workspace.remove-graphics.title" -msgstr "正在更新 %s..." - #: src/app/main/ui/workspace/context_menu.cljs msgid "workspace.shape.menu.add-flex" msgstr "添加弹性布局"