Skip to content

Commit 62ae25d

Browse files
authored
Merge pull request #115 from shinokada/feat/v3.8
feat: v3.8.0 Sync & Backup * **New Features** * Rebranded "Gist Management" → "Sync & Backup" with guided export/restore and remote sync flows; checklist selections persist between runs. * Local ZIP export/restore with conflict detection, overwrite prompts, and atomic file operations. * Remote push/pull backup to GitHub Gists with in-place updates and deletion/tombstone handling. * New interactive checklist UI for multi-select sync choices. * **Bug Fixes** * Improved terminal rendering to account for styled (ANSI) text; safer overwrite/conflict handling. * **Tests** * Comprehensive unit tests for backups, gist sync, sync preferences, and checklist UI. * **Documentation** * Added 3.8.0 changelog and updated README/docs for Sync & Backup.
2 parents 9720eca + b0b0a89 commit 62ae25d

File tree

17 files changed

+3111
-446
lines changed

17 files changed

+3111
-446
lines changed

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to TERA will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [3.8.0] - 2026-03-10
9+
10+
### ✨ Added
11+
12+
#### Sync & Backup (menu renamed from "Gist Management")
13+
- **Export backup (zip)** — save all selected data to a local zip file; no GitHub token required
14+
- **Restore from backup (zip)** — restore selected categories from a local zip with overwrite warning
15+
- **Sync all data to Gist** — push selected categories to a dedicated secret `tera-data-backup` Gist
16+
- **Restore all data from Gist** — pull selected categories from the `tera-data-backup` Gist
17+
- **Category checklist** — shared checklist UI for all four operations; selections persist in `sync_prefs.json`
18+
- **Overwrite warning** — lists conflicting files before any restore, with Enter to proceed or Esc to cancel
19+
- **`sync_prefs.json`** — new file in the config directory that persists checklist selections across runs
20+
- **`tera-manifest.json`** sentinel in every backup Gist prevents false-positive matches on unrelated Gists
21+
22+
#### Sync categories
23+
24+
| Category | Default |
25+
| ----------------------- | ------- |
26+
| Favorites (playlists) | ✅ on |
27+
| Settings (config.yaml) | ✅ on |
28+
| Ratings & votes | ✅ on |
29+
| Blocklist | ✅ on |
30+
| Station metadata & tags | ✅ on |
31+
| Search history | ❌ off |
32+
33+
### 🔒 Security
34+
- Reject path-traversal filenames in Gist restores (`fav--../../config.yaml` style attacks)
35+
- `FindBackupGist` errors on duplicate backup Gists instead of silently using the first match
36+
37+
### 🐛 Fixed
38+
- Gist sync/restore actions now gate on `gistSyncMgr != nil` (previously showed misleading "token required" error when sync manager init failed)
39+
- `NewBackupManager` and `NewGistSyncManager` failures surfaced as startup warnings in the TUI
40+
- `SaveSyncPrefs` errors shown as non-blocking warnings instead of silently discarded
41+
- Empty checklist selection rejected before export/sync/restore flows begin
42+
- Removed redundant `GetGist` fetch in `Pull` (reuses the full Gist returned by `FindBackupGist`)
43+
844
## [3.1.0]
945

1046
- OS keychain token storage with file-based fallback
@@ -227,3 +263,4 @@ Examples:
227263
---
228264

229265
[3.0.0]: https://github.com/shinokada/tera/releases/tag/v3.0.0
266+
[3.8.0]: https://github.com/shinokada/tera/releases/tag/v3.8.0

