Skip to content

Commit 7614d89

Browse files
committed
auto-saving + auto-backups!
1 parent f12a455 commit 7614d89

File tree

7 files changed

+216
-25
lines changed

7 files changed

+216
-25
lines changed

mod.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@
126126
"name": "Show Grid on Size Change",
127127
"description": "If the grid is currently hidden and you use the controls to change its size, its toggled on",
128128
"platforms": ["windows", "android64"]
129+
},
130+
"auto-save": {
131+
"type": "bool",
132+
"default": true,
133+
"name": "Auto-save",
134+
"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"
129135
}
130136
},
131137
"tags": ["editor", "enhancement", "utility", "customization"]

src/features/backups/AutoSave.cpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#include <Geode/modify/EditorUI.hpp>
2+
#include "Backup.hpp"
3+
4+
using namespace geode::prelude;
5+
6+
class $modify(AutoSaveUI, EditorUI) {
7+
struct Fields {
8+
size_t secondsSinceLastAutoSave = 0;
9+
Ref<Notification> autoSaveCountdownNotification;
10+
};
11+
12+
bool init(LevelEditorLayer* editor) {
13+
if (!EditorUI::init(editor))
14+
return false;
15+
16+
if (Mod::get()->template getSettingValue<bool>("auto-save")) {
17+
this->schedule(schedule_selector(AutoSaveUI::onAutoSaveTick), 1);
18+
}
19+
20+
return true;
21+
}
22+
void onAutoSaveTick(float) {
23+
constexpr size_t COUNTDOWN = 5;
24+
constexpr size_t AUTO_SAVE_INTERVAL = 20;
25+
26+
m_fields->secondsSinceLastAutoSave += 1;
27+
if (m_fields->secondsSinceLastAutoSave > AUTO_SAVE_INTERVAL - COUNTDOWN) {
28+
// Make sure the autosave notification exists
29+
if (!m_fields->autoSaveCountdownNotification) {
30+
m_fields->autoSaveCountdownNotification = Notification::create(
31+
"", CCSprite::createWithSpriteFrameName("GJ_timeIcon_001.png"), 0
32+
);
33+
m_fields->autoSaveCountdownNotification->show();
34+
}
35+
36+
// The actual save
37+
if (m_fields->secondsSinceLastAutoSave > AUTO_SAVE_INTERVAL) {
38+
m_fields->autoSaveCountdownNotification->setString("Saving...");
39+
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Loading);
40+
m_fields->secondsSinceLastAutoSave = 0;
41+
42+
// Run on next frame to ensure this one gets rendered first
43+
Loader::get()->queueInMainThread([this] {
44+
// Save level
45+
auto layer = EditorPauseLayer::create(m_editorLayer);
46+
layer->saveLevel();
47+
layer->release();
48+
49+
// Create backup
50+
auto res = Backup::create(m_editorLayer->m_level, true);
51+
52+
// Cleanup
53+
auto clean = Backup::cleanAutomated(m_editorLayer->m_level);
54+
if (!clean) {
55+
log::error("Failed to clean up automated backups: {}", clean.unwrapErr());
56+
}
57+
58+
// Show error / finished
59+
if (!res) {
60+
log::error("Backing level up failed: {}", res.unwrapErr());
61+
m_fields->autoSaveCountdownNotification->setString("Backing up failed!");
62+
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Error);
63+
}
64+
else {
65+
m_fields->autoSaveCountdownNotification->setString("Level saved & backed up!");
66+
m_fields->autoSaveCountdownNotification->setIcon(NotificationIcon::Success);
67+
}
68+
69+
// Hide the notification
70+
m_fields->autoSaveCountdownNotification->hide();
71+
m_fields->autoSaveCountdownNotification = nullptr;
72+
});
73+
}
74+
// Warning countdown
75+
else {
76+
m_fields->autoSaveCountdownNotification->setString(
77+
fmt::format("Saving in {} seconds", AUTO_SAVE_INTERVAL - m_fields->secondsSinceLastAutoSave)
78+
);
79+
}
80+
}
81+
}
82+
};

src/features/backups/Backup.cpp

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,28 @@
77

