🎉 Add binfile-v3 export/import file format

This commit is contained in:
Andrey Antukh 2024-10-15 17:56:22 +02:00
parent 4fb5d3fb20
commit 8618cb950f
35 changed files with 2031 additions and 599 deletions

View file

@ -8,6 +8,7 @@
"A general purpose events."
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.types.components-list :as ctkl]
[app.common.types.team :as tt]
[app.config :as cf]
@ -136,9 +137,31 @@
;; Exportations
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:export-files
[:sequential {:title "Files"}
[:map {:title "FileParam"}
[:id ::sm/uuid]
[:name :string]
[:project-id ::sm/uuid]
[:is-shared ::sm/boolean]]])
(def check-export-files!
(sm/check-fn schema:export-files))
(def valid-export-formats
#{:binfile-v1 :binfile-v3 :legacy-zip})
(defn export-files
[files binary?]
(ptk/reify ::request-file-export
[files format]
(dm/assert!
"expected valid files param"
(check-export-files! files))
(dm/assert!
"expected valid format"
(contains? valid-export-formats format))
(ptk/reify ::export-files
ptk/WatchEvent
(watch [_ state _]
(let [features (features/get-team-enabled-features state)
@ -147,16 +170,15 @@
(rx/mapcat
(fn [file]
(->> (rp/cmd! :has-file-libraries {:file-id (:id file)})
(rx/map #(assoc file :has-libraries? %)))))
(rx/map #(assoc file :has-libraries %)))))
(rx/reduce conj [])
(rx/map (fn [files]
(modal/show
{:type :export
:features features
:team-id team-id
:has-libraries? (->> files (some :has-libraries?))
:files files
:binary? binary?}))))))))
:format format}))))))))
;;;;;;;;;;;;;;;;;;;;;;
;; Team Request

View file

@ -753,7 +753,7 @@
libraries (wsh/get-libraries state)
page-id (:current-page-id state)
container (cfh/get-container file :page page-id)
container (ctn/get-container file :page page-id)
components-v2
(features/active-feature? state "components/v2")
@ -806,7 +806,7 @@
(let [page-id (get state :current-page-id)
local-file (wsh/get-local-file state)
full-file (wsh/get-local-file-full state)
container (cfh/get-container local-file :page page-id)
container (ctn/get-container local-file :page page-id)
shape (ctn/get-shape container id)
components-v2 (features/active-feature? state "components/v2")]

View file

@ -7,6 +7,7 @@
(ns app.main.repo
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
[app.config :as cf]
@ -17,7 +18,7 @@
[cuerdas.core :as str]))
(defn handle-response
[{:keys [status body headers] :as response}]
[{:keys [status body headers uri] :as response}]
(cond
(= 204 status)
;; We need to send "something" so the streams listening downstream can act
@ -52,8 +53,10 @@
:else
(rx/throw
(ex-info "http error"
{:type :unexpected-error
(ex-info "repository requet error"
{:type :internal
:code :repository-access-error
:uri uri
:status status
:headers headers
:data body}))))
@ -71,20 +74,19 @@
:form-data? true}
::sse/clone-template
{:response-type ::sse/stream}
{:stream? true}
::sse/import-binfile
{:response-type ::sse/stream
{:stream? true
:form-data? true}
:export-binfile {:response-type :blob}
:retrieve-list-of-builtin-templates {:query-params :all}})
(defn- send!
"A simple helper for a common case of sending and receiving transit
data to the penpot mutation api."
[id params options]
(let [{:keys [response-type
stream?
form-data?
raw-transit?
query-params
@ -92,46 +94,61 @@
(-> (get default-options id)
(merge options))
decode-fn (if raw-transit?
http/conditional-error-decode-transit
http/conditional-decode-transit)
decode-fn
(if raw-transit?
http/conditional-error-decode-transit
http/conditional-decode-transit)
id (or rename-to id)
nid (name id)
method (cond
(= query-params :all) :get
(str/starts-with? nid "get-") :get
:else :post)
request {:method method
:uri (u/join cf/public-uri "api/rpc/command/" nid)
:credentials "include"
:headers {"accept" "application/transit+json,text/event-stream,*/*"
"x-external-session-id" (cf/external-session-id)
"x-event-origin" (::ev/origin (meta params))}
:body (when (= method :post)
(if form-data?
(http/form-data params)
(http/transit-data params)))
:query (if (= method :get)
params
(if query-params
(select-keys params query-params)
nil))
id (or rename-to id)
nid (name id)
method (cond
(= query-params :all) :get
(str/starts-with? nid "get-") :get
:else :post)
:response-type
(if (= response-type ::sse/stream)
:stream
(or response-type :text))}
response-type
(d/nilv response-type :text)
result (->> (http/send! request)
(rx/map decode-fn)
(rx/mapcat handle-response))]
request
{:method method
:uri (u/join cf/public-uri "api/rpc/command/" nid)
:credentials "include"
:headers {"accept" "application/transit+json,text/event-stream,*/*"
"x-external-session-id" (cf/external-session-id)
"x-event-origin" (::ev/origin (meta params))}
:body (when (= method :post)
(if form-data?
(http/form-data params)
(http/transit-data params)))
:query (if (= method :get)
params
(if query-params
(select-keys params query-params)
nil))
:response-type
(if stream? nil response-type)}]
(cond->> result
(= ::sse/stream response-type)
(rx/mapcat (fn [body]
(-> (sse/create-stream body)
(sse/read-stream t/decode-str)))))))
(->> (http/fetch request)
(rx/map http/response->map)
(rx/mapcat (fn [{:keys [headers body] :as response}]
(let [ctype (get headers "content-type")
response-stream? (str/starts-with? ctype "text/event-stream")]
(when (and response-stream? (not stream?))
(ex/raise :type :internal
:code :invalid-response-processing
:hint "expected normal response, received sse stream"
:response-uri (:uri response)
:response-status (:status response)))
(if response-stream?
(-> (sse/create-stream body)
(sse/read-stream t/decode-str))
(->> response
(http/process-response-type response-type)
(rx/map decode-fn)
(rx/mapcat handle-response)))))))))
(defmulti cmd! (fn [id _] id))

