From 17645bb2a7d3763912ca4d42c61ae5ca6b837e58 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 5 Jul 2022 12:35:28 +0200 Subject: [PATCH] :sparkles: Frontend support for binary files --- backend/src/app/rpc/commands/binfile.clj | 49 ++++++-- frontend/src/app/main/repo.cljs | 5 +- .../src/app/main/ui/dashboard/export.cljs | 8 +- .../src/app/main/ui/dashboard/file_menu.cljs | 53 ++++++--- .../src/app/main/ui/dashboard/import.cljs | 42 ++++--- .../src/app/main/ui/workspace/header.cljs | 83 +++++++------ frontend/src/app/worker/export.cljs | 31 ++++- frontend/src/app/worker/import.cljs | 111 +++++++++++++----- frontend/translations/en.po | 12 ++ frontend/translations/es.po | 12 ++ 10 files changed, 281 insertions(+), 125 deletions(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 6a2fe9145..5dfd9ff37 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -16,7 +16,8 @@ [app.config :as cf] [app.db :as db] [app.media :as media] - [app.rpc.queries.files :refer [decode-row]] + [app.rpc.queries.files :refer [decode-row check-edition-permissions!]] + [app.rpc.queries.profile :as profile] [app.storage :as sto] [app.storage.tmp :as tmp] [app.tasks.file-gc] @@ -84,10 +85,10 @@ [v type] `(let [expected# (get-mark ~type) val# (long ~v)] - (when (not= val# expected#) - (ex/raise :type :validation - :code :unexpected-mark - :hint (format "received mark %s, expected %s" val# expected#))))) + (when (not= val# expected#) + (ex/raise :type :validation + :code :unexpected-mark + :hint (format "received mark %s, expected %s" val# expected#))))) (defmacro assert-label [expr label] @@ -759,8 +760,8 @@ (finally (l/info :hint "exportation finished" :export-id id - :elapsed (str (inst-ms (dt/diff ts (dt/now))) "ms") - :cause @cs))))) + :elapsed (str (inst-ms (dt/diff ts (dt/now))) "ms") + :cause @cs))))) (defn import! [{:keys [::input] :as cfg}] @@ -787,12 +788,38 @@ (s/def ::file-id ::us/uuid) (s/def ::profile-id ::us/uuid) +(s/def ::include-libraries? ::us/boolean) +(s/def ::embed-assets? ::us/boolean) (s/def ::export-binfile - (s/keys :req-un [::profile-id ::file-id])) + (s/keys :req-un [::profile-id ::file-id ::include-libraries? ::embed-assets?])) -#_:clj-kondo/ignore (sv/defmethod ::export-binfile "Export a penpot file in a binary format." - [{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}] - {:hello "world"}) + [{:keys [pool] :as cfg} {:keys [profile-id file-id include-libraries? embed-assets?] :as params}] + (db/with-atomic [conn pool] + (check-edition-permissions! conn profile-id file-id) + (let [path (export! (assoc cfg + ::file-ids [file-id] + ::embed-assets? embed-assets? + ::include-libraries? include-libraries?))] + (with-meta {} + {:transform-response (fn [_ response] + (assoc response + :body (io/input-stream path) + :headers {"content-type" "application/octet-stream"}))})))) + +(s/def ::input ::media/upload) + +(s/def ::import-binfile + (s/keys :req-un [::profile-id ::input])) + +(sv/defmethod ::import-binfile + "Import a penpot file in a binary format." + [{:keys [pool] :as cfg} {:keys [profile-id input] :as params}] + (let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)] + (import! (assoc cfg + ::input (:path input) + ::project-id project-id + ::ignore-index-errors? true)))) + diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index b1d7b581c..d1c3c1e1f 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -76,11 +76,12 @@ (defn- send-command! "A simple helper for a common case of sending and receiving transit data to the penpot mutation api." - [id params] + [id {:keys [blob? form-data?] :as params}] (->> (http/send! {:method :post :uri (u/join base-uri "api/rpc/command/" (name id)) :credentials "include" - :body (http/transit-data params)}) + :body (if form-data? (http/form-data params) (http/transit-data params)) + :response-type (if blob? :blob :text)}) (rx/map http/conditional-decode-transit) (rx/mapcat handle-response))) diff --git a/frontend/src/app/main/ui/dashboard/export.cljs b/frontend/src/app/main/ui/dashboard/export.cljs index 5a1425ba6..58f8d957d 100644 --- a/frontend/src/app/main/ui/dashboard/export.cljs +++ b/frontend/src/app/main/ui/dashboard/export.cljs @@ -51,7 +51,7 @@ (mf/defc export-dialog {::mf/register modal/components ::mf/register-as :export} - [{:keys [team-id files has-libraries?]}] + [{:keys [team-id files has-libraries? binary?]}] (let [state (mf/use-state {:status :prepare :files (->> files (mapv #(assoc % :loading? true)))}) selected-option (mf/use-state :all) @@ -60,10 +60,11 @@ (fn [] (swap! state assoc :status :exporting) (->> (uw/ask-many! - {:cmd :export-file + {:cmd (if binary? :export-binary-file :export-standard-file) :team-id team-id :export-type @selected-option - :files (->> files (mapv :id))}) + :files files + }) (rx/delay-emit 1000) (rx/subs (fn [msg] @@ -73,6 +74,7 @@ (when (= :finish (:type msg)) (swap! state update :files mark-file-success (:file-id msg)) (dom/trigger-download-uri (:filename msg) (:mtype msg) (:uri msg))))))) + cancel-fn (mf/use-callback (fn [event] diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index bbcdb74e0..7903f591a 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -158,26 +158,38 @@ :on-accept del-shared}))) on-export-files + (fn [event-name binary?] + (st/emit! (ptk/event ::ev/event {::ev/name event-name + ::ev/origin "dashboard" + :num-files (count files)})) + + (->> (rx/from files) + (rx/flat-map + (fn [file] + (->> (rp/query :file-libraries {:file-id (:id file)}) + (rx/map #(assoc file :has-libraries? (d/not-empty? %)))))) + (rx/reduce conj []) + (rx/subs + (fn [files] + (st/emit! + (modal/show + {:type :export + :team-id current-team-id + :has-libraries? (->> files (some :has-libraries?)) + :files files + :binary? binary?})))))) + + on-export-binary-files (mf/use-callback (mf/deps files current-team-id) (fn [_] - (st/emit! (ptk/event ::ev/event {::ev/name "export-files" - ::ev/origin "dashboard" - :num-files (count files)})) - (->> (rx/from files) - (rx/flat-map - (fn [file] - (->> (rp/query :file-libraries {:file-id (:id file)}) - (rx/map #(assoc file :has-libraries? (d/not-empty? %)))))) - (rx/reduce conj []) - (rx/subs - (fn [files] - (st/emit! - (modal/show - {:type :export - :team-id current-team-id - :has-libraries? (->> files (some :has-libraries?)) - :files files}))))))) + (on-export-files "export-binary-files" true))) + + on-export-standard-files + (mf/use-callback + (mf/deps files current-team-id) + (fn [_] + (on-export-files "export-standard-files" false))) ;; NOTE: this is used for detect if component is still mounted mounted-ref (mf/use-ref true)] @@ -210,7 +222,8 @@ [[(tr "dashboard.duplicate-multi" file-count) on-duplicate nil "duplicate-multi"] (when (or (seq current-projects) (seq other-teams)) [(tr "dashboard.move-to-multi" file-count) nil sub-options "move-to-multi"]) - [(tr "dashboard.export-multi" file-count) on-export-files] + [(tr "dashboard.export-binary-multi" file-count) on-export-binary-files] + [(tr "dashboard.export-standard-multi" file-count) on-export-standard-files] [:separator] [(tr "labels.delete-multi-files" file-count) on-delete nil "delete-multi-files"]] @@ -222,7 +235,9 @@ (if (:is-shared file) [(tr "dashboard.remove-shared") on-del-shared nil "file-del-shared"] [(tr "dashboard.add-shared") on-add-shared nil "file-add-shared"]) - [(tr "dashboard.export-single") on-export-files nil "file-export"] + [:separator] + [(tr "dashboard.download-binary-file") on-export-binary-files nil "download-binary-file"] + [(tr "dashboard.download-standard-file") on-export-standard-files nil "download-standard-file"] [:separator] [(tr "labels.delete") on-delete nil "file-delete"]])] diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 6fa2f4697..93b48d987 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -49,7 +49,7 @@ (let [on-file-selected (use-import-file project-id on-finish-import)] [:form.import-file - [:& file-uploader {:accept ".penpot" + [:& file-uploader {:accept ".penpot,.zip" :multi true :ref external-ref :on-selected on-file-selected}]])) @@ -78,19 +78,20 @@ (= uri (:uri file)) (assoc :status :analyze-error)))))) -(defn set-analyze-result [files uri data] +(defn set-analyze-result [files uri type data] (let [existing-files? (into #{} (->> files (map :file-id) (filter some?))) replace-file (fn [file] - (if (and (= uri (:uri file) ) + (if (and (= uri (:uri file)) (= (:status file) :analyzing)) (->> (:files data) - (remove (comp existing-files? first) ) + (remove (comp existing-files? first)) (mapv (fn [[file-id file-data]] (-> file-data (assoc :file-id file-id :status :ready - :uri uri))))) + :uri uri + :type type))))) [file]))] (into [] (mapcat replace-file) files))) @@ -139,7 +140,7 @@ (str message))) (mf/defc import-entry - [{:keys [state file editing?]}] + [{:keys [state file editing? can-be-deleted?]}] (let [loading? (or (= :analyzing (:status file)) (= :importing (:status file))) @@ -206,9 +207,11 @@ [:div.file-name-label (:name file) (when is-shared? i/library)]) - [:div.edit-entry-buttons - [:button {:on-click handle-edit-entry} i/pencil] - [:button {:on-click handle-remove-entry} i/trash]]] + [:div.edit-entry-buttons + (when (= "application/zip" (:type file)) + [:button {:on-click handle-edit-entry} i/pencil]) + (when can-be-deleted? + [:button {:on-click handle-remove-entry} i/trash])]] (cond analyze-error? @@ -245,21 +248,20 @@ (fn [files] (->> (uw/ask-many! {:cmd :analyze-import - :files (->> files (mapv :uri))}) + :files files}) (rx/delay-emit emit-delay) (rx/subs - (fn [{:keys [uri data error] :as msg}] + (fn [{:keys [uri data error type] :as msg}] (log/debug :uri uri :data data :error error) (if (some? error) (swap! state update :files set-analyze-error uri) - (swap! state update :files set-analyze-result uri data))))))) + (swap! state update :files set-analyze-result uri type data))))))) import-files (mf/use-callback (fn [project-id files] (st/emit! (ptk/event ::ev/event {::ev/name "import-files" :num-files (count files)})) - (->> (uw/ask-many! {:cmd :import-files :project-id project-id @@ -281,7 +283,7 @@ (mf/deps project-id (:files @state)) (fn [event] (dom/prevent-default event) - (let [files (->> @state :files (filterv #(= :ready (:status %))))] + (let [files (->> @state :files (filterv #(and (= :ready (:status %)) (not (:deleted? %)))))] (import-files project-id files)) (swap! state @@ -300,7 +302,8 @@ warning-files (->> @state :files (filter #(and (= (:status %) :import-finish) (d/not-empty? (:errors %)))) count) success-files (->> @state :files (filter #(and (= (:status %) :import-finish) (empty? (:errors %)))) count) pending-analysis? (> (->> @state :files (filter #(= (:status %) :analyzing)) count) 0) - pending-import? (> (->> @state :files (filter #(= (:status %) :importing)) count) 0)] + pending-import? (> (->> @state :files (filter #(= (:status %) :importing)) count) 0) + files (->> (:files @state) (filterv (comp not :deleted?)))] (mf/use-effect (fn [] @@ -333,12 +336,13 @@ [:div.icon i/checkbox-checked] [:div.message (tr "dashboard.import.import-message" success-files)]])) - (for [file (->> (:files @state) (filterv (comp not :deleted?)))] - (let [editing? (and (some? (:file-id file)) - (= (:file-id file) (:editing @state)))] + (for [file files] + (let [editing? (and (some? (:file-id file)) + (= (:file-id file) (:editing @state)))] [:& import-entry {:state state :file file - :editing? editing?}]))] + :editing? editing? + :can-be-deleted? (> (count files) 1)}]))] [:div.modal-footer [:div.action-buttons diff --git a/frontend/src/app/main/ui/workspace/header.cljs b/frontend/src/app/main/ui/workspace/header.cljs index 2b4fd9728..5f93e4def 100644 --- a/frontend/src/app/main/ui/workspace/header.cljs +++ b/frontend/src/app/main/ui/workspace/header.cljs @@ -121,26 +121,26 @@ (mf/use-fn (mf/deps file) #(st/emit! (modal/show - {:type :confirm - :message "" - :title (tr "modals.add-shared-confirm.message" (:name file)) - :hint (tr "modals.add-shared-confirm.hint") - :cancel-label :omit - :accept-label (tr "modals.add-shared-confirm.accept") - :accept-style :primary - :on-accept add-shared-fn}))) + {:type :confirm + :message "" + :title (tr "modals.add-shared-confirm.message" (:name file)) + :hint (tr "modals.add-shared-confirm.hint") + :cancel-label :omit + :accept-label (tr "modals.add-shared-confirm.accept") + :accept-style :primary + :on-accept add-shared-fn}))) on-remove-shared (mf/use-fn (mf/deps file) #(st/emit! (modal/show - {:type :confirm - :message "" - :title (tr "modals.remove-shared-confirm.message" (:name file)) - :hint (tr "modals.remove-shared-confirm.hint") - :cancel-label :omit - :accept-label (tr "modals.remove-shared-confirm.accept") - :on-accept del-shared-fn}))) + {:type :confirm + :message "" + :title (tr "modals.remove-shared-confirm.message" (:name file)) + :hint (tr "modals.remove-shared-confirm.hint") + :cancel-label :omit + :accept-label (tr "modals.remove-shared-confirm.accept") + :on-accept del-shared-fn}))) handle-blur (fn [_] (let [value (-> edit-input-ref mf/ref-val dom/get-value)] @@ -160,27 +160,38 @@ (st/emit! (de/show-workspace-export-dialog)))) on-export-file + (fn [event-name binary?] + (st/emit! (ptk/event ::ev/event {::ev/name event-name + ::ev/origin "workspace" + :num-files 1})) + + (->> (rx/of file) + (rx/flat-map + (fn [file] + (->> (rp/query :file-libraries {:file-id (:id file)}) + (rx/map #(assoc file :has-libraries? (d/not-empty? %)))))) + (rx/reduce conj []) + (rx/subs + (fn [files] + (st/emit! + (modal/show + {:type :export + :team-id team-id + :has-libraries? (->> files (some :has-libraries?)) + :files files + :binary? binary?})))))) + + on-export-binary-file (mf/use-callback (mf/deps file team-id) (fn [_] - (st/emit! (ptk/event ::ev/event {::ev/name "export-files" - ::ev/origin "workspace" - :num-files 1})) + (on-export-file "export-binary-files" true))) - (->> (rx/of file) - (rx/flat-map - (fn [file] - (->> (rp/query :file-libraries {:file-id (:id file)}) - (rx/map #(assoc file :has-libraries? (d/not-empty? %)))))) - (rx/reduce conj []) - (rx/subs - (fn [files] - (st/emit! - (modal/show - {:type :export - :team-id team-id - :has-libraries? (->> files (some :has-libraries?)) - :files files}))))))) + on-export-standard-file + (mf/use-callback + (mf/deps file team-id) + (fn [_] + (on-export-file "export-standard-files" false))) on-export-frames (mf/use-callback @@ -274,10 +285,12 @@ [:li.export-file {:on-click on-export-shapes} [:span (tr "dashboard.export-shapes")] [:span.shortcut (sc/get-tooltip :export-shapes)]] - [:li.export-file {:on-click on-export-file} - [:span (tr "dashboard.export-single")]] + [:li.separator.export-file {:on-click on-export-binary-file} + [:span (tr "dashboard.download-binary-file")]] + [:li.export-file {:on-click on-export-standard-file} + [:span (tr "dashboard.download-standard-file")]] (when (seq frames) - [:li.export-file {:on-click on-export-frames} + [:li.separator.export-file {:on-click on-export-frames} [:span (tr "dashboard.export-frames")]])]] [:& dropdown {:show (= @show-sub-menu? :edit) diff --git a/frontend/src/app/worker/export.cljs b/frontend/src/app/worker/export.cljs index 6a568b61b..0e48fe922 100644 --- a/frontend/src/app/worker/export.cljs +++ b/frontend/src/app/worker/export.cljs @@ -450,13 +450,34 @@ (->> (uz/compress-files data) (rx/map #(vector (get files file-id) %))))))))) -(defmethod impl/handler :export-file +(defmethod impl/handler :export-binary-file + [{:keys [files export-type] :as message}] + (->> (rx/from files) + (rx/mapcat + (fn [file] + (->> (rp/command! :export-binfile {:file-id (:id file) + :include-libraries? (= export-type :all) + :embed-assets? (= export-type :merge) + :blob? true}) + (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-standard-file [{:keys [team-id files export-type] :as message}] (->> (rx/from files) (rx/mapcat (fn [file] - (->> (export-file team-id file export-type) + (->> (export-file team-id (:id file) export-type) (rx/map (fn [value] (if (contains? value :type) @@ -465,11 +486,11 @@ {:type :finish :file-id (:id file) :filename (:name file) - :mtype "application/penpot" - :description "Penpot export (*.penpot)" + :mtype "application/zip" + :description "Penpot export (*.zip)" :uri (wapi/create-uri export-blob)})))) (rx/catch (fn [err] (rx/of {:type :error :error (str err) - :file-id file})))))))) + :file-id (:id file)})))))))) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 9038d8181..6e8cc8fac 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -7,6 +7,7 @@ (ns app.worker.import (:refer-clojure :exclude [resolve]) (:require + ["jszip" :as zip] [app.common.data :as d] [app.common.file-builder :as fb] [app.common.geom.point :as gpt] @@ -20,6 +21,7 @@ [app.util.http :as http] [app.util.import.parser :as cip] [app.util.json :as json] + [app.util.webapi :as wapi] [app.util.zip :as uz] [app.worker.impl :as impl] [beicon.core :as rx] @@ -519,48 +521,95 @@ (rx/flat-map link-file-libraries) (rx/ignore))))) +(defn parse-mtype [ba] + (let [u8 (js/Uint8Array. ba 0 4) + sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] + (case sg + "120 113 3 4" "application/zip" + "application/octet-stream"))) + (defmethod impl/handler :analyze-import [{:keys [files]}] (->> (rx/from files) (rx/flat-map - (fn [uri] - (->> (rx/of uri) - (rx/flat-map uz/load-from-url) - (rx/flat-map #(get-file {:zip %} :manifest)) - (rx/map (comp d/kebab-keys cip/string->uuid)) - (rx/map #(hash-map :uri uri :data %)) - (rx/catch #(rx/of {:uri uri :error (.-message %)}))))))) + (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/flat-map #(zip/loadAsync (:body %))) + (rx/flat-map #(get-file {:zip %} :manifest)) + (rx/map (comp d/kebab-keys cip/string->uuid)) + (rx/map #(hash-map :uri (:uri file) :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"}))))) + (rx/catch #(rx/of {:uri (:uri file) :error (.-message %)})))))))) (defmethod impl/handler :import-files [{:keys [project-id files]}] (let [context {:project-id project-id - :resolve (resolve-factory)}] + :resolve (resolve-factory)} + zip-files (filter #(= "application/zip" (:type %)) files) + binary-files (filter #(= "application/octet-stream" (:type %)) files)] - (->> (create-files context files) - (rx/flat-map - (fn [[file data]] - (->> (uz/load-from-url (:uri data)) - (rx/map #(-> context (assoc :zip %) (merge data))) - (rx/merge-map - (fn [context] - ;; process file retrieves a stream that will emit progress notifications - ;; and other that will emit the files once imported - (let [[progress-stream file-stream] (process-file context file)] - (rx/merge progress-stream - (->> file-stream - (rx/map - (fn [file] - {:status :import-finish - :errors (:errors file) - :file-id (:file-id data)}))))))) - (rx/catch (fn [cause] - (log/error :hint (ex-message cause) :file-id (:file-id data) :cause cause) - (rx/of {:status :import-error - :file-id (:file-id data) - :error (ex-message cause) - :error-data (ex-data cause)})))))) + (->> (rx/merge + (->> (create-files context zip-files) + (rx/flat-map + (fn [[file data]] + (->> (uz/load-from-url (:uri data)) + (rx/map #(-> context (assoc :zip %) (merge data))) + (rx/merge-map + (fn [context] + ;; process file retrieves a stream that will emit progress notifications + ;; and other that will emit the files once imported + (let [[progress-stream file-stream] (process-file context file)] + (rx/merge progress-stream + (->> file-stream + (rx/map + (fn [file] + {:status :import-finish + :errors (:errors file) + :file-id (:file-id data)}))))))) + (rx/catch (fn [cause] + (log/error :hint (ex-message cause) :file-id (:file-id data) :cause cause) + (rx/of {:status :import-error + :file-id (:file-id data) + :error (ex-message cause) + :error-data (ex-data cause)}))))))) + + (->> (rx/from binary-files) + (rx/flat-map + (fn [data] + (->> (http/send! + {:uri (:uri data) + :response-type :blob + :method :get}) + (rx/map :body) + (rx/mapcat #(rp/command! :import-binfile {:input % + :form-data? true})) + (rx/map + (fn [_] + {:status :import-finish + :file-id (:file-id data)}))))))) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f6969b443..1977cb092 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -309,6 +309,18 @@ msgstr "Export selection" msgid "dashboard.export-single" msgstr "Export Penpot file" +msgid "dashboard.download-binary-file" +msgstr "Download Penpot file (.penpot)" + +msgid "dashboard.download-standard-file" +msgstr "Download standard file (.svg + .json)" + +msgid "dashboard.export-binary-multi" +msgstr "Download %s Penpot files (.penpot)" + +msgid "dashboard.export-standard-multi" +msgstr "Download %s standard files (.svg + .json)" + msgid "dashboard.export.detail" msgstr "* Might include components, graphics, colors and/or typographies." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index ad8af9bdf..63dea9c57 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -315,6 +315,18 @@ msgstr "Exportar selección" msgid "dashboard.export-single" msgstr "Exportar archivo Penpot" +msgid "dashboard.download-binary-file" +msgstr "Descargar archivo Penpot (.penpot)" + +msgid "dashboard.download-standard-file" +msgstr "Descargar archivo estándar (.svg + .json)" + +msgid "dashboard.export-binary-multi" +msgstr "Descargar %s archivos Penpot (.penpot)" + +msgid "dashboard.export-standard-multi" +msgstr "Descargar %s archivos estándar (.svg + .json)" + msgid "dashboard.export.detail" msgstr "* Pueden incluir components, gráficos, colores y/o tipografias."