diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index c309d1f170..ceddce4588 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -25,7 +25,7 @@ - A save answered with `:rejected` (backend kept the commits before the first failing one, see `mem:backend/subtleties`) is not a failure: `rollback-rejected` runs before `commit-persisted`. The unit is the undo group (one user action, which can commit to several files, e.g. `update-component` writes the copy's file and the library, and `component-changed` syncs inherit the edit's group; multi-file actions must share a non-nil group): the pure bookkeeping lives in `app.main.data.persistence.commits` (`pcm`): `pcm/rollback-plan` reverts, in the saved file, the pending commits from the first rejected one on, and in any file the pending commits from the first one of a reverted group on, iterating until no new group appears; other files' unrelated commits stay. `pcm/without-rolled-back` drops pending reverted commits from queue entries (partial entries trimmed, empty ones leave the queue) and the buffer; saved commits of a reverted group (the head's kept ones, and `:saved`, which `keep-saved-commits` fills on `discard-commit` with saved commits whose group a pending commit still shares) are undone by a new `save-undo? false` commit (`compensate-commits`, flushed like any edit); for a file whose data is not loaded the undo goes straight to the queue as an entry, nothing applied locally. Per file, `dch/rebase-commits` undoes every pending commit newest first, re-applies the remote changes kept since the oldest pending commit, and redoes the pending commits that stay. Undo changes hold absolute values from commit time, so undoing only the rejected commits would overwrite a peer's later change to the same attr. Remote changes are numbered (`record-remote-changes`, `[:persistence :remote-seq]`) and kept per file under `:remote-changes` only while the file has pending commits; `prune-remote-changes` (discard, release) forgets those at or below the oldest pending stamp, since re-applying them would undo saved edits. Then `dwu/forget-undo-groups groups undo-index` (cut history before the first entry whose `:undo-groups` — every group the entry holds, filled by `append-undo`, transactions and stacking — meets a reverted group; no match keeps the history; the open transaction is emptied only when cut or holding one, keeping its group for later commits; a reverted Ctrl+Z/Ctrl+Shift+Z commit, which carries real `:undo-changes` and `:undo-index` (the index it moved from, kept by `pcm` commit-keys), puts the index back to the plan's `:undo-index`, the oldest one's; background commits with `:undo-changes []` (position-data, component `modified-at`, deleted-font fixes) stay on screen after a rollback by design), selection pruned and edition mode left when the edited shape is gone, or when it is a path being edited that the rollback changed: `discard-path-edit` drops `[:workspace-local :edit-path id]` first, so `finalize-path-content` sees no `:old-content` and the editor's stale copy is never saved (`prune-removed-shapes` takes the edited object from before the rebase to compare). Text editions stay open: the v3 WASM editor already shows the reverted text, and the v2 editor resets its DOM root from the shape on `::rolled-back` when the text differs, since its unmount `on-blur` would otherwise save the stale DOM content (`prune-removed-shapes`; `v2-update-text-shape-content` also skips a missing shape, so the editor's exit commits nothing), `errors/flash` `:unhandled` toast tagged `:persistence-rejected`, hidden after `rejected-timeout-ms` (10 s). It ends with `(ptk/data-event ::rolled-back {:file-ids})`: `dwl/sync-file` waits for it or `wait-persisted` and marks the library synced only on a landed save (on rollback it restores `:synced-at` and re-checks updates after the rollback's own save). `update-status` ignores `:pending` when buffer and queue are empty (`nothing-to-save?`): a dropped commit's 200 ms pending debounce would otherwise leave the status stuck and `wait-persisted-or-error` waiting forever. `prune-removed-shapes` also closes a `:colorpicker` modal. `rebase-commits` is not a commit (not saved/broadcast/undoable, commit watchers such as thumbnails do not see it) and falls back to `::dw/reload-current-file` if applying throws. `debug.commitInvalidChange({kind, before, after})` forces such a rejection live. - Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker. - Persistence is skipped in version preview/read-only mode or without edit permission. -- Failing saves lock the workspace (`app.main.data.persistence.lock`, alias `plk`): `plk/failing?` (status `:error`, or `:recovering` while a slow-cycle attempt is in flight as `:saving`) drives `sync-failure-lock` (also on `change-team-role`), which locks only while `[:permissions :can-edit]`: a viewer, already read-only, gets the `:save-permission-denied` toast and never the save-lock banner; `lock-on-failure` stores the previous read-only value in `[:workspace-global :persistence-lock]` and sets `:read-only?`; `unlock-after-failure` restores it (into `[:render-state :pre-context-lost-read-only?]` instead while the WebGL context is lost, which keeps its own save/restore). `set-workspace-read-only` writes into the lock while it is held, so an Inspect/Design switch is not lost. `view-only-bar*` (`ui/workspace/viewport/top_bar.cljs`, shared by both renderers) shows the save error while the lock exists (text per `dps/save-retrying?`), hides its Done/exit button and takes priority over the context-loss message. No save-failure popup: page navigation, pan, zoom and inspection stay available. Once unlocked, the usual Inspect/context-loss message and action return. Plugins still write while read-only (never as a viewer: `commit-changes` drops commits without `:can-edit`). A peer's `:file-restored` reloads the file and opens the `:file-restored` modal named after the restoring session's profile. +- Failing saves lock the workspace (`app.main.data.persistence.lock`, alias `plk`): `plk/failing?` (status `:error`, or `:recovering` while a slow-cycle attempt is in flight as `:saving`) drives `sync-failure-lock` (also on `change-team-role`), which locks only while `[:permissions :can-edit]`: a viewer, already read-only, gets the `:save-permission-denied` toast and never the save-lock banner; `lock-on-failure` stores the previous read-only value in `[:workspace-global :persistence-lock]` and sets `:read-only?`; `unlock-after-failure` restores it (into `[:render-state :pre-context-lost-read-only?]` instead while the WebGL context is lost, which keeps its own save/restore). `set-workspace-read-only` writes into the lock while it is held, so an Inspect/Design switch is not lost. `view-only-bar*` (`ui/workspace/viewport/top_bar.cljs`, shared by both renderers) shows the save error while the lock exists (text per `dps/save-retrying?`), hides its Done/exit button and takes priority over the context-loss message. No save-failure popup: page navigation, pan, zoom and inspection stay available. Once unlocked, the usual Inspect/context-loss message and action return. Plugins still write while read-only (never as a viewer: `commit-changes` drops commits without `:can-edit`). A peer's `:file-restored` (backend adds `:profile-id`, `:created-at` and, for a version a user named, `:label`) reloads the file and opens the `:file-restored` modal: who restored (profile, else the session's presence, else "A teammate"), the version by label, else date (`PPp`), and the "latest changes couldn't be saved" line when `dps/pending-file-changes` held edits of the replaced version, which the reload drops. - A terminal failure halts the queue on purpose: the edits stay queued so `wait-persisted-or-error` keeps rejecting, and `workspace/versions.cljs` relies on that to avoid snapshotting a file whose edits were never saved. `recover-persistence` lifts the halt when a file is opened again or the role changes. A transport failure still inside its retry window (`slow-retry-pending?`) is resent once (`resume-persistence true`), never released; any other halt emits `release-failed-file` (drops every queued commit of the file that failed) then `resume-persistence`. Opening a file also replays the queued, then buffered, edits of its version onto the loaded data (`pending-file-changes` → `restore-pending-changes` in `bundle-fetched`); a replay that throws keeps the server data and the queue. That is what unblocks the editor without a reload; because the queue is shared, dropping the failed file is also what lets other files' pending edits drain. `release-failed-file` runs at recovery time, never at failure time — clearing the error eagerly would make waiters resolve. - The split between `initialize-persistence-state` and `recover-persistence` is deliberate: initialization deliberately does NOT lift a halt, so undeliverable edits are released when the user acts, not when the view mounts. - **Every non-transport terminal save failure is permanent** (a backend without commit-id groups still answers an invalid batch with a terminal `:validation`). There is no "stale but valid" category: permission lost, file gone, and server-rejected changes all fail again on a resend. Losing edit permission is not temporary — the backend raises `:not-found` for it (`check-edition-permissions!` → `perms/make-check-fn`), and a save's `:not-found` is a terminal save error that keeps the workspace open (`errors/delegated-persistence-types` is only `#{:authentication}`; a missing data fragment answers it too). So `:save-permission-denied` (a client-side `can-edit` check) must never be resumed. A role change that leaves `can-edit` false keeps it halted (the edits stay queued and on screen; `notify-kept-failure` shows the count toast again, since the single-toast store lets the role-change info toast replace it); the role change that brings edit rights back releases it. A release on a role change (`release-failed-file` with `revert?`, i.e. not on opening a file, whose data comes fresh from the server) also rolls the released commits back locally with `pcm/release-plan` (a `rollback-plan` rejecting every pending commit of the file) through `apply-rollback-plan`: rebase, compensation of saved commits of the same groups, undo-history cut, `prune-removed-shapes`, `::rolled-back`. Without it the canvas keeps showing edits the server never got. Its toast `errors.save-permission-lost` is plural over `:unsaved-commits` (`count-unsaved-commits`); plural keys need `(tr k (i18n/c n))`, a bare number renders the fallback text. @@ -36,6 +36,7 @@ - `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. - `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It reads `[:persistence :buffer]` and `:queue` and syncs on `::buffer-commit`, `::append-commit`, `::discard-commit` and `::release-failed-file` (every buffer and queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. - `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `persist-buffered`, which forces a save when `[:persistence :buffer]` is not empty, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save. +- Exports (exporter and client/WASM PDF/SVG/PNG alike), the dashboard `dd/duplicate-files` and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` first. A client export draws local data, so its warning says the export includes unsaved changes (`workspace.export.unsaved-changes-included`); exporter exports and the duplicate copy server data and warn they miss them. `duplicate-files` owns its one toast, shown once every copy exists: the plural `dashboard.duplicate-unsaved-changes` warning when the files had unsaved changes before the copy request, else the success toast; a failed copy errors the stream, so neither shows (the file menu must not emit its own success toast, the single-toast store would let it replace the warning). - Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`. - `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth failures. - `start-undo-transaction` stamps the transaction with a fresh `:commit-group` (dropped from the history entry); `commit-changes` uses `(or undo-group (:undo-group tx) (:commit-group tx) (uuid/next))`, so all commits of one transaction are one rollback unit. Pre-setting `:undo-group` instead breaks entries whose group is set later (sync transactions). diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index ba27fb5186..80be2cd189 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -196,13 +196,20 @@ (ex/raise :type :conflict :code :file-modified :hint "the file was modified during the restore process, please retry"))) - (let [vern (fsnap/restore! cfg file-id id)] + (let [snapshot (fsnap/get-minimal-snapshot cfg id) + vern (fsnap/restore! cfg file-id id)] + ;; Peers name the restored version by its label when a + ;; user named it, else by its date (mbus/pub! msgbus :topic file-id - :message {:type :file-restored - :session-id session-id - :file-id file-id - :vern vern}) + :message (cond-> {:type :file-restored + :session-id session-id + :profile-id profile-id + :file-id file-id + :vern vern + :created-at (:created-at snapshot)} + (= "user" (:created-by snapshot)) + (assoc :label (:label snapshot)))) nil))))) (def ^:private schema:update-file-snapshot diff --git a/backend/test/backend_tests/rpc_file_snapshot_test.clj b/backend/test/backend_tests/rpc_file_snapshot_test.clj index 9238a25b79..91fea25e0b 100644 --- a/backend/test/backend_tests/rpc_file_snapshot_test.clj +++ b/backend/test/backend_tests/rpc_file_snapshot_test.clj @@ -23,6 +23,7 @@ [app.features.fdata :as fdata] [app.features.file-snapshots :as fsnap] [app.http :as http] + [app.msgbus :as mbus] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] @@ -92,11 +93,19 @@ ::rpc/profile-id (:id profile) :file-id (:id file) :id @snapshot-id} - out (th/command! params)] + calls (atom []) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [message]}] + (swap! calls conj message))] + (th/command! params)) + sent (first (filter #(= :file-restored (:type %)) @calls))] ;; (th/print-result! out) (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) + (t/is (nil? (:result out))) + ;; Peers learn who restored which version + (t/is (= (:id profile) (:profile-id sent))) + (t/is (= "label1" (:label sent))) + (t/is (ct/inst? (:created-at sent)))) (let [[row1 row2 :as rows] (th/db-query :file-change diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 28f36671ff..ab2ace94da 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -23,6 +23,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.persistence :as dps] [app.main.data.team :as dtm] [app.main.data.websocket :as dws] [app.main.repo :as rp] @@ -535,29 +536,60 @@ (rx/map #(with-meta (file-created %) (meta it))) (rx/catch on-error)))))) -;; --- EVENT: duplicate-file +;; --- EVENT: duplicate-files -(defn duplicate-file - [{:keys [id name] :as params}] - (dm/assert! (uuid? id)) - (dm/assert! (string? name)) - (ptk/reify ::duplicate-file +;; Longest wait for the pending changes of a file to be saved before copying it. +(def ^:private save-wait-timeout-ms 10000) + +(defn- notify-duplicated + "Tells the user the `n` copies exist. With `unsaved`, warns instead that + they miss the changes not saved yet." + [n unsaved] + (if unsaved + (ntf/show {:content (tr "dashboard.duplicate-unsaved-changes" (i18n/c n)) + :type :toast + :level :warning + :timeout 8000}) + (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c n))))) + +(defn- copy-files + "Copies `files` on the server, then tells the user. A copy that fails + ends the stream with its error, so nothing is told." + [files] + (ptk/reify ::copy-files ptk/WatchEvent (watch [_ state _] - (let [{:keys [on-success on-error] - :or {on-success identity - on-error rx/throw}} (meta params) - unames (cfh/get-used-names (get state :files)) + (let [file-ids (into #{} (map :id) files) + ;; The copies hold the server's data, so what is unsaved now is missing + unsaved (dps/unsaved-changes? state file-ids) + unames (cfh/get-used-names (get state :files)) suffix-fn (fn [copy-count] (str/concat " " (tr "dashboard.copy-suffix") (when (> copy-count 1) - (str " " copy-count)))) - new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)] - (->> (rp/cmd! :duplicate-file {:file-id id :name new-name}) - (rx/tap on-success) - (rx/map file-created) - (rx/catch on-error)))))) + (str " " copy-count))))] + (rx/concat + (->> (rx/from files) + (rx/merge-map + (fn [{:keys [id name]}] + (let [new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)] + (->> (rp/cmd! :duplicate-file {:file-id id :name new-name}) + (rx/map file-created)))))) + (rx/of (notify-duplicated (count files) unsaved))))))) + +(defn duplicate-files + "Sends the pending changes of `files`, copies them and tells the user + once every copy exists." + [files] + (dm/assert! (every? (comp uuid? :id) files)) + (dm/assert! (every? (comp string? :name) files)) + (ptk/reify ::duplicate-files + ptk/WatchEvent + (watch [_ _ _] + ;; The copy is made from the server's data: send the pending changes first. + (rx/concat + (dps/force-persist-files-and-wait (into #{} (map :id) files) save-wait-timeout-ms) + (rx/of (copy-files files)))))) ;; --- EVENT: move-files diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index e85c1c3cd3..f041557921 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -38,13 +38,13 @@ (into #{} (keep :file-id) exports)) (defn- warn-unsaved-changes - "Warns that the export may miss the changes of `file-ids` not saved yet." - [file-ids] + "Shows `message` when some changes of `file-ids` are not saved yet." + [file-ids message] (ptk/reify ::warn-unsaved-changes ptk/WatchEvent (watch [_ state _] (when (dwp/unsaved-changes? state file-ids) - (rx/of (ntf/show {:content (tr "workspace.export.unsaved-changes") + (rx/of (ntf/show {:content message :type :toast :level :warning :timeout 8000})))))) @@ -362,11 +362,16 @@ ptk/WatchEvent (watch [_ state _] (case @target + ;; Drawn from the local data, which a rejected save may still change :client - (rx/of (request-local-export-wasm {:exports [export] - :cmd :export-shapes - :name (:name export) - :kind :single})) + (rx/concat + (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) + (rx/of (warn-unsaved-changes (export-file-ids [export]) + (tr "workspace.export.unsaved-changes-included")) + (request-local-export-wasm {:exports [export] + :cmd :export-shapes + :name (:name export) + :kind :single}))) :too-large (rx/of (ntf/error (tr "errors.export-too-large"))) @@ -379,7 +384,8 @@ :is-wasm (wasm-export-enabled? state)})] (rx/concat (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) - (rx/of (warn-unsaved-changes (export-file-ids [export]))) + (rx/of (warn-unsaved-changes (export-file-ids [export]) + (tr "workspace.export.unsaved-changes"))) (->> (rp/cmd! :export params) (rx/map (fn [{:keys [filename mtype uri]}] @@ -555,7 +561,7 @@ (rx/concat (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) - (rx/of (warn-unsaved-changes file-ids)) + (rx/of (warn-unsaved-changes file-ids (tr "workspace.export.unsaved-changes"))) (rx/merge ;; Updates the export state with each progress message, until ;; shortly after the export ends or fails. @@ -599,7 +605,12 @@ ptk/WatchEvent (watch [_ state _] (if (wasm-export-enabled? state) - (rx/of (request-local-export-wasm params)) + ;; Drawn from the local data, which a rejected save may still change + (let [file-ids (export-file-ids exports)] + (rx/concat + (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (rx/of (warn-unsaved-changes file-ids (tr "workspace.export.unsaved-changes-included")) + (request-local-export-wasm params)))) (rx/of (request-multiple-export-server params))))))) (defn request-export diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 6945fb00a9..b2637bbce7 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -17,6 +17,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.persistence :as dps] [app.main.data.plugins :as dpl] [app.main.data.websocket :as dws] [app.main.data.workspace :as-alias dw] @@ -302,13 +303,27 @@ [:map {:title "handle-file-restored"} [:type :keyword] [:file-id ::sm/uuid] - [:vern :int]]) + [:vern :int] + [:profile-id {:optional true} ::sm/uuid] + [:label {:optional true} :string] + [:created-at {:optional true} ::ct/inst]]) (def ^:private check-file-restored-params (sm/check-fn schema:handle-file-restored)) +(defn- restorer-fullname + "Name of the user who restored the file, when known: by `profile-id`, or + else by the presence of the session that restored it." + [state profile-id session-id] + (when-let [profile-id (or profile-id + (dm/get-in state [:workspace-presence session-id :profile-id]))] + (dm/get-in state [:profiles profile-id :fullname]))) + (defn handle-file-restored - [{:keys [file-id vern] :as msg}] + "Reloads the file a peer restored to an earlier version and tells the + user who restored which version, and whether their edits not saved yet + were dropped with the replaced version." + [{:keys [file-id vern session-id profile-id label created-at] :as msg}] (assert (check-file-restored-params msg) "expected valid parameters") @@ -320,9 +335,16 @@ file (dsh/lookup-file state curr-file-id) curr-vern (:vern file)] - (when (and (= file-id curr-file-id) - (not= vern curr-vern)) - (rx/of (ptk/event ::dw/reload-current-file))))))) + (if (and (= file-id curr-file-id) + (not= vern curr-vern)) + (rx/of (ptk/event ::dw/reload-current-file) + (modal/show :file-restored + {:fullname (restorer-fullname state profile-id session-id) + :label label + :created-at created-at + ;; Made on the replaced version, so the reload drops them + :unsaved (boolean (seq (dps/pending-file-changes state file)))})) + (rx/empty)))))) (def ^:private schema:handle-library-change [:map {:title "handle-library-change"} diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 48b74d7023..33719208a7 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -1529,7 +1529,8 @@ :element-type (cfh/get-selected-type objects ids) :moved moved-count})))) - (when (and (some? frame-id) (d/not-empty? changes)) + ;; Shapes that already belong to the frame leave nothing to move + (when (and (some? frame-id) (seq ids)) (rx/of (dch/commit-changes changes) (dwc/expand-collapse frame-id))) (when add-component-to-variant? diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 5e376d6a98..71b85abf58 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -77,7 +77,13 @@ (rx/mapcat (fn [{:keys [id]}] (rx/of (update-versions-state {:editing id}) - (fetch-versions)))))))))) + (fetch-versions)))) + ;; A version of the file without the unsaved changes would + ;; lose them, so none is made and the user is told why + (rx/catch (fn [cause] + (if (= :persistence (:type (ex-data cause))) + (rx/of (ntf/warn (tr "workspace.versions.create-failed-unsaved"))) + (rx/throw cause)))))))))) (defn rename-version [id label] diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index 7a0abddea5..2e5dfa1596 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -120,8 +120,7 @@ on-duplicate (fn [] - (apply st/emit! (map dd/duplicate-file files)) - (st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c file-count))))) + (st/emit! (dd/duplicate-files files))) on-delete-accept (fn [_] diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index c626b36a29..4b37d284a9 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -27,6 +27,7 @@ [app.main.ui.workspace.components-debugger :refer [components-debugger*]] [app.main.ui.workspace.context-menu :refer [context-menu*]] [app.main.ui.workspace.coordinates :as coordinates] + [app.main.ui.workspace.file-restored-modal] [app.main.ui.workspace.libraries] [app.main.ui.workspace.nudge] [app.main.ui.workspace.palette :refer [palette*]] diff --git a/frontend/src/app/main/ui/workspace/file_restored_modal.cljs b/frontend/src/app/main/ui/workspace/file_restored_modal.cljs new file mode 100644 index 0000000000..badadb4957 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/file_restored_modal.cljs @@ -0,0 +1,59 @@ +;; 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 SUBSIDIARY SL + +(ns app.main.ui.workspace.file-restored-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.time :as ct] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +(defn- restored-message + "Who restored which version: the version by its `label`, else by its + date, else as an earlier version." + [fullname label created-at] + (let [date (some-> created-at (ct/format-inst "PPp"))] + (cond + (and fullname label) (tr "workspace.file-restored.message-label" fullname label) + label (tr "workspace.file-restored.message-label-anonymous" label) + (and fullname date) (tr "workspace.file-restored.message-date" fullname date) + date (tr "workspace.file-restored.message-date-anonymous" date) + fullname (tr "workspace.file-restored.message" fullname) + :else (tr "workspace.file-restored.message-anonymous")))) + +#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]} +(mf/defc file-restored-modal* + "Tells a peer that another user restored a version of the open file, + which was reloaded, and whether their edits not saved yet were lost." + {::mf/register modal/components + ::mf/register-as :file-restored} + [{:keys [fullname label created-at unsaved]}] + (let [on-close (mf/use-fn #(st/emit! (modal/hide)))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog) + :role "dialog" + :aria-modal true + :aria-labelledby "file-restored-title"} + [:> heading* {:id "file-restored-title" + :level 2 + :typography t/title-medium + :class (stl/css :modal-title)} + (tr "workspace.file-restored.title")] + [:> text* {:as "p" :typography t/body-large} + (restored-message fullname label created-at)] + (when unsaved + [:> text* {:as "p" :typography t/body-large} + (tr "workspace.file-restored.unsaved-changes")]) + [:footer {:class (stl/css :modal-footer)} + [:> button* {:on-click on-close + :variant "primary"} + (tr "workspace.file-restored.accept")]]]])) diff --git a/frontend/src/app/main/ui/workspace/file_restored_modal.scss b/frontend/src/app/main/ui/workspace/file_restored_modal.scss new file mode 100644 index 0000000000..568841a417 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/file_restored_modal.scss @@ -0,0 +1,34 @@ +// 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 SUBSIDIARY SL + +@use "ds/_utils.scss" as *; +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend %modal-overlay-base; +} + +.modal-dialog { + @extend %modal-container-base; + + color: var(--color-foreground-secondary); + display: grid; + gap: var(--sp-s); + max-width: px2rem(482); // FIXME: This should be a token + + & > * { + margin: 0; // FIXME: This should be in reset styles + } +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-footer { + margin-block-start: var(--sp-m); + justify-self: end; +} diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index c97e3742f9..d2589d21c2 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -11,6 +11,7 @@ [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] [app.main.data.notifications :as ntf] + [app.main.data.persistence :as dps] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.router :as rt] @@ -190,3 +191,76 @@ (await (async/observe (ptk/watch (dd/initialize team-id) state stream) :on-next #(swap! processed conj (ptk/type %)))))) (t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed)))))) + +;; Scenario: the user duplicates a file whose workspace still has a change +;; waiting to be saved. Proves: the copy is requested once the save is done, +;; and only then is the user told, with a warning that the copy misses what +;; could not be saved, never replaced by the success toast. +(t/deftest ^:async duplicating-a-file-waits-for-its-pending-changes + (let [file-id (uuid/next) + commit-id (uuid/next) + calls (atom []) + answer (rx/subject) + store (ptk/store {:state {:files {} + :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}}})] + (await + (mock/with-mocks* + {dps/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (swap! calls conj [:wait file-ids]) + (rx/empty))) + rp/cmd! (mock/stub (fn [cmd _] + (swap! calls conj [cmd]) + (->> answer (rx/take 1) (rx/observe-on :async)))) + i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))} + (ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}])) + (await (async/wait-for #(= 2 (count @calls)) "the copy is requested")) + (t/is (= [[:wait #{file-id}] [:duplicate-file]] @calls)) + (t/is (nil? (:notification @store)) "nothing is told before the copy exists") + (rx/push! answer {:id (uuid/next)}) + (await (async/wait-for #(some? (:notification @store)) "the user is told")) + (await (async/settle)) + (t/is (= "translated:dashboard.duplicate-unsaved-changes" + (get-in @store [:notification :content]))) + (t/is (= :warning (get-in @store [:notification :level]))))))) + +;; Scenario: the user duplicates a file with everything saved. Proves: the +;; success toast shows once the copy exists, not when it is requested. +(t/deftest ^:async duplicating-a-saved-file-tells-once-the-copy-exists + (let [file-id (uuid/next) + answer (rx/subject) + sent (atom 0) + store (ptk/store {:state {:files {}}})] + (await + (mock/with-mocks* + {dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + rp/cmd! (mock/stub (fn [_ _] + (swap! sent inc) + (->> answer (rx/take 1) (rx/observe-on :async)))) + i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))} + (ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}])) + (await (async/wait-for #(= 1 @sent) "the copy is requested")) + (t/is (nil? (:notification @store))) + (rx/push! answer {:id (uuid/next)}) + (await (async/wait-for #(some? (:notification @store)) "the user is told")) + (t/is (= "translated:dashboard.success-duplicate-file" + (get-in @store [:notification :content]))) + (t/is (= :success (get-in @store [:notification :level]))))))) + +;; Scenario: the server fails to copy the file. Proves: no success toast +;; claims a copy that does not exist. +(t/deftest ^:async a-failed-copy-tells-no-success + (let [errors (atom []) + store (ptk/store {:state {:files {}} + :on-error #(swap! errors conj %)})] + (await + (mock/with-mocks* + {dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + rp/cmd! (mock/stub (fn [_ _] + (->> (rx/throw (ex-info "copy failed" {:type :internal})) + (rx/observe-on :async)))) + i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))} + (ptk/emit! store (dd/duplicate-files [{:id (uuid/next) :name "File"}])) + (await (async/wait-for #(seq @errors) "the copy fails")) + (await (async/settle)) + (t/is (nil? (:notification @store))))))) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index 4673b13411..18277c2f37 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -454,3 +454,49 @@ selected (de/collect-export-objects state exports)] (t/is (= #{root child} (set (keys selected)))) (t/is (not (contains? selected other))))) + +;; Scenario: a shape is drawn in the browser while its file has a change not +;; saved yet. Proves: the export waits for the save first, and the user is +;; told the export includes changes the server does not have. +(t/deftest ^:async client-export-waits-for-the-save-and-warns-of-unsaved-changes + (let [file-id (uuid/next) + calls (atom []) + store (export-store (assoc (unsaved-state file-id) :features #{"render-wasm/v1"}) + (atom []))] + (await + (mock/with-mocks* + {dwp/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (swap! calls conj [:wait file-ids]) + (rx/empty))) + wasm.api/export-target (mock/stub (constantly :client)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [_] + (swap! calls conj [:export]) + (rx/empty))) + i18n/tr (mock/stub #(str "translated:" %)) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id)})) + (await (async/wait-for #(= 2 (count @calls)) "the export runs")) + (t/is (= [[:wait #{file-id}] [:export]] @calls) + "the export starts once the save is done") + (t/is (= "translated:workspace.export.unsaved-changes-included" + (get-in @store [:notification :content]))))))) + +;; Scenario: a shape is drawn in the browser with every change saved. +;; Proves: no warning is shown. +(t/deftest ^:async client-export-shows-no-warning-when-the-file-is-saved + (let [exported (atom false) + store (export-store (wasm-state) (atom []))] + (await + (mock/with-mocks* + {dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + wasm.api/export-target (mock/stub (constantly :client)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [_] + (reset! exported true) + (rx/empty))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next))})) + (await (async/wait-for #(true? @exported) "the export runs")) + (await (async/settle)) + (t/is (nil? (:notification @store))))))) diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs index aceafe6bed..7c6d362941 100644 --- a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -7,9 +7,12 @@ (ns frontend-tests.data.workspace-notifications-test (:require [app.common.data :as d] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.changes :as dch] + [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.notifications :as dwntf] [app.util.i18n :as i18n] [beicon.v2.core :as rx] @@ -50,3 +53,93 @@ (t/deftest ^:async ordinary-file-change-does-not-notify (let [events (await (watch-file-change (file-change-msg {})))] (t/is (= [::dch/commit] (mapv ptk/type events))))) + +(defn- restored-events + "Events the watch of `handle-file-restored` emits for `msg` on `state`." + [state msg] + (let [events (atom [])] + (-> (async/observe (ptk/watch (dwntf/handle-file-restored msg) state nil) + :on-next #(swap! events conj %)) + (.then (fn [_] @events))))) + +(defn- shown-modal + [events] + (some #(when (= ::modal/show-modal (ptk/type %)) + (::modal/modal (ptk/update % {}))) + events)) + +;; Scenario: a peer restores an earlier version of the open file. The file +;; reloads and a modal names the peer. Proves: the reload is not silent. +(t/deftest ^:async a-peer-restore-reloads-and-names-the-peer + (let [file-id (uuid/next) + session-id (uuid/next) + profile-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 1}} + :workspace-presence {session-id {:profile-id profile-id}} + :profiles {profile-id {:id profile-id :fullname "Ada"}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id session-id + :vern 2})) + modal (shown-modal events)] + (t/is (some #(= ::dw/reload-current-file (ptk/type %)) events)) + (t/is (= :file-restored (:type modal))) + (t/is (= "Ada" (get-in modal [:props :fullname]))) + (t/is (false? (get-in modal [:props :unsaved]))))) + +;; Scenario: a peer restores a version they named, while this tab has a +;; rename not saved yet and the restorer has no presence. Proves: the modal +;; names the restorer by profile and the version by its label, and says the +;; unsaved rename is lost. +(t/deftest ^:async a-peer-restore-names-the-version-and-the-lost-edits + (let [file-id (uuid/next) + profile-id (uuid/next) + created-at (ct/now) + commit-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 1}} + :profiles {profile-id {:id profile-id :fullname "Ada"}} + :persistence {:queue [commit-id] + :index {commit-id {:id commit-id + :file-id file-id + :file-vern 1 + :commits [{:redo-changes [{:type :mod-page}]}]}}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id (uuid/next) + :profile-id profile-id + :label "Hand-off v2" + :created-at created-at + :vern 2})) + props (:props (shown-modal events))] + (t/is (= "Ada" (:fullname props))) + (t/is (= "Hand-off v2" (:label props))) + (t/is (= created-at (:created-at props))) + (t/is (true? (:unsaved props))))) + +;; Scenario: the restoring session left before its message arrived, so it +;; has no presence. Proves: the modal still shows, without a name. +(t/deftest ^:async a-restore-from-an-unknown-session-shows-no-name + (let [file-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 1}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id (uuid/next) + :vern 2})) + modal (shown-modal events)] + (t/is (= :file-restored (:type modal))) + (t/is (nil? (get-in modal [:props :fullname]))))) + +;; Scenario: the file already shows the restored version. Proves: no reload +;; and no modal for a version the user already sees. +(t/deftest ^:async a-restore-of-the-loaded-version-does-nothing + (let [file-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 2}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id (uuid/next) + :vern 2}))] + (t/is (empty? events)))) diff --git a/frontend/test/frontend_tests/data/workspace_versions_test.cljs b/frontend/test/frontend_tests/data/workspace_versions_test.cljs index 5ec7fa87bb..c715f2dc02 100644 --- a/frontend/test/frontend_tests/data/workspace_versions_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_versions_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.data.changes :as dch] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] [app.main.data.workspace :as dw] [app.main.data.workspace.pages :as dwpg] @@ -27,6 +28,7 @@ (let [file-id (uuid/next) resolved (atom []) rejected (atom []) + emitted (atom []) requests (atom []) state {:current-file-id file-id} event (case operation @@ -42,12 +44,22 @@ rx/timeout (mock/stub (fn [_ fallback source] (if (= failure :timeout) fallback source)))] (->> (ptk/watch event state (rx/empty)) - (rx/subs! (fn [_]) #(swap! rejected conj %))) + (rx/subs! #(swap! emitted conj %) #(swap! rejected conj %))) (t/is (empty? @requests) "Do not create a version without saving") (t/is (empty? @resolved) "Do not resolve the plugin promise on failure") - (t/is (= 1 (count @rejected))) - (t/is (= (if (= failure :error) :save-permission-denied :save-timeout) - (:code (ex-data (first @rejected)))))))))) + (if (= operation :create) + ;; The save failure is reported on its own; the user is told + ;; why the version is missing instead + (let [notice (some #(when (= ::ntf/show (ptk/type %)) + (:notification (ptk/update % {}))) + @emitted)] + (t/is (empty? @rejected)) + (t/is (= "workspace.versions.create-failed-unsaved" (:content notice))) + (t/is (= :warning (:level notice)))) + (do + (t/is (= 1 (count @rejected))) + (t/is (= (if (= failure :error) :save-permission-denied :save-timeout) + (:code (ex-data (first @rejected)))))))))))) (t/deftest leaving-the-workspace-clears-the-preview-flags (let [team-id (uuid/next) diff --git a/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs b/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs new file mode 100644 index 0000000000..7dab714ad3 --- /dev/null +++ b/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs @@ -0,0 +1,65 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.logic.move-shapes-to-frame-test + "Tests for the reparenting step that ends a move (`dwt/move-shapes-to-frame`)." + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.workspace.transforms :as dwt] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each {:before cthi/reset-idmap!}) + +(defn- count-commits + "Runs `event` on a store of `file` and calls `check` with the number of + commits it made and the resulting file." + [done file event check] + (let [store (ths/setup-store file) + commits (atom 0) + sub (->> (ptk/input-stream store) + (rx/filter dch/commit?) + (rx/subs! (fn [_] (swap! commits inc))))] + (ths/run-store store done [event] + (fn [state] + (rx/dispose! sub) + (check @commits (ths/get-file-from-state state)))))) + +;; Scenario: a move ends over the frame the shape already belongs to. +;; Proves: nothing changes parent, so no commit (and no save) is made. +(t/deftest moving-a-shape-within-its-frame-commits-nothing + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1)) + rect (cths/get-shape file :rect1)] + (count-commits done file + (dwt/move-shapes-to-frame [(:id rect)] uuid/zero nil nil) + (fn [commits _] + (t/is (zero? commits))))))) + +;; Scenario: a move ends over another board. Proves: the shape is moved into +;; it with one commit. +(t/deftest moving-a-shape-into-a-board-commits-the-new-parent + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1) + (ctho/add-rect :rect1)) + rect (cths/get-shape file :rect1) + frame (cths/get-shape file :frame1)] + (count-commits done file + (dwt/move-shapes-to-frame [(:id rect)] (:id frame) nil nil) + (fn [commits file'] + (t/is (= 1 commits)) + (t/is (= (:id frame) (:parent-id (cths/get-shape file' :rect1))))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 0f8903d5e3..ea6c7f9a55 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -37,6 +37,7 @@ [frontend-tests.data.workspace-libraries-sync-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] + [frontend-tests.data.workspace-notifications-test] [frontend-tests.data.workspace-pages-test] [frontend-tests.data.workspace-path-edition-test] [frontend-tests.data.workspace-reflow-test] @@ -61,6 +62,7 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.ignore-tree-test] + [frontend-tests.logic.move-shapes-to-frame-test] [frontend-tests.logic.multi-variant-switch-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] @@ -139,6 +141,7 @@ [frontend-tests.ui.drawarea-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.error-boundary-test] + [frontend-tests.ui.file-restored-modal-test] [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.history-test] [frontend-tests.ui.inspect-geometry-test] @@ -227,6 +230,7 @@ 'frontend-tests.data.workspace-libraries-sync-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test + 'frontend-tests.data.workspace-notifications-test 'frontend-tests.data.workspace-pages-test 'frontend-tests.data.workspace-path-edition-test 'frontend-tests.data.workspace-reflow-test @@ -251,6 +255,7 @@ 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test 'frontend-tests.logic.ignore-tree-test + 'frontend-tests.logic.move-shapes-to-frame-test 'frontend-tests.logic.multi-variant-switch-test 'frontend-tests.logic.nudge-selected-shapes-test 'frontend-tests.logic.path-actions-test @@ -328,6 +333,7 @@ 'frontend-tests.ui.drawarea-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.error-boundary-test + 'frontend-tests.ui.file-restored-modal-test 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.history-test 'frontend-tests.ui.inspect-geometry-test diff --git a/frontend/test/frontend_tests/ui/file_restored_modal_test.cljs b/frontend/test/frontend_tests/ui/file_restored_modal_test.cljs new file mode 100644 index 0000000000..169baa4573 --- /dev/null +++ b/frontend/test/frontend_tests/ui/file_restored_modal_test.cljs @@ -0,0 +1,34 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.file-restored-modal-test + (:require + [app.common.time :as ct] + [app.main.ui.workspace.file-restored-modal :as frm] + [app.util.i18n :as i18n] + [cljs.test :as t :include-macros true])) + +(defn- message + "The translation key and arguments of the restore message." + [fullname label created-at] + (with-redefs [i18n/tr (fn ([key] [key]) ([key & args] (into [key] args)))] + (#'frm/restored-message fullname label created-at))) + +(t/deftest the-message-names-the-version-by-its-label-or-its-date + (let [created-at (ct/now) + date (ct/format-inst created-at "PPp")] + (t/is (= ["workspace.file-restored.message-label" "Ada" "Hand-off v2"] + (message "Ada" "Hand-off v2" created-at))) + (t/is (= ["workspace.file-restored.message-label-anonymous" "Hand-off v2"] + (message nil "Hand-off v2" created-at))) + (t/is (= ["workspace.file-restored.message-date" "Ada" date] + (message "Ada" nil created-at))) + (t/is (= ["workspace.file-restored.message-date-anonymous" date] + (message nil nil created-at))) + (t/is (= ["workspace.file-restored.message" "Ada"] + (message "Ada" nil nil))) + (t/is (= ["workspace.file-restored.message-anonymous"] + (message nil nil nil))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fa243eab0c..1bad8e40ec 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -260,11 +260,11 @@ msgstr "" msgid "dasboard.team-hero.title" msgstr "Team up!" -#: src/app/main/ui/dashboard/file_menu.cljs:234 +#: src/app/main/ui/dashboard/file_menu.cljs:233 msgid "dashboard-restore-file-confirmation.description" msgstr "You're going to restore %s." -#: src/app/main/ui/dashboard/file_menu.cljs:233 +#: src/app/main/ui/dashboard/file_menu.cljs:232 msgid "dashboard-restore-file-confirmation.title" msgstr "Restore file" @@ -272,7 +272,7 @@ msgstr "Restore file" msgid "dashboard.add-file" msgstr "Add file" -#: src/app/main/ui/dashboard/file_menu.cljs:324, src/app/main/ui/workspace/main_menu.cljs:701 +#: src/app/main/ui/dashboard/file_menu.cljs:323, src/app/main/ui/workspace/main_menu.cljs:701 msgid "dashboard.add-shared" msgstr "Add as Shared Library" @@ -364,7 +364,7 @@ msgstr "Clear search" msgid "dashboard.clear-trash-button" msgstr "Clear trash" -#: src/app/main/data/dashboard.cljs:317, src/app/main/data/dashboard.cljs:553, src/app/main/data/workspace/pages.cljs:206 +#: src/app/main/data/dashboard.cljs:318, src/app/main/data/dashboard.cljs:568, src/app/main/data/workspace/pages.cljs:206 msgid "dashboard.copy-suffix" msgstr "(copy)" @@ -390,15 +390,15 @@ msgstr "" "Are you sure you want to delete forever all your deleted projects and " "files? This is a non reversible action." -#: src/app/main/ui/dashboard/file_menu.cljs:247 +#: src/app/main/ui/dashboard/file_menu.cljs:246 msgid "dashboard.delete-file-forever-confirmation.description" msgstr "Are you sure you want to delete forever %s? This is a non reversible action." -#: src/app/main/data/dashboard.cljs:810 +#: src/app/main/data/dashboard.cljs:842 msgid "dashboard.delete-files-success-notification" msgstr "%s files have been successfully deleted." -#: src/app/main/ui/dashboard/deleted.cljs:55, src/app/main/ui/dashboard/deleted.cljs:57, src/app/main/ui/dashboard/deleted.cljs:273, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:246, src/app/main/ui/dashboard/file_menu.cljs:248 +#: src/app/main/ui/dashboard/deleted.cljs:55, src/app/main/ui/dashboard/deleted.cljs:57, src/app/main/ui/dashboard/deleted.cljs:273, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:245, src/app/main/ui/dashboard/file_menu.cljs:247 msgid "dashboard.delete-forever-confirmation.title" msgstr "Delete forever" @@ -413,7 +413,7 @@ msgstr "" "it forever an all of the files contained in it. This is a non reversible " "action." -#: src/app/main/data/dashboard.cljs:809, src/app/main/data/dashboard.cljs:843 +#: src/app/main/data/dashboard.cljs:841, src/app/main/data/dashboard.cljs:875 msgid "dashboard.delete-success-notification" msgstr "%s has been successfully deleted." @@ -429,18 +429,28 @@ msgstr "Your trash is empty. Deleted files and projects will appear here." msgid "dashboard.deleted.will-be-deleted-at" msgstr "Will be deleted %s" -#: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:738 +#: src/app/main/ui/dashboard/file_menu.cljs:328, src/app/main/ui/workspace/main_menu.cljs:738 msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" -#: src/app/main/ui/dashboard/file_menu.cljs:302, src/app/main/ui/dashboard/project_menu.cljs:94 +#: src/app/main/ui/dashboard/file_menu.cljs:301, src/app/main/ui/dashboard/project_menu.cljs:94 msgid "dashboard.duplicate" msgstr "Duplicate" -#: src/app/main/ui/dashboard/file_menu.cljs:269 +#: src/app/main/ui/dashboard/file_menu.cljs:268 msgid "dashboard.duplicate-multi" msgstr "Duplicate %s files" +#: src/app/main/data/dashboard.cljs:549 +msgid "dashboard.duplicate-unsaved-changes" +msgid_plural "dashboard.duplicate-unsaved-changes" +msgstr[0] "" +"Your file has been duplicated, but the copy doesn't include your latest " +"changes because they aren't saved yet." +msgstr[1] "" +"Your files have been duplicated, but the copies don't include your latest " +"changes because they aren't saved yet." + #: src/app/main/ui/dashboard/files.cljs:243, src/app/main/ui/dashboard/projects.cljs:318 msgid "dashboard.empty-placeholder-drafts-subtitle" msgstr "Once a project member creates a draft, it will be displayed here." @@ -501,27 +511,27 @@ msgstr "Import a .penpot file" msgid "dashboard.empty-project.start" msgstr "Start building amazing stuff" -#: src/app/main/data/dashboard.cljs:813 +#: src/app/main/data/dashboard.cljs:845 msgid "dashboard.errors.error-on-delete-files" msgstr "There was an error while deleting the files." -#: src/app/main/data/dashboard.cljs:846 +#: src/app/main/data/dashboard.cljs:878 msgid "dashboard.errors.error-on-delete-project" msgstr "There was an error while deleting the project %s." -#: src/app/main/data/dashboard.cljs:941, src/app/main/ui/dashboard/file_menu.cljs:227 +#: src/app/main/data/dashboard.cljs:973, src/app/main/ui/dashboard/file_menu.cljs:226 msgid "dashboard.errors.error-on-restore-file" msgstr "There was an error while restoring the file %s." -#: src/app/main/data/dashboard.cljs:942 +#: src/app/main/data/dashboard.cljs:974 msgid "dashboard.errors.error-on-restore-files" msgstr "There was an error while restoring the files." -#: src/app/main/data/dashboard.cljs:974 +#: src/app/main/data/dashboard.cljs:1006 msgid "dashboard.errors.error-on-restoring-project" msgstr "There was an error while restoring the project %s and its files." -#: src/app/main/ui/dashboard/file_menu.cljs:279 +#: src/app/main/ui/dashboard/file_menu.cljs:278 msgid "dashboard.export-binary-multi" msgstr "Download %s Penpot files (.penpot)" @@ -559,13 +569,13 @@ msgstr "There are no elements with export settings." msgid "dashboard.export-shapes.title" msgstr "Export selection" -#: src/app/main/ui/dashboard/file_menu.cljs:263 +#: src/app/main/ui/dashboard/file_menu.cljs:262 msgid "dashboard.file-menu.delete-files-permanently-option" msgid_plural "dashboard.file-menu.delete-files-permanently-option" msgstr[0] "Delete file" msgstr[1] "Delete files" -#: src/app/main/ui/dashboard/file_menu.cljs:261 +#: src/app/main/ui/dashboard/file_menu.cljs:260 msgid "dashboard.file-menu.restore-files-option" msgid_plural "dashboard.file-menu.restore-files-option" msgstr[0] "Restore file" @@ -907,11 +917,11 @@ msgstr "loading your fonts …" msgid "dashboard.mark-all-as-read.success" msgstr "Marked all notifications as read" -#: src/app/main/ui/dashboard/file_menu.cljs:308, src/app/main/ui/dashboard/project_menu.cljs:101 +#: src/app/main/ui/dashboard/file_menu.cljs:307, src/app/main/ui/dashboard/project_menu.cljs:101 msgid "dashboard.move-to" msgstr "Move to" -#: src/app/main/ui/dashboard/file_menu.cljs:272 +#: src/app/main/ui/dashboard/file_menu.cljs:271 msgid "dashboard.move-to-multi" msgstr "Move %s files to" @@ -923,7 +933,7 @@ msgstr "Move to other team" msgid "dashboard.new-file" msgstr "+ New File" -#: src/app/main/data/dashboard.cljs:524, src/app/main/data/dashboard.cljs:636 +#: src/app/main/data/dashboard.cljs:525, src/app/main/data/dashboard.cljs:668 msgid "dashboard.new-file-prefix" msgstr "New File" @@ -931,7 +941,7 @@ msgstr "New File" msgid "dashboard.new-project" msgstr "+ New project" -#: src/app/main/data/dashboard.cljs:269, src/app/main/data/dashboard.cljs:639 +#: src/app/main/data/dashboard.cljs:270, src/app/main/data/dashboard.cljs:671 msgid "dashboard.new-project-prefix" msgstr "New Project" @@ -997,7 +1007,7 @@ msgstr "View notifications" msgid "dashboard.num-of-members" msgstr "%s members" -#: src/app/main/ui/dashboard/file_menu.cljs:294 +#: src/app/main/ui/dashboard/file_menu.cljs:293 msgid "dashboard.open-in-new-tab" msgstr "Open file in a new tab" @@ -1013,7 +1023,7 @@ msgstr "Order by role" msgid "dashboard.order-invitations-by-status" msgstr "Order by status" -#: src/app/main/data/dashboard.cljs:721 +#: src/app/main/data/dashboard.cljs:753 msgid "dashboard.organization-deleted" msgstr "The %s organization has been deleted." @@ -1061,19 +1071,19 @@ msgstr "Cannot parse the plugin manifest" msgid "dashboard.plugins.try-plugin" msgstr "Try plugin: " -#: src/app/main/data/dashboard.cljs:754 +#: src/app/main/data/dashboard.cljs:786 msgid "dashboard.progress-notification.deleting-files" msgstr "Deleting files…" -#: src/app/main/data/dashboard.cljs:875 +#: src/app/main/data/dashboard.cljs:907 msgid "dashboard.progress-notification.restoring-files" msgstr "Restoring files…" -#: src/app/main/data/dashboard.cljs:755 +#: src/app/main/data/dashboard.cljs:787 msgid "dashboard.progress-notification.slow-delete" msgstr "Deletion unexpectedly slow" -#: src/app/main/data/dashboard.cljs:876 +#: src/app/main/data/dashboard.cljs:908 msgid "dashboard.progress-notification.slow-restore" msgstr "Restore unexpectedly slow" @@ -1101,7 +1111,7 @@ msgstr "Restore all projects and files" msgid "dashboard.restore-all-deleted-button" msgstr "Restore All" -#: src/app/main/data/dashboard.cljs:935 +#: src/app/main/data/dashboard.cljs:967 msgid "dashboard.restore-files-success-notification" msgstr "%s files have been successfully restored." @@ -1117,7 +1127,7 @@ msgstr "You're going to restore %s project and all the files contained in it." msgid "dashboard.restore-project-confirmation.title" msgstr "Restore Project" -#: src/app/main/data/dashboard.cljs:907, src/app/main/data/dashboard.cljs:934, src/app/main/data/dashboard.cljs:971, src/app/main/ui/dashboard/file_menu.cljs:224 +#: src/app/main/data/dashboard.cljs:939, src/app/main/data/dashboard.cljs:966, src/app/main/data/dashboard.cljs:1003, src/app/main/ui/dashboard/file_menu.cljs:223 msgid "dashboard.restore-success-notification" msgstr "%s has been successfully restored." @@ -1267,7 +1277,7 @@ msgstr "Show all files" msgid "dashboard.show-version-history" msgstr "Version history" -#: src/app/main/ui/dashboard/file_menu.cljs:129 +#: src/app/main/ui/dashboard/file_menu.cljs:128 msgid "dashboard.success-delete-file" msgid_plural "dashboard.success-delete-file" msgstr[0] "Your file has been deleted successfully" @@ -1277,7 +1287,7 @@ msgstr[1] "Your files have been deleted successfully" msgid "dashboard.success-delete-project" msgstr "Your project has been deleted successfully" -#: src/app/main/ui/dashboard/file_menu.cljs:124 +#: src/app/main/data/dashboard.cljs:553 msgid "dashboard.success-duplicate-file" msgid_plural "dashboard.success-delete-file" msgstr[0] "Your file has been duplicated successfully" @@ -1287,11 +1297,11 @@ msgstr[1] "Your files have been duplicated successfully" msgid "dashboard.success-duplicate-project" msgstr "Your project has been duplicated successfully" -#: src/app/main/ui/dashboard/file_menu.cljs:162, src/app/main/ui/dashboard/grid.cljs:729, src/app/main/ui/dashboard/sidebar.cljs:130 +#: src/app/main/ui/dashboard/file_menu.cljs:161, src/app/main/ui/dashboard/grid.cljs:729, src/app/main/ui/dashboard/sidebar.cljs:130 msgid "dashboard.success-move-file" msgstr "Your file has been moved successfully" -#: src/app/main/ui/dashboard/file_menu.cljs:161 +#: src/app/main/ui/dashboard/file_menu.cljs:160 msgid "dashboard.success-move-files" msgstr "Your files have been moved successfully" @@ -1383,7 +1393,7 @@ msgstr "" msgid "dashboard.type-something" msgstr "Type to search results" -#: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:693 +#: src/app/main/ui/dashboard/file_menu.cljs:321, src/app/main/ui/workspace/main_menu.cljs:693 msgid "dashboard.unpublish-shared" msgstr "Unpublish Library" @@ -1391,7 +1401,7 @@ msgstr "Unpublish Library" msgid "dashboard.update-settings" msgstr "Update settings" -#: src/app/main/data/dashboard.cljs:689 +#: src/app/main/data/dashboard.cljs:721 #, backend msgid "dashboard.user-no-longer-belong-organization" msgstr "You are no longer a member of the organization %s" @@ -1638,7 +1648,7 @@ msgstr "The email «%s» has been reported as spam or permanently bounce." msgid "errors.export-failed" msgstr "The export failed. Try again in a moment." -#: src/app/main/data/exports/assets.cljs:372 +#: src/app/main/data/exports/assets.cljs:377 msgid "errors.export-too-large" msgstr "Too large to export, try a smaller scale" @@ -1693,7 +1703,7 @@ msgstr "Something wrong has happened with the web worker." msgid "errors.invalid-color" msgstr "Invalid color" -#: src/app/plugins/utils.cljs:517, ../common/src/app/common/schema/messages.cljc:33, ../common/src/app/common/schema/messages.cljc:87 +#: src/app/plugins/utils.cljs:537, ../common/src/app/common/schema/messages.cljc:33, ../common/src/app/common/schema/messages.cljc:87 msgid "errors.invalid-data" msgstr "Invalid data" @@ -1832,17 +1842,17 @@ msgstr[1] "" "You can no longer edit this file, so %s changes could not be saved. Reload " "the page to see the file as it is now." -#: src/app/main/data/persistence.cljs:647 +#: src/app/main/data/persistence.cljs:680 msgid "errors.save-rejected-changes-undone" msgstr "" "Some of your latest changes couldn't be saved and were undone. The rest of " "your work is saved." -#: src/app/main/data/persistence.cljs:526 +#: src/app/main/data/persistence.cljs:528 msgid "errors.save-retrying" msgstr "Connection lost. Retrying to save your changes." -#: src/app/main/data/persistence.cljs:525 +#: src/app/main/data/persistence.cljs:527 msgid "errors.save-retrying-busy" msgstr "The server is busy. Retrying to save your changes." @@ -2976,7 +2986,7 @@ msgstr "Contact support" msgid "labels.contact-us" msgstr "Contact us" -#: src/app/main/ui/auth/login.cljs:209, src/app/main/ui/dashboard/deleted.cljs:47, src/app/main/ui/dashboard/deleted.cljs:287, src/app/main/ui/dashboard/file_menu.cljs:235, src/app/main/ui/dashboard/import.cljs:764, src/app/main/ui/dashboard/team.cljs:907, src/app/main/ui/exports/files.cljs:452, src/app/main/ui/settings/subscription.cljs:337, src/app/main/ui/settings/subscription.cljs:371, src/app/main/ui/settings/subscription.cljs:923 +#: src/app/main/ui/auth/login.cljs:209, src/app/main/ui/dashboard/deleted.cljs:47, src/app/main/ui/dashboard/deleted.cljs:287, src/app/main/ui/dashboard/file_menu.cljs:234, src/app/main/ui/dashboard/import.cljs:764, src/app/main/ui/dashboard/team.cljs:907, src/app/main/ui/exports/files.cljs:452, src/app/main/ui/settings/subscription.cljs:337, src/app/main/ui/settings/subscription.cljs:371, src/app/main/ui/settings/subscription.cljs:923 msgid "labels.continue" msgstr "Continue" @@ -3024,7 +3034,7 @@ msgstr "Dashboard" msgid "labels.default" msgstr "Default" -#: src/app/main/ui/dashboard/file_menu.cljs:335, src/app/main/ui/dashboard/fonts.cljs:303, src/app/main/ui/dashboard/fonts.cljs:380, src/app/main/ui/dashboard/fonts.cljs:414, src/app/main/ui/dashboard/import.cljs:319, src/app/main/ui/dashboard/project_menu.cljs:116, src/app/main/ui/dashboard/team.cljs:1454, src/app/main/ui/settings/integrations.cljs:376, src/app/main/ui/settings/profile.cljs:123, src/app/main/ui/settings/profile.cljs:137, src/app/main/ui/settings/profile.cljs:138, src/app/main/ui/workspace/sidebar/assets/common.cljs:231, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:228, src/app/main/ui/workspace/sidebar/versions.cljs:257, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:353, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:111, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:394 +#: src/app/main/ui/dashboard/file_menu.cljs:334, src/app/main/ui/dashboard/fonts.cljs:303, src/app/main/ui/dashboard/fonts.cljs:380, src/app/main/ui/dashboard/fonts.cljs:414, src/app/main/ui/dashboard/import.cljs:319, src/app/main/ui/dashboard/project_menu.cljs:116, src/app/main/ui/dashboard/team.cljs:1454, src/app/main/ui/settings/integrations.cljs:376, src/app/main/ui/settings/profile.cljs:123, src/app/main/ui/settings/profile.cljs:137, src/app/main/ui/settings/profile.cljs:138, src/app/main/ui/workspace/sidebar/assets/common.cljs:231, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:228, src/app/main/ui/workspace/sidebar/versions.cljs:257, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:353, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:111, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:394 msgid "labels.delete" msgstr "Delete" @@ -3040,7 +3050,7 @@ msgstr "Delete thread" msgid "labels.delete-invitation" msgstr "Delete invitation" -#: src/app/main/ui/dashboard/file_menu.cljs:289 +#: src/app/main/ui/dashboard/file_menu.cljs:288 msgid "labels.delete-multi-files" msgstr "Delete %s files" @@ -3096,7 +3106,7 @@ msgstr "Empty" msgid "labels.error" msgstr "Error" -#: src/app/main/data/workspace/versions.cljs:314 +#: src/app/main/data/workspace/versions.cljs:320 msgid "labels.exit" msgstr "Exit" @@ -3236,7 +3246,7 @@ msgstr "Learning Center" msgid "labels.libraries-and-templates" msgstr "Libraries & Templates" -#: src/app/main/ui/auth/verify_token.cljs:137, src/app/main/ui/dashboard/fonts.cljs:519, src/app/main/ui/dashboard/grid.cljs:127, src/app/main/ui/dashboard/grid.cljs:143, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/mcp_authorize.cljs:140, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:694, src/app/main/ui/viewer.cljs:652, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:263, src/app/main/ui/workspace.cljs:132 +#: src/app/main/ui/auth/verify_token.cljs:137, src/app/main/ui/dashboard/fonts.cljs:519, src/app/main/ui/dashboard/grid.cljs:127, src/app/main/ui/dashboard/grid.cljs:143, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/mcp_authorize.cljs:140, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:694, src/app/main/ui/viewer.cljs:652, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:263, src/app/main/ui/workspace.cljs:133 msgid "labels.loading" msgstr "Loading…" @@ -3440,7 +3450,7 @@ msgstr "Remove" msgid "labels.remove-member" msgstr "Remove member" -#: src/app/main/ui/dashboard/file_menu.cljs:298, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:204, src/app/main/ui/dashboard/project_menu.cljs:90, src/app/main/ui/workspace/sidebar/assets/groups.cljs:187, src/app/main/ui/workspace/sidebar/versions.cljs:228, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:76, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:101, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 +#: src/app/main/ui/dashboard/file_menu.cljs:297, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:204, src/app/main/ui/dashboard/project_menu.cljs:90, src/app/main/ui/workspace/sidebar/assets/groups.cljs:187, src/app/main/ui/workspace/sidebar/versions.cljs:228, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:76, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:101, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 msgid "labels.rename" msgstr "Rename" @@ -3476,7 +3486,7 @@ msgstr "Resend" msgid "labels.resend-invitation" msgstr "Resend invitation" -#: src/app/main/data/workspace/versions.cljs:246, src/app/main/data/workspace/versions.cljs:319, src/app/main/ui/workspace/sidebar/versions.cljs:238 +#: src/app/main/data/workspace/versions.cljs:252, src/app/main/data/workspace/versions.cljs:325, src/app/main/ui/workspace/sidebar/versions.cljs:238 msgid "labels.restore" msgstr "Restore" @@ -3643,7 +3653,7 @@ msgstr "Unknown error" msgid "labels.unlock" msgstr "Unlock" -#: src/app/main/ui/dashboard/file_menu.cljs:283 +#: src/app/main/ui/dashboard/file_menu.cljs:282 msgid "labels.unpublish-multi-files" msgstr "Unpublish %s files" @@ -3997,27 +4007,27 @@ msgstr "Are you sure you want to delete this annotation?" msgid "modals.delete-component-annotation.title" msgstr "Delete annotation" -#: src/app/main/ui/dashboard/file_menu.cljs:155 +#: src/app/main/ui/dashboard/file_menu.cljs:154 msgid "modals.delete-file-confirm.accept" msgstr "Delete file" -#: src/app/main/ui/dashboard/file_menu.cljs:154 +#: src/app/main/ui/dashboard/file_menu.cljs:153 msgid "modals.delete-file-confirm.message" msgstr "Are you sure you want to delete this file?" -#: src/app/main/ui/dashboard/file_menu.cljs:153 +#: src/app/main/ui/dashboard/file_menu.cljs:152 msgid "modals.delete-file-confirm.title" msgstr "Deleting file" -#: src/app/main/ui/dashboard/file_menu.cljs:149 +#: src/app/main/ui/dashboard/file_menu.cljs:148 msgid "modals.delete-file-multi-confirm.accept" msgstr "Delete files" -#: src/app/main/ui/dashboard/file_menu.cljs:148 +#: src/app/main/ui/dashboard/file_menu.cljs:147 msgid "modals.delete-file-multi-confirm.message" msgstr "Are you sure you want to delete %s files?" -#: src/app/main/ui/dashboard/file_menu.cljs:147 +#: src/app/main/ui/dashboard/file_menu.cljs:146 msgid "modals.delete-file-multi-confirm.title" msgstr "Deleting %s files" @@ -4939,15 +4949,15 @@ msgstr "Start designing" msgid "options.deleted-token-with-name" msgstr "{%s} token does not exist or has been deleted." -#: src/app/plugins/utils.cljs:555 +#: src/app/plugins/utils.cljs:575 msgid "plugins.validation.invalid-value" msgstr "expected %s, got %s (%s)" -#: src/app/plugins/utils.cljs:569 +#: src/app/plugins/utils.cljs:589 msgid "plugins.validation.message" msgstr "Field %s is invalid: %s" -#: src/app/plugins/utils.cljs:558 +#: src/app/plugins/utils.cljs:578 msgid "plugins.validation.received-value" msgstr "got %s (%s)" @@ -6255,7 +6265,7 @@ msgstr "Webhooks - %s - Penpot" msgid "title.viewer" msgstr "%s - View mode - Penpot" -#: src/app/main/ui/workspace.cljs:240 +#: src/app/main/ui/workspace.cljs:241 msgid "title.workspace" msgstr "%s - Penpot" @@ -6683,12 +6693,66 @@ msgstr "Remove guide" msgid "workspace.debug.title" msgstr "Debugging tools" -#: src/app/main/data/exports/assets.cljs:47 +#: src/app/main/data/exports/assets.cljs:388, src/app/main/data/exports/assets.cljs:564 msgid "workspace.export.unsaved-changes" msgstr "" "Some of your latest changes are not saved yet, so this export may not " "include them." +#: src/app/main/data/exports/assets.cljs:370, src/app/main/data/exports/assets.cljs:612 +msgid "workspace.export.unsaved-changes-included" +msgstr "Some of your latest changes are not saved yet. This export includes them." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:59 +msgid "workspace.file-restored.accept" +msgstr "Got it" + +#: src/app/main/ui/workspace/file_restored_modal.cljs:30 +msgid "workspace.file-restored.message" +msgstr "" +"%s restored an earlier version of this file. Changes made after it are no " +"longer in the file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:31 +msgid "workspace.file-restored.message-anonymous" +msgstr "" +"A teammate restored an earlier version of this file. Changes made after it " +"are no longer in the file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:28 +msgid "workspace.file-restored.message-date" +msgstr "" +"%s restored the version from %s. Changes made after it are no longer in the " +"file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:29 +msgid "workspace.file-restored.message-date-anonymous" +msgstr "" +"A teammate restored the version from %s. Changes made after it are no " +"longer in the file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:26 +msgid "workspace.file-restored.message-label" +msgstr "" +"%s restored the version “%s”. Changes made after it are no longer in the " +"file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:27 +msgid "workspace.file-restored.message-label-anonymous" +msgstr "" +"A teammate restored the version “%s”. Changes made after it are no longer " +"in the file." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:50 +msgid "workspace.file-restored.title" +msgstr "This file was restored" + +#: src/app/main/ui/workspace/file_restored_modal.cljs:55 +msgid "workspace.file-restored.unsaved-changes" +msgstr "" +"Your latest changes couldn't be saved because a version of the file was " +"restored." + #: src/app/main/ui/workspace/sidebar/layers.cljs:924 msgid "workspace.focus.focus-mode" msgstr "Focus mode" @@ -7231,7 +7295,7 @@ msgstr "UPDATES" msgid "workspace.notification-pill.detail" msgstr "Details" -#: src/app/main/data/workspace/notifications.cljs:282 +#: src/app/main/data/workspace/notifications.cljs:283 msgid "workspace.notifications.file-repaired" msgstr "" "We fixed some issues in this file. You can find a copy of the original " @@ -9805,7 +9869,7 @@ msgstr "typography asset" msgid "workspace.undo.entry.unknown" msgstr "Operation over %s" -#: src/app/main/data/workspace/libraries.cljs:1500, src/app/main/data/workspace/versions.cljs:241 +#: src/app/main/data/workspace/libraries.cljs:1500, src/app/main/data/workspace/versions.cljs:247 msgid "workspace.updates.dismiss" msgstr "Dismiss" @@ -9825,7 +9889,7 @@ msgstr "Update" msgid "workspace.versions.autosaved.entry" msgstr "%s autosave versions" -#: src/app/main/data/workspace/versions.cljs:301, src/app/main/ui/workspace/sidebar/versions.cljs:300 +#: src/app/main/data/workspace/versions.cljs:307, src/app/main/ui/workspace/sidebar/versions.cljs:300 msgid "workspace.versions.autosaved.version" msgstr "Autosaved %s" @@ -9845,6 +9909,10 @@ msgstr "Restore version" msgid "workspace.versions.button.save" msgstr "Save version" +#: src/app/main/data/workspace/versions.cljs:85 +msgid "workspace.versions.create-failed-unsaved" +msgstr "The version was not created because your changes could not be saved." + #: src/app/main/ui/workspace/sidebar/versions.cljs:434 msgid "workspace.versions.empty" msgstr "There are no versions yet" @@ -9873,15 +9941,15 @@ msgstr "%s's versions" msgid "workspace.versions.loading" msgstr "Loading..." -#: src/app/main/data/workspace/versions.cljs:312 +#: src/app/main/data/workspace/versions.cljs:318 msgid "workspace.versions.preview-banner-title" msgstr "Previewing version: %s" -#: src/app/main/data/workspace/versions.cljs:306 +#: src/app/main/data/workspace/versions.cljs:312 msgid "workspace.versions.preview.unnamed" msgstr "Unnamed version" -#: src/app/main/data/workspace/versions.cljs:239 +#: src/app/main/data/workspace/versions.cljs:245 msgid "workspace.versions.restore-warning" msgstr "Do you want to restore this version?" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1d19bca823..e4c164eb1b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -266,11 +266,11 @@ msgstr "" msgid "dasboard.team-hero.title" msgstr "¡Forma equipo!" -#: src/app/main/ui/dashboard/file_menu.cljs:234 +#: src/app/main/ui/dashboard/file_menu.cljs:233 msgid "dashboard-restore-file-confirmation.description" msgstr "Vas a restaurar %s." -#: src/app/main/ui/dashboard/file_menu.cljs:233 +#: src/app/main/ui/dashboard/file_menu.cljs:232 msgid "dashboard-restore-file-confirmation.title" msgstr "Restaurar archivo" @@ -278,7 +278,7 @@ msgstr "Restaurar archivo" msgid "dashboard.add-file" msgstr "Añadir archivo" -#: src/app/main/ui/dashboard/file_menu.cljs:324, src/app/main/ui/workspace/main_menu.cljs:701 +#: src/app/main/ui/dashboard/file_menu.cljs:323, src/app/main/ui/workspace/main_menu.cljs:701 msgid "dashboard.add-shared" msgstr "Añadir como Biblioteca Compartida" @@ -370,7 +370,7 @@ msgstr "Borrar búsqueda" msgid "dashboard.clear-trash-button" msgstr "Vaciar papelera" -#: src/app/main/data/dashboard.cljs:317, src/app/main/data/dashboard.cljs:553, src/app/main/data/workspace/pages.cljs:206 +#: src/app/main/data/dashboard.cljs:318, src/app/main/data/dashboard.cljs:568, src/app/main/data/workspace/pages.cljs:206 msgid "dashboard.copy-suffix" msgstr "(copia)" @@ -396,17 +396,17 @@ msgstr "" "¿Estás seguro de que quieres eliminar para siempre todos tus proyectos y " "archivos eliminados? Esta es una acción irreversible." -#: src/app/main/ui/dashboard/file_menu.cljs:247 +#: src/app/main/ui/dashboard/file_menu.cljs:246 msgid "dashboard.delete-file-forever-confirmation.description" msgstr "" "¿Estás seguro de que quieres eliminar para siempre %s? Esta es una acción " "irreversible." -#: src/app/main/data/dashboard.cljs:810 +#: src/app/main/data/dashboard.cljs:842 msgid "dashboard.delete-files-success-notification" msgstr "%s ficheros han sido eliminados correctamente." -#: src/app/main/ui/dashboard/deleted.cljs:55, src/app/main/ui/dashboard/deleted.cljs:57, src/app/main/ui/dashboard/deleted.cljs:273, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:246, src/app/main/ui/dashboard/file_menu.cljs:248 +#: src/app/main/ui/dashboard/deleted.cljs:55, src/app/main/ui/dashboard/deleted.cljs:57, src/app/main/ui/dashboard/deleted.cljs:273, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:245, src/app/main/ui/dashboard/file_menu.cljs:247 msgid "dashboard.delete-forever-confirmation.title" msgstr "Eliminar para siempre" @@ -421,7 +421,7 @@ msgstr "" "eliminarlo para siempre junto con todos los archivos que contiene. Esta es " "una acción irreversible." -#: src/app/main/data/dashboard.cljs:809, src/app/main/data/dashboard.cljs:843 +#: src/app/main/data/dashboard.cljs:841, src/app/main/data/dashboard.cljs:875 msgid "dashboard.delete-success-notification" msgstr "%s ha sido eliminado correctamente." @@ -437,18 +437,28 @@ msgstr "Tu papelera está vacía. Los archivos y proyectos eliminados aparecerá msgid "dashboard.deleted.will-be-deleted-at" msgstr "Se eliminará %s" -#: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:738 +#: src/app/main/ui/dashboard/file_menu.cljs:328, src/app/main/ui/workspace/main_menu.cljs:738 msgid "dashboard.download-binary-file" msgstr "Descargar archivo Penpot (.penpot)" -#: src/app/main/ui/dashboard/file_menu.cljs:302, src/app/main/ui/dashboard/project_menu.cljs:94 +#: src/app/main/ui/dashboard/file_menu.cljs:301, src/app/main/ui/dashboard/project_menu.cljs:94 msgid "dashboard.duplicate" msgstr "Duplicar" -#: src/app/main/ui/dashboard/file_menu.cljs:269 +#: src/app/main/ui/dashboard/file_menu.cljs:268 msgid "dashboard.duplicate-multi" msgstr "Duplicar %s archivos" +#: src/app/main/data/dashboard.cljs:549 +msgid "dashboard.duplicate-unsaved-changes" +msgid_plural "dashboard.duplicate-unsaved-changes" +msgstr[0] "" +"Se ha duplicado tu archivo, pero la copia no incluye tus últimos cambios " +"porque aún no se han guardado." +msgstr[1] "" +"Se han duplicado tus archivos, pero las copias no incluyen tus últimos " +"cambios porque aún no se han guardado." + #: src/app/main/ui/dashboard/files.cljs:243, src/app/main/ui/dashboard/projects.cljs:318 msgid "dashboard.empty-placeholder-drafts-subtitle" msgstr "Cuando un miembro del equipo cree algún borrador, este aparecerá aquí." @@ -509,27 +519,27 @@ msgstr "Importa un fichero .penpot" msgid "dashboard.empty-project.start" msgstr "Empieza a crear cosas increíbles" -#: src/app/main/data/dashboard.cljs:813 +#: src/app/main/data/dashboard.cljs:845 msgid "dashboard.errors.error-on-delete-files" msgstr "Hubo un error al eliminar archivos." -#: src/app/main/data/dashboard.cljs:846 +#: src/app/main/data/dashboard.cljs:878 msgid "dashboard.errors.error-on-delete-project" msgstr "Hubo un error al eliminar el proyecto %s" -#: src/app/main/data/dashboard.cljs:941, src/app/main/ui/dashboard/file_menu.cljs:227 +#: src/app/main/data/dashboard.cljs:973, src/app/main/ui/dashboard/file_menu.cljs:226 msgid "dashboard.errors.error-on-restore-file" msgstr "Hubo un error al restaurar el archivo %s." -#: src/app/main/data/dashboard.cljs:942 +#: src/app/main/data/dashboard.cljs:974 msgid "dashboard.errors.error-on-restore-files" msgstr "Hubo un error al restaurar los archivos." -#: src/app/main/data/dashboard.cljs:974 +#: src/app/main/data/dashboard.cljs:1006 msgid "dashboard.errors.error-on-restoring-project" msgstr "Hubo un error al restaurar el proyecto %s y sus archivos." -#: src/app/main/ui/dashboard/file_menu.cljs:279 +#: src/app/main/ui/dashboard/file_menu.cljs:278 msgid "dashboard.export-binary-multi" msgstr "Descargar %s archivos Penpot (.penpot)" @@ -567,13 +577,13 @@ msgstr "No hay elementos con configuraciones de exportación." msgid "dashboard.export-shapes.title" msgstr "Exportar selección" -#: src/app/main/ui/dashboard/file_menu.cljs:263 +#: src/app/main/ui/dashboard/file_menu.cljs:262 msgid "dashboard.file-menu.delete-files-permanently-option" msgid_plural "dashboard.file-menu.delete-files-permanently-option" msgstr[0] "Eliminar archivo" msgstr[1] "Eliminar archivos" -#: src/app/main/ui/dashboard/file_menu.cljs:261 +#: src/app/main/ui/dashboard/file_menu.cljs:260 msgid "dashboard.file-menu.restore-files-option" msgid_plural "dashboard.file-menu.restore-files-option" msgstr[0] "Restaurar archivo" @@ -924,11 +934,11 @@ msgstr "cargando tus fuentes …" msgid "dashboard.mark-all-as-read.success" msgstr "Se han marcado todas las noficaciones como leídas" -#: src/app/main/ui/dashboard/file_menu.cljs:308, src/app/main/ui/dashboard/project_menu.cljs:101 +#: src/app/main/ui/dashboard/file_menu.cljs:307, src/app/main/ui/dashboard/project_menu.cljs:101 msgid "dashboard.move-to" msgstr "Mover a" -#: src/app/main/ui/dashboard/file_menu.cljs:272 +#: src/app/main/ui/dashboard/file_menu.cljs:271 msgid "dashboard.move-to-multi" msgstr "Mover %s archivos a" @@ -940,7 +950,7 @@ msgstr "Mover a otro equipo" msgid "dashboard.new-file" msgstr "+ Nuevo Archivo" -#: src/app/main/data/dashboard.cljs:524, src/app/main/data/dashboard.cljs:636 +#: src/app/main/data/dashboard.cljs:525, src/app/main/data/dashboard.cljs:668 msgid "dashboard.new-file-prefix" msgstr "Nuevo Archivo" @@ -948,7 +958,7 @@ msgstr "Nuevo Archivo" msgid "dashboard.new-project" msgstr "+ Nuevo proyecto" -#: src/app/main/data/dashboard.cljs:269, src/app/main/data/dashboard.cljs:639 +#: src/app/main/data/dashboard.cljs:270, src/app/main/data/dashboard.cljs:671 msgid "dashboard.new-project-prefix" msgstr "Nuevo Proyecto" @@ -1016,7 +1026,7 @@ msgstr "Ver notificaciones" msgid "dashboard.num-of-members" msgstr "%s integrantes" -#: src/app/main/ui/dashboard/file_menu.cljs:294 +#: src/app/main/ui/dashboard/file_menu.cljs:293 msgid "dashboard.open-in-new-tab" msgstr "Abrir en una pestaña nueva" @@ -1032,7 +1042,7 @@ msgstr "Ordenar por rol" msgid "dashboard.order-invitations-by-status" msgstr "Ordenar por estado" -#: src/app/main/data/dashboard.cljs:721 +#: src/app/main/data/dashboard.cljs:753 msgid "dashboard.organization-deleted" msgstr "La organización %s se ha borrado." @@ -1080,19 +1090,19 @@ msgstr "No se puede analizar el manifiest de la extensión" msgid "dashboard.plugins.try-plugin" msgstr "Prueba la extensión: " -#: src/app/main/data/dashboard.cljs:754 +#: src/app/main/data/dashboard.cljs:786 msgid "dashboard.progress-notification.deleting-files" msgstr "Eliminando archivos…" -#: src/app/main/data/dashboard.cljs:875 +#: src/app/main/data/dashboard.cljs:907 msgid "dashboard.progress-notification.restoring-files" msgstr "Restaurando archivos…" -#: src/app/main/data/dashboard.cljs:755 +#: src/app/main/data/dashboard.cljs:787 msgid "dashboard.progress-notification.slow-delete" msgstr "Eliminación inesperadamente lenta" -#: src/app/main/data/dashboard.cljs:876 +#: src/app/main/data/dashboard.cljs:908 msgid "dashboard.progress-notification.slow-restore" msgstr "Restauración inesperadamente lenta" @@ -1120,7 +1130,7 @@ msgstr "Restaurar todos los proyectos y archivos" msgid "dashboard.restore-all-deleted-button" msgstr "Restaurar todo" -#: src/app/main/data/dashboard.cljs:935 +#: src/app/main/data/dashboard.cljs:967 msgid "dashboard.restore-files-success-notification" msgstr "%s ficheros han sido restaurado correctamente." @@ -1136,7 +1146,7 @@ msgstr "Vas a restaurar el proyecto %s y todos los archivos que contiene." msgid "dashboard.restore-project-confirmation.title" msgstr "Restaurar proyecto" -#: src/app/main/data/dashboard.cljs:907, src/app/main/data/dashboard.cljs:934, src/app/main/data/dashboard.cljs:971, src/app/main/ui/dashboard/file_menu.cljs:224 +#: src/app/main/data/dashboard.cljs:939, src/app/main/data/dashboard.cljs:966, src/app/main/data/dashboard.cljs:1003, src/app/main/ui/dashboard/file_menu.cljs:223 msgid "dashboard.restore-success-notification" msgstr "%s ha sido restaurado correctamente." @@ -1287,7 +1297,7 @@ msgstr "Ver todos los archivos" msgid "dashboard.show-version-history" msgstr "Histórico de versiones" -#: src/app/main/ui/dashboard/file_menu.cljs:129 +#: src/app/main/ui/dashboard/file_menu.cljs:128 msgid "dashboard.success-delete-file" msgid_plural "dashboard.success-delete-file" msgstr[0] "Tu archivo ha sido borrado con éxito" @@ -1297,7 +1307,7 @@ msgstr[1] "Tus archivos han sido borrado con éxito" msgid "dashboard.success-delete-project" msgstr "Tu proyecto ha sido borrado con éxito" -#: src/app/main/ui/dashboard/file_menu.cljs:124 +#: src/app/main/data/dashboard.cljs:553 msgid "dashboard.success-duplicate-file" msgid_plural "dashboard.success-delete-file" msgstr[0] "Tu archivo ha sido duplicado con éxito" @@ -1307,11 +1317,11 @@ msgstr[1] "Tus archivos han sido duplicado con éxito" msgid "dashboard.success-duplicate-project" msgstr "Tu proyecto ha sido duplicado con éxito" -#: src/app/main/ui/dashboard/file_menu.cljs:162, src/app/main/ui/dashboard/grid.cljs:729, src/app/main/ui/dashboard/sidebar.cljs:130 +#: src/app/main/ui/dashboard/file_menu.cljs:161, src/app/main/ui/dashboard/grid.cljs:729, src/app/main/ui/dashboard/sidebar.cljs:130 msgid "dashboard.success-move-file" msgstr "Tu archivo ha sido movido con éxito" -#: src/app/main/ui/dashboard/file_menu.cljs:161 +#: src/app/main/ui/dashboard/file_menu.cljs:160 msgid "dashboard.success-move-files" msgstr "Tus archivos han sido movidos con éxito" @@ -1403,7 +1413,7 @@ msgstr "" msgid "dashboard.type-something" msgstr "Escribe algo para buscar" -#: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:693 +#: src/app/main/ui/dashboard/file_menu.cljs:321, src/app/main/ui/workspace/main_menu.cljs:693 msgid "dashboard.unpublish-shared" msgstr "Despublicar Biblioteca" @@ -1411,7 +1421,7 @@ msgstr "Despublicar Biblioteca" msgid "dashboard.update-settings" msgstr "Actualizar opciones" -#: src/app/main/data/dashboard.cljs:689 +#: src/app/main/data/dashboard.cljs:721 #, backend msgid "dashboard.user-no-longer-belong-organization" msgstr "Ya no perteneces a la organización %s" @@ -1660,7 +1670,7 @@ msgstr "El email «%s» tiene reportes de spam o de rebote permanente." msgid "errors.export-failed" msgstr "La exportación ha fallado. Inténtalo de nuevo en un momento." -#: src/app/main/data/exports/assets.cljs:372 +#: src/app/main/data/exports/assets.cljs:377 msgid "errors.export-too-large" msgstr "El archivo es demasiado grande, prueba con otra escala" @@ -1715,7 +1725,7 @@ msgstr "Ha ocurrido un problema con el web worker." msgid "errors.invalid-color" msgstr "Color no válido" -#: src/app/plugins/utils.cljs:517, ../common/src/app/common/schema/messages.cljc:33, ../common/src/app/common/schema/messages.cljc:87 +#: src/app/plugins/utils.cljs:537, ../common/src/app/common/schema/messages.cljc:33, ../common/src/app/common/schema/messages.cljc:87 msgid "errors.invalid-data" msgstr "Datos no válidos" @@ -1865,17 +1875,17 @@ msgstr[1] "" "Ya no puedes editar este archivo, así que %s cambios no se han podido " "guardar. Recarga la página para ver el archivo como está ahora." -#: src/app/main/data/persistence.cljs:647 +#: src/app/main/data/persistence.cljs:680 msgid "errors.save-rejected-changes-undone" msgstr "" "Algunos de tus últimos cambios no se han podido guardar y se han deshecho. " "El resto de tu trabajo está guardado." -#: src/app/main/data/persistence.cljs:526 +#: src/app/main/data/persistence.cljs:528 msgid "errors.save-retrying" msgstr "Se ha perdido la conexión. Reintentando guardar los cambios." -#: src/app/main/data/persistence.cljs:525 +#: src/app/main/data/persistence.cljs:527 msgid "errors.save-retrying-busy" msgstr "El servidor está ocupado. Reintentando guardar tus cambios." @@ -3025,7 +3035,7 @@ msgstr "Contacta con soporte" msgid "labels.contact-us" msgstr "Contáctanos" -#: src/app/main/ui/auth/login.cljs:209, src/app/main/ui/dashboard/deleted.cljs:47, src/app/main/ui/dashboard/deleted.cljs:287, src/app/main/ui/dashboard/file_menu.cljs:235, src/app/main/ui/dashboard/import.cljs:764, src/app/main/ui/dashboard/team.cljs:907, src/app/main/ui/exports/files.cljs:452, src/app/main/ui/settings/subscription.cljs:337, src/app/main/ui/settings/subscription.cljs:371, src/app/main/ui/settings/subscription.cljs:923 +#: src/app/main/ui/auth/login.cljs:209, src/app/main/ui/dashboard/deleted.cljs:47, src/app/main/ui/dashboard/deleted.cljs:287, src/app/main/ui/dashboard/file_menu.cljs:234, src/app/main/ui/dashboard/import.cljs:764, src/app/main/ui/dashboard/team.cljs:907, src/app/main/ui/exports/files.cljs:452, src/app/main/ui/settings/subscription.cljs:337, src/app/main/ui/settings/subscription.cljs:371, src/app/main/ui/settings/subscription.cljs:923 msgid "labels.continue" msgstr "Continuar" @@ -3073,7 +3083,7 @@ msgstr "Panel" msgid "labels.default" msgstr "Valor por defecto" -#: src/app/main/ui/dashboard/file_menu.cljs:335, src/app/main/ui/dashboard/fonts.cljs:303, src/app/main/ui/dashboard/fonts.cljs:380, src/app/main/ui/dashboard/fonts.cljs:414, src/app/main/ui/dashboard/import.cljs:319, src/app/main/ui/dashboard/project_menu.cljs:116, src/app/main/ui/dashboard/team.cljs:1454, src/app/main/ui/settings/integrations.cljs:376, src/app/main/ui/settings/profile.cljs:123, src/app/main/ui/settings/profile.cljs:137, src/app/main/ui/settings/profile.cljs:138, src/app/main/ui/workspace/sidebar/assets/common.cljs:231, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:228, src/app/main/ui/workspace/sidebar/versions.cljs:257, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:353, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:111, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:394 +#: src/app/main/ui/dashboard/file_menu.cljs:334, src/app/main/ui/dashboard/fonts.cljs:303, src/app/main/ui/dashboard/fonts.cljs:380, src/app/main/ui/dashboard/fonts.cljs:414, src/app/main/ui/dashboard/import.cljs:319, src/app/main/ui/dashboard/project_menu.cljs:116, src/app/main/ui/dashboard/team.cljs:1454, src/app/main/ui/settings/integrations.cljs:376, src/app/main/ui/settings/profile.cljs:123, src/app/main/ui/settings/profile.cljs:137, src/app/main/ui/settings/profile.cljs:138, src/app/main/ui/workspace/sidebar/assets/common.cljs:231, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:228, src/app/main/ui/workspace/sidebar/versions.cljs:257, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:353, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:111, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:394 msgid "labels.delete" msgstr "Borrar" @@ -3089,7 +3099,7 @@ msgstr "Eliminar hilo" msgid "labels.delete-invitation" msgstr "Eliminar invitation" -#: src/app/main/ui/dashboard/file_menu.cljs:289 +#: src/app/main/ui/dashboard/file_menu.cljs:288 msgid "labels.delete-multi-files" msgstr "Borrar %s archivos" @@ -3145,7 +3155,7 @@ msgstr "Vacío" msgid "labels.error" msgstr "Error" -#: src/app/main/data/workspace/versions.cljs:314 +#: src/app/main/data/workspace/versions.cljs:320 msgid "labels.exit" msgstr "Salir" @@ -3287,7 +3297,7 @@ msgstr "Centro de aprendizaje" msgid "labels.libraries-and-templates" msgstr "Bibliotecas y Plantillas" -#: src/app/main/ui/auth/verify_token.cljs:137, src/app/main/ui/dashboard/fonts.cljs:519, src/app/main/ui/dashboard/grid.cljs:127, src/app/main/ui/dashboard/grid.cljs:143, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/mcp_authorize.cljs:140, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:694, src/app/main/ui/viewer.cljs:652, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:263, src/app/main/ui/workspace.cljs:132 +#: src/app/main/ui/auth/verify_token.cljs:137, src/app/main/ui/dashboard/fonts.cljs:519, src/app/main/ui/dashboard/grid.cljs:127, src/app/main/ui/dashboard/grid.cljs:143, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/mcp_authorize.cljs:140, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:694, src/app/main/ui/viewer.cljs:652, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:263, src/app/main/ui/workspace.cljs:133 msgid "labels.loading" msgstr "Cargando…" @@ -3493,7 +3503,7 @@ msgstr "Quitar" msgid "labels.remove-member" msgstr "Eliminar integrante" -#: src/app/main/ui/dashboard/file_menu.cljs:298, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:204, src/app/main/ui/dashboard/project_menu.cljs:90, src/app/main/ui/workspace/sidebar/assets/groups.cljs:187, src/app/main/ui/workspace/sidebar/versions.cljs:228, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:76, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:101, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 +#: src/app/main/ui/dashboard/file_menu.cljs:297, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:204, src/app/main/ui/dashboard/project_menu.cljs:90, src/app/main/ui/workspace/sidebar/assets/groups.cljs:187, src/app/main/ui/workspace/sidebar/versions.cljs:228, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:76, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:101, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 msgid "labels.rename" msgstr "Renombrar" @@ -3529,7 +3539,7 @@ msgstr "Reenviar" msgid "labels.resend-invitation" msgstr "Reenviar invitacion" -#: src/app/main/data/workspace/versions.cljs:246, src/app/main/data/workspace/versions.cljs:319, src/app/main/ui/workspace/sidebar/versions.cljs:238 +#: src/app/main/data/workspace/versions.cljs:252, src/app/main/data/workspace/versions.cljs:325, src/app/main/ui/workspace/sidebar/versions.cljs:238 msgid "labels.restore" msgstr "Restaurar" @@ -3696,7 +3706,7 @@ msgstr "Error desconocido" msgid "labels.unlock" msgstr "Desbloquear" -#: src/app/main/ui/dashboard/file_menu.cljs:283 +#: src/app/main/ui/dashboard/file_menu.cljs:282 msgid "labels.unpublish-multi-files" msgstr "Despublicar %s archivos" @@ -4028,27 +4038,27 @@ msgstr "¿Seguro que quieres borrar esta nota?" msgid "modals.delete-component-annotation.title" msgstr "Borrar nota" -#: src/app/main/ui/dashboard/file_menu.cljs:155 +#: src/app/main/ui/dashboard/file_menu.cljs:154 msgid "modals.delete-file-confirm.accept" msgstr "Borrar archivo" -#: src/app/main/ui/dashboard/file_menu.cljs:154 +#: src/app/main/ui/dashboard/file_menu.cljs:153 msgid "modals.delete-file-confirm.message" msgstr "¿Seguro que quieres borrar este archivo?" -#: src/app/main/ui/dashboard/file_menu.cljs:153 +#: src/app/main/ui/dashboard/file_menu.cljs:152 msgid "modals.delete-file-confirm.title" msgstr "Borrando archivo" -#: src/app/main/ui/dashboard/file_menu.cljs:149 +#: src/app/main/ui/dashboard/file_menu.cljs:148 msgid "modals.delete-file-multi-confirm.accept" msgstr "Borrar archivos" -#: src/app/main/ui/dashboard/file_menu.cljs:148 +#: src/app/main/ui/dashboard/file_menu.cljs:147 msgid "modals.delete-file-multi-confirm.message" msgstr "¿Seguro que quieres borrar %s archivos?" -#: src/app/main/ui/dashboard/file_menu.cljs:147 +#: src/app/main/ui/dashboard/file_menu.cljs:146 msgid "modals.delete-file-multi-confirm.title" msgstr "Borrando %s archivos" @@ -4986,15 +4996,15 @@ msgstr "Empezar a diseñar" msgid "options.deleted-token-with-name" msgstr "El token {%s} no existe o ha sido borrado." -#: src/app/plugins/utils.cljs:555 +#: src/app/plugins/utils.cljs:575 msgid "plugins.validation.invalid-value" msgstr "esperado %s, obtenido %s (%s)" -#: src/app/plugins/utils.cljs:569 +#: src/app/plugins/utils.cljs:589 msgid "plugins.validation.message" msgstr "El campo %s no es válido: %s" -#: src/app/plugins/utils.cljs:558 +#: src/app/plugins/utils.cljs:578 msgid "plugins.validation.received-value" msgstr "obtenido %s (%s)" @@ -6319,7 +6329,7 @@ msgstr "Webhooks - %s - Penpot" msgid "title.viewer" msgstr "%s - Modo de visualización - Penpot" -#: src/app/main/ui/workspace.cljs:240 +#: src/app/main/ui/workspace.cljs:241 msgid "title.workspace" msgstr "%s - Penpot" @@ -6750,12 +6760,68 @@ msgstr "Eliminar guía" msgid "workspace.debug.title" msgstr "Herramientas de depuración" -#: src/app/main/data/exports/assets.cljs:47 +#: src/app/main/data/exports/assets.cljs:388, src/app/main/data/exports/assets.cljs:564 msgid "workspace.export.unsaved-changes" msgstr "" "Algunos de tus últimos cambios aún no se han guardado, así que puede que " "esta exportación no los incluya." +#: src/app/main/data/exports/assets.cljs:370, src/app/main/data/exports/assets.cljs:612 +msgid "workspace.export.unsaved-changes-included" +msgstr "" +"Algunos de tus últimos cambios aún no se han guardado. Esta exportación los " +"incluye." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:59 +msgid "workspace.file-restored.accept" +msgstr "Entendido" + +#: src/app/main/ui/workspace/file_restored_modal.cljs:30 +msgid "workspace.file-restored.message" +msgstr "" +"%s ha restaurado una versión anterior de este archivo. Los cambios " +"posteriores ya no están en el archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:31 +msgid "workspace.file-restored.message-anonymous" +msgstr "" +"Un miembro del equipo ha restaurado una versión anterior de este archivo. " +"Los cambios posteriores ya no están en el archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:28 +msgid "workspace.file-restored.message-date" +msgstr "" +"%s ha restaurado la versión del %s. Los cambios posteriores ya no están en " +"el archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:29 +msgid "workspace.file-restored.message-date-anonymous" +msgstr "" +"Un miembro del equipo ha restaurado la versión del %s. Los cambios " +"posteriores ya no están en el archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:26 +msgid "workspace.file-restored.message-label" +msgstr "" +"%s ha restaurado la versión «%s». Los cambios posteriores ya no están en el " +"archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:27 +msgid "workspace.file-restored.message-label-anonymous" +msgstr "" +"Un miembro del equipo ha restaurado la versión «%s». Los cambios " +"posteriores ya no están en el archivo." + +#: src/app/main/ui/workspace/file_restored_modal.cljs:50 +msgid "workspace.file-restored.title" +msgstr "Se ha restaurado este archivo" + +#: src/app/main/ui/workspace/file_restored_modal.cljs:55 +msgid "workspace.file-restored.unsaved-changes" +msgstr "" +"No se han podido guardar tus últimos cambios porque se ha restaurado una " +"versión del archivo." + #: src/app/main/ui/workspace/sidebar/layers.cljs:924 msgid "workspace.focus.focus-mode" msgstr "Modo foco" @@ -7298,7 +7364,7 @@ msgstr "ACTUALIZACIONES" msgid "workspace.notification-pill.detail" msgstr "Detalles" -#: src/app/main/data/workspace/notifications.cljs:282 +#: src/app/main/data/workspace/notifications.cljs:283 msgid "workspace.notifications.file-repaired" msgstr "" "Hemos corregido algunos problemas de este archivo. Hay una copia de la " @@ -9894,7 +9960,7 @@ msgstr "tipografía" msgid "workspace.undo.entry.unknown" msgstr "Operación sobre %s" -#: src/app/main/data/workspace/libraries.cljs:1500, src/app/main/data/workspace/versions.cljs:241 +#: src/app/main/data/workspace/libraries.cljs:1500, src/app/main/data/workspace/versions.cljs:247 msgid "workspace.updates.dismiss" msgstr "Ignorar" @@ -9914,7 +9980,7 @@ msgstr "Actualizar" msgid "workspace.versions.autosaved.entry" msgstr "%s versiones de autoguardado" -#: src/app/main/data/workspace/versions.cljs:301, src/app/main/ui/workspace/sidebar/versions.cljs:300 +#: src/app/main/data/workspace/versions.cljs:307, src/app/main/ui/workspace/sidebar/versions.cljs:300 msgid "workspace.versions.autosaved.version" msgstr "Autoguardado %s" @@ -9934,6 +10000,10 @@ msgstr "Restaurar versión" msgid "workspace.versions.button.save" msgstr "Guardar versión" +#: src/app/main/data/workspace/versions.cljs:85 +msgid "workspace.versions.create-failed-unsaved" +msgstr "No se ha creado la versión porque tus cambios no se han podido guardar." + #: src/app/main/ui/workspace/sidebar/versions.cljs:434 msgid "workspace.versions.empty" msgstr "No hay versiones aún" @@ -9962,15 +10032,15 @@ msgstr "Versiones de %s" msgid "workspace.versions.loading" msgstr "Cargando..." -#: src/app/main/data/workspace/versions.cljs:312 +#: src/app/main/data/workspace/versions.cljs:318 msgid "workspace.versions.preview-banner-title" msgstr "Previsualizando version: %s" -#: src/app/main/data/workspace/versions.cljs:306 +#: src/app/main/data/workspace/versions.cljs:312 msgid "workspace.versions.preview.unnamed" msgstr "Versión sin nombre" -#: src/app/main/data/workspace/versions.cljs:239 +#: src/app/main/data/workspace/versions.cljs:245 msgid "workspace.versions.restore-warning" msgstr "¿Quieres restaurar esta versión?"