mirror of
https://github.com/penpot/penpot.git
synced 2026-10-03 01:06:14 +00:00
✨ Add size limits to profile props and plugin registry (#11596)
* ✨ Add size limits to profile props and plugin registry Bound the total serialized size of profile settings to 2 MiB (:profile-props-max-size), checked on the merged result before persisting, with a controlled :props-too-large error. Profiles that already exceed the limit can still shrink but cannot grow. Cap plugin registry entries in the shared schema (code 1 MiB, 50 plugins max, bounded name/host/description/icon) and restore rate limiting on the plugin RPCs (profile-mutations bucket, one write at a time per profile). The plugin manager now asks for confirmation before removal and ignores repeated clicks while a persist request is in flight. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Enforce plugin count cap, byte sizes and removal guard Enforce the declared 50-plugin cap in add-profile-plugin with a specific :too-many-plugins error (updates of existing entries still pass); the cap lives in a shared max-plugins constant. Measure profile props size in UTF-8 bytes instead of chars so multibyte content cannot slip past the limit. Cover install/remove persist logic with mocked-RPC frontend tests (release semantics, in-flight dedupe, validation vs rollback split) and add the missing boundary tests in common. Expose the in-flight persist set from the plugin registry and disable the remove button of entries being saved. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix rollback loops and restore paths in plugin registry Restore the previous plugin version instead of dropping the entry when a validation error rejects an update of an installed plugin. Make compensating writes one-shot with terminal callbacks so a persistent failure cannot ping-pong between install and remove. Restores keep the original list position; the unused public plugin-persisting? predicate is removed. Pin count-before-size precedence with a dedicated test and fix translation source refs to their canonical lines. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Guard notifications write and fix restore ordering Route update-profile-notifications through check-props-size! so oversized profiles cannot grow through that path; document the exempt system writers. Remove the duplicated stale entries in en.po, keeping the canonical translation refs. Restore rejected plugin updates at their original list position instead of leaving the optimistic move in place. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Skip no-op plugin removal and clarify size comments Return early from remove-profile-plugin when the id is absent: no wasted write, no size check, and no manufactured :plugins key that could spuriously fail on oversized profiles. Clarify that per-field string caps count chars while the byte budget is enforced by profile-props-max-size. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 📎 Fix formatting in rlimit.edn for profile operations Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Fix formatting of import-binfile/global entry Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Simplify props size check and tighten plugin entry caps Measure props with transit bytes directly instead of the PGobject string roundtrip. Rename check-props-size! to check-props-size: single hard limit on the merged props, no growth comparison, and return props so writers thread the check into the update. Move the 2 MiB default into default-props-max-size on the profile namespace, still overridable with the optional :profile-props-max-size config entry. Tighten registry-entry :code and :icon to 500 chars: they hold manifest paths, not content. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix compatibility problems --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
parent
b2c3fd872a
commit
b5fbc4fd8c
@ -51,4 +51,10 @@
|
||||
{:permits 4}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2}
|
||||
|
||||
:profile-plugin-ops/global
|
||||
{:permits 4}
|
||||
|
||||
:profile-plugin-ops/by-profile
|
||||
{:permits 1 :queue 2}}
|
||||
|
||||
@ -221,6 +221,8 @@
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/update-profile
|
||||
:main/update-profile-props
|
||||
:main/add-profile-plugin
|
||||
:main/remove-profile-plugin
|
||||
:main/update-profile-photo
|
||||
:main/update-profile-password
|
||||
:main/update-profile-notifications
|
||||
|
||||
@ -165,6 +165,9 @@
|
||||
[:binfile-import-max-text-total-size {:optional true} ::sm/int]
|
||||
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
|
||||
|
||||
;; Max serialized size of profile props in bytes (default 2 MiB)
|
||||
[:profile-props-max-size {:optional true} ::sm/int]
|
||||
|
||||
[:login-lockout-max-attempts {:optional true} ::sm/int]
|
||||
[:login-lockout-window {:optional true} ::ct/duration]
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
@ -33,6 +34,8 @@
|
||||
|
||||
(sv/defmethod ::add-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
|
||||
[:profile-plugin-ops/global]]
|
||||
::sm/params schema:add-profile-plugin
|
||||
::sm/result ctp/schema:registry-entry
|
||||
::db/transaction true}
|
||||
@ -42,14 +45,22 @@
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id (:plugin-id plugin)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (distinct (conj % plugin-id))))
|
||||
(assoc-in [:data plugin-id] plugin))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
plugin))
|
||||
exists? (contains? (set (:ids plugins)) plugin-id)]
|
||||
(when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins))
|
||||
(ex/raise :type :validation
|
||||
:code :too-many-plugins
|
||||
:hint "plugin registry exceeds maximum size"))
|
||||
(let [plugins (-> plugins
|
||||
(update :ids #(vec (distinct (conj % plugin-id))))
|
||||
(assoc-in [:data plugin-id] plugin))
|
||||
props (-> (:props profile)
|
||||
(assoc :plugins plugins)
|
||||
(profile/check-props-size))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
plugin)))
|
||||
|
||||
(def ^:private
|
||||
schema:remove-profile-plugin
|
||||
@ -58,18 +69,28 @@
|
||||
|
||||
(sv/defmethod ::remove-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
|
||||
[:profile-plugin-ops/global]]
|
||||
::sm/params schema:remove-profile-plugin
|
||||
::sm/result :nil
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id-str (str plugin-id)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (remove (partial = plugin-id-str) %)))
|
||||
(update :data dissoc plugin-id-str))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
nil))
|
||||
plugin-id-str (str plugin-id)]
|
||||
(if-not (or (some #(= % plugin-id-str) (:ids plugins))
|
||||
(contains? (:data plugins) plugin-id-str))
|
||||
;; Nothing to remove: no write
|
||||
nil
|
||||
(let [plugins (-> plugins
|
||||
(update :ids #(vec (remove (partial = plugin-id-str) %)))
|
||||
(update :data dissoc plugin-id-str))
|
||||
;; Raises when the remaining props still exceed the size limit
|
||||
props (-> (:props profile)
|
||||
(assoc :plugins plugins)
|
||||
(profile/check-props-size))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
nil))))
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@ -36,6 +37,7 @@
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(declare check-profile-existence!)
|
||||
(declare check-props-size)
|
||||
(declare decode-row)
|
||||
(declare filter-props)
|
||||
(declare get-profile)
|
||||
@ -283,7 +285,8 @@
|
||||
|
||||
props
|
||||
(-> (get profile :props)
|
||||
(assoc :notifications notifications))]
|
||||
(assoc :notifications notifications)
|
||||
(check-props-size))]
|
||||
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
@ -474,18 +477,46 @@
|
||||
[:map {:title "update-profile-props"}
|
||||
[:props schema:props-writeable]])
|
||||
|
||||
(def default-props-max-size
|
||||
"Default total serialized size limit (in bytes) for profile props.
|
||||
Overridable with the :profile-props-max-size config entry."
|
||||
(* 1024 1024 2)) ;; 2 MiB
|
||||
|
||||
(defn- props-size
|
||||
"Returns the serialized size in UTF-8 bytes of the props map."
|
||||
[props]
|
||||
(if props
|
||||
(alength ^bytes (t/encode props {:type :json-verbose}))
|
||||
0))
|
||||
|
||||
(defn check-props-size
|
||||
"Raises :props-too-large when props exceed the total size limit.
|
||||
Returns props unchanged so it can be threaded into the write.
|
||||
|
||||
Used by the user-facing props writers; system writers (OIDC login,
|
||||
management subscription) write fixed-key props and skip it."
|
||||
[props]
|
||||
(let [limit (cf/get :profile-props-max-size default-props-max-size)
|
||||
size (props-size props)]
|
||||
(when (> size limit)
|
||||
(ex/raise :type :validation
|
||||
:code :props-too-large
|
||||
:hint "profile props exceed maximum size"))
|
||||
props))
|
||||
|
||||
(defn update-profile-props
|
||||
[{:keys [::db/conn] :as cfg} profile-id props]
|
||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||
props (reduce-kv (fn [props k v]
|
||||
;; We don't accept namespaced keys
|
||||
(if (simple-ident? k)
|
||||
(if (nil? v)
|
||||
(dissoc props k)
|
||||
(assoc props k v))
|
||||
props))
|
||||
(:props profile)
|
||||
(apply dissoc props system-managed-props))]
|
||||
props (->> (apply dissoc props system-managed-props)
|
||||
(reduce-kv (fn [props k v]
|
||||
;; We don't accept namespaced keys
|
||||
(if (simple-ident? k)
|
||||
(if (nil? v)
|
||||
(dissoc props k)
|
||||
(assoc props k v))
|
||||
props))
|
||||
(:props profile))
|
||||
(check-props-size))]
|
||||
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
@ -658,8 +689,41 @@
|
||||
[props]
|
||||
(into {} (filter (fn [[k _]] (simple-ident? k))) props))
|
||||
|
||||
(defn- truncate-string
|
||||
"Cuts s to at most n chars without splitting a surrogate pair."
|
||||
[^String s n]
|
||||
(if (> (count s) n)
|
||||
(let [n (if (Character/isHighSurrogate (.charAt s (dec n))) (dec n) n)]
|
||||
(subs s 0 n))
|
||||
s))
|
||||
|
||||
(defn- clamp-plugin-entry
|
||||
[entry]
|
||||
(reduce-kv (fn [entry k n]
|
||||
(let [v (get entry k)]
|
||||
(if (and (string? v) (> (count v) n))
|
||||
(assoc entry k (truncate-string v n))
|
||||
entry)))
|
||||
entry
|
||||
ctp/registry-entry-max-lengths))
|
||||
|
||||
(defn clamp-plugins-registry
|
||||
"Fits a plugin registry into the registry schema caps: keeps the
|
||||
first `ctp/max-plugins` plugins with data and truncates the bounded
|
||||
strings. Applied on read, so a stored registry over the caps cannot
|
||||
keep the profile over the props size limit."
|
||||
[{:keys [ids data] :as plugins}]
|
||||
(let [ids (->> ids
|
||||
(filter #(contains? data %))
|
||||
(distinct)
|
||||
(take ctp/max-plugins)
|
||||
(vec))
|
||||
data (update-vals (select-keys data ids) clamp-plugin-entry)]
|
||||
(assoc plugins :ids ids :data data)))
|
||||
|
||||
(defn decode-row
|
||||
[{:keys [props] :as row}]
|
||||
(cond-> row
|
||||
(db/pgobject? props "jsonb")
|
||||
(assoc :props (db/decode-transit-pgobject props))))
|
||||
(assoc :props (-> (db/decode-transit-pgobject props)
|
||||
(d/update-when :plugins #(cond-> % (map? %) (clamp-plugins-registry)))))))
|
||||
|
||||
@ -6,7 +6,11 @@
|
||||
|
||||
(ns backend-tests.rpc-plugins-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[backend-tests.helpers :as th]
|
||||
@ -145,6 +149,140 @@
|
||||
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
|
||||
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
|
||||
|
||||
(t/deftest add-profile-plugin-rejects-oversized-code
|
||||
;; The merged props must not exceed :profile-props-max-size
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin}]
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
(t/deftest add-profile-plugin-rejects-oversized-code-path
|
||||
;; :code holds a manifest path, not content: overlong values are
|
||||
;; rejected by the entry schema before the props size check runs
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin (assoc valid-plugin :code (apply str (repeat 501 "x")))
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
|
||||
;; Removal shrinks props, so it passes even under a tight limit
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
|
||||
;; Seed an oversized registry while the limit is high
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
|
||||
(let [out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin})]
|
||||
(t/is (nil? (:error out)))))
|
||||
;; Removal under a tighter limit still passes: the seeded registry
|
||||
;; is oversized against it, but the remaining props fit
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 300})]
|
||||
(let [out (th/command! {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/uuid plugin-id-1)})]
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest add-profile-plugin-rejects-51st-plugin
|
||||
;; The registry holds at most 50 plugins; the 51st (new id) must fail
|
||||
(let [profile (th/create-profile* 1)]
|
||||
;; Seed 50 plugins
|
||||
(doseq [i (range 50)]
|
||||
(let [plugin (assoc valid-plugin
|
||||
:plugin-id (str (uuid/next))
|
||||
:name (str "Plugin " i))
|
||||
out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin})]
|
||||
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
|
||||
;; The 51st must fail with a specific error
|
||||
(let [extra (assoc valid-plugin
|
||||
:plugin-id (str (uuid/next))
|
||||
:name "One Too Many")
|
||||
out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin extra})]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
|
||||
;; And nothing extra was persisted
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
|
||||
|
||||
(t/deftest add-profile-plugin-updates-existing-at-limit
|
||||
;; Re-adding an existing id at the limit is an update, not a new entry
|
||||
(let [profile (th/create-profile* 1)
|
||||
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
|
||||
(doseq [[i pid] (map-indexed vector ids)]
|
||||
(th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
|
||||
(let [out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
|
||||
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
|
||||
|
||||
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
|
||||
;; A full registry plus oversized content reports the count guard,
|
||||
;; which runs before the size check
|
||||
(let [profile (th/create-profile* 1)]
|
||||
;; Seed 50 plugins under a generous limit
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
|
||||
(doseq [i (range 50)]
|
||||
(let [plugin (assoc valid-plugin
|
||||
:plugin-id (str (uuid/next))
|
||||
:name (str "Plugin " i))
|
||||
out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin})]
|
||||
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
|
||||
;; Tight limit + 51st small plugin: count wins over size
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [extra (assoc valid-plugin
|
||||
:plugin-id (str (uuid/next))
|
||||
:name "One Too Many")
|
||||
out (th/command! {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin extra})]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
|
||||
;; And nothing extra was persisted
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
|
||||
|
||||
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
|
||||
;; Removing an absent id changes nothing: no write, no size failure,
|
||||
;; and no :plugins key is manufactured
|
||||
(let [profile (th/create-profile* 1)
|
||||
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
|
||||
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [out (th/command! {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/next)})]
|
||||
(t/is (nil? (:error out)))))
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in props [:props :plugins])))
|
||||
(t/is (= big (get props :props))))))
|
||||
|
||||
(t/deftest update-profile-props-rejects-plugins
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-props
|
||||
@ -160,3 +298,86 @@
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in props [:props :plugins]))
|
||||
":plugins must not be writable via update-profile-props"))))
|
||||
|
||||
;; --- Stored registries over the caps
|
||||
|
||||
(defn- legacy-entry
|
||||
[plugin-id & {:as attrs}]
|
||||
(merge valid-plugin {:plugin-id plugin-id :code "plugin.js"} attrs))
|
||||
|
||||
(defn- legacy-registry
|
||||
[entries]
|
||||
{:ids (mapv :plugin-id entries)
|
||||
:data (into {} (map (juxt :plugin-id identity)) entries)})
|
||||
|
||||
(defn- stored-props
|
||||
"Props as stored in the database, without the read-time clamping."
|
||||
[profile-id]
|
||||
(db/decode-transit-pgobject (:props (th/db-get :profile {:id profile-id}))))
|
||||
|
||||
(t/deftest get-profile-clamps-legacy-registry
|
||||
(let [profile (th/create-profile* 1)
|
||||
big (apply str (repeat 10000 "x"))
|
||||
;; "😀" is two chars, so char 500 is a high surrogate
|
||||
emoji (str (apply str (repeat 499 "n")) "😀")
|
||||
entries (into [(legacy-entry plugin-id-1
|
||||
:name emoji
|
||||
:description big
|
||||
:host big
|
||||
:code big
|
||||
:icon big)]
|
||||
(map #(legacy-entry (str "extra-" %)))
|
||||
(range 60))
|
||||
stored (-> (legacy-registry entries)
|
||||
(update :ids conj "dangling"))]
|
||||
(th/db-update! :profile {:props (db/tjson {:plugins stored :renderer :wasm})}
|
||||
{:id (:id profile)})
|
||||
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})
|
||||
props (get-in out [:result :props])
|
||||
plugins (:plugins props)
|
||||
entry (get-in plugins [:data plugin-id-1])]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :wasm (:renderer props)) "other props are kept")
|
||||
(t/is (= (mapv :plugin-id (take ctp/max-plugins entries)) (:ids plugins))
|
||||
"keeps the first plugins in registry order, drops ids without data")
|
||||
(t/is (= (set (:ids plugins)) (set (keys (:data plugins)))))
|
||||
(t/is (= (apply str (repeat 499 "n")) (:name entry)) "does not split a surrogate pair")
|
||||
(doseq [k [:description :host :code :icon]]
|
||||
(t/is (= (get ctp/registry-entry-max-lengths k) (count (get entry k)))
|
||||
(str k " truncated to its cap")))
|
||||
(t/is (sm/validate ctp/schema:plugin-registry plugins)))
|
||||
(t/is (= stored (:plugins (stored-props (:id profile))))
|
||||
"reading does not write")))
|
||||
|
||||
(t/deftest get-profile-keeps-valid-registry
|
||||
(let [profile (th/create-profile* 1)
|
||||
stored (legacy-registry [(legacy-entry plugin-id-1)])]
|
||||
(th/db-update! :profile {:props (db/tjson {:plugins stored})} {:id (:id profile)})
|
||||
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})]
|
||||
(t/is (= stored (get-in out [:result :props :plugins]))))))
|
||||
|
||||
(t/deftest oversized-legacy-profile-recovers-on-write
|
||||
;; A stored registry over the caps pushes props past the size limit;
|
||||
;; the clamped read lets writes through and saves the clamped registry
|
||||
(let [profile (th/create-profile* 1)
|
||||
code (apply str (repeat 3000 "c"))
|
||||
entries [(legacy-entry plugin-id-1 :code code)
|
||||
(legacy-entry plugin-id-2 :code code)]]
|
||||
(th/db-update! :profile {:props (db/tjson {:plugins (legacy-registry entries)})}
|
||||
{:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 5000})]
|
||||
(t/is (thrown? Exception (profile/check-props-size (stored-props (:id profile))))
|
||||
"the stored props exceed the limit")
|
||||
(let [out (th/command! {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:workspace-visited true}})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [out (th/command! {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/uuid plugin-id-1)})]
|
||||
(t/is (nil? (:error out)))))
|
||||
(let [props (stored-props (:id profile))]
|
||||
(t/is (true? (:workspace-visited props)))
|
||||
(t/is (= [plugin-id-2] (get-in props [:plugins :ids])))
|
||||
(t/is (= 500 (count (get-in props [:plugins :data plugin-id-2 :code])))
|
||||
"the write saved the clamped registry"))))
|
||||
|
||||
@ -1549,6 +1549,111 @@
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
|
||||
(t/deftest update-profile-props-rejects-oversized-props
|
||||
;; The merged props must not exceed :profile-props-max-size
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
|
||||
(t/deftest update-profile-props-enforces-hard-limit-on-oversized-profile
|
||||
;; An already-oversized profile can only write back under the limit:
|
||||
;; shrinking below it passes, staying above it fails
|
||||
(let [profile (th/create-profile* 1)
|
||||
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
|
||||
;; Seed an already-oversized profile directly in DB (bypasses RPC validation)
|
||||
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
;; Shrinking below the limit passes
|
||||
(let [data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:onboarding-questions {:big-blob "small"}}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out))))
|
||||
;; Staying above the limit fails
|
||||
(let [data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
|
||||
(t/deftest check-props-size-measures-bytes-not-chars
|
||||
;; The limit is in UTF-8 bytes: multibyte content that fits in chars
|
||||
;; but exceeds the byte limit must be rejected
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})]
|
||||
;; 70 ASCII chars (~87 bytes serialized) passes and returns props unchanged
|
||||
(let [props {:blob (apply str (repeat 70 "x"))}]
|
||||
(t/is (= props (profile/check-props-size props))))
|
||||
;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises
|
||||
(try
|
||||
(profile/check-props-size {:blob (apply str (repeat 70 "日"))})
|
||||
(t/is false "should have thrown")
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :props-too-large (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest update-profile-props-rejects-steady-size-on-oversized-profile
|
||||
;; Same size (not smaller) on an oversized profile still exceeds
|
||||
;; the limit, so it fails
|
||||
(let [profile (th/create-profile* 1)
|
||||
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
|
||||
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
|
||||
(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile
|
||||
;; The notifications write path goes through the same size check:
|
||||
;; growing an oversized profile fails
|
||||
(let [profile (th/create-profile* 1)
|
||||
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
|
||||
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [data {::th/type :update-profile-notifications
|
||||
::rpc/profile-id (:id profile)
|
||||
:dashboard-comments :all
|
||||
:email-comments :all
|
||||
:email-invites :all}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
(t/deftest update-profile-notifications-rejects-steady-on-oversized-profile
|
||||
;; Same-size notifications write on an oversized profile still exceeds
|
||||
;; the limit, so it fails
|
||||
(let [profile (th/create-profile* 1)
|
||||
notifications {:dashboard-comments :all
|
||||
:email-comments :all
|
||||
:email-invites :all}
|
||||
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}
|
||||
:notifications notifications}]
|
||||
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
||||
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
||||
(let [data (merge {::th/type :update-profile-notifications
|
||||
::rpc/profile-id (:id profile)}
|
||||
notifications)
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
||||
|
||||
|
||||
(t/deftest prepare-register-profile-password-too-short
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "user@example.com"
|
||||
|
||||
@ -41,21 +41,34 @@
|
||||
"Schema for plugin permissions - a set of valid permission strings."
|
||||
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
|
||||
|
||||
(def max-plugins
|
||||
"Maximum number of plugins a profile can hold."
|
||||
50)
|
||||
|
||||
(def registry-entry-max-lengths
|
||||
"Maximum length (in chars) of the bounded registry entry strings.
|
||||
`:code` and `:icon` hold manifest paths, not content."
|
||||
{:name 500
|
||||
:description 4096
|
||||
:host 500
|
||||
:code 500
|
||||
:icon 500})
|
||||
|
||||
(def schema:registry-entry
|
||||
[:map
|
||||
[:plugin-id :string]
|
||||
[:version {:optional true} :int]
|
||||
[:name :string]
|
||||
[:description {:optional true} :string]
|
||||
[:host :string]
|
||||
[:code :string]
|
||||
[:icon {:optional true} :string]
|
||||
[:name [:string {:max (:name registry-entry-max-lengths)}]]
|
||||
[:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]]
|
||||
[:host [:string {:max (:host registry-entry-max-lengths)}]]
|
||||
[:code [:string {:max (:code registry-entry-max-lengths)}]]
|
||||
[:icon {:optional true} [:string {:max (:icon registry-entry-max-lengths)}]]
|
||||
[:permissions schema:permissions]])
|
||||
|
||||
(def schema:plugin-registry
|
||||
[:map
|
||||
[:ids [:vector :string]]
|
||||
[:ids [:vector {:max max-plugins} :string]]
|
||||
[:data
|
||||
[:map-of {:gen/max 5}
|
||||
[:map-of {:gen/max 5 :max max-plugins}
|
||||
:string
|
||||
schema:registry-entry]]])
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
[common-tests.types.objects-map-test]
|
||||
[common-tests.types.organization-test]
|
||||
[common-tests.types.path-data-test]
|
||||
[common-tests.types.plugins-test]
|
||||
[common-tests.types.shape-decode-encode-test]
|
||||
[common-tests.types.shape-interactions-test]
|
||||
[common-tests.types.shape-layout-test]
|
||||
@ -167,6 +168,7 @@
|
||||
'common-tests.types.objects-map-test
|
||||
'common-tests.types.organization-test
|
||||
'common-tests.types.path-data-test
|
||||
'common-tests.types.plugins-test
|
||||
'common-tests.types.shape-decode-encode-test
|
||||
'common-tests.types.shape-interactions-test
|
||||
'common-tests.types.shape-layout-test
|
||||
|
||||
64
common/test/common_tests/types/plugins_test.cljc
Normal file
64
common/test/common_tests/types/plugins_test.cljc
Normal file
@ -0,0 +1,64 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns common-tests.types.plugins-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(def ^:private valid-entry
|
||||
{:plugin-id "plugin-1"
|
||||
:name "Test Plugin"
|
||||
:description "A test plugin"
|
||||
:host "https://example.com"
|
||||
:code "(function() {})()"
|
||||
:permissions #{"content:read"}})
|
||||
|
||||
(t/deftest registry-entry-accepts-valid-plugin
|
||||
(t/is (true? (sm/validate ctp/schema:registry-entry valid-entry))))
|
||||
|
||||
(t/deftest registry-entry-rejects-oversized-code
|
||||
(t/is (false? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry
|
||||
:code (apply str (repeat 501 "x")))))))
|
||||
|
||||
(t/deftest registry-entry-rejects-oversized-name
|
||||
(t/is (false? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry :name (apply str (repeat 501 "x")))))))
|
||||
|
||||
(t/deftest registry-entry-rejects-oversized-host
|
||||
(t/is (false? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry :host (apply str (repeat 501 "x")))))))
|
||||
|
||||
(t/deftest registry-entry-rejects-oversized-description
|
||||
(t/is (false? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry :description (apply str (repeat 4097 "x")))))))
|
||||
|
||||
(t/deftest registry-entry-rejects-oversized-icon
|
||||
(t/is (false? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry :icon (apply str (repeat 501 "x")))))))
|
||||
|
||||
(t/deftest registry-entry-accepts-values-at-max
|
||||
(t/is (true? (sm/validate ctp/schema:registry-entry
|
||||
(assoc valid-entry
|
||||
:name (apply str (repeat 500 "x"))
|
||||
:host (apply str (repeat 500 "x"))
|
||||
:description (apply str (repeat 4096 "x"))
|
||||
:icon (apply str (repeat 500 "x"))
|
||||
:code (apply str (repeat 500 "x")))))))
|
||||
|
||||
(defn- make-registry
|
||||
[n]
|
||||
(let [ids (mapv #(str "plugin-" %) (range n))]
|
||||
{:ids ids
|
||||
:data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))}))
|
||||
|
||||
(t/deftest plugin-registry-accepts-fifty-plugins
|
||||
(t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50)))))
|
||||
|
||||
(t/deftest plugin-registry-rejects-more-than-fifty-plugins
|
||||
(t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51)))))
|
||||
@ -274,6 +274,7 @@ hay
|
||||
hubo
|
||||
importada
|
||||
iniciarse
|
||||
instalarla
|
||||
invitaciones
|
||||
lectores
|
||||
letras
|
||||
|
||||
@ -719,6 +719,15 @@
|
||||
:level :error
|
||||
:timeout 3000})))
|
||||
|
||||
;; Rejected profile write: notify and keep the app running
|
||||
(= code :props-too-large)
|
||||
(let [message (tr "errors.profile-props-too-large")]
|
||||
(st/async-emit!
|
||||
(ntf/show {:content message
|
||||
:type :toast
|
||||
:level :error
|
||||
:timeout 5000})))
|
||||
|
||||
(= code :snapshot-already-locked)
|
||||
(let [message (tr "errors.version-already-locked")]
|
||||
(st/async-emit!
|
||||
|
||||
@ -79,6 +79,7 @@
|
||||
.modal-msg {
|
||||
@include use-typography("body-large");
|
||||
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
|
||||
@ -806,7 +806,7 @@
|
||||
:submenu-item true
|
||||
:disabled (not can-open?))
|
||||
:on-key-down on-key-down}
|
||||
[:span {:class (stl/css :item-name)} name]
|
||||
[:span {:class (stl/css :item-name :plugin-name) :title name} name]
|
||||
(when-not can-open?
|
||||
[:span {:title (tr "workspace.plugins.error.need-editor")}
|
||||
[:> icon* {:icon-id i/help
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/mixins.scss" as *;
|
||||
|
||||
.base-menu {
|
||||
position: absolute;
|
||||
@ -70,6 +71,7 @@
|
||||
}
|
||||
|
||||
&.plugins {
|
||||
max-width: $sz-364;
|
||||
max-height: calc(100vh - $sz-200);
|
||||
overflow: hidden auto;
|
||||
}
|
||||
@ -120,6 +122,12 @@
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
@include text-ellipsis;
|
||||
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-indicator {
|
||||
--menu-indicator-color: var(--color-foreground-secondary);
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
icon))
|
||||
|
||||
(mf/defc plugin-entry*
|
||||
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}]
|
||||
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
|
||||
|
||||
(let [{:keys [plugin-id host icon name description permissions]} manifest
|
||||
plugins-permissions-peek (deref refs/plugins-permissions-peek)
|
||||
@ -86,8 +86,8 @@
|
||||
(icon-url host icon)
|
||||
(avatars/generate {:name name}))}]]
|
||||
[:div {:class (stl/css :plugin-description)}
|
||||
[:div {:class (stl/css :plugin-title)} name]
|
||||
[:div {:class (stl/css :plugin-summary)} (d/nilv description "")]]
|
||||
[:div {:class (stl/css :plugin-title) :title name} name]
|
||||
[:div {:class (stl/css :plugin-summary) :title description} (d/nilv description "")]]
|
||||
|
||||
|
||||
[:> button* {:class (stl/css :open-button)
|
||||
@ -100,6 +100,7 @@
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.plugins.remove-plugin")
|
||||
:on-click handle-delete-click
|
||||
:disabled remove-disabled
|
||||
:icon i/delete}]]))
|
||||
|
||||
(mf/defc plugin-management-dialog
|
||||
@ -126,6 +127,10 @@
|
||||
fetching-manifest?
|
||||
(mf/use-state false)
|
||||
|
||||
;; Ids with a persist in flight; their remove button is disabled
|
||||
in-flight*
|
||||
(mf/use-state #{})
|
||||
|
||||
on-url-change
|
||||
(mf/use-fn
|
||||
(fn [value]
|
||||
@ -174,13 +179,35 @@
|
||||
(mf/deps plugins-state)
|
||||
(fn [plugin-index]
|
||||
(let [plugins-list (preg/plugins-list)
|
||||
plugin (nth plugins-list plugin-index)]
|
||||
(st/emit! (ev/event {::ev/name "remove-plugin"
|
||||
:name (:name plugin)
|
||||
:host (:host plugin)}))
|
||||
(dp/close-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(reset! plugins-state* (preg/plugins-list)))))]
|
||||
plugin (nth plugins-list plugin-index)
|
||||
plugin-name (:name plugin)
|
||||
;; Truncated so long names fit the confirm dialog
|
||||
plugin-name (if (> (count plugin-name) 60)
|
||||
(str (subs plugin-name 0 60) "…")
|
||||
plugin-name)]
|
||||
(modal/show!
|
||||
{:type :confirm
|
||||
:title (tr "workspace.plugins.remove-confirmation.title")
|
||||
:message (tr "workspace.plugins.remove-confirmation.message" plugin-name)
|
||||
:accept-label (tr "workspace.plugins.remove-plugin")
|
||||
:on-accept (fn [_]
|
||||
(st/emit! (ev/event {::ev/name "remove-plugin"
|
||||
:name (:name plugin)
|
||||
:host (:host plugin)}))
|
||||
(dp/close-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(modal/show! :plugin-management {}))
|
||||
:on-cancel (fn [_]
|
||||
(modal/show! :plugin-management {}))}))))]
|
||||
|
||||
(mf/with-effect []
|
||||
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
|
||||
(partial preg/unsubscribe-in-flight! listener)))
|
||||
|
||||
;; Re-reads the list on every registry change, including rollbacks
|
||||
(mf/with-effect []
|
||||
(let [listener (preg/subscribe-registry! #(reset! plugins-state* (preg/plugins-list)))]
|
||||
(partial preg/unsubscribe-registry! listener)))
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-dialog :plugin-management)}
|
||||
@ -236,6 +263,7 @@
|
||||
:index idx
|
||||
:manifest manifest
|
||||
:user-can-edit user-can-edit?
|
||||
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
|
||||
:on-open-plugin on-open-plugin
|
||||
:on-remove-plugin on-remove-plugin}])]])]]]))
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "ds/mixins.scss" as *;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
@ -15,6 +16,7 @@
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-height: initial;
|
||||
|
||||
&.plugin-permissions {
|
||||
@ -160,17 +162,21 @@
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
@include deprecated.body-medium-typography;
|
||||
@include text-ellipsis;
|
||||
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.plugin-summary {
|
||||
@include deprecated.body-small-typography;
|
||||
@include two-line-text-ellipsis;
|
||||
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
|
||||
@ -112,6 +112,20 @@
|
||||
manifest
|
||||
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
|
||||
|
||||
(defn subscribe-registry!
|
||||
"Subscribes f, called with no arguments on every registry change.
|
||||
Returns f."
|
||||
[f]
|
||||
(add-watch registry f (fn [_ _ old new]
|
||||
(when-not (identical? old new)
|
||||
(f))))
|
||||
f)
|
||||
|
||||
(defn unsubscribe-registry!
|
||||
[f]
|
||||
(remove-watch registry f)
|
||||
nil)
|
||||
|
||||
(defn load-from-store
|
||||
[]
|
||||
(reset! registry (get-in @st/state [:profile :props :plugins] {})))
|
||||
@ -122,36 +136,128 @@
|
||||
|
||||
(declare remove-plugin!)
|
||||
|
||||
;; Plugin ids with a persist in flight; install/remove calls on them are skipped
|
||||
(defonce ^:private in-flight (atom #{}))
|
||||
|
||||
(defonce ^:private in-flight-listeners (atom #{}))
|
||||
|
||||
(defn subscribe-in-flight!
|
||||
"Subscribes f, called with the in-flight id set on every change.
|
||||
Calls f immediately with the current set. Returns f."
|
||||
[f]
|
||||
(swap! in-flight-listeners conj f)
|
||||
(f @in-flight)
|
||||
f)
|
||||
|
||||
(defn unsubscribe-in-flight!
|
||||
[f]
|
||||
(swap! in-flight-listeners disj f)
|
||||
nil)
|
||||
|
||||
(defn- notify-in-flight!
|
||||
[]
|
||||
(let [ids @in-flight]
|
||||
(doseq [f @in-flight-listeners]
|
||||
(f ids))))
|
||||
|
||||
(defn- track!
|
||||
[plugin-id]
|
||||
(swap! in-flight conj plugin-id)
|
||||
(notify-in-flight!))
|
||||
|
||||
(defn- release!
|
||||
[plugin-id]
|
||||
(swap! in-flight disj plugin-id)
|
||||
(notify-in-flight!))
|
||||
|
||||
(defn- validation-error?
|
||||
[err]
|
||||
(= :validation (:type (ex-data err))))
|
||||
|
||||
(defn- drop-local!
|
||||
[{:keys [plugin-id]}]
|
||||
(swap! registry #(-> %
|
||||
(update :ids (fn [ids] (vec (remove (partial = plugin-id) ids))))
|
||||
(update :data dissoc plugin-id))))
|
||||
|
||||
(defn- insert-at
|
||||
[ids idx id]
|
||||
(let [v (vec ids)
|
||||
idx (max 0 (min idx (count v)))]
|
||||
(vec (concat (subvec v 0 idx) [id] (subvec v idx)))))
|
||||
|
||||
(defn- restore-local!
|
||||
"Puts the stored plugin back into the registry at position idx."
|
||||
[{:keys [plugin-id] :as plugin} idx]
|
||||
(swap! registry #(-> %
|
||||
(update :ids (fn [ids]
|
||||
(insert-at (remove (partial = plugin-id) ids)
|
||||
idx
|
||||
plugin-id)))
|
||||
(assoc-in [:data plugin-id] plugin))))
|
||||
|
||||
(defn install-plugin!
|
||||
[plugin]
|
||||
(letfn [(update-ids [ids]
|
||||
(conj
|
||||
(->> ids (remove #(= % (:plugin-id plugin))))
|
||||
(:plugin-id plugin)))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data assoc (:plugin-id plugin) plugin)))
|
||||
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
|
||||
(rx/subs! identity
|
||||
(fn [err]
|
||||
(remove-plugin! plugin)
|
||||
(.error js/console "Failed to install plugin:" err))))))
|
||||
(let [plugin-id (:plugin-id plugin)
|
||||
previous (get-plugin plugin-id)
|
||||
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
|
||||
(when-not (contains? @in-flight plugin-id)
|
||||
(track! plugin-id)
|
||||
(letfn [(update-ids [ids]
|
||||
(conj
|
||||
(->> ids (remove #(= % (:plugin-id plugin))))
|
||||
(:plugin-id plugin)))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data assoc (:plugin-id plugin) plugin)))
|
||||
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
|
||||
(rx/subs! (fn [_]
|
||||
(release! plugin-id))
|
||||
(fn [err]
|
||||
(release! plugin-id)
|
||||
;; Restore the previous version in place, else drop it
|
||||
(if previous
|
||||
(restore-local! previous prev-idx)
|
||||
(drop-local! plugin))
|
||||
;; Other failures may have reached the server: undo it
|
||||
;; once by re-saving the previous version or removing
|
||||
;; the new entry, without further rollback.
|
||||
(when-not (validation-error? err)
|
||||
(->> (if previous
|
||||
(rp/cmd! :add-profile-plugin {:plugin previous})
|
||||
(rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}))
|
||||
(rx/subs! (fn [_] nil)
|
||||
(fn [err2]
|
||||
(.error js/console "Rollback failed:" err2)))))
|
||||
(.error js/console "Failed to install plugin:" err))))))))
|
||||
|
||||
(defn remove-plugin!
|
||||
[{:keys [plugin-id]}]
|
||||
(let [plugin (get-plugin plugin-id)]
|
||||
(letfn [(update-ids [ids]
|
||||
(->> ids
|
||||
(remove #(= % plugin-id))))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data dissoc plugin-id)))
|
||||
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
|
||||
(rx/subs! identity
|
||||
(fn [err]
|
||||
(when plugin
|
||||
(install-plugin! plugin))
|
||||
(.error js/console "Failed to remove plugin:" err)))))))
|
||||
(let [stored (get-plugin plugin-id)
|
||||
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
|
||||
(when-not (contains? @in-flight plugin-id)
|
||||
(track! plugin-id)
|
||||
(letfn [(update-ids [ids]
|
||||
(->> ids
|
||||
(remove #(= % plugin-id))))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data dissoc plugin-id)))
|
||||
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
|
||||
(rx/subs! (fn [_]
|
||||
(release! plugin-id))
|
||||
(fn [err]
|
||||
(release! plugin-id)
|
||||
(when stored
|
||||
;; Restore in place; validation errors keep it server-side
|
||||
(restore-local! stored prev-idx)
|
||||
;; Other failures: re-save it once, without further rollback
|
||||
(when-not (validation-error? err)
|
||||
(->> (rp/cmd! :add-profile-plugin {:plugin stored})
|
||||
(rx/subs! (fn [_] nil)
|
||||
(fn [err2]
|
||||
(.error js/console "Rollback install failed:" err2))))))
|
||||
(.error js/console "Failed to remove plugin:" err))))))))
|
||||
|
||||
(defn check-permission
|
||||
[plugin-id permission]
|
||||
|
||||
322
frontend/test/frontend_tests/plugins/register_test.cljs
Normal file
322
frontend/test/frontend_tests/plugins/register_test.cljs
Normal file
@ -0,0 +1,322 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.plugins.register-test
|
||||
(:require
|
||||
[app.main.repo :as rp]
|
||||
[app.plugins.register :as preg]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
|
||||
(defn- record-cmd-mock
|
||||
"Mock rp/cmd! that records calls in mock/rpc-calls and answers
|
||||
with (response-fn cmd params)."
|
||||
[response-fn]
|
||||
(mock/stub
|
||||
(fn [cmd params]
|
||||
(swap! mock/rpc-calls conj {:cmd cmd :params params})
|
||||
(response-fn cmd params))))
|
||||
|
||||
(defn- cmds
|
||||
[]
|
||||
(mapv :cmd @mock/rpc-calls))
|
||||
|
||||
;; --- install-plugin! ---
|
||||
|
||||
(t/deftest install-success-releases-id
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-install-ok"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin] (cmds)))
|
||||
(t/is (= plugin (preg/get-plugin "reg-install-ok")))
|
||||
;; id released: a second install issues a second RPC
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= 2 (count @mock/rpc-calls)))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-while-in-flight-issues-no-second-rpc
|
||||
(t/async done
|
||||
(let [subjects (atom [])]
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ _]
|
||||
(let [sb (rx/subject)]
|
||||
(swap! subjects conj sb)
|
||||
sb)))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-install-dedupe"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped")
|
||||
;; complete the pending call; the id is released afterwards
|
||||
(rx/push! (first @subjects) {:ok true})
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= 2 (count @mock/rpc-calls)))
|
||||
(done')))
|
||||
done))))
|
||||
|
||||
(t/deftest install-validation-error-cleans-local-only
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-install-validation"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection")
|
||||
(t/is (nil? (preg/get-plugin "reg-install-validation")))
|
||||
;; id released: the next install retries the RPC
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= 2 (count @mock/rpc-calls)))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-validation-error-restores-previous-version
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ params]
|
||||
(if (= "v2" (get-in params [:plugin :name]))
|
||||
(rx/throw (ex-info "rejected" {:type :validation}))
|
||||
(rx/of {:ok true}))))}
|
||||
(fn [done']
|
||||
(let [v1 {:plugin-id "reg-install-prev" :name "v1"}
|
||||
v2 {:plugin-id "reg-install-prev" :name "v2"}]
|
||||
(preg/install-plugin! v1)
|
||||
(preg/install-plugin! v2)
|
||||
(t/is (= 2 (count @mock/rpc-calls)))
|
||||
(t/is (= v1 (preg/get-plugin "reg-install-prev"))
|
||||
"the server-kept version is restored, not dropped")
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-validation-error-keeps-original-position
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ params]
|
||||
(if (= "v2" (get-in params [:plugin :name]))
|
||||
(rx/throw (ex-info "rejected" {:type :validation}))
|
||||
(rx/of {:ok true}))))}
|
||||
(fn [done']
|
||||
(let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"}
|
||||
v1 {:plugin-id "reg-pos-b" :name "v1"}
|
||||
v2 {:plugin-id "reg-pos-b" :name "v2"}]
|
||||
(preg/install-plugin! {:plugin-id "reg-pos-a"})
|
||||
(preg/install-plugin! v1)
|
||||
(preg/install-plugin! {:plugin-id "reg-pos-c"})
|
||||
(preg/install-plugin! v2)
|
||||
;; installs prepend, so newest-first; the rejected update
|
||||
;; must preserve order and restore v1
|
||||
(t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"]
|
||||
(filterv own (mapv :plugin-id (preg/plugins-list)))))
|
||||
(t/is (= v1 (preg/get-plugin "reg-pos-b")))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-persistent-failure-terminates
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-install-hang"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
|
||||
"one-shot rollback: no further calls")
|
||||
(t/is (nil? (preg/get-plugin "reg-install-hang")))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-non-validation-error-rolls-back-via-rpc
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :add-profile-plugin)
|
||||
(rx/throw (ex-info "boom" {:type :other}))
|
||||
(rx/of nil))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-install-rollback"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
|
||||
(t/is (nil? (preg/get-plugin "reg-install-rollback")))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest install-non-validation-error-on-update-resaves-previous
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ params]
|
||||
(if (= "v2" (get-in params [:plugin :name]))
|
||||
(rx/throw (ex-info "busy" {:type :concurrency-limit}))
|
||||
(rx/of {:ok true}))))}
|
||||
(fn [done']
|
||||
(let [own #{"reg-upd-a" "reg-upd-b" "reg-upd-c"}
|
||||
v1 {:plugin-id "reg-upd-b" :name "v1"}
|
||||
v2 {:plugin-id "reg-upd-b" :name "v2"}]
|
||||
(preg/install-plugin! {:plugin-id "reg-upd-a"})
|
||||
(preg/install-plugin! v1)
|
||||
(preg/install-plugin! {:plugin-id "reg-upd-c"})
|
||||
(reset! mock/rpc-calls [])
|
||||
(preg/install-plugin! v2)
|
||||
(t/is (= [:add-profile-plugin :add-profile-plugin] (cmds))
|
||||
"the compensating write re-saves v1, never removes it")
|
||||
(t/is (= v1 (get-in (second @mock/rpc-calls) [:params :plugin])))
|
||||
(t/is (= v1 (preg/get-plugin "reg-upd-b")))
|
||||
(t/is (= ["reg-upd-c" "reg-upd-b" "reg-upd-a"]
|
||||
(filterv own (mapv :plugin-id (preg/plugins-list)))))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
;; --- remove-plugin! ---
|
||||
|
||||
(t/deftest remove-success-releases-id
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-remove-ok"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
|
||||
(t/is (nil? (preg/get-plugin "reg-remove-ok")))
|
||||
;; id released: a second remove issues another RPC
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= 3 (count @mock/rpc-calls)))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest remove-while-in-flight-issues-no-second-rpc
|
||||
(t/async done
|
||||
(let [subjects (atom [])]
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :add-profile-plugin)
|
||||
(rx/of {:ok true})
|
||||
(let [sb (rx/subject)]
|
||||
(swap! subjects conj sb)
|
||||
sb))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-remove-dedupe"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped")
|
||||
;; complete the pending call; the id is released afterwards
|
||||
(rx/push! (first @subjects) nil)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= 3 (count @mock/rpc-calls)))
|
||||
(done')))
|
||||
done))))
|
||||
|
||||
(t/deftest remove-validation-error-restores-local-only
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :add-profile-plugin)
|
||||
(rx/of {:ok true})
|
||||
(rx/throw (ex-info "rejected" {:type :validation})))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-remove-validation"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
|
||||
"no reinstall write after validation rejection")
|
||||
(t/is (= plugin (preg/get-plugin "reg-remove-validation"))
|
||||
"local entry is restored")
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest remove-non-validation-error-reinstalls-via-rpc
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :remove-profile-plugin)
|
||||
(rx/throw (ex-info "boom" {:type :other}))
|
||||
(rx/of {:ok true}))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-remove-rollback"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)))
|
||||
(t/is (= plugin (preg/get-plugin "reg-remove-rollback")))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest remove-persistent-failure-terminates
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :add-profile-plugin)
|
||||
(rx/of {:ok true})
|
||||
(rx/throw (ex-info "boom" {:type :other})))))}
|
||||
(fn [done']
|
||||
(let [plugin {:plugin-id "reg-remove-hang"}]
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/remove-plugin! plugin)
|
||||
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))
|
||||
"one-shot rollback: no further calls")
|
||||
(t/is (= plugin (preg/get-plugin "reg-remove-hang")))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
(t/deftest remove-validation-error-keeps-original-position
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [cmd _]
|
||||
(if (= cmd :add-profile-plugin)
|
||||
(rx/of {:ok true})
|
||||
(rx/throw (ex-info "rejected" {:type :validation})))))}
|
||||
(fn [done']
|
||||
(let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"}
|
||||
plugin {:plugin-id "reg-idx-b"}]
|
||||
(preg/install-plugin! {:plugin-id "reg-idx-a"})
|
||||
(preg/install-plugin! plugin)
|
||||
(preg/install-plugin! {:plugin-id "reg-idx-c"})
|
||||
(preg/remove-plugin! plugin)
|
||||
;; installs prepend, so the order is newest-first;
|
||||
;; the failed removal must preserve it exactly
|
||||
(t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"]
|
||||
(filterv own (mapv :plugin-id (preg/plugins-list)))))
|
||||
(done')))
|
||||
done)))
|
||||
|
||||
;; --- subscribe-registry! ---
|
||||
|
||||
(t/deftest registry-subscriber-sees-rollback
|
||||
(t/async done
|
||||
(let [subjects (atom [])
|
||||
seen (atom [])
|
||||
listener (fn [] (swap! seen conj (some? (preg/get-plugin "reg-watch"))))]
|
||||
(mock/with-mocks
|
||||
{rp/cmd! (record-cmd-mock
|
||||
(fn [_ _]
|
||||
(let [sb (rx/subject)]
|
||||
(swap! subjects conj sb)
|
||||
sb)))}
|
||||
(fn [done']
|
||||
(preg/subscribe-registry! listener)
|
||||
(preg/install-plugin! {:plugin-id "reg-watch"})
|
||||
(rx/error! (first @subjects) (ex-info "rejected" {:type :validation}))
|
||||
(t/is (= [true false] @seen)
|
||||
"notified on the optimistic add and on the rollback")
|
||||
(preg/unsubscribe-registry! listener)
|
||||
(preg/install-plugin! {:plugin-id "reg-watch"})
|
||||
(t/is (= [true false] @seen) "no calls after unsubscribing")
|
||||
(done'))
|
||||
done))))
|
||||
@ -72,6 +72,7 @@
|
||||
[frontend-tests.plugins.page-active-validation-test]
|
||||
[frontend-tests.plugins.page-test]
|
||||
[frontend-tests.plugins.parser-test]
|
||||
[frontend-tests.plugins.register-test]
|
||||
[frontend-tests.plugins.shape-bugfixes-test]
|
||||
[frontend-tests.plugins.text-test]
|
||||
[frontend-tests.plugins.tokens-test]
|
||||
@ -207,6 +208,7 @@
|
||||
'frontend-tests.plugins.page-active-validation-test
|
||||
'frontend-tests.plugins.page-test
|
||||
'frontend-tests.plugins.parser-test
|
||||
'frontend-tests.plugins.register-test
|
||||
'frontend-tests.plugins.shape-bugfixes-test
|
||||
'frontend-tests.plugins.text-test
|
||||
'frontend-tests.plugins.tokens-test
|
||||
|
||||
@ -1438,11 +1438,11 @@ msgstr "Attention"
|
||||
msgid "ds.component-subtitle"
|
||||
msgstr "Components to update:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442
|
||||
#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
|
||||
msgid "ds.confirm-allow"
|
||||
msgstr "Allow"
|
||||
|
||||
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436
|
||||
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
|
||||
msgid "ds.confirm-cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
@ -1524,7 +1524,7 @@ msgstr ""
|
||||
"Clipboard access denied. Please allow clipboard permissions in your browser "
|
||||
"to paste content"
|
||||
|
||||
#: src/app/main/errors.cljs:773
|
||||
#: src/app/main/errors.cljs:782
|
||||
msgid "errors.comment-error"
|
||||
msgstr "There was an error with the comment"
|
||||
|
||||
@ -1532,21 +1532,21 @@ msgstr "There was an error with the comment"
|
||||
msgid "errors.connection-error"
|
||||
msgstr "Cannot reach the server. Check your connection and try again."
|
||||
|
||||
#: src/app/main/errors.cljs:842
|
||||
#: src/app/main/errors.cljs:851
|
||||
msgid "errors.deprecated"
|
||||
msgstr ""
|
||||
"Sorry! This is an old file that uses a deprecated type of Penpot assets and "
|
||||
"you can't open it."
|
||||
|
||||
#: src/app/main/errors.cljs:845
|
||||
#: src/app/main/errors.cljs:854
|
||||
msgid "errors.deprecated.contact.after"
|
||||
msgstr "so we can help you."
|
||||
|
||||
#: src/app/main/errors.cljs:843
|
||||
#: src/app/main/errors.cljs:852
|
||||
msgid "errors.deprecated.contact.before"
|
||||
msgstr "Although Penpot no longer support this type of Assets, you can"
|
||||
|
||||
#: src/app/main/errors.cljs:844
|
||||
#: src/app/main/errors.cljs:853
|
||||
msgid "errors.deprecated.contact.text"
|
||||
msgstr "contact us"
|
||||
|
||||
@ -1582,13 +1582,13 @@ msgstr "The email «%s» has many permanent bounce reports."
|
||||
msgid "errors.email-spam-or-permanent-bounces"
|
||||
msgstr "The email «%s» has been reported as spam or permanently bounce."
|
||||
|
||||
#: src/app/main/errors.cljs:819
|
||||
#: src/app/main/errors.cljs:828
|
||||
msgid "errors.feature-mismatch"
|
||||
msgstr ""
|
||||
"Looks like you are opening a file that has the feature '%s' enabled but the "
|
||||
"current penpot version does not supports it or has it disabled."
|
||||
|
||||
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837
|
||||
#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
|
||||
msgid "errors.feature-not-supported"
|
||||
msgstr "Feature '%s' is not supported."
|
||||
|
||||
@ -1608,7 +1608,7 @@ msgstr "Empty field"
|
||||
msgid "errors.field-not-all-whitespace"
|
||||
msgstr "The name must contain some character other than space."
|
||||
|
||||
#: src/app/main/errors.cljs:815
|
||||
#: src/app/main/errors.cljs:824
|
||||
msgid "errors.file-feature-mismatch"
|
||||
msgstr ""
|
||||
"It seems that there is a mismatch between the enabled features and the "
|
||||
@ -1621,11 +1621,11 @@ msgstr ""
|
||||
"The font family name can only contain letters, numbers, spaces, hyphens, "
|
||||
"underscores, and dots."
|
||||
|
||||
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
|
||||
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
|
||||
msgid "errors.generic"
|
||||
msgstr "Something wrong has happened."
|
||||
|
||||
#: src/app/main/errors.cljs:752
|
||||
#: src/app/main/errors.cljs:761
|
||||
msgid "errors.internal-worker-error"
|
||||
msgstr "Something wrong has happened with the web worker."
|
||||
|
||||
@ -1675,7 +1675,7 @@ msgstr "This invite might be canceled or may be expired."
|
||||
msgid "errors.ldap-disabled"
|
||||
msgstr "LDAP authentication is disabled."
|
||||
|
||||
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
|
||||
#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
|
||||
msgid "errors.max-quota-reached"
|
||||
msgstr "You have reached the '%s' quota. Contact support."
|
||||
|
||||
@ -1701,7 +1701,7 @@ msgstr "Seems that this is not a valid image."
|
||||
msgid "errors.member-is-muted"
|
||||
msgstr "The profile you inviting has emails muted (spam reports or high bounces)."
|
||||
|
||||
#: src/app/main/errors.cljs:805
|
||||
#: src/app/main/errors.cljs:814
|
||||
msgid "errors.migration-in-progress"
|
||||
msgstr "Migration in progress"
|
||||
|
||||
@ -1745,6 +1745,12 @@ msgstr "The profile is blocked"
|
||||
msgid "errors.profile-is-muted"
|
||||
msgstr "Your profile has emails muted (spam reports or high bounces)."
|
||||
|
||||
#: src/app/main/errors.cljs:724
|
||||
msgid "errors.profile-props-too-large"
|
||||
msgstr ""
|
||||
"Your profile settings are too large to save. Remove some plugins and try "
|
||||
"again."
|
||||
|
||||
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
|
||||
msgid "errors.registration-disabled"
|
||||
msgstr "The registration is currently disabled."
|
||||
@ -1759,11 +1765,11 @@ msgstr ""
|
||||
msgid "errors.save-retrying"
|
||||
msgstr "Connection lost. Retrying to save your changes."
|
||||
|
||||
#: src/app/main/errors.cljs:764
|
||||
#: src/app/main/errors.cljs:773
|
||||
msgid "errors.svg-parser.invalid-svg"
|
||||
msgstr "SVG is invalid or malformed"
|
||||
|
||||
#: src/app/main/errors.cljs:810
|
||||
#: src/app/main/errors.cljs:819
|
||||
msgid "errors.team-feature-mismatch"
|
||||
msgstr "Detected incompatible feature '%s'"
|
||||
|
||||
@ -1936,7 +1942,7 @@ msgstr "An unexpected error occurred."
|
||||
msgid "errors.unexpected-token"
|
||||
msgstr "Unknown token"
|
||||
|
||||
#: src/app/main/errors.cljs:723
|
||||
#: src/app/main/errors.cljs:732
|
||||
msgid "errors.version-already-locked"
|
||||
msgstr "This version is already locked"
|
||||
|
||||
@ -1944,7 +1950,7 @@ msgstr "This version is already locked"
|
||||
msgid "errors.version-locked"
|
||||
msgstr "This version is locked and cannot be deleted by others"
|
||||
|
||||
#: src/app/main/errors.cljs:827
|
||||
#: src/app/main/errors.cljs:836
|
||||
msgid "errors.version-not-supported"
|
||||
msgstr "File has an incompatible version number"
|
||||
|
||||
@ -8099,16 +8105,16 @@ msgstr "Snap nodes (%s)"
|
||||
msgid "workspace.plugins.button-open"
|
||||
msgstr "Open"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:215
|
||||
#: src/app/main/ui/workspace/plugins.cljs:242
|
||||
#, markdown
|
||||
msgid "workspace.plugins.discover"
|
||||
msgstr "Discover [more plugins](%s)"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:222
|
||||
#: src/app/main/ui/workspace/plugins.cljs:249
|
||||
msgid "workspace.plugins.empty-plugins"
|
||||
msgstr "No plugins installed yet"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:209
|
||||
#: src/app/main/ui/workspace/plugins.cljs:236
|
||||
msgid "workspace.plugins.error.manifest"
|
||||
msgstr "The plugin manifest is incorrect."
|
||||
|
||||
@ -8120,15 +8126,15 @@ msgstr "You need to be an editor to use this plugin"
|
||||
msgid "workspace.plugins.error.unreachable"
|
||||
msgstr "The plugin URL could not be reached."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:205
|
||||
#: src/app/main/ui/workspace/plugins.cljs:232
|
||||
msgid "workspace.plugins.error.url"
|
||||
msgstr "The plugin doesn't exist or the URL is not correct."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:201
|
||||
#: src/app/main/ui/workspace/plugins.cljs:228
|
||||
msgid "workspace.plugins.install"
|
||||
msgstr "Install"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:231
|
||||
#: src/app/main/ui/workspace/plugins.cljs:258
|
||||
msgid "workspace.plugins.installed-plugins"
|
||||
msgstr "Installed plugins"
|
||||
|
||||
@ -8140,102 +8146,112 @@ msgstr "Plugins manager"
|
||||
msgid "workspace.plugins.menu.title"
|
||||
msgstr "Plugins"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:424
|
||||
#: src/app/main/ui/workspace/plugins.cljs:452
|
||||
msgid "workspace.plugins.permissions-update.title"
|
||||
msgstr "UPDATE THIS PLUGIN"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:428
|
||||
#: src/app/main/ui/workspace/plugins.cljs:456
|
||||
msgid "workspace.plugins.permissions-update.warning"
|
||||
msgstr ""
|
||||
"The plugin has been modified since you last opened it. It now also wants to "
|
||||
"access:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:297
|
||||
#: src/app/main/ui/workspace/plugins.cljs:325
|
||||
msgid "workspace.plugins.permissions.allow-download"
|
||||
msgstr "Start file downloads."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:304
|
||||
#: src/app/main/ui/workspace/plugins.cljs:332
|
||||
msgid "workspace.plugins.permissions.allow-localstorage"
|
||||
msgstr "Store data in the browser."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:317
|
||||
#: src/app/main/ui/workspace/plugins.cljs:345
|
||||
msgid "workspace.plugins.permissions.clipboard-read"
|
||||
msgstr "Read the contents of your clipboard."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:311
|
||||
#: src/app/main/ui/workspace/plugins.cljs:339
|
||||
msgid "workspace.plugins.permissions.clipboard-write"
|
||||
msgstr "Read and write to your clipboard."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:290
|
||||
#: src/app/main/ui/workspace/plugins.cljs:318
|
||||
msgid "workspace.plugins.permissions.comment-read"
|
||||
msgstr "Read your comments and replies."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:284
|
||||
#: src/app/main/ui/workspace/plugins.cljs:312
|
||||
msgid "workspace.plugins.permissions.comment-write"
|
||||
msgstr "Read and modify your comments and reply in your name."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:257
|
||||
#: src/app/main/ui/workspace/plugins.cljs:285
|
||||
msgid "workspace.plugins.permissions.content-read"
|
||||
msgstr "Read the content of files that users have access to."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:251
|
||||
#: src/app/main/ui/workspace/plugins.cljs:279
|
||||
msgid "workspace.plugins.permissions.content-write"
|
||||
msgstr "Read and modify the content of files that users have access to."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:366
|
||||
#: src/app/main/ui/workspace/plugins.cljs:394
|
||||
msgid "workspace.plugins.permissions.disclaimer"
|
||||
msgstr ""
|
||||
"Please note that this plugin is created by an external party, so ensure you "
|
||||
"trust it before granting access. Your data privacy and security are "
|
||||
"important to us. If you have any concerns, please contact support."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:277
|
||||
#: src/app/main/ui/workspace/plugins.cljs:305
|
||||
msgid "workspace.plugins.permissions.library-read"
|
||||
msgstr "Read your libraries and assets."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:271
|
||||
#: src/app/main/ui/workspace/plugins.cljs:299
|
||||
msgid "workspace.plugins.permissions.library-write"
|
||||
msgstr "Read and modify your libraries and assets."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:359
|
||||
#: src/app/main/ui/workspace/plugins.cljs:387
|
||||
msgid "workspace.plugins.permissions.title"
|
||||
msgstr "'%s' PLUGIN WANTS ACCESS TO:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:264
|
||||
#: src/app/main/ui/workspace/plugins.cljs:292
|
||||
msgid "workspace.plugins.permissions.user-read"
|
||||
msgstr "Read the profile information of the current user."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:227
|
||||
#: src/app/main/ui/workspace/plugins.cljs:254
|
||||
msgid "workspace.plugins.plugin-list-link"
|
||||
msgstr "Plugins List"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:101
|
||||
#: src/app/main/ui/workspace/plugins.cljs:191
|
||||
msgid "workspace.plugins.remove-confirmation.message"
|
||||
msgstr ""
|
||||
"Are you sure you want to remove the plugin %s? You can install it again at "
|
||||
"any time."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:190
|
||||
msgid "workspace.plugins.remove-confirmation.title"
|
||||
msgstr "Remove plugin"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
|
||||
msgid "workspace.plugins.remove-plugin"
|
||||
msgstr "Remove plugin"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:195
|
||||
#: src/app/main/ui/workspace/plugins.cljs:222
|
||||
msgid "workspace.plugins.search-placeholder"
|
||||
msgstr "Write a plugin URL"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:188
|
||||
#: src/app/main/ui/workspace/plugins.cljs:215
|
||||
msgid "workspace.plugins.title"
|
||||
msgstr "Plugins"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:494
|
||||
#: src/app/main/ui/workspace/plugins.cljs:522
|
||||
msgid "workspace.plugins.try-out.cancel"
|
||||
msgstr "NOT NOW"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:487
|
||||
#: src/app/main/ui/workspace/plugins.cljs:515
|
||||
msgid "workspace.plugins.try-out.message"
|
||||
msgstr ""
|
||||
"Want to take a look? It will open in a new draft for your current team. (If "
|
||||
"not, you can always find it in the installed plugins of any file.)"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:483
|
||||
#: src/app/main/ui/workspace/plugins.cljs:511
|
||||
msgid "workspace.plugins.try-out.title"
|
||||
msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:500
|
||||
#: src/app/main/ui/workspace/plugins.cljs:528
|
||||
msgid "workspace.plugins.try-out.try"
|
||||
msgstr "TRY PLUGIN"
|
||||
|
||||
|
||||
@ -1452,11 +1452,11 @@ msgstr "Atención"
|
||||
msgid "ds.component-subtitle"
|
||||
msgstr "Componentes a actualizar:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442
|
||||
#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
|
||||
msgid "ds.confirm-allow"
|
||||
msgstr "Permitir"
|
||||
|
||||
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436
|
||||
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
|
||||
msgid "ds.confirm-cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
@ -1538,25 +1538,25 @@ msgstr ""
|
||||
"Acceso al portapapeles denegado. Permite el acceso al portapapeles en tu "
|
||||
"navegador para pegar contenido."
|
||||
|
||||
#: src/app/main/errors.cljs:773
|
||||
#: src/app/main/errors.cljs:782
|
||||
msgid "errors.comment-error"
|
||||
msgstr "Ha habido un error con el comentario"
|
||||
|
||||
#: src/app/main/errors.cljs:842
|
||||
#: src/app/main/errors.cljs:851
|
||||
msgid "errors.deprecated"
|
||||
msgstr ""
|
||||
"¡Lo sentimos! Este es un fichero antiguo que utiliza un tipo de recurso de "
|
||||
"Penpot obsoleto, y no puedes abrirlo."
|
||||
|
||||
#: src/app/main/errors.cljs:845
|
||||
#: src/app/main/errors.cljs:854
|
||||
msgid "errors.deprecated.contact.after"
|
||||
msgstr "para que podamos ayudarte."
|
||||
|
||||
#: src/app/main/errors.cljs:843
|
||||
#: src/app/main/errors.cljs:852
|
||||
msgid "errors.deprecated.contact.before"
|
||||
msgstr "Aunque Penpot ya no da soporte a este tipo de Recursos, puedes"
|
||||
|
||||
#: src/app/main/errors.cljs:844
|
||||
#: src/app/main/errors.cljs:853
|
||||
msgid "errors.deprecated.contact.text"
|
||||
msgstr "contactar con nosotros"
|
||||
|
||||
@ -1592,13 +1592,13 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente
|
||||
msgid "errors.email-spam-or-permanent-bounces"
|
||||
msgstr "El email «%s» tiene reportes de spam o de rebote permanente."
|
||||
|
||||
#: src/app/main/errors.cljs:819
|
||||
#: src/app/main/errors.cljs:828
|
||||
msgid "errors.feature-mismatch"
|
||||
msgstr ""
|
||||
"Parece que está abriendo un archivo que tiene la función '%s' habilitada, "
|
||||
"pero la versión actual de penpot no la admite o la tiene deshabilitada."
|
||||
|
||||
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837
|
||||
#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
|
||||
msgid "errors.feature-not-supported"
|
||||
msgstr "Caracteristica no soportada: '%s'."
|
||||
|
||||
@ -1618,7 +1618,7 @@ msgstr "Campo vacio"
|
||||
msgid "errors.field-not-all-whitespace"
|
||||
msgstr "Debe contener algún carácter diferente de espacio."
|
||||
|
||||
#: src/app/main/errors.cljs:815
|
||||
#: src/app/main/errors.cljs:824
|
||||
msgid "errors.file-feature-mismatch"
|
||||
msgstr ""
|
||||
"Parece que hay discordancia entre las features habilitadas y las features "
|
||||
@ -1631,11 +1631,11 @@ msgstr ""
|
||||
"El nombre de la familia tipográfica solo puede contener letras, números, "
|
||||
"espacios, guiones, guiones bajos y puntos."
|
||||
|
||||
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
|
||||
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
|
||||
msgid "errors.generic"
|
||||
msgstr "Ha ocurrido algún error."
|
||||
|
||||
#: src/app/main/errors.cljs:752
|
||||
#: src/app/main/errors.cljs:761
|
||||
msgid "errors.internal-worker-error"
|
||||
msgstr "Ha ocurrido un problema con el web worker."
|
||||
|
||||
@ -1687,7 +1687,7 @@ msgstr "Esta invitación puede haber sido cancelada o ha expirado."
|
||||
msgid "errors.ldap-disabled"
|
||||
msgstr "La autheticacion via LDAP esta deshabilitada."
|
||||
|
||||
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
|
||||
#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
|
||||
msgid "errors.max-quota-reached"
|
||||
msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico."
|
||||
|
||||
@ -1717,7 +1717,7 @@ msgstr ""
|
||||
"El perfil que esta invitando tiene los emails silenciados (por reportes de "
|
||||
"spam o alto índice de rebote)."
|
||||
|
||||
#: src/app/main/errors.cljs:805
|
||||
#: src/app/main/errors.cljs:814
|
||||
msgid "errors.migration-in-progress"
|
||||
msgstr "Migración en proceso"
|
||||
|
||||
@ -1765,6 +1765,12 @@ msgstr ""
|
||||
"Tu perfil tiene los emails silenciados (por reportes de spam o alto índice "
|
||||
"de rebote)."
|
||||
|
||||
#: src/app/main/errors.cljs:724
|
||||
msgid "errors.profile-props-too-large"
|
||||
msgstr ""
|
||||
"La configuración de tu perfil es demasiado grande y no se puede guardar. "
|
||||
"Elimina algunas extensiones e inténtalo de nuevo."
|
||||
|
||||
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
|
||||
msgid "errors.registration-disabled"
|
||||
msgstr "El registro está actualmente desactivado."
|
||||
@ -1776,11 +1782,11 @@ msgstr ""
|
||||
"informar del error y recarga para continuar desde los últimos cambios "
|
||||
"guardados."
|
||||
|
||||
#: src/app/main/errors.cljs:764
|
||||
#: src/app/main/errors.cljs:773
|
||||
msgid "errors.svg-parser.invalid-svg"
|
||||
msgstr "El SVG no es válido o está mal formado"
|
||||
|
||||
#: src/app/main/errors.cljs:810
|
||||
#: src/app/main/errors.cljs:819
|
||||
msgid "errors.team-feature-mismatch"
|
||||
msgstr "Detectada funcionalidad incompatible '%s'"
|
||||
|
||||
@ -1964,7 +1970,7 @@ msgstr "Ha ocurrido un error inesperado."
|
||||
msgid "errors.unexpected-token"
|
||||
msgstr "Token desconocido"
|
||||
|
||||
#: src/app/main/errors.cljs:723
|
||||
#: src/app/main/errors.cljs:732
|
||||
msgid "errors.version-already-locked"
|
||||
msgstr "Esta versión ya está bloqueada"
|
||||
|
||||
@ -1972,7 +1978,7 @@ msgstr "Esta versión ya está bloqueada"
|
||||
msgid "errors.version-locked"
|
||||
msgstr "Esta versión está bloqueada y otras personas no pueden eliminarla"
|
||||
|
||||
#: src/app/main/errors.cljs:827
|
||||
#: src/app/main/errors.cljs:836
|
||||
msgid "errors.version-not-supported"
|
||||
msgstr "El fichero tiene un número de versión incompatible"
|
||||
|
||||
@ -8169,16 +8175,16 @@ msgstr "Alinear nodos (%s)"
|
||||
msgid "workspace.plugins.button-open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:215
|
||||
#: src/app/main/ui/workspace/plugins.cljs:242
|
||||
#, markdown
|
||||
msgid "workspace.plugins.discover"
|
||||
msgstr "Descubre [más extensiones](%s)"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:222
|
||||
#: src/app/main/ui/workspace/plugins.cljs:249
|
||||
msgid "workspace.plugins.empty-plugins"
|
||||
msgstr "No se encuentran extensiones"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:209
|
||||
#: src/app/main/ui/workspace/plugins.cljs:236
|
||||
msgid "workspace.plugins.error.manifest"
|
||||
msgstr "El manifiesto de la expansión es incorrecto."
|
||||
|
||||
@ -8190,15 +8196,15 @@ msgstr "Debes ser un editor para usar este plugin"
|
||||
msgid "workspace.plugins.error.unreachable"
|
||||
msgstr "No se ha podido acceder a la URL de la extensión."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:205
|
||||
#: src/app/main/ui/workspace/plugins.cljs:232
|
||||
msgid "workspace.plugins.error.url"
|
||||
msgstr "La extensión no existe o la url no es correcta."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:201
|
||||
#: src/app/main/ui/workspace/plugins.cljs:228
|
||||
msgid "workspace.plugins.install"
|
||||
msgstr "Instalar"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:231
|
||||
#: src/app/main/ui/workspace/plugins.cljs:258
|
||||
msgid "workspace.plugins.installed-plugins"
|
||||
msgstr "Extensiones instaladas"
|
||||
|
||||
@ -8210,49 +8216,49 @@ msgstr "Gestor de extensiones"
|
||||
msgid "workspace.plugins.menu.title"
|
||||
msgstr "Extensiones"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:424
|
||||
#: src/app/main/ui/workspace/plugins.cljs:452
|
||||
msgid "workspace.plugins.permissions-update.title"
|
||||
msgstr "EXTENSIÓN ACTUALIZADA"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:428
|
||||
#: src/app/main/ui/workspace/plugins.cljs:456
|
||||
msgid "workspace.plugins.permissions-update.warning"
|
||||
msgstr ""
|
||||
"La extensión ha cambiado desde la última vez que la abriste. Ahora quiere "
|
||||
"acceder a:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:297
|
||||
#: src/app/main/ui/workspace/plugins.cljs:325
|
||||
msgid "workspace.plugins.permissions.allow-download"
|
||||
msgstr "Comenzar descargas de ficheros."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:304
|
||||
#: src/app/main/ui/workspace/plugins.cljs:332
|
||||
msgid "workspace.plugins.permissions.allow-localstorage"
|
||||
msgstr "Guardar datos en el navegador."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:317
|
||||
#: src/app/main/ui/workspace/plugins.cljs:345
|
||||
msgid "workspace.plugins.permissions.clipboard-read"
|
||||
msgstr "Leer el contenido de tu portapapeles."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:311
|
||||
#: src/app/main/ui/workspace/plugins.cljs:339
|
||||
msgid "workspace.plugins.permissions.clipboard-write"
|
||||
msgstr "Leer y escribir en tu portapapeles."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:290
|
||||
#: src/app/main/ui/workspace/plugins.cljs:318
|
||||
msgid "workspace.plugins.permissions.comment-read"
|
||||
msgstr "Leer tus comentarios y respuestas."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:284
|
||||
#: src/app/main/ui/workspace/plugins.cljs:312
|
||||
msgid "workspace.plugins.permissions.comment-write"
|
||||
msgstr "Leer y modificar tus comentarios y responder en tu nombre."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:257
|
||||
#: src/app/main/ui/workspace/plugins.cljs:285
|
||||
msgid "workspace.plugins.permissions.content-read"
|
||||
msgstr "Leer el contenido de sus archivos."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:251
|
||||
#: src/app/main/ui/workspace/plugins.cljs:279
|
||||
msgid "workspace.plugins.permissions.content-write"
|
||||
msgstr "Leer y modificar el contenido de sus archivos."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:366
|
||||
#: src/app/main/ui/workspace/plugins.cljs:394
|
||||
msgid "workspace.plugins.permissions.disclaimer"
|
||||
msgstr ""
|
||||
"Ten en cuenta que las extensiones están desarrolladas por terceros, "
|
||||
@ -8260,54 +8266,64 @@ msgstr ""
|
||||
"seguridad es importante para nosotros. Si tienes cualquier duda, contacta "
|
||||
"con soporte."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:277
|
||||
#: src/app/main/ui/workspace/plugins.cljs:305
|
||||
msgid "workspace.plugins.permissions.library-read"
|
||||
msgstr "Leer la información de sus bibliotecas y recursos."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:271
|
||||
#: src/app/main/ui/workspace/plugins.cljs:299
|
||||
msgid "workspace.plugins.permissions.library-write"
|
||||
msgstr "Leer y modificar la información de sus bibliotecas y recursos."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:359
|
||||
#: src/app/main/ui/workspace/plugins.cljs:387
|
||||
msgid "workspace.plugins.permissions.title"
|
||||
msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:264
|
||||
#: src/app/main/ui/workspace/plugins.cljs:292
|
||||
msgid "workspace.plugins.permissions.user-read"
|
||||
msgstr "Leer la información del usuario actual."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:227
|
||||
#: src/app/main/ui/workspace/plugins.cljs:254
|
||||
msgid "workspace.plugins.plugin-list-link"
|
||||
msgstr "Lista de extensiones"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:101
|
||||
#: src/app/main/ui/workspace/plugins.cljs:191
|
||||
msgid "workspace.plugins.remove-confirmation.message"
|
||||
msgstr ""
|
||||
"¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en "
|
||||
"cualquier momento."
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:190
|
||||
msgid "workspace.plugins.remove-confirmation.title"
|
||||
msgstr "Eliminar extensión"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
|
||||
msgid "workspace.plugins.remove-plugin"
|
||||
msgstr "Eliminar extensión"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:195
|
||||
#: src/app/main/ui/workspace/plugins.cljs:222
|
||||
msgid "workspace.plugins.search-placeholder"
|
||||
msgstr "Intruduzca URL de la extensión"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:188
|
||||
#: src/app/main/ui/workspace/plugins.cljs:215
|
||||
msgid "workspace.plugins.title"
|
||||
msgstr "Extensiones"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:494
|
||||
#: src/app/main/ui/workspace/plugins.cljs:522
|
||||
msgid "workspace.plugins.try-out.cancel"
|
||||
msgstr "AHORA NO"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:487
|
||||
#: src/app/main/ui/workspace/plugins.cljs:515
|
||||
msgid "workspace.plugins.try-out.message"
|
||||
msgstr ""
|
||||
"¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo "
|
||||
"actual. (Si no, puedes encontrar los plugins instalados en cualquier "
|
||||
"fichero.)"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:483
|
||||
#: src/app/main/ui/workspace/plugins.cljs:511
|
||||
msgid "workspace.plugins.try-out.title"
|
||||
msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!"
|
||||
|
||||
#: src/app/main/ui/workspace/plugins.cljs:500
|
||||
#: src/app/main/ui/workspace/plugins.cljs:528
|
||||
msgid "workspace.plugins.try-out.try"
|
||||
msgstr "PROBAR PLUGIN"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user