From 47fc35369fe65222209151f6b9c628aebad80c57 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 16 Jul 2026 19:25:08 +0200 Subject: [PATCH] :sparkles: Sync Component library changes into the Ladybug graph --- backend/src/app/graph/ladybug.clj | 35 +++++- backend/src/app/graph/sync.clj | 196 +++++++++++++++++++++++++++--- 2 files changed, 210 insertions(+), 21 deletions(-) diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj index a4bcc042ad..374136dd72 100644 --- a/backend/src/app/graph/ladybug.clj +++ b/backend/src/app/graph/ladybug.clj @@ -68,11 +68,30 @@ [v] (str "json('" (escape-cypher-string (json/encode v)) "')")) +(defn format-timestamp + "Ladybug TIMESTAMP literal (beadpot: `timestamp('…')`)." + [v] + (let [s (cond + (instance? java.time.Instant v) + (.toString ^java.time.Instant v) + + (instance? java.util.Date v) + (.toString (.toInstant ^java.util.Date v)) + + (string? v) + v + + :else + (str v))] + (str "timestamp('" (escape-cypher-string s) "')"))) + (defn format-value [v] (cond (nil? v) "NULL" (uuid? v) (format-uuid v) + (instance? java.time.Instant v) (format-timestamp v) + (instance? java.util.Date v) (format-timestamp v) (string? v) (format-string v) (number? v) (format-number v) (boolean? v) (if v "true" "false") @@ -85,12 +104,13 @@ "Format one element of a Cypher LIST literal for typed `elem-type`." [elem-type v] (case elem-type - "UUID" (format-uuid v) - "STRING" (format-string (str v)) - "JSON" (format-json v) - "INT64" (format-int v) - "DOUBLE" (format-number v) - "BOOLEAN" (if v "true" "false") + "UUID" (format-uuid v) + "STRING" (format-string (str v)) + "JSON" (format-json v) + "INT64" (format-int v) + "DOUBLE" (format-number v) + "BOOLEAN" (if v "true" "false") + "TIMESTAMP" (format-timestamp v) (format-value v))) (defn- format-list @@ -119,6 +139,9 @@ (= ladybug-type "UUID") (format-uuid v) + (= ladybug-type "TIMESTAMP") + (format-timestamp v) + (and (string? ladybug-type) (str/ends-with? ladybug-type "[]")) (format-list ladybug-type v) diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj index c1ad02cb91..caf8b2159f 100644 --- a/backend/src/app/graph/sync.clj +++ b/backend/src/app/graph/sync.clj @@ -18,7 +18,10 @@ (set! *warn-on-reflection* true) (def ^:private supported-change-types - #{:add-obj :mod-obj :del-obj :add-page :del-page :mod-page :mov-objects}) + #{:add-obj :mod-obj :del-obj + :add-page :del-page :mod-page :mov-objects + :add-component :mod-component :del-component + :restore-component :purge-component}) (defn- shape-table [shape] @@ -80,9 +83,22 @@ [nodes] (into {} (map page-index-entry (table-rows nodes "Page")))) +(defn- component-index-entry + [attrs] + (let [id (node-attrs-id attrs)] + [id {:id id + :name (:name attrs) + :deleted (boolean (:deleted attrs))}])) + +(defn- index-components + [nodes] + (into {} (map component-index-entry (table-rows nodes "Component")))) + (defn- shape-index-table? [table] - (not (contains? #{"Document" "Page" :Document :Page} table))) + (not (contains? #{"Document" "Page" "Component" + :Document :Page :Component} + table))) (defn- shape-index-entry [table attrs parents pages edges] @@ -111,15 +127,17 @@ [file-id revn {:keys [nodes edges]}] (let [doc-id (document-id-from-nodes nodes file-id) pages (index-pages nodes) + components (index-components nodes) parents (build-parent-map edges) children-index (build-children-map edges) shapes (index-shapes nodes edges parents pages)] - {:file-id file-id - :doc-id doc-id - :revn (long revn) - :pages pages - :shapes shapes - :children children-index})) + {:file-id file-id + :doc-id doc-id + :revn (long revn) + :pages pages + :components components + :shapes shapes + :children children-index})) (defn- format-node-value @@ -196,6 +214,25 @@ (str "MATCH (p:Page {id: " (ladybug/format-uuid page-id) "}) " "SET p.name = " (ladybug/format-string name) ";")) +(defn- remove-node-attr-statement + "Clear a property. Ladybug has no Neo4j-style REMOVE; SET to NULL." + [table shape-id attr] + (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "SET s." (nodes/cypher-property-key attr) " = NULL;")) + +(defn- index-add-component! + [index {:keys [id name doc-id]}] + (-> index + (assoc-in [:components id] {:id id :name name :deleted false}) + (update :children update doc-id (fnil conj #{}) id))) + +(defn- index-remove-component! + [index component-id] + (let [doc-id (:doc-id index)] + (-> index + (update :components dissoc component-id) + (update :children update doc-id #(disj (or % #{}) component-id))))) + (defn- set-document-revision-statement [doc-id revn] @@ -501,16 +538,145 @@ :applied? true} {:index index :statements [] :applied? false :reason :unsupported-page-change})) +(defn- component-syncable-attrs + "Projected Component columns that sync may SET (everything but :id)." + [] + (disj (set (nodes/column-keys "Component")) :id)) + +(defn- component-attrs-from-change + "Build CREATE attrs for `:add-component` (objects are not projected)." + [{:keys [id name path main-instance-id main-instance-page + annotation variant-id variant-properties]}] + (cond-> {:id id + :name (or name "Component") + :path (or path "") + :main-instance-id main-instance-id + :main-instance-page main-instance-page} + (some? annotation) (assoc :annotation annotation) + (some? variant-id) (assoc :variant-id variant-id) + (seq variant-properties) (assoc :variant-properties variant-properties))) + +(defn- apply-add-component + [index {:keys [id] :as change}] + (if (get-in index [:components id]) + {:index index :statements [] :applied? true} + (let [doc-id (:doc-id index) + position (count (:components index)) + attrs (nodes/project-attrs "Component" (component-attrs-from-change change)) + edge {:from-table "Component" + :from-id id + :to-table "Document" + :to-id doc-id + :position position}] + {:index (index-add-component! index + {:id id + :name (:name attrs) + :doc-id doc-id}) + :statements [(create-node-statement "Component" attrs) + (create-edge-statement edge)] + :applied? true}))) + +(defn- apply-mod-component + "Update projected Component attrs from a `:mod-component` change. + + Nil optional values clear the property (Penpot dissocs them). `:objects` + is never projected — shape trees live on pages." + [index {:keys [id] :as change}] + (let [syncable (component-syncable-attrs) + sets (into {} + (keep (fn [[k v]] + (when (and (contains? syncable k) (some? v)) + [k v]))) + (dissoc change :type :id :objects)) + removes (into [] + (keep (fn [[k v]] + (when (and (contains? syncable k) (nil? v)) + k))) + (dissoc change :type :id :objects)) + stmts (into (mapv (fn [[k v]] + (set-node-attr-statement "Component" id k v)) + sets) + (map #(remove-node-attr-statement "Component" id %) removes)) + index' (if (get-in index [:components id]) + (cond-> index + (contains? sets :name) + (assoc-in [:components id :name] (:name sets))) + (assoc-in index [:components id] + {:id id + :name (:name sets) + :deleted false}))] + (if (empty? stmts) + {:index index :statements [] :applied? true} + {:index index' :statements stmts :applied? true}))) + +(defn- apply-del-component + [index {:keys [id skip-undelete?]}] + (cond + (not (get-in index [:components id])) + {:index index :statements [] :applied? true} + + skip-undelete? + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + + :else + {:index (assoc-in index [:components id :deleted] true) + :statements [(set-node-attr-statement "Component" id :deleted true)] + :applied? true})) + +(defn- apply-restore-component + [index {:keys [id page-id]}] + (let [stmts (cond-> [(set-node-attr-statement "Component" id :deleted false)] + page-id + (conj (set-node-attr-statement "Component" id :main-instance-page page-id))) + index (if (get-in index [:components id]) + (-> index + (assoc-in [:components id :deleted] false) + (cond-> page-id + (assoc-in [:components id :main-instance-page] page-id))) + (assoc-in index [:components id] + {:id id :name nil :deleted false}))] + {:index index :statements stmts :applied? true})) + +(defn- apply-purge-component + [index {:keys [id]}] + (if-not (get-in index [:components id]) + ;; Still attempt delete in case the node exists but was not indexed. + {:index index + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true})) + (defn- apply-change [index change] (case (:type change) - :add-obj (apply-add-obj index change) - :mod-obj (apply-mod-obj index change) - :del-obj (apply-del-obj index change) - :add-page (apply-add-page index change) - :del-page (apply-del-page index change) - :mod-page (apply-mod-page index change) - :mov-objects (apply-mov-objects index change) + :add-obj (apply-add-obj index change) + :mod-obj (apply-mod-obj index change) + :del-obj (apply-del-obj index change) + :add-page (apply-add-page index change) + :del-page (apply-del-page index change) + :mod-page (apply-mod-page index change) + :mov-objects (apply-mov-objects index change) + :add-component (apply-add-component index change) + :mod-component (apply-mod-component index change) + :del-component (apply-del-component index change) + :restore-component (apply-restore-component index change) + :purge-component (apply-purge-component index change) {:index index :statements [] :applied? false :reason :unsupported-type})) (defn apply-changes!