mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
173 lines
7.2 KiB
Rust
173 lines
7.2 KiB
Rust
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string};
|
|
|
|
pub(super) fn version_json(index: usize, v: &Version) -> Value {
|
|
let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time});
|
|
if v.auto {
|
|
j["auto"] = json!(true);
|
|
}
|
|
j
|
|
}
|
|
|
|
/// Autosaves kept per file; older ones make room for new ones.
|
|
pub const MAX_AUTOSAVES: usize = 20;
|
|
|
|
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
|
pub(super) fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let auto_save = s.prefs.auto_save;
|
|
let ds = s.ds_mut()?;
|
|
// A file that saves itself is rarely unsaved: edits since it was opened count then.
|
|
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
|
|
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
|
|
return Ok(json!({"saved": false}));
|
|
}
|
|
Ok(match add_version(ds, AUTOSAVE, "", true) {
|
|
Some(v) => json!({"saved": true, "version": v}),
|
|
None => json!({"saved": false}),
|
|
})
|
|
}
|
|
|
|
/// What checkpoints are called.
|
|
const AUTOSAVE: &str = "Autosave";
|
|
|
|
/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest
|
|
/// autosave makes room when the autosaves or the history are full; versions the user named are
|
|
/// never dropped, so a history full of them takes nothing (None).
|
|
pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option<Value> {
|
|
let autos = ds.versions.iter().filter(|v| v.auto).count();
|
|
if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS)
|
|
&& let Some(oldest) = ds.versions.iter().position(|v| v.auto)
|
|
{
|
|
ds.versions_mut().remove(oldest);
|
|
}
|
|
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
|
|
return None;
|
|
}
|
|
let mut v = snapshot_version(ds, name, description);
|
|
v.auto = auto;
|
|
let out = version_json(ds.versions.len(), &v);
|
|
ds.versions_mut().push(v);
|
|
ds.mark_checkpoint();
|
|
Some(out)
|
|
}
|
|
|
|
/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless
|
|
/// it is what the last version saved or restored holds.
|
|
pub(crate) fn keep_in_history(ds: &mut DocState) {
|
|
if ds.branch_of.is_none() && ds.changed_since_checkpoint() {
|
|
add_version(ds, AUTOSAVE, "", true);
|
|
}
|
|
}
|
|
|
|
/// A version of `ds`'s document as it is now, opening on the page shown.
|
|
fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version {
|
|
let mut snap = ds.doc.clone();
|
|
if snap.current_page != Some(ds.page) {
|
|
Arc::make_mut(&mut snap).current_page = Some(ds.page);
|
|
}
|
|
Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, crate::cmd::comment::now_secs(), snap)
|
|
}
|
|
|
|
pub(super) fn version_list(ds: &DocState) -> Value {
|
|
let previewing = ds.preview.as_ref().map(|p| p.id.as_str());
|
|
Value::Array(
|
|
ds.versions
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| {
|
|
let mut j = version_json(i, v);
|
|
if previewing == Some(v.id.as_str()) {
|
|
j["previewing"] = json!(true);
|
|
}
|
|
j
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// The version named by `id`, else by `index` (validated: never negative, never past the end).
|
|
pub(super) fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
|
|
if let Some(id) = string(p, "id") {
|
|
return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`")));
|
|
}
|
|
if get(p, "index").is_none() {
|
|
return Err(bad(cmd, "pass the version's `id` or `index`"));
|
|
}
|
|
let i = req_index(cmd, p, "index")?;
|
|
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
|
|
}
|
|
|
|
pub(crate) fn read_version(v: &Version) -> Result<Arc<Document>> {
|
|
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
|
|
}
|
|
|
|
/// A new version or branch id: the time in milliseconds (hex), not `taken`.
|
|
pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String {
|
|
let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
|
|
loop {
|
|
let id = format!("{n:x}");
|
|
if !taken(&id) {
|
|
return id;
|
|
}
|
|
n = n.wrapping_add(1);
|
|
}
|
|
}
|
|
|
|
/// `file.saveVersion`; on a branch tab the version goes into the branch's own history.
|
|
pub(super) fn save_version(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ds = s.ds_mut()?;
|
|
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
|
|
return Err(EngineError::Disabled(
|
|
"file.saveVersion".into(),
|
|
format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS),
|
|
));
|
|
}
|
|
let name =
|
|
string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1));
|
|
let description = string(p, "description").unwrap_or_default();
|
|
// The version opens on the page shown when it was saved.
|
|
add_version(ds, &name, description.trim(), false).ok_or_else(|| {
|
|
EngineError::Disabled(
|
|
"file.saveVersion".into(),
|
|
format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub(super) fn restore_version(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "version.restore";
|
|
// Refused before anything goes into the history.
|
|
if let Some(why) = s.live_read_only(s.ds()?.uid) {
|
|
return Err(EngineError::Disabled(CMD.into(), why.into()));
|
|
}
|
|
let ds = s.ds_mut()?;
|
|
let i = version_index(CMD, ds, p)?;
|
|
let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?;
|
|
let (id, mut doc) = (v.id.clone(), read_version(v)?);
|
|
// Restoring never loses work: the document as it is goes into the history first.
|
|
keep_in_history(ds);
|
|
let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v));
|
|
let d = Arc::make_mut(&mut doc);
|
|
d.name = ds.doc.name.clone();
|
|
// What the file published belongs to the file, not to the version.
|
|
d.published = ds.doc.published.clone();
|
|
// Comments are a conversation about the file, kept apart from its design history: they stay,
|
|
// and those on layers the version doesn't have stay where they were.
|
|
d.comments = ds.doc.comments.clone();
|
|
crate::cmd::comment::follow_anchors(d, &ds.doc);
|
|
// The snapshot is already consistent: nothing to re-normalise.
|
|
d.take_dirty();
|
|
// Stay on the current page when the version has it.
|
|
ds.page = Some(ds.page)
|
|
.filter(|pg| d.pages.contains(pg))
|
|
.or_else(|| d.current_page.filter(|pg| d.pages.contains(pg)))
|
|
.or_else(|| d.first_page())
|
|
.unwrap_or(ds.page);
|
|
ds.doc = doc;
|
|
ds.selection.clear();
|
|
ds.text_edit = None;
|
|
ds.vector_edit = None;
|
|
// The restored design is in the history already: restoring it again keeps nothing new.
|
|
ds.mark_checkpoint();
|
|
Ok(out)
|
|
}
|