diff --git a/.continue/mcpServers/new-mcp-server.yaml b/.continue/mcpServers/new-mcp-server.yaml new file mode 100644 index 0000000000..0e32aa6d45 --- /dev/null +++ b/.continue/mcpServers/new-mcp-server.yaml @@ -0,0 +1,10 @@ +name: New MCP server +version: 0.0.1 +schema: v1 +mcpServers: + - name: New MCP server + command: npx + args: + - -y + - + env: {} diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index aecf9a37eb..8125c81e12 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -1,6 +1,7 @@ name: _DEVELOP on: + workflow_dispatch: schedule: - cron: '16 5-20 * * 1-5' diff --git a/.github/workflows/build-staging-render.yml b/.github/workflows/build-staging-render.yml index 8478999ebf..7e65a518a9 100644 --- a/.github/workflows/build-staging-render.yml +++ b/.github/workflows/build-staging-render.yml @@ -1,6 +1,7 @@ name: _STAGING RENDER on: + workflow_dispatch: schedule: - cron: '36 5-20 * * 1-5' diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 1c5d48d02e..572d8c2a95 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -1,6 +1,7 @@ name: _STAGING on: + workflow_dispatch: schedule: - cron: '36 5-20 * * 1-5' diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index c32e363888..58fa0413c0 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -1,6 +1,7 @@ name: _TAG on: + workflow_dispatch: push: tags: - '*' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ba57dde95..fa21646cac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,11 +146,18 @@ jobs: name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest + needs: test-render-wasm steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Restore shared.js + uses: actions/cache/restore@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + - name: Unit Tests working-directory: ./frontend run: | @@ -187,6 +194,19 @@ jobs: run: | ./test + - name: Copy shared.js artifact + working-directory: ./render-wasm + run: | + SHARED_FILE=$(find target -name render_wasm_shared.js | head -n 1); + mkdir -p ../frontend/src/app/render_wasm/api; + cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + + - name: Cache shared.js + uses: actions/cache@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + test-backend: name: "Backend Tests" runs-on: penpot-runner-02 diff --git a/CHANGES.md b/CHANGES.md index 6d52b2b01f..da8dcab15f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,40 @@ # CHANGELOG +## 2.16.0 (Unreleased) + +### :boom: Breaking changes & Deprecations + +### :rocket: Epics and highlights + +### :sparkles: New features & Enhancements + +- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912) +- Import Tokens from linked library [Github #8391](https://github.com/penpot/penpot/pull/8391) +- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320) +- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248) +- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313) +- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474) + + +### :bug: Bugs fixed + + +## 2.15.0 (Unreleased) + +### :boom: Breaking changes & Deprecations + +### :rocket: Epics and highlights + +### :sparkles: New features & Enhancements + +- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112), [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) + +### :bug: Bugs fixed + +- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361) +- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527) + + ## 2.14.0 (Unreleased) ### :boom: Breaking changes & Deprecations @@ -32,6 +67,8 @@ - Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174) - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) +- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333) +- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) - Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) diff --git a/backend/package.json b/backend/package.json index 8ad7cd3c1d..c8f354874f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC", "private": true, - "packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index da5fd4e05a..765f0c894d 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -53,6 +53,7 @@ ::yres/status 200 ::yres/body (yres/stream-body (fn [_ output] + (let [channel (sp/chan :buf buf :xf (keep encode)) listener (events/spawn-listener channel diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index d54f19ab10..00b6db36d4 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -54,7 +54,7 @@ [:path ::fs/path] [:mtype {:optional true} ::sm/text]]) -(def ^:private check-input +(def check-input (sm/check-fn schema:input)) (defn validate-media-type! @@ -409,6 +409,22 @@ (when (zero? (:exit res)) (:out res)))) + (woff2->sfnt [data] + ;; woff2_decompress outputs to same directory with .ttf extension + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") + foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] + (try + (io/write* finput data) + (let [res (sh/sh "woff2_decompress" (str finput))] + (if (zero? (:exit res)) + foutput + (do + (when (fs/exists? foutput) + (fs/delete foutput)) + nil))) + (finally + (fs/delete finput))))) + ;; Documented here: ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory (get-sfnt-type [data] @@ -458,4 +474,27 @@ (= stype :ttf) (-> (assoc "font/otf" (ttf->otf sfnt)) - (assoc "font/ttf" sfnt))))))))) + (assoc "font/ttf" sfnt))))) + + (contains? current "font/woff2") + (let [data (get input "font/woff2") + foutput (woff2->sfnt data)] + (when-not foutput + (ex/raise :type :validation + :code :invalid-woff2-file + :hint "invalid woff2 file")) + (try + (let [sfnt (io/read* foutput) + type (get-sfnt-type sfnt)] + (cond-> input + (= type :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) + + (= type :ttf) + (-> (assoc "font/ttf" sfnt) + (assoc "font/otf" (ttf->otf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) + (finally + (fs/delete foutput)))))))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2a9d9eba0b..4c9199a6f5 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -463,8 +463,10 @@ :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")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/clj/migration_0023.clj b/backend/src/app/migrations/clj/migration_0023.clj index 6e928028c6..b2027d2275 100644 --- a/backend/src/app/migrations/clj/migration_0023.clj +++ b/backend/src/app/migrations/clj/migration_0023.clj @@ -58,4 +58,3 @@ (when (nil? (:data file)) (migrate-file conn file))) (db/exec-one! conn ["drop table page cascade;"]))) - diff --git a/backend/src/app/migrations/sql/0146-mod-access-token-table.sql b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql new file mode 100644 index 0000000000..574257859d --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE access_token + ADD COLUMN type text NULL; diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 14c1da0e99..cfa0ff9014 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -3,6 +3,8 @@ (:require [app.common.logging :as l] [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.time :as ct] [app.config :as cf] [app.http.client :as http] [app.rpc :as-alias rpc] @@ -80,22 +82,96 @@ (def ^:private schema:organization [:map - [:id ::sm/text] - [:name ::sm/text]]) + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text]]) -(def ^:private schema:user +;; TODO Unify with schemas on backend/src/app/http/management.clj +(def ^:private schema:timestamp + (sm/type-schema + {:type ::timestamp + :pred ct/inst? + :type-properties + {:title "inst" + :description "The same as :app.common.time/inst but encodes to epoch" + :error/message "should be an instant" + :gen/gen (->> (sg/small-int) + (sg/fmap (fn [v] (ct/inst v)))) + :decode/string ct/inst + :encode/string inst-ms + :decode/json ct/inst + :encode/json inst-ms}})) + +(def ^:private schema:subscription + [:map {:title "Subscription"} + [:id ::sm/text] + [:customer-id ::sm/text] + [:type [:enum + "unlimited" + "professional" + "enterprise" + "nitrate"]] + [:status [:enum + "active" + "canceled" + "incomplete" + "incomplete_expired" + "past_due" + "paused" + "trialing" + "unpaid"]] + + [:billing-period [:enum + "month" + "day" + "week" + "year"]] + [:quantity :int] + [:description [:maybe ::sm/text]] + [:created-at schema:timestamp] + [:start-date [:maybe schema:timestamp]] + [:ended-at [:maybe schema:timestamp]] + [:trial-end [:maybe schema:timestamp]] + [:trial-start [:maybe schema:timestamp]] + [:cancel-at [:maybe schema:timestamp]] + [:canceled-at [:maybe schema:timestamp]] + [:current-period-end [:maybe schema:timestamp]] + [:current-period-start [:maybe schema:timestamp]] + [:cancel-at-period-end :boolean] + + [:cancellation-details + [:map {:title "CancellationDetails"} + [:comment [:maybe ::sm/text]] + [:reason [:maybe ::sm/text]] + [:feedback [:maybe + [:enum + "customer_service" + "low_quality" + "missing_feature" + "other" + "switched_service" + "too_complex" + "too_expensive" + "unused"]]]]]]) + +(def ^:private schema:connectivity [:map - [:valid ::sm/boolean]]) + [:licenses ::sm/boolean]]) (defn- get-team-org [cfg {:keys [team-id] :as params}] (let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))) -(defn- is-valid-user +(defn- get-subscription [cfg {:keys [profile-id] :as params}] (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) + (request-to-nitrate cfg :get (str baseuri "/api/subscriptions/" (str profile-id)) schema:subscription params))) + +(defn- get-connectivity + [cfg params] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION @@ -104,8 +180,9 @@ (defmethod ig/init-key ::client [_ cfg] (when (contains? cf/flags :nitrate) - {:get-team-org (partial get-team-org cfg) - :is-valid-user (partial is-valid-user cfg)})) + {:get-team-org (partial get-team-org cfg) + :get-subscription (partial get-subscription cfg) + :connectivity (partial get-connectivity cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS @@ -113,18 +190,40 @@ (defn add-nitrate-licence-to-profile + "Enriches a profile map with subscription information from Nitrate. + Adds a :subscription field containing the user's license details. + Returns the original profile unchanged if the request fails." [cfg profile] (try - (let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})] - (assoc profile :nitrate-licence (:valid nitrate-licence))) + (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] + (assoc profile :subscription subscription)) (catch Throwable cause (l/error :hint "failed to get nitrate licence" :profile-id (:id profile) :cause cause) profile))) -(defn add-org-to-team +(defn add-org-info-to-team + "Enriches a team map with organization information from Nitrate. + Adds organization-id, organization-name, organization-slug, and your-penpot fields. + Returns the original team unchanged if the request fails or org data is nil." [cfg team params] - (let [params (assoc (or params {}) :team-id (:id team)) - org (call cfg :get-team-org params)] - (assoc team :organization-id (:id org) :organization-name (:name org)))) + (try + (let [params (assoc (or params {}) :team-id (:id team)) + org (call cfg :get-team-org params)] + (if (some? org) + (assoc team + :organization-id (:id org) + :organization-name (:name org) + :organization-slug (:slug org) + :is-default (or (:is-default team) (true? (:isYourPenpot org)))) + team)) + (catch Throwable cause + (l/error :hint "failed to get team organization info" + :team-id (:id team) + :cause cause) + team))) + +(defn connectivity + [cfg] + (call cfg :connectivity {})) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c5ef53aaf4..4b47c19451 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -73,9 +73,13 @@ (if (nil? result) 204 200)) - headers (cond-> (::http/headers mdata {}) - (yres/stream-body? result) + + headers (::http/headers mdata {}) + headers (cond-> headers + (and (yres/stream-body? result) + (not (contains? headers "content-type"))) (assoc "content-type" "application/octet-stream"))] + {::yres/status status ::yres/headers headers ::yres/body result}))] @@ -258,6 +262,7 @@ 'app.rpc.commands.ldap 'app.rpc.commands.management 'app.rpc.commands.media + 'app.rpc.commands.nitrate 'app.rpc.commands.profile 'app.rpc.commands.projects 'app.rpc.commands.search diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index a302b82053..393f824599 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -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 #(ct/is-after? (:expires-at %) request-at)) + (map decode-row) + (first))) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 03c66a968f..b47c6c2e38 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -9,12 +9,14 @@ [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.media :as cmedia] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] [app.db.sql :as-alias sql] [app.features.logical-deletion :as ldel] + [app.http :as-alias http] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media :as media] @@ -34,7 +36,9 @@ java.io.InputStream java.io.OutputStream java.io.SequenceInputStream - java.util.Collections)) + java.util.Collections + java.util.zip.ZipEntry + java.util.zip.ZipOutputStream)) (set! *warn-on-reflection* true) @@ -296,3 +300,98 @@ (rph/with-meta (rph/wrap) {::audit/props {:font-family (:font-family variant) :font-id (:font-id variant)}}))) + +;; --- DOWNLOAD FONT + +(defn- make-temporal-storage-object + [cfg profile-id content] + (let [storage (sto/resolve cfg) + content (media/check-input content) + hash (sto/calculate-hash (:path content)) + data (-> (sto/content (:path content)) + (sto/wrap-with-hash hash)) + mtype (:mtype content "application/octet-stream") + content {::sto/content data + ::sto/deduplicate? true + ::sto/touched-at (ct/in-future {:minutes 30}) + :profile-id profile-id + :content-type mtype + :bucket "tempfile"}] + + (sto/put-object! storage content))) + +(defn- make-variant-filename + [v mtype] + (str (:font-family v) "-" (:font-weight v) + (when-not (= "normal" (:font-style v)) (str "-" (:font-style v))) + (cmedia/mtype->extension mtype))) + +(def ^:private schema:download-font + [:map {:title "download-font"} + [:id ::sm/uuid]]) + +(sv/defmethod ::download-font + "Download the font file. Returns a http redirect to the asset resource uri." + {::doc/added "2.15" + ::sm/params schema:download-font} + [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] + (let [variant (db/get pool :team-font-variant {:id id})] + (teams/check-read-permissions! pool profile-id (:team-id variant)) + + ;; Try to get the best available font format (prefer TTF for broader compatibility). + (let [media-id (or (:ttf-file-id variant) + (:otf-file-id variant) + (:woff2-file-id variant) + (:woff1-file-id variant)) + sobj (sto/get-object storage media-id) + mtype (-> sobj meta :content-type)] + + {:id (:id sobj) + :uri (files/resolve-public-uri (:id sobj)) + :name (make-variant-filename variant mtype)}))) + +(def ^:private schema:download-font-family + [:map {:title "download-font-family"} + [:font-id ::sm/uuid]]) + +(sv/defmethod ::download-font-family + "Download the entire font family as a zip file. Returns the zip + bytes on the body, without encoding it on transit or json." + {::doc/added "2.15" + ::sm/params schema:download-font-family} + [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}] + (let [variants (db/query pool :team-font-variant + {:font-id font-id + :deleted-at nil})] + + (when-not (seq variants) + (ex/raise :type :not-found + :code :object-not-found)) + + (teams/check-read-permissions! pool profile-id (:team-id (first variants))) + + (let [tempfile (tmp/tempfile :suffix ".zip") + ffamily (-> variants first :font-family)] + + (with-open [^OutputStream output (io/output-stream tempfile) + ^OutputStream output (ZipOutputStream. output)] + (doseq [v variants] + (let [media-id (or (:ttf-file-id v) + (:otf-file-id v) + (:woff2-file-id v) + (:woff1-file-id v)) + sobj (sto/get-object storage media-id) + mtype (-> sobj meta :content-type) + name (make-variant-filename v mtype)] + + (with-open [input (sto/get-object-data storage sobj)] + (.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name)) + (io/copy input output :size (:size sobj)) + (.closeEntry ^ZipOutputStream output))))) + + (let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id + {:mtype "application/zip" + :path tempfile})] + {:id id + :uri (files/resolve-public-uri id) + :name (str ffamily ".zip")})))) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj new file mode 100644 index 0000000000..5313817fd3 --- /dev/null +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -0,0 +1,20 @@ +(ns app.rpc.commands.nitrate + (:require + [app.common.schema :as sm] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [app.rpc.doc :as-alias doc] + [app.util.services :as sv])) + + +(def schema:connectivity + [:map {:title "nitrate-connectivity"} + [:licenses ::sm/boolean]]) + +(sv/defmethod ::get-nitrate-connectivity + {::rpc/auth false + ::doc/added "1.18" + ::sm/params [:map] + ::sm/result schema:connectivity} + [cfg _params] + (nitrate/connectivity cfg)) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 3d2f2b1351..4383ab794f 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,6 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] + [:mcp-status {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 603e12187b..220602d4e9 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -193,7 +193,7 @@ (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) (contains? cf/flags :nitrate) - (map #(nitrate/add-org-to-team cfg % params))))) + (map #(nitrate/add-org-info-to-team cfg % params))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 23a8dcaa37..455b96705b 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,17 +8,22 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require + [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.msgbus :as mbus] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as doc] - [app.util.services :as sv])) + [app.rpc.quotes :as quotes] + [app.util.services :as sv] + [clojure.set :as set])) ;; ---- API: authenticate @@ -45,6 +50,19 @@ AND t.is_default IS FALSE AND t.deleted_at IS NULL;") +;; ---- API: get-penpot-version + +(def ^:private schema:get-penpot-version-result + [:map [:version ::sm/text]]) + +(sv/defmethod ::get-penpot-version + "Get the current Penpot version" + {::doc/added "2.14" + ::sm/params [:map] + ::sm/result schema:get-penpot-version-result} + [_cfg _params] + {:version cf/version}) + (def ^:private schema:get-teams-result [:vector schema:team]) @@ -63,7 +81,8 @@ (def ^:private schema:notify-team-change [:map [:id ::sm/uuid] - [:organization-id ::sm/text]]) + [:organization-id ::sm/uuid] + [:organization-name ::sm/text]]) (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" @@ -81,6 +100,32 @@ :organization-id organization-id :organization-name organization-name}))) +;; ---- API: notify-user-added-to-organization + +(def ^:private schema:notify-user-added-to-organization + [:map + [:profile-id ::sm/uuid] + [:organization-id ::sm/uuid] + [:role ::sm/text]]) + +(sv/defmethod ::notify-user-added-to-organization + "Notify to Penpot that an user has joined an org from nitrate" + {::doc/added "2.14" + ::sm/params schema:notify-user-added-to-organization + ::rpc/auth false} + [cfg {:keys [profile-id]}] + (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile + ::quotes/profile-id profile-id}) + + (let [features (-> (cfeat/get-enabled-features cf/flags) + (set/difference cfeat/frontend-only-features) + (set/difference cfeat/no-team-inheritable-features)) + params {:profile-id profile-id + :name "Default" + :features features} + team (db/tx-run! cfg teams/create-team params)] + (select-keys team [:id]))) + ;; ---- API: get-managed-profiles @@ -112,3 +157,4 @@ [cfg {:keys [::rpc/profile-id]}] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id]))) + diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index b4fa5062d5..809e43f9e3 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -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)] diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index fe0269d609..6dc96ac0f6 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -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))))))) + diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 1316b237c9..be5410ffd0 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -93,6 +93,41 @@ :font-weight :font-style)))) +(t/deftest woff2-font-upload-1 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + + data (-> (io/resource "backend_tests/test_files/font-1.woff2") + (io/read*)) + + params {::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/woff2" data}} + out (th/command! params)] + + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (uuid? (:id result))) + (t/is (uuid? (:ttf-file-id result))) + (t/is (uuid? (:otf-file-id result))) + (t/is (uuid? (:woff1-file-id result))) + (t/is (uuid? (:woff2-file-id result))) + (t/are [k] (= (get params k) + (get result k)) + :team-id + :font-id + :font-family + :font-weight + :font-style)))) + (t/deftest font-deletion-1 (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) diff --git a/backend/test/backend_tests/test_files/font-1.woff2 b/backend/test/backend_tests/test_files/font-1.woff2 new file mode 100644 index 0000000000..492d463d90 Binary files /dev/null and b/backend/test/backend_tests/test_files/font-1.woff2 differ diff --git a/common/package.json b/common/package.json index ac874c2b45..300c768dcb 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC", "private": true, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "type": "module", "repository": { "type": "git", diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc60..414179753c 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1165,3 +1165,40 @@ [class current-class] (str (if (some? class) (str class " ") "") current-class)) + + +(defn nth-index-of* + "Finds the nth occurrence of `char` in `string`, searching either forward or backward. + `dir` must be :forward (left to right) or :backward (right to left). + Returns the absolute index of the match, or nil if fewer than n occurrences exist." + [string char n dir] + (loop [s string + offset 0 + cnt 1] + (let [index (case dir + :forward (str/index-of s char) + :backward (str/last-index-of s char))] + (cond + (nil? index) nil + (= cnt n) (case dir + :forward (+ index offset) + :backward index) + :else (case dir + :forward (recur (str/slice s (inc index)) + (+ offset index 1) + (inc cnt)) + :backward (recur (str/slice s 0 index) + offset + (inc cnt))))))) + +(defn nth-index-of + "Returns the index of the nth occurrence of `char` in `string`, searching left to right. + Returns nil if fewer than n occurrences exist." + [string char n] + (nth-index-of* string char n :forward)) + +(defn nth-last-index-of + "Returns the index of the nth occurrence of `char` in `string`, searching right to left. + Returns nil if fewer than n occurrences exist." + [string char n] + (nth-index-of* string char n :backward)) \ No newline at end of file diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc index dc23426a95..2fdc9bc963 100644 --- a/common/src/app/common/data/macros.cljc +++ b/common/src/app/common/data/macros.cljc @@ -40,6 +40,13 @@ (list `c/get key))) keys))))) +(defmacro number + "Coerce number to number in a multiplatform way" + [o] + (if (:ns &env) + (with-meta o {:tag 'number}) + `(double ~o))) + (defmacro str [& params] `(str/concat ~@params)) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 2fb3ddfa53..93ac58d03b 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -605,31 +605,31 @@ add-undo-change-shape (fn [change-set id] (let [shape (get objects id)] - (conj - change-set - {:type :add-obj - :id id - :page-id page-id - :parent-id (:parent-id shape) - :frame-id (:frame-id shape) - :index (cfh/get-position-on-parent objects id) - :obj (cond-> shape - (contains? shape :shapes) - (assoc :shapes []))}))) + (cond-> change-set + (some? shape) + (conj {:type :add-obj + :id id + :page-id page-id + :parent-id (:parent-id shape) + :frame-id (:frame-id shape) + :index (cfh/get-position-on-parent objects id) + :obj (cond-> shape + (contains? shape :shapes) + (assoc :shapes []))})))) add-undo-change-parent (fn [change-set id] (let [shape (get objects id) prev-sibling (cfh/get-prev-sibling objects (:id shape))] - (conj - change-set - {:type :mov-objects - :page-id page-id - :parent-id (:parent-id shape) - :shapes [id] - :after-shape prev-sibling - :index 0 - :ignore-touched true})))] + (cond-> change-set + (some? shape) + (conj {:type :mov-objects + :page-id page-id + :parent-id (:parent-id shape) + :shapes [id] + :after-shape prev-sibling + :index 0 + :ignore-touched true}))))] (-> changes (update :redo-changes #(reduce add-redo-change % ids)) @@ -1150,3 +1150,24 @@ [changes] (::page-id (meta changes))) + +(defn set-text-content + [changes id content prev-content] + (assert-page-id! changes) + (let [page-id (::page-id (meta changes)) + + redo-change + {:type :mod-obj + :page-id page-id + :id id + :operations [{:type :set :attr :content :val content}]} + + undo-change + {:type :mod-obj + :page-id page-id + :id id + :operations [{:type :set :attr :content :val prev-content}]}] + + (-> changes + (update :redo-changes conj redo-change) + (update :undo-changes conj undo-change)))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 816bc2edbb..96b6764616 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -119,12 +119,16 @@ :strict-session-cookies :telemetry :terms-and-privacy-checkbox - ;; Only for developtment. :tiered-file-data-storage + + ;; Tokens :token-base-font-size + :token-combobox :token-color :token-shadow :token-tokenscript + :token-import-from-library + ;; Only for developtment. :transit-readable-response :user-feedback ;; TODO: remove this flag. @@ -139,6 +143,10 @@ ;; Enable performance logs in devconsole (disabled by default) :perf-logs + ;; Used for designate features that will be available in the next + ;; release + :canary + ;; Security layer middleware that filters request by fetch ;; metadata headers :sec-fetch-metadata-middleware @@ -152,7 +160,9 @@ :redis-cache ;; Activates the nitrate module - :nitrate}) + :nitrate + + :mcp}) (def all-flags (set/union email login varia)) @@ -178,7 +188,9 @@ :enable-token-color :enable-token-shadow :enable-inspect-styles - :enable-feature-fdata-objects-map]) + :enable-feature-fdata-objects-map + ;; Temporary deactivated + #_:enable-token-import-from-library]) (defn parse [& flags] diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 4cdf8488ce..fc349765a2 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -12,6 +12,7 @@ (def font-types #{"font/ttf" "font/woff" + "font/woff2" "font/otf" "font/opentype"}) @@ -81,21 +82,22 @@ (defn parse-font-weight [variant] (cond - (re-seq #"(?i)(?:hairline|thin)" variant) 100 - (re-seq #"(?i)(?:extra\s*light|ultra\s*light)" variant) 200 - (re-seq #"(?i)(?:light)" variant) 300 - (re-seq #"(?i)(?:normal|regular)" variant) 400 - (re-seq #"(?i)(?:medium)" variant) 500 - (re-seq #"(?i)(?:semi\s*bold|demi\s*bold)" variant) 600 - (re-seq #"(?i)(?:extra\s*bold|ultra\s*bold)" variant) 800 - (re-seq #"(?i)(?:bold)" variant) 700 - (re-seq #"(?i)(?:extra\s*black|ultra\s*black)" variant) 950 - (re-seq #"(?i)(?:black|heavy|solid)" variant) 900 - :else 400)) + (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 + (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 + (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 + (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 + (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 + (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 + (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 + :else 400)) (defn parse-font-style [variant] - (if (re-seq #"(?i)(?:italic)" variant) + (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) + (re-seq #"(?i)italic$" variant)) "italic" "normal")) diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 16bc20d5f4..87d0784969 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -863,7 +863,7 @@ (defn parse-boolean [v] (if (string? v) - (case v + (case (str/lower v) ("true" "t" "1") true ("false" "f" "0") false v) diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 755151bcfc..3784cba8a5 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -28,6 +28,7 @@ ["date-fns/locale/eu$default" :as dfn-eu] ["date-fns/locale/fa-IR$default" :as dfn-fa-ir] ["date-fns/locale/fr$default" :as dfn-fr] + ["date-fns/locale/fr-CA$default" :as dfn-fr-ca] ["date-fns/locale/gl$default" :as dfn-gl] ["date-fns/locale/he$default" :as dfn-he] ["date-fns/locale/hr$default" :as dfn-hr] @@ -253,6 +254,7 @@ :fa dfn-fa-ir :fa_ir dfn-fa-ir :fr dfn-fr + :fr_ca dfn-fr-ca :he dfn-he :pt dfn-pt :pt_pt dfn-pt diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 20f542fbbe..92732e18a1 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -115,21 +115,25 @@ (defn get-frames "Retrieves all frame objects as vector" ([objects] (get-frames objects nil)) - ([objects {:keys [skip-components? skip-copies?] + ([objects {:keys [skip-components? skip-copies? ignore-index?] :or {skip-components? false - skip-copies? false}}] - (->> (or (-> objects meta ::index-frames) - (let [lookup (d/getf objects) - xform (comp (remove #(= uuid/zero %)) - (keep lookup) - (filter cfh/frame-shape?))] - (->> (keys objects) - (sequence xform)))) - (remove #(or (and ^boolean skip-components? - ^boolean (ctk/instance-head? %)) - (and ^boolean skip-copies? - (and ^boolean (ctk/instance-head? %) - (not ^boolean (ctk/main-instance? %))))))))) + skip-copies? false + ignore-index? false}}] + (let [frame-index + (if (and (not ignore-index?) (-> objects meta ::index-frames)) + (-> objects meta ::index-frames) + (let [lookup (d/getf objects) + xform (comp (remove #(= uuid/zero %)) + (keep lookup) + (filter cfh/frame-shape?))] + (->> (keys objects) + (sequence xform))))] + (->> frame-index + (remove #(or (and ^boolean skip-components? + ^boolean (ctk/instance-head? %)) + (and ^boolean skip-copies? + (and ^boolean (ctk/instance-head? %) + (not ^boolean (ctk/main-instance? %)))))))))) (defn get-frames-ids "Retrieves all frame ids as vector" diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33..55ecc842e7 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -7,6 +7,7 @@ (ns app.common.types.token (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] @@ -637,3 +638,107 @@ (when (font-weight-values weight) (cond-> {:weight weight} italic? (assoc :style "italic"))))) + + +;;;;;; Combobox token parsing + +(defn- inside-ref? + "Returns true if `position` in `value` is inside an open reference block (i.e. after a `{` + that has no matching `}` to its left). + A reference block is considered open when the last `{` appears after the last `}`, + or when there is a `{` but no `}` at all to the left of `position`." + [value position] + (let [left (str/slice value 0 position) + last-open (str/last-index-of left "{") + last-close (str/last-index-of left "}")] + (and (some? last-open) + (or (nil? last-close) + (< last-close last-open))))) + +(defn- block-open-start + "Returns the index of the leftmost `{` in the run of consecutive `{` characters + that contains the last `{` before `position` in `value`. + Used to find where a reference block truly starts when multiple braces are stacked." + [value position] + (let [left (str/slice value 0 position) + last-open (str/last-index-of left "{")] + (loop [i last-open] + (if (and i + (> i 0) + (= (nth left (dec i)) \{)) + (recur (dec i)) + i)))) + +(defn- start-ref-position + "Returns the position where the current token (reference candidate) starts, + relative to `position` in `value`. + The start is determined by whichever comes last: the opening `{` of the current + reference block or the character after the last space before `position`." + [value position] + (let [left (str/slice value 0 position) + open-pos (block-open-start value position) + space-pos (some-> (str/last-index-of left " ") inc)] + (->> [open-pos space-pos] + (remove nil?) + sort + last))) + +(defn- inside-closed-ref? + "Returns true if `position` falls inside a complete (closed) reference block, + i.e. there is a `{` to the left and a `}` to the right with no spaces between + either delimiter and the position. + Returns nil (falsy) when not inside a closed reference." + [value position] + (let [left (str/slice value 0 position) + right (str/slice value position) + + open-pos (d/nth-last-index-of left "{" 1) + close-pos (d/nth-index-of right "}" 1) + last-space-left (d/nth-last-index-of left " " 1) + first-space-right (d/nth-index-of right " " 1)] + + (boolean + (and (number? open-pos) + (number? close-pos) + (or (nil? last-space-left) (> (dm/number open-pos) (dm/number last-space-left))) + (or (nil? first-space-right) (< (dm/number close-pos) (dm/number first-space-right))))))) + +(defn- build-result + "Builds the result map for `insert-ref` by replacing the substring of `value` + between `prefix-end` and `suffix-start` with a formatted reference `{name}`. + Returns a map with: + :value — the updated string + :cursor — the index immediately after the inserted reference" + [value prefix-end suffix-start name] + (let [ref (str "{" name "}") + first-part (str/slice value 0 prefix-end) + second-part (str/slice value suffix-start)] + {:value (str first-part ref second-part) + :cursor (+ (count first-part) (count ref))})) + +(defn insert-ref + "Inserts a reference `{name}` into `value` at `position`, respecting the context: + + - Outside any reference block: inserts `{name}` at the cursor position. + - Inside an open reference block (no closing `}`): replaces from the block's + start up to the cursor with `{name}`. + - Inside a closed reference block (has both `{` and `}`): replaces the entire + existing reference with `{name}`. + + Returns a map with: + :value — the resulting string after insertion + :cursor — the index immediately after the inserted reference" + [value position name] + (if (inside-ref? value position) + (if (inside-closed-ref? value position) + (let [open-pos (-> (str/slice value 0 position) + (d/nth-last-index-of "{" 1)) + close-pos (-> (str/slice value position) + (d/nth-index-of "}" 1)) + close-pos (if (number? close-pos) + (+ position close-pos 1) + position)] + (build-result value open-pos close-pos name)) + + (build-result value (start-ref-position value position) position name)) + (build-result value position position name))) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 573dac181d..63cd87e393 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -611,7 +611,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))) @@ -632,14 +632,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] @@ -724,7 +719,7 @@ (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 #{} (filter some?) %)) (check-token-theme-attrs) (map->TokenTheme)))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index f2885c07f7..c4cbe4c100 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -113,3 +113,27 @@ (t/is (= (d/reorder v 3 -1) ["d" "a" "b" "c"])) (t/is (= (d/reorder v 5 -1) ["d" "a" "b" "c"])) (t/is (= (d/reorder v -1 5) ["b" "c" "d" "a"])))) + +(t/deftest nth-last-index-of-test + (t/is (= (d/nth-last-index-of "" "*" 1) nil)) + (t/is (= (d/nth-last-index-of "*abc" "*" 1) 0)) + (t/is (= (d/nth-last-index-of "**abc" "*" 2) 0)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 3) nil)) + (t/is (= (d/nth-last-index-of "" "*" 2) nil)) + (t/is (= (d/nth-last-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-last-index-of "abc*" "*" 2) nil)) + (t/is (= (d/nth-last-index-of "*abc[*" "*" 1) 5)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 1) 7)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 2) 3))) + +(t/deftest nth-index-of-test + (t/is (= (d/nth-index-of "" "*" 1) nil)) + (t/is (= (d/nth-index-of "" "*" 2) nil)) + (t/is (= (d/nth-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc**" "*" 2) 4)) + (t/is (= (d/nth-index-of "abc*" "*" 2) nil)) + (t/is (= (d/nth-index-of "*abc[*" "*" 1) 0)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 2) 7)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 3) nil))) diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index 5098bf6e82..b6c18aab2d 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -9,6 +9,39 @@ [app.common.media :as media] [clojure.test :as t])) +(t/deftest test-parse-font-weight + (t/testing "matches weight tokens with proper boundaries" + (t/is (= 700 (media/parse-font-weight "Roboto-Bold"))) + (t/is (= 700 (media/parse-font-weight "Roboto_Bold"))) + (t/is (= 700 (media/parse-font-weight "Roboto Bold"))) + (t/is (= 700 (media/parse-font-weight "Bold"))) + (t/is (= 800 (media/parse-font-weight "Roboto-ExtraBold"))) + (t/is (= 600 (media/parse-font-weight "OpenSans-SemiBold"))) + (t/is (= 300 (media/parse-font-weight "Lato-Light"))) + (t/is (= 100 (media/parse-font-weight "Roboto-Thin"))) + (t/is (= 200 (media/parse-font-weight "Roboto-ExtraLight"))) + (t/is (= 500 (media/parse-font-weight "Roboto-Medium"))) + (t/is (= 900 (media/parse-font-weight "Roboto-Black")))) + + (t/testing "does not match weight tokens embedded in words" + (t/is (= 400 (media/parse-font-weight "Boldini"))) + (t/is (= 400 (media/parse-font-weight "Lighthaus"))) + (t/is (= 400 (media/parse-font-weight "Blackwood"))) + (t/is (= 400 (media/parse-font-weight "Thinker"))) + (t/is (= 400 (media/parse-font-weight "Mediaeval"))))) + +(t/deftest test-parse-font-style + (t/testing "matches italic with proper boundaries" + (t/is (= "italic" (media/parse-font-style "Roboto-Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto_Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto Italic"))) + (t/is (= "italic" (media/parse-font-style "Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic")))) + + (t/testing "does not match italic embedded in words" + (t/is (= "normal" (media/parse-font-style "Italica"))) + (t/is (= "normal" (media/parse-font-style "Roboto-Regular"))))) + (t/deftest test-strip-image-extension (t/testing "removes extension from supported image files" (t/is (= (media/strip-image-extension "foo.png") "foo")) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index fb93bd2541..96e642690c 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -24,3 +24,89 @@ (t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar"))) (t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar"))) (t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar")))) + + +(t/deftest token-value-with-refs + (t/testing "empty value" + (t/is (= (cto/insert-ref "" 0 "token1") + {:value "{token1}" :cursor 8}))) + + (t/testing "value without references" + (t/is (= (cto/insert-ref "ABC" 0 "token1") + {:value "{token1}ABC" :cursor 8})) + (t/is (= (cto/insert-ref "23 + " 5 "token1") + {:value "23 + {token1}" :cursor 13})) + (t/is (= (cto/insert-ref "23 + " 5 "token1") + {:value "23 + {token1}" :cursor 13}))) + + (t/testing "value with closed references" + (t/is (= (cto/insert-ref "{token2}" 8 "token1") + {:value "{token2}{token1}" :cursor 16})) + (t/is (= (cto/insert-ref "{token2}" 6 "token1") + {:value "{token1}" :cursor 8})) + (t/is (= (cto/insert-ref "{token2} + + {token3}" 10 "token1") + {:value "{token2} +{token1} + {token3}" :cursor 18})) + (t/is (= (cto/insert-ref "{token2} + {token3}" 16 "token1") + {:value "{token2} + {token1}" :cursor 19}))) + + (t/testing "value with open references" + (t/is (= (cto/insert-ref "{tok" 4 "token1") + {:value "{token1}" :cursor 8})) + (t/is (= (cto/insert-ref "{tok" 2 "token1") + {:value "{token1}ok" :cursor 8})) + (t/is (= (cto/insert-ref "{token2}{" 9 "token1") + {:value "{token2}{token1}" :cursor 16})) + (t/is (= (cto/insert-ref "{token2{}" 8 "token1") + {:value "{token2{token1}" :cursor 15})) + (t/is (= (cto/insert-ref "{token2} + { + token3}" 12 "token1") + {:value "{token2} + {token1} + token3}" :cursor 19})) + (t/is (= (cto/insert-ref "{token2{}" 8 "token1") + {:value "{token2{token1}" :cursor 15})) + (t/is (= (cto/insert-ref "{token2} + {{{{{{{{{{ + {token3}" 21 "token1") + {:value "{token2} + {token1} + {token3}" :cursor 19}))) + + (t/testing "value with broken references" + (t/is (= (cto/insert-ref "{tok {en2}" 6 "token1") + {:value "{tok {token1}" :cursor 13})) + (t/is (= (cto/insert-ref "{tok en2}" 5 "token1") + {:value "{tok {token1}en2}" :cursor 13}))) + + (t/testing "edge cases" + (t/is (= (cto/insert-ref "" 0 "x") + {:value "{x}" :cursor 3})) + (t/is (= (cto/insert-ref "abc" 3 "x") + {:value "abc{x}" :cursor 6})) + (t/is (= (cto/insert-ref "{token2}" 0 "x") + {:value "{x}{token2}" :cursor 3})) + (t/is (= (cto/insert-ref "abc" 3 "") + {:value "abc{}" :cursor 5})) + (t/is (= (cto/insert-ref "{a} {b}" 4 "x") + {:value "{a} {x}{b}" :cursor 7})) + (t/is (= (cto/insert-ref "{a {b {c" 8 "x") + {:value "{a {b {x}" :cursor 9})) + (t/is (= (cto/insert-ref "{ { {" 5 "x") + {:value "{ { {x}" :cursor 7}))) + + ;; inside-ref? coverage + (t/is (= (cto/insert-ref "AAA " 4 "x") + {:value "AAA {x}" :cursor 7})) + (t/is (= (cto/insert-ref "{abc}" 5 "x") + {:value "{abc}{x}" :cursor 8})) + (t/is (= (cto/insert-ref "{a}{b}" 6 "x") + {:value "{a}{b}{x}" :cursor 9})) + (t/is (= (cto/insert-ref "abc}" 4 "x") + {:value "abc}{x}" :cursor 7})) + (t/is (= (cto/insert-ref "{abc[}" 0 "x") + {:value "{x}{abc[}" :cursor 3})) + (t/is (= (cto/insert-ref "{abc[}" 1 "x") + {:value "{x}" :cursor 3})) + + ;; inside-closed-ref? coverage + (t/is (= (cto/insert-ref "{abc}" 1 "x") + {:value "{x}" :cursor 3})) + (t/is (= (cto/insert-ref "abc {def}ghi" 8 "x") + {:value "abc {x}ghi" :cursor 7})) + (t/is (= (cto/insert-ref "{ab cd}" 3 "x") + {:value "{x} cd}" :cursor 3})) + (t/is (= (cto/insert-ref "{a}{bc}" 5 "x") + {:value "{a}{x}" :cursor 6}))) diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 150ffcfb08..23bed42897 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -235,6 +235,19 @@ (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) #"expected valid params for token-theme" (ctob/make-token-theme params))))) +(t/deftest make-token-theme-strips-nil-from-sets + (t/testing "make-token-theme strips nil values from :sets" + (let [theme (ctob/make-token-theme :name "test" :sets #{"valid-set" nil})] + (t/is (= (:sets theme) #{"valid-set"})))) + (t/testing "enable-set with nil set-name does not add nil to :sets" + (let [theme (ctob/make-token-theme :name "test" :sets #{"existing-set"}) + theme' (ctob/enable-set theme nil)] + (t/is (= (:sets theme') #{"existing-set"})))) + (t/testing "toggle-set with nil set-name does not add nil to :sets" + (let [theme (ctob/make-token-theme :name "test" :sets #{}) + theme' (ctob/toggle-set theme nil)] + (t/is (= (:sets theme') #{}))))) + (t/deftest make-tokens-lib (let [tokens-lib (ctob/make-tokens-lib)] (t/is (= (ctob/set-count tokens-lib) 0)))) diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index ca770e04c7..3a6f50b4be 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -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'; diff --git a/docs/package.json b/docs/package.json index 1f9d4908dc..0a11edb8e5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^9.0.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48" + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" } diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 878cfb4230..b8f4a1029e 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -276,9 +276,6 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

Sizing

Sizing tokens can define various size-related design properties, namely the height and width of design elements.The sizing token supports numeric values, which include negative values.

-
- Tokens spacing -

Applying Sizing Tokens

To apply the sizing token to an element, select the element and choose the token from the list: