0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-06 14:50:20 -05:00

File history versions management

This commit is contained in:
alonso.torres 2024-10-15 12:11:32 +02:00
parent fa4f2aa5cc
commit ecb7f0a2f6
33 changed files with 1100 additions and 102 deletions

View file

@ -13,6 +13,7 @@
### :sparkles: New features
- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590)
- File history versions management [Taiga](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
### :bug: Bugs fixed

View file

@ -89,6 +89,10 @@
(dissoc ::s/problems ::s/value ::s/spec ::sm/explain)
(cond-> explain (assoc :explain explain)))})
(= code :vern-conflict)
{::yres/status 409 ;; 409 - Conflict
::yres/body data}
(= code :request-body-too-large)
{::yres/status 413 ::yres/body data}

View file

@ -415,7 +415,13 @@
:fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}
{:name "0131-mod-webhook-table"
:fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}])
:fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}
{:name "0132-mod-file-change-table"
:fn (mg/resource "app/migrations/sql/0132-mod-file-change-table.sql")}
{:name "0133-mod-file-table"
:fn (mg/resource "app/migrations/sql/0133-mod-file-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View file

@ -0,0 +1,2 @@
ALTER TABLE file_change
ADD COLUMN created_by text NOT NULL DEFAULT 'system';

View file

@ -0,0 +1,2 @@
ALTER TABLE file
ADD COLUMN vern int NOT NULL DEFAULT 0;

View file

@ -182,6 +182,7 @@
[:comment-thread-seqn [::sm/int {:min 0}]]
[:name [:string {:max 250}]]
[:revn [::sm/int {:min 0}]]
[:vern [::sm/int {:min 0}]]
[:modified-at ::dt/instant]
[:is-shared ::sm/boolean]
[:project-id ::sm/uuid]
@ -270,7 +271,7 @@
(defn get-minimal-file
[cfg id & {:as opts}]
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :data-ref-id :data-backend])]
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :vern :data-ref-id :data-backend])]
(db/get cfg :file {:id id} opts)))
(defn- get-minimal-file-with-perms
@ -280,8 +281,8 @@
(assoc mfile :permissions perms)))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn permissions]}]
(str profile-id "/" revn "/"
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
(str profile-id "/" revn "/" vern "/"
(dt/format-instant modified-at :iso)
"/"
(uri/map->query-string permissions)))
@ -371,6 +372,7 @@
f.modified_at,
f.name,
f.revn,
f.vern,
f.is_shared,
ft.media_id
from file as f
@ -526,6 +528,7 @@
(def ^:private sql:team-shared-files
"select f.id,
f.revn,
f.vern,
f.data,
f.project_id,
f.created_at,
@ -609,6 +612,7 @@
l.deleted_at,
l.name,
l.revn,
l.vern,
l.synced_at
FROM libs AS l
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
@ -670,6 +674,7 @@
"with recent_files as (
select f.id,
f.revn,
f.vern,
f.project_id,
f.created_at,
f.modified_at,

View file

@ -10,14 +10,13 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata]
[app.main :as-alias main]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.util.blob :as blob]
@ -26,22 +25,12 @@
[app.util.time :as dt]
[cuerdas.core :as str]))
(defn check-authorized!
[{:keys [::db/pool]} profile-id]
(when-not (or (= "devenv" (cf/get :host))
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
admins (or (cf/get :admins) #{})]
(contains? admins (:email profile))))
(ex/raise :type :authentication
:code :authentication-required
:hint "only admins allowed")))
(def sql:get-file-snapshots
"SELECT id, label, revn, created_at
"SELECT id, label, revn, created_at, created_by, profile_id
FROM file_change
WHERE file_id = ?
AND created_at < ?
AND label IS NOT NULL
AND data IS NOT NULL
ORDER BY created_at DESC
LIMIT ?")
@ -50,25 +39,23 @@
:or {limit Long/MAX_VALUE}}]
(let [start-at (or start-at (dt/now))
limit (min limit 20)]
(->> (db/exec! conn [sql:get-file-snapshots file-id start-at limit])
(mapv (fn [row]
(update row :created-at dt/format-instant :rfc1123))))))
(db/exec! conn [sql:get-file-snapshots file-id start-at limit])))
(def ^:private schema:get-file-snapshots
[:map [:file-id ::sm/uuid]])
[:map {:title "get-file-snapshots"}
[:file-id ::sm/uuid]])
(sv/defmethod ::get-file-snapshots
{::doc/added "1.20"
::doc/skip true
::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id] :as params}]
(check-authorized! cfg profile-id)
[cfg params]
(db/run! cfg get-file-snapshots params))
(defn restore-file-snapshot!
[{:keys [::db/conn] :as cfg} {:keys [file-id id]}]
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [file-id id]}]
(let [storage (sto/resolve cfg {::db/reuse-conn true})
file (files/get-minimal-file conn file-id {::db/for-update true})
vern (rand-int Integer/MAX_VALUE)
snapshot (db/get* conn :file-change
{:file-id file-id
:id id}
@ -103,6 +90,7 @@
(db/update! conn :file
{:data (:data snapshot)
:revn (inc (:revn file))
:vern vern
:version (:version snapshot)
:data-backend nil
:data-ref-id nil
@ -126,38 +114,28 @@
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
;; Send to the clients a notification to reload the file
(mbus/pub! msgbus
:topic (:id file)
:message {:type :file-restore
:file-id (:id file)
:vern vern})
{:id (:id snapshot)
:label (:label snapshot)})))
(defn- resolve-snapshot-by-label
[conn file-id label]
(->> (db/query conn :file-change
{:file-id file-id
:label label}
{::sql/order-by [[:created-at :desc]]
::sql/columns [:file-id :id :label]})
(first)))
(def ^:private
schema:restore-file-snapshot
[:and
[:map
[:file-id ::sm/uuid]
[:id {:optional true} ::sm/uuid]
[:label {:optional true} :string]]
[:id {:optional true} ::sm/uuid]]
[::sm/contains-any #{:id :label}]])
(sv/defmethod ::restore-file-snapshot
{::doc/added "1.20"
::doc/skip true
::sm/params schema:restore-file-snapshot}
[cfg {:keys [::rpc/profile-id file-id id label] :as params}]
(check-authorized! cfg profile-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [params (cond-> params
(and (not id) (string? label))
(merge (resolve-snapshot-by-label conn file-id label)))]
(restore-file-snapshot! cfg params)))))
[cfg params]
(db/tx-run! cfg restore-file-snapshot! params))
(defn- get-file
[cfg file-id]
@ -170,29 +148,7 @@
(update :data feat.fdata/process-objects (partial into {}))
(update :data blob/encode)))))
(defn take-file-snapshot!
[cfg {:keys [file-id label ::rpc/profile-id]}]
(let [file (get-file cfg file-id)
id (uuid/next)]
(l/debug :hint "creating file snapshot"
:file-id (str file-id)
:label label)
(db/insert! cfg :file-change
{:id id
:revn (:revn file)
:data (:data file)
:version (:version file)
:features (:features file)
:profile-id profile-id
:file-id (:id file)
:label label}
{::db/return-keys false})
{:id id :label label}))
(defn generate-snapshot-label
(defn- generate-snapshot-label
[]
(let [ts (-> (dt/now)
(dt/format-instant)
@ -200,17 +156,92 @@
(str/rtrim "Z"))]
(str "snapshot-" ts)))
(defn take-file-snapshot!
[cfg {:keys [file-id label ::rpc/profile-id]}]
(let [label (or label (generate-snapshot-label))
file (-> (get-file cfg file-id)
(update :data
(fn [data]
(-> data
(blob/decode)
(assoc :id file-id)))))
snapshot-id
(uuid/next)
snapshot-data
(-> (:data file)
(feat.fdata/process-pointers deref)
(feat.fdata/process-objects (partial into {}))
(blob/encode))]
(l/debug :hint "creating file snapshot"
:file-id (str file-id)
:id (str snapshot-id)
:label label)
(db/insert! cfg :file-change
{:id snapshot-id
:revn (:revn file)
:data snapshot-data
:version (:version file)
:features (:features file)
:profile-id profile-id
:file-id (:id file)
:label label
:created-by "user"}
{::db/return-keys false})
{:id snapshot-id :label label}))
(def ^:private schema:take-file-snapshot
[:map [:file-id ::sm/uuid]])
[:map
[:file-id ::sm/uuid]
[:label {:optional true} :string]])
(sv/defmethod ::take-file-snapshot
{::doc/added "1.20"
::doc/skip true
::sm/params schema:take-file-snapshot}
[cfg {:keys [::rpc/profile-id] :as params}]
(check-authorized! cfg profile-id)
(db/tx-run! cfg (fn [cfg]
(let [params (update params :label (fn [label]
(or label (generate-snapshot-label))))]
(take-file-snapshot! cfg params)))))
[cfg params]
(db/tx-run! cfg take-file-snapshot! params))
(def ^:private schema:update-file-snapshot
[:map {:title "update-file-snapshot"}
[:id ::sm/uuid]
[:label ::sm/text]])
(defn update-file-snapshot!
[{:keys [::db/conn] :as cfg} {:keys [id label]}]
(let [result
(db/update! conn :file-change
{:label label
:created-by "user"}
{:id id}
{::db/return-keys true})]
(select-keys result [:id :label :revn :created-at :profile-id :created-by])))
(sv/defmethod ::update-file-snapshot
{::doc/added "1.20"
::sm/params schema:update-file-snapshot}
[cfg params]
(db/tx-run! cfg update-file-snapshot! params))
(def ^:private schema:remove-file-snapshot
[:map {:title "remove-file-snapshot"}
[:id ::sm/uuid]])
(defn remove-file-snapshot!
[{:keys [::db/conn] :as cfg} {:keys [id]}]
(db/delete! conn :file-change
{:id id :created-by "user"}
{::db/return-keys false})
nil)
(sv/defmethod ::remove-file-snapshot
{::doc/added "1.20"
::sm/params schema:remove-file-snapshot}
[cfg params]
(db/tx-run! cfg remove-file-snapshot! params))

View file

@ -60,6 +60,7 @@
[:id ::sm/uuid]
[:session-id ::sm/uuid]
[:revn {:min 0} ::sm/int]
[:vern {:min 0} ::sm/int]
[:features {:optional true} ::cfeat/features]
[:changes {:optional true} [:vector ::cpc/change]]
[:changes-with-metadata {:optional true}
@ -157,6 +158,14 @@
tpoint (dt/tpoint)]
(when (not= (:vern params)
(:vern file))
(ex/raise :type :validation
:code :vern-conflict
:hint "A different version has been restored for the file."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
@ -455,7 +464,7 @@
"SELECT fch.id, fch.created_at
FROM file_change AS fch
WHERE fch.file_id = ?
AND fch.label LIKE 'internal/%'
AND fch.created_by = 'system'
ORDER BY fch.created_at DESC
LIMIT ?")
@ -465,7 +474,7 @@
"UPDATE file_change
SET label = NULL
WHERE file_id = ?
AND label LIKE 'internal/%'
AND created_by LIKE 'system'
AND created_at < ?")
(defn- delete-old-snapshots!
@ -502,6 +511,7 @@
:file-id (:id file)
:session-id (:session-id params)
:revn (:revn file)
:vern (:vern file)
:changes changes})
(when (and (:is-shared file) (seq lchanges))

View file

@ -336,8 +336,7 @@
(defn take-team-snapshot!
[team-id & {:keys [label rollback?] :or {rollback? true}}]
(let [team-id (h/parse-uuid team-id)
label (or label (fsnap/generate-snapshot-label))]
(let [team-id (h/parse-uuid team-id)]
(-> (assoc main/system ::db/rollback rollback?)
(db/tx-run! h/take-team-snapshot! team-id label))))

View file

@ -36,6 +36,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
@ -55,6 +56,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test 1"
@ -67,6 +69,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id-1

View file

@ -312,6 +312,7 @@
(#'files.update/update-file* system
{:id file-id
:revn revn
:vern 0
:file file
:features (:features file)
:changes changes
@ -327,6 +328,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features features
:changes changes}
out (command! params)]

View file

@ -32,6 +32,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
@ -147,6 +148,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
@ -174,6 +176,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"
@ -203,6 +206,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
@ -279,6 +283,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
@ -305,6 +310,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
@ -367,6 +373,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes [{:type :del-obj
:page-id (first (get-in file [:data :pages]))
:id shid}])
@ -418,6 +425,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:components-v2 true
:changes changes}
out (th/command! params)]
@ -452,6 +460,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
@ -528,6 +537,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes [{:type :del-obj
:page-id (first (get-in file [:data :pages]))
:id s-shid}
@ -622,6 +632,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
@ -688,6 +699,7 @@
:file-id file-id
:profile-id (:id profile)
:revn 0
:vern 0
:changes [{:type :del-obj
:page-id page-id
:id frame-id-2}])
@ -721,6 +733,7 @@
:file-id file-id
:profile-id (:id profile)
:revn 0
:vern 0
:changes [{:type :del-obj
:page-id page-id
:id frame-id-1}])
@ -978,6 +991,7 @@
(th/update-file* {:file-id (:id file)
:profile-id (:id prof)
:revn 0
:vern 0
:components-v2 true
:changes changes})
@ -1178,6 +1192,7 @@
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:revn 2
:vern 0
:is-shared false})]
(t/testing "create a file thumbnail"
@ -1286,6 +1301,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"

View file

@ -42,6 +42,7 @@
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
@ -253,7 +254,8 @@
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false
:revn 3})
:revn 3
:vern 0})
data1 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile)