View file

@ -6,6 +6,7 @@
(ns app.main.ui.dashboard.file-menu
(:require
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
@ -189,24 +190,30 @@
on-export-files
(mf/use-fn
(mf/deps files)
(fn [binary?]
(let [evname (if binary?
"export-binary-files"
"export-standard-files")]
(fn [format]
(let [evname (if (= format :legacy-zip)
"export-standard-files"
"export-binary-files")]
(st/emit! (ptk/event ::ev/event {::ev/name evname
::ev/origin "dashboard"
:format format
:num-files (count files)})
(dcm/export-files files binary?)))))
(dcm/export-files files format)))))
on-export-binary-files
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files true))
(partial on-export-files :binfile-v1))
on-export-binary-files-v3
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files :binfile-v3))
on-export-standard-files
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files false))
(partial on-export-files :legacy-zip))
;; NOTE: this is used for detect if component is still mounted
mounted-ref (mf/use-ref true)]
@ -256,9 +263,14 @@
:options sub-options})
{:name (tr "dashboard.export-binary-multi" file-count)
:id "file-binari-export-multi"
:id "file-binary-export-multi"
:handler on-export-binary-files}
(when (contains? cf/flags :export-file-v3)
{:name (tr "dashboard.export-binary-multi-v3" file-count)
:id "file-binary-export-multi-v3"
:handler on-export-binary-files-v3})
{:name (tr "dashboard.export-standard-multi" file-count)
:id "file-standard-export-multi"
:handler on-export-standard-files}
@ -315,6 +327,11 @@
:id "download-binary-file"
:handler on-export-binary-files}
(when (contains? cf/flags :export-file-v3)
{:name (tr "dashboard.download-binary-file-v3")
:id "download-binary-file-v3"
:handler on-export-binary-files-v3})
{:name (tr "dashboard.download-standard-file")
:id "download-standard-file"
:handler on-export-standard-files}

View file

