diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 69c36a0e44..dd81c5c882 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -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 diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 281c834256..d45dec0453 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -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)))))) diff --git a/common/src/app/common/files/stats.cljc b/common/src/app/common/files/stats.cljc new file mode 100644 index 0000000000..99a2315243 --- /dev/null +++ b/common/src/app/common/files/stats.cljc @@ -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)}))