mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-16 08:36:37 +02:00
human-readable backup labels, Backup Types README section, clippy fixes
This commit is contained in:
@@ -41,6 +41,11 @@ All notable changes to NotAlterra are documented in this file.
|
|||||||
- **Context-aware header path** — the right side of the title bar shows
|
- **Context-aware header path** — the right side of the title bar shows
|
||||||
the relevant file location for the current menu or submenu item (save
|
the relevant file location for the current menu or submenu item (save
|
||||||
folder, backup root, or ini Config\Windows path)
|
folder, backup root, or ini Config\Windows path)
|
||||||
|
- **Human-readable backup labels** — archive filenames are now displayed
|
||||||
|
as `Full Backup — <date>` and `Pre-restore — <date>` in the restore
|
||||||
|
picker instead of raw `.tar.gz` filenames
|
||||||
|
- **Backup Types section** in README — explains Full Backup vs
|
||||||
|
Pre-restore in plain language so users understand the safety flow
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- **"Inspect save files" menu entry** — metadata is now visible inline in
|
- **"Inspect save files" menu entry** — metadata is now visible inline in
|
||||||
|
|||||||
@@ -158,6 +158,27 @@ in the main menu. The path is persisted in `app.ini`.
|
|||||||
path (typical Proton locations are shown under "Where Files Live" above).
|
path (typical Proton locations are shown under "Where Files Live" above).
|
||||||
|
|
||||||
|
|
||||||
|
## Backup Types
|
||||||
|
|
||||||
|
NotAlterra creates two kinds of save backups. The label in the restore
|
||||||
|
picker tells you which is which.
|
||||||
|
|
||||||
|
| Label | How it's created |
|
||||||
|
|---|---|
|
||||||
|
| `Full Backup — <date>` | Manually via **Create full backup** in the main menu. |
|
||||||
|
| `Pre-restore — <date>` | Automatically when you use **Restore full backup**. |
|
||||||
|
|
||||||
|
When you use **Restore full backup**, NotAlterra automatically takes a
|
||||||
|
safety snapshot of your current saves **before** it does anything. This is
|
||||||
|
called a **Pre-restore** backup.
|
||||||
|
|
||||||
|
If the restore doesn't go as expected, your old saves haven't disappeared —
|
||||||
|
they're right there in the restore picker labeled `Pre-restore — <date>`.
|
||||||
|
You can restore from that snapshot just like any other backup.
|
||||||
|
|
||||||
|
Pre-restore snapshots are never deleted automatically. Once you're happy
|
||||||
|
with the restore, you can delete them manually from the file system.
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
- Runs in your user context — no admin privileges required
|
- Runs in your user context — no admin privileges required
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ description: Verification checklist for code changes — always run before claim
|
|||||||
|
|
||||||
Before reporting any task as complete, verify:
|
Before reporting any task as complete, verify:
|
||||||
|
|
||||||
1. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions.
|
1. **Format** — `cargo fmt --all -- --check` produces no output (pass).
|
||||||
2. **Compilation** — `cargo check --workspace` must pass with no errors.
|
2. **Clippy** — `cargo clippy --workspace -- -D warnings` passes with zero errors.
|
||||||
3. **Git status** — no uncommitted changes unless intentionally deferred.
|
3. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions.
|
||||||
4. **Remote parity** — `git log --oneline -1` matches `origin/master`.
|
4. **Compilation** — `cargo check --workspace` must pass with no errors.
|
||||||
5. **Claim specificity** — report exactly what was done and what was verified, not what was assumed.
|
5. **Tests** — `cargo test --workspace` passes all tests.
|
||||||
|
6. **Git status** — no uncommitted changes unless intentionally deferred.
|
||||||
|
7. **Remote parity** — `git log --oneline -1` matches `origin/master`.
|
||||||
|
8. **Claim specificity** — report exactly what was done and what was verified, not what was assumed.
|
||||||
|
|
||||||
Do not skip the check script. Do not assume prior passes still hold.
|
Do not skip the check script. Do not assume prior passes still hold.
|
||||||
|
|||||||
+76
-2
@@ -143,6 +143,9 @@ fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) {
|
|||||||
tui_state.live_save_count = live;
|
tui_state.live_save_count = live;
|
||||||
tui_state.backup_count = bak;
|
tui_state.backup_count = bak;
|
||||||
tui_state.has_ini_backup = ini;
|
tui_state.has_ini_backup = ini;
|
||||||
|
tui_state.context_path = Some(
|
||||||
|
crate::config::get_backup_root().display().to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main loop ──────────────────────────────────────────────────────────────
|
// ── main loop ──────────────────────────────────────────────────────────────
|
||||||
@@ -239,6 +242,20 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
|||||||
let mut menu_state = ListState::default().with_selected(Some(0));
|
let mut menu_state = ListState::default().with_selected(Some(0));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Main menu: show path based on highlighted item
|
||||||
|
{
|
||||||
|
let sel = menu_state.selected().unwrap_or(0);
|
||||||
|
let br = crate::config::get_backup_root().to_string_lossy().to_string();
|
||||||
|
let ini_path_str = app.save_folder.as_ref().and_then(|sf| {
|
||||||
|
discovery::derive_ini_path(sf).map(|p| p.to_string_lossy().to_string())
|
||||||
|
});
|
||||||
|
app.tui_state.context_path = match sel {
|
||||||
|
0 | 1 => None, // save path (fallback)
|
||||||
|
3..=5 => Some(br), // backup root
|
||||||
|
7 => ini_path_str, // ini Config\Windows path
|
||||||
|
_ => Some(String::new()), // blank / disclaimer / exit → no path
|
||||||
|
};
|
||||||
|
}
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
let cols = f.area().width;
|
let cols = f.area().width;
|
||||||
let rows = f.area().height;
|
let rows = f.area().height;
|
||||||
@@ -696,6 +713,8 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Recover picker: browsing .bak files in the live save folder → show save path
|
||||||
|
app.tui_state.context_path = None;
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
tui::draw_picker_split(
|
tui::draw_picker_split(
|
||||||
f,
|
f,
|
||||||
@@ -911,7 +930,7 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.file_name().unwrap().to_string_lossy();
|
let name = p.file_name().unwrap().to_string_lossy();
|
||||||
format!(" {name}")
|
format!(" {}", format_backup_label(&name))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
|
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
|
||||||
@@ -919,6 +938,10 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
|||||||
let mut state = ListState::default().with_selected(Some(0));
|
let mut state = ListState::default().with_selected(Some(0));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Restore picker: show backup root in header
|
||||||
|
app.tui_state.context_path = Some(
|
||||||
|
crate::config::get_backup_root().to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
|
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
|
||||||
})?;
|
})?;
|
||||||
@@ -1021,7 +1044,20 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
|
|||||||
const INI_SKIP: &[usize] = &[3];
|
const INI_SKIP: &[usize] = &[3];
|
||||||
let ini_max = 4usize;
|
let ini_max = 4usize;
|
||||||
|
|
||||||
|
let ini_path_str = app.save_folder.as_ref().and_then(|sf| {
|
||||||
|
discovery::derive_ini_path(sf).map(|p| p.to_string_lossy().to_string())
|
||||||
|
});
|
||||||
loop {
|
loop {
|
||||||
|
// Ini submenu: show path based on highlighted item
|
||||||
|
{
|
||||||
|
let sel = state.selected().unwrap_or(0);
|
||||||
|
let br = crate::config::get_backup_root().to_string_lossy().to_string();
|
||||||
|
app.tui_state.context_path = match sel {
|
||||||
|
0 | 1 => Some(br), // backup root (backup/restore)
|
||||||
|
2 => ini_path_str.clone(), // Config\Windows path (delete)
|
||||||
|
_ => Some(String::new()), // blank / back → nothing
|
||||||
|
};
|
||||||
|
}
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
tui::draw_sub_menu(
|
tui::draw_sub_menu(
|
||||||
f,
|
f,
|
||||||
@@ -1142,7 +1178,7 @@ fn ini_restore_action<B: Backend>(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.file_name().unwrap().to_string_lossy();
|
let name = p.file_name().unwrap().to_string_lossy();
|
||||||
format!(" {name}")
|
format!(" {}", format_backup_label(&name))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
|
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
|
||||||
@@ -1150,6 +1186,10 @@ fn ini_restore_action<B: Backend>(
|
|||||||
let mut state = ListState::default().with_selected(Some(0));
|
let mut state = ListState::default().with_selected(Some(0));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Restore picker: show backup root in header
|
||||||
|
app.tui_state.context_path = Some(
|
||||||
|
crate::config::get_backup_root().to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
|
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
|
||||||
})?;
|
})?;
|
||||||
@@ -1584,6 +1624,40 @@ fn confirm_modal<B: Backend>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a backup archive filename into a human-readable label.
|
||||||
|
/// `snapshot_2026-06-09_125430_001.tar.gz` → `Full Backup — 2026-Jun-09 12:54`
|
||||||
|
fn format_backup_label(filename: &str) -> String {
|
||||||
|
let stripped = filename.strip_suffix(".tar.gz").unwrap_or(filename);
|
||||||
|
let (prefix, rest) = match stripped.split_once('_') {
|
||||||
|
Some((p, r)) => (p, r),
|
||||||
|
None => return filename.to_string(),
|
||||||
|
};
|
||||||
|
let label = match prefix {
|
||||||
|
"snapshot" => "Full Backup",
|
||||||
|
"pre" if stripped.contains("pre_restore") => "Pre-restore",
|
||||||
|
"ini" => "INI Backup",
|
||||||
|
"migrated" => "Migrated",
|
||||||
|
_ => prefix,
|
||||||
|
};
|
||||||
|
// rest looks like: "2026-06-09_125430_001" or "notalterra_copy_..."
|
||||||
|
// Try to parse the timestamp portion (first YYYY-MM-DD_HHMMSS segment)
|
||||||
|
let date_str = if let Some(pos) = rest.find(|c: char| c.is_ascii_digit()) {
|
||||||
|
let slice = &rest[pos..];
|
||||||
|
if slice.len() >= 17 {
|
||||||
|
let date_part = &slice[..10]; // 2026-06-09
|
||||||
|
let time_part = &slice[11..17]; // 125430
|
||||||
|
let h = &time_part[..2];
|
||||||
|
let m = &time_part[2..4];
|
||||||
|
format!("{date_part} {h}:{m}")
|
||||||
|
} else {
|
||||||
|
rest.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rest.to_string()
|
||||||
|
};
|
||||||
|
format!("{label} — {date_str}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a rectangle centered in the parent area by percentage.
|
/// Create a rectangle centered in the parent area by percentage.
|
||||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
let popup_layout = Layout::default()
|
let popup_layout = Layout::default()
|
||||||
|
|||||||
+33
-11
@@ -34,6 +34,9 @@ pub struct AppState {
|
|||||||
pub backup_count: usize,
|
pub backup_count: usize,
|
||||||
/// Whether a .ini backup exists
|
/// Whether a .ini backup exists
|
||||||
pub has_ini_backup: bool,
|
pub has_ini_backup: bool,
|
||||||
|
/// Context-specific path shown on the right side of the header bar.
|
||||||
|
/// When `None`, falls back to `save_path`.
|
||||||
|
pub context_path: Option<String>,
|
||||||
/// Version string for the header
|
/// Version string for the header
|
||||||
pub version: String,
|
pub version: String,
|
||||||
/// Last operation result (for the status bar)
|
/// Last operation result (for the status bar)
|
||||||
@@ -63,6 +66,7 @@ impl Default for AppState {
|
|||||||
live_save_count: 0,
|
live_save_count: 0,
|
||||||
backup_count: 0,
|
backup_count: 0,
|
||||||
has_ini_backup: false,
|
has_ini_backup: false,
|
||||||
|
context_path: None,
|
||||||
version: String::new(),
|
version: String::new(),
|
||||||
status_message: None,
|
status_message: None,
|
||||||
status_style: StatusStyle::Neutral,
|
status_style: StatusStyle::Neutral,
|
||||||
@@ -594,17 +598,35 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
|
|||||||
]);
|
]);
|
||||||
f.render_widget(Paragraph::new(title_line), chunks[0]);
|
f.render_widget(Paragraph::new(title_line), chunks[0]);
|
||||||
|
|
||||||
let path_line = if let Some(ref path) = app.save_path {
|
// Header path priority:
|
||||||
let max_w = chunks[1].width.saturating_sub(2) as usize;
|
// 1. context_path = Some("path") → show that path
|
||||||
let display = truncate_path_tail(path, max_w);
|
// 2. context_path = Some("") → show nothing (blank/disclaimer/exit)
|
||||||
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
|
// 3. context_path = None → fall back to save_path
|
||||||
.alignment(Alignment::Right)
|
let max_w = chunks[1].width.saturating_sub(2) as usize;
|
||||||
} else {
|
let path_line = match &app.context_path {
|
||||||
Paragraph::new(Span::styled(
|
Some(p) if p.is_empty() => {
|
||||||
"no save folder selected",
|
// Show nothing — blank line, disclaimer, or exit
|
||||||
Style::default().fg(Color::DarkGray),
|
Paragraph::new(Span::raw(""))
|
||||||
))
|
}
|
||||||
.alignment(Alignment::Right)
|
Some(p) => {
|
||||||
|
let display = truncate_path_tail(p, max_w);
|
||||||
|
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
|
||||||
|
.alignment(Alignment::Right)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Fall back to save_path
|
||||||
|
if let Some(ref save) = app.save_path {
|
||||||
|
let display = truncate_path_tail(save, max_w);
|
||||||
|
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
|
||||||
|
.alignment(Alignment::Right)
|
||||||
|
} else {
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
"no save folder selected",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Right)
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
f.render_widget(path_line, chunks[1]);
|
f.render_widget(path_line, chunks[1]);
|
||||||
f.render_widget(header_block, area);
|
f.render_widget(header_block, area);
|
||||||
|
|||||||
Reference in New Issue
Block a user