Merge remote-tracking branch 'origin/main-staging'

This commit is contained in:
Andrey Antukh 2026-04-28 12:37:02 +02:00
commit 42c9c4a929
212 changed files with 7603 additions and 3312 deletions

View File

@ -1,5 +1,18 @@
# CHANGELOG
## 2.15.0 (Unreleased)
### :sparkles: New features & Enhancements
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
### :bug: Bugs fixed
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
## 2.14.4
### :bug: Bugs fixed
@ -100,6 +113,8 @@
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
### :bug: Bugs fixed

View File

@ -83,7 +83,52 @@ are config maps with `::ig/ref` for dependencies. Components implement
`ig/init-key` / `ig/halt-key!`.
### Database Access
### Connecting to the Database
Two PostgreSQL databases are used in this environment:
| Database | Purpose | Connection string |
|---------------|--------------------|----------------------------------------------------|
| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` |
| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` |
**Interactive psql session:**
```bash
# development DB
psql "postgresql://penpot:penpot@postgres/penpot"
# test DB
psql "postgresql://penpot:penpot@postgres/penpot_test"
```
**One-shot query (non-interactive):**
```bash
psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;"
```
**Useful psql meta-commands:**
```
\dt -- list all tables
\d <table> -- describe a table (columns, types, constraints)
\di -- list indexes
\q -- quit
```
> **Migrations table:** Applied migrations are tracked in the `migrations` table
> with columns `module`, `step`, and `created_at`. When renaming a migration
> logical name, update this table in both databases to match the new name;
> otherwise the runner will attempt to re-apply the migration on next startup.
```bash
# Example: fix a renamed migration entry in the test DB
psql "postgresql://penpot:penpot@postgres/penpot_test" \
-c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';"
```
### Database Access (Clojure)
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
@ -146,3 +191,69 @@ optimized implementations:
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
:enable-smtp)`.
### Background Tasks
Background tasks live in `src/app/tasks/`. Each task is an Integrant component
that exposes a `::handler` key and follows this three-method pattern:
```clojure
(defmethod ig/assert-key ::handler ;; validate config at startup
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/expand-key ::handler ;; inject defaults before init
[k v]
{k (assoc v ::my-option default-value)})
(defmethod ig/init-key ::handler ;; return the task fn
[_ cfg]
(fn [_task] ;; receives the task row from the worker
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
;; … do work …
))))
```
**Wiring a new task** requires two changes in `src/app/main.clj`:
1. **Handler config** add an entry in `system-config` with the dependencies:
```clojure
:app.tasks.my-task/handler
{::db/pool (ig/ref ::db/pool)}
```
2. **Registry + cron** register the handler name and schedule it:
```clojure
;; in ::wrk/registry ::wrk/tasks map:
:my-task (ig/ref :app.tasks.my-task/handler)
;; in worker-config ::wrk/cron ::wrk/entries vector:
{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight
:task :my-task}
```
**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow):
| Expression | Meaning |
|------------------------------|--------------------|
| `"0 0 0 * * ?"` | Daily at midnight |
| `"0 0 */6 * * ?"` | Every 6 hours |
| `"0 */5 * * * ?"` | Every 5 minutes |
**Time helpers** (`app.common.time`):
```clojure
(ct/now) ;; current instant
(ct/duration {:hours 1}) ;; java.time.Duration
(ct/minus (ct/now) some-duration) ;; subtract duration from instant
```
`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL
interval object suitable for use in SQL queries:
```clojure
(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds"
```

View File

@ -82,7 +82,10 @@
:initial-project-skey "initial-project"
;; time to avoid email sending after profile modification
:email-verify-threshold "15m"})
:email-verify-threshold "15m"
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20})
(def schema:config
(do #_sm/optional-keys
@ -154,6 +157,8 @@
[:quotes-snapshots-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]

View File

@ -388,6 +388,7 @@
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler)
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
:session-gc (ig/ref ::session.tasks/gc)
@ -423,6 +424,9 @@
:app.tasks.tasks-gc/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.upload-session-gc/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.objects-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
@ -544,6 +548,9 @@
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :tasks-gc}
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :upload-session-gc}
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
:task :file-gc-scheduler}

View File

@ -463,8 +463,13 @@
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}])
:fn mg0145/migrate}
{:name "0146-mod-access-token-table"
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
{:name "0147-add-upload-session-table"
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,2 @@
ALTER TABLE access_token
ADD COLUMN type text NULL;

View File

@ -0,0 +1,14 @@
CREATE TABLE upload_session (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
total_chunks integer NOT NULL
);
CREATE INDEX upload_session__profile_id__idx
ON upload_session(profile_id);
CREATE INDEX upload_session__created_at__idx
ON upload_session(created_at);

View File

@ -92,6 +92,7 @@
(fn [{:keys [params path-params method] :as request}]
(let [handler-name (:method-name path-params)
etag (yreq/get-header request "if-none-match")
session-id (yreq/get-header request "x-session-id")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request)
@ -104,6 +105,7 @@
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::session-id (some-> session-id uuid/parse*))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))

View File

@ -23,7 +23,7 @@
(dissoc row :perms))
(defn create-access-token
[{:keys [::db/conn] :as cfg} profile-id name expiration]
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
@ -36,6 +36,7 @@
{:id token-id
:name name
:token token
:type type
:profile-id profile-id
:created-at created-at
:updated-at created-at
@ -50,17 +51,18 @@
(def ^:private schema:create-access-token
[:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]]
[:expiration {:optional true} ::ct/duration]])
[:expiration {:optional true} ::ct/duration]
[:type {:optional true} :string]])
(sv/defmethod ::create-access-token
{::doc/added "1.18"
::sm/params schema:create-access-token}
[cfg {:keys [::rpc/profile-id name expiration]}]
[cfg {:keys [::rpc/profile-id name expiration type]}]
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id})
(db/tx-run! cfg create-access-token profile-id name expiration))
(db/tx-run! cfg create-access-token profile-id name expiration type))
(def ^:private schema:delete-access-token
[:map {:title "delete-access-token"}
@ -83,5 +85,22 @@
(->> (db/query pool :access-token
{:profile-id profile-id}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :created-at :updated-at :expires-at]})
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
(mapv decode-row)))
(def ^:private schema:get-current-mcp-token
[:map {:title "get-current-mcp-token"}])
(sv/defmethod ::get-current-mcp-token
{::doc/added "2.15"
::sm/params schema:get-current-mcp-token}
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
(->> (db/query pool :access-token
{:profile-id profile-id
:type "mcp"}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:token :expires-at]})
(remove #(and (some? (:expires-at %))
(ct/is-after? request-at (:expires-at %))))
(map decode-row)
(first)))

View File

@ -22,6 +22,7 @@
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :as media-cmd]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
@ -80,20 +81,33 @@
;; --- Command: import-binfile
(defn- import-binfile
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}]
(let [team (teams/get-team pool
:profile-id profile-id
:project-id project-id)
cfg (-> cfg
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
(assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id)
(assoc ::bfc/name name)
(assoc ::bfc/input (:path file)))
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}]
(let [team
(teams/get-team pool
:profile-id profile-id
:project-id project-id)
result (case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg))]
cfg
(-> cfg
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
(assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id)
(assoc ::bfc/name name))
input-path (:path file)
owned? (some? upload-id)
cfg
(assoc cfg ::bfc/input input-path)
result
(try
(case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg))
(finally
(when owned?
(fs/delete input-path))))]
(db/update! pool :project
{:modified-at (ct/now)}
@ -103,13 +117,18 @@
result))
(def ^:private schema:import-binfile
[:map {:title "import-binfile"}
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:version {:optional true} ::sm/int]
[:file media/schema:upload]])
[:and
[:map {:title "import-binfile"}
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:version {:optional true} ::sm/int]
[:file {:optional true} media/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
[:fn {:error/message "one of :file or :upload-id is required"}
(fn [{:keys [file upload-id]}]
(or (some? file) (some? upload-id)))]])
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format. If `file-id` is provided,
@ -117,28 +136,40 @@
The in-place imports are only supported for binfile-v3 and when a
.penpot file only contains one penpot file.
The file content may be provided either as a multipart `file` upload
or as an `upload-id` referencing a completed chunked-upload session,
which allows importing files larger than the multipart size limit.
"
{::doc/added "1.15"
::doc/changes ["1.20" "Add file-id param for in-place import"
"1.20" "Set default version to 3"]
"1.20" "Set default version to 3"
"2.15" "Add upload-id param for chunked upload support"]
::webhooks/event? true
::sse/stream? true
::sm/params schema:import-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
(let [version (or version 3)
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))
manifest (case (int version)
1 nil
3 (bf.v3/get-manifest (:path file)))]
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
(assoc params :file file))
params)
manifest
(case (int version)
1 nil
3 (bf.v3/get-manifest (-> params :file :path)))]
(with-meta
(sse/response (partial import-binfile cfg params))

View File

@ -71,7 +71,7 @@
{::doc/added "1.20"
::sm/params schema:restore-file-snapshot
::db/transaction true}
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
(files/check-edition-permissions! conn profile-id file-id)
(let [file (bfc/get-file cfg file-id)
team (teams/get-team conn
@ -88,7 +88,8 @@
;; Send to the clients a notification to reload the file
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-restore
:message {:type :file-restored
:session-id session-id
:file-id (:id file)
:vern vern})
nil)))

View File

@ -7,9 +7,11 @@
(ns app.rpc.commands.media
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as-alias audit]
[app.media :as media]
@ -17,8 +19,13 @@
[app.rpc.climit :as climit]
[app.rpc.commands.files :as files]
[app.rpc.doc :as-alias doc]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.util.services :as sv]))
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[datoteka.io :as io])
(:import
java.io.OutputStream))
(def thumbnail-options
{:width 100
@ -236,3 +243,182 @@
:width (:width mobj)
:height (:height mobj)
:mtype (:mtype mobj)})))
;; --- Chunked Upload: Create an upload session
(def ^:private schema:create-upload-session
[:map {:title "create-upload-session"}
[:total-chunks ::sm/int]])
(def ^:private schema:create-upload-session-result
[:map {:title "create-upload-session-result"}
[:session-id ::sm/uuid]])
(sv/defmethod ::create-upload-session
{::doc/added "2.16"
::sm/params schema:create-upload-session
::sm/result schema:create-upload-session-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id total-chunks]}]
(let [max-chunks (cf/get :quotes-upload-chunks-per-session)]
(when (> total-chunks max-chunks)
(ex/raise :type :restriction
:code :max-quote-reached
:target "upload-chunks-per-session"
:quote max-chunks
:count total-chunks)))
(quotes/check! cfg {::quotes/id ::quotes/upload-sessions-per-profile
::quotes/profile-id profile-id})
(let [session-id (uuid/next)]
(db/insert! pool :upload-session
{:id session-id
:profile-id profile-id
:total-chunks total-chunks})
{:session-id session-id}))
;; --- Chunked Upload: Upload a single chunk
(def ^:private schema:upload-chunk
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
[:index ::sm/int]
[:content media/schema:upload]])
(def ^:private schema:upload-chunk-result
[:map {:title "upload-chunk-result"}
[:session-id ::sm/uuid]
[:index ::sm/int]])
(sv/defmethod ::upload-chunk
{::doc/added "2.16"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id index content] :as _params}]
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
(when (or (neg? index) (>= index (:total-chunks session)))
(ex/raise :type :validation
:code :invalid-chunk-index
:hint "chunk index is out of range for this session"
:session-id session-id
:total-chunks (:total-chunks session)
:index index)))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket "tempfile"
:upload-id (str session-id)
:chunk-index index}))
{:session-id session-id
:index index})
;; --- Chunked Upload: shared helpers
(def ^:private sql:get-upload-chunks
"SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index
FROM storage_object
WHERE (metadata->>'~:upload-id') = ?::text
AND deleted_at IS NULL
ORDER BY (metadata->>'~:chunk-index')::integer ASC")
(defn- get-upload-chunks
[conn session-id]
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
(defn- concat-chunks
"Reads all chunk storage objects in order and writes them to a single
temporary file on the local filesystem. Returns a path to that file."
[storage chunks]
(let [tmp (tmp/tempfile :prefix "penpot.chunked-upload.")]
(with-open [^OutputStream out (io/output-stream tmp)]
(doseq [{:keys [id]} chunks]
(let [sobj (sto/get-object storage id)
bytes (sto/get-object-bytes storage sobj)]
(.write out ^bytes bytes))))
tmp))
(defn assemble-chunks
"Validates that all expected chunks are present for `session-id` and
concatenates them into a single temporary file. Returns a map
conforming to `media/schema:upload` with `:filename`, `:path` and
`:size`.
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} session-id]
(let [session (db/get conn :upload-session {:id session-id})
chunks (get-upload-chunks conn session-id)]
(when (not= (count chunks) (:total-chunks session))
(ex/raise :type :validation
:code :missing-chunks
:hint "number of stored chunks does not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
(let [storage (sto/resolve cfg ::db/reuse-conn true)
path (concat-chunks storage chunks)
size (reduce #(+ %1 (:size %2)) 0 chunks)]
(db/delete! conn :upload-session {:id session-id})
{:filename "upload"
:path path
:size size})))
;; --- Chunked Upload: Assemble all chunks into a final media object
(def ^:private schema:assemble-file-media-object
[:map {:title "assemble-file-media-object"}
[:session-id ::sm/uuid]
[:file-id ::sm/uuid]
[:is-local ::sm/boolean]
[:name [:string {:max 250}]]
[:mtype :string]
[:id {:optional true} ::sm/uuid]])
(sv/defmethod ::assemble-file-media-object
{::doc/added "2.16"
::sm/params schema:assemble-file-media-object
::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}]
(files/check-edition-permissions! pool profile-id file-id)
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [path size]} (assemble-chunks cfg session-id)
content {:filename "upload"
:size size
:path path
:mtype mtype}
_ (media/validate-media-type! content)
mobj (create-file-media-object cfg (assoc params
:id (or id (uuid/next))
:content content))]
(db/update! conn :file
{:modified-at (ct/now)
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(with-meta mobj
{::audit/replace-props
{:name name
:file-id file-id
:is-local is-local
:mtype mtype}})))))

View File

@ -48,6 +48,7 @@
(def schema:props
[:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry]
[:mcp-enabled {:optional true} ::sm/boolean]
[:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid]

View File

@ -522,6 +522,30 @@
(assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:upload-sessions-per-profile
[:map [::profile-id ::sm/uuid]])
(def ^:private valid-upload-sessions-per-profile-quote?
(sm/lazy-validator schema:upload-sessions-per-profile))
(def ^:private sql:get-upload-sessions-per-profile
"SELECT count(*) AS total
FROM upload_session
WHERE profile_id = ?")
(defmethod check-quote ::upload-sessions-per-profile
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -149,7 +149,7 @@
:status "delete"
:bucket bucket)
(recur to-freeze (conj to-delete id) (rest objects))))
(let [deletion-delay (if (= bucket "tempfile")
(let [deletion-delay (if (= "tempfile" bucket)
(ct/duration {:hours 2})
(cf/get-deletion-delay))]
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
@ -213,8 +213,13 @@
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid storage"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(process-touched! (assoc cfg ::timestamp (ct/now)))))
(defmethod ig/expand-key ::handler
[k v]
{k (merge {::min-age (ct/duration {:hours 2})} v)})
(defmethod ig/init-key ::handler
[_ {:keys [::min-age] :as cfg}]
(fn [_]
(let [threshold (ct/minus (ct/now) min-age)]
(process-touched! (assoc cfg ::timestamp threshold)))))

View File

@ -0,0 +1,41 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.tasks.upload-session-gc
"A maintenance task that deletes stalled (incomplete) upload sessions.
An upload session is considered stalled when it was created more than
`max-age` ago without being completed (i.e. the session row still
exists because `assemble-chunks` was never called to clean it up).
The default max-age is 1 hour."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[integrant.core :as ig]))
(def ^:private sql:delete-stalled-sessions
"DELETE FROM upload_session
WHERE created_at < ?::timestamptz")
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/expand-key ::handler
[k v]
{k (merge {::max-age (ct/duration {:hours 1})} v)})
(defmethod ig/init-key ::handler
[_ {:keys [::max-age] :as cfg}]
(fn [_]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [threshold (ct/minus (ct/now) max-age)
result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold])
(db/get-update-count))]
(l/debug :hint "task finished" :deleted result)
{:deleted result})))))

View File

@ -102,7 +102,7 @@
(t/deftest access-token-authz
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
(let [response (handler nil)]

View File

@ -107,4 +107,18 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [results (:result out)]
(t/is (= 2 (count results))))))))
(t/is (= 2 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
;; (th/print-result! result)
(t/is (nil? error))
(t/is (string? (:token result)))))))

View File

@ -312,7 +312,8 @@
;; freeze because of the deduplication (we have uploaded 2 times
;; the same files).
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@ -386,7 +387,8 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@ -571,7 +573,8 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted.
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@ -664,7 +667,8 @@
;; because of the deduplication (we have uploaded 2 times the
;; same files).
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:freeze res)))
(t/is (= 0 (:delete res))))
@ -714,7 +718,8 @@
;; Now that objects-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! "storage-gc-touched" {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! "storage-gc-touched" {}))]
(t/is (= 1 (:freeze res))))
;; check file media objects
@ -749,7 +754,8 @@
;; Now that file-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:delete res))))
;; check file media objects
@ -1319,7 +1325,8 @@
;; The FileGC task will schedule an inner taskq
(th/run-pending-tasks!)
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@ -1413,7 +1420,8 @@
;; we ensure that once object-gc is passed and marked two storage
;; objects to delete
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))

View File

@ -85,7 +85,7 @@
(t/is (map? (:result out))))
;; run the task again
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! "storage-gc-touched" {}))]
(t/is (= 2 (:freeze res))))
@ -136,7 +136,7 @@
(t/is (some? (sto/get-object storage (:media-id row2))))
;; run the task again
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:delete res)))
(t/is (= 0 (:freeze res))))
@ -235,7 +235,8 @@
(t/is (= (:object-id data1) (:object-id row)))
(t/is (uuid? (:media-id row1))))
(let [result (th/run-task! :storage-gc-touched {})]
(let [result (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:delete result))))
;; Check if storage objects still exists after file-gc

View File

@ -130,7 +130,8 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@ -142,14 +143,16 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(t/is (= 2 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res)))))))
@ -191,7 +194,8 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@ -203,14 +207,16 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
@ -220,57 +226,42 @@
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))
params1 {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff" data1}}
params2 {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 500
:font-style "normal"
:data {"font/woff" data2}}
data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))
params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 400 :font-style "normal" :data {"font/woff" data1}}
params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 500 :font-style "normal" :data {"font/woff" data2}}
out1 (th/command! params1)
out2 (th/command! params2)]
;; (th/print-result! out1)
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
(let [res (th/run-task! :storage-gc-touched {})]
;; freeze with hours 3 clock
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:id (-> out1 :result :id)}
(let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :id (-> out1 :result :id)}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
;; no-op with hours 3 clock (nothing touched yet)
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))

View File