View file

@ -30,6 +30,7 @@
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
@ -65,6 +66,7 @@
:file-id (:id file1)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-media
:object mobj}])
@ -195,6 +197,7 @@
:file-id (:id file1)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-media
:object mobj}])

View file

@ -134,6 +134,7 @@
:project-id project-id
:name name
:revn revn
:vern 0
:is-shared is-shared
:version version
:data data

View file

@ -0,0 +1 @@
<svg width="14" xmlns="http://www.w3.org/2000/svg" height="14" id="screenshot-4156406f-352e-8098-8005-2199804fae83" viewBox="2888.82 1433.645 14 14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" stroke-linecap="round"><path d="M2895.820,1445.645C2898.581,1445.645,2900.820,1443.407,2900.820,1440.645C2900.820,1437.884,2898.581,1435.645,2895.820,1435.645C2893.058,1435.645,2890.820,1437.884,2890.820,1440.645C2890.820,1443.407,2893.058,1445.645,2895.820,1445.645ZM2897.820,1441.645L2895.820,1440.645L2895.820,1437.645" style="stroke-linecap: round;"/></svg>

After

Width:  |  Height:  |  Size: 568 B

View file

@ -106,7 +106,7 @@
(defn commit
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn undo-group tags stack-undo? source]}]
file-id file-revn file-vern undo-group tags stack-undo? source]}]
(dm/assert!
"expect valid vector of changes for redo-changes"
@ -126,6 +126,7 @@
:features features
:file-id file-id
:file-revn file-revn
:file-vern file-vern
:changes redo-changes
:redo-changes redo-changes
:undo-changes undo-changes
@ -160,6 +161,13 @@
(:revn file)
(dm/get-in state [:workspace-libraries file-id :revn]))))
(defn- resolve-file-vern
[state file-id]
(let [file (:workspace-file state)]
(if (= (:id file) file-id)
(:vern file)
(dm/get-in state [:workspace-libraries file-id :vern]))))
(defn commit-changes
"Schedules a list of changes to execute now, and add the corresponding undo changes to
the undo stack.
@ -194,6 +202,7 @@
(assoc :save-undo? save-undo?)
(assoc :file-id file-id)
(assoc :file-revn (resolve-file-revn state file-id))
(assoc :file-vern (resolve-file-vern state file-id))
(assoc :undo-changes uchg)
(assoc :redo-changes rchg)
(commit))))))))

View file

