penpot/common/src/app/common/features.cljc

305 lines
12 KiB
Clojure

;; 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-styles-v2 "styles/v2"
:feature-grid-layout "layout/grid"
:feature-fdata-objects-map "fdata/objects-map"
:feature-fdata-pointer-map "fdata/pointer-map"
nil))
(defn migrate-legacy-features
"A helper that translates old feature names to new names"
[features]
(cond-> (or features #{})
(contains? features "storage/pointer-map")
(-> (conj "fdata/pointer-map")
(disj "storage/pointer-map"))
(contains? features "storage/objects-map")
(-> (conj "fdata/objects-map")
(disj "storage/objects-map"))
(or (contains? features "internal/geom-record")
(contains? features "internal/shape-record"))
(-> (conj "fdata/shape-data-type")
(disj "internal/geom-record")
(disj "internal/shape-record"))))
(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 (get-enabled-features 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 enabled 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 frontend-only-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 :feature-not-supported
: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 [;; We should ignore all features that does not match with
;; the `no-migration-features` set because we can't enable
;; them as-is, because they probably need migrations
client-features (set/intersection client-features no-migration-features)
not-supported (-> file-features
(set/difference enabled-features)
(set/difference client-features)
(set/difference backend-only-features)
(set/difference frontend-only-features))]
(when (seq not-supported)
(ex/raise :type :restriction
:code :file-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))))))
(defn check-paste-features!
"Function used for check feature compability between currently enabled
features set on the application with the provided featured set by
the paste data (frontend clipboard)."
[enabled-features paste-features]
(let [not-supported (-> enabled-features
(set/difference paste-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 :missing-features-in-paste-content
:feature (first not-supported)
:hint (str/ffmt "expected features '%' not present in pasted content"
(str/join "," not-supported)))))
(let [not-supported (set/difference enabled-features supported-features)]
(when (seq not-supported)
(ex/raise :type :restriction
:code :paste-feature-not-supported
:feature (first not-supported)
:hint (str/ffmt "features '%' not supported in the application"
(str/join "," not-supported)))))
(let [not-supported (-> paste-features
(set/difference enabled-features)
(set/difference backend-only-features)
(set/difference frontend-only-features))]
(when (seq not-supported)
(ex/raise :type :restriction
:code :paste-feature-not-enabled
:feature (first not-supported)
:hint (str/ffmt "paste features '%' not enabled on the application"
(str/join "," not-supported))))))