@ -6,9 +6,7 @@
(ns backend-tests.rpc-media-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.http.client :as http]
[app.media :as media]
[app.rpc :as-alias rpc]
@ -16,7 +14,10 @@
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[mockery.core :refer [with-mocks]]))
[datoteka.io :as io]
[mockery.core :refer [with-mocks]])
(:import
java.io.RandomAccessFile))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -260,7 +261,7 @@
:is-shared false})
_ (th/db-update! :file
{:deleted-at (ct/now)}
{:deleted-at (app.common.time/now)}
{:id (:id file)})
mfile {:filename "sample.jpg"
@ -378,3 +379,325 @@
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
;; --------------------------------------------------------------------
;; Helpers for chunked-upload tests
;; --------------------------------------------------------------------
(defn- split-file-into-chunks
"Splits the file at `path` into byte-array chunks of at most
`chunk-size` bytes. Returns a vector of byte arrays."
[path chunk-size]
(let [file (RandomAccessFile. (str path) "r")
length (.length file)]
(try
(loop [offset 0 chunks []]
(if (>= offset length)
chunks
(let [remaining (- length offset)
size (min chunk-size remaining)
buf (byte-array size)]
(.seek file offset)
(.readFully file buf)
(recur (+ offset size) (conj chunks buf)))))
(finally
(.close file)))))
(defn- make-chunk-mfile
"Writes `data` (byte array) to a tempfile and returns a map
compatible with `media/schema:upload`."
[data mtype]
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")]
(io/write* tmp data)
{:filename "chunk"
:path tmp
:mtype mtype
:size (alength data)}))
;; --------------------------------------------------------------------
;; Chunked-upload tests
;; --------------------------------------------------------------------
(defn- create-session!
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
[prof total-chunks]
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks total-chunks})]
(t/is (nil? (:error out)))
(:session-id (:result out))))
(t/deftest chunked-upload-happy-path
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000) ; ~107 KB each
mtype "image/jpeg"
total-size (reduce + (map alength chunks))
session-id (create-session! prof (count chunks))]
(t/is (= 3 (count chunks)))
;; --- 1. Upload chunks ---
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))
(t/is (= session-id (:session-id (:result out))))
(t/is (= idx (:index (:result out))))))
;; --- 2. Assemble ---
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype mtype})]
(t/is (nil? (:error assemble-out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= mtype (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= total-size (:size mobj1))))))))
(t/deftest chunked-upload-idempotency
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
media-id (uuid/next)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file
mtype "image/jpeg"
mfile (make-chunk-mfile (first chunks) mtype)
session-id (create-session! prof 1)]
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; First assemble succeeds; session row is deleted afterwards
(let [out1 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "sample"
:mtype mtype
:id media-id})]
(t/is (nil? (:error out1)))
(t/is (= media-id (:id (:result out1)))))
;; Second assemble with the same session-id must fail because the
;; session row has been deleted after the first assembly
(let [out2 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "sample"
:mtype mtype
:id media-id})]
(t/is (some? (:error out2)))
(t/is (= :not-found (-> out2 :error ex-data :type)))
(t/is (= :object-not-found (-> out2 :error ex-data :code))))))
(t/deftest chunked-upload-no-permission
;; A second profile must not be able to upload chunks into a session
;; that belongs to another profile: the DB lookup includes profile-id,
;; so the session will not be found.
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
;; prof2 tries to upload a chunk into prof1's session
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof2)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; Assemble with a wrong mtype should fail validation
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "bad-type"
:mtype "application/octet-stream"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))))
(t/deftest chunked-upload-missing-chunks
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
;; Session expects 3 chunks
session-id (create-session! prof 3)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; Upload only 1 chunk
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; Assemble: session says 3 expected, only 1 stored → :missing-chunks
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "incomplete"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-session-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
bogus-id (uuid/next)]
;; Assemble with a session-id that was never created
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id bogus-id
:file-id (:id file)
:is-local true
:name "ghost"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-over-chunk-limit
;; Verify that requesting more chunks than the configured maximum
;; (quotes-upload-chunks-per-session) raises a :restriction error.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-chunks-per-session 3})}]
(let [prof (th/create-profile* 1)
out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 4})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
(t/deftest chunked-upload-invalid-chunk-index
;; Both a negative index and an index >= total-chunks must be
;; rejected with a :validation / :invalid-chunk-index error.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; index == total-chunks (out of range)
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 2
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))
;; negative index
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index -1
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
(t/deftest chunked-upload-sessions-per-profile-quota
;; With the session limit set to 2, creating a third session for the
;; same profile must fail with :restriction / :max-quote-reached.
;; The :quotes flag is already enabled by the test fixture.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-sessions-per-profile 2})}]
(let [prof (th/create-profile* 1)]
;; First two sessions succeed
(create-session! prof 1)
(create-session! prof 1)
;; Third session must be rejected
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))

View File

@ -169,7 +169,8 @@
(t/is (= 2 (:count res))))
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@ -229,7 +230,8 @@
(t/is (nil? (:error out2)))
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 5 (:freeze res)))
(t/is (= 0 (:delete res)))
@ -249,7 +251,8 @@
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])
;; Run the task again
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 3 (:delete res))))
@ -295,7 +298,8 @@
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@ -310,7 +314,8 @@
(t/is (= 2 (:processed res))))
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@ -336,7 +341,7 @@
(t/is (= 0 (:delete res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))]
(binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res)))))

View File

@ -152,7 +152,9 @@
:redis-cache
;; Activates the nitrate module
:nitrate})
:nitrate
:mcp})
(def all-flags
(set/union email login varia))

View File

@ -13,3 +13,10 @@
unit tests or backend code for logs or error messages."
[key & _args]
key)
(defn c
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[x]
x)

View File

@ -0,0 +1,105 @@
;; 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.schema.messages
(:require
[app.common.data :as d]
[app.common.i18n :as i18n :refer [tr]]
[app.common.schema :as sm]
[malli.core :as m]))
;; --- Handlers Helpers
(defn- translate-code
[code]
(if (vector? code)
(tr (nth code 0) (i18n/c (nth code 1)))
(tr code)))
(defn- handle-error-fn
[props problem]
(let [v-fn (:error/fn props)
result (v-fn problem)]
(if (string? result)
{:message result}
{:message (or (some-> (get result :code)
(translate-code))
(get result :message)
(tr "errors.invalid-data"))})))
(defn- handle-error-message
[props]
{:message (get props :error/message)})
(defn- handle-error-code
[props]
(let [code (get props :error/code)]
{:message (translate-code code)}))
(defn interpret-schema-problem
[acc {:keys [schema in value type] :as problem}]
(let [props (m/properties schema)
tprops (m/type-properties schema)
field (or (:error/field props)
in)
field (if (vector? field)
field
[field])]
(if (and (= 1 (count field))
(contains? acc (first field)))
acc
(cond
(or (nil? field)
(empty? field))
acc
(or (= type :malli.core/missing-key)
(nil? value))
(assoc-in acc field {:message (tr "errors.field-missing")})
;; --- CHECK on schema props
(contains? props :error/fn)
(assoc-in acc field (handle-error-fn props problem))
(contains? props :error/message)
(assoc-in acc field (handle-error-message props))
(contains? props :error/code)
(assoc-in acc field (handle-error-code props))
;; --- CHECK on type props
(contains? tprops :error/fn)
(assoc-in acc field (handle-error-fn tprops problem))
(contains? tprops :error/message)
(assoc-in acc field (handle-error-message tprops))
(contains? tprops :error/code)
(assoc-in acc field (handle-error-code tprops))
:else
(assoc-in acc field {:message (tr "errors.invalid-data")})))))
(defn- apply-validators
[validators state errors]
(reduce (fn [errors validator-fn]
(merge errors (validator-fn errors (:data state))))
errors
validators))
(defn collect-schema-errors
[schema validators state]
(let [explain (sm/explain schema (:data state))
errors (->> (reduce interpret-schema-problem {} (:errors explain))
(apply-validators validators state))]
(-> (:errors state)
(merge errors)
(d/without-nils)
(not-empty))))

View File

@ -242,17 +242,19 @@
(update-token- [this token-id f]
(assert (uuid? token-id) "expected uuid for `token-id`")
(if-let [token (get-token- this token-id)]
(let [token' (-> (make-token (f token))
(assoc :modified-at (ct/now)))]
(TokenSet. id
name
description
(ct/now)
(if (= (:name token) (:name token'))
(assoc tokens (:name token') token')
(-> tokens
(d/oassoc-before (:name token) (:name token') token')
(dissoc (:name token))))))
(let [token' (f token)]
(if (not= token token')
(let [token' (assoc token' :modified-at (ct/now))]
(TokenSet. id
name
description
(ct/now)
(if (= (:name token) (:name token'))
(assoc tokens (:name token') token')
(-> tokens
(d/oassoc-before (:name token) (:name token') token')
(dissoc (:name token))))))
this))
this))
(delete-token- [this token-id]
@ -303,6 +305,35 @@
(-clj->js [this]
(clj->js (datafy this)))))
(def ^:private set-prefix "S-")
(def ^:private set-group-prefix "G-")
(def ^:private set-separator "/")
(defn get-set-path
[token-set]
(cpn/split-path (get-name token-set) :separator set-separator))
(defn split-set-name
[name]
(cpn/split-path name :separator set-separator))
(defn join-set-path [path]
(cpn/join-path path :separator set-separator :with-spaces? false))
(defn normalize-set-name
"Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set').
If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name."
([name]
(-> (split-set-name (str name))
(cpn/join-path :separator set-separator :with-spaces? false)))
([name relative-to]
(-> (concat (butlast (split-set-name relative-to))
(split-set-name (str name)))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn token-set?
[o]
(instance? TokenSet o))
@ -357,6 +388,7 @@
(def check-token-set
(sm/check-fn schema:token-set :hint "expected valid token set"))
(defn map->token-set
[& {:as attrs}]
(TokenSet. (:id attrs)
@ -372,38 +404,10 @@
(update :modified-at #(or % (ct/now)))
(update :tokens #(into (d/ordered-map) %))
(update :description d/nilv "")
(update :name normalize-set-name)
(check-token-set-attrs)
(map->token-set)))
(def ^:private set-prefix "S-")
(def ^:private set-group-prefix "G-")
(def ^:private set-separator "/")
(defn get-set-path
[token-set]
(cpn/split-path (get-name token-set) :separator set-separator))
(defn split-set-name
[name]
(cpn/split-path name :separator set-separator))
(defn join-set-path [path]
(cpn/join-path path :separator set-separator :with-spaces? false))
(defn normalize-set-name
"Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set').
If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name."
([name]
(-> (split-set-name name)
(cpn/join-path :separator set-separator :with-spaces? false)))
([name relative-to]
(-> (concat (butlast (split-set-name relative-to))
(split-set-name name))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)."
[name]
@ -609,7 +613,7 @@
is-source
external-id
(ct/now)
set-names))
(into #{} (filter some?) set-names)))
(enable-set [this set-name]
(set-sets this (conj sets set-name)))
@ -630,14 +634,9 @@
(update-set-name [this prev-set-name set-name]
(if (get sets prev-set-name)
(TokenTheme. id
name
group
description
is-source
external-id
(ct/now)
(conj (disj sets prev-set-name) set-name))
(let [sets (-> (disj sets prev-set-name)
(conj set-name))]
(set-sets this sets))
this))
(theme-matches-group-name [this group name]
@ -722,7 +721,8 @@
(update :is-source d/nilv false)
(update :external-id #(or % (str new-id)))
(update :modified-at #(or % (ct/now)))
(update :sets set)
(update :sets #(into #{} (comp (filter some?)
(map normalize-set-name)) %))
(check-token-theme-attrs)
(map->TokenTheme))))

View File

@ -8,7 +8,7 @@ localhost:3449 {
header -Strict-Transport-Security
}
http://localhost:3450 {
:3450 {
# For subpath test
# handle_path /penpot/* {
# reverse_proxy localhost:4449

View File

@ -5,6 +5,9 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh;
export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
export CARGO_HOME="/home/penpot/.cargo"
export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0
export PENPOT_MCP_SERVER_HOST=0.0.0.0
alias l='ls --color -GFlh'
alias ll='ls --color -GFlh'
alias rm='rm -rf'

View File

@ -126,6 +126,12 @@ http {
proxy_http_version 1.1;
}
location /plugins {
autoindex on;
alias /home/penpot/penpot/plugins/dist/apps;
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
PATH=/opt/node/bin:$PATH \
PENPOT_MCP_SERVER_HOST=0.0.0.0
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \

View File

@ -105,7 +105,7 @@ services:
# - "traefik.http.routers.penpot-https.tls=true"
environment:
<< : [*penpot-flags, *penpot-http-body-size]
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-latest}"

View File

@ -19,6 +19,10 @@ update_flags() {
-e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \
"$1")" > "$1"
fi
if [ -n "$PENPOT_PUBLIC_URI" ]; then
echo "var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";" >> "$1";
fi
}
update_flags /var/www/app/js/config.js
@ -30,8 +34,9 @@ update_flags /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@ -135,6 +135,23 @@ http {
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_MCP_URI:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass $PENPOT_MCP_URI:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass $PENPOT_MCP_URI:4401/sse;
proxy_http_version 1.1;
}
location /readyz {
access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri;

View File

@ -2,7 +2,7 @@
title: Contributing
desc: Learn how to contribute to Penpot, the open-source design collaboration platform! Find guides on bug reporting, translations, code contributions, and more.
eleventyNavigation:
key: Contributing
key: Contribute
order: 3
---
@ -10,7 +10,7 @@ eleventyNavigation:
<img src="/img/home-contribution.webp" alt="User guide" border="0">
</div>
<h1 id="contributing-guide">Contributing guide.</h1>
<h1 id="contributing-guide">Contributing guide</h1>
<p class="main-paragraph">In this documentation you will find (almost) everything you need to know about how to contribute at Penpot.</p>

View File

@ -2,6 +2,9 @@
title: Penpot MCP server
order: 1
desc: Installing and using the Penpot MCP server with any AI agent or LLM you trust.
eleventyNavigation:
key: MCP Server
order: 6
---
<div class="main-illus">
@ -69,7 +72,7 @@ There are three key pieces:
### Basic concepts
Some important concepts for users:
* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server (Beta)**. Here you enable or disable MCP, get the server URL and manage the MCP key.
* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key.
* **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup.
* **Currently focused page**: MCP always operates on the page you have in focus in Penpot. If you change the focused page (even in another browser window), the MCP context follows that page.
* **Active MCP tab**: MCP can only be active in one browser tab at a time. If you have Penpot open in several tabs, you choose explicitly which one owns MCP before running agents.
@ -110,32 +113,20 @@ If you just want to try Penpot MCP quickly, follow this path for the **hosted (r
### Remote MCP in 5 steps
<div class="advice">
### Important: remote MCP is not in production yet
Remote MCP is not available yet in Penpot production (`design.penpot.app`). It is planned for an upcoming release (currently targeted around **2.16**).
Right now, the remote MCP flow is available only in **testing environments** (for example, instances deployed from the `staging` branch: https://github.com/penpot/penpot/tree/staging).
If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server).
</div>
1. #### Enable MCP in Penpot
Go to **Your account → Integrations → MCP Server (Beta)** and enable the feature.
Go to **Your account → Integrations → MCP Server** and enable the feature.
![MCP Server (Beta) in Penpot Integrations, enable](/img/mcp/mcp-enable.webp)
![MCP Server in Penpot Integrations, enable](/img/mcp/mcp-enable.webp)
2. #### Generate your MCP key
If you do not have one yet, create it. The key is shown only once—store it safely.
![MCP Server (Beta) in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp)
![MCP Server in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp)
3. #### Copy the server URL
In the same Integrations section, copy the **server URL** that already includes your MCP key as `userToken`.
![MCP Server (Beta) in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp)
![MCP Server in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp)
4. #### Add the server to your MCP client
In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL.
@ -191,7 +182,7 @@ You can use Penpot MCP server in two main ways:
* Hosted for you (no need to run anything on your machine).
* Best option for most users, simpler installation and fewer moving parts.
* Does **not** have privileged access to your local file system, it can only work with what Penpot exposes (design files, libraries, tokens, etc.).
* The **server URL** is provided in **Your account → Integrations → MCP Server (Beta)** and looks like:
* The **server URL** is provided in **Your account → Integrations → MCP Server** and looks like:
* `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY`
* The domain depends on the Penpot installation. In the official SaaS it will be `design.penpot.app`.
* **Local MCP server**
@ -298,21 +289,11 @@ In Penpot, open a file and connect the plugin from **File → MCP Server → Con
Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted for you, so you don't need to install or run anything on your machine.
<div class="advice">
### Availability note
Remote MCP is currently available only in **testing environments**. It is not yet available in Penpot production (`design.penpot.app`) and is planned for an upcoming release (currently targeted around **2.16**).
If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server).
</div>
<a id="install-and-activate-remote"></a>
### Install and activate
1. Open **Your account → Integrations**.
2. In the **MCP Server (Beta)** section, read the short description to confirm that feature is available for your account.
2. In the **MCP Server** section, read the short description to confirm that feature is available for your account.
3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions.
4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely.
* Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples.
@ -326,7 +307,7 @@ If you need MCP in production today, use the **Local MCP server** setup instead.
For client-specific setup, use the shared section **Connect your MCP client**.
For remote mode, use the URL shown in **Your account → Integrations → MCP Server (Beta)**, which includes your `userToken`.
For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`.
<a id="use-remote"></a>
### Use
@ -336,7 +317,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat
#### Run
1. **Enable MCP**
* Go to **Your account → Integrations → MCP Server (Beta)** and set **Status** to **Enabled**.
* Go to **Your account → Integrations → MCP Server** and set **Status** to **Enabled**.
2. **Connect plugin**:
* Open a design file and use **File → MCP Server → Connect**.
3. **Run prompts**:
@ -393,7 +374,7 @@ At a high level:
2. Start the MCP server and plugin server from your terminal:
```json
npx @penpot/mcp@beta
npx @penpot/mcp@stable
```
Leave this terminal running while you use MCP.
@ -426,7 +407,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat
1. **Start MCP**
Run `npx -y @penpot/mcp@stable` (production) or `npx -y @penpot/mcp@beta` (test), and keep that terminal running.
Run `npx -y @penpot/mcp@stable` (production), and keep that terminal running.
2. **Connect plugin**
In Penpot, load `http://localhost:4400/manifest.json`, run the plugin, and click **Connect to MCP server**.

View File

@ -2,7 +2,7 @@
title: User guide
desc: Learn everything from interface basics to advanced features like prototyping and design sharing with Penpot's comprehensive user guide! Free access.
eleventyNavigation:
key: User guide
key: User Guide
order: 2
---

View File

@ -50,7 +50,7 @@
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap
'@penpot/plugins-runtime':
specifier: link:../plugins/dist/plugins-runtime
version: link:../plugins/dist/plugins-runtime
specifier: link:../plugins/libs/plugins-runtime
version: link:../plugins/libs/plugins-runtime
'@penpot/svgo':
specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -14,7 +14,7 @@
</script>
{{# manifest}}
<script src="{{& config}}"></script>
<script src="{{& config_render}}"></script>
<script src="{{& polyfills}}"></script>
<script type="importmap">{{& importmap }}</script>
{{/manifest}}

View File

@ -207,6 +207,7 @@ async function generateManifest() {
rasterizer_main: "./js/rasterizer.js",
config: "./js/config.js?version=" + VERSION_TAG,
config_render: "./js/config-render.js?version=" + VERSION_TAG,
polyfills: "./js/polyfills.js?version=" + VERSION_TAG,
libs: "./js/libs.js?version=" + VERSION_TAG,
default_translations: "./js/translation.en.js?version=" + VERSION_TAG,

View File

@ -36,7 +36,7 @@ popd
pushd ../mcp;
rm -rf node_modules;
./scripts/setup
WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user
WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build
popd;
pnpm run build:app:main $EXTRA_PARAMS;

View File

@ -12,6 +12,7 @@
[app.common.logging :as log]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.common.version :as v]
[app.util.avatars :as avatars]
[app.util.extends]
@ -108,10 +109,12 @@
(def target (parse-target global))
(def browser (parse-browser))
(def platform (parse-platform))
(def session-id (uuid/next))
(def version (parse-version global))
(def version-tag (obj/get global "penpotVersionTag"))
(defn stale-build?
"Returns true when the compiled JS was built with a different version
tag than the one present in the current index.html. This indicates
@ -156,6 +159,7 @@
(def plugins-list-uri (obj/get global "penpotPluginsListURI" "https://penpot.app/penpothub/plugins"))
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
(def templates-uri (obj/get global "penpotTemplatesURI" "https://penpot.github.io/penpot-files/"))
(def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB
;; We set the current parsed flags under common for make
;; it available for common code without the need to pass
@ -172,6 +176,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri))
@ -208,6 +216,9 @@
(let [f (obj/get global "initializeExternalConfigInfo")]
(when (fn? f) (f))))
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str))
(def mcp-help-center-uri "https://help.penpot.app/mcp/")
;; --- Helper Functions
(defn ^boolean check-browser? [candidate]

View File

@ -8,8 +8,9 @@
(:require
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.time :as ct]
[app.common.transit :as t]
[app.common.types.objects-map]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
@ -43,7 +44,8 @@
(log/inf :version (:full cf/version)
:asserts *assert*
:build-date cf/build-date
:public-uri (dm/str cf/public-uri))
:public-uri (dm/str cf/public-uri)
:session-id (str cf/session-id))
(log/inf :hint "enabled flags" :flags (str/join " " (map name cf/flags))))
(declare reinit)
@ -61,7 +63,7 @@
(ptk/reify ::initialize
ptk/UpdateEvent
(update [_ state]
(assoc state :session-id (uuid/next)))
(assoc state :session-id cf/session-id))
ptk/WatchEvent
(watch [_ _ stream]
@ -100,6 +102,15 @@
(defn ^:export init
[options]
;; WORKAROUND: we set this really not usefull property for signal a
;; sideffect and prevent GCC remove it. We need it because we need
;; to populate the Date prototype with transit related properties
;; before SES hardning is applied on loading MCP plugin
(unchecked-set js/globalThis "penpotStartDate"
(-> (ct/now)
(t/encode-str)
(t/decode-str)))
;; Before initializing anything, check if the browser has loaded
;; stale JS from a previous deployment. If so, do a hard reload so
;; the browser fetches fresh assets matching the current index.html.

View File

@ -57,5 +57,6 @@
[type data]
(ptk/reify ::event
ptk/EffectEvent
(effect [_ _ _]
(emit! type data))))
(effect [_ state _]
(let [session-id (get state :session-id)]
(emit! session-id type data)))))

View File

@ -431,6 +431,7 @@
context (-> @context
(merge (:context event))
(assoc :session session*)
(assoc :session-id cf/session-id)
(assoc :external-session-id (cf/external-session-id))
(add-external-context-info)
(d/without-nils))]

View File

@ -7,12 +7,14 @@
(ns app.main.data.plugins
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb]
[app.common.time :as ct]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st]
[app.plugins.flags :as pflag]
[app.plugins.register :as preg]
@ -20,7 +22,8 @@
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
[potok.v2.core :as ptk]
[promesa.core :as p]))
(defn save-plugin-permissions-peek
[id permissions]
@ -52,27 +55,47 @@
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
(try
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
[{:keys [plugin-id name version description host code icon permissions]}]
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e
(st/emit! (remove-current-plugin plugin-id))
(.error js/console "Error" e))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?]

View File

@ -498,4 +498,3 @@
(->> (rp/cmd! :delete-access-token params)
(rx/tap on-success)
(rx/catch on-error))))))