@ -33,7 +33,7 @@
(log/set-level! :debug)
(def ^:const emit-delay 1000)
(def ^:const emit-delay 200)
(defn use-import-file
[project-id on-finish-import]
@ -82,51 +82,35 @@
(assoc :deleted true)))
entries))
(defn- update-with-analyze-error
[entries uri error]
(->> entries
(mapv (fn [entry]
(cond-> entry
(= uri (:uri entry))
(-> (assoc :status :analyze-error)
(assoc :error error)))))))
(defn- update-with-analyze-result
[entries uri type result]
(let [existing-entries? (into #{} (keep :file-id) entries)
replace-entry
(fn [entry]
(if (and (= uri (:uri entry))
(= (:status entry) :analyzing))
(->> (:files result)
(remove (comp existing-entries? first))
(map (fn [[file-id file-data]]
(-> file-data
(assoc :file-id file-id)
(assoc :status :ready)
(assoc :uri uri)
(assoc :type type)))))
[entry]))]
(into [] (mapcat replace-entry) entries)))
(defn- mark-entries-importing
[entries]
(->> entries
(filter #(= :ready (:status %)))
(mapv #(assoc % :status :importing))))
[entries {:keys [file-id status] :as updated}]
(let [entries (filterv (comp uuid? :file-id) entries)
status (case status
:success :import-ready
:error :analyze-error)
updated (assoc updated :status status)]
(if (some #(= file-id (:file-id %)) entries)
(mapv (fn [entry]
(if (= (:file-id entry) file-id)
(merge entry updated)
entry))
entries)
(conj entries updated))))
(defn- update-entry-status
[entries file-id status progress errors]
[entries message]
(mapv (fn [entry]
(cond-> entry
(and (= file-id (:file-id entry)) (not= status :import-progress))
(assoc :status status)
(and (= file-id (:file-id entry)) (= status :import-progress))
(assoc :progress progress)
(= file-id (:file-id entry))
(assoc :errors errors)))
(if (= (:file-id entry) (:file-id message))
(let [status (case (:status message)
:progress :import-progress
:finish :import-success
:error :import-error)]
(-> entry
(assoc :progress (:progress message))
(assoc :status status)
(assoc :error (:error message))
(d/without-nils)))
entry))
entries))
(defn- parse-progress-message
@ -153,33 +137,27 @@
:process-components
(tr "dashboard.import.progress.process-components")
(str message)))
:process-deleted-components
(tr "dashboard.import.progress.process-components")
(defn- has-status-importing?
[item]
(= (:status item) :importing))
""))
(defn- has-status-analyzing?
(defn- has-status-analyze?
[item]
(= (:status item) :analyzing))
(= (:status item) :analyze))
(defn- has-status-analyze-error?
(defn- has-status-import-success?
[item]
(= (:status item) :analyzing))
(defn- has-status-success?
[item]
(and (= (:status item) :import-finish)
(empty? (:errors item))))
(= (:status item) :import-success))
(defn- has-status-error?
[item]
(and (= (:status item) :import-finish)
(d/not-empty? (:errors item))))
(or (= (:status item) :import-error)
(= (:status item) :analyze-error)))
(defn- has-status-ready?
[item]
(and (= :ready (:status item))
(and (= :import-ready (:status item))
(not (:deleted item))))
(defn- analyze-entries
@ -191,12 +169,10 @@
(rx/mapcat #(rx/delay emit-delay (rx/of %)))
(rx/filter some?)
(rx/subs!
(fn [{:keys [uri data error type] :as msg}]
(if (some? error)
(swap! state update-with-analyze-error uri error)
(swap! state update-with-analyze-result uri type data))))))
(fn [message]
(swap! state update-with-analyze-result message)))))
(defn- import-files!
(defn- import-files
[state project-id entries]
(st/emit! (ptk/data-event ::ev/event {::ev/name "import-files"
:num-files (count entries)}))
@ -205,28 +181,36 @@
:project-id project-id
:files entries
:features @features/features-ref})
(rx/filter (comp uuid? :file-id))
(rx/subs!
(fn [{:keys [file-id status message errors] :as msg}]
(swap! state update-entry-status file-id status message errors)))))
(fn [message]
(swap! state update-entry-status message)))))
(mf/defc import-entry
(mf/defc import-entry*
{::mf/props :obj
::mf/memo true
::mf/private true}
[{:keys [entries entry edition can-be-deleted on-edit on-change on-delete]}]
(let [status (:status entry)
loading? (or (= :analyzing status)
(= :importing status))
analyze-error? (= :analyze-error status)
import-finish? (= :import-finish status)
import-error? (= :import-error status)
import-warn? (d/not-empty? (:errors entry))
ready? (= :ready status)
is-shared? (:shared entry)
progress (:progress entry)
(let [status (:status entry)
;; FIXME: rename to format
format (:type entry)
file-id (:file-id entry)
editing? (and (some? file-id) (= edition file-id))
loading? (or (= :analyze status)
(= :import-progress status))
analyze-error? (= :analyze-error status)
import-success? (= :import-success status)
import-error? (= :import-error status)
import-ready? (= :import-ready status)
is-shared? (:shared entry)
progress (:progress entry)
file-id (:file-id entry)
editing? (and (some? file-id) (= edition file-id))
editable? (and (or (= :binfile-v3 format)
(= :legacy-zip format))
(= status :import-ready))
on-edit-key-press
(mf/use-fn
@ -261,23 +245,21 @@
[:div {:class (stl/css-case
:file-entry true
:loading loading?
:success (and import-finish? (not import-warn?) (not import-error?))
:warning (and import-finish? import-warn? (not import-error?))
:success import-success?
:error (or import-error? analyze-error?)
:editable (and ready? (not editing?)))}
:editable (and import-ready? (not editing?)))}
[:div {:class (stl/css :file-name)}
(if loading?
[:> loader* {:width 16
:title (tr "labels.loading")}]
[:div {:class (stl/css-case :file-icon true
:icon-fill ready?)}
(cond ready? i/logo-icon
import-warn? i/msg-warning
import-error? i/close
import-finish? i/tick
analyze-error? i/close)])
[:> loader* {:width 16 :title (tr "labels.loading")}]
[:div {:class (stl/css-case
:file-icon true
:icon-fill import-ready?)}
(cond
import-ready? i/logo-icon
import-error? i/close
import-success? i/tick
analyze-error? i/close)])
(if editing?
[:div {:class (stl/css :file-name-edit)}
@ -294,10 +276,9 @@
i/library])])
[:div {:class (stl/css :edit-entry-buttons)}
(when (and (= "application/zip" (:type entry))
(= status :ready))
(when ^boolean editable?
[:button {:on-click on-edit'} i/curve])
(when can-be-deleted
(when ^boolean can-be-deleted
[:button {:on-click on-delete'} i/delete])]]
(cond
@ -311,9 +292,10 @@
[:div {:class (stl/css :error-message)}
(tr "dashboard.import.import-error")]
(and (not import-finish?) (some? progress))
(and (not import-success?) (some? progress))
[:div {:class (stl/css :progress-message)} (parse-progress-message progress)])
;; This is legacy code, will be removed when legacy-zip format is removed
[:div {:class (stl/css :linked-libraries)}
(for [library-id (:libraries entry)]
(let [library-data (d/seek #(= library-id (:file-id %)) entries)
@ -328,6 +310,11 @@
:error error?)}
i/detach]])))]]))
(defn initialize-state
[entries]
(fn []
(mapv #(assoc % :status :analyze) entries)))
(mf/defc import-dialog
{::mf/register modal/components
::mf/register-as :import
@ -336,74 +323,66 @@
[{:keys [project-id entries template on-finish-import]}]
(mf/with-effect []
;; dispose uris when the component is umount
;; Revoke all uri's on commonent unmount
(fn [] (run! wapi/revoke-uri (map :uri entries))))
(let [entries* (mf/use-state
(fn [] (mapv #(assoc % :status :analyzing) entries)))
entries (deref entries*)
(let [state* (mf/use-state (initialize-state entries))
entries (deref state*)
status* (mf/use-state :analyzing)
status* (mf/use-state :analyze)
status (deref status*)
edition* (mf/use-state nil)
edition (deref edition*)
template-finished* (mf/use-state nil)
template-finished (deref template-finished*)
on-template-cloned-success
(mf/use-fn
(fn []
(reset! status* :importing)
(reset! template-finished* true)
(st/emit! (dd/fetch-recent-files))))
on-template-cloned-error
(mf/use-fn
(fn [cause]
(reset! status* :error)
(reset! template-finished* true)
(errors/print-error! cause)
(rx/of (modal/hide)
(ntf/error (tr "dashboard.libraries-and-templates.import-error")))))
continue-entries
(mf/use-fn
(mf/deps entries)
(fn []
(let [entries (filterv has-status-ready? entries)]
(swap! status* (constantly :importing))
(swap! entries* mark-entries-importing)
(import-files! entries* project-id entries))))
(reset! status* :import-progress)
(import-files state* project-id entries))))
continue-template
(mf/use-fn
(mf/deps on-template-cloned-success
on-template-cloned-error
template)
(fn []
(let [mdata {:on-success on-template-cloned-success
:on-error on-template-cloned-error}
params {:project-id project-id :template-id (:id template)}]
(swap! status* (constantly :importing))
(st/emit! (dd/clone-template (with-meta params mdata))))))
(fn [template]
(let [on-success
(fn [_event]
(reset! status* :import-success)
(st/emit! (dd/fetch-recent-files)))
on-error
(fn [cause]
(reset! status* :error)
(errors/print-error! cause)
(rx/of (modal/hide)
(ntf/error (tr "dashboard.libraries-and-templates.import-error"))))
params
{:project-id project-id
:template-id (:id template)}]
(reset! status* :import-progress)
(st/emit! (dd/clone-template
(with-meta params
{:on-success on-success
:on-error on-error}))))))
on-edit
(mf/use-fn
(fn [file-id _event]
(swap! edition* (constantly file-id))))
(reset! edition* file-id)))
on-entry-change
(mf/use-fn
(fn [file-id value]
(swap! edition* (constantly nil))
(swap! entries* update-entry-name file-id value)))
(swap! state* update-entry-name file-id value)))
on-entry-delete
(mf/use-fn
(fn [file-id]
(swap! entries* remove-entry file-id)))
(swap! state* remove-entry file-id)))
on-cancel
(mf/use-fn
@ -415,13 +394,12 @@
on-continue
(mf/use-fn
(mf/deps template
continue-template
(mf/deps continue-template
continue-entries)
(fn [event]
(dom/prevent-default event)
(if (some? template)
(continue-template)
(continue-template template)
(continue-entries))))
on-accept
@ -433,41 +411,40 @@
(when (fn? on-finish-import)
(on-finish-import))))
entries (filterv (comp not :deleted) entries)
num-importing (+ (count (filterv has-status-importing? entries))
(if (some? template) 1 0))
entries
(mf/with-memo [entries]
(filterv (complement :deleted) entries))
success-num (if (some? template)
1
(count (filterv has-status-success? entries)))
import-success-total
(if (some? template)
1
(count (filterv has-status-import-success? entries)))
errors? (if (some? template)
(= status :error)
(or (some has-status-error? entries)
(zero? (count entries))))
errors?
(if (some? template)
(= status :error)
(or (some has-status-error? entries)
(zero? (count entries))))
pending-analysis? (some has-status-analyzing? entries)
pending-import? (and (or (nil? template)
(not template-finished))
(pos? num-importing))
pending-analysis?
(some has-status-analyze? entries)]
valid-all-entries? (or (some? template)
(not (some has-status-analyze-error? entries)))
(mf/with-effect [entries]
(cond
(some? template)
(reset! status* :import-ready)
template-status
(cond
(and (= :importing status) pending-import?)
:importing
(and (seq entries)
(every? #(= :import-ready (:status %)) entries))
(reset! status* :import-ready)
(and (= :importing status) (not ^boolean pending-import?))
:import-finish
:else
:ready)]
(and (seq entries)
(every? #(= :import-success (:status %)) entries))
(reset! status* :import-success)))
;; Run analyze operation on component mount
(mf/with-effect []
(let [sub (analyze-entries entries* entries)]
(let [sub (analyze-entries state* entries)]
(partial rx/dispose! sub)))
[:div {:class (stl/css :modal-overlay)}
@ -479,55 +456,51 @@
:on-click on-cancel} i/close]]
[:div {:class (stl/css :modal-content)}
(when (and (= :analyzing status) errors?)
(when (and (= :analyze status) errors?)
[:& context-notification
{:level :warning
:content (tr "dashboard.import.import-warning")}])
(when (and (= :importing status) (not ^boolean pending-import?))
(cond
errors?
[:& context-notification
{:level :warning
:content (tr "dashboard.import.import-warning")}]
:else
[:& context-notification
{:level (if (zero? success-num) :warning :success)
:content (tr "dashboard.import.import-message" (i18n/c success-num))}]))
(when (= :import-success status)
[:& context-notification
{:level (if (zero? import-success-total) :warning :success)
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}])
(for [entry entries]
[:& import-entry {:edition edition
:key (dm/str (:uri entry))
:entry entry
:entries entries
:on-edit on-edit
:on-change on-entry-change
:on-delete on-entry-delete
:can-be-deleted (> (count entries) 1)}])
[:> import-entry* {:edition edition
:key (dm/str (:uri entry) "/" (:file-id entry))
:entry entry
:entries entries
:on-edit on-edit
:on-change on-entry-change
:on-delete on-entry-delete
:can-be-deleted (> (count entries) 1)}])
(when (some? template)
[:& import-entry {:entry (assoc template :status template-status)
:can-be-deleted false}])]
[:> import-entry* {:entry (assoc template :status status)
:can-be-deleted false}])]
;; (prn "import-dialog" status)
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(when (= :analyzing status)
(when (= :analyze status)
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click on-cancel}])
(when (and (= :analyzing status) (not errors?))
(when (= status :import-ready)
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.continue")
:disabled (or pending-analysis? (not valid-all-entries?))
:disabled pending-analysis?
:on-click on-continue}])
(when (and (= :importing status) (not errors?))
(when (or (= :import-success status)
(= :import-progress status))
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.accept")
:disabled (or pending-import? (not valid-all-entries?))
:disabled (= :import-progress status)
:on-click on-accept}])]]]]))

