mirror of
https://github.com/penpot/penpot.git
synced 2026-07-28 17:06:21 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
e9603f00b0
1
.gitignore
vendored
1
.gitignore
vendored
@ -101,5 +101,6 @@ opencode.json
|
||||
/.opencode/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
|
||||
@ -109,4 +109,5 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||
|
||||
|
||||
@ -156,11 +156,13 @@
|
||||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
|
||||
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
|
||||
(ct/format-inst modified-at :iso)
|
||||
"/"
|
||||
(uri/map->query-string permissions)))
|
||||
(uri/map->query-string permissions)
|
||||
"/"
|
||||
(some-> deleted-at (ct/format-inst :iso))))
|
||||
|
||||
(sv/defmethod ::get-file
|
||||
"Retrieve a file by its ID. Only authenticated users."
|
||||
@ -1102,6 +1104,13 @@
|
||||
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
|
||||
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
||||
(when (contains? transitive-deps file-id)
|
||||
(ex/raise :type :validation
|
||||
:code :circular-library-reference
|
||||
:hint "linking this library would create a circular dependency")))
|
||||
|
||||
(link-file-to-library conn params)
|
||||
(bfc/get-libraries cfg [library-id]))
|
||||
|
||||
|
||||
@ -374,61 +374,6 @@
|
||||
|
||||
;; --- MUTATION COMMAND: create-file-thumbnail
|
||||
|
||||
(defn- create-file-thumbnail
|
||||
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
|
||||
(media/validate-media-type! media)
|
||||
(media/validate-media-size! media)
|
||||
|
||||
(let [file (bfc/get-file cfg file-id
|
||||
:include-deleted? true
|
||||
:load-data? false)
|
||||
|
||||
props (db/tjson (or props {}))
|
||||
path (:path media)
|
||||
mtype (:mtype media)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
tnow (ct/now)
|
||||
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at tnow
|
||||
:content-type mtype
|
||||
:bucket "file-thumbnail"})
|
||||
|
||||
thumb (db/get* conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn}
|
||||
{::db/remove-deleted false
|
||||
::sql/for-update true})]
|
||||
|
||||
(if (some? thumb)
|
||||
(do
|
||||
;; We mark the old media id as touched if it does not match
|
||||
(when (not= (:id media) (:media-id thumb))
|
||||
(sto/touch-object! storage (:media-id thumb)))
|
||||
|
||||
(db/update! conn :file-thumbnail
|
||||
{:media-id (:id media)
|
||||
:deleted-at (:deleted-at file)
|
||||
:updated-at tnow
|
||||
:props props}
|
||||
{:file-id file-id
|
||||
:revn revn}))
|
||||
|
||||
(db/insert! conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn
|
||||
:created-at tnow
|
||||
:updated-at tnow
|
||||
:deleted-at (:deleted-at file)
|
||||
:props props
|
||||
:media-id (:id media)}))
|
||||
|
||||
media))
|
||||
|
||||
(def ^:private
|
||||
schema:create-file-thumbnail
|
||||
[:map {:title "create-file-thumbnail"}
|
||||
@ -448,12 +393,57 @@
|
||||
::rtry/when rtry/conflict-exception?
|
||||
::sm/params schema:create-file-thumbnail}
|
||||
|
||||
;; FIXME: do not run the thumbnail upload inside a transaction
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(let [media (create-file-thumbnail cfg params)]
|
||||
{:uri (files/resolve-public-uri (:id media))
|
||||
:id (:id media)})))))
|
||||
(media/validate-media-type! (:media params))
|
||||
(media/validate-media-size! (:media params))
|
||||
|
||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||
|
||||
(when-not (db/read-only? (::db/pool cfg))
|
||||
(let [storage (::sto/storage cfg)
|
||||
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
|
||||
props (db/tjson (or (:props params) {}))
|
||||
{:keys [path mtype]} (:media params)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
tnow (ct/now)
|
||||
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at tnow
|
||||
:content-type mtype
|
||||
:bucket "file-thumbnail"})
|
||||
|
||||
revn (:revn params)
|
||||
|
||||
result (db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [thumb (db/get* conn :file-thumbnail
|
||||
{:file-id file-id :revn revn}
|
||||
{::db/remove-deleted false
|
||||
::sql/for-update true})]
|
||||
(if (some? thumb)
|
||||
(do
|
||||
(when (not= (:id media) (:media-id thumb))
|
||||
(sto/touch-object! storage (:media-id thumb)))
|
||||
(db/update! conn :file-thumbnail
|
||||
{:media-id (:id media)
|
||||
:deleted-at (:deleted-at file)
|
||||
:updated-at tnow
|
||||
:props props}
|
||||
{:file-id file-id :revn revn}))
|
||||
(db/insert! conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn
|
||||
:created-at tnow
|
||||
:updated-at tnow
|
||||
:deleted-at (:deleted-at file)
|
||||
:props props
|
||||
:media-id (:id media)}))
|
||||
media)))]
|
||||
|
||||
(when result
|
||||
{:uri (files/resolve-public-uri (:id result))
|
||||
:id (:id result)}))))
|
||||
|
||||
@ -6,9 +6,11 @@
|
||||
|
||||
(ns app.rpc.commands.search
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.schema :as sm]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
@ -66,11 +68,13 @@
|
||||
(def ^:private schema:search-files
|
||||
[:map {:title "search-files"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:search-term {:optional true} :string]])
|
||||
[:search-term {:optional true} [:string {:max 250}]]])
|
||||
|
||||
(sv/defmethod ::search-files
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files
|
||||
::sm/params schema:search-files}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
|
||||
(some->> search-term (search-files pool profile-id team-id)))
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(some->> search-term (search-files conn profile-id team-id))))
|
||||
|
||||
@ -2319,3 +2319,75 @@
|
||||
(t/is (not (nil? (:error out))))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
;; --- Security Fix Tests ---
|
||||
|
||||
(t/deftest link-file-to-library-circular-reference
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
file3 (th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
(th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)})
|
||||
(th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)})
|
||||
(let [data {::th/type :link-file-to-library
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file1)
|
||||
:library-id (:id file3)}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :circular-library-reference (:code edata)))))))
|
||||
|
||||
(t/deftest get-file-etag-includes-deleted-at
|
||||
(let [profile-id (uuid/random)
|
||||
file1 {:modified-at (ct/now)
|
||||
:revn 1
|
||||
:vern 0
|
||||
:deleted-at nil
|
||||
:permissions {:can-edit true}}
|
||||
file2 (assoc file1 :deleted-at (ct/now))]
|
||||
(t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1)
|
||||
(files/get-file-etag {::rpc/profile-id profile-id} file2)))))
|
||||
|
||||
(t/deftest search-files-with-permission
|
||||
(let [profile (th/create-profile* 1)
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id profile)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term "test"}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (vector? (:result out)))))
|
||||
|
||||
(t/deftest search-files-forbidden
|
||||
(let [profile (th/create-profile* 1)
|
||||
other (th/create-profile* 2)
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id other)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term "test"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
(t/deftest search-files-term-too-long
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id profile)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term (apply str (repeat 300 "x"))}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata))))))
|
||||
|
||||
@ -1573,7 +1573,12 @@
|
||||
(as-> libraries-to-load $
|
||||
(remove loaded-libraries $)
|
||||
(conj $ library-id)
|
||||
(map #(load-library-file file-id %) $))))))
|
||||
(map #(load-library-file file-id %) $))))
|
||||
(rx/catch (fn [cause]
|
||||
(let [error (ex-data cause)]
|
||||
(if (= (:code error) :circular-library-reference)
|
||||
(rx/of (ntf/error (tr "errors.circular-library-reference")))
|
||||
(rx/throw cause)))))))
|
||||
(rx/of (ptk/reify ::attach-library-finished))
|
||||
(when (pos? variants-count)
|
||||
(->> (rp/cmd! :get-library-usage {:file-id library-id})
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private xf:options
|
||||
@ -229,8 +230,11 @@
|
||||
(partial ug/unlisten "penpot:context-menu:open" on-event)))
|
||||
|
||||
(mf/with-effect [ids]
|
||||
(tm/schedule-on-idle
|
||||
#(dom/focus! (dom/get-element (first ids)))))
|
||||
(let [handle (tm/schedule
|
||||
(fn []
|
||||
(some-> (dom/get-element (first ids))
|
||||
(dom/focus!))))]
|
||||
#(rx/dispose! handle)))
|
||||
|
||||
(when (some? levels)
|
||||
[:> dropdown-content* props
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.util.globals :as globals]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf])
|
||||
(:import goog.events.EventType))
|
||||
@ -45,9 +46,10 @@
|
||||
(fn []
|
||||
(let [keys [(events/listen globals/document EventType.CLICK on-click)
|
||||
(events/listen globals/document EventType.CONTEXTMENU on-click)
|
||||
(events/listen globals/document EventType.KEYUP on-keyup)]]
|
||||
(tm/schedule #(mf/set-ref-val! listening-ref true))
|
||||
#(run! events/unlistenByKey keys)))]
|
||||
(events/listen globals/document EventType.KEYUP on-keyup)]
|
||||
timer (tm/schedule #(mf/set-ref-val! listening-ref true))]
|
||||
#(do (rx/dispose! timer)
|
||||
(run! events/unlistenByKey keys))))]
|
||||
|
||||
(mf/use-effect on-mount)
|
||||
children))
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.nitrate.nitrate-form]
|
||||
[app.util.dom :as dom]
|
||||
@ -95,6 +95,14 @@
|
||||
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
|
||||
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle")
|
||||
|
||||
(defn schedule-focus-by-id!
|
||||
[ref element-id]
|
||||
(when-let [h (mf/ref-val ref)]
|
||||
(ts/dispose! h))
|
||||
(mf/set-ref-val! ref
|
||||
(ts/schedule
|
||||
#(dom/focus-and-untabbable! (dom/get-element element-id)))))
|
||||
|
||||
(mf/defc sidebar-project*
|
||||
{::mf/private true}
|
||||
[{:keys [item is-selected]}]
|
||||
@ -113,6 +121,8 @@
|
||||
|
||||
project-id (get item :id)
|
||||
|
||||
focus-timer-ref (use-focus-timer-ref)
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(mf/deps project-id)
|
||||
@ -124,14 +134,9 @@
|
||||
(mf/deps project-id)
|
||||
(fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(st/emit!
|
||||
(dcm/go-to-dashboard-files :project-id project-id))
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(when-let [title (dom/get-element (str project-id))]
|
||||
(dom/set-attribute! title "tabindex" "0")
|
||||
(dom/focus! title)
|
||||
(dom/set-attribute! title "tabindex" "-1")))))))
|
||||
(schedule-focus-by-id! focus-timer-ref (str project-id))
|
||||
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)))))
|
||||
|
||||
|
||||
on-menu-click
|
||||
(mf/use-fn
|
||||
@ -229,6 +234,8 @@
|
||||
focused? (mf/use-state false)
|
||||
emit! (mf/use-memo #(f/debounce st/emit! 500))
|
||||
|
||||
focus-timer-ref (use-focus-timer-ref)
|
||||
|
||||
on-search-blur
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
@ -255,13 +262,7 @@
|
||||
(mf/use-fn
|
||||
(fn [e]
|
||||
(when (kbd/enter? e)
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(let [search-title (dom/get-element (str "dashboard-search-title"))]
|
||||
(when search-title
|
||||
(dom/set-attribute! search-title "tabindex" "0")
|
||||
(dom/focus! search-title)
|
||||
(dom/set-attribute! search-title "tabindex" "-1")))))
|
||||
(schedule-focus-by-id! focus-timer-ref "dashboard-search-title")
|
||||
(dom/prevent-default e)
|
||||
(dom/stop-propagation e))))
|
||||
|
||||
@ -1003,6 +1004,8 @@
|
||||
|
||||
nitrate? (contains? cf/flags :nitrate)
|
||||
|
||||
focus-timer-ref (use-focus-timer-ref)
|
||||
|
||||
go-projects
|
||||
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
|
||||
|
||||
@ -1012,12 +1015,7 @@
|
||||
(fn []
|
||||
(st/emit!
|
||||
(dcm/go-to-dashboard-recent :team-id team-id))
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(when-let [projects-title (dom/get-element "dashboard-projects-title")]
|
||||
(dom/set-attribute! projects-title "tabindex" "0")
|
||||
(dom/focus! projects-title)
|
||||
(dom/set-attribute! projects-title "tabindex" "-1"))))))
|
||||
(schedule-focus-by-id! focus-timer-ref "dashboard-projects-title")))
|
||||
|
||||
go-fonts
|
||||
(mf/use-fn
|
||||
@ -1030,13 +1028,7 @@
|
||||
(fn []
|
||||
(st/emit!
|
||||
(dcm/go-to-dashboard-fonts :team-id team-id))
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(let [font-title (dom/get-element "dashboard-fonts-title")]
|
||||
(when font-title
|
||||
(dom/set-attribute! font-title "tabindex" "0")
|
||||
(dom/focus! font-title)
|
||||
(dom/set-attribute! font-title "tabindex" "-1")))))))
|
||||
(schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title")))
|
||||
|
||||
go-drafts
|
||||
(mf/use-fn
|
||||
@ -1049,12 +1041,7 @@
|
||||
(mf/deps team-id default-project-id)
|
||||
(fn []
|
||||
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(when-let [title (dom/get-element "dashboard-drafts-title")]
|
||||
(dom/set-attribute! title "tabindex" "0")
|
||||
(dom/focus! title)
|
||||
(dom/set-attribute! title "tabindex" "-1"))))))
|
||||
(schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title")))
|
||||
|
||||
go-libs
|
||||
(mf/use-fn
|
||||
@ -1067,13 +1054,7 @@
|
||||
(fn []
|
||||
(st/emit!
|
||||
(dcm/go-to-dashboard-libraries :team-id team-id))
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(let [libs-title (dom/get-element "dashboard-libraries-title")]
|
||||
(when libs-title
|
||||
(dom/set-attribute! libs-title "tabindex" "0")
|
||||
(dom/focus! libs-title)
|
||||
(dom/set-attribute! libs-title "tabindex" "-1")))))))
|
||||
(schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title")))
|
||||
|
||||
pinned-projects
|
||||
(mf/with-memo [projects]
|
||||
|
||||
@ -322,25 +322,26 @@
|
||||
(let [trigger-el (mf/ref-val trigger-ref)
|
||||
tooltip-el (mf/ref-val tooltip-ref)]
|
||||
(when (and trigger-el tooltip-el)
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [origin-brect (dom/get-bounding-rect trigger-el)
|
||||
tooltip-brect (dom/get-bounding-rect tooltip-el)
|
||||
window-size (dom/get-window-size)]
|
||||
(when-let [[new-placement placement-rect]
|
||||
(find-matching-placement
|
||||
placement
|
||||
tooltip-brect
|
||||
origin-brect
|
||||
window-size
|
||||
offset)]
|
||||
(dom/set-css-property! tooltip-el "inset-block-start"
|
||||
(str (:top placement-rect) "px"))
|
||||
(dom/set-css-property! tooltip-el "inset-inline-start"
|
||||
(str (:left placement-rect) "px"))
|
||||
(let [raf-id (ts/raf
|
||||
(fn []
|
||||
(let [origin-brect (dom/get-bounding-rect trigger-el)
|
||||
tooltip-brect (dom/get-bounding-rect tooltip-el)
|
||||
window-size (dom/get-window-size)]
|
||||
(when-let [[new-placement placement-rect]
|
||||
(find-matching-placement
|
||||
placement
|
||||
tooltip-brect
|
||||
origin-brect
|
||||
window-size
|
||||
offset)]
|
||||
(dom/set-css-property! tooltip-el "inset-block-start"
|
||||
(str (:top placement-rect) "px"))
|
||||
(dom/set-css-property! tooltip-el "inset-inline-start"
|
||||
(str (:left placement-rect) "px"))
|
||||
|
||||
(when (not= new-placement placement)
|
||||
(reset! placement* new-placement)))))))))))
|
||||
(when (not= new-placement placement)
|
||||
(reset! placement* new-placement))))))]
|
||||
#(ts/cancel-af! raf-id)))))))
|
||||
|
||||
[:> :div props
|
||||
children
|
||||
|
||||
@ -282,6 +282,16 @@
|
||||
(mf/set-ref-val! ref val))
|
||||
(mf/ref-val ref)))
|
||||
|
||||
;; FIXME: replace with rumext
|
||||
(defn use-focus-timer-ref
|
||||
"Returns a ref for scheduling focus timers and disposes any pending
|
||||
timer on component unmount."
|
||||
[]
|
||||
(let [ref (mf/use-ref nil)]
|
||||
(mf/with-effect []
|
||||
#(some-> (mf/ref-val ref) ts/dispose!))
|
||||
ref))
|
||||
|
||||
;; FIXME: rename to use-focus-objects
|
||||
(defn with-focus-objects
|
||||
([objects]
|
||||
|
||||
@ -707,6 +707,13 @@
|
||||
(when (some? node)
|
||||
(.setAttribute node attr value)))
|
||||
|
||||
(defn focus-and-untabbable!
|
||||
[^js node]
|
||||
(when (some? node)
|
||||
(set-attribute! node "tabindex" "0")
|
||||
(focus! node)
|
||||
(set-attribute! node "tabindex" "-1")))
|
||||
|
||||
(defn set-style!
|
||||
[^js node ^string style value]
|
||||
(when (some? node)
|
||||
|
||||
@ -1504,6 +1504,9 @@ msgstr "The fonts %s could not be loaded"
|
||||
msgid "errors.cannot-upload"
|
||||
msgstr "Cannot upload the media file."
|
||||
|
||||
msgid "errors.circular-library-reference"
|
||||
msgstr "Cannot add library: this would create a circular dependency"
|
||||
|
||||
#: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864
|
||||
msgid "errors.character-limit-exceeded"
|
||||
msgstr "Character limit exceeded"
|
||||
|
||||
@ -1515,6 +1515,9 @@ msgstr "No se han podido cargar las fuentes %s"
|
||||
msgid "errors.cannot-upload"
|
||||
msgstr "No se puede cargar el archivo multimedia."
|
||||
|
||||
msgid "errors.circular-library-reference"
|
||||
msgstr "No se puede añadir la biblioteca: crearía una dependencia circular"
|
||||
|
||||
#: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864
|
||||
msgid "errors.character-limit-exceeded"
|
||||
msgstr "Se ha superado el límite de caracteres"
|
||||
|
||||
478
scripts/ci
Executable file
478
scripts/ci
Executable file
@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci - CI orchestration script for Penpot monorepo
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Constants
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
LOG_DIR="$PROJECT_ROOT/.ci-logs"
|
||||
|
||||
# Color codes
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Available modules
|
||||
ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library")
|
||||
|
||||
# Module commands
|
||||
declare -A LINT_CMD=(
|
||||
[frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss"
|
||||
[backend]="pnpm run lint"
|
||||
[common]="pnpm run lint:clj"
|
||||
[render-wasm]="./lint"
|
||||
[exporter]="pnpm run lint"
|
||||
[mcp]=""
|
||||
[plugins]="pnpm run lint"
|
||||
[library]="pnpm run lint"
|
||||
)
|
||||
|
||||
declare -A TEST_CMD=(
|
||||
[frontend]="pnpm run test:quiet"
|
||||
[backend]="clojure -M:dev:test"
|
||||
[common]="clojure -M:dev:test && pnpm run test:quiet"
|
||||
[render-wasm]="./test"
|
||||
[exporter]=""
|
||||
[mcp]="pnpm run test"
|
||||
[plugins]="pnpm run test"
|
||||
[library]="pnpm run test"
|
||||
)
|
||||
|
||||
declare -A FMT_CHECK_CMD=(
|
||||
[frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss"
|
||||
[backend]="pnpm run check-fmt"
|
||||
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
|
||||
[render-wasm]="cargo fmt --check"
|
||||
[exporter]="pnpm run check-fmt"
|
||||
[mcp]="pnpm run fmt:check"
|
||||
[plugins]="pnpm run format:check"
|
||||
[library]="pnpm run check-fmt"
|
||||
)
|
||||
|
||||
declare -A FMT_FIX_CMD=(
|
||||
[frontend]="pnpm run fmt"
|
||||
[backend]="pnpm run fmt"
|
||||
[common]="pnpm run fmt:clj && pnpm run fmt:js"
|
||||
[render-wasm]="cargo fmt"
|
||||
[exporter]="pnpm run fmt"
|
||||
[mcp]="pnpm run fmt"
|
||||
[plugins]="pnpm run format"
|
||||
[library]="pnpm run fmt"
|
||||
)
|
||||
|
||||
# Default options
|
||||
MODULES=()
|
||||
TASKS=("lint" "test" "fmt")
|
||||
FIX_MODE=false
|
||||
FAIL_FAST=false
|
||||
VERBOSE=true
|
||||
DRY_RUN=false
|
||||
|
||||
# Results tracking
|
||||
declare -A RESULTS=()
|
||||
declare -a FAILED_LOGS=()
|
||||
|
||||
timestamp() {
|
||||
date "+%H:%M:%S"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[$(timestamp)]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[$(timestamp)] ✓${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[$(timestamp)] ✗${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
local module=$1
|
||||
local current=$2
|
||||
local total=$3
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${CYAN} ${BOLD}[$current/$total] $module${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
run_task() {
|
||||
local module=$1
|
||||
local task=$2
|
||||
local cmd=$3
|
||||
local logfile="$LOG_DIR/${module}-${task}.log"
|
||||
|
||||
if [[ -z "$cmd" ]]; then
|
||||
log_warning "$task: not defined for $module, skipping"
|
||||
RESULTS["$module:$task"]="SKIPPED"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "$task: would run '$cmd' in $module/"
|
||||
RESULTS["$module:$task"]="DRY-RUN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Running ${BOLD}$task${NC} for $module..."
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${BLUE} cmd: $cmd${NC}"
|
||||
fi
|
||||
|
||||
local exit_code=0
|
||||
(cd "$PROJECT_ROOT/$module" && eval "$cmd") > "$logfile" 2>&1 || exit_code=$?
|
||||
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
log_success "$task passed for $module ${BLUE}(${duration}s)${NC}"
|
||||
RESULTS["$module:$task"]="PASSED"
|
||||
return 0
|
||||
else
|
||||
log_error "$task failed for $module ${BLUE}(${duration}s)${NC}"
|
||||
echo -e " ${RED}log: $logfile${NC}"
|
||||
RESULTS["$module:$task"]="FAILED"
|
||||
FAILED_LOGS+=("$logfile")
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${YELLOW} --- last 30 lines ---${NC}"
|
||||
tail -n 30 "$logfile" | sed 's/^/ /'
|
||||
echo -e "${YELLOW} ---------------------${NC}"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_module() {
|
||||
local module=$1
|
||||
local failed=false
|
||||
|
||||
for task in "${TASKS[@]}"; do
|
||||
local cmd=""
|
||||
case $task in
|
||||
lint)
|
||||
cmd="${LINT_CMD[$module]:-}"
|
||||
;;
|
||||
test)
|
||||
cmd="${TEST_CMD[$module]:-}"
|
||||
;;
|
||||
fmt)
|
||||
if [[ "$FIX_MODE" == "true" ]]; then
|
||||
cmd="${FMT_FIX_CMD[$module]:-}"
|
||||
else
|
||||
cmd="${FMT_CHECK_CMD[$module]:-}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! run_task "$module" "$task" "$cmd"; then
|
||||
failed=true
|
||||
if [[ "$FAIL_FAST" == "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$failed" == "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${CYAN} ${BOLD}SUMMARY${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
local total=0
|
||||
local passed=0
|
||||
local failed=0
|
||||
local skipped=0
|
||||
|
||||
for module in "${MODULES[@]}"; do
|
||||
for task in "${TASKS[@]}"; do
|
||||
local result="${RESULTS[$module:$task]:-N/A}"
|
||||
total=$((total + 1))
|
||||
case $result in
|
||||
PASSED)
|
||||
passed=$((passed + 1))
|
||||
echo -e " ${GREEN}✓${NC} $module:$task"
|
||||
;;
|
||||
FAILED)
|
||||
failed=$((failed + 1))
|
||||
echo -e " ${RED}✗${NC} $module:$task"
|
||||
;;
|
||||
SKIPPED)
|
||||
skipped=$((skipped + 1))
|
||||
echo -e " ${YELLOW}○${NC} $module:$task"
|
||||
;;
|
||||
DRY-RUN)
|
||||
echo -e " ${BLUE}~${NC} $module:$task"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}Total:${NC} $total ${GREEN}Passed:${NC} $passed ${RED}Failed:${NC} $failed ${YELLOW}Skipped:${NC} $skipped"
|
||||
|
||||
if [[ $failed -gt 0 ]]; then
|
||||
echo ""
|
||||
echo -e " ${RED}${BOLD}Failed logs:${NC}"
|
||||
for logfile in "${FAILED_LOGS[@]}"; do
|
||||
echo -e " ${RED}•${NC} $logfile"
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_module() {
|
||||
local mod=$1
|
||||
for valid in "${ALL_MODULES[@]}"; do
|
||||
if [[ "$valid" == "$mod" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS] [MODULES...]
|
||||
|
||||
CI orchestration script for Penpot monorepo.
|
||||
|
||||
MODULES:
|
||||
frontend, backend, common, render-wasm, exporter, mcp, plugins, library
|
||||
Use --all to run all modules.
|
||||
|
||||
OPTIONS:
|
||||
--all Run all modules
|
||||
--exclude MOD Exclude a module (can be repeated)
|
||||
--lint Run lint only
|
||||
--no-lint Skip lint
|
||||
--test Run tests only
|
||||
--no-test Skip tests
|
||||
--fmt Run format check only
|
||||
--no-fmt Skip format check
|
||||
--fix Run format fix instead of check
|
||||
--fail-fast Stop on first failure
|
||||
--verbose Show command output on failure (default)
|
||||
--quiet Suppress command output
|
||||
--dry-run Show what would run without executing
|
||||
--clean Remove .ci-logs directory
|
||||
--help Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
$(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend
|
||||
$(basename "$0") --all --no-test # Run lint and fmt on all modules
|
||||
$(basename "$0") --lint common # Run only lint on common
|
||||
$(basename "$0") --fix frontend # Fix formatting in frontend
|
||||
$(basename "$0") --all --fail-fast --verbose
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
main() {
|
||||
local exclude_modules=()
|
||||
local run_all=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--all)
|
||||
run_all=true
|
||||
shift
|
||||
;;
|
||||
--exclude)
|
||||
exclude_modules+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--lint)
|
||||
TASKS=("lint")
|
||||
shift
|
||||
;;
|
||||
--no-lint)
|
||||
TASKS=("${TASKS[@]/lint/}")
|
||||
shift
|
||||
;;
|
||||
--test)
|
||||
TASKS=("test")
|
||||
shift
|
||||
;;
|
||||
--no-test)
|
||||
TASKS=("${TASKS[@]/test/}")
|
||||
shift
|
||||
;;
|
||||
--fmt)
|
||||
TASKS=("fmt")
|
||||
shift
|
||||
;;
|
||||
--no-fmt)
|
||||
TASKS=("${TASKS[@]/fmt/}")
|
||||
shift
|
||||
;;
|
||||
--fix)
|
||||
FIX_MODE=true
|
||||
shift
|
||||
;;
|
||||
--fail-fast)
|
||||
FAIL_FAST=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--quiet)
|
||||
VERBOSE=false
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--clean)
|
||||
if [[ -d "$LOG_DIR" ]]; then
|
||||
rm -rf "$LOG_DIR"
|
||||
echo "Cleaned $LOG_DIR"
|
||||
else
|
||||
echo "No logs to clean"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
--help | -h)
|
||||
usage
|
||||
;;
|
||||
-*)
|
||||
echo -e "${RED}Error: Unknown option $1${NC}" >&2
|
||||
echo "Run '$(basename "$0") --help' for usage." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if validate_module "$1"; then
|
||||
MODULES+=("$1")
|
||||
else
|
||||
echo -e "${RED}Error: Unknown module '$1'${NC}" >&2
|
||||
echo "Valid modules: ${ALL_MODULES[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Clean empty entries from TASKS after removal
|
||||
TASKS=("${TASKS[@]// /}")
|
||||
TASKS=("${TASKS[@]/#/}")
|
||||
local clean_tasks=()
|
||||
for t in "${TASKS[@]}"; do
|
||||
if [[ -n "$t" ]]; then
|
||||
clean_tasks+=("$t")
|
||||
fi
|
||||
done
|
||||
TASKS=("${clean_tasks[@]}")
|
||||
|
||||
# Set modules
|
||||
if [[ "$run_all" == "true" ]]; then
|
||||
MODULES=("${ALL_MODULES[@]}")
|
||||
fi
|
||||
|
||||
# Apply exclusions
|
||||
if [[ ${#exclude_modules[@]} -gt 0 ]]; then
|
||||
local filtered=()
|
||||
for mod in "${MODULES[@]}"; do
|
||||
local excluded=false
|
||||
for ex in "${exclude_modules[@]}"; do
|
||||
if [[ "$mod" == "$ex" ]]; then
|
||||
excluded=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$excluded" == "false" ]]; then
|
||||
filtered+=("$mod")
|
||||
fi
|
||||
done
|
||||
MODULES=("${filtered[@]}")
|
||||
fi
|
||||
|
||||
# Validate
|
||||
if [[ ${#MODULES[@]} -eq 0 ]]; then
|
||||
echo -e "${RED}Error: No modules specified.${NC}" >&2
|
||||
echo "Use --all or specify modules: ${ALL_MODULES[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#TASKS[@]} -eq 0 ]]; then
|
||||
echo -e "${RED}Error: No tasks selected.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print configuration
|
||||
echo ""
|
||||
echo -e "${CYAN}${BOLD}Penpot CI Orchestrator${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " ${BOLD}Modules:${NC} ${MODULES[*]}"
|
||||
echo -e " ${BOLD}Tasks:${NC} ${TASKS[*]}"
|
||||
echo -e " ${BOLD}Fix mode:${NC} $FIX_MODE"
|
||||
echo -e " ${BOLD}Fail-fast:${NC} $FAIL_FAST"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e " ${BOLD}Mode:${NC} ${YELLOW}DRY RUN${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Run
|
||||
local total_modules=${#MODULES[@]}
|
||||
local current=0
|
||||
local modules_failed=0
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
for module in "${MODULES[@]}"; do
|
||||
current=$((current + 1))
|
||||
log_header "$module" "$current" "$total_modules"
|
||||
|
||||
if ! run_module "$module"; then
|
||||
modules_failed=$((modules_failed + 1))
|
||||
if [[ "$FAIL_FAST" == "true" ]]; then
|
||||
log_error "Fail-fast: stopping due to failure in $module"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local total_duration=$((end_time - start_time))
|
||||
|
||||
print_summary || true
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}Duration:${NC} ${total_duration}s"
|
||||
echo ""
|
||||
|
||||
if [[ $modules_failed -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
x
Reference in New Issue
Block a user