View File

@ -0,0 +1,70 @@
;; 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.main.data.uploads
"Generic chunked-upload helpers.
Provides a purpose-agnostic three-step session API that can be used
by any feature that needs to upload large binary blobs:
1. create-upload-session obtain a session-id
2. upload-chunk upload each slice (max-parallel-chunk-uploads in-flight)
3. caller-specific step e.g. assemble-file-media-object or import-binfile
`upload-blob-chunked` drives steps 1 and 2 and emits the completed
`{:session-id …}` map so that the caller can proceed with its own
step 3."
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.repo :as rp]
[beicon.v2.core :as rx]))
;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global
;; variable at startup; defaults to 25 MiB (overridden in production).
(def ^:private chunk-size cf/upload-chunk-size)
(def ^:private max-parallel-chunk-uploads
"Maximum number of chunk upload requests that may be in-flight at the
same time within a single chunked upload session."
2)
(defn upload-blob-chunked
"Uploads `blob` via the three-step chunked session API.
Steps performed:
1. Creates an upload session (`create-upload-session`).
2. Slices `blob` and uploads every chunk (`upload-chunk`),
with at most `max-parallel-chunk-uploads` concurrent requests.
Returns an observable that emits exactly one map:
`{:session-id <uuid>}`
The caller is responsible for the final step (assemble / import)."
[blob]
(let [total-size (.-size blob)
total-chunks (js/Math.ceil (/ total-size chunk-size))]
(->> (rp/cmd! :create-upload-session
{:total-chunks total-chunks})
(rx/mapcat
(fn [{raw-session-id :session-id}]
(let [session-id (cond-> raw-session-id
(string? raw-session-id) uuid/uuid)
chunk-uploads
(->> (range total-chunks)
(map (fn [idx]
(let [start (* idx chunk-size)
end (min (+ start chunk-size) total-size)
chunk (.slice blob start end)]
(rp/cmd! :upload-chunk
{:session-id session-id
:index idx
:content (list chunk (dm/str "chunk-" idx))})))))]
(->> (rx/from chunk-uploads)
(rx/merge-all max-parallel-chunk-uploads)
(rx/last)
(rx/map (fn [_] {:session-id session-id})))))))))

View File

@ -52,6 +52,7 @@
[app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp]
@ -211,8 +212,11 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id)))))
(rx/merge
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id))
(when (contains? cf/flags :mcp)
(rx/of (mcp/init)))))))
(defn- bundle-fetched
[{:keys [file file-id thumbnails] :as bundle}]
@ -220,6 +224,7 @@
IDeref
(-deref [_] bundle)
ptk/UpdateEvent
(update [_ state]
(-> state
@ -242,6 +247,7 @@
(rx/of (dws/select-shapes frames-id)
dwz/zoom-to-selected-shape)))))
;; FIXME: rename to `fetch-file`
(defn- fetch-bundle
"Multi-stage file bundle fetch coordinator"
[file-id features]
@ -279,6 +285,20 @@
(when shape
(wasm.api/process-object shape))))))
(defn initialize-file
[team-id file-id]
(assert (uuid? team-id) "expected valud uuid for `team-id`")
(assert (uuid? file-id) "expected valud uuid for `file-id`")
(ptk/reify ::initialize-file
ptk/WatchEvent
(watch [_ state _]
(let [features (features/get-enabled-features state team-id)]
(log/dbg :hint "initialize-file"
:team-id (dm/str team-id)
:file-id (dm/str file-id))
(rx/of (fetch-bundle file-id features))))))
(defn initialize-workspace
[team-id file-id]
(assert (uuid? team-id) "expected valud uuid for `team-id`")
@ -304,164 +324,169 @@
:team-id (dm/str team-id)
:file-id (dm/str file-id))
(->> (rx/merge
(rx/concat
;; Fetch all essential data that should be loaded before the file
(rx/merge
(if ^boolean render-wasm?
(->> (rx/from @wasm/module)
(rx/filter true?)
(rx/tap (fn [_]
(let [event (ug/event "penpot:wasm:loaded")]
(ug/dispatch! event))))
(rx/ignore))
(rx/empty))
(rx/concat
(->> (rx/merge
(rx/concat
;; Fetch all essential data that should be loaded before the file
(rx/merge
(if ^boolean render-wasm?
(->> (rx/from @wasm/module)
(rx/filter true?)
(rx/tap (fn [_]
(let [event (ug/event "penpot:wasm:loaded")]
(ug/dispatch! event))))
(rx/ignore))
(rx/empty))
(->> stream
(rx/filter (ptk/type? ::df/fonts-loaded))
(rx/take 1)
(rx/ignore))
(->> stream
(rx/filter (ptk/type? ::df/fonts-loaded))
(rx/take 1)
(rx/ignore))
(rx/of (ntf/hide)
(dcmt/retrieve-comment-threads file-id)
(dcmt/fetch-profiles)
(df/fetch-fonts team-id)))
(rx/of (ntf/hide)
(dcmt/retrieve-comment-threads file-id)
(dcmt/fetch-profiles)
(df/fetch-fonts team-id))
;; Once the essential data is fetched, lets proceed to
;; fetch teh file bunldle
(rx/of (fetch-bundle file-id features)))
(when (contains? cf/flags :mcp)
(rx/of (du/fetch-access-tokens))))
(->> stream
(rx/filter (ptk/type? ::bundle-fetched))
(rx/take 1)
(rx/map deref)
(rx/mapcat
(fn [{:keys [file]}]
(log/debug :hint "bundle fetched"
:team-id (dm/str team-id)
:file-id (dm/str file-id))
;; Once the essential data is fetched, lets proceed to
;; fetch teh file bunldle
(rx/of (initialize-file team-id file-id)))
(rx/of (dpj/initialize-project (:project-id file))
(dwn/initialize team-id file-id)
(dwsl/initialize-shape-layout)
(fetch-libraries file-id features)
(-> (workspace-initialized file-id)
(with-meta {:team-id team-id
:file-id file-id}))))))
(->> stream
(rx/filter (ptk/type? ::bundle-fetched))
(rx/take 1)
(rx/map deref)
(rx/mapcat
(fn [{:keys [file]}]
(log/debug :hint "bundle fetched"
:team-id (dm/str team-id)
:file-id (dm/str file-id))
;; Install dev perf observers once the workspace is ready
(when (contains? cf/flags :perf-logs)
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1)
(rx/tap (fn [_] (perf/setup)))))
(rx/of (dpj/initialize-project (:project-id file))
(dwn/initialize team-id file-id)
(dwsl/initialize-shape-layout)
(fetch-libraries file-id features)
(-> (workspace-initialized file-id)
(with-meta {:team-id team-id
:file-id file-id}))))))
(->> stream
(rx/filter (ptk/type? ::dps/persistence-notification))
(rx/take 1)
(rx/map dwc/set-workspace-visited))
;; Install dev perf observers once the workspace is ready
(when (contains? cf/flags :perf-logs)
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1)
(rx/tap (fn [_] (perf/setup)))))
(when-let [component-id (some-> rparams :component-id uuid/parse)]
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async)
(rx/take 1)
(rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams)))))
(->> stream
(rx/filter (ptk/type? ::dps/persistence-notification))
(rx/take 1)
(rx/map dwc/set-workspace-visited))
(when (:board-id rparams)
(->> stream
(rx/filter (ptk/type? ::dwv/initialize-viewport))
(rx/take 1)
(rx/map zoom-to-frame)))
(when-let [component-id (some-> rparams :component-id uuid/parse)]
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async)
(rx/take 1)
(rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams)))))
(when-let [comment-id (some-> rparams :comment-id uuid/parse)]
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async)
(rx/take 1)
(rx/map #(dwcm/navigate-to-comment-id comment-id))))
(when (:board-id rparams)
(->> stream
(rx/filter (ptk/type? ::dwv/initialize-viewport))
(rx/take 1)
(rx/map zoom-to-frame)))
(when render-wasm?
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/mapcat
(fn [{:keys [redo-changes]}]
(let [added (->> redo-changes
(filter #(= (:type %) :add-obj))
(map :id))]
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when-let [comment-id (some-> rparams :comment-id uuid/parse)]
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async)
(rx/take 1)
(rx/map #(dwcm/navigate-to-comment-id comment-id))))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
(when render-wasm?
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/mapcat
(fn [{:keys [redo-changes]}]
(let [added (->> redo-changes
(filter #(= (:type %) :add-obj))
(map :id))]
(->> (rx/from added)
(rx/map process-wasm-object)))))))
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(when (d/not-empty? changes)
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))
(rx/take-until stoper-s))))
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/mapcat
(fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}]
(if (and save-undo? (seq undo-changes))
(let [entry {:undo-changes undo-changes
:redo-changes redo-changes
:undo-group undo-group
:tags tags}]
(rx/of (dwu/append-undo entry stack-undo?)))
(rx/empty))))))
(rx/take-until stoper-s))))
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(when (d/not-empty? changes)
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))
(rx/take-until stoper-s))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/mapcat
(fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}]
(if (and save-undo? (seq undo-changes))
(let [entry {:undo-changes undo-changes
:redo-changes redo-changes
:undo-group undo-group
:tags tags}]
(rx/of (dwu/append-undo entry stack-undo?)))
(rx/empty))))))
(rx/take-until stoper-s))
(rx/of (mcp/notify-other-tabs-disconnect)))))
ptk/EffectEvent
(effect [_ _ _]
(let [name (dm/str "workspace-" file-id)]

View File

@ -1294,9 +1294,10 @@
(watch [_ _ stream]
(let [stopper-s
(->> stream
(rx/filter #(or (= ::dwpg/finalize-page (ptk/type %))
(= ::watch-component-changes (ptk/type %)))))
(rx/map ptk/type)
(rx/filter (fn [event-type]
(or (= ::dwpg/finalize-page event-type)
(= ::watch-component-changes event-type)))))
workspace-data-s
(->> (rx/from-atom refs/workspace-data {:emit-current-value? true})
(rx/share))

View File

@ -0,0 +1,292 @@
;; 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.main.data.workspace.mcp
(:require
[app.common.logging :as log]
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.register :refer [mcp-plugin-id]]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(def retry-interval 10000)
(log/set-level! :info)
(def ^:private default-manifest
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
#{"library:read" "library:write"
"comment:read" "comment:write"
"content:write" "content:read"}})
(defonce interval-sub (atom nil))
(defn finalize-workspace?
[event]
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
(defn set-mcp-active
[value]
(ptk/reify ::set-mcp-active
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:mcp :active] value))))
(defn start-reconnect-watcher!
[]
(st/emit! (set-mcp-active true))
(when (nil? @interval-sub)
(reset!
interval-sub
(ts/interval
retry-interval
(fn []
;; Try to reconnect if active and not connected
(when-not (contains? #{"connecting" "connected"}
(-> @st/state :mcp :connection-status))
(.log js/console "Reconnecting to MCP...")
(st/emit! (ptk/data-event ::connect))))))))
(defn stop-reconnect-watcher!
[]
(st/emit! (set-mcp-active false))
(when @interval-sub
(rx/dispose! @interval-sub)
(reset! interval-sub nil)))
(declare manage-mcp-notification)
(defn handle-pong
[{:keys [id data]}]
(ptk/reify ::handle-pong
ptk/UpdateEvent
(update [_ state]
(let [mcp-state (get state :mcp)]
(cond
(= "connected" (:connection-status data))
(update state :mcp assoc :connected-tab id)
(and (= "disconnected" (:connection-status data))
(= id (:connection-status mcp-state)))
(update state :mcp dissoc :connected-tab)
:else
state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)))))
;; This event will arrive when a new workspace is open in another tab
(defn handle-ping
[]
(ptk/reify ::handle-ping
ptk/WatchEvent
(watch [_ state _]
(let [conn-status (get-in state [:mcp :connection-status])]
(rx/of (mbc/event :mcp/pong {:connection-status conn-status}))))))
(defn notify-other-tabs-disconnect
[]
(ptk/reify ::notify-other-tabs-disconnect
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/pong {:connection-status "disconnected"})))))
;; This event will arrive when the mcp is enabled in the dashboard
(defn update-mcp-status
[value]
(ptk/reify ::update-mcp-status
ptk/UpdateEvent
(update [_ state]
(update-in state [:profile :props] assoc :mcp-enabled value))
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (manage-mcp-notification))
(case value
true (rx/of (ptk/data-event ::connect))
false (rx/of (ptk/data-event ::disconnect))
nil)))))
(defn update-mcp-connection-status
[value]
(ptk/reify ::update-mcp-plugin-connection
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connection-status value))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)
(mbc/event :mcp/pong {:connection-status value})))))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/force-disconect {})
(ptk/data-event ::connect)))))
;; This event will arrive when the user selects disconnect on the menu
;; or there is a broadcast message for disconnection
(defn user-disconnect-mcp
[]
(ptk/reify ::user-disconnect-mcp
ptk/WatchEvent
(watch [_ _ _]
(rx/of (ptk/data-event ::disconnect)
(update-mcp-connection-status "disconnected")))
ptk/EffectEvent
(effect [_ _ _]
(stop-reconnect-watcher!))))
(defn- manage-mcp-notification
[]
(ptk/reify ::manage-mcp-notification
ptk/WatchEvent
(watch [_ state _]
(let [mcp-state (get state :mcp)
mcp-enabled? (-> state :profile :props :mcp-enabled)
current-tab-id (get state :session-id)
connected-tab-id (get mcp-state :connected-tab)]
(if mcp-enabled?
(if (= connected-tab-id current-tab-id)
(rx/of (ntf/hide))
(rx/of (ntf/dialog
{:content (tr "notifications.mcp.active-in-another-tab")
:cancel {:label (tr "labels.dismiss")
:callback #(st/emit! (ntf/hide)
(ev/event {::ev/name "confirm-mcp-tab-switch"
::ev/origin "workspace-notification"}))}
:accept {:label (tr "labels.switch")
:callback #(st/emit! (connect-mcp)
(ev/event {::ev/name "dismiss-mcp-tab-switch"
::ev/origin "workspace-notification"}))}})))
(rx/of (ntf/hide)))))))
(defn init-mcp
[stream]
(->> (rp/cmd! :get-current-mcp-token)
(rx/tap
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
(when (= status "connected")
(start-reconnect-watcher!))
(st/emit! (update-mcp-connection-status status))
(log/info :hint "MCP STATUS" :status status))
:on
(fn [event cb]
(when-let [event
(case event
"disconnect" ::disconnect
"connect" ::connect
nil)]
(let [stopper (rx/filter finalize-workspace? stream)]
(->> stream
(rx/filter (ptk/type? event))
(rx/take-until stopper)
(rx/subs! #(cb))))))}}))))
(rx/ignore)))
(defn init
[]
(ptk/reify ::init
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state) :active true))
ptk/WatchEvent
(watch [_ state stream]
(let [stoper-s (rx/merge
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream)
(rx/filter (ptk/type? ::init) stream))
session-id (get state :session-id)
enabled? (-> state :profile :props :mcp-enabled)]
(->> (rx/merge
(if enabled?
(rx/merge
(init-mcp stream)
(rx/of (mbc/event :mcp/ping {}))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/ping))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-ping))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/pong))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-pong))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/force-disconect))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map deref)
(rx/map (fn [] (user-disconnect-mcp)))))
(rx/empty))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/enable))
(rx/mapcat (fn [_]
;; NOTE: we don't need an explicit
;; connect because the plugin has
;; auto-connect
(rx/of (update-mcp-status true)
(init)))))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/disable))
(rx/mapcat (fn [_]
(rx/of (update-mcp-status false)
(init)
(user-disconnect-mcp))))))
(rx/take-until stoper-s))))))

View File