docs/README.md

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A terminal-based internet radio player powered by [Radio Browser](https://www.ra
1212
-**Quick Play** - Direct playback from main menu (shortcuts 10-99+)
1313
- 🔊 **Playback Control** - Play/pause with persistent status, adjust volume, and mute during playback
1414
- 🚫 **Block List** - Block unwanted stations from appearing in searches and auto-play
15-
- ☁️ **Gist Sync** - Backup and restore favorites via GitHub Gists
15+
- ☁️ **Sync & Backup** - Export/restore local zip backups and sync all data via GitHub Gists
1616
- 🗳️ **Voting** - Support your favorite stations on Radio Browser
1717
- 📊 **Most Played** - View your listening history sorted by play count, last played, or first played
1818
- 🎨 **Themes** - Choose from predefined themes or customize via unified config
@@ -134,7 +134,7 @@ tera
134134
# 7) Manage Lists - Create/edit/delete favorite lists
135135
# 8) Block List - Manage blocked stations
136136
# 9) I Feel Lucky - Random station by keyword
137-
# 0) Gist Management - Backup/restore via GitHub
137+
# 0) Sync & Backup - Backup/restore data locally or via GitHub
138138
# -) Settings - Configure TERA
139139

140140
# Quick Play (from main menu):
@@ -671,7 +671,7 @@ You can edit this file directly or use the Settings menu.
671671

672672
| Key | Action |
673673
| -------- | ---------------------------- |
674-
| `0` | Gist Management |
674+
| `0` | Sync & Backup |
675675
| `1-9` | Quick select menu item |
676676
| `10-99+` | Quick play from My-favorites |
677677
| `-` | Settings |
@@ -758,22 +758,54 @@ Results are sorted by **votes** (most popular first) and limited to 100 stations
758758
- Press `s` to add to another list
759759
- Press `v` to vote for the station
760760

761-
## Gist Sync
761+
## Sync & Backup
762762

763-
Backup and sync your favorite lists across devices using GitHub Gists.
763+
Back up and sync your data locally or across devices using zip archives and GitHub Gists.
764+
765+
### Export Backup (zip)
766+
767+
Save a local copy of your data with no GitHub account required.
768+
769+
1. From the main menu press `0` → **Sync & Backup**
770+
2. Select **7. Export backup (zip)**
771+
3. Choose which categories to include (favorites, ratings, tags, etc.)
772+
4. Confirm the save path (default: `~/tera-backup-YYYY-MM-DD.zip`)
773+
774+
### Restore from Backup (zip)
775+
776+
1. Select **8. Restore from backup (zip)**
777+
2. Enter the path to your zip file
778+
3. Choose which categories to restore
779+
4. Confirm — you will be warned before any existing files are overwritten
780+
781+
### Sync to Gist
782+
783+
Push all selected data to a dedicated secret GitHub Gist (`tera-data-backup`).
764784

765785
**Quick Setup:**
766-
1. Go to: Main Menu → 0) Gist Management → 6) Token Management
767-
2. Create a GitHub Personal Access Token (with `gist` scope only)
768-
3. Paste token in TERA
769-
4. Create your first gist backup!
786+
1. Go to **0) Sync & Backup → Token Management**
787+
2. Create a GitHub Personal Access Token with `gist` scope
788+
3. Paste the token in TERA
789+
4. Select **9. Sync all data to Gist** and choose categories
770790

771-
**Features:**
772-
- Create secret or public gists
773-
- View your gist history
774-
- Recover favorites from any gist URL
775-
- Update gist descriptions
776-
- Delete old backups
791+
### Restore from Gist
792+
793+
1. Select **10. Restore all data from Gist**
794+
2. TERA fetches the `tera-data-backup` Gist and shows available categories
795+
3. Choose what to restore — you will be warned before overwriting
796+
797+
### Sync Categories
798+
799+
| Category | Default |
800+
| ----------------------- | ------- |
801+
| Favorites (playlists) | ✅ on |
802+
| Settings (config.yaml) | ✅ on |
803+
| Ratings & votes | ✅ on |
804+
| Blocklist | ✅ on |
805+
| Station metadata & tags | ✅ on |
806+
| Search history | ❌ off |
807+
808+
Category selections are saved in `sync_prefs.json` and reused on the next run.
777809

778810
**Documentation:**
779811
- [Gist Setup Guide](/docs/GIST_SETUP.md) - Token setup and security