View file

@ -66,7 +66,6 @@
.file-entry {
.file-name {
@include flexRow;
margin-bottom: $s-8;
.file-icon {
@include flexCenter;
height: $s-24;

View file

@ -314,18 +314,16 @@
:stroke-dashoffset (- 280 pwidth)
:style {:transition "stroke-dashoffset 1s ease-in-out"}}]]])])]))
(def ^:const options [:all :merge :detach])
(mf/defc export-entry
{::mf/wrap-props false}
[{:keys [file]}]
[:div {:class (stl/css-case :file-entry true
:loading (:loading? file)
:loading (:loading file)
:success (:export-success? file)
:error (:export-error? file))}
[:div {:class (stl/css :file-name)}
(if (:loading? file)
(if (:loading file)
[:> loader* {:width 16
:title (tr "labels.loading")}]
[:span {:class (stl/css :file-icon)}
@ -340,7 +338,7 @@
(mapv #(cond-> %
(= file-id (:id %))
(assoc :export-error? true
:loading? false))
:loading false))
files))
(defn- mark-file-success
@ -348,30 +346,38 @@
(mapv #(cond-> %
(= file-id (:id %))
(assoc :export-success? true
:loading? false))
:loading false))
files))
(def export-types
[:all :merge :detach])
(defn- initialize-state
"Initialize export dialog state"
[files]
(let [files (mapv (fn [file] (assoc file :loading true)) files)]
{:status :prepare
:selected :all
:files files}))
(def default-export-types
(d/ordered-set :all :merge :detach))
(mf/defc export-dialog
{::mf/register modal/components
::mf/register-as :export
::mf/wrap-props false}
[{:keys [team-id files has-libraries? binary? features]}]
(let [state* (mf/use-state
#(let [files (mapv (fn [file] (assoc file :loading? true)) files)]
{:status :prepare
:selected :all
:files files}))
[{:keys [team-id files features format]}]
(let [state* (mf/use-state (partial initialize-state files))
has-libs? (some :has-libraries files)
state (deref state*)
selected (:selected state)
status (:status state)
;; We've deprecated the merge option on non-binary files because it wasn't working
;; and we're planning to remove this export in future releases.
export-types (if binary? export-types [:all :detach])
binary? (not= format :legacy-zip)
;; We've deprecated the merge option on non-binary files
;; because it wasn't working and we're planning to remove this
;; export in future releases.
export-types (if binary? default-export-types [:all :detach])
start-export
(mf/use-fn
@ -379,10 +385,11 @@
(fn []
(swap! state* assoc :status :exporting)
(->> (uw/ask-many!
{:cmd (if binary? :export-binary-file :export-standard-file)
{:cmd :export-files
:format format
:team-id team-id
:features features
:export-type selected
:type selected
:files files})
(rx/mapcat #(->> (rx/of %)
(rx/delay 1000)))
@ -418,9 +425,9 @@
(keyword))]
(swap! state* assoc :selected type))))]
(mf/with-effect [has-libraries?]
(mf/with-effect [has-libs?]
;; Start download automatically when no libraries
(when-not has-libraries?
(when-not has-libs?
(start-export)))
[:div {:class (stl/css :modal-overlay)}
@ -443,13 +450,13 @@
:key (name type)}
[:label {:for (str "export-" type)
:class (stl/css-case :global/checked (= selected type))}
;; Execution time translation strings:
;; (tr "dashboard.export.options.all.message")
;; (tr "dashboard.export.options.all.title")
;; (tr "dashboard.export.options.detach.message")
;; (tr "dashboard.export.options.detach.title")
;; (tr "dashboard.export.options.merge.message")
;; (tr "dashboard.export.options.merge.title")
;; Execution time translation strings:
;; (tr "dashboard.export.options.all.message")
;; (tr "dashboard.export.options.all.title")
;; (tr "dashboard.export.options.detach.message")
;; (tr "dashboard.export.options.detach.title")
;; (tr "dashboard.export.options.merge.message")
;; (tr "dashboard.export.options.merge.title")
[:span {:class (stl/css-case :global/checked (= selected type))}
(when (= selected type)
i/status-tick)]
@ -488,5 +495,5 @@
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.close")
:disabled (->> state :files (some :loading?))
:disabled (->> state :files (some :loading))
:on-click on-cancel}]]]])]]))

