Skip to content

Commit

Permalink
auto-saving + auto-backups!
Browse files Browse the repository at this point in the history
  • Loading branch information
HJfod committed May 15, 2024
1 parent f12a455 commit 7614d89
Show file tree
Hide file tree
Showing 7 changed files with 216 additions and 25 deletions.
6 changes: 6 additions & 0 deletions mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@
"name": "Show Grid on Size Change",
"description": "If the grid is currently hidden and you use the controls to change its size, its toggled on",
"platforms": ["windows", "android64"]
},
"auto-save": {
"type": "bool",
"default": true,
"name": "Auto-save",
"description": "Automatically saves the level every 5 minutes, as well as creates a backup. The backups created by autosave get automatically deleted after 3 new ones have been created to avoid using too much disk space"
}
},
"tags": ["editor", "enhancement", "utility", "customization"]
Expand Down
82 changes: 82 additions & 0 deletions src/features/backups/AutoSave.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include <Geode/modify/EditorUI.hpp>
#include "Backup.hpp"

using namespace geode::prelude;

class $modify(AutoSaveUI, EditorUI) {
struct Fields {
size_t secondsSinceLastAutoSave = 0;
Ref<Notification> autoSaveCountdownNotification;
};

bool init(LevelEditorLayer* editor) {
if (!EditorUI::init(editor))
return false;

if (Mod::get()->template getSettingValue<bool>("auto-save")) {
this->schedule(schedule_selector(AutoSaveUI::onAutoSaveTick), 1);
}

return true;
}
void onAutoSaveTick(float) {
constexpr size_t COUNTDOWN = 5;
constexpr size_t AUTO_SAVE_INTERVAL = 20;

m_fields->secondsSinceLastAutoSave += 1;
if (m_fields->secondsSinceLastAutoSave > AUTO_SAVE_INTERVAL - COUNTDOWN) {
// Make sure the autosave notification exists
if (!m_fields->autoSaveCountdownNotification) {
m_fields->autoSaveCountdownNotification = Notification::create(
"", CCSprite::createWithSpriteFrameName("GJ_timeIcon_001.png"), 0
);
m_fields->autoSaveCountdownNotification->show();
}

// The actual save
if (m_fields->secondsSinceLastAutoSave > AUTO_SAVE_INTERVAL) {
m_fields->autoSaveCountdownNotification->setString("Saving...");
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Loading);
m_fields->secondsSinceLastAutoSave = 0;

// Run on next frame to ensure this one gets rendered first
Loader::get()->queueInMainThread([this] {
// Save level
auto layer = EditorPauseLayer::create(m_editorLayer);
layer->saveLevel();
layer->release();

// Create backup
auto res = Backup::create(m_editorLayer->m_level, true);

// Cleanup
auto clean = Backup::cleanAutomated(m_editorLayer->m_level);
if (!clean) {
log::error("Failed to clean up automated backups: {}", clean.unwrapErr());
}

// Show error / finished
if (!res) {
log::error("Backing level up failed: {}", res.unwrapErr());
m_fields->autoSaveCountdownNotification->setString("Backing up failed!");
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Error);
}
else {
m_fields->autoSaveCountdownNotification->setString("Level saved & backed up!");
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Success);
}

// Hide the notification
m_fields->autoSaveCountdownNotification->hide();
m_fields->autoSaveCountdownNotification = nullptr;
});
}
// Warning countdown
else {
m_fields->autoSaveCountdownNotification->setString(
fmt::format("Saving in {} seconds", AUTO_SAVE_INTERVAL - m_fields->secondsSinceLastAutoSave)
);
}
}
}
};
74 changes: 70 additions & 4 deletions src/features/backups/Backup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,28 @@

struct BackupMetadata final {
typename Backup::TimePoint createTime = Backup::Clock::now();
bool automated = false;
};