v3/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ All notable changes to TERA will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [3.8.0] - unreleased
9+
10+
### Added (Phase 1 — Core Storage)
11+
- `storage.SyncPrefs` — per-category backup preferences persisted to `sync_prefs.json`; `LoadSyncPrefs()` / `SaveSyncPrefs()` with atomic write and defaults (search history off).
12+
- `storage.BackupManager` — zip-based export and restore using `archive/zip` (no new dependencies); `Export()`, `Restore()`, `ListArchiveCategories()`, `ConflictingFiles()`, `ResolveBackupPath()`.
13+
- `storage.RestoreConflictError` — typed error returned when a restore would overwrite existing files; carries the list of conflicting paths for the overwrite-warning UI.
14+
- `storage.GistSyncManager` — Gist-based push/pull of user data to a dedicated secret Gist (`tera-data-backup`); `Push()`, `Pull()`, `FindBackupGist()`, `AvailableCategories()`.
15+
- `gist.Client.UpdateGistFiles()` — new PATCH method to replace file contents of an existing Gist, needed by `GistSyncManager.Push()`.
16+
17+
### Internal
18+
- `gist_filename` / `gistFilenameToRelPath` encode the config-relative path ↔ Gist filename mapping (e.g. `data/favorites/Jazz.json``fav--Jazz.json`) since Gist filenames cannot contain `/`.
19+
- Unit tests: `sync_prefs_test.go`, `backup_test.go`, `gist_sync_test.go`.
20+
21+
### Added (Phase 2 — UI Components)
22+
- `ui/components.ChecklistModel` — reusable bubbletea checklist component with cursor navigation (`↑↓`/`jk`), Space toggle, `a` toggle-all, Enter confirm, Esc/q cancel.
23+
- `ChecklistConfirmedMsg` / `ChecklistCancelledMsg` — typed tea messages emitted on confirm and cancel.
24+
- Unit tests: `checklist_test.go` (navigation, toggle, toggle-all, confirm/cancel, helpers, View).
25+
26+
### Changed (Phase 3 — Gist Screen Integration)
27+
- `ui/gist.go`: Renamed screen title to `Sync & Backup`; added 8 new states for export/restore/sync flows; implemented export backup (checklist → path prompt → zip), restore from zip (path prompt → inspect → checklist → conflict check → overwrite warn/extract), sync to Gist (checklist → push), restore from Gist (fetch available categories → checklist → conflict check → overwrite warn/pull); overwrite warning prompt shared by zip and Gist flows; checklist selections persisted to `sync_prefs.json` on confirm.
28+
- `ui/app.go`: Menu label `Gist Management``Sync & Backup` via `syncBackupMenuLabel` constant.
29+
30+
---
31+
832
## [3.7.1] - 2026-03-09
933

1034
### Fixed