@ -108,11 +108,12 @@
ptk/WatchEvent
(watch [_ state _]
(log/dbg :hint "persist-commit" :commit-id (dm/str commit-id))
(when-let [{:keys [file-id file-revn changes features] :as commit} (dm/get-in state [:persistence :index commit-id])]
(when-let [{:keys [file-id file-revn file-vern changes features] :as commit} (dm/get-in state [:persistence :index commit-id])]
(let [sid (:session-id state)
revn (max file-revn (get @revn-data file-id 0))
params {:id file-id
:revn revn
:vern file-vern
:session-id sid
:origin (:origin commit)
:created-at (:created-at commit)

View file

@ -70,6 +70,7 @@
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.viewport :as dwv]
[app.main.data.workspace.zoom :as dwz]
[app.main.errors]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.repo :as rp]
@ -378,6 +379,19 @@
(let [name (dm/str "workspace-" file-id)]
(unchecked-set ug/global "name" name)))))
(defn reload-file
[]
(ptk/reify ::reload-file
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
project-id (:current-project-id state)]
(rx/of (initialize-file project-id file-id))))))
;; We need to inject this so there are no cycles
(set! app.main.data.workspace.notifications/reload-file reload-file)
(set! app.main.errors/reload-file reload-file)
(defn finalize-file
[_project-id file-id]
(ptk/reify ::finalize-file

View file

@ -29,12 +29,16 @@
[clojure.set :as set]
[potok.v2.core :as ptk]))
;; From app.main.data.workspace we can use directly because it causes a circular dependency
(def reload-file nil)
;; FIXME: this ns should be renamed to something different
(declare process-message)
(declare handle-presence)
(declare handle-pointer-update)
(declare handle-file-change)
(declare handle-file-restore)
(declare handle-library-change)
(declare handle-pointer-send)
(declare handle-export-update)
@ -124,6 +128,7 @@
:disconnect (handle-presence msg)
:pointer-update (handle-pointer-update msg)
:file-change (handle-file-change msg)
:file-restore (handle-file-restore msg)
:library-change (handle-library-change msg)
:notification (dc/handle-notification msg)
:team-role-change (handle-change-team-role msg)
@ -229,13 +234,14 @@
[:file-id ::sm/uuid]
[:session-id ::sm/uuid]
[:revn :int]
[:vern :int]
[:changes ::cpc/changes]])
(def ^:private check-file-change-params!
(sm/check-fn schema:handle-file-change))
(defn handle-file-change
[{:keys [file-id changes revn] :as msg}]
[{:keys [file-id changes revn vern] :as msg}]
(dm/assert!
"expected valid parameters"
@ -250,13 +256,41 @@
;; The commit event is responsible to apply the data localy
;; and update the persistence internal state with the updated
;; file-revn
(rx/of (dch/commit {:file-id file-id
:file-revn revn
:file-vern vern
:save-undo? false
:source :remote
:redo-changes (vec changes)
:undo-changes []})))))
(def ^:private
schema:handle-file-restore
[:map {:title "handle-file-restore"}
[:type :keyword]
[:file-id ::sm/uuid]
[:vern :int]])
(def ^:private check-file-restore-params!
(sm/check-fn schema:handle-file-restore))
(defn handle-file-restore
[{:keys [file-id vern] :as msg}]
(dm/assert!
"expected valid parameters"
(check-file-restore-params! msg))
(ptk/reify ::handle-file-restore
ptk/WatchEvent
(watch [_ state _]
(let [curr-file-id (:current-file-id state)
curr-vern (dm/get-in state [:workspace-file :vern])
reload? (and (= file-id curr-file-id) (not= vern curr-vern))]
(when reload?
(rx/of (reload-file)))))))
(def ^:private schema:handle-library-change
[:map {:title "handle-library-change"}
[:type :keyword]

View file

@ -0,0 +1,131 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.versions
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.data.persistence :as dwp]
[app.main.data.workspace :as dw]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.util.time :as dt]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defonce default-state
{:status :loading
:data nil
:editing nil})
(declare fetch-versions)
(defn init-version-state
[file-id]
(ptk/reify ::init-version-state
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-versions default-state))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (fetch-versions file-id)))))
(defn update-version-state
[version-state]
(ptk/reify ::update-version-state
ptk/UpdateEvent
(update [_ state]
(update state :workspace-versions merge version-state))))
(defn fetch-versions
[file-id]
(dm/assert! (uuid? file-id))
(ptk/reify ::fetch-versions
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :get-file-snapshots {:file-id file-id})
(rx/map #(update-version-state {:status :loaded :data %}))))))
(defn create-version
[file-id]
(dm/assert! (uuid? file-id))
(ptk/reify ::create-version
ptk/WatchEvent
(watch [_ _ _]
(let [label (dt/format (dt/now) :date-full)]
;; Force persist before creating snapshot, otherwise we could loss changes
(rx/concat
(rx/of ::dwp/force-persist)
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1)
(rx/mapcat #(rp/cmd! :take-file-snapshot {:file-id file-id :label label}))
(rx/mapcat
(fn [{:keys [id]}]
(rx/of
(update-version-state {:editing id})
(fetch-versions file-id))))))))))
(defn rename-version
[file-id id label]
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? id))
(dm/assert! (and (string? label) (d/not-empty? label)))
(ptk/reify ::rename-version
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (update-version-state {:editing false}))
(->> (rp/cmd! :update-file-snapshot {:id id :label label})
(rx/map #(fetch-versions file-id)))))))
(defn restore-version
[project-id file-id id]
(dm/assert! (uuid? project-id))
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? id))
(ptk/reify ::restore-version
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(rx/of ::dwp/force-persist)
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1)
(rx/mapcat #(rp/cmd! :take-file-snapshot {:file-id file-id :created-by "system" :label (dt/format (dt/now) :date-full)}))
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(dw/initialize-file project-id file-id)))))))
(defn delete-version
[file-id id]
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? id))
(ptk/reify ::delete-version
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :remove-file-snapshot {:id id})
(rx/map #(fetch-versions file-id))))))
(defn pin-version
[file-id id]
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? id))
(ptk/reify ::pin-version
ptk/WatchEvent
(watch [_ state _]
(let [version (->> (dm/get-in state [:workspace-versions :data])
(d/seek #(= (:id %) id)))
params {:id id
:label (dt/format (:created-at version) :date-full)}]
(->> (rp/cmd! :update-file-snapshot params)
(rx/mapcat #(rx/of (update-version-state {:editing id})
(fetch-versions file-id))))))))