View file

@ -526,15 +526,17 @@
(mf/deps file)
(fn [event]
(let [target (dom/get-current-target event)
binary? (= (dom/get-data target "binary") "true")
evname (if binary?
"export-binary-files"
"export-standard-files")]
format (-> (dom/get-data target "format")
(keyword))
evname (if (= format :legacy-zip)
"export-standard-files"
"export-binary-files")]
(st/emit!
(ptk/event ::ev/event {::ev/name evname
::ev/origin "workspace"
:format format
:num-files 1})
(dcm/export-files [file] binary?)))))
(dcm/export-files [file] format)))))
on-export-file-key-down
(mf/use-fn
@ -587,15 +589,24 @@
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary true
:data-format "binfile-v1"
:id "file-menu-binary-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-binary-file")]]
(when (contains? cf/flags :export-file-v3)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-format "binfile-v3"
:id "file-menu-binary-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-binary-file-v3")]])
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary false
:data-format "legacy-zip"
:id "file-menu-standard-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-standard-file")]]

View file

@ -70,6 +70,7 @@
[{:keys [component renaming listing-thumbs? selected
file-id on-asset-click on-context-menu on-drag-start do-rename
cancel-rename selected-full selected-paths local]}]
(let [item-ref (mf/use-ref)
dragging* (mf/use-state false)

View file

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.common.record :as crc]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.store :as st]
@ -114,29 +115,33 @@
(page/page-proxy $plugin $id page-id))))
(export
[self type export-type]
(let [export-type (or (parser/parse-keyword export-type) :all)]
[self format type]
(let [type (or (parser/parse-keyword type) :all)]
(cond
(not (contains? #{"penpot" "zip"} type))
(u/display-not-valid :export-type type)
(not (contains? #{"penpot" "zip"} format))
(u/display-not-valid :format type)
(not (contains? (set mue/export-types) export-type))
(u/display-not-valid :export-exportType export-type)
(not (contains? (set mue/default-export-types) type))
(u/display-not-valid :type type)
:else
(let [export-cmd (if (= type "penpot") :export-binary-file :export-standard-file)
file (u/proxy->file self)
features (features/get-team-enabled-features @st/state)
team-id (:current-team-id @st/state)]
(let [file (u/proxy->file self)
features (features/get-team-enabled-features @st/state)
team-id (:current-team-id @st/state)
format (case format
"penpot" (if (contains? cf/flags :export-file-v3)
:binfile-v3
:binfile-v1)
"zip" :legacy-zip)]
(p/create
(fn [resolve reject]
(->> (uw/ask-many!
{:cmd export-cmd
{:cmd :export-files
:format format
:type type
:team-id team-id
:features features
:export-type export-type
:files [file]})
(rx/mapcat #(->> (rx/of %) (rx/delay 1000)))
(rx/mapcat
(fn [msg]
(case (:type msg)
@ -147,9 +152,11 @@
(rx/empty)
:finish
(http/send! {:method :get :uri (:uri msg) :mode :no-cors :response-type :blob}))))
(rx/first)
(rx/mapcat (fn [{:keys [body]}] (.arrayBuffer ^js body)))
(http/send! {:method :get
:uri (:uri msg)
:mode :no-cors
:response-type :buffer}))))
(rx/take 1)
(rx/map (fn [data] (js/Uint8Array. data)))
(rx/subs! resolve reject)))))))))

View file

@ -103,26 +103,31 @@
(when @abortable?
(.abort ^js controller)))))))
(defn response->map
[response]
{:status (.-status ^js response)
:uri (.-url ^js response)
:headers (parse-headers (.-headers ^js response))
:body (.-body ^js response)
::response response})
(defn process-response-type
[response-type response]
(let [native-response (::response response)
body (case response-type
:buffer (.arrayBuffer ^js native-response)
:json (.json ^js native-response)
:text (.text ^js native-response)
:blob (.blob ^js native-response))]
(->> (rx/from body)
(rx/map (fn [body]
(assoc response :body body))))))
(defn send!
[{:keys [response-type] :or {response-type :text} :as params}]
(letfn [(on-response [^js response]
(if (= :stream response-type)
(rx/of {:status (.-status response)
:headers (parse-headers (.-headers response))
:body (.-body response)
::response response})
(let [body (case response-type
:json (.json ^js response)
:text (.text ^js response)
:blob (.blob ^js response))]
(->> (rx/from body)
(rx/map (fn [body]
{::response response
:status (.-status ^js response)
:headers (parse-headers (.-headers ^js response))
:body body}))))))]
(->> (fetch params)
(rx/mapcat on-response))))
(->> (fetch params)
(rx/map response->map)
(rx/mapcat (partial process-response-type response-type))))
(defn form-data
[data]

View file

@ -33,16 +33,24 @@
(defn- process-file
[entry path type]
;; (js/console.log "zip:process-file" entry path type)
(cond
(nil? entry)
(p/rejected (str "File not found: " path))
(.-dir entry)
(.-dir ^js entry)
(p/resolved {:dir path})
:else
(-> (.async entry type)
(p/then #(hash-map :path path :content %)))))
(->> (.async ^js entry type)
(p/fmap (fn [content]
;; (js/console.log "zip:process-file" 2 content)
{:path path
:content content})))))
(defn load
[data]
(rx/from (zip/loadAsync data)))
(defn get-file
"Gets a single file from the zip archive"

View file

@ -64,8 +64,9 @@
(reply-completed
([] (reply-completed nil))
([msg] (post {:payload msg
:completed true})))]
([msg]
(post {:payload msg
:completed true})))]
(try
(let [result (impl/handler payload transfer)

View file

@ -7,6 +7,7 @@
(ns app.worker.export
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.media :as cm]
[app.common.text :as ct]
@ -396,46 +397,55 @@
(->> (uz/compress-files data)
(rx/map #(vector (get files file-id) %)))))))))
(defmethod impl/handler :export-binary-file
[{:keys [files export-type] :as message}]
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (rp/cmd! :export-binfile {:file-id (:id file)
:include-libraries (= export-type :all)
:embed-assets (= export-type :merge)})
(rx/map #(hash-map :type :finish
:file-id (:id file)
:filename (:name file)
:mtype "application/penpot"
:description "Penpot export (*.penpot)"
:uri (wapi/create-uri (wapi/create-blob %))))
(rx/catch
(fn [err]
(rx/of {:type :error
:error (str err)
:file-id (:id file)}))))))))
(defmethod impl/handler :export-files
[{:keys [team-id files type format features] :as message}]
(cond
(or (= format :binfile-v1)
(= format :binfile-v3))
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (rp/cmd! :export-binfile {:file-id (:id file)
:version (if (= format :binfile-v3) 3 1)
:include-libraries (= type :all)
:embed-assets (= type :merge)})
(rx/map wapi/create-blob)
(rx/map wapi/create-uri)
(rx/map (fn [uri]
{:type :finish
:file-id (:id file)
:filename (:name file)
:mtype (if (= format :binfile-v3)
"application/zip"
"application/penpot")
:uri uri}))
(rx/catch
(fn [cause]
(rx/of (ex/raise :type :internal
:code :export-error
:hint "unexpected error on exporting file"
:file-id (:id file)
:cause cause))))))))
(defmethod impl/handler :export-standard-file
[{:keys [team-id files export-type features] :as message}]
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (export-file team-id (:id file) export-type features)
(rx/map
(fn [value]
(if (contains? value :type)
value
(let [[file export-blob] value]
{:type :finish
:file-id (:id file)
:filename (:name file)
:mtype "application/zip"
:description "Penpot export (*.zip)"
:uri (wapi/create-uri export-blob)}))))
(rx/catch (fn [err]
(js/console.error err)
(rx/of {:type :error
:error (str err)
:file-id (:id file)}))))))))
(= format :legacy-zip)
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (export-file team-id (:id file) type features)
(rx/map
(fn [value]
(if (contains? value :type)
value
(let [[file export-blob] value]
{:type :finish
:file-id (:id file)
:filename (:name file)
:mtype "application/zip"
:uri (wapi/create-uri export-blob)}))))
(rx/catch
(fn [cause]
(rx/of (ex/raise :type :internal
:code :export-error
:hint "unexpected error on exporting file"
:file-id (:id file)
:cause cause))))))))))