v3/README.md

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A terminal-based internet radio player powered by [Radio Browser](https://www.ra
1313
- 🕐 **Recently Played** - Last N stations shown below Quick Play Favorites in the main menu
1414
- 🔊 **Playback Control** - Play/pause with persistent status, adjust volume, and mute during playback
1515
- 🚫 **Block List** - Block unwanted stations from appearing in searches and auto-play
16-
- ☁️ **Gist Sync** - Backup and restore favorites via GitHub Gists
16+
- ☁️ **Sync & Backup** - Export/restore local zip backups and sync all data via GitHub Gists
1717
- 🗳️ **Voting** - Support your favorite stations on Radio Browser
1818
- 🎨 **Themes** - Choose from predefined themes or customize via YAML config
1919
- 💤 **Sleep Timer** - Set a timer to stop playback automatically
@@ -111,7 +111,7 @@ tera
111111
# 7) Manage Lists - Create/edit/delete favorite lists
112112
# 8) Block List - Manage blocked stations
113113
# 9) I Feel Lucky - Random station by keyword
114-
# 0) Gist Management - Backup/restore via GitHub
114+
# 0) Sync & Backup - Backup/restore data locally or via GitHub
115115
# -) Settings - Configure TERA
116116

117117
# Quick Play (from main menu):
@@ -588,7 +588,7 @@ You can edit this file directly or use the Settings menu.
588588

589589
| Key | Action |
590590
| -------- | ---------------------------- |
591-
| `0` | Gist Management |
591+
| `0` | Sync & Backup |
592592
| `1-9` | Quick select menu item |
593593
| `10-99+` | Quick play from My-favorites / Recently Played |
594594
| `-` | Settings |
@@ -682,22 +682,54 @@ Results are sorted by **votes** (most popular first) and limited to 100 stations
682682
- Press `s` to add to another list
683683
- Press `v` to vote for the station
684684

685-
## Gist Sync
685+
## Sync & Backup
686686

687-
Backup and sync your favorite lists across devices using GitHub Gists.
687+
Back up and sync your data locally or across devices using zip archives and GitHub Gists.
688+
689+
### Export Backup (zip)
690+
691+
Save a local copy of your data with no GitHub account required.
692+
693+
1. From the main menu press `0` → **Sync & Backup**
694+
2. Select **7. Export backup (zip)**
695+
3. Choose which categories to include (favorites, ratings, tags, etc.)
696+
4. Confirm the save path (default: `~/tera-backup-YYYY-MM-DD.zip`)
697+
698+
### Restore from Backup (zip)
699+
700+
1. Select **8. Restore from backup (zip)**
701+
2. Enter the path to your zip file
702+
3. Choose which categories to restore
703+
4. Confirm — you will be warned before any existing files are overwritten
704+
705+
### Sync to Gist
706+
707+
Push all selected data to a dedicated secret GitHub Gist (`tera-data-backup`).
688708

689709
**Quick Setup:**
690-
1. Go to: Main Menu → 0) Gist Management → 6) Token Management
691-
2. Create a GitHub Personal Access Token (with `gist` scope only)
692-
3. Paste token in TERA
693-
4. Create your first gist backup!
710+
1. Go to **0) Sync & Backup → Token Management**
711+
2. Create a GitHub Personal Access Token with `gist` scope
712+
3. Paste the token in TERA
713+
4. Select **9. Sync all data to Gist** and choose categories
694714

695-
**Features:**
696-
- Create secret or public gists
697-
- View your gist history
698-
- Recover favorites from any gist URL
699-
- Update gist descriptions
700-
- Delete old backups
715+
### Restore from Gist
716+
717+
1. Select **10. Restore all data from Gist**
718+
2. TERA fetches the `tera-data-backup` Gist and shows available categories
719+
3. Choose what to restore — you will be warned before overwriting
720+
721+
### Sync Categories
722+
723+
| Category | Default |
724+
| ----------------------- | ------- |
725+
| Favorites (playlists) | ✅ on |
726+
| Settings (config.yaml) | ✅ on |
727+
| Ratings & votes | ✅ on |
728+
| Blocklist | ✅ on |
729+
| Station metadata & tags | ✅ on |
730+
| Search history | ❌ off |
731+
732+
Category selections are saved in `sync_prefs.json` and reused on the next run.
701733

702734
**Documentation:**
703735
- [Gist Setup Guide](GIST_SETUP.md) - Token setup and security

v3/internal/gist/client.go

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,37 @@ type GistFile struct {
4848
RawURL string `json:"raw_url,omitempty"`
4949
}
5050