View file

@ -21,6 +21,9 @@
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
;; From app.main.data.workspace we can use directly because it causes a circular dependency
(def reload-file nil)
(defn- print-data!
[data]
(-> data
@ -137,6 +140,9 @@
:level :error
:timeout 3000})))
(= code :vern-conflict)
(st/emit! (reload-file))
:else
(st/async-emit! (rt/assign-exception error))))

View file

@ -591,6 +591,9 @@
(def current-file-id
(l/derived :current-file-id st/state))
(def current-project-id
(l/derived :current-project-id st/state))
(def workspace-preview-blend
(l/derived :workspace-preview-blend st/state))
@ -604,4 +607,4 @@
(l/derived :updating-library st/state))
(def persistence-state
(l/derived (comp :status :workspace-persistence) st/state))
(l/derived (comp :status :persistence) st/state))

View file

@ -88,6 +88,7 @@
(def ^:icon-id character-z "character-z")
(def ^:icon-id clip-content "clip-content")
(def ^:icon-id clipboard "clipboard")
(def ^:icon-id clock "clock")
(def ^:icon-id close-small "close-small")
(def ^:icon-id close "close")
(def ^:icon-id code "code")

View file

@ -72,6 +72,7 @@
(def ^:icon bug (icon-xref :bug))
(def ^:icon clip-content (icon-xref :clip-content))
(def ^:icon clipboard (icon-xref :clipboard))
(def ^:icon clock (icon-xref :clock))
(def ^:icon close-small (icon-xref :close-small))
(def ^:icon close (icon-xref :close))
(def ^:icon code (icon-xref :code))

View file

@ -26,6 +26,7 @@
[app.main.ui.workspace.sidebar.options :refer [options-toolbox]]
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]]
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap]]
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox]]
[app.util.debug :as dbg]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
@ -181,7 +182,17 @@
props
(mf/spread props
:on-change-section handle-change-section
:on-expand handle-expand)]
:on-expand handle-expand)
history-tab
(mf/html
[:article {:class (stl/css :history-tab)}
[:& history-toolbox {}]])
versions-tab
(mf/html
[:article {:class (stl/css :versions-tab)}
[:& versions-toolbox {}]])]
[:& (mf/provider muc/sidebar) {:value :right}
[:aside {:class (stl/css-case :right-settings-bar true
@ -208,7 +219,15 @@
[:& comments-sidebar]
(true? is-history?)
[:> history-toolbox {}]
[:> tab-switcher* {:tabs #js [#js {:label "History" :id "history" :content versions-tab}
#js {:label "Actions" :id "actions" :content history-tab}]
:default-selected "history"
;;:selected (name section)
;;:on-change-tab on-tab-change
:class (stl/css :left-sidebar-tabs)
;;:action-button-position "start"
;;:action-button (mf/html [:& collapse-button {:on-click handle-collapse}])
}]
:else
[:> options-toolbox props])]]]))

View file

@ -109,3 +109,8 @@ $width-settings-bar-max: $s-500;
--collapse-icon-color: var(--color-foreground-primary);
}
}
.versions-tab {
width: 100%;
overflow: hidden;
}

View file

