mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 03:08:18 +00:00
🎉 Add get-file-stats RPC command (#9074)
* 🎉 Add get-file-stats RPC command Introduce a new lightweight RPC query that returns aggregate statistics for a single file: page count, shape counts by type, component/color/ typography counts, and inbound and outbound library reference counts. Mirrors the existing get-file-summary permission and decoding pattern. Useful for plugin authors enforcing per-file budgets, the @penpot/library npm SDK, and future admin dashboards. Purely additive — no migrations, no UI, no breaking changes. Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com> * 🐛 Bind *load-fn* around file data walk in get-file-stats The binding previously wrapped only — a plain key lookup that does not realize any pointers — so by the time walked and accessed on each page, was unbound and every PointerMap dereference threw , failing the three new tests. Move inside the form so the walk runs with available, matching the existing pattern used in . Signed-off-by: Edwin Rivera <bytelogic772@gmail.com> --------- Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com> Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>
This commit is contained in:
parent
09fca1c820
commit
2579527e64
@ -13,6 +13,7 @@
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.stats :as cfs]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.desc-js-like :as-alias smdj]
|
||||
@ -606,6 +607,76 @@
|
||||
(get-file-summary cfg id))
|
||||
|
||||
|
||||
;; --- COMMAND QUERY: get-file-stats
|
||||
|
||||
(def ^:private sql:file-stats-library-counts
|
||||
"SELECT
|
||||
(SELECT COUNT(*)
|
||||
FROM file_library_rel AS flr
|
||||
JOIN file AS fl ON (fl.id = flr.library_file_id)
|
||||
WHERE flr.file_id = ?::uuid
|
||||
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM file_library_rel AS flr
|
||||
JOIN file AS fl ON (fl.id = flr.file_id)
|
||||
WHERE flr.library_file_id = ?::uuid
|
||||
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")
|
||||
|
||||
(defn- get-file-stats-library-counts
|
||||
[conn file-id]
|
||||
(let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
|
||||
{:library-count (or (:library-count row) 0)
|
||||
:referenced-by-count (or (:referenced-by-count row) 0)}))
|
||||
|
||||
(defn- get-file-stats
|
||||
[{:keys [::db/conn] :as cfg} file-id]
|
||||
(let [file (bfc/get-file cfg file-id)
|
||||
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
||||
(cfs/calc-file-stats (:data file)))
|
||||
lib-cnt (get-file-stats-library-counts conn file-id)]
|
||||
(-> base
|
||||
(merge lib-cnt)
|
||||
(assoc :file-id file-id
|
||||
:revn (:revn file)
|
||||
:updated-at (:modified-at file)))))
|
||||
|
||||
(def ^:private schema:shape-counts
|
||||
[:map {:title "FileStatsShapeCounts"}
|
||||
[:total [::sm/int {:min 0}]]
|
||||
[:by-type [:map-of :keyword [::sm/int {:min 0}]]]])
|
||||
|
||||
(def ^:private schema:get-file-stats-result
|
||||
[:map {:title "FileStats"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:page-count [::sm/int {:min 0}]]
|
||||
[:shape-counts schema:shape-counts]
|
||||
[:component-count [::sm/int {:min 0}]]
|
||||
[:deleted-component-count [::sm/int {:min 0}]]
|
||||
[:color-count [::sm/int {:min 0}]]
|
||||
[:typography-count [::sm/int {:min 0}]]
|
||||
[:library-count [::sm/int {:min 0}]]
|
||||
[:referenced-by-count [::sm/int {:min 0}]]
|
||||
[:revn [::sm/int {:min 0}]]
|
||||
[:updated-at ::ct/inst]])
|
||||
|
||||
(def ^:private schema:get-file-stats
|
||||
[:map {:title "get-file-stats"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::get-file-stats
|
||||
"Return aggregate statistics for a single file: page count, shape
|
||||
counts by type, component/color/typography counts, and inbound and
|
||||
outbound library reference counts. Cheap alternative to `get-file`
|
||||
when only metrics are needed."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-file-stats
|
||||
::sm/result schema:get-file-stats-result
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
(get-file-stats cfg id))
|
||||
|
||||
|
||||
;; --- COMMAND QUERY: get-file-libraries
|
||||
|
||||
(def ^:private schema:get-file-libraries
|
||||
|
||||
@ -2121,3 +2121,92 @@
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
|
||||
(t/is (nil? (:deleted-at row1))))))))
|
||||
|
||||
(t/deftest get-file-stats-empty-file
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :get-file-stats
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [result (:result out)]
|
||||
(t/is (= (:id file) (:file-id result)))
|
||||
(t/is (pos? (:page-count result)))
|
||||
(t/is (zero? (:component-count result)))
|
||||
(t/is (zero? (:deleted-component-count result)))
|
||||
(t/is (zero? (:color-count result)))
|
||||
(t/is (zero? (:typography-count result)))
|
||||
(t/is (zero? (:library-count result)))
|
||||
(t/is (zero? (:referenced-by-count result)))
|
||||
(t/is (contains? result :shape-counts))
|
||||
(t/is (zero? (get-in result [:shape-counts :total])))
|
||||
(t/is (= {} (get-in result [:shape-counts :by-type]))))))
|
||||
|
||||
(t/deftest get-file-stats-with-shapes
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (-> file :data :pages first)
|
||||
rect-id (uuid/random)
|
||||
frame-id (uuid/random)]
|
||||
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id frame-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id frame-id
|
||||
:name "frame"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame})}
|
||||
{:type :add-obj
|
||||
:page-id page-id
|
||||
:id rect-id
|
||||
:parent-id frame-id
|
||||
:frame-id frame-id
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id rect-id
|
||||
:name "rect"
|
||||
:frame-id frame-id
|
||||
:parent-id frame-id
|
||||
:type :rect})}])
|
||||
|
||||
(let [out (th/command! {::th/type :get-file-stats
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
result (:result out)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 2 (get-in result [:shape-counts :total])))
|
||||
(t/is (= 1 (get-in result [:shape-counts :by-type :rect])))
|
||||
(t/is (= 1 (get-in result [:shape-counts :by-type :frame]))))))
|
||||
|
||||
(t/deftest get-file-stats-forbidden
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :get-file-stats
|
||||
::rpc/profile-id (:id other)
|
||||
:id (:id file)})]
|
||||
|
||||
(t/is (not (nil? (:error out))))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
74
common/src/app/common/files/stats.cljc
Normal file
74
common/src/app/common/files/stats.cljc
Normal file
@ -0,0 +1,74 @@
|
||||
;; 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.files.stats
|
||||
"Pure helpers that compute aggregate statistics for a file data map.
|
||||
|
||||
Given a decoded file data structure (the value stored under `:data`
|
||||
on a file row), produces a small map with page/shape/library counts.
|
||||
Intended to be cheap — a single pass over each page's `:objects`
|
||||
map, no database access, no side effects."
|
||||
(:require
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(def empty-shape-counts
|
||||
{:total 0 :by-type {}})
|
||||
|
||||
(defn- inc-type
|
||||
[by-type shape-type]
|
||||
(if (nil? shape-type)
|
||||
by-type
|
||||
(update by-type shape-type (fnil inc 0))))
|
||||
|
||||
(defn count-shapes-by-type
|
||||
"Walk an `:objects` map of a single page and return
|
||||
`{:total N :by-type {:rect N :frame N ...}}`. The synthetic root
|
||||
shape at `uuid/zero` is skipped so it never contributes to totals."
|
||||
[objects]
|
||||
(if (empty? objects)
|
||||
empty-shape-counts
|
||||
(reduce-kv
|
||||
(fn [acc id shape]
|
||||
(if (= id uuid/zero)
|
||||
acc
|
||||
(-> acc
|
||||
(update :total inc)
|
||||
(update :by-type inc-type (:type shape)))))
|
||||
empty-shape-counts
|
||||
objects)))
|
||||
|
||||
(defn- merge-shape-counts
|
||||
[a b]
|
||||
{:total (+ (:total a) (:total b))
|
||||
:by-type (merge-with + (:by-type a) (:by-type b))})
|
||||
|
||||
(defn- aggregate-shape-counts
|
||||
[pages-index]
|
||||
(transduce
|
||||
(map (comp count-shapes-by-type :objects))
|
||||
(completing merge-shape-counts)
|
||||
empty-shape-counts
|
||||
(vals pages-index)))
|
||||
|
||||
(defn calc-file-stats
|
||||
"Given a decoded file data map with the standard keys
|
||||
`:pages-index`, `:components`, `:deleted-components`, `:colors`
|
||||
and `:typographies`, return per-file aggregates.
|
||||
|
||||
The result is a plain map suitable for serialization; it never
|
||||
contains any pointer-map or objects-map instances."
|
||||
[fdata]
|
||||
(let [pages-index (get fdata :pages-index)
|
||||
components (get fdata :components)
|
||||
deleted-components (get fdata :deleted-components)
|
||||
colors (get fdata :colors)
|
||||
typographies (get fdata :typographies)]
|
||||
{:page-count (count pages-index)
|
||||
:shape-counts (aggregate-shape-counts pages-index)
|
||||
:component-count (count components)
|
||||
:deleted-component-count (count deleted-components)
|
||||
:color-count (count colors)
|
||||
:typography-count (count typographies)}))
|
||||
Loading…
x
Reference in New Issue
Block a user