@ -24,6 +24,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.media :as dmm]
[app.main.data.notifications :as ntf]
[app.main.data.uploads :as uploads]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.svg-upload :as svg]
[app.main.repo :as rp]
@ -103,6 +104,26 @@
:url url
:is-local true}))
;; Size of each upload chunk in bytes — read from config directly,
;; same source used by the uploads namespace.
(def ^:private chunk-size cf/upload-chunk-size)
(defn- upload-blob-chunked
"Uploads `blob` to `file-id` as a chunked media object using the
three-step session API. Returns an observable that emits the
assembled file-media-object map."
[{:keys [file-id name is-local blob]}]
(let [mtype (.-type blob)]
(->> (uploads/upload-blob-chunked blob)
(rx/mapcat
(fn [{:keys [session-id]}]
(rp/cmd! :assemble-file-media-object
{:session-id session-id
:file-id file-id
:is-local is-local
:name name
:mtype mtype}))))))
(defn process-uris
[{:keys [file-id local? name uris mtype on-image on-svg]}]
(letfn [(svg-url? [url]
@ -143,12 +164,18 @@
(and (not force-media)
(= (.-type blob) "image/svg+xml")))
(prepare-blob [blob]
(let [name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))]
{:file-id file-id
:name name
:is-local local?
:content blob}))
(upload-blob [blob]
(let [params {:file-id file-id
:name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))
:is-local local?
:blob blob}]
(if (>= (.-size blob) chunk-size)
(upload-blob-chunked params)
(rp/cmd! :upload-file-media-object
{:file-id file-id
:name (:name params)
:is-local local?
:content blob}))))
(extract-content [blob]
(let [name (or name (.-name blob))]
@ -159,8 +186,7 @@
(->> (rx/from blobs)
(rx/map dmm/validate-file)
(rx/filter (comp not svg-blob?))
(rx/map prepare-blob)
(rx/mapcat #(rp/cmd! :upload-file-media-object %))
(rx/mapcat upload-blob)
(rx/tap on-image))
(->> (rx/from blobs)
@ -170,9 +196,10 @@
(rx/merge-map svg->clj)
(rx/tap on-svg)))))
(defn handle-media-error [error on-error]
(if (ex/ex-info? error)
(handle-media-error (ex-data error) on-error)
(defn handle-media-error
[cause]
(ex/print-throwable cause)
(let [error (ex-data cause)]
(cond
(= (:code error) :invalid-svg-file)
(rx/of (ntf/error (tr "errors.media-type-not-allowed")))
@ -195,13 +222,8 @@
(= (:code error) :unable-to-optimize)
(rx/of (ntf/error (:hint error)))
(fn? on-error)
(on-error error)
:else
(do
(.error js/console "ERROR" error)
(rx/of (ntf/error (tr "errors.cannot-upload")))))))
(rx/of (ntf/error (tr "errors.cannot-upload"))))))
(def ^:private
@ -215,7 +237,7 @@
[:mtype {:optional true} :string]])
(defn- process-media-objects
[{:keys [uris on-error] :as params}]
[{:keys [uris] :as params}]
(dm/assert!
(and (sm/check schema:process-media-objects params)
(or (contains? params :blobs)
@ -238,7 +260,7 @@
;; Every stream has its own sideeffect. We need to ignore the result
(rx/ignore)
(rx/catch #(handle-media-error % on-error))
(rx/catch handle-media-error)
(rx/finalize #(st/emit! (ntf/hide :tag :media-loading))))))))
(defn upload-media-workspace
@ -278,8 +300,6 @@
(rx/tap on-upload-success)
(rx/catch handle-media-error))))))
;; --- Upload File Media objects
(defn create-shapes-svg
"Convert svg elements into penpot shapes."
[file-id objects pos svg-data]

View File

@ -40,7 +40,7 @@
(declare handle-pointer-update)
(declare handle-file-change)
(declare handle-file-deleted)
(declare handle-file-restore)
(declare handle-file-restored)
(declare handle-library-change)
(declare handle-pointer-send)
(declare handle-export-update)
@ -132,7 +132,7 @@
:pointer-update (handle-pointer-update msg)
:file-change (handle-file-change msg)
:file-deleted (handle-file-deleted msg)
:file-restore (handle-file-restore msg)
:file-restored (handle-file-restored msg)
:library-change (handle-library-change msg)
:notification (dc/handle-notification msg)
:team-role-change (handle-change-team-role msg)
@ -214,6 +214,7 @@
(update state :workspace-presence dissoc session-id)
(update state :workspace-presence update-presence))))))
(defn handle-pointer-update
[{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}]
(ptk/reify ::handle-pointer-update
@ -282,22 +283,22 @@
(rt/nav :dashboard-recent {:team-id team-id})))))))
(def ^:private
schema:handle-file-restore
[:map {:title "handle-file-restore"}
schema:handle-file-restored
[:map {:title "handle-file-restored"}
[:type :keyword]
[:file-id ::sm/uuid]
[:vern :int]])
(def ^:private check-file-restore-params
(sm/check-fn schema:handle-file-restore))
(def ^:private check-file-restored-params
(sm/check-fn schema:handle-file-restored))
(defn handle-file-restore
(defn handle-file-restored
[{:keys [file-id vern] :as msg}]
(assert (check-file-restore-params msg)
(assert (check-file-restored-params msg)
"expected valid parameters")
(ptk/reify ::handle-file-restore
(ptk/reify ::handle-file-restored
ptk/WatchEvent
(watch [_ state _]
(let [curr-file-id (:current-file-id state)

View File

@ -49,14 +49,14 @@
;; (note that dwsh/update-shapes function returns an event)
(defn update-shape-radius-all
([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil))
([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions
(defn update-shape-radius
([value shape-ids attributes] (update-shape-radius value shape-ids attributes nil))
([value shape-ids attributes page-id]
(when (number? value)
(let [value (max 0 value)]
(dwsh/update-shapes shape-ids
(fn [shape]
(ctsr/set-radius-to-all-corners shape value))
(ctsr/set-radius-for-corners shape attributes value))
{:reg-objects? true
:ignore-touched true
:page-id page-id
@ -531,7 +531,7 @@
(some attributes #{:r1 :r2 :r3 :r4})
(conj #(if (= attributes #{:r1 :r2 :r3 :r4})
(update-shape-radius-all value shape-ids attributes page-id)
(update-shape-radius value shape-ids attributes page-id)
(update-shape-radius-for-corners
value shape-ids
(set (filter attributes #{:r1 :r2 :r3 :r4}))
@ -607,6 +607,46 @@
:state state})]
(apply rx/of (map #(%) actions)))))))))
(def attributes->shape-update
"Maps each attribute-set to the update function that applies it to a shape.
Used both here (to resolve the correct update fn when explicit attrs are
passed to toggle-token) and in propagation.cljs (re-exported from there)."
{ctt/border-radius-keys update-shape-radius-for-corners
ctt/color-keys update-fill-stroke
ctt/stroke-width-keys update-stroke-width
ctt/sizing-keys apply-dimensions-token
ctt/opacity-keys update-opacity
ctt/rotation-keys update-rotation
;; Typography
ctt/font-family-keys update-font-family
ctt/font-size-keys update-font-size
ctt/font-weight-keys update-font-weight
ctt/letter-spacing-keys update-letter-spacing
ctt/text-case-keys update-text-case
ctt/text-decoration-keys update-text-decoration
ctt/typography-token-keys update-typography
ctt/shadow-keys update-shadow
ctt/line-height-keys update-line-height
;; Layout
#{:x :y} update-shape-position
#{:p1 :p2 :p3 :p4} update-layout-padding
#{:m1 :m2 :m3 :m4} update-layout-item-margin
#{:column-gap :row-gap} update-layout-gap
#{:width :height} apply-dimensions-token
#{:layout-item-min-w :layout-item-min-h
:layout-item-max-w :layout-item-max-h} update-layout-sizing-limits})
;; Flattened per-individual-key version of attributes->shape-update.
;; Allows O(1) lookup of the update function for any single attribute.
(def ^:private attr->shape-update
(reduce
(fn [acc [attr-set update-fn]]
(into acc (map (fn [k] [k update-fn]) attr-set)))
{}
attributes->shape-update))
;; Events to apply / unapply tokens to shapes ------------------------------------------------------------
(defn apply-token
@ -620,65 +660,73 @@
ptk/WatchEvent
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
(if (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(if (and (empty? (get state :workspace-editor-state))
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:type :toast
:level :warning
:timeout 3000}))))))
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:type :toast
:level :warning
:timeout 3000})))))))
(defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button.
@ -744,10 +792,16 @@
{:keys [attributes all-attributes on-update-shape]}
(get token-properties (:type token))
on-update-shape
(if (seq attrs)
(or (get attr->shape-update (first attrs)) on-update-shape)
on-update-shape)
unapply-tokens?
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
shape-ids
(map :id shapes)]
(if unapply-tokens?
(rx/of
@ -808,7 +862,7 @@
:border-radius
{:title "Border Radius"
:attributes ctt/border-radius-keys
:on-update-shape update-shape-radius-all
:on-update-shape update-shape-radius
:modal {:key :tokens/border-radius
:fields [{:label "Border Radius"
:key :border-radius}]}}

View File

@ -613,7 +613,7 @@
vec))
(defn combine-as-variants
[ids {:keys [page-id trigger]}]
[ids {:keys [page-id trigger variant-id]}]
(ptk/reify ::combine-as-variants
ptk/WatchEvent
(watch [_ state stream]
@ -647,7 +647,7 @@
:shapes
count
inc)
variant-id (uuid/next)
variant-id (or variant-id (uuid/next))
undo-id (js/Symbol)]
(rx/concat

View File

@ -11,9 +11,10 @@
[app.common.schema :as sm]
[app.common.time :as ct]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dwp]
[app.main.data.workspace :as dw]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.thumbnails :as th]
[app.main.refs :as refs]
[app.main.repo :as rp]
@ -92,33 +93,59 @@
(->> (rp/cmd! :update-file-snapshot {:id id :label label})
(rx/map fetch-versions)))))))
(defn- initialize-version
[]
(ptk/reify ::initialize-version
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
file-id (:current-file-id state)
team-id (:current-team-id state)]
(rx/merge
(->> stream
(rx/filter (ptk/type? ::dw/bundle-fetched))
(rx/take 1)
(rx/map #(dwpg/initialize-page file-id page-id)))
(rx/of (ntf/hide :tag :restore-dialog)
(dw/initialize-file team-id file-id)))))
ptk/EffectEvent
(effect [_ _ _]
(th/clear-queue!))))
(defn- wait-for-persistence
[file-id snapshot-id]
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id}))))
(defn restore-version
[id origin]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::restore-version
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
team-id (:current-team-id state)]
(let [file-id (:current-file-id state)
team-id (:current-team-id state)
event-name (case origin
:version "restore-pin-version"
:snapshot "restore-autosave"
:plugin "restore-version-plugin")]
(rx/concat
(rx/of ::dwp/force-persist
(dw/remove-layout-flag :document-history))
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/tap #(th/clear-queue!))
(rx/map #(dw/initialize-workspace team-id file-id)))
(case origin
:version
(rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"}))
:snapshot
(rx/of (ptk/event ::ev/event {::ev/name "restore-autosave"}))
:plugin
(rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"}))
(->> (wait-for-persistence file-id id)
(rx/map #(initialize-version)))
(if event-name
(rx/of (ev/event {::ev/name event-name
:file-id file-id
:team-id team-id}))
(rx/empty)))))))
(defn delete-version
@ -220,18 +247,15 @@
(ptk/reify ::restore-version-from-plugins
ptk/WatchEvent
(watch [_ state _]
(let [file (dsh/lookup-file state file-id)
team-id (or (:team-id file) (:current-file-id state))]
(let [team-id (:current-team-id state)]
(rx/concat
(rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"})
(rx/of (ev/event {::ev/name "restore-version-plugin"
:file-id file-id
:team-id team-id})
::dwp/force-persist)
;; FIXME: we should abstract this
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(dw/initialize-workspace team-id file-id)))
(->> (wait-for-persistence file-id id)
(rx/map #(initialize-version)))
(->> (rx/of 1)
(rx/tap resolve)

View File

@ -33,6 +33,16 @@
;; Will contain last uncaught exception
(def last-exception nil)
(defn is-plugin-error?
"This is a placeholder that always return false. It will be
overwritten when plugin system is initialized. This works this way
because we can't import plugins here because plugins requries full
DOM.
This placeholder is set on app.plugins/initialize event"
[_]
false)
;; Re-entrancy guard: prevents on-error from calling itself recursively.
;; If an error occurs while we are already handling an error (e.g. the
;; notification emit itself throws), we log it and bail out immediately
@ -206,6 +216,16 @@
(ex/print-throwable cause :prefix "Unexpected Error")
(flash :cause cause :type :unhandled))))
(defmethod ptk/handle-error :wasm-error
[error]
(when-let [cause (::instance error)]
(ex/print-throwable cause)
(let [code (get error :code)]
(if (or (= code :panic)
(= code :webgl-context-lost))
(st/emit! (rt/assign-exception error))
(flash :type :handled :cause cause)))))
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all
@ -461,36 +481,70 @@
(and (= (.-name ^js cause) "NotFoundError")
(str/includes? message "removeChild")))))
(defn- from-plugin?
"Check if the error is marked as originating from plugin code. The
plugin runtime tracks plugin errors in a WeakMap, which works even
in SES hardened environments where error objects may be frozen."
[cause]
(try
(is-plugin-error? cause)
(catch :default _
false)))
(defonce uncaught-error-handler
(letfn [(on-unhandled-error [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled)))))))))
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled))))))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled)))))))))]
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled))))))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -150,6 +150,9 @@
(def workspace-global
(l/derived :workspace-global st/state))
(def mcp
(l/derived :mcp st/state))
(def workspace-drawing
(l/derived :workspace-drawing st/state))
@ -645,3 +648,9 @@
(def progress
(l/derived :progress st/state))
(def access-tokens
(l/derived :access-tokens st/state))
(def access-token-created
(l/derived :access-token-created st/state))

View File

@ -139,8 +139,7 @@
{:stream? true}
::sse/import-binfile
{:stream? true
:form-data? true}
{:stream? true}
::sse/permanently-delete-team-files
{:stream? true}
@ -183,6 +182,7 @@
:credentials "include"
:headers {"accept" "application/transit+json,text/event-stream,*/*"
"x-external-session-id" (cf/external-session-id)
"x-session-id" (str cf/session-id)
"x-event-origin" (::ev/origin (meta params))}
:body (when (= method :post)
(if form-data?
@ -273,6 +273,7 @@
(send-export (merge default params))))
(derive :upload-file-media-object ::multipart-upload)
(derive :upload-chunk ::multipart-upload)
(derive :update-profile-photo ::multipart-upload)
(derive :update-team-photo ::multipart-upload)

View File

@ -190,7 +190,7 @@
:settings-options
:settings-feedback
:settings-subscription
:settings-access-tokens
:settings-integrations
:settings-notifications)
(let [params (get params :query)
error-report-id (some-> params :error-report-id uuid/parse*)]

View File

@ -36,8 +36,8 @@
(mf/defc demo-warning*
[]
[:> context-notification*
{:level :warning
:content (tr "auth.demo-warning")}])
{:level :warning}
(tr "auth.demo-warning")])
(defn create-demo-profile
[]

View File

@ -9,14 +9,17 @@
(:require
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as k]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(:import
goog.events.EventType))
(mf/defc confirm-dialog
{::mf/register modal/components
@ -68,8 +71,11 @@
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} title]
[:button {:class (stl/css :modal-close-btn)
:on-click cancel-fn} deprecated-icon/close]]
[:div {:class (stl/css :modal-close-btn)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click cancel-fn
:icon i/close}]]]
[:div {:class (stl/css :modal-content)}
(when (and (string? message) (not= message ""))
@ -87,24 +93,19 @@
[:ul {:class (stl/css :component-list)}
(for [item items]
[:li {:class (stl/css :modal-item-element)}
[:span {:class (stl/css :modal-component-icon)}
deprecated-icon/component]
[:> icon* {:icon-id i/component
:class (stl/css :modal-component-icon)
:size "s"}]
[:span {:class (stl/css :modal-component-name)}
(:name item)]])]])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(when-not (= cancel-label :omit)
[:input
{:class (stl/css :cancel-button)
:type "button"
:value cancel-label
:on-click cancel-fn}])
[:input
{:class (stl/css-case :accept-btn true
:danger (= accept-style :danger)
:primary (= accept-style :primary))
:type "button"
:value accept-label
:on-click accept-fn}]]]]]))
[:> button* {:variant "secondary"
:on-click cancel-fn}
cancel-label])
[:> button* {:variant (cond (= accept-style :danger) "destructive"
(= accept-style :primary) "primary")
:on-click accept-fn}
accept-label]]]]]))

View File

@ -15,10 +15,9 @@
.modal-container {
@extend .modal-container-base;
}
.modal-header {
margin-bottom: deprecated.$s-24;
display: flex;
flex-direction: column;
gap: var(--sp-xxl);
}
.modal-title {
@ -27,12 +26,13 @@
}
.modal-close-btn {
@extend .modal-close-btn-base;
position: absolute;
top: var(--sp-m);
right: var(--sp-m);
}
.modal-content {
@include deprecated.bodyLargeTypography;
margin-bottom: deprecated.$s-24;
}
.modal-item-element {
@ -41,32 +41,18 @@
.modal-component-icon {
@include deprecated.flexCenter;
height: deprecated.$s-16;
width: deprecated.$s-16;
svg {
@extend .button-icon-small;
stroke: var(--color);
}
color: var(--color-foreground-secondary);
}
.modal-component-name {
@include deprecated.bodyLargeTypography;
color: var(--color-foreground-secondary);
}
.action-buttons {
@extend .modal-action-btns;
}
.cancel-button {
@extend .modal-cancel-btn;
}
.accept-btn {
@extend .modal-accept-btn;
&.danger {
@extend .modal-danger-btn;
}
}
.modal-scd-msg,
.modal-subtitle,
.modal-msg {

View File

@ -18,6 +18,7 @@ $sz-32: px2rem(32);
$sz-36: px2rem(36);
$sz-40: px2rem(40);
$sz-48: px2rem(48);
$sz-64: px2rem(64);
$sz-88: px2rem(88);
$sz-96: px2rem(96);
$sz-120: px2rem(120);

View File

@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.constants :refer [max-input-length]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
@ -52,10 +51,11 @@
:has-hint has-hint
:hint-type hint-type
:variant variant})]
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
:variant-dense (= variant "dense")
:variant-comfortable (= variant "comfortable")
:has-hint has-hint))}
[:div {:class [class (stl/css-case :input-wrapper true
:variant-dense (= variant "dense")
:variant-comfortable (= variant "comfortable")
:has-hint has-hint)]}
(when has-label
[:> label* {:for id :is-optional is-optional} label])
[:> input-field* props]
@ -64,4 +64,3 @@
:class hint-class
:message hint-message
:type hint-type}])]))

View File

@ -8,6 +8,7 @@
(:require
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.keyboard :as k]
@ -47,6 +48,23 @@
[:> input* props]))
(mf/defc form-select*
[{:keys [name] :as props}]
(let [select-name name
form (mf/use-ctx context)
value (get-in @form [:data select-name] "")
handle-change
(fn [event]
(let [value (if (string? event) event (dom/get-target-val event))]
(fm/on-input-change form select-name value)))
props
(mf/spread-props props {:on-change handle-change
:value value})]
[:> select* props]))
(mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context)
@ -79,4 +97,4 @@
(when (fn? on-submit)
(on-submit form event))))]
[:> (mf/provider context) {:value form}
[:form {:class class :on-submit on-submit'} children]]))
[:form {:class class :on-submit on-submit'} children]]))

View File

@ -33,6 +33,7 @@
[app.main.ui.releases.v2-12]
[app.main.ui.releases.v2-13]
[app.main.ui.releases.v2-14]
[app.main.ui.releases.v2-15]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@ -105,4 +106,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.14")))
(rc/render-release-notes (assoc params :version "2.15")))

View File

@ -0,0 +1,159 @@
;; 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.main.ui.releases.v2-15
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.15"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.15-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.15 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"One major feature: the Penpot MCP Server, with infinite workflow possibilities"]
[:p {:class (stl/css :feature-content)}
"This release marks a major MCP milestone: Penpot MCP moves from an early technical setup to an accessible in-app experience via hosted remote setup. Whether you already know MCP or are new to it, it's now zero-friction to connect your AI client and turn prompts into real actions on real design data."]
[:p {:class (stl/css :feature-content)}
"With 2.15, we are opening the door to truly multi-directional workflows between design and code, while staying faithful to Penpot values: openness, freedom of choice, and respect for your data."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.15-mcp-01.gif"
:class (stl/css :start-image)
:border "0"
:alt "Penpot MCP Server: AI connected to real design context"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Penpot MCP Server: AI connected to real design context"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Penpot MCP Server is the bridge between your AI client and your Penpot file. You describe what you need in natural language, your agent picks the right operation, and MCP translates that into real actions through Penpot APIs."]
[:p {:class (stl/css :feature-content)}
"This is not a generic 'describe and generate' flow. It is context-aware work with components, tokens, pages, layers, and structure. In short: design expressed as code, now usable through your preferred AI assistant."]
[:p {:class (stl/css :feature-content)}
"You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.15-mcp-02.gif"
:class (stl/css :start-image)
:border "0"
:alt "Multi-directional workflows, from design to code and back"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Multi-directional workflows, from design to code and back"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"The biggest unlock in 2.15 is multi-directionality. You can move from design to code, and from code back to design, without losing intent or structure in the process."]
[:p {:class (stl/css :feature-content)}
"• Generate semantic HTML/CSS from real layouts."]
[:p {:class (stl/css :feature-content)}
"• Translate tokens and styles into code variables."]
[:p {:class (stl/css :feature-content)}
"• Export only assets in use."]
[:p {:class (stl/css :feature-content)}
"• Validate design-code consistency."]
[:p {:class (stl/css :feature-content)}
"• Reorganize layers, apply naming rules, and automate repetitive design system maintenance."]
[:p {:class (stl/css :feature-content)}
"This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.15-mcp-03.gif"
:class (stl/css :start-image)
:border "0"
:alt "Your stack, your model, your control"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Your stack, your model, your control"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"With MCP, you connect Penpot to the AI client and model you already trust. Cursor, Claude, VS Code, Codex, or another MCP-compatible setup: the workflow adapts to your stack, not the other way around."]
[:p {:class (stl/css :feature-content)}
"You can run it hosted for a faster setup, or locally when you need tighter infrastructure control. The same applies to data boundaries: Penpot provides the bridge to your design context, while your team decides how and where AI runs."]
[:p {:class (stl/css :feature-content)}
"In practice, this means teams can automate design and code workflows without giving up tool freedom, deployment control, or ownership of their process.
"]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@ -0,0 +1,102 @@
// 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
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flexCenter;
@include deprecated.headlineSmallTypography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@ -36,7 +36,7 @@
["/feedback" :settings-feedback]
["/options" :settings-options]
["/subscriptions" :settings-subscription]
["/access-tokens" :settings-access-tokens]
["/integrations" :settings-integrations]
["/notifications" :settings-notifications]]
["/frame-preview" :frame-preview]

View File

@ -13,10 +13,10 @@
[app.main.store :as st]
[app.main.ui.hooks :as hooks]
[app.main.ui.modal :refer [modal-container*]]
[app.main.ui.settings.access-tokens :refer [access-tokens-page]]
[app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page*]]
[app.main.ui.settings.integrations :refer [integrations-page*]]
[app.main.ui.settings.notifications :refer [notifications-page*]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.password :refer [password-page]]
@ -73,8 +73,8 @@
:settings-subscription
[:> subscription-page* {:profile profile}]
:settings-access-tokens
[:& access-tokens-page]
:settings-integrations
[:> integrations-page*]
:settings-notifications
[:& notifications-page* {:profile profile}])]]]]))

