Andrey Antukh 88fb5e7ab5 ♻️ Update integrant to latest version
This upgrade also includes complete elimination of use spec
from the backend codebase, completing the long running migration
to fully use malli for validation and decoding.
2024-11-13 19:09:19 +01:00

468 lines
16 KiB
Clojure

;; 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.main.data.workspace.media
(:require
["@penpot/svgo$default" :as svgo]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.builder :as fb]
[app.common.files.changes-builder :as pcb]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.svg.shapes-builder :as csvg.shapes-builder]
[app.common.types.container :as ctn]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.changes :as dch]
[app.main.data.media :as dmm]
[app.main.data.notifications :as ntf]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.svg-upload :as svg]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.http :as http]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[promesa.core :as p]
[tubax.core :as tubax]))
(defn- optimize
[input]
(svgo/optimize input svgo/defaultOptions))
(defn svg->clj
[[name text]]
(try
(let [text (if (contains? cf/flags :frontend-svgo)
(optimize text)
text)
data (-> (tubax/xml->clj text)
(assoc :name name))]
(rx/of data))
(catch :default cause
(js/console.error cause)
(rx/throw (ex/error :type :svg-parser
:hint (ex-message cause))))))
;; TODO: rename to bitmap-image-uploaded
(defn image-uploaded
[image {:keys [x y]}]
(ptk/reify ::image-uploaded
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [name width height id mtype]} image
shape {:name name
:width width
:height height
:x (mth/round (- x (/ width 2)))
:y (mth/round (- y (/ height 2)))
:fills [{:fill-opacity 1
:fill-image {:name name
:width width
:height height
:mtype mtype
:id id
:keep-aspect-ratio true}}]}]
(rx/of (dwsh/create-and-add-shape :rect x y shape))))))
(defn svg-uploaded
[svg-data file-id position]
(ptk/reify ::svg-uploaded
ptk/WatchEvent
(watch [_ _ _]
;; Once the SVG is uploaded, we need to extract all the bitmap
;; images and upload them separately, then proceed to create
;; all shapes.
(->> (svg/upload-images svg-data file-id)
(rx/map #(svg/add-svg-shapes (assoc svg-data :image-data %) position))))))
(defn upload-media-url
[name file-id url]
(rp/cmd!
:create-file-media-object-from-url
{:name name
:file-id file-id
:url url
:is-local true}))
(defn process-uris
[{:keys [file-id local? name uris mtype on-image on-svg]}]
(letfn [(svg-url? [url]
(or (and mtype (= mtype "image/svg+xml"))
(str/ends-with? url ".svg")))
(upload [uri]
(->> (http/send! {:method :get :uri uri :mode :no-cors :response-type :blob})
(rx/map :body)
(rx/map (fn [content]
{:file-id file-id
:name (or name (svg/extract-name uri))
:is-local local?
:content content}))
(rx/mapcat #(rp/cmd! :upload-file-media-object %))))
(fetch-svg [name uri]
(->> (http/send! {:method :get :uri uri :mode :no-cors})
(rx/map #(vector
(or name (svg/extract-name uri))
(:body %)))))]
(rx/merge
(->> (rx/from uris)
(rx/filter (comp not svg-url?))
(rx/mapcat upload)
(rx/tap on-image))
(->> (rx/from uris)
(rx/filter svg-url?)
(rx/merge-map (partial fetch-svg name))
(rx/merge-map svg->clj)
(rx/tap on-svg)))))
(defn process-blobs
[{:keys [file-id local? name blobs force-media on-image on-svg]}]
(letfn [(svg-blob? [blob]
(and (not force-media)
(= (.-type blob) "image/svg+xml")))
(prepare-blob [blob]
(let [name (or name (if (dmm/file? blob) (fb/strip-image-extension (.-name blob)) "blob"))]
{:file-id file-id
:name name
:is-local local?
:content blob}))
(extract-content [blob]
(let [name (or name (.-name blob))]
(-> (.text ^js blob)
(p/then #(vector name %)))))]
(rx/merge
(->> (rx/from blobs)
(rx/map dmm/validate-file)
(rx/filter (comp not svg-blob?))
(rx/map prepare-blob)
(rx/mapcat #(rp/cmd! :upload-file-media-object %))
(rx/tap on-image))
(->> (rx/from blobs)
(rx/map dmm/validate-file)
(rx/filter svg-blob?)
(rx/merge-map extract-content)
(rx/merge-map svg->clj)
(rx/tap on-svg)))))
(defn handle-media-error [error on-error]
(if (ex/ex-info? error)
(handle-media-error (ex-data error) on-error)
(cond
(= (:code error) :invalid-svg-file)
(rx/of (ntf/error (tr "errors.media-type-not-allowed")))
(= (:code error) :media-type-not-allowed)
(rx/of (ntf/error (tr "errors.media-type-not-allowed")))
(= (:code error) :unable-to-access-to-url)
(rx/of (ntf/error (tr "errors.media-type-not-allowed")))
(= (:code error) :invalid-image)
(rx/of (ntf/error (tr "errors.media-type-not-allowed")))
(= (:code error) :media-max-file-size-reached)
(rx/of (ntf/error (tr "errors.media-too-large")))
(= (:code error) :media-type-mismatch)
(rx/of (ntf/error (tr "errors.media-type-mismatch")))
(= (:code error) :unable-to-optimize)
(rx/of (ntf/error (:hint error)))
(fn? on-error)
(on-error error)
:else
(do
(.error js/console "ERROR" error)
(rx/of (ntf/error (tr "errors.cannot-upload")))))))
(def ^:private
schema:process-media-objects
[:map {:title "process-media-objects"}
[:file-id ::sm/uuid]
[:local? :boolean]
[:name {:optional true} :string]
[:data {:optional true} :any] ; FIXME
[:uris {:optional true} [:sequential :string]]
[:mtype {:optional true} :string]])
(defn- process-media-objects
[{:keys [uris on-error] :as params}]
(dm/assert!
(and (sm/check schema:process-media-objects params)
(or (contains? params :blobs)
(contains? params :uris))))
(ptk/reify ::process-media-objects
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(rx/of (ntf/show {:content (tr "media.loading")
:type :toast
:level :info
:timeout nil
:tag :media-loading}))
(->> (if (seq uris)
;; Media objects is a list of URL's pointing to the path
(process-uris params)
;; Media objects are blob of data to be upload
(process-blobs params))
;; Every stream has its own sideeffect. We need to ignore the result
(rx/ignore)
(rx/catch #(handle-media-error % on-error))
(rx/finalize #(st/emit! (ntf/hide :tag :media-loading))))))))
;; Deprecated in components-v2
(defn upload-media-asset
[params]
(let [params (assoc params
:force-media true
:local? false
:on-image #(st/emit! (dwl/add-media %))
:on-svg #(st/emit! (dwl/add-media %)))]
(process-media-objects params)))
(defn upload-media-workspace
[{:keys [position file-id] :as params}]
(let [params (assoc params
:local? true
:on-image #(st/emit! (image-uploaded % position))
:on-svg #(st/emit! (svg-uploaded % file-id position)))]
(process-media-objects params)))
(defn upload-fill-image
[file on-success]
(dm/assert!
"expected a valid blob for `file` param"
(dmm/blob? file))
(ptk/reify ::upload-fill-image
ptk/WatchEvent
(watch [_ state _]
(let [on-upload-success
(fn [image]
(on-success image)
(dmm/notify-finished-loading))
prepare
(fn [content]
{:file-id (get-in state [:workspace-file :id])
:name (if (dmm/file? content) (.-name content) (tr "media.image"))
:is-local false
:content content})]
(dmm/notify-start-loading)
(->> (rx/of file)
(rx/map dmm/validate-file)
(rx/map prepare)
(rx/mapcat #(rp/cmd! :upload-file-media-object %))
(rx/tap on-upload-success)
(rx/catch handle-media-error))))))
;; --- Upload File Media objects
(defn load-and-parse-svg
"Load the contents of a media-obj of type svg, and parse it
into a clojure structure."
[media-obj]
(let [path (cf/resolve-file-media media-obj)]
(->> (http/send! {:method :get :uri path :mode :no-cors})
(rx/map :body)
(rx/map #(vector (:name media-obj) %))
(rx/merge-map svg->clj)
(rx/catch ; When error downloading media-obj, skip it and continue with next one
#(log/error :msg (str "Error downloading " (:name media-obj) " from " path)
:hint (ex-message %)
:error %)))))
(defn create-shapes-svg
"Convert svg elements into penpot shapes."
[file-id objects pos svg-data]
(let [upload-images
(fn [svg-data]
(->> (svg/upload-images svg-data file-id)
(rx/map #(assoc svg-data :image-data %))))
process-svg
(fn [svg-data]
(let [[root-svg-shape children]
(csvg.shapes-builder/create-svg-shapes svg-data pos objects uuid/zero nil #{} false)
frame-shape
(cts/setup-shape
{:type :frame
:x (:x pos)
:y (:y pos)
:width (-> root-svg-shape :selrect :width)
:height (-> root-svg-shape :selrect :height)
:name (:name root-svg-shape)
:frame-id uuid/zero
:parent-id uuid/zero
:fills []})
root-svg-shape
(-> root-svg-shape
(assoc :frame-id (:id frame-shape) :parent-id (:id frame-shape)))
shapes
(->> children
(filter #(= (:parent-id %) (:id root-svg-shape)))
(mapv :id))
root-svg-shape
(assoc root-svg-shape :shapes shapes)
children (->> children (mapv #(assoc % :frame-id (:id frame-shape))))
children (d/concat-vec [root-svg-shape] children)]
[frame-shape children]))]
(->> (upload-images svg-data)
(rx/map process-svg))))
(defn create-shapes-img
"Convert a media object that contains a bitmap image into shapes,
one shape of type :rect containing an image fill and one group that contains it."
[pos {:keys [name width height id mtype] :as media-obj}]
(let [frame-shape (cts/setup-shape
{:type :frame
:x (:x pos)
:y (:y pos)
:width width
:height height
:name name
:frame-id uuid/zero
:parent-id uuid/zero})
img-shape (cts/setup-shape
{:type :rect
:x (:x pos)
:y (:y pos)
:width width
:height height
:fills [{:fill-opacity 1
:fill-image {:name name
:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio true}}]
:name name
:frame-id (:id frame-shape)
:parent-id (:id frame-shape)})]
(rx/of [frame-shape [img-shape]])))
(defn- add-shapes-and-component
[it file-data page name [shape children]]
(let [[component-shape component-shapes updated-shapes]
(ctn/convert-shape-in-component shape children (:id file-data))
changes (-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/with-objects (:objects page))
(pcb/with-library-data file-data)
(pcb/add-objects (cons shape children))
(pcb/add-component (:id component-shape)
""
name
component-shapes
updated-shapes
(:id shape)
(:id page)))]
(dch/commit-changes changes)))
(defn- process-img-component
[media-obj]
(ptk/reify ::process-img-component
ptk/WatchEvent
(watch [it state _]
(let [file-data (wsh/get-local-file state)
page (wsh/lookup-page state)
pos (wsh/viewport-center state)]
(->> (create-shapes-img pos media-obj)
(rx/map (partial add-shapes-and-component it file-data page (:name media-obj))))))))
(defn- process-svg-component
[svg-data]
(ptk/reify ::process-svg-component
ptk/WatchEvent
(watch [it state _]
(let [file-data (wsh/get-local-file state)
page (wsh/lookup-page state)
pos (wsh/viewport-center state)]
(->> (create-shapes-svg (:id file-data) (:objects page) pos svg-data)
(rx/map (partial add-shapes-and-component it file-data page (:name svg-data))))))))
(defn upload-media-components
[params]
(let [params (assoc params
:local? false
:on-image #(st/emit! (process-img-component %))
:on-svg #(st/emit! (process-svg-component %)))]
(process-media-objects params)))
(def ^:private
schema:clone-media-object
[:map {:title "clone-media-object"}
[:file-id ::sm/uuid]
[:object-id ::sm/uuid]])
(defn clone-media-object
[{:keys [file-id object-id] :as params}]
(dm/assert!
(sm/check schema:clone-media-object params))
(ptk/reify ::clone-media-objects
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)
params {:is-local true
:file-id file-id
:id object-id}]
(rx/concat
(rx/of (ntf/show {:content (tr "media.loading")
:type :toast
:level :info
:timeout nil
:tag :media-loading}))
(->> (rp/cmd! :clone-file-media-object params)
(rx/tap on-success)
(rx/catch on-error)
(rx/finalize #(st/emit! (ntf/hide :tag :media-loading)))))))))
(defn create-svg-shape
[id name svg-string position]
(ptk/reify ::create-svg-shape
ptk/WatchEvent
(watch [_ _ _]
(->> (svg->clj [name svg-string])
(rx/take 1)
(rx/map #(svg/add-svg-shapes id % position {:ignore-selection? true
:change-selection? false}))))))