51-
// CreateGist creates a new gist with the provided files
52-
// If public is true, the gist will be publicly visible; otherwise it will be secret
53-
func (c *Client) CreateGist(description string, files map[string]string, public bool) (*Gist, error) {
54-
gistFiles := make(map[string]GistFile)
51+
// GistFileUpdate wraps the content field for PATCH requests.
52+
// Deletion is represented by a nil file entry in the parent files map;
53+
// a non-nil GistFileUpdate sets or replaces the file content.
54+
type GistFileUpdate struct {
55+
Content *string `json:"content"`
56+
}
57+
58+
// gistFileCreate is the per-file payload for POST /gists.
59+
// Content is a plain string without omitempty so that empty files are
60+
// serialised as {"content":""} rather than being silently dropped.
61+
type gistFileCreate struct {
62+
Content string `json:"content"`
63+
}
64+
65+
// CreateGist creates a new gist with the provided files.
66+
// If public is true, the gist will be publicly visible; otherwise it will be
67+
// secret. Each map value is a pointer: nil entries are skipped (deletion has
68+
// no meaning on create), non-nil pointers set the file content.
69+
func (c *Client) CreateGist(description string, files map[string]*string, public bool) (*Gist, error) {
70+
gistFiles := make(map[string]gistFileCreate)
5571
for filename, content := range files {
56-
gistFiles[filename] = GistFile{
57-
Content: content,
72+
if content == nil {
73+
continue // nothing to create for a nil entry
5874
}
75+
gistFiles[filename] = gistFileCreate{Content: *content}
5976
}
6077

6178
payload := struct {
62-
Description string `json:"description"`
63-
Public bool `json:"public"`
64-
Files map[string]GistFile `json:"files"`
79+
Description string `json:"description"`
80+
Public bool `json:"public"`
81+
Files map[string]gistFileCreate `json:"files"`
6582
}{
6683
Description: description,
6784
Public: public,
@@ -86,19 +103,28 @@ func (c *Client) CreateGist(description string, files map[string]string, public
86103
return &gist, nil
87104
}
88105

89-
// ListGists lists all gists for the authenticated user
106+
// ListGists lists all gists for the authenticated user, paginating through
107+
// all pages so callers see the complete set regardless of account size.
90108
func (c *Client) ListGists() ([]*Gist, error) {
91-
req, err := http.NewRequest("GET", c.baseURL+"/gists", nil)
92-
if err != nil {
93-
return nil, fmt.Errorf("failed to create request: %w", err)
94-
}
95-
96-
var gists []*Gist
97-
if err := c.do(req, &gists); err != nil {
98-
return nil, err
109+
var all []*Gist
110+
page := 1
111+
for {
112+
url := fmt.Sprintf("%s/gists?per_page=100&page=%d", c.baseURL, page)
113+
req, err := http.NewRequest("GET", url, nil)
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to create request: %w", err)
116+
}
117+
var pageGists []*Gist
118+
if err := c.do(req, &pageGists); err != nil {
119+
return nil, err
120+
}
121+
all = append(all, pageGists...)
122+
if len(pageGists) < 100 {
123+
break // last page
124+
}
125+
page++
99126
}
100-
101-
return gists, nil
127+
return all, nil
102128
}
103129

104130
// UpdateGist updates the description of an existing gist
@@ -122,6 +148,39 @@ func (c *Client) UpdateGist(gistID, description string) error {
122148
return c.do(req, nil)
123149
}
124150

151+
// UpdateGistFiles updates or replaces the files of an existing gist.
152+
// Each key in files is the filename; the value controls the operation:
153+
// - nil pointer → delete the file (sends null per the GitHub API)
154+
// - non-nil pointer → set or replace content, including empty string
155+
func (c *Client) UpdateGistFiles(gistID string, files map[string]*string) error {
156+
gistFiles := make(map[string]*GistFileUpdate)
157+
for filename, content := range files {
158+
if content == nil {
159+
gistFiles[filename] = nil // marshals to null → deletes the file
160+
} else {
161+
gistFiles[filename] = &GistFileUpdate{Content: content}
162+
}
163+
}
164+
165+
payload := struct {
166+
Files map[string]*GistFileUpdate `json:"files"`
167+
}{
168+
Files: gistFiles,
169+
}
170+
171+
body, err := json.Marshal(payload)
172+
if err != nil {
173+
return fmt.Errorf("failed to marshal update payload: %w", err)
174+
}
175+
176+
req, err := http.NewRequest("PATCH", fmt.Sprintf("%s/gists/%s", c.baseURL, gistID), bytes.NewBuffer(body))
177+
if err != nil {
178+
return fmt.Errorf("failed to create request: %w", err)
179+
}
180+
181+
return c.do(req, nil)
182+
}
183+
125184
// DeleteGist deletes a gist
126185
func (c *Client) DeleteGist(gistID string) error {
127186
req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/gists/%s", c.baseURL, gistID), nil)

0 commit comments

Comments
 (0)