View File

@ -1,291 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.settings.access-tokens
(:require-macros [app.main.style :as stl])
(:require
[app.common.schema :as sm]
[app.common.time :as ct]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as deprecated-icon]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private clipboard-icon
(deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon)))
(def ^:private close-icon
(deprecated-icon/icon-xref :close (stl/css :close-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def tokens-ref
(l/derived :access-tokens st/state))
(def token-created-ref
(l/derived :access-token-created st/state))
(def ^:private schema:form
[:map {:title "AccessTokenForm"}
[:name [::sm/text {:max 250}]]
[:expiration-date [::sm/text {:max 250}]]])
(def initial-data
{:name "" :expiration-date "never"})
(mf/defc access-token-modal
{::mf/register modal/components
::mf/register-as :access-token}
[]
(let [form (fm/use-form
:initial initial-data
:schema schema:form)
created (mf/deref token-created-ref)
created? (mf/use-state false)
on-success
(mf/use-fn
(mf/deps created)
(fn [_]
(let [message (tr "dashboard.access-tokens.create.success")]
(st/emit! (du/fetch-access-tokens)
(ntf/success message)
(reset! created? true)))))
on-close
(mf/use-fn
(mf/deps created)
(fn [_]
(reset! created? false)
(st/emit! (modal/hide))))
on-error
(mf/use-fn
(fn [_]
(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide))))
on-submit
(mf/use-fn
(fn [form]
(let [cdata (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
expiration (:expiration-date cdata)
params (cond-> {:name (:name cdata)
:perms (:perms cdata)}
(not= "never" expiration) (assoc :expiration expiration))]
(st/emit! (du/create-access-token
(with-meta params mdata))))))
copy-token
(mf/use-fn
(mf/deps created)
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard (:token created))
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "dashboard.access-tokens.copied-success")
:timeout 7000}))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form :on-submit on-submit}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click on-close}
close-icon]]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "text"
:auto-focus? true
:form form
:name :name
:disabled @created?
:label (tr "modals.create-access-token.name.label")
:show-success? true
:placeholder (tr "modals.create-access-token.name.placeholder")}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :select-title)}
(tr "modals.create-access-token.expiration-date.label")]
[:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"}
{:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"}
{:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"}
{:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"}
{:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}]
:default "never"
:disabled @created?
:name :expiration-date}]
(when @created?
[:span {:class (stl/css :token-created-info)}
(if (:expires-at created)
(tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP"))
(tr "dashboard.access-tokens.token-will-not-expire"))])]
[:div {:class (stl/css :fields-row)}
(when @created?
[:div {:class (stl/css :custon-input-wrapper)}
[:input {:type "text"
:value (:token created "")
:class (stl/css :custom-input-token)
:read-only true}]
[:button {:title (tr "modals.create-access-token.copy-token")
:class (stl/css :copy-btn)
:on-click copy-token}
clipboard-icon]])
#_(when @created?
[:button {:class (stl/css :copy-btn)
:title (tr "modals.create-access-token.copy-token")
:on-click copy-token}
[:span {:class (stl/css :token-value)} (:token created "")]
[:span {:class (stl/css :icon)}
i/clipboard]])]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(if @created?
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.close")
:on-click modal/hide!}]
[:*
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click modal/hide!}]
[:> fm/submit-button*
{:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]]))
(mf/defc access-tokens-hero
[]
(let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))]
[:div {:class (stl/css :access-tokens-hero)}
[:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")]
[:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")]
[:button {:class (stl/css :hero-btn)
:on-click on-click}
(tr "dashboard.access-tokens.create")]]))
(mf/defc access-token-actions
[{:keys [on-delete]}]
(let [local (mf/use-state {:menu-open false})
show? (:menu-open @local)
options (mf/with-memo [on-delete]
[{:name (tr "labels.delete")
:id "access-token-delete"
:handler on-delete}])
menu-ref (mf/use-ref)
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
on-menu-click
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(swap! local assoc :menu-open true)))
on-keydown
(mf/use-fn
(mf/deps on-menu-click)
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))]
[:button {:class (stl/css :menu-btn)
:tab-index "0"
:ref menu-ref
:on-click on-menu-click
:on-key-down on-keydown}
menu-icon
[:> context-menu*
{:on-close on-menu-close
:show show?
:fixed true
:min-width true
:top "auto"
:left "auto"
:options options}]]))
(mf/defc access-token-item
{::mf/wrap [mf/memo]}
[{:keys [token] :as props}]
(let [expires-at (:expires-at token)
expires-txt (some-> expires-at (ct/format-inst "PPP"))
expired? (and (some? expires-at) (> (ct/now) expires-at))
delete-fn
(mf/use-fn
(mf/deps token)
(fn []
(let [params {:id (:id token)}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))))))
on-delete
(mf/use-fn
(mf/deps delete-fn)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-acces-token.title")
:message (tr "modals.delete-acces-token.message")
:accept-label (tr "modals.delete-acces-token.accept")
:on-accept delete-fn}))))]
[:div {:class (stl/css :table-row)}
[:div {:class (stl/css :table-field :field-name)}
(str (:name token))]
[:div {:class (stl/css-case :expiration-date true
:expired expired?)}
(cond
(nil? expires-at) (tr "dashboard.access-tokens.no-expiration")
expired? (tr "dashboard.access-tokens.expired-on" expires-txt)
:else (tr "dashboard.access-tokens.expires-on" expires-txt))]
[:div {:class (stl/css :table-field :actions)}
[:& access-token-actions
{:on-delete on-delete}]]]))
(mf/defc access-tokens-page
[]
(let [tokens (mf/deref tokens-ref)]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.access-tokens"))
(st/emit! (du/fetch-access-tokens)))
[:div {:class (stl/css :dashboard-access-tokens)}
[:& access-tokens-hero]
(if (empty? tokens)
[:div {:class (stl/css :access-tokens-empty)}
[:div (tr "dashboard.access-tokens.empty.no-access-tokens")]
[:div (tr "dashboard.access-tokens.empty.add-one")]]
[:div {:class (stl/css :dashboard-table)}
[:div {:class (stl/css :table-rows)}
(for [token tokens]
[:& access-token-item {:token token :key (:id token)}])]])]))

View File

@ -1,202 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
// ACCESS TOKENS PAGE
.dashboard-access-tokens {
display: grid;
grid-template-rows: auto 1fr;
margin: deprecated.$s-80 auto deprecated.$s-120 auto;
gap: deprecated.$s-32;
width: deprecated.$s-800;
}
// hero
.access-tokens-hero {
display: grid;
grid-template-rows: auto auto 1fr;
gap: deprecated.$s-32;
width: deprecated.$s-500;
font-size: deprecated.$fs-14;
margin: deprecated.$s-16 auto 0 auto;
}
.hero-title {
@include deprecated.bigTitleTipography;
color: var(--title-foreground-color-hover);
}
.hero-desc {
color: var(--title-foreground-color);
margin-bottom: 0;
font-size: deprecated.$fs-14;
}
.hero-btn {
@extend .button-primary;
}
// table empty
.access-tokens-empty {
display: grid;
place-items: center;
align-content: center;
height: deprecated.$s-156;
max-width: deprecated.$s-1000;
width: 100%;
padding: deprecated.$s-32;
border: deprecated.$s-1 solid var(--panel-border-color);
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
// Access tokens table
.dashboard-table {
height: fit-content;
}
.table-rows {
display: grid;
grid-auto-rows: deprecated.$s-64;
gap: deprecated.$s-16;
width: 100%;
height: 100%;
max-width: deprecated.$s-1000;
margin-top: deprecated.$s-16;
color: var(--title-foreground-color);
}
.table-row {
display: grid;
grid-template-columns: 43% 1fr auto;
align-items: center;
height: deprecated.$s-64;
width: 100%;
padding: 0 deprecated.$s-16;
border-radius: deprecated.$br-8;
background-color: var(--dashboard-list-background-color);
color: var(--dashboard-list-foreground-color);
}
.field-name {
@include deprecated.textEllipsis;
display: grid;
width: 43%;
min-width: deprecated.$s-300;
}
.expiration-date {
@include deprecated.flexCenter;
min-width: deprecated.$s-76;
width: fit-content;
height: deprecated.$s-24;
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
.expired {
@include deprecated.headlineSmallTypography;
padding: 0 deprecated.$s-6;
color: var(--pill-foreground-color);
background-color: var(--status-widget-background-color-warning);
}
.actions {
position: relative;
}
.menu-icon {
@extend .button-icon;
stroke: var(--icon-foreground);
}
.menu-btn {
@include deprecated.buttonStyle;
}
// Create access token modal
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
min-width: deprecated.$s-408;
}
.modal-header {
margin-bottom: deprecated.$s-24;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend .modal-close-btn-base;
}
.modal-content {
@include deprecated.flexColumn;
gap: deprecated.$s-24;
@include deprecated.bodySmallTypography;
margin-bottom: deprecated.$s-24;
}
.select-title {
@include deprecated.bodySmallTypography;
color: var(--modal-title-foreground-color);
}
.custon-input-wrapper {
@include deprecated.flexRow;
border-radius: deprecated.$br-8;
height: deprecated.$s-32;
background-color: var(--input-background-color);
}
.custom-input-token {
@extend .input-element;
@include deprecated.bodySmallTypography;
margin: 0;
flex-grow: 1;
&:focus {
outline: none;
border: deprecated.$s-1 solid var(--input-border-color-active);
}
}
.token-value {
@include deprecated.textEllipsis;
@include deprecated.bodySmallTypography;
flex-grow: 1;
}
.copy-btn {
@include deprecated.flexCenter;
@extend .button-secondary;
height: deprecated.$s-28;
width: deprecated.$s-28;
}
.clipboard-icon {
@extend .button-icon-small;
}
.token-created-info {
color: var(--modal-text-foreground-color);
}
.action-buttons {
@extend .modal-action-btns;
button {
@extend .modal-accept-btn;
}
}
.cancel-button {
@extend .modal-cancel-btn;
}

View File

@ -0,0 +1,633 @@
;; 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.main.ui.settings.integrations
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.forms :as fc]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
(def notification-timeout 7000)
(def ^:private schema:form-access-token
[:map
[:name [::sm/text {:max 250}]]
[:expiration-date [::sm/text {:max 250}]]])
(def ^:private schema:form-mcp-key
[:map
[:expiration-date [::sm/text {:max 250}]]])
(def form-initial-data-access-token
{:name ""
:expiration-date "never"})
(def form-initial-data-mcp-key
{:expiration-date "never"})
(mf/defc input-copy*
{::mf/private true}
[{:keys [value on-copy-to-clipboard]}]
[:div {:class (stl/css :input-copy)}
[:> input* {:type "text"
:default-value value
:read-only true}]
[:div {:class (stl/css :input-copy-button-wrapper)}
[:> icon-button* {:variant "secondary"
:class (stl/css :input-copy-button)
:aria-label (tr "integrations.copy-to-clipboard")
:on-click on-copy-to-clipboard
:icon i/clipboard}]]])
(mf/defc token-created*
{::mf/private true}
[{:keys [title mcp-key?]}]
(let [token-created (mf/deref refs/access-token-created)
on-copy-to-clipboard
(mf/use-fn
(mf/deps token-created)
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard (:token token-created))
(st/emit! (ntf/show {:level :info
:type :toast
:content (if mcp-key?
(tr "integrations.notification.success.mcp-key-copied")
(tr "integrations.notification.success.token-copied"))
:timeout notification-timeout}))))]
[:div {:class (stl/css :modal-form)}
[:> text* {:as "h2"
:typography t/headline-large
:class (stl/css :color-primary)}
title]
[:> notification-pill* {:level :info
:type :context}
[:> text* {:as "div"
:typography t/body-small
:class (stl/css :color-primary)}
(if mcp-key?
(tr "integrations.mcp-key.info.non-recuperable")
(tr "integrations.token.info.non-recuperable"))]]
[:div {:class (stl/css :modal-content)}
[:> input-copy* {:value (:token token-created "")
:on-copy-to-clipboard on-copy-to-clipboard}]
[:> text* {:as "div"
:typography t/body-small
:class (stl/css :color-secondary)}
(if (:expires-at token-created)
(if mcp-key?
(tr "integrations.mcp-key.will-expire" (ct/format-inst (:expires-at token-created) "PPP"))
(tr "integrations.token.will-expire" (ct/format-inst (:expires-at token-created) "PPP")))
(if mcp-key?
(tr "integrations.mcp-key.will-not-expire")
(tr "integrations.token.will-not-expire")))]]
(when mcp-key?
[:div {:class (stl/css :modal-content)}
[:> text* {:as "div"
:typography t/body-small
:class (stl/css :color-primary)}
(tr "integrations.info.mcp-client-config")]
[:textarea {:class (stl/css :textarea)
:wrap "off"
:rows 7
:read-only true}
(dm/str
"{\n"
" \"mcpServers\": {\n"
" \"penpot\": {\n"
" \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n"
" }\n"
" }"
"\n}")]])
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.close")]]]))
(mf/defc create-token*
{::mf/private true}
[{:keys [title info mcp-key? on-created]}]
(let [form (fm/use-form
:initial (if mcp-key?
form-initial-data-mcp-key
form-initial-data-access-token)
:schema (if mcp-key?
schema:form-mcp-key
schema:form-access-token))
on-error
(mf/use-fn
#(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide)))
on-success
(mf/use-fn
#(st/emit! (du/fetch-access-tokens)
(ntf/success (tr "integrations.notification.success.created"))
(on-created)))
on-submit
(mf/use-fn
(fn [form]
(let [cdata (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
expiration (:expiration-date cdata)
params (cond-> {:name (:name cdata)
:perms (:perms cdata)}
(not= "never" expiration) (assoc :expiration expiration)
(true? mcp-key?) (assoc :type "mcp"
:name "MCP key"))]
(st/emit! (du/create-access-token (with-meta params mdata))))))]
[:> fc/form* {:form form
:class (stl/css :modal-form)
:on-submit on-submit}
[:> text* {:as "h2"
:typography t/headline-large
:class (stl/css :color-primary)}
title]
(when (some? info)
[:> notification-pill* {:level :info
:type :context}
[:> text* {:as "div"
:typography t/body-small
:class (stl/css :color-primary)}
info]])
(if mcp-key?
[:div {:class (stl/css :modal-content)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.info.mcp-server")]]
[:div {:class (stl/css :modal-content)}
[:> fc/form-input* {:type "text"
:auto-focus? true
:form form
:name :name
:label (tr "integrations.name.label")
:placeholder (tr "integrations.name.placeholder")}]])
[:div {:class (stl/css :modal-content)}
[:> text* {:as "label"
:typography t/body-small
:for :expiration-date
:class (stl/css :color-primary)}
(tr "integrations.expiration-date.label")]
[:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"}
{:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"}
{:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"}
{:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"}
{:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}]
:default-selected "never"
:name :expiration-date}]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"}
title]]]))
(mf/defc create-access-token-modal
{::mf/register modal/components
::mf/register-as :create-access-token}
[]
(let [created? (mf/use-state false)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
#(reset! created? true))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.create-access-token.title.created")}]
[:> create-token* {:title (tr "integrations.create-access-token.title")
:on-created on-created}])]]))
(mf/defc generate-mcp-key-modal
{::mf/register modal/components
::mf/register-as :generate-mcp-key}
[]
(let [created? (mf/use-state false)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
(fn []
(st/emit! (du/update-profile-props {:mcp-enabled true})
(ev/event {::ev/name "generate-mcp-key"
::ev/origin "integrations"})
(ev/event {::ev/name "enable-mcp"
::ev/origin "integrations"
:source "key-creation"})
(mbc/event :mcp/enable {}))
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.generate-mcp-key.title.created")
:mcp-key? true}]
[:> create-token* {:title (tr "integrations.generate-mcp-key.title")
:mcp-key? true
:on-created on-created}])]]))
(mf/defc regenerate-mcp-key-modal
{::mf/register modal/components
::mf/register-as :regenerate-mcp-key}
[]
(let [created? (mf/use-state false)
tokens (mf/deref refs/access-tokens)
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-key-id (:id mcp-key)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
(fn []
(st/emit! (du/delete-access-token {:id mcp-key-id})
(du/update-profile-props {:mcp-enabled true})
(ev/event {::ev/name "regenerate-mcp-key"
::ev/origin "integrations"})
(mbc/event :mcp/enable {}))
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created")
:mcp-key? true}]
[:> create-token* {:title (tr "integrations.regenerate-mcp-key.title")
:info (tr "integrations.regenerate-mcp-key.info")
:mcp-key? true
:on-created on-created}])]]))
(mf/defc token-item*
{::mf/private true
::mf/wrap [mf/memo]}
[{:keys [name expires-at on-delete]}]
(let [expires-txt (some-> expires-at (ct/format-inst "PPP"))
expired? (and (some? expires-at) (> (ct/now) expires-at))
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
handle-menu-close
(mf/use-fn
#(reset! menu-open* false))
handle-menu-click
(mf/use-fn
#(reset! menu-open* (not menu-open?)))
handle-open-confirm-modal
(mf/use-fn
(mf/deps on-delete)
(fn []
(st/emit! (modal/show {:type :confirm
:title (tr "integrations.delete-token.title")
:message (tr "integrations.delete-token.message")
:accept-label (tr "integrations.delete-token.accept")
:on-accept on-delete}))))
options
(mf/with-memo [on-delete]
[{:name (tr "labels.delete")
:id "token-delete"
:handler handle-open-confirm-modal}])]
[:div {:class (stl/css :item)}
[:> text* {:as "div"
:typography t/body-medium
:title name
:class (stl/css :item-title)}
name]
[:> text* {:as "div"
:typography t/body-small
:class (stl/css-case :item-subtitle true
:warning expired?)}
(cond
(nil? expires-at) (tr "integrations.no-expiration")
expired? (tr "integrations.expired-on" expires-txt)
:else (tr "integrations.expires-on" expires-txt))]
[:div {:class (stl/css :item-actions)}
[:> icon-button* {:variant "ghost"
:class (stl/css :item-button)
:aria-pressed menu-open?
:aria-label (tr "labels.options")
:on-click handle-menu-click
:icon i/menu}]
[:> context-menu* {:on-close handle-menu-close
:show menu-open?
:min-width true
:top -10
:left -138
:options options}]]]))
(mf/defc mcp-server-section*
{::mf/private true}
[]
(let [tokens (mf/deref refs/access-tokens)
profile (mf/deref refs/profile)
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-enabled? (true? (-> profile :props :mcp-enabled))
expires-at (:expires-at mcp-key)
expired? (and (some? expires-at) (> (ct/now) expires-at))
show-enabled? (and mcp-enabled? (false? expired?))
tooltip-id
(mf/use-id)
handle-mcp-change
(mf/use-fn
(fn [value]
(st/emit! (du/update-profile-props {:mcp-enabled value})
(ntf/show {:level :info
:type :toast
:content (if (true? value)
(tr "integrations.notification.success.mcp-server-enabled")
(tr "integrations.notification.success.mcp-server-disabled"))
:timeout notification-timeout})
(ev/event {::ev/name (if (true? value) "enable-mcp" "disable-mcp")
::ev/origin "integrations"
:source "toggle"})
(if value
(mbc/event :mcp/enable {})
(mbc/event :mcp/disable {})))))
handle-generate-mcp-key
(mf/use-fn
#(st/emit! (modal/show {:type :generate-mcp-key})))
handle-regenerate-mcp-key
(mf/use-fn
#(st/emit! (modal/show {:type :regenerate-mcp-key})))
handle-delete
(mf/use-fn
(mf/deps mcp-key)
(fn []
(let [params {:id (:id mcp-key)}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))
(du/update-profile-props {:mcp-enabled false})
(mbc/event :mcp/disable {})))))
on-copy-to-clipboard
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard cf/mcp-server-url)
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "integrations.notification.success.copied-link")
:timeout notification-timeout})
(ev/event {::ev/name "copy-mcp-url"
::ev/origin "integrations"}))))]
[:section {:class (stl/css :mcp-server-section)}
[:div
[:div {:class (stl/css :title)}
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :color-primary :mcp-server-title)}
(tr "integrations.mcp-server.title")]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :beta)}
(tr "integrations.mcp-server.title.beta")]]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.mcp-server.description")]]
[:div
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status")]
[:div {:class (stl/css :mcp-server-block)}
(when expired?
[:> notification-pill* {:level :error
:type :context}
[:div {:class (stl/css :mcp-server-notification)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status.expired.0")]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status.expired.1")]]])
[:div {:class (stl/css :mcp-server-switch)}
[:> switch* {:label (if show-enabled?
(tr "integrations.mcp-server.status.enabled")
(tr "integrations.mcp-server.status.disabled"))
:default-checked show-enabled?
:on-change handle-mcp-change}]
(when (and (false? mcp-enabled?) (nil? mcp-key))
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-generate-mcp-key}])
(when (true? expired?)
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-regenerate-mcp-key}])]]]
(when (some? mcp-key)
[:div {:class (stl/css :mcp-server-key)}
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.mcp-keys.title")]
[:div {:class (stl/css :mcp-server-block)}
[:div {:class (stl/css :mcp-server-regenerate)}
[:> button* {:variant "primary"
:class (stl/css :fit-content)
:on-click handle-regenerate-mcp-key}
(tr "integrations.mcp-server.mcp-keys.regenerate")]
[:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip")
:id tooltip-id}
[:> icon* {:icon-id i/info
:class (stl/css :color-secondary)}]]]
[:div {:class (stl/css :list)}
[:> token-item* {:key (:id mcp-key)
:name (:name mcp-key)
:expires-at (:expires-at mcp-key)
:on-delete handle-delete}]]]])
[:> notification-pill* {:level :default
:type :context}
[:div {:class (stl/css :mcp-server-notification)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.mcp-server.mcp-keys.info")]
[:> input-copy* {:value (dm/str cf/mcp-server-url "?userToken=")
:on-copy-to-clipboard on-copy-to-clipboard}]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
[:a {:href cf/mcp-help-center-uri
:target "_blank"
:rel "noopener noreferrer"
:class (stl/css :mcp-server-notification-link)}
(tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]]))
(mf/defc access-tokens-section*
{::mf/private true}
[]
(let [tokens (mf/deref refs/access-tokens)
handle-click
(mf/use-fn
#(st/emit! (modal/show {:type :create-access-token})))
handle-delete
(mf/use-fn
(fn [token-id]
(let [params {:id token-id}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))))))]
[:section {:class (stl/css :access-tokens-section)}
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :color-primary)}
(tr "integrations.access-tokens.personal")]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.access-tokens.personal.description")]
[:> button* {:variant "primary"
:class (stl/css :fit-content)
:on-click handle-click}
(tr "integrations.access-tokens.create")]
(if (empty? tokens)
[:div {:class (stl/css :frame)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary :text-center)}
[:div (tr "integrations.access-tokens.empty.no-access-tokens")]
[:div (tr "integrations.access-tokens.empty.add-one")]]]
[:div {:class (stl/css :list)}
(for [token tokens]
(when (nil? (:type token))
[:> token-item* {:key (:id token)
:name (:name token)
:expires-at (:expires-at token)
:on-delete (partial handle-delete (:id token))}]))])]))
(mf/defc integrations-page*
[]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.integrations"))
(st/emit! (du/fetch-access-tokens)))
[:div {:class (stl/css :integrations)}
[:> heading* {:level 1
:typography t/title-large
:class (stl/css :color-primary)}
(tr "integrations.title")]
(when (contains? cf/flags :mcp)
[:> mcp-server-section*])
(when (and (contains? cf/flags :mcp)
(contains? cf/flags :access-tokens))
[:hr {:class (stl/css :separator)}])
(when (contains? cf/flags :access-tokens)
[:> access-tokens-section*])])

View File

@ -0,0 +1,239 @@
// 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
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
.color-primary {
color: var(--color-foreground-primary);
}
.color-secondary {
color: var(--color-foreground-secondary);
}
.text-center {
text-align: center;
}
.fit-content {
inline-size: fit-content;
}
.beta {
color: var(--color-accent-primary);
border: $b-1 solid var(--color-accent-primary);
inline-size: fit-content;
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-4;
}
.title {
display: flex;
flex-direction: row;
align-items: baseline;
gap: var(--sp-s);
}
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
inline-size: $sz-400;
max-block-size: fit-content;
position: relative;
}
.modal-content {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.modal-form {
display: flex;
flex-direction: column;
gap: var(--sp-xxxl);
}
.modal-close-button {
position: absolute;
top: var(--sp-s);
right: var(--sp-s);
}
.modal-footer {
display: flex;
justify-content: right;
gap: var(--sp-s);
}
.input-copy {
position: relative;
}
.input-copy-button-wrapper {
position: absolute;
top: 0;
right: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.input-copy-button {
border-radius: 0 $br-8 $br-8 0;
}
.integrations {
display: grid;
grid-template-rows: auto 1fr;
margin: $sz-88 auto $sz-120 auto;
gap: $sz-32;
inline-size: $sz-500;
}
.access-tokens-section {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var(--sp-m);
}
.mcp-server-section {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.mcp-server-key {
display: flex;
flex-direction: column;
}
.mcp-server-notification {
display: flex;
flex-direction: column;
gap: var(--sp-m);
padding-right: var(--sp-xxl);
}
.mcp-server-notification-link {
cursor: pointer;
color: var(--color-accent-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--sp-xs);
}
.mcp-server-title {
margin: var(--sp-s) 0;
}
.mcp-server-block {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.mcp-server-regenerate {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.mcp-server-switch {
position: relative;
}
.mcp-server-switch-cover {
position: absolute;
inset-block: 0;
inset-inline: 0;
}
.separator {
border: $b-1 solid var(--color-background-quaternary);
margin: var(--sp-s) 0;
}
.frame {
border: $b-1 solid var(--color-background-quaternary);
padding: var(--sp-m);
border-radius: $br-8;
}
.list {
display: grid;
grid-auto-rows: $sz-64;
gap: var(--sp-m);
}
.item {
display: grid;
grid-template-columns: 45% 1fr auto;
align-items: center;
background-color: var(--color-background-tertiary);
border-radius: $br-8;
}
.item-title {
@include textEllipsis;
align-content: center;
block-size: $sz-64;
padding: 0 var(--sp-l);
color: var(--color-foreground-primary);
}
.item-subtitle {
align-content: center;
block-size: $sz-64;
color: var(--color-foreground-secondary);
&.warning {
padding: var(--sp-s) var(--sp-m);
block-size: fit-content;
inline-size: fit-content;
color: var(--color-foreground-primary);
background-color: var(--color-background-warning);
border: $b-1 solid var(--color-accent-warning);
border-radius: $br-8;
}
}
.item-actions {
position: relative;
}
.item-button {
block-size: $sz-64;
inline-size: $sz-48;
border-radius: 0 var(--sp-s) var(--sp-s) 0;
}
.textarea {
@include t.use-typography("body-small");
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-secondary);
padding: var(--sp-xs) var(--sp-s);
border: 0;
resize: none;
&:hover {
background-color: var(--color-background-quaternary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
}
}

View File

@ -43,8 +43,8 @@
(def ^:private go-settings-subscription
#(st/emit! (rt/nav :settings-subscription)))
(def ^:private go-settings-access-tokens
#(st/emit! (rt/nav :settings-access-tokens)))
(def ^:private go-settings-integrations
#(st/emit! (rt/nav :settings-integrations)))
(def ^:private go-settings-notifications
#(st/emit! (rt/nav :settings-notifications)))
@ -66,7 +66,7 @@
options? (= section :settings-options)
feedback? (= section :settings-feedback)
subscription? (= section :settings-subscription)
access-tokens? (= section :settings-access-tokens)
integrations? (= section :settings-integrations)
notifications? (= section :settings-notifications)
team-id (or (dtm/get-last-team-id)
(:default-team-id profile))
@ -115,12 +115,13 @@
:data-testid "settings-subscription"}
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
(when (contains? cf/flags :access-tokens)
[:li {:class (stl/css-case :current access-tokens?
(when (or (contains? cf/flags :access-tokens)
(contains? cf/flags :mcp))
[:li {:class (stl/css-case :current integrations?
:settings-item true)
:on-click go-settings-access-tokens
:data-testid "settings-access-tokens"}
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]])
:on-click go-settings-integrations
:data-testid "settings-integrations"}
[:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
[:hr {:class (stl/css :sidebar-separator)}]

View File

@ -15,7 +15,6 @@
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom]
@ -27,12 +26,10 @@
;; --- Header Component
(mf/defc left-header*
[{:keys [file layout project page-id class]}]
(let [profile (mf/deref refs/profile)
file-id (:id file)
[{:keys [file layout project class]}]
(let [file-id (:id file)
file-name (:name file)
project-id (:id project)
team-id (:team-id project)
shared? (:is-shared file)
persistence
(mf/deref refs/persistence)
@ -40,8 +37,6 @@
persistence-status
(get persistence :status)
read-only? (mf/use-ctx ctx/workspace-read-only?)
editing* (mf/use-state false)
editing? (deref editing*)
input-ref (mf/use-ref nil)
@ -137,10 +132,5 @@
(when ^boolean shared?
[:span {:class (stl/css :shared-badge)} deprecated-icon/library])
[:div {:class (stl/css :menu-section)}
[:& main-menu/menu
{:layout layout
:file file
:profile profile
:read-only? read-only?
:team-id team-id
:page-id page-id}]]]))
[:> main-menu/menu* {:layout layout
:file file}]]]))

File diff suppressed because it is too large Load Diff

View File

@ -4,125 +4,178 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography.scss" as t;
@use "ds/z-index.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.base-menu {
position: absolute;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-xs);
border-radius: $br-8;
z-index: var(--z-index-dropdown);
background-color: var(--menu-background-color);
border: $b-2 solid var(--panel-border-color);
box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color);
}
.menu {
@extend .menu-dropdown;
top: deprecated.$s-48;
left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16);
width: deprecated.$s-192;
margin: 0;
}
.menu-item {
@extend .menu-item-base;
cursor: pointer;
.open-arrow {
@include deprecated.flexCenter;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
&:hover {
color: var(--menu-foreground-color-hover);
.open-arrow {
svg {
stroke: var(--menu-foreground-color-hover);
}
}
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
.separator {
border-top: deprecated.$s-1 solid var(--color-background-quaternary);
height: deprecated.$s-4;
left: calc(-1 * deprecated.$s-4);
margin-top: deprecated.$s-8;
position: relative;
width: calc(100% + deprecated.$s-8);
}
.shortcut {
@extend .shortcut-base;
}
.shortcut-key {
@extend .shortcut-key-base;
top: $sz-48;
left: calc(var(--right-sidebar-width) - $sz-40);
inline-size: $sz-192;
}
.sub-menu {
@extend .menu-dropdown;
left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180);
width: deprecated.$s-192;
min-width: calc(deprecated.$s-272 - deprecated.$s-2);
width: 110%;
left: calc(var(--right-sidebar-width) + $sz-154);
min-width: $sz-284;
width: 115%;
.submenu-item {
@extend .menu-item-base;
&:hover {
color: var(--menu-foreground-color-hover);
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
&.pos-1 {
top: calc($sz-16 + $sz-32);
}
.menu-disabled {
color: var(--color-foreground-secondary);
&:hover {
cursor: default;
color: var(--color-foreground-secondary);
background-color: var(--menu-background-color);
}
&.pos-2 {
top: calc($sz-16 + (2 * $sz-32));
}
&.file {
top: deprecated.$s-48;
&.pos-3 {
top: calc($sz-16 + (3 * $sz-32));
}
&.edit {
top: deprecated.$s-76;
&.pos-4 {
top: calc($sz-16 + (4 * $sz-32));
}
&.view {
top: deprecated.$s-116;
&.pos-5 {
top: calc($sz-16 + (5 * $sz-32));
}
&.preferences {
top: deprecated.$s-148;
&.pos-6 {
top: calc($sz-16 + (6 * $sz-32));
}
&.pos-final-5 {
top: calc($sz-32 + (5 * $sz-32));
}
&.pos-final-6 {
top: calc($sz-32 + (6 * $sz-32));
}
&.pos-final-7 {
top: calc($sz-32 + (7 * $sz-32));
}
&.plugins {
top: deprecated.$s-180;
max-height: calc(100vh - deprecated.$s-180);
max-height: calc(100vh - $sz-200);
overflow-x: hidden;
overflow-y: auto;
}
}
&.help-info {
top: deprecated.$s-232;
.base-menu-item {
@include t.use-typography("body-small");
display: grid;
align-items: center;
grid-template-columns: auto $sz-16 $sz-16;
grid-template-areas: "name indicator arrow";
block-size: $sz-28;
inline-size: 100%;
padding: $sz-6;
border-radius: $br-8;
color: var(--menu-foreground-color);
background-color: var(--menu-background-color);
&:hover {
--menu-foreground-color: var(--menu-foreground-color-hover);
--menu-background-color: var(--menu-background-color-hover);
--menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover);
--menu-icon-foreground-color: var(--menu-foreground-color-hover);
}
&.help-info-old {
top: deprecated.$s-192;
&.disabled {
--menu-foreground-color: var(--color-foreground-secondary);
pointer-events: none;
}
}
.menu-item {
display: grid;
align-items: center;
grid-template-columns: auto $sz-16 $sz-16;
grid-template-areas: "name indicator arrow";
}
.submenu-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-name {
grid-area: name;
}
.item-indicator {
--menu-indicator-color: var(--color-foreground-secondary);
grid-area: indicator;
display: flex;
align-items: center;
justify-content: center;
inline-size: px2rem(8);
block-size: px2rem(8);
border-radius: $br-circle;
background-color: var(--menu-indicator-color);
&.active {
--menu-indicator-color: var(--color-accent-primary);
}
&.failed {
--menu-indicator-color: var(--color-foreground-error);
}
}
.item-arrow {
grid-area: arrow;
color: var(--menu-icon-foreground-color);
}
.item-icon {
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
color: var(--menu-icon-foreground-color);
display: flex;
align-items: center;
justify-content: center;
}
.separator {
position: relative;
block-size: var(--sp-xs);
inline-size: calc(100% + var(--sp-s));
border-top: $b-1 solid var(--color-background-quaternary);
left: calc(-1 * var(--sp-xs));
margin-top: var(--sp-s);
}
.shortcut {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-xxs);
color: var(--menu-shortcut-foreground-color);
}
.shortcut-key {
@include t.use-typography("body-small");
display: flex;
align-items: center;
justify-content: center;
height: px2rem(20);
padding: var(--sp-xxs) px2rem(6);
border-radius: $br-6;
background-color: var(--menu-shortcut-background-color);
}

View File

@ -119,7 +119,7 @@
(mf/defc left-sidebar*
{::mf/memo true}
[{:keys [layout file page-id tokens-lib active-tokens resolved-active-tokens]}]
[{:keys [layout file tokens-lib active-tokens resolved-active-tokens]}]
(let [options-mode (mf/deref refs/options-mode-global)
project (mf/deref refs/project)
file-id (get file :id)
@ -185,12 +185,10 @@
:class aside-class
:style {:--left-sidebar-width (dm/str width "px")}}
[:> left-header*
{:file file
:layout layout
:project project
:page-id page-id
:class (stl/css :left-header)}]
[:> left-header* {:file file
:layout layout
:project project
:class (stl/css :left-header)}]
[:div {:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture

View File

@ -291,7 +291,7 @@
:r4 "Bottom Left"
:r3 "Bottom Right"}
:hint (tr "workspace.tokens.radius")
:on-update-shape-all dwta/update-shape-radius-all
:on-update-shape-all dwta/update-shape-radius
:on-update-shape update-shape-radius-for-corners})
shadow (partial generic-attribute-actions #{:shadow} "Shadow")]
{:border-radius border-radius

View File

@ -81,6 +81,7 @@
selected))
(mf/defc viewport-classic*
{::mf/private true}
[{:keys [selected wglobal layout file page palete-size]}]
(let [{:keys [edit-path
panning
@ -108,8 +109,8 @@
;; DEREFS
drawing (mf/deref refs/workspace-drawing)
focus (mf/deref refs/workspace-focus-selected)
file-id (get file :id)
vern (get file :vern)
page-id (get page :id)
objects (get page :objects)
background (get page :background clr/canvas)
@ -340,7 +341,7 @@
:opacity 0.6}}
(when (and (:can-edit permissions) (not read-only?))
[:& stvh/viewport-texts
{:key (dm/str "texts-" page-id)
{:key (dm/str "viewport-texts-" page-id "-" vern)
:page-id page-id
:objects objects
:modifiers modifiers
@ -366,7 +367,7 @@
:xmlnsXlink "http://www.w3.org/1999/xlink"
:xmlns:penpot "https://penpot.app/xmlns"
:preserveAspectRatio "xMidYMid meet"
:key (str "render" page-id)
:key (dm/str "viewport-svg-" page-id "-" vern)
:width (:width vport 0)
:height (:height vport 0)
:view-box (utils/format-viewbox vbox)
@ -400,7 +401,7 @@
[:& (mf/provider ctx/current-vbox) {:value vbox'}
[:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)}
;; Render root shape
[:& shapes/root-shape {:key page-id
[:& shapes/root-shape {:key (str page-id)
:objects base-objects
:active-frames @active-frames}]]]]
@ -408,7 +409,7 @@
{:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:preserveAspectRatio "xMidYMid meet"
:key (str "viewport" page-id)
:key (dm/str "viewport-controls-" page-id "-" vern)
:view-box (utils/format-viewbox vbox)
:ref on-viewport-ref
:class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls))
@ -719,7 +720,7 @@
(not= @hover-top-frame-id (:id frame)))
[:& grid-layout/editor
{:zoom zoom
:key (dm/str (:id frame))
:key (dm/str "viewport-frame-" (:id frame))
:objects base-objects
:modifiers modifiers
:shape frame
@ -733,8 +734,11 @@
:bottom-padding (when palete-size (+ palete-size 8))}]]]]]))
(mf/defc viewport*
[props]
(let [wasm-renderer-enabled? (features/use-feature "render-wasm/v1")]
(if ^boolean wasm-renderer-enabled?
[:> viewport.wasm/viewport* props]
[:> viewport-classic* props])))
[{:keys [file page] :as props}]
(let [vern (get file :vern)
page-id (get page :id)
render-wasm? (features/use-feature "render-wasm/v1")]
[:* {:key (dm/str "viewport-" page-id "-" vern)}
(if ^boolean render-wasm?
[:> viewport.wasm/viewport* props]
[:> viewport-classic* props])]))

View File

@ -8,6 +8,7 @@
"RPC for plugins runtime."
(:require
["@penpot/plugins-runtime" :as runtime]
[app.main.errors :as errors]
[app.main.features :as features]
[app.main.store :as st]
[app.plugins.api :as api]
@ -30,6 +31,8 @@
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ stream]
(set! errors/is-plugin-error? runtime/isPluginError)
(->> stream
(rx/filter (ptk/type? ::features/initialize))
(rx/observe-on :async)

View File

@ -14,9 +14,11 @@
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.types.color :as ctc]
[app.common.types.component :as ctk]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.changes :as ch]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
@ -26,6 +28,7 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :refer [fetch-font-css]]
@ -82,6 +85,10 @@
:$plugin {:enumerable false :get (fn [] plugin-id)}
;; Public properties
:version
{:this true
:get (constantly (:base cf/version))}
:root
{:this true
:get #(.getRoot ^js %)}
@ -110,7 +117,7 @@
(fn [_ shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :selection shapes)
(u/not-valid plugin-id :selection shapes)
:else
(let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)]
@ -175,7 +182,7 @@
(fn [shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :shapesColors-shapes shapes)
(u/not-valid plugin-id :shapesColors-shapes shapes)
:else
(let [objects (u/locate-objects)
@ -195,13 +202,13 @@
new-color (parser/parse-color-data new-color)]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :replaceColor-shapes shapes)
(u/not-valid plugin-id :replaceColor-shapes shapes)
(not (sm/validate ctc/schema:color old-color))
(u/display-not-valid :replaceColor-oldColor old-color)
(u/not-valid plugin-id :replaceColor-oldColor old-color)
(not (sm/validate ctc/schema:color new-color))
(u/display-not-valid :replaceColor-newColor new-color)
(u/not-valid plugin-id :replaceColor-newColor new-color)
:else
(let [file-id (:current-file-id @st/state)
@ -254,10 +261,10 @@
(fn [name url]
(cond
(not (string? name))
(u/display-not-valid :uploadMedia-name name)
(u/not-valid plugin-id :uploadMedia-name name)
(not (string? url))
(u/display-not-valid :uploadMedia-url url)
(u/not-valid plugin-id :uploadMedia-url url)
:else
(let [file-id (:current-file-id @st/state)]
@ -288,7 +295,7 @@
(fn [shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :group-shapes shapes)
(u/not-valid plugin-id :group-shapes shapes)
:else
(let [file-id (:current-file-id @st/state)
@ -303,10 +310,10 @@
(fn [group & rest]
(cond
(not (shape/shape-proxy? group))
(u/display-not-valid :ungroup group)
(u/not-valid plugin-id :ungroup group)
(and (some? rest) (not (every? shape/shape-proxy? rest)))
(u/display-not-valid :ungroup rest)
(u/not-valid plugin-id :ungroup rest)
:else
(let [shapes (concat [group] rest)
@ -346,7 +353,7 @@
(fn [text]
(cond
(or (not (string? text)) (empty? text))
(u/display-not-valid :createText text)
(u/not-valid plugin-id :createText text)
:else
(let [page (dsh/lookup-page @st/state)
@ -377,7 +384,7 @@
(fn [svg-string]
(cond
(or (not (string? svg-string)) (empty? svg-string))
(u/display-not-valid :createShapeFromSvg svg-string)
(u/not-valid plugin-id :createShapeFromSvg svg-string)
:else
(let [id (uuid/next)
@ -394,7 +401,7 @@
(cond
(or (not (string? svg-string)) (empty? svg-string))
(do
(u/display-not-valid :createShapeFromSvg "Svg not valid")
(u/not-valid plugin-id :createShapeFromSvg "Svg not valid")
(reject "Svg not valid"))
:else
@ -412,10 +419,10 @@
(let [bool-type (keyword bool-type)]
(cond
(not (contains? cts/bool-types bool-type))
(u/display-not-valid :createBoolean-boolType bool-type)
(u/not-valid plugin-id :createBoolean-boolType bool-type)
(or (not (array? shapes)) (empty? shapes) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :createBoolean-shapes shapes)
(u/not-valid plugin-id :createBoolean-shapes shapes)
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)
@ -429,10 +436,10 @@
(let [type (d/nilv (obj/get options "type") "html")]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :generateMarkup-shapes shapes)
(u/not-valid plugin-id :generateMarkup-shapes shapes)
(and (some? type) (not (contains? #{"html" "svg"} type)))
(u/display-not-valid :generateMarkup-type type)
(u/not-valid plugin-id :generateMarkup-type type)
:else
(let [resolved-code
@ -464,16 +471,16 @@
children? (d/nilv (obj/get options "includeChildren") true)]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :generateStyle-shapes shapes)
(u/not-valid plugin-id :generateStyle-shapes shapes)
(and (some? type) (not (contains? #{"css"} type)))
(u/display-not-valid :generateStyle-type type)
(u/not-valid plugin-id :generateStyle-type type)
(and (some? prelude?) (not (boolean? prelude?)))
(u/display-not-valid :generateStyle-withPrelude prelude?)
(u/not-valid plugin-id :generateStyle-withPrelude prelude?)
(and (some? children?) (not (boolean? children?)))
(u/display-not-valid :generateStyle-includeChildren children?)
(u/not-valid plugin-id :generateStyle-includeChildren children?)
:else
(let [resolved-styles
@ -546,7 +553,7 @@
:else nil)
new-window (if (boolean? new-window) new-window false)]
(if (nil? id)
(u/display-not-valid :openPage "Expected a Page object or a page UUID string")
(u/not-valid plugin-id :openPage "Expected a Page object or a page UUID string")
(st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window)))))
:alignHorizontal
@ -558,10 +565,10 @@
nil)]
(cond
(nil? dir)
(u/display-not-valid :alignHorizontal-direction "Direction not valid")
(u/not-valid plugin-id :alignHorizontal-direction "Direction not valid")
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :alignHorizontal-shapes "Not valid shapes")
(u/not-valid plugin-id :alignHorizontal-shapes "Not valid shapes")
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -576,10 +583,10 @@
nil)]
(cond
(nil? dir)
(u/display-not-valid :alignVertical-direction "Direction not valid")
(u/not-valid plugin-id :alignVertical-direction "Direction not valid")
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :alignVertical-shapes "Not valid shapes")
(u/not-valid plugin-id :alignVertical-shapes "Not valid shapes")
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -589,7 +596,7 @@
(fn [shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :distributeHorizontal-shapes "Not valid shapes")
(u/not-valid plugin-id :distributeHorizontal-shapes "Not valid shapes")
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -599,7 +606,7 @@
(fn [shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :distributeVertical-shapes "Not valid shapes")
(u/not-valid plugin-id :distributeVertical-shapes "Not valid shapes")
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -609,8 +616,41 @@
(fn [shapes]
(cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :flatten-shapes "Not valid shapes")
(u/not-valid plugin-id :flatten-shapes "Not valid shapes")
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
(st/emit! (dw/convert-selected-to-path ids)))))))
(st/emit! (dw/convert-selected-to-path ids)))))
:createVariantFromComponents
(fn [shapes]
(cond
(or (not (seq shapes))
(not (every? u/is-main-component-proxy? shapes)))
(u/not-valid plugin-id :shapes shapes)
:else
(let [file-id (obj/get (first shapes) "$file")
page-id (obj/get (first shapes) "$page")
ids (->> shapes
(map #(obj/get % "$id"))
(into #{}))
;; Check that every component is:
;; - in the same page
;; - not already a variant
valid?
(every?
(fn [id]
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
(not (ctk/is-variant? component))))
ids)]
(if valid?
(let [variant-id (uuid/next)]
(st/emit! (dwv/combine-as-variants
ids
{:trigger "plugin:combine-as-variants" :variant-id variant-id}))
(shape/shape-proxy plugin-id variant-id))
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))))

View File

@ -60,13 +60,13 @@
(let [profile (:profile @st/state)]
(cond
(or (not (string? content)) (empty? content))
(u/display-not-valid :content "Not valid")
(u/not-valid plugin-id :content "Not valid")
(not= (:id profile) (:owner-id data))
(u/display-not-valid :content "Cannot change content from another user's comments")
(u/not-valid plugin-id :content "Cannot change content from another user's comments")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :content "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :content "Plugin doesn't have 'comment:write' permission")
:else
(->> (rp/cmd! :update-comment {:id (:id data) :content content})
@ -81,7 +81,7 @@
(cond
(not (r/check-permission plugin-id "comment:write"))
(do
(u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission")
(reject "Plugin doesn't have 'comment:write' permission"))
:else
@ -120,10 +120,10 @@
(cond
(or (not (sm/valid-safe-number? (:x position)))
(not (sm/valid-safe-number? (:y position))))
(u/display-not-valid :position "Not valid point")
(u/not-valid plugin-id :position "Not valid point")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :position "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :position "Plugin doesn't have 'comment:write' permission")
:else
(do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)]))
@ -137,10 +137,10 @@
(fn [is-resolved]
(cond
(not (boolean? is-resolved))
(u/display-not-valid :resolved "Not a boolean type")
(u/not-valid plugin-id :resolved "Not a boolean type")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :resolved "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :resolved "Plugin doesn't have 'comment:write' permission")
:else
(do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved)))
@ -153,7 +153,7 @@
(cond
(not (r/check-permission plugin-id "comment:read"))
(do
(u/display-not-valid :findComments "Plugin doesn't have 'comment:read' permission")
(u/not-valid plugin-id :findComments "Plugin doesn't have 'comment:read' permission")
(reject "Plugin doesn't have 'comment:read' permission"))
:else
@ -169,10 +169,10 @@
(fn [content]
(cond
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :reply "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :reply "Plugin doesn't have 'comment:write' permission")
(or (not (string? content)) (empty? content))
(u/display-not-valid :reply "Not valid")
(u/not-valid plugin-id :reply "Not valid")
:else
(js/Promise.
@ -186,10 +186,10 @@
owner (dsh/lookup-profile @st/state (:owner-id data))]
(cond
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission")
(not= (:id profile) owner)
(u/display-not-valid :remove "Cannot change content from another user's comments")
(u/not-valid plugin-id :remove "Cannot change content from another user's comments")
:else
(js/Promise.

View File

@ -45,10 +45,10 @@
(fn [value]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :label "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :label "Plugin doesn't have 'content:write' permission")
(or (not (string? value)) (empty? value))
(u/display-not-valid :label value)
(u/not-valid plugin-id :label value)
:else
(do (swap! data assoc :label value :created-by "user")
@ -145,7 +145,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :getPluginData-key key)
(u/not-valid plugin-id :getPluginData-key key)
:else
(let [file (u/locate-file id)]
@ -155,13 +155,13 @@
(fn [key value]
(cond
(or (not (string? key)) (empty? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(not (string? value))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value))))
@ -175,10 +175,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace)
(u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :getSharedPluginData-key key)
(u/not-valid plugin-id :getSharedPluginData-key key)
:else
(let [file (u/locate-file id)]
@ -188,16 +188,16 @@
(fn [namespace key value]
(cond
(or (not (string? namespace)) (empty? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(or (not (string? key)) (empty? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(not (string? value))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value))))
@ -206,7 +206,7 @@
(fn [namespace]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys namespace)
(u/not-valid plugin-id :getSharedPluginDataKeys namespace)
:else
(let [file (u/locate-file id)]
@ -216,7 +216,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :createPage "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :createPage "Plugin doesn't have 'content:write' permission")
:else
(let [page-id (uuid/next)]

View File

@ -6,17 +6,11 @@
(ns app.plugins.flags
(:require
[app.common.data.macros :as dm]
[app.main.store :as st]
[app.plugins.utils :as u]
[app.util.object :as obj]
[potok.v2.core :as ptk]))
(defn natural-child-ordering?
[plugin-id]
(boolean
(dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering])))
(defn clear
[id]
(ptk/reify ::reset
@ -37,13 +31,27 @@
:naturalChildOrdering
{:this false
:get
(fn [] (natural-child-ordering? plugin-id))
(fn [] (u/natural-child-ordering? plugin-id))
:set
(fn [value]
(cond
(not (boolean? value))
(u/display-not-valid :naturalChildOrdering value)
(u/not-valid plugin-id :naturalChildOrdering value)
:else
(st/emit! (set-flag plugin-id :natural-child-ordering value))))}))
(st/emit! (set-flag plugin-id :natural-child-ordering value))))}
:throwValidationErrors
{:this false
:get
(fn [] (u/throw-validation-errors? plugin-id))
:set
(fn [value]
(cond
(not (boolean? value))
(u/not-valid plugin-id :throwValidationErrors value)
:else
(st/emit! (set-flag plugin-id :throw-validation-errors value))))}))

View File

@ -12,7 +12,6 @@
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.store :as st]
[app.plugins.flags :refer [natural-child-ordering?]]
[app.plugins.register :as r]
[app.plugins.utils :as u]
[app.util.object :as obj]))
@ -39,10 +38,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/flex-direction-types value))
(u/display-not-valid :dir value)
(u/not-valid plugin-id :dir value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :dir "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))}
@ -55,10 +54,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/wrap-types value))
(u/display-not-valid :wrap value)
(u/not-valid plugin-id :wrap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :wrap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :wrap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))}
@ -71,10 +70,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-items-types value))
(u/display-not-valid :alignItems value)
(u/not-valid plugin-id :alignItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))}
@ -87,10 +86,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-content-types value))
(u/display-not-valid :alignContent value)
(u/not-valid plugin-id :alignContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))}
@ -103,10 +102,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-items-types value))
(u/display-not-valid :justifyItems value)
(u/not-valid plugin-id :justifyItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))}
@ -119,10 +118,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-content-types value))
(u/display-not-valid :justifyContent value)
(u/not-valid plugin-id :justifyContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))}
@ -134,10 +133,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rowGap value)
(u/not-valid plugin-id :rowGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))}
@ -149,10 +148,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :columnGap value)
(u/not-valid plugin-id :columnGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))}
@ -164,10 +163,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :verticalPadding value)
(u/not-valid plugin-id :verticalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))}
@ -179,10 +178,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :horizontalPadding value)
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))}
@ -195,10 +194,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :topPadding value)
(u/not-valid plugin-id :topPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))}
@ -210,10 +209,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rightPadding value)
(u/not-valid plugin-id :rightPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rightPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rightPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))}
@ -225,10 +224,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :bottomPadding value)
(u/not-valid plugin-id :bottomPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))}
@ -240,10 +239,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :leftPadding value)
(u/not-valid plugin-id :leftPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))}
@ -256,13 +255,13 @@
(fn [child]
(cond
(not (shape-proxy? child))
(u/display-not-valid :appendChild child)
(u/not-valid plugin-id :appendChild child)
:else
(let [child-id (obj/get child "$id")
shape (u/locate-shape file-id page-id id)
index
(if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape)))
(if (and (u/natural-child-ordering? plugin-id) (not (ctl/reverse? shape)))
0
(count (:shapes shape)))]
(st/emit! (dwsh/relocate-shapes #{child-id} id index)))))
@ -275,10 +274,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/item-h-sizing-types value))
(u/display-not-valid :horizontalSizing value)
(u/not-valid plugin-id :horizontalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))}
@ -291,10 +290,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/item-v-sizing-types value))
(u/display-not-valid :verticalSizing value)
(u/not-valid plugin-id :verticalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))}))
@ -317,10 +316,10 @@
(fn [_ value]
(cond
(not (boolean? value))
(u/display-not-valid :absolute value)
(u/not-valid plugin-id :absolute value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :absolute "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :absolute "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))}
@ -332,10 +331,10 @@
(fn [_ value]
(cond
(sm/valid-safe-int? value)
(u/display-not-valid :zIndex value)
(u/not-valid plugin-id :zIndex value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :zIndex "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :zIndex "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))}
@ -348,10 +347,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/item-h-sizing-types value))
(u/display-not-valid :horizontalPadding value)
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))}
@ -364,10 +363,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/item-v-sizing-types value))
(u/display-not-valid :verticalSizing value)
(u/not-valid plugin-id :verticalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))}
@ -380,10 +379,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/item-align-self-types value))
(u/display-not-valid :alignSelf value)
(u/not-valid plugin-id :alignSelf value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))}
@ -395,10 +394,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :verticalMargin value)
(u/not-valid plugin-id :verticalMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))}
@ -410,10 +409,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :horizontalMargin value)
(u/not-valid plugin-id :horizontalMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))}
@ -425,10 +424,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :topMargin value)
(u/not-valid plugin-id :topMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :topMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :topMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))}
@ -440,10 +439,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :rightMargin value)
(u/not-valid plugin-id :rightMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rightMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rightMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))}
@ -455,10 +454,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :bottomMargin value)
(u/not-valid plugin-id :bottomMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :bottomMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :bottomMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))}
@ -470,10 +469,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :leftMargin value)
(u/not-valid plugin-id :leftMargin value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :leftMargin "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :leftMargin "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))}
@ -485,10 +484,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :maxWidth value)
(u/not-valid plugin-id :maxWidth value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :maxWidth "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :maxWidth "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))}
@ -500,10 +499,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :minWidth value)
(u/not-valid plugin-id :minWidth value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :minWidth "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :minWidth "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))}
@ -515,10 +514,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :maxHeight value)
(u/not-valid plugin-id :maxHeight value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :maxHeight "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :maxHeight "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))}
@ -530,10 +529,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :minHeight value)
(u/not-valid plugin-id :minHeight value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :minHeight "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :minHeight "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))}))