template <>
struct matjson::Serialize<BackupMetadata> {
static matjson::Value to_json(BackupMetadata const& meta) {
return matjson::Object({
{ "create-time", std::chrono::duration_cast<Backup::TimeUnit>(meta.createTime.time_since_epoch()).count() }
{ "create-time", std::chrono::duration_cast<Backup::TimeUnit>(meta.createTime.time_since_epoch()).count() },
{ "automated", meta.automated },
});
}
static BackupMetadata from_json(matjson::Value const& value) {
auto meta = BackupMetadata();
auto obj = value.as_object();

meta.createTime = Backup::TimePoint(Backup::TimeUnit(obj["create-time"].as_int()));

// Parsing should be as fault-tolerant as possible
if (obj.contains("automated")) {
meta.automated = obj["automated"].as_bool();
}

return meta;
}
static bool is_json(matjson::Value const& value) {
Expand All @@ -30,9 +39,15 @@ struct matjson::Serialize<BackupMetadata> {
GJGameLevel* Backup::getLevel() const {
return m_level;
}
GJGameLevel* Backup::getOriginalLevel() const {
return m_forLevel;
}
typename Backup::TimePoint Backup::getCreateTime() const {
return m_createTime;
}
bool Backup::isAutomated() const {
return m_automated;
}

Result<> Backup::restoreThis() {
// Add changes to memory
Expand Down Expand Up @@ -60,6 +75,15 @@ Result<> Backup::deleteThis() {
}
return Ok();
}
Result<> Backup::preserveAutomated() {
m_automated = false;
auto metadata = BackupMetadata {
.createTime = m_createTime,
.automated = false,
};
GEODE_UNWRAP(file::writeToJson(m_directory / "meta.json", metadata).expect("Unable to save metadata: {error}"));
return Ok();
}

Result<std::shared_ptr<Backup>> Backup::load(ghc::filesystem::path const& dir, GJGameLevel* forLevel) {
GEODE_UNWRAP_INTO(auto level, gmd::importGmdAsLevel(dir / "level.gmd").expect("Unable to read level file: {error}"));
Expand All @@ -68,8 +92,9 @@ Result<std::shared_ptr<Backup>> Backup::load(ghc::filesystem::path const& dir, G
auto backup = std::make_shared<Backup>();
backup->m_level = level;
backup->m_forLevel = forLevel;
backup->m_createTime = meta.createTime;
backup->m_directory = dir;
backup->m_createTime = meta.createTime;
backup->m_automated = meta.automated;
return Ok(backup);
}
std::vector<std::shared_ptr<Backup>> Backup::load(GJGameLevel* level) {
Expand All @@ -85,10 +110,15 @@ std::vector<std::shared_ptr<Backup>> Backup::load(GJGameLevel* level) {
}
res.push_back(*b);
}

// This takes advantage of the fact that directories are sorted
// alphabetically and the date format is oldest-newest when sorted that way
// Might want to consider a proper sort-by-newest-first
std::reverse(res.begin(), res.end());

return res;
}
Result<> Backup::create(GJGameLevel* level) {
Result<> Backup::create(GJGameLevel* level, bool automated) {
if (level->m_levelType != GJLevelType::Editor) {
return Err("Can not backup a non-editor level");
}
Expand All @@ -105,9 +135,45 @@ Result<> Backup::create(GJGameLevel* level) {
GEODE_UNWRAP(gmd::exportLevelAsGmd(level, dir / "level.gmd").expect("Unable to save level: {error}"));

auto metadata = BackupMetadata {
.createTime = time
.createTime = time,
.automated = automated,
};
GEODE_UNWRAP(file::writeToJson(dir / "meta.json", metadata).expect("Unable to save metadata: {error}"));

return Ok();
}
Result<> Backup::cleanAutomated(GJGameLevel* level) {
std::vector<std::shared_ptr<Backup>> automated;
for (auto folder : file::readDirectory(save::getCurrentLevelSaveDir(level) / "backups").unwrapOrDefault()) {
if (!ghc::filesystem::is_directory(folder)) {
continue;
}
auto b = Backup::load(folder, level);
if (!b) {
continue;
}
auto backup = *b;
if (backup->m_automated) {
automated.push_back(backup);
}
}

// This takes advantage of the fact that directories are sorted
// alphabetically and the date format is oldest-newest when sorted that way
// Might want to consider a proper sort-by-newest-first
std::reverse(automated.begin(), automated.end());

constexpr size_t MAX_AUTOMATED_COUNT = 3;

// Do the cleanup
size_t ix = 0;
for (auto backup : automated) {
// Keep only the MAX_AUTOMATED_COUNT newest automated backups
if (++ix > MAX_AUTOMATED_COUNT) {
GEODE_UNWRAP(backup->deleteThis());
}
}
automated.erase(automated.begin() + MAX_AUTOMATED_COUNT, automated.end());

return Ok();
}
7 changes: 6 additions & 1 deletion src/features/backups/Backup.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@ class Backup final {
Ref<GJGameLevel> m_level;
Ref<GJGameLevel> m_forLevel;
TimePoint m_createTime;
bool m_automated;

public:
static Result<std::shared_ptr<Backup>> load(ghc::filesystem::path const& dir, GJGameLevel* forLevel);
static std::vector<std::shared_ptr<Backup>> load(GJGameLevel* level);
static Result<> create(GJGameLevel* level);
static Result<> create(GJGameLevel* level, bool automated);
static Result<> cleanAutomated(GJGameLevel* level);

GJGameLevel* getLevel() const;
GJGameLevel* getOriginalLevel() const;
TimePoint getCreateTime() const;
bool isAutomated() const;

Result<> restoreThis();
Result<> deleteThis();
Result<> preserveAutomated();
};
using BackupPtr = std::shared_ptr<Backup>;
62 changes: 47 additions & 15 deletions src/features/backups/BackupItem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
#include <fmt/chrono.h>
#include <utils/EditorViewOnlyMode.hpp>

bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) {
bool BackupItem::init(BackupPtr backup) {
if (!CCNode::init())
return false;

this->setContentSize({ 275, 35 });

m_backup = backup;
m_forLevel = forLevel;

auto bg = CCScale9Sprite::create("square02c_001.png");
bg->setColor({ 25, 25, 25 });
Expand All @@ -33,28 +32,39 @@ bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) {
menu->setAnchorPoint({ 1, .5f });
menu->setContentWidth(100);

auto viewSpr = CircleButtonSprite::createWithSpriteFrameName(
"eye-white.png"_spr, 1.f, CircleBaseColor::Green, CircleBaseSize::Small
);
auto viewBtn = CCMenuItemSpriteExtra::create(
viewSpr, this, menu_selector(BackupItem::onView)
auto deleteSpr = CCSprite::createWithSpriteFrameName("GJ_trashBtn_001.png");
auto deleteBtn = CCMenuItemSpriteExtra::create(
deleteSpr, this, menu_selector(BackupItem::onDelete)
);
menu->addChild(viewBtn);
menu->addChild(deleteBtn);

auto restoreSpr = CCSprite::createWithSpriteFrameName("GJ_undoBtn_001.png");
auto restoreBtn = CCMenuItemSpriteExtra::create(
restoreSpr, this, menu_selector(BackupItem::onRestore)
);
menu->addChild(restoreBtn);

auto deleteSpr = CCSprite::createWithSpriteFrameName("GJ_trashBtn_001.png");
auto deleteBtn = CCMenuItemSpriteExtra::create(
deleteSpr, this, menu_selector(BackupItem::onDelete)
auto viewSpr = CircleButtonSprite::createWithSpriteFrameName(
"eye-white.png"_spr, 1.f, CircleBaseColor::Green, CircleBaseSize::Small
);
menu->addChild(deleteBtn);
auto viewBtn = CCMenuItemSpriteExtra::create(
viewSpr, this, menu_selector(BackupItem::onView)
);
menu->addChild(viewBtn);

if (backup->isAutomated()) {
bg->setColor({ 30, 93, 156 });

auto convertSpr = CCSprite::createWithSpriteFrameName("GJ_rotationControlBtn02_001.png");
auto convertBtn = CCMenuItemSpriteExtra::create(
convertSpr, this, menu_selector(BackupItem::onConvertAutomated)
);
menu->addChild(convertBtn);
}

menu->setLayout(
RowLayout::create()
->setAxisReverse(true)
->setAxisAlignment(AxisAlignment::End)
->setDefaultScaleLimits(.1f, .55f)
);
Expand All @@ -63,9 +73,31 @@ bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) {
return true;
}

void BackupItem::onConvertAutomated(CCObject*) {
createQuickPopup(
"Preserve Backup",
"Do you want to <cj>preserve this automated backup</c>?\n"
"By default, <cy>automated backups are deleted after three newer backups have been made</c>.\n"
"Preserving the backup turns it into a normal backup, <cp>preventing it from being deleted automatically</c>.",
"Cancel", "Preserve",
[this](auto*, bool btn2) {
if (btn2) {
auto res = m_backup->preserveAutomated();
if (!res) {
FLAlertLayer::create(
"Unable to Preserve Backup",
fmt::format("Unable to preserve backup: {}", res.unwrapErr()),
"OK"
)->show();
}
UpdateBackupListEvent().post();
}
}
);
}
void BackupItem::onView(CCObject*) {
auto scene = CCScene::create();
scene->addChild(createViewOnlyEditor(m_backup->getLevel(), [level = m_forLevel]() {
scene->addChild(createViewOnlyEditor(m_backup->getLevel(), [level = m_backup->getOriginalLevel()]() {
auto layer = EditLevelLayer::create(level);
auto popup = BackupListPopup::create(level);
popup->m_scene = layer;
Expand Down Expand Up @@ -122,9 +154,9 @@ void BackupItem::onDelete(CCObject*) {
);
}

BackupItem* BackupItem::create(BackupPtr backup, GJGameLevel* forLevel) {
BackupItem* BackupItem::create(BackupPtr backup) {
auto ret = new BackupItem();
if (ret && ret->init(backup, forLevel)) {
if (ret && ret->init(backup)) {
ret->autorelease();
return ret;
}
Expand Down
6 changes: 3 additions & 3 deletions src/features/backups/BackupItem.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ struct UpdateBackupListEvent : public Event {
class BackupItem : public CCNode {
protected:
BackupPtr m_backup;
GJGameLevel* m_forLevel;

bool init(BackupPtr backup, GJGameLevel* forLevel);
bool init(BackupPtr backup);

void onView(CCObject*);
void onRestore(CCObject*);
void onDelete(CCObject*);
void onConvertAutomated(CCObject*);

public:
static BackupItem* create(BackupPtr backup, GJGameLevel* forLevel);
static BackupItem* create(BackupPtr backup);
};
Loading

0 comments on commit 7614d89

Please sign in to comment.