88
struct BackupMetadata final {
99
typename Backup::TimePoint createTime = Backup::Clock::now();
10+
bool automated = false;
1011
};
1112

1213
template <>
1314
struct matjson::Serialize<BackupMetadata> {
1415
static matjson::Value to_json(BackupMetadata const& meta) {
1516
return matjson::Object({
16-
{ "create-time", std::chrono::duration_cast<Backup::TimeUnit>(meta.createTime.time_since_epoch()).count() }
17+
{ "create-time", std::chrono::duration_cast<Backup::TimeUnit>(meta.createTime.time_since_epoch()).count() },
18+
{ "automated", meta.automated },
1719
});
1820
}
1921
static BackupMetadata from_json(matjson::Value const& value) {
2022
auto meta = BackupMetadata();
2123
auto obj = value.as_object();
24+
2225
meta.createTime = Backup::TimePoint(Backup::TimeUnit(obj["create-time"].as_int()));
26+
27+
// Parsing should be as fault-tolerant as possible
28+
if (obj.contains("automated")) {
29+
meta.automated = obj["automated"].as_bool();
30+
}
31+
2332
return meta;
2433
}
2534
static bool is_json(matjson::Value const& value) {
@@ -30,9 +39,15 @@ struct matjson::Serialize<BackupMetadata> {
3039
GJGameLevel* Backup::getLevel() const {
3140
return m_level;
3241
}
42+
GJGameLevel* Backup::getOriginalLevel() const {
43+
return m_forLevel;
44+
}
3345
typename Backup::TimePoint Backup::getCreateTime() const {
3446
return m_createTime;
3547
}
48+
bool Backup::isAutomated() const {
49+
return m_automated;
50+
}
3651

3752
Result<> Backup::restoreThis() {
3853
// Add changes to memory
@@ -60,6 +75,15 @@ Result<> Backup::deleteThis() {
6075
}
6176
return Ok();
6277
}
78+
Result<> Backup::preserveAutomated() {
79+
m_automated = false;
80+
auto metadata = BackupMetadata {
81+
.createTime = m_createTime,
82+
.automated = false,
83+
};
84+
GEODE_UNWRAP(file::writeToJson(m_directory / "meta.json", metadata).expect("Unable to save metadata: {error}"));
85+
return Ok();
86+
}
6387

6488
Result<std::shared_ptr<Backup>> Backup::load(ghc::filesystem::path const& dir, GJGameLevel* forLevel) {
6589
GEODE_UNWRAP_INTO(auto level, gmd::importGmdAsLevel(dir / "level.gmd").expect("Unable to read level file: {error}"));
@@ -68,8 +92,9 @@ Result<std::shared_ptr<Backup>> Backup::load(ghc::filesystem::path const& dir, G
6892
auto backup = std::make_shared<Backup>();
6993
backup->m_level = level;
7094
backup->m_forLevel = forLevel;
71-
backup->m_createTime = meta.createTime;
7295
backup->m_directory = dir;
96+
backup->m_createTime = meta.createTime;
97+
backup->m_automated = meta.automated;
7398
return Ok(backup);
7499
}
75100
std::vector<std::shared_ptr<Backup>> Backup::load(GJGameLevel* level) {
@@ -85,10 +110,15 @@ std::vector<std::shared_ptr<Backup>> Backup::load(GJGameLevel* level) {
85110
}
86111
res.push_back(*b);
87112
}
113+
114+
// This takes advantage of the fact that directories are sorted
115+
// alphabetically and the date format is oldest-newest when sorted that way
116+
// Might want to consider a proper sort-by-newest-first
88117
std::reverse(res.begin(), res.end());
118+
89119
return res;
90120
}
91-
Result<> Backup::create(GJGameLevel* level) {
121+
Result<> Backup::create(GJGameLevel* level, bool automated) {
92122
if (level->m_levelType != GJLevelType::Editor) {
93123
return Err("Can not backup a non-editor level");
94124
}
@@ -105,9 +135,45 @@ Result<> Backup::create(GJGameLevel* level) {
105135
GEODE_UNWRAP(gmd::exportLevelAsGmd(level, dir / "level.gmd").expect("Unable to save level: {error}"));
106136

107137
auto metadata = BackupMetadata {
108-
.createTime = time
138+
.createTime = time,
139+
.automated = automated,
109140
};
110141
GEODE_UNWRAP(file::writeToJson(dir / "meta.json", metadata).expect("Unable to save metadata: {error}"));
111142

112143
return Ok();
113144
}
145+
Result<> Backup::cleanAutomated(GJGameLevel* level) {
146+
std::vector<std::shared_ptr<Backup>> automated;
147+
for (auto folder : file::readDirectory(save::getCurrentLevelSaveDir(level) / "backups").unwrapOrDefault()) {
148+
if (!ghc::filesystem::is_directory(folder)) {
149+
continue;
150+
}
151+
auto b = Backup::load(folder, level);
152+
if (!b) {
153+
continue;
154+
}
155+
auto backup = *b;
156+
if (backup->m_automated) {
157+
automated.push_back(backup);
158+
}
159+
}
160+
161+
// This takes advantage of the fact that directories are sorted
162+
// alphabetically and the date format is oldest-newest when sorted that way
163+
// Might want to consider a proper sort-by-newest-first
164+
std::reverse(automated.begin(), automated.end());
165+
166+
constexpr size_t MAX_AUTOMATED_COUNT = 3;
167+
168+
// Do the cleanup
169+
size_t ix = 0;
170+
for (auto backup : automated) {
171+
// Keep only the MAX_AUTOMATED_COUNT newest automated backups
172+
if (++ix > MAX_AUTOMATED_COUNT) {
173+
GEODE_UNWRAP(backup->deleteThis());
174+
}
175+
}
176+
automated.erase(automated.begin() + MAX_AUTOMATED_COUNT, automated.end());
177+
178+
return Ok();
179+
}

src/features/backups/Backup.hpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@ class Backup final {
1515
Ref<GJGameLevel> m_level;
1616
Ref<GJGameLevel> m_forLevel;
1717
TimePoint m_createTime;
18+
bool m_automated;
1819

1920
public:
2021
static Result<std::shared_ptr<Backup>> load(ghc::filesystem::path const& dir, GJGameLevel* forLevel);
2122
static std::vector<std::shared_ptr<Backup>> load(GJGameLevel* level);
22-
static Result<> create(GJGameLevel* level);
23+
static Result<> create(GJGameLevel* level, bool automated);
24+
static Result<> cleanAutomated(GJGameLevel* level);
2325

2426
GJGameLevel* getLevel() const;
27+
GJGameLevel* getOriginalLevel() const;
2528
TimePoint getCreateTime() const;
29+
bool isAutomated() const;
2630

2731
Result<> restoreThis();
2832
Result<> deleteThis();
33+
Result<> preserveAutomated();
2934
};
3035
using BackupPtr = std::shared_ptr<Backup>;

src/features/backups/BackupItem.cpp

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
#include <fmt/chrono.h>
44
#include <utils/EditorViewOnlyMode.hpp>
55

6-
bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) {
6+
bool BackupItem::init(BackupPtr backup) {
77
if (!CCNode::init())
88
return false;
99

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

1212
m_backup = backup;
13-
m_forLevel = forLevel;
1413

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

36-
auto viewSpr = CircleButtonSprite::createWithSpriteFrameName(
37-
"eye-white.png"_spr, 1.f, CircleBaseColor::Green, CircleBaseSize::Small
38-
);
39-
auto viewBtn = CCMenuItemSpriteExtra::create(
40-
viewSpr, this, menu_selector(BackupItem::onView)
35+
auto deleteSpr = CCSprite::createWithSpriteFrameName("GJ_trashBtn_001.png");
36+
auto deleteBtn = CCMenuItemSpriteExtra::create(
37+
deleteSpr, this, menu_selector(BackupItem::onDelete)
4138
);
42-
menu->addChild(viewBtn);
39+
menu->addChild(deleteBtn);
4340

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

50-
auto deleteSpr = CCSprite::createWithSpriteFrameName("GJ_trashBtn_001.png");
51-
auto deleteBtn = CCMenuItemSpriteExtra::create(
52-
deleteSpr, this, menu_selector(BackupItem::onDelete)
47+
auto viewSpr = CircleButtonSprite::createWithSpriteFrameName(
48+
"eye-white.png"_spr, 1.f, CircleBaseColor::Green, CircleBaseSize::Small
5349
);
54-
menu->addChild(deleteBtn);
50+
auto viewBtn = CCMenuItemSpriteExtra::create(
51+
viewSpr, this, menu_selector(BackupItem::onView)
52+
);
53+
menu->addChild(viewBtn);
54+
55+
if (backup->isAutomated()) {
56+
bg->setColor({ 30, 93, 156 });
57+
58+
auto convertSpr = CCSprite::createWithSpriteFrameName("GJ_rotationControlBtn02_001.png");
59+
auto convertBtn = CCMenuItemSpriteExtra::create(
60+
convertSpr, this, menu_selector(BackupItem::onConvertAutomated)
61+
);
62+
menu->addChild(convertBtn);
63+
}
5564

5665
menu->setLayout(
5766
RowLayout::create()
67+
->setAxisReverse(true)
5868
->setAxisAlignment(AxisAlignment::End)
5969
->setDefaultScaleLimits(.1f, .55f)
6070
);
@@ -63,9 +73,31 @@ bool BackupItem::init(BackupPtr backup, GJGameLevel* forLevel) {
6373
return true;
6474
}
6575

76+
void BackupItem::onConvertAutomated(CCObject*) {
77+
createQuickPopup(
78+
"Preserve Backup",
79+
"Do you want to <cj>preserve this automated backup</c>?\n"
80+
"By default, <cy>automated backups are deleted after three newer backups have been made</c>.\n"
81+
"Preserving the backup turns it into a normal backup, <cp>preventing it from being deleted automatically</c>.",
82+
"Cancel", "Preserve",
83+
[this](auto*, bool btn2) {
84+
if (btn2) {
85+
auto res = m_backup->preserveAutomated();
86+
if (!res) {
87+
FLAlertLayer::create(
88+
"Unable to Preserve Backup",
89+
fmt::format("Unable to preserve backup: {}", res.unwrapErr()),
90+
"OK"
91+
)->show();
92+
}
93+
UpdateBackupListEvent().post();
94+
}
95+
}
96+
);
97+
}
6698
void BackupItem::onView(CCObject*) {
6799
auto scene = CCScene::create();
68-
scene->addChild(createViewOnlyEditor(m_backup->getLevel(), [level = m_forLevel]() {
100+
scene->addChild(createViewOnlyEditor(m_backup->getLevel(), [level = m_backup->getOriginalLevel()]() {
69101
auto layer = EditLevelLayer::create(level);
70102
auto popup = BackupListPopup::create(level);
71103
popup->m_scene = layer;
@@ -122,9 +154,9 @@ void BackupItem::onDelete(CCObject*) {
122154
);
123155
}
124156

125-
BackupItem* BackupItem::create(BackupPtr backup, GJGameLevel* forLevel) {
157+
BackupItem* BackupItem::create(BackupPtr backup) {
126158
auto ret = new BackupItem();
127-
if (ret && ret->init(backup, forLevel)) {
159+
if (ret && ret->init(backup)) {
128160
ret->autorelease();
129161
return ret;
130162
}

src/features/backups/BackupItem.hpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ struct UpdateBackupListEvent : public Event {
1212
class BackupItem : public CCNode {
1313
protected:
1414
BackupPtr m_backup;
15-
GJGameLevel* m_forLevel;
1615

17-
bool init(BackupPtr backup, GJGameLevel* forLevel);
16+
bool init(BackupPtr backup);
1817

1918
void onView(CCObject*);
2019
void onRestore(CCObject*);
2120
void onDelete(CCObject*);
21+
void onConvertAutomated(CCObject*);
2222

2323
public:
24-
static BackupItem* create(BackupPtr backup, GJGameLevel* forLevel);
24+
static BackupItem* create(BackupPtr backup);
2525
};

0 commit comments

Comments
 (0)