diff --git a/mod.json b/mod.json index 8142b5a..d01575f 100644 --- a/mod.json +++ b/mod.json @@ -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"] diff --git a/src/features/backups/AutoSave.cpp b/src/features/backups/AutoSave.cpp new file mode 100644 index 0000000..8054a87 --- /dev/null +++ b/src/features/backups/AutoSave.cpp @@ -0,0 +1,82 @@ +#include +#include "Backup.hpp" + +using namespace geode::prelude; + +class $modify(AutoSaveUI, EditorUI) { + struct Fields { + size_t secondsSinceLastAutoSave = 0; + Ref autoSaveCountdownNotification; + }; + + bool init(LevelEditorLayer* editor) { + if (!EditorUI::init(editor)) + return false; + + if (Mod::get()->template getSettingValue("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) + ); + } + } + } +}; diff --git a/src/features/backups/Backup.cpp b/src/features/backups/Backup.cpp index 7285dd5..91bdee2 100644 --- a/src/features/backups/Backup.cpp +++ b/src/features/backups/Backup.cpp @@ -7,19 +7,28 @@ struct BackupMetadata final { typename Backup::TimePoint createTime = Backup::Clock::now(); + bool automated = false; }; template <> struct matjson::Serialize { static matjson::Value to_json(BackupMetadata const& meta) { return matjson::Object({ - { "create-time", std::chrono::duration_cast(meta.createTime.time_since_epoch()).count() } + { "create-time", std::chrono::duration_cast(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) { @@ -30,9 +39,15 @@ struct matjson::Serialize { 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 @@ -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> 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}")); @@ -68,8 +92,9 @@ Result> Backup::load(ghc::filesystem::path const& dir, G auto backup = std::make_shared(); 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> Backup::load(GJGameLevel* level) { @@ -85,10 +110,15 @@ std::vector> 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"); } @@ -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> 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(); +} diff --git a/src/features/backups/Backup.hpp b/src/features/backups/Backup.hpp index b1626bc..8835bf6 100644 --- a/src/features/backups/Backup.hpp +++ b/src/features/backups/Backup.hpp @@ -15,16 +15,21 @@ class Backup final { Ref m_level; Ref m_forLevel; TimePoint m_createTime; + bool m_automated; public: static Result> load(ghc::filesystem::path const& dir, GJGameLevel* forLevel); static std::vector> 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; diff --git a/src/features/backups/BackupItem.cpp b/src/features/backups/BackupItem.cpp index 6c8810e..6b050d1 100644 --- a/src/features/backups/BackupItem.cpp +++ b/src/features/backups/BackupItem.cpp @@ -3,14 +3,13 @@ #include #include -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 }); @@ -33,13 +32,11 @@ 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( @@ -47,14 +44,27 @@ bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) { ); 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) ); @@ -63,9 +73,31 @@ bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) { return true; } +void BackupItem::onConvertAutomated(CCObject*) { + createQuickPopup( + "Preserve Backup", + "Do you want to preserve this automated backup?\n" + "By default, automated backups are deleted after three newer backups have been made.\n" + "Preserving the backup turns it into a normal backup, preventing it from being deleted automatically.", + "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; @@ -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; } diff --git a/src/features/backups/BackupItem.hpp b/src/features/backups/BackupItem.hpp index f8f6b19..f47fb7a 100644 --- a/src/features/backups/BackupItem.hpp +++ b/src/features/backups/BackupItem.hpp @@ -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); }; diff --git a/src/features/backups/BackupListPopup.cpp b/src/features/backups/BackupListPopup.cpp index 5e6ca03..c48e69f 100644 --- a/src/features/backups/BackupListPopup.cpp +++ b/src/features/backups/BackupListPopup.cpp @@ -66,14 +66,14 @@ void BackupListPopup::updateList() { m_statusLabel->setVisible(true); } else for (auto backup : backups) { - m_scrollLayer->m_contentLayer->addChild(BackupItem::create(backup, m_level)); + m_scrollLayer->m_contentLayer->addChild(BackupItem::create(backup)); } m_scrollLayer->m_contentLayer->updateLayout(); } void BackupListPopup::onNewBackup(CCObject*) { - auto res = Backup::create(m_level); + auto res = Backup::create(m_level, false); if (!res) { FLAlertLayer::create( "Unable to Backup",