@ -9,12 +9,9 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.data.events :as ev]
[app.main.data.workspace :as dw]
[app.main.data.workspace.undo :as dwu]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n]
@ -326,18 +323,8 @@
[]
(let [objects (mf/deref refs/workspace-page-objects)
{:keys [items index]} (mf/deref workspace-undo)
entries (parse-entries items objects)
toggle-history
(mf/use-fn
#(st/emit! (-> (dw/toggle-layout-flag :document-history)
(vary-meta assoc ::ev/origin "history-toolbox"))))]
entries (parse-entries items objects)]
[:div {:class (stl/css :history-toolbox)}
[:div {:class (stl/css :history-toolbox-title)}
[:span (tr "workspace.undo.title")]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click toggle-history
:icon "close"}]]
(if (empty? entries)
[:div {:class (stl/css :history-entry-empty)}
[:div {:class (stl/css :history-entry-empty-icon)} i/history]

View file

@ -0,0 +1,377 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License v. 2.0. If a copy of the MPL was not distributed with this
;; file You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.sidebar.versions
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.main.data.notifications :as ntf]
[app.main.data.workspace.versions :as dwv]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.select :refer [select]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.time :as dt]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def versions
(l/derived :workspace-versions st/state))
(defn group-snapshots
[data]
(->> (concat
(->> data
(filterv #(= "user" (:created-by %)))
(map #(assoc % :type :version)))
(->> data
(filterv #(= "system" (:created-by %)))
(group-by #(.toISODate ^js (:created-at %)))
(map (fn [[day entries]]
{:type :snapshot
:created-at (ct/parse-instant day)
:snapshots entries}))))
(sort-by :created-at)
(reverse)))
(mf/defc version-entry
[{:keys [entry profile on-restore-version on-delete-version on-rename-version editing?]}]
(let [input-ref (mf/use-ref nil)
show-menu? (mf/use-state false)
handle-open-menu
(mf/use-fn
(fn []
(reset! show-menu? true)))
handle-close-menu
(mf/use-fn
(fn []
(reset! show-menu? false)))
handle-rename-version
(mf/use-fn
(mf/deps entry)
(fn []
(st/emit! (dwv/update-version-state {:editing (:id entry)}))))
handle-restore-version
(mf/use-fn
(mf/deps entry on-restore-version)
(fn []
(when on-restore-version
(on-restore-version (:id entry)))))
handle-delete-version
(mf/use-callback
(mf/deps entry on-delete-version)
(fn []
(when on-delete-version
(on-delete-version (:id entry)))))
handle-name-input-focus
(mf/use-fn
(fn [event]
(dom/select-text! (dom/get-target event))))
handle-name-input-blur
(mf/use-fn
(mf/deps entry on-rename-version)
(fn [event]
(let [label (str/trim (dom/get-target-val event))]
(when (and (not (str/empty? label))
(some? on-rename-version))
(on-rename-version (:id entry) label))
(st/emit! (dwv/update-version-state {:editing nil})))))
handle-name-input-key-down
(mf/use-fn
(mf/deps handle-name-input-blur)
(fn [event]
(cond
(kbd/enter? event)
(handle-name-input-blur event)
(kbd/esc? event)
(st/emit! (dwv/update-version-state {:editing nil})))))]
[:li {:class (stl/css :version-entry-wrap)}
[:div {:class (stl/css :version-entry :is-snapshot)}
[:img {:class (stl/css :version-entry-avatar)
:alt (:fullname profile)
:src (cfg/resolve-profile-photo-url profile)}]
[:div {:class (stl/css :version-entry-data)}
(if editing?
[:input {:class (stl/css :version-entry-name-edit)
:type "text"
:ref input-ref
:on-focus handle-name-input-focus
:on-blur handle-name-input-blur
:on-key-down handle-name-input-key-down
:auto-focus true
:default-value (:label entry)}]
[:p {:class (stl/css :version-entry-name)}
(:label entry)])
[:p {:class (stl/css :version-entry-time)}
(let [locale (mf/deref i18n/locale)
time (dt/timeago (:created-at entry) {:locale locale})]
[:span {:class (stl/css :date)} time])]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.versions.version-menu")
:on-click handle-open-menu
:icon "menu"}]]
[:& dropdown {:show @show-menu? :on-close handle-close-menu}
[:ul {:class (stl/css :version-options-dropdown)}
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-rename-version} (tr "labels.rename")]
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-restore-version} (tr "labels.restore")]
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-delete-version} (tr "labels.delete")]]]]))
(mf/defc snapshot-entry
[{:keys [index is-expanded entry on-toggle-expand on-pin-snapshot on-restore-snapshot]}]
(let [open-menu (mf/use-state nil)
handle-toggle-expand
(mf/use-fn
(mf/deps index on-toggle-expand)
(fn []
(when on-toggle-expand
(on-toggle-expand index))))
handle-pin-snapshot
(mf/use-fn
(mf/deps on-pin-snapshot)
(fn [event]
(let [node (dom/get-current-target event)
id (-> (dom/get-data node "id") uuid/uuid)]
(when on-pin-snapshot (on-pin-snapshot id)))))
handle-restore-snapshot
(mf/use-fn
(mf/deps on-restore-snapshot)
(fn [event]
(let [node (dom/get-current-target event)
id (-> (dom/get-data node "id") uuid/uuid)]
(when on-restore-snapshot (on-restore-snapshot id)))))]
[:li {:class (stl/css :version-entry-wrap)}
[:div {:class (stl/css-case :version-entry true
:is-autosave true
:is-expanded is-expanded)}
[:p {:class (stl/css :version-entry-name)}
(tr "workspace.versions.autosaved.version" (dt/format (:created-at entry) :date-full))]
[:button {:class (stl/css :version-entry-snapshots)
:aria-label (tr "workspace.versions.expand-snapshot")
:on-click handle-toggle-expand}
[:> i/icon* {:id i/clock :class (stl/css :icon-clock)}]
(tr "workspace.versions.autosaved.entry" (count (:snapshots entry)))
[:> i/icon* {:id i/arrow :class (stl/css :icon-arrow)}]]
[:ul {:class (stl/css :version-snapshot-list)}
(for [[idx snapshot] (d/enumerate (:snapshots entry))]
[:li {:class (stl/css :version-snapshot-entry-wrapper)
:key (dm/str "snp-" idx)}
[:div {:class (stl/css :version-snapshot-entry)}
(str
(dt/format (:created-at snapshot) :date-full)
" . "
(dt/format (:created-at snapshot) :time-24-simple))]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.versions.snapshot-menu")
:on-click #(reset! open-menu snapshot)
:icon "menu"
:class (stl/css :version-snapshot-menu-btn)}]
[:& dropdown {:show (= @open-menu snapshot)
:on-close #(reset! open-menu nil)}
[:ul {:class (stl/css :version-options-dropdown)}
[:li {:class (stl/css :menu-option)
:role "button"
:data-id (dm/str (:id snapshot))
:on-click handle-restore-snapshot}
(tr "workspace.versions.button.restore")]
[:li {:class (stl/css :menu-option)
:role "button"
:data-id (dm/str (:id snapshot))
:on-click handle-pin-snapshot}
(tr "workspace.versions.button.pin")]]]])]]]))
(mf/defc versions-toolbox
[]
(let [users (mf/deref refs/users)
profile (mf/deref refs/profile)
project-id (mf/deref refs/current-project-id)
file-id (mf/deref refs/current-file-id)
expanded (mf/use-state #{})
{:keys [status data editing]}
(mf/deref versions)
;; Store users that have a version
data-users
(mf/use-memo
(mf/deps data)
(fn []
(into #{} (keep (fn [{:keys [created-by profile-id]}]
(when (= "user" created-by) profile-id))) data)))
data
(mf/use-memo
(mf/deps @versions)
(fn []
(->> data
(filter #(or (not (:filter @versions))
(and
(= "user" (:created-by %))
(= (:filter @versions) (:profile-id %)))))
(group-snapshots))))
handle-create-version
(mf/use-fn
(fn []
(st/emit! (dwv/create-version file-id))))
handle-toggle-expand
(mf/use-fn
(fn [id]
(swap! expanded
(fn [expanded]
(let [has-element? (contains? expanded id)]
(cond-> expanded
has-element? (disj id)
(not has-element?) (conj id)))))))
handle-rename-version
(mf/use-fn
(mf/deps file-id)
(fn [id label]
(st/emit! (dwv/rename-version file-id id label))))
handle-restore-version
(mf/use-fn
(mf/deps project-id file-id)
(fn [id]
(st/emit!
(ntf/dialog
:content (tr "workspace.versions.restore-warning")
:controls :inline-actions
:actions [{:label (tr "workspace.updates.dismiss")
:type :secondary
:callback #(st/emit! (ntf/hide))}
{:label (tr "labels.restore")
:type :primary
:callback #(st/emit! (dwv/restore-version project-id file-id id))}]
:tag :restore-dialog))))
handle-delete-version
(mf/use-fn
(mf/deps file-id)
(fn [id]
(st/emit! (dwv/delete-version file-id id))))
handle-pin-version
(mf/use-fn
(mf/deps file-id)
(fn [id]
(st/emit! (dwv/pin-version file-id id))))
handle-change-filter
(mf/use-fn
(fn [filter]
(cond
(= :all filter)
(st/emit! (dwv/update-version-state {:filter nil}))
(= :own filter)
(st/emit! (dwv/update-version-state {:filter (:id profile)}))
:else
(st/emit! (dwv/update-version-state {:filter filter})))))]
(mf/with-effect
[file-id]
(when file-id
(st/emit! (dwv/init-version-state file-id))))
[:div {:class (stl/css :version-toolbox)}
[:& select
{:default-value :all
:aria-label (tr "workspace.versions.filter.label")
:options (into [{:value :all :label (tr "workspace.versions.filter.all")}
{:value :own :label (tr "workspace.versions.filter.mine")}]
(->> data-users
(keep
(fn [id]
(let [{:keys [fullname]} (get users id)]
(when (not= id (:id profile))
{:value id :label (tr "workspace.versions.filter.user" fullname)}))))))
:on-change handle-change-filter}]
(cond
(= status :loading)
[:div {:class (stl/css :versions-entry-empty)}
[:div {:class (stl/css :versions-entry-empty-msg)} (tr "workspace.versions.loading")]]
(= status :loaded)
[:*
[:div {:class (stl/css :version-save-version)}
(tr "workspace.versions.button.save")
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.versions.button.save")
:on-click handle-create-version
:icon "pin"}]]
(if (empty? data)
[:div {:class (stl/css :versions-entry-empty)}
[:div {:class (stl/css :versions-entry-empty-icon)} [:> i/icon* {:id i/history}]]
[:div {:class (stl/css :versions-entry-empty-msg)} (tr "workspace.versions.empty")]]
[:ul {:class (stl/css :versions-entries)}
(for [[idx-entry entry] (->> data (map-indexed vector))]
(case (:type entry)
:version
[:& version-entry {:key idx-entry
:entry entry
:editing? (= (:id entry) editing)
:profile (get users (:profile-id entry))
:on-rename-version handle-rename-version
:on-restore-version handle-restore-version
:on-delete-version handle-delete-version}]
:snapshot
[:& snapshot-entry {:key idx-entry
:index idx-entry
:entry entry
:is-expanded (contains? @expanded idx-entry)
:on-toggle-expand handle-toggle-expand
:on-restore-snapshot handle-restore-version
:on-pin-snapshot handle-pin-version}]
nil))])])]))