View file

@ -7,7 +7,6 @@
(ns app.worker.import
(:refer-clojure :exclude [resolve])
(:require
["jszip" :as zip]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.files.builder :as fb]
@ -16,7 +15,6 @@
[app.common.json :as json]
[app.common.logging :as log]
[app.common.media :as cm]
[app.common.pprint :as pp]
[app.common.schema :as sm]
[app.common.text :as ct]
[app.common.time :as tm]
@ -25,7 +23,6 @@
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[app.util.sse :as sse]
[app.util.webapi :as wapi]
[app.util.zip :as uz]
[app.worker.impl :as impl]
[app.worker.import.parser :as parser]
@ -64,7 +61,8 @@
m))
(defn get-file
"Resolves the file inside the context given its id and the data"
"Resolves the file inside the context given its id and the
data. LEGACY"
([context type]
(get-file context type nil nil))
@ -105,6 +103,12 @@
:else
stream)))))
(defn- read-zip-manifest
[zipfile]
(->> (uz/get-file zipfile "manifest.json")
(rx/map :content)
(rx/map json/decode)))
(defn progress!
([context type]
(assert (keyword? type))
@ -123,14 +127,14 @@
([context type file current total]
(when (and context (contains? context :progress))
(let [msg {:type type
:file file
:current current
:total total}]
(log/debug :status :import-progress :message msg)
(let [progress {:type type
:file file
:current current
:total total}]
(log/debug :status :progress :progress progress)
(rx/push! (:progress context) {:file-id (:file-id context)
:status :import-progress
:message msg})))))
:status :progress
:progress progress})))))
(defn resolve-factory
"Creates a wrapper around the atom to remap ids to new ids and keep
@ -162,7 +166,7 @@
(rp/cmd! :create-temp-file
{:id file-id
:name (:name context)
:is-shared (:shared context)
:is-shared (:is-shared context)
:project-id (:project-id context)
:create-page false
@ -212,6 +216,15 @@
;; We use merge to keep some information not stored in back-end
(rx/map #(merge file %))))))
(defn slurp-uri
([uri] (slurp-uri uri :text))
([uri response-type]
(->> (http/send!
{:uri uri
:response-type response-type
:method :get})
(rx/map :body))))
(defn upload-media-files
"Upload a image to the backend and returns its id"
[context file-id name data-uri]
@ -312,8 +325,6 @@
(let [frame-id (:current-frame-id file)
frame (when (and (some? frame-id) (not= frame-id uuid/zero))
(fb/lookup-shape file frame-id))]
(js/console.log " translate-frame" (clj->js frame))
(if (some? frame)
(-> data
(d/update-when :x + (:x frame))
@ -716,7 +727,6 @@
(defn create-files
[{:keys [system-features] :as context} files]
(let [data (group-by :file-id files)]
(rx/concat
(->> (rx/from files)
@ -738,68 +748,124 @@
"1 13 32 206" "application/octet-stream"
"other")))
(defn- analyze-file-legacy-zip-entry
[features entry]
;; NOTE: LEGACY manifest reading mechanism, we can't
;; reuse the new read-zip-manifest funcion here
(->> (rx/from (uz/load (:body entry)))
(rx/merge-map #(get-file {:zip %} :manifest))
(rx/mapcat
(fn [manifest]
;; Checks if the file is exported with
;; components v2 and the current team
;; only supports components v1
(let [has-file-v2?
(->> (:files manifest)
(d/seek (fn [[_ file]] (contains? (set (:features file)) "components/v2"))))]
(if (and has-file-v2? (not (contains? features "components/v2")))
(rx/of (-> entry
(assoc :error "dashboard.import.analyze-error.components-v2")
(dissoc :body)))
(->> (rx/from (:files manifest))
(rx/map (fn [[file-id data]]
(-> entry
(dissoc :body)
(merge data)
(dissoc :shared)
(assoc :is-shared (:shared data))
(assoc :file-id file-id)
(assoc :status :success)))))))))))
;; NOTE: this is a limited subset schema for the manifest file of
;; binfile-v3 format; is used for partially parse it and read the
;; files referenced inside the exported file
(def ^:private schema:manifest
[:map {:title "Manifest"}
[:type :string]
[:files
[:vector
[:map
[:id ::sm/uuid]
[:name :string]]]]])
(def ^:private decode-manifest
(sm/decoder schema:manifest sm/json-transformer))
(defn analyze-file
[features {:keys [uri] :as file}]
(let [stream (->> (slurp-uri uri :buffer)
(rx/merge-map
(fn [body]
(let [mtype (parse-mtype body)]
(if (= "application/zip" mtype)
(->> (uz/load body)
(rx/merge-map read-zip-manifest)
(rx/map
(fn [manifest]
(if (= (:type manifest) "penpot/export-files")
(let [manifest (decode-manifest manifest)]
(assoc file :type :binfile-v3 :files (:files manifest)))
(assoc file :type :legacy-zip :body body)))))
(rx/of (assoc file :type :binfile-v1))))))
(rx/share))]
(->> (rx/merge
(->> stream
(rx/filter (fn [entry] (= :legacy-zip (:type entry))))
(rx/merge-map (partial analyze-file-legacy-zip-entry features)))
(->> stream
(rx/filter (fn [entry] (= :binfile-v1 (:type entry))))
(rx/map (fn [entry]
(let [file-id (uuid/next)]
(-> entry
(assoc :file-id file-id)
(assoc :name (:name file))
(assoc :status :success))))))
(->> stream
(rx/filter (fn [entry] (= :binfile-v3 (:type entry))))
(rx/merge-map (fn [{:keys [files] :as entry}]
(->> (rx/from files)
(rx/map (fn [file]
(-> entry
(dissoc :files)
(assoc :name (:name file))
(assoc :file-id (:id file))
(assoc :status :success))))))))
(->> stream
(rx/filter (fn [data] (= "other" (:type data))))
(rx/map (fn [_]
{:uri (:uri file)
:error (tr "dashboard.import.analyze-error")}))))
(rx/catch (fn [cause]
(let [error (or (ex-message cause) (tr "dashboard.import.analyze-error"))]
(rx/of (assoc file :error error :status :error))))))))
(defmethod impl/handler :analyze-import
[{:keys [files features]}]
(->> (rx/from files)
(rx/merge-map
(fn [file]
(let [st (->> (http/send!
{:uri (:uri file)
:response-type :blob
:method :get})
(rx/map :body)
(rx/mapcat wapi/read-file-as-array-buffer)
(rx/map (fn [data]
{:type (parse-mtype data)
:uri (:uri file)
:body data})))]
(->> (rx/merge
(->> st
(rx/filter (fn [data] (= "application/zip" (:type data))))
(rx/merge-map #(zip/loadAsync (:body %)))
(rx/merge-map #(get-file {:zip %} :manifest))
(rx/map
(fn [data]
;; Checks if the file is exported with components v2 and the current team only
;; supports components v1
(let [has-file-v2?
(->> (:files data)
(d/seek (fn [[_ file]] (contains? (set (:features file)) "components/v2"))))]
(if (and has-file-v2? (not (contains? features "components/v2")))
{:uri (:uri file) :error "dashboard.import.analyze-error.components-v2"}
(hash-map :uri (:uri file) :data data :type "application/zip"))))))
(->> st
(rx/filter (fn [data] (= "application/octet-stream" (:type data))))
(rx/map (fn [_]
(let [file-id (uuid/next)]
{:uri (:uri file)
:data {:name (:name file)
:file-id file-id
:files {file-id {:name (:name file)}}
:status :ready}
:type "application/octet-stream"}))))
(->> st
(rx/filter (fn [data] (= "other" (:type data))))
(rx/map (fn [_]
{:uri (:uri file)
:error (tr "dashboard.import.analyze-error")}))))
(rx/catch (fn [data]
(let [error (or (.-message data) (tr "dashboard.import.analyze-error"))]
(rx/of {:uri (:uri file) :error error}))))))))))
(rx/merge-map (partial analyze-file features))))
(defmethod impl/handler :import-files
[{:keys [project-id files features]}]
(let [context {:project-id project-id
:resolve (resolve-factory)
:system-features features}
(let [context {:project-id project-id
:resolve (resolve-factory)
:system-features features}
zip-files (filter #(= "application/zip" (:type %)) files)
binary-files (filter #(= "application/octet-stream" (:type %)) files)]
legacy-zip (filter #(= :legacy-zip (:type %)) files)
binfile-v1 (filter #(= :binfile-v1 (:type %)) files)
binfile-v3 (filter #(= :binfile-v3 (:type %)) files)]
(rx/merge
(->> (create-files context zip-files)
;; NOTE: LEGACY, will be removed so no new development should be
;; done for this part
(->> (create-files context legacy-zip)
(rx/merge-map
(fn [[file data]]
(->> (uz/load-from-url (:uri data))
@ -813,9 +879,12 @@
(->> file-stream
(rx/map
(fn [file]
{:status :import-finish
:errors (:errors file)
:file-id (:file-id data)})))))))
(if-let [errors (not-empty (:errors file))]
{:status :error
:error (first errors)
:file-id (:file-id data)}
{:status :finish
:file-id (:file-id data)}))))))))
(rx/catch (fn [cause]
(let [data (ex-data cause)]
(log/error :hint (ex-message cause)
@ -823,12 +892,11 @@
(when-let [explain (:explain data)]
(js/console.log explain)))
(rx/of {:status :import-error
(rx/of {:status :error
:file-id (:file-id data)
:error (ex-message cause)
:error-data (ex-data cause)})))))))
:error (ex-message cause)})))))))
(->> (rx/from binary-files)
(->> (rx/from binfile-v1)
(rx/merge-map
(fn [data]
(->> (http/send!
@ -836,32 +904,74 @@
:response-type :blob
:method :get})
(rx/map :body)
(rx/mapcat (fn [file]
(rx/mapcat
(fn [file]
(->> (rp/cmd! ::sse/import-binfile
{:name (str/replace (:name data) #".penpot$" "")
:file file
:project-id project-id})
(rx/tap (fn [event]
(let [payload (sse/get-payload event)
type (sse/get-type event)]
(if (= type "progress")
(log/dbg :hint "import-binfile: progress"
:section (:section payload)
:name (:name payload))
(log/dbg :hint "import-binfile: end")))))
(rx/filter sse/end-of-stream?)
(rx/map (fn [_]
{:status :finish
:file-id (:file-id data)})))))
(rx/catch
(fn [cause]
(log/error :hint "unexpected error on import process"
:project-id project-id
:cause cause)
(rx/of {:status :error
:error (ex-message cause)
:file-id (:file-id data)})))))))
(->> (rx/from binfile-v3)
(rx/reduce (fn [result file]
(update result (:uri file) (fnil conj []) file))
{})
(rx/mapcat identity)
(rx/merge-map
(fn [[uri entries]]
(->> (slurp-uri uri :blob)
(rx/mapcat (fn [content]
;; FIXME: implement the naming and filtering
(->> (rp/cmd! ::sse/import-binfile
{:name (str/replace (:name data) #".penpot$" "")
:file file
{:name (-> entries first :name)
:file content
:version 3
:project-id project-id})
(rx/tap (fn [event]
(let [payload (sse/get-payload event)
type (sse/get-type event)]
(if (= type "progress")
(log/dbg :hint "import-binfile: progress" :section (:section payload) :name (:name payload))
(log/dbg :hint "import-binfile: progress"
:section (:section payload)
:name (:name payload))
(log/dbg :hint "import-binfile: end")))))
(rx/filter sse/end-of-stream?)
(rx/map (fn [_]
{:status :import-finish
:file-id (:file-id data)})))))
(rx/catch (fn [cause]
(log/error :hint "unexpected error on import process"
:project-id project-id
::log/sync? true)
(let [edata (if (map? cause) cause (ex-data cause))]
(println "Error data:")
(pp/pprint (dissoc edata :explain) {:level 3 :length 10})
(rx/mapcat (fn [_]
(->> (rx/from entries)
(rx/map (fn [entry]
{:status :finish
:file-id (:file-id entry)}))))))))
(when (string? (:explain edata))
(js/console.log (:explain edata)))
(rx/catch
(fn [cause]
(log/error :hint "unexpected error on import process"
:project-id project-id
::log/sync? true
:cause cause)
(->> (rx/from entries)
(rx/map (fn [entry]
{:status :error
:error (ex-message cause)
:file-id (:file-id entry)}))))))))))))
(rx/of {:status :import-error
:file-id (:file-id data)})))))))))))