View File

@ -32,7 +32,7 @@
(obj/type-of? p "FontProxy"))
(defn font-proxy
[{:keys [id family name variants] :as font}]
[plugin-id {:keys [id family name variants] :as font}]
(when (some? font)
(let [default-variant (fonts/get-default-variant font)]
(obj/reify {:name "FontProxy"}
@ -55,10 +55,10 @@
(fn [text variant]
(cond
(not (shape/shape-proxy? text))
(u/display-not-valid :applyToText text)
(u/not-valid plugin-id :applyToText text)
(not (r/check-permission (obj/get text "$plugin") "content:write"))
(u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission")
:else
(let [id (obj/get text "$id")
@ -73,10 +73,10 @@
(fn [range variant]
(cond
(not (text/text-range-proxy? range))
(u/display-not-valid :applyToRange range)
(u/not-valid plugin-id :applyToRange range)
(not (r/check-permission (obj/get range "$plugin") "content:write"))
(u/display-not-valid :applyToRange "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :applyToRange "Plugin doesn't have 'content:write' permission")
:else
(let [id (obj/get range "$id")
@ -98,53 +98,53 @@
{:get
(fn []
(format/format-array
font-proxy
(partial font-proxy plugin-id)
(vals @fonts/fontsdb)))}
:findById
(fn [id]
(cond
(not (string? id))
(u/display-not-valid :findbyId id)
(u/not-valid plugin-id :findbyId id)
:else
(->> (vals @fonts/fontsdb)
(d/seek #(str/includes? (str/lower (:id %)) (str/lower id)))
(font-proxy))))
(font-proxy plugin-id))))
:findByName
(fn [name]
(cond
(not (string? name))
(u/display-not-valid :findByName name)
(u/not-valid plugin-id :findByName name)
:else
(->> (vals @fonts/fontsdb)
(d/seek #(str/includes? (str/lower (:name %)) (str/lower name)))
(font-proxy))))
(font-proxy plugin-id))))
:findAllById
(fn [id]
(cond
(not (string? id))
(u/display-not-valid :findAllById name)
(u/not-valid plugin-id :findAllById name)
:else
(format/format-array
(fn [font]
(when (str/includes? (str/lower (:id font)) (str/lower id))
(font-proxy font)))
(font-proxy plugin-id font)))
(vals @fonts/fontsdb))))
:findAllByName
(fn [name]
(cond
(not (string? name))
(u/display-not-valid :findAllByName name)
(u/not-valid plugin-id :findAllByName name)
:else
(format/format-array
(fn [font]
(when (str/includes? (str/lower (:name font)) (str/lower name))
(font-proxy font)))
(font-proxy plugin-id font)))
(vals @fonts/fontsdb))))))

View File

@ -598,3 +598,10 @@
(case axis
:y "horizontal"
:x "vertical"))
(defn format-geom-rect
[{:keys [x y width height]}]
#js {:x x
:y y
:width width
:height height})

View File

@ -40,10 +40,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/grid-direction-types value))
(u/display-not-valid :dir value)
(u/not-valid plugin-id :dir value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :dir "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))}
@ -64,10 +64,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-items-types value))
(u/display-not-valid :alignItems value)
(u/not-valid plugin-id :alignItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))}
@ -80,10 +80,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-content-types value))
(u/display-not-valid :alignContent value)
(u/not-valid plugin-id :alignContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))}
@ -96,10 +96,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-items-types value))
(u/display-not-valid :justifyItems value)
(u/not-valid plugin-id :justifyItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))}
@ -112,10 +112,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-content-types value))
(u/display-not-valid :justifyContent value)
(u/not-valid plugin-id :justifyContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))}
@ -127,10 +127,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rowGap value)
(u/not-valid plugin-id :rowGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))}
@ -142,10 +142,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :columnGap value)
(u/not-valid plugin-id :columnGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))}
@ -157,10 +157,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :verticalPadding value)
(u/not-valid plugin-id :verticalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))}
@ -172,10 +172,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :horizontalPadding value)
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))}
@ -187,10 +187,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :topPadding value)
(u/not-valid plugin-id :topPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))}
@ -202,10 +202,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rightPadding value)
(u/not-valid plugin-id :rightPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :righPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :righPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))}
@ -217,10 +217,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :bottomPadding value)
(u/not-valid plugin-id :bottomPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))}
@ -232,10 +232,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :leftPadding value)
(u/not-valid plugin-id :leftPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))}
@ -245,14 +245,14 @@
(let [type (keyword type)]
(cond
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addRow-type type)
(u/not-valid plugin-id :addRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addRow-value value)
(u/not-valid plugin-id :addRow-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value})))))
@ -262,17 +262,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :addRowAtIndex-index index)
(u/not-valid plugin-id :addRowAtIndex-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addRowAtIndex-type type)
(u/not-valid plugin-id :addRowAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addRowAtIndex-value value)
(u/not-valid plugin-id :addRowAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addRowAtIndex "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addRowAtIndex "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index)))))
@ -282,14 +282,14 @@
(let [type (keyword type)]
(cond
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addColumn-type type)
(u/not-valid plugin-id :addColumn-type type)
(and (or (= :percent type) (= :flex type) (= :lex type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addColumn-value value)
(u/not-valid plugin-id :addColumn-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value})))))
@ -298,17 +298,17 @@
(fn [index type value]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :addColumnAtIndex-index index)
(u/not-valid plugin-id :addColumnAtIndex-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addColumnAtIndex-type type)
(u/not-valid plugin-id :addColumnAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addColumnAtIndex-value value)
(u/not-valid plugin-id :addColumnAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addColumnAtIndex "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission")
:else
(let [type (keyword type)]
@ -318,10 +318,10 @@
(fn [index]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :removeRow index)
(u/not-valid plugin-id :removeRow index)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :removeRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout-track #{id} :row index))))
@ -330,10 +330,10 @@
(fn [index]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :removeColumn index)
(u/not-valid plugin-id :removeColumn index)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :removeColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout-track #{id} :column index))))
@ -343,17 +343,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :setColumn-index index)
(u/not-valid plugin-id :setColumn-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :setColumn-type type)
(u/not-valid plugin-id :setColumn-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :setColumn-value value)
(u/not-valid plugin-id :setColumn-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value}))))))
@ -363,17 +363,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :setRow-index index)
(u/not-valid plugin-id :setRow-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :setRow-type type)
(u/not-valid plugin-id :setRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :setRow-value value)
(u/not-valid plugin-id :setRow-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value}))))))
@ -382,7 +382,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :remove "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout #{id}))))
@ -391,16 +391,16 @@
(fn [child row column]
(cond
(not (shape-proxy? child))
(u/display-not-valid :appendChild-child child)
(u/not-valid plugin-id :appendChild-child child)
(or (< row 0) (not (sm/valid-safe-int? row)))
(u/display-not-valid :appendChild-row row)
(u/not-valid plugin-id :appendChild-row row)
(or (< column 0) (not (sm/valid-safe-int? column)))
(u/display-not-valid :appendChild-column column)
(u/not-valid plugin-id :appendChild-column column)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission")
:else
(let [child-id (obj/get child "$id")]
@ -432,13 +432,13 @@
shape (u/proxy->shape self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :row-value value)
(u/not-valid plugin-id :row-value value)
(nil? cell)
(u/display-not-valid :row-cell "cell not found")
(u/not-valid plugin-id :row-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :row "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :row "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))}
@ -452,13 +452,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rowSpan-value value)
(u/not-valid plugin-id :rowSpan-value value)
(nil? cell)
(u/display-not-valid :rowSpan-cell "cell not found")
(u/not-valid plugin-id :rowSpan-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rowSpan "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rowSpan "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))}
@ -472,13 +472,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :column-value value)
(u/not-valid plugin-id :column-value value)
(nil? cell)
(u/display-not-valid :column-cell "cell not found")
(u/not-valid plugin-id :column-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :column "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :column "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))}
@ -492,13 +492,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :columnSpan-value value)
(u/not-valid plugin-id :columnSpan-value value)
(nil? cell)
(u/display-not-valid :columnSpan-cell "cell not found")
(u/not-valid plugin-id :columnSpan-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :columnSpan "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :columnSpan "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))}
@ -512,13 +512,13 @@
cell (locate-cell self)]
(cond
(not (string? value))
(u/display-not-valid :areaName-value value)
(u/not-valid plugin-id :areaName-value value)
(nil? cell)
(u/display-not-valid :areaName-cell "cell not found")
(u/not-valid plugin-id :areaName-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :areaName "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :areaName "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))}
@ -533,13 +533,13 @@
value (keyword value)]
(cond
(not (contains? ctl/grid-position-types value))
(u/display-not-valid :position-value value)
(u/not-valid plugin-id :position-value value)
(nil? cell)
(u/display-not-valid :position-cell "cell not found")
(u/not-valid plugin-id :position-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :position "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))}
@ -554,13 +554,13 @@
cell (locate-cell self)]
(cond
(not (contains? ctl/grid-cell-align-self-types value))
(u/display-not-valid :alignSelf-value value)
(u/not-valid plugin-id :alignSelf-value value)
(nil? cell)
(u/display-not-valid :alignSelf-cell "cell not found")
(u/not-valid plugin-id :alignSelf-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))}
@ -575,13 +575,13 @@
cell (locate-cell self)]
(cond
(not (contains? ctl/grid-cell-justify-self-types value))
(u/display-not-valid :justifySelf-value value)
(u/not-valid plugin-id :justifySelf-value value)
(nil? cell)
(u/display-not-valid :justifySelf-cell "cell not found")
(u/not-valid plugin-id :justifySelf-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifySelf "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifySelf "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))})))

View File

@ -24,7 +24,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission")
:else
(let [id (js/Symbol)]
@ -35,10 +35,10 @@
(fn [block-id]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission")
(not block-id)
(u/display-not-valid :undoBlockFinish block-id)
(u/not-valid plugin-id :undoBlockFinish block-id)
:else
(st/emit! (dwu/commit-undo-transaction block-id))))))

View File

@ -60,10 +60,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :name "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission")
:else
(let [color (u/proxy->library-color self)
@ -77,10 +77,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :path value)
(u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :path "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission")
:else
(let [color (-> (u/proxy->library-color self)
@ -94,10 +94,10 @@
(fn [self value]
(cond
(or (not (string? value)) (not (clr/valid-hex-color? value)))
(u/display-not-valid :color value)
(u/not-valid plugin-id :color value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :color "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :color "Plugin doesn't have 'library:write' permission")
:else
(let [color (-> (u/proxy->library-color self)
@ -111,10 +111,10 @@
(fn [self value]
(cond
(or (not (number? value)) (< value 0) (> value 1))
(u/display-not-valid :opacity value)
(u/not-valid plugin-id :opacity value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :opacity "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :opacity "Plugin doesn't have 'library:write' permission")
:else
(let [color (-> (u/proxy->library-color self)
@ -129,10 +129,10 @@
(let [value (parser/parse-gradient value)]
(cond
(not (sm/validate clr/schema:gradient value))
(u/display-not-valid :gradient value)
(u/not-valid plugin-id :gradient value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :gradient "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :gradient "Plugin doesn't have 'library:write' permission")
:else
(let [color (-> (u/proxy->library-color self)
@ -147,10 +147,10 @@
(let [value (parser/parse-image-data value)]
(cond
(not (sm/validate clr/schema:image value))
(u/display-not-valid :image value)
(u/not-valid plugin-id :image value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :image "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :image "Plugin doesn't have 'library:write' permission")
:else
(let [color (-> (u/proxy->library-color self)
@ -161,7 +161,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :remove "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dwl/delete-color {:id id}))))
@ -170,7 +170,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :clone "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission")
:else
(let [color-id (uuid/next)
@ -207,7 +207,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :getPluginData-key key)
(u/not-valid plugin-id :getPluginData-key key)
:else
(let [color (u/locate-library-color file-id id)]
@ -217,16 +217,16 @@
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setPluginData-non-local-library file-id)
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value))))
@ -240,10 +240,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace)
(u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :getSharedPluginData-key key)
(u/not-valid plugin-id :getSharedPluginData-key key)
:else
(let [color (u/locate-library-color file-id id)]
@ -253,19 +253,19 @@
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setSharedPluginData-non-local-library file-id)
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value))))
@ -274,7 +274,7 @@
(fn [namespace]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys-namespace namespace)
(u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace)
:else
(let [color (u/locate-library-color file-id id)]
@ -301,10 +301,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :name "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission")
:else
(let [typo (u/proxy->library-typography self)
@ -318,10 +318,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :path value)
(u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :path "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -335,10 +335,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontId value)
(u/not-valid plugin-id :fontId value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontId "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -352,10 +352,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontFamily value)
(u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontFamily "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -369,10 +369,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontVariantId value)
(u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontVariantId "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -386,10 +386,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontSize value)
(u/not-valid plugin-id :fontSize value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontSize "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -403,10 +403,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontWeight value)
(u/not-valid plugin-id :fontWeight value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontWeight "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -420,10 +420,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :fontStyle value)
(u/not-valid plugin-id :fontStyle value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :fontStyle "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -437,10 +437,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :lineHeight value)
(u/not-valid plugin-id :lineHeight value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :lineHeight "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -454,10 +454,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :letterSpacing value)
(u/not-valid plugin-id :letterSpacing value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :letterSpacing "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -471,10 +471,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :textTransform value)
(u/not-valid plugin-id :textTransform value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :textTransform "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :textTransform "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
@ -485,7 +485,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :remove "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dwl/delete-typography {:id id}))))
@ -494,7 +494,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :clone "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission")
:else
(let [typo-id (uuid/next)
@ -507,10 +507,10 @@
(fn [shape]
(cond
(not (shape/shape-proxy? shape))
(u/display-not-valid :applyToText shape)
(u/not-valid plugin-id :applyToText shape)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission")
:else
(let [shape-id (obj/get shape "$id")
@ -521,10 +521,10 @@
(fn [range]
(cond
(not (text/text-range-proxy? range))
(u/display-not-valid :applyToText range)
(u/not-valid plugin-id :applyToText range)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission")
:else
(let [shape-id (obj/get range "$id")
@ -542,7 +542,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :typography-plugin-data-key key)
(u/not-valid plugin-id :typography-plugin-data-key key)
:else
(let [typography (u/locate-library-typography file-id id)]
@ -552,16 +552,16 @@
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setPluginData-non-local-library file-id)
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value))))
@ -575,10 +575,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace)
(u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :getSharedPluginData-key key)
(u/not-valid plugin-id :getSharedPluginData-key key)
:else
(let [typography (u/locate-library-typography file-id id)]
@ -588,19 +588,19 @@
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setSharedPluginData-non-local-library file-id)
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value))))
@ -609,7 +609,7 @@
(fn [namespace]
(cond
(not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys-namespace namespace)
(u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace)
:else
(let [typography (u/locate-library-typography file-id id)]
@ -674,7 +674,7 @@
:removeProperty
(fn [pos]
(if (not (nat-int? pos))
(u/display-not-valid :pos pos)
(u/not-valid plugin-id :pos pos)
(st/emit!
(ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"})
(dwv/remove-property id pos))))
@ -683,10 +683,10 @@
(fn [pos name]
(cond
(not (nat-int? pos))
(u/display-not-valid :pos pos)
(u/not-valid plugin-id :pos pos)
(not (string? name))
(u/display-not-valid :name name)
(u/not-valid plugin-id :name name)
:else
(st/emit!
@ -715,10 +715,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :name "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission")
:else
(let [component (u/proxy->library-component self)
@ -732,10 +732,10 @@
(fn [self value]
(cond
(not (string? value))
(u/display-not-valid :path value)
(u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :path "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission")
:else
(let [component (u/proxy->library-component self)
@ -746,7 +746,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :remove "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dwl/delete-component {:id id}))))
@ -755,7 +755,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :instance "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :instance "Plugin doesn't have 'content:write' permission")
:else
(let [id-ref (atom nil)]
@ -766,7 +766,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :component-plugin-data-key key)
(u/not-valid plugin-id :component-plugin-data-key key)
:else
(let [component (u/locate-library-component file-id id)]
@ -776,16 +776,16 @@
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setPluginData-non-local-library file-id)
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value))))
@ -799,10 +799,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :component-plugin-data-namespace namespace)
(u/not-valid plugin-id :component-plugin-data-namespace namespace)
(not (string? key))
(u/display-not-valid :component-plugin-data-key key)
(u/not-valid plugin-id :component-plugin-data-key key)
:else
(let [component (u/locate-library-component file-id id)]
@ -812,19 +812,19 @@
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/display-not-valid :setSharedPluginData-non-local-library file-id)
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value))))
@ -833,7 +833,7 @@
(fn [namespace]
(cond
(not (string? namespace))
(u/display-not-valid :component-plugin-data-namespace namespace)
(u/not-valid plugin-id :component-plugin-data-namespace namespace)
:else
(let [component (u/locate-library-component file-id id)]
@ -901,10 +901,10 @@
(fn [pos value]
(cond
(not (nat-int? pos))
(u/display-not-valid :pos (str pos))
(u/not-valid plugin-id :pos (str pos))
(not (string? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
:else
(st/emit!
@ -970,7 +970,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :createColor "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :createColor "Plugin doesn't have 'library:write' permission")
:else
(let [color-id (uuid/next)]
@ -981,7 +981,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :createTypography "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :createTypography "Plugin doesn't have 'library:write' permission")
:else
(let [typography-id (uuid/next)]
@ -992,7 +992,7 @@
(fn [shapes]
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :createComponent "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :createComponent "Plugin doesn't have 'library:write' permission")
:else
(let [id-ref (atom nil)
@ -1005,7 +1005,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :file-plugin-data-key key)
(u/not-valid plugin-id :file-plugin-data-key key)
:else
(let [file (u/locate-file file-id)]
@ -1015,13 +1015,13 @@
(fn [key value]
(cond
(not (string? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value))))
@ -1035,10 +1035,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :file-plugin-data-namespace namespace)
(u/not-valid plugin-id :file-plugin-data-namespace namespace)
(not (string? key))
(u/display-not-valid :file-plugin-data-key key)
(u/not-valid plugin-id :file-plugin-data-key key)
:else
(let [file (u/locate-file file-id)]
@ -1048,16 +1048,16 @@
(fn [namespace key value]
(cond
(not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value))))
@ -1066,7 +1066,7 @@
(fn [namespace]
(cond
(not (string? namespace))
(u/display-not-valid :namespace namespace)
(u/not-valid plugin-id :namespace namespace)
:else
(let [file (u/locate-file file-id)]
@ -1110,14 +1110,14 @@
(fn [library-id]
(cond
(not (r/check-permission plugin-id "library:write"))
(u/display-not-valid :connectLibrary "Plugin doesn't have 'library:write' permission")
(u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission")
:else
(js/Promise.
(fn [resolve reject]
(cond
(not (string? library-id))
(do (u/display-not-valid :connectLibrary library-id)
(do (u/not-valid plugin-id :connectLibrary library-id)
(reject nil))
:else

View File

@ -30,10 +30,10 @@
(fn [key]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :getItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :getItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :getItem "The key must be a string")
(u/not-valid plugin-id :getItem "The key must be a string")
:else
(.getItem ^js local-storage (prefix-key plugin-id key))))
@ -42,10 +42,10 @@
(fn [key value]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :setItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :setItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :setItem "The key must be a string")
(u/not-valid plugin-id :setItem "The key must be a string")
:else
(.setItem ^js local-storage (prefix-key plugin-id key) value)))
@ -54,10 +54,10 @@
(fn [key]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :removeItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :removeItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :removeItem "The key must be a string")
(u/not-valid plugin-id :removeItem "The key must be a string")
:else
(.getItem ^js local-storage (prefix-key plugin-id key))))

View File

@ -59,7 +59,7 @@
(fn [_ value]
(cond
(or (not (string? value)) (empty? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
@ -74,7 +74,7 @@
(fn [_ value]
(cond
(not (shape/shape-proxy? value))
(u/display-not-valid :startingBoard value)
(u/not-valid plugin-id :startingBoard value)
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
@ -103,10 +103,10 @@
(fn [_ value]
(cond
(not (string? value))
(u/display-not-valid :name value)
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :name "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dw/rename-page id value))))}
@ -127,10 +127,10 @@
(fn [_ value]
(cond
(or (not (string? value)) (not (cc/valid-hex-color? value)))
(u/display-not-valid :background value)
(u/not-valid plugin-id :background value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :background "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :background "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dw/change-canvas-color id {:color value}))))}
@ -158,7 +158,7 @@
(fn [shape-id]
(cond
(not (string? shape-id))
(u/display-not-valid :getShapeById shape-id)
(u/not-valid plugin-id :getShapeById shape-id)
:else
(let [shape-id (uuid/parse shape-id)
@ -195,7 +195,7 @@
(fn [key]
(cond
(not (string? key))
(u/display-not-valid :page-plugin-data-key key)
(u/not-valid plugin-id :page-plugin-data-key key)
:else
(let [page (u/locate-page file-id id)]
@ -205,13 +205,13 @@
(fn [key value]
(cond
(not (string? key))
(u/display-not-valid :setPluginData-key key)
(u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setPluginData-value value)
(u/not-valid plugin-id :setPluginData-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value))))
@ -225,10 +225,10 @@
(fn [namespace key]
(cond
(not (string? namespace))
(u/display-not-valid :page-plugin-data-namespace namespace)
(u/not-valid plugin-id :page-plugin-data-namespace namespace)
(not (string? key))
(u/display-not-valid :page-plugin-data-key key)
(u/not-valid plugin-id :page-plugin-data-key key)
:else
(let [page (u/locate-page file-id id)]
@ -238,16 +238,16 @@
(fn [namespace key value]
(cond
(not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace)
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key))
(u/display-not-valid :setSharedPluginData-key key)
(u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value)))
(u/display-not-valid :setSharedPluginData-value value)
(u/not-valid plugin-id :setSharedPluginData-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
@ -256,7 +256,7 @@
(fn [self namespace]
(cond
(not (string? namespace))
(u/display-not-valid :page-plugin-data-namespace namespace)
(u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else
(let [page (u/proxy->page self)]
@ -266,7 +266,7 @@
(fn [new-window]
(cond
(not (r/check-permission plugin-id "content:read"))
(u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission")
(u/not-valid plugin-id :openPage "Plugin doesn't have 'content:read' permission")
:else
(let [new-window (if (boolean? new-window) new-window false)]
@ -276,10 +276,10 @@
(fn [name frame]
(cond
(or (not (string? name)) (empty? name))
(u/display-not-valid :createFlow-name name)
(u/not-valid plugin-id :createFlow-name name)
(not (shape/shape-proxy? frame))
(u/display-not-valid :createFlow-frame frame)
(u/not-valid plugin-id :createFlow-frame frame)
:else
(let [flow-id (uuid/next)]
@ -290,7 +290,7 @@
(fn [flow]
(cond
(not (flow-proxy? flow))
(u/display-not-valid :removeFlow-flow flow)
(u/not-valid plugin-id :removeFlow-flow flow)
:else
(st/emit! (dwi/remove-flow id (obj/get flow "$id")))))
@ -300,18 +300,18 @@
(let [shape (u/proxy->shape board)]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :addRulerGuide "Value not a safe number")
(u/not-valid plugin-id :addRulerGuide "Value not a safe number")
(not (contains? #{"vertical" "horizontal"} orientation))
(u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'")
(u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'")
(and (some? shape)
(or (not (shape/shape-proxy? board))
(not (cfh/frame-shape? shape))))
(u/display-not-valid :addRulerGuide "The shape is not a board")
(u/not-valid plugin-id :addRulerGuide "The shape is not a board")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission")
:else
(let [ruler-id (uuid/next)]
@ -328,10 +328,10 @@
(fn [value]
(cond
(not (rg/ruler-guide-proxy? value))
(u/display-not-valid :removeRulerGuide "Guide not provided")
(u/not-valid plugin-id :removeRulerGuide "Guide not provided")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :removeRulerGuide "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'comment:write' permission")
:else
(let [guide (u/proxy->ruler-guide value)]
@ -343,17 +343,17 @@
position (parser/parse-point position)]
(cond
(or (not (string? content)) (empty? content))
(u/display-not-valid :addCommentThread "Content not valid")
(u/not-valid plugin-id :addCommentThread "Content not valid")
(or (not (sm/valid-safe-number? (:x position)))
(not (sm/valid-safe-number? (:y position))))
(u/display-not-valid :addCommentThread "Position not valid")
(u/not-valid plugin-id :addCommentThread "Position not valid")
(and (some? board) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape))))
(u/display-not-valid :addCommentThread "Board not valid")
(u/not-valid plugin-id :addCommentThread "Board not valid")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :addCommentThread "Plugin doesn't have 'comment:write' permission")
(u/not-valid plugin-id :addCommentThread "Plugin doesn't have 'comment:write' permission")
:else
(let [position
@ -378,10 +378,10 @@
(fn [thread]
(cond
(not (pc/comment-thread-proxy? thread))
(u/display-not-valid :removeCommentThread "Comment thread not valid")
(u/not-valid plugin-id :removeCommentThread "Comment thread not valid")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :removeCommentThread "Plugin doesn't have 'content:write' permission")
:else
(js/Promise.
@ -400,7 +400,7 @@
(cond
(not (r/check-permission plugin-id "comment:read"))
(do
(u/display-not-valid :findCommentThreads "Plugin doesn't have 'comment:read' permission")
(u/not-valid plugin-id :findCommentThreads "Plugin doesn't have 'comment:read' permission")
(reject "Plugin doesn't have 'comment:read' permission"))
:else

Some files were not shown because too many files have changed in this diff Show More