View file

@ -0,0 +1,226 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
.version-toolbox {
padding: $s-8;
}
.versions-entry-empty {
align-items: center;
color: var(--color-foreground-secondary);
display: flex;
flex-direction: column;
font-size: $fs-12;
gap: $s-8;
padding: $s-16;
}
.versions-entry-empty-icon {
background: var(--color-background-tertiary);
border-radius: 50%;
padding: $s-8;
display: flex;
}
.version-save-version {
font-weight: 600;
text-transform: uppercase;
color: var(--color-foreground-secondary);
font-size: $fs-12;
padding: $s-16 0 $s-16 $s-16;
justify-content: space-between;
width: 100%;
display: flex;
align-items: center;
}
.version-save-button {
background: none;
border: none;
cursor: pointer;
}
.versions-entries {
display: flex;
flex-direction: column;
gap: $s-6;
}
.version-entry {
border: 1px solid transparent;
p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover .version-entry-options {
visibility: initial;
}
}
.version-entry {
display: flex;
padding: $s-4 $s-4 $s-4 $s-16;
gap: $s-8;
border-radius: 8px;
align-items: center;
&:hover {
border-color: var(--color-accent-primary);
}
}
.version-entry.is-autosave {
flex-direction: column;
align-items: start;
padding-left: $s-48;
gap: 0;
}
.version-entry-wrap {
position: relative;
}
.version-entry-avatar {
border-radius: 50%;
width: $s-24;
height: $s-24;
}
.version-entry-data {
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.version-entry-name {
color: var(--color-foreground-primary);
border-bottom: 1px solid transparent;
}
.version-entry-name-edit {
font-size: $fs-12;
color: var(--color-foreground-primary);
background: none;
margin: 0;
padding: 0;
border: none;
outline: none;
border-bottom: 1px solid var(--color-foreground-secondary);
}
.version-entry-time {
color: var(--color-foreground-secondary);
}
.version-entry-options {
background: none;
border: 0;
cursor: pointer;
visibility: hidden;
padding: 0;
height: $s-40;
width: $s-32;
}
.version-options-dropdown {
@extend .dropdown-wrapper;
position: absolute;
width: fit-content;
max-width: $s-200;
right: 0;
left: unset;
.menu-option {
@extend .dropdown-element-base;
}
}
.version-entry-snapshots {
display: flex;
align-items: center;
gap: $s-6;
color: var(--color-foreground-secondary);
background: none;
border: 0;
cursor: pointer;
padding: 0;
.icon-clock {
stroke: var(--color-accent-warning);
}
.icon-arrow {
stroke: var(--color-foreground-secondary);
}
&:hover {
color: var(--color-accent-primary);
.icon-arrow {
stroke: var(--color-accent-primary);
}
}
.is-expanded & .icon-arrow {
transform: rotate(90deg);
}
}
.version-snapshot-list {
display: none;
margin-top: $s-8;
flex-direction: column;
width: 100%;
.version-entry.is-expanded & {
display: flex;
}
}
.version-snapshot-entry-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
&:hover .version-snapshot-menu-btn {
visibility: initial;
}
}
.version-snapshot-entry {
font-size: $fs-12;
color: var(--color-foreground-secondary);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
justify-content: space-between;
width: 100%;
white-space: nowrap;
&:hover {
color: var(--color-accent-primary);
}
&:active {
color: var(--color-accent-primary);
:global(.icon-pin) {
visibility: initial;
fill: var(--color-accent-primary);
}
}
}
.version-snapshot-menu-btn {
visibility: hidden;
}

View file

@ -429,12 +429,12 @@
params {:id (:id file)
:revn (:revn file)
:vern (:vern file)
:session-id sid
:changes changes
:features features
:skip-validate true}]
(->> (rp/cmd! :update-file params)
(rx/subs! (fn [_]
(when reload?

View file

@ -1978,6 +1978,9 @@ msgstr "Remove member"
msgid "labels.rename"
msgstr "Rename"
msgid "labels.restore"
msgstr "Restore"
#: src/app/main/ui/dashboard/team_form.cljs:99
msgid "labels.rename-team"
msgstr "Rename team"
@ -6351,3 +6354,48 @@ msgstr "Update"
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"
msgid "workspace.versions.button.save"
msgstr "Save version"
msgid "workspace.versions.button.pin"
msgstr "Pin version"
msgid "workspace.versions.button.restore"
msgstr "Restore version"
msgid "workspace.versions.empty"
msgstr "There are no versions yet"
msgid "workspace.versions.autosaved.version"
msgstr "Autosaved %s"
msgid "workspace.versions.autosaved.entry"
msgstr "%s autosave versions"
msgid "workspace.versions.loading"
msgstr "Loading..."
msgid "workspace.versions.filter.label"
msgstr "Versions filter"
msgid "workspace.versions.filter.all"
msgstr "All versions"
msgid "workspace.versions.filter.mine"
msgstr "My versions"
msgid "workspace.versions.filter.user"
msgstr "%s's versions"
msgid "workspace.versions.restore-warning"
msgstr "Do you want to restore this version?"
msgid "workspace.versions.snapshot-menu"
msgstr "Open snapshot menu"
msgid "workspace.versions.version-menu"
msgstr "Open version menu"
msgid "workspace.versions.expand-snapshot"
msgstr "Expand snapshots"

View file

@ -1970,6 +1970,9 @@ msgstr "Eliminar integrante"
msgid "labels.rename"
msgstr "Renombrar"
msgid "labels.restore"
msgstr "Restaurar"
#: src/app/main/ui/dashboard/team_form.cljs:99
msgid "labels.rename-team"
msgstr "Renombra el equipo"
@ -6332,3 +6335,48 @@ msgstr "Pulsar para cerrar la ruta"
msgid "errors.maximum-invitations-by-request-reached"
msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"
msgid "workspace.versions.button.save"
msgstr "Guardar versión"
msgid "workspace.versions.button.pin"
msgstr "Fijar versión"
msgid "workspace.versions.button.restore"
msgstr "Restaurar versión"
msgid "workspace.versions.empty"
msgstr "No hay versiones aún"
msgid "workspace.versions.autosaved.version"
msgstr "Autoguardado %s"
msgid "workspace.versions.autosaved.entry"
msgstr "%s versiones de autoguardado"
msgid "workspace.versions.loading"
msgstr "Cargando..."
msgid "workspace.versions.filter.label"
msgstr "Filtro de versiones"
msgid "workspace.versions.filter.all"
msgstr "Todas las versiones"
msgid "workspace.versions.filter.mine"
msgstr "Mis versiones"
msgid "workspace.versions.filter.user"
msgstr "Versiones de %s"
msgid "workspace.versions.restore-warning"
msgstr "¿Quieres restaurar esta versión?"
msgid "workspace.versions.snapshot-menu"
msgstr "Abrir menu de versiones"
msgid "workspace.versions.version-menu"
msgstr "Abrir menu de versiones"
msgid "workspace.versions.expand-snapshot"
msgstr "Expandir versiones"