diff --git a/README.md b/README.md
index ed68077f..33f6c139 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ A visual theming application for [Omarchy](https://omarchy.org). Extract colors
### Wallpaper Tools
- Search and download wallpapers from wallhaven.cc directly in the app
+- Export favorite wallpapers as a ZIP archive with source metadata
- Full wallpaper editor with blur, exposure, sharpen, vignette, grain, and color toning
- 12 one-click image presets: Cinematic, Vintage, Film, Dramatic, and more
@@ -131,6 +132,7 @@ From `frontend/`, run `npm ci`, `npm run check`, `npm test`, and `npm run build`
| [Base16 Schemes](docs/base16.md) | Import community color schemes |
| [Wallpaper Editor](docs/wallpaper-editor.md) | Image filters and presets |
| [Wallhaven](docs/wallhaven.md) | Browse online wallpapers |
+| [Favorites](docs/favorites.md) | Save wallpapers and export a collection |
| [Blueprints](docs/blueprints.md) | Save and restore themes |
| [Custom Templates](docs/custom-templates.md) | Add support for your apps |
| [Custom Apps](docs/custom-apps.md) | Per-app template system |
diff --git a/app.go b/app.go
index a4b2301c..ba2f5015 100644
--- a/app.go
+++ b/app.go
@@ -15,6 +15,7 @@ import (
"aether/internal/blueprint"
"aether/internal/color"
"aether/internal/extraction"
+ "aether/internal/favexport"
"aether/internal/favorites"
"aether/internal/icontheme"
"aether/internal/omarchy"
@@ -38,6 +39,7 @@ type App struct {
writer *theme.Writer
blueprints *blueprint.Service
favorites *favorites.Service
+ favExport *favexport.Exporter
wallhaven *wallhaven.Client
batch *batch.Processor
iconThemes *icontheme.Catalog
@@ -90,13 +92,15 @@ func (a *App) StartUpgrade() error {
// NewApp creates a new App instance.
func NewApp() *App {
+ wh := wallhaven.NewClient()
return &App{
state: newSeededState(),
history: theme.NewHistoryManager(),
writer: theme.NewWriter(EmbeddedTemplates, "templates"),
blueprints: blueprint.NewService(),
favorites: favorites.NewService(),
- wallhaven: wallhaven.NewClient(),
+ favExport: favexport.New(wh),
+ wallhaven: wh,
batch: batch.NewProcessor(),
iconThemes: icontheme.NewCatalog(),
themeWatcher: theme.NewThemeWatcher(),
@@ -726,6 +730,85 @@ func (a *App) IsFavorite(path string) bool {
return a.favorites.IsFavorite(path)
}
+// ExportFavoritesRequest is the payload from the frontend for zipping favorites.
+type ExportFavoritesRequest struct {
+ Paths []string `json:"paths"` // favorite paths, in display order
+}
+
+// ExportFavorites archives the given favorites into a .zip in a user-chosen
+// directory. Wallhaven favorites are remote URLs, so anything not already
+// downloaded is fetched first — which makes this slow enough that the work runs
+// in the background and reports through favorites-export-* events. Returns the
+// path the archive is being written to.
+func (a *App) ExportFavorites(req ExportFavoritesRequest) (string, error) {
+ if a.favExport.IsRunning() {
+ return "", fmt.Errorf("an export is already running")
+ }
+ items := a.favoriteItems(req.Paths)
+ if len(items) == 0 {
+ return "", fmt.Errorf("no favorites to export")
+ }
+
+ dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{
+ Title: "Choose Export Directory",
+ CanCreateDirectories: true,
+ })
+ if err != nil {
+ return "", fmt.Errorf("choose export directory: %w", err)
+ }
+ if dir == "" {
+ return "", fmt.Errorf("export cancelled")
+ }
+
+ return a.favExport.Start(a.ctx, items, dir)
+}
+
+// CancelFavoritesExport stops a running favorites export.
+func (a *App) CancelFavoritesExport() { a.favExport.Cancel() }
+
+// IsFavoritesExportRunning reports whether an export is in flight. The frontend
+// uses this to recover its progress state after a reload.
+func (a *App) IsFavoritesExportRunning() bool { return a.favExport.IsRunning() }
+
+// favoriteItems resolves frontend-supplied paths against the favorites store.
+// Only the path crosses the boundary — names and metadata are read back from
+// the service so the archive cannot be steered by the caller.
+func (a *App) favoriteItems(paths []string) []favexport.Item {
+ known := make(map[string]favorites.Favorite)
+ for _, fav := range a.favorites.GetAll() {
+ known[fav.Path] = fav
+ }
+
+ items := make([]favexport.Item, 0, len(paths))
+ seen := make(map[string]bool, len(paths))
+ for _, path := range paths {
+ fav, ok := known[path]
+ if !ok || seen[path] {
+ continue
+ }
+ seen[path] = true
+
+ item := favexport.Item{Path: fav.Path, Meta: map[string]interface{}{}}
+ if fav.Type != "" {
+ item.Meta["type"] = fav.Type
+ }
+ for k, v := range fav.Data {
+ if v == nil {
+ continue
+ }
+ item.Meta[k] = v
+ }
+ // The tile label is the local name, falling back to the wallhaven id.
+ if name, ok := fav.Data["name"].(string); ok {
+ item.Name = name
+ } else if id, ok := fav.Data["id"].(string); ok {
+ item.Name = id
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
// ---------------------------------------------------------------------------
// App Settings (template toggles, neovim config)
// ---------------------------------------------------------------------------
diff --git a/docs/favorites.md b/docs/favorites.md
new file mode 100644
index 00000000..464e3e49
--- /dev/null
+++ b/docs/favorites.md
@@ -0,0 +1,27 @@
+# Favorites
+
+Use the heart control in Local or Wallhaven to save a wallpaper in Favorites.
+The favorite state stays in sync across the three views.
+
+## Export a collection
+
+1. Open Favorites.
+2. Select a label to filter the collection, if needed.
+3. Click `Export .zip`.
+4. Choose an export folder.
+5. Review the export result.
+
+The export includes the wallpapers in the current filtered list.
+Aether downloads remote wallpapers before it creates the archive.
+The progress panel remains available when you change tabs.
+Use `Cancel` to stop an active export.
+
+The archive contains the wallpaper files and a `favorites.json` source manifest.
+The manifest records each archive filename, original source, and available wallpaper metadata.
+Duplicate filenames receive a numeric suffix.
+
+The result lists files that Aether cannot find or download.
+Use `Open folder` to locate the completed archive.
+Use `Dismiss` to close the result.
+
+To use the collection on another machine, extract the archive and select its images from Local.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 6a782da3..604b102e 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -12,6 +12,8 @@
import OmarchyThemes from '$lib/components/blueprints/OmarchyThemes.svelte';
import SettingsView from '$lib/components/settings/SettingsView.svelte';
import AboutView from '$lib/components/layout/AboutView.svelte';
+ import ExportProgress from '$lib/components/favorites/ExportProgress.svelte';
+ import {initExportEvents} from '$lib/stores/favoritesExport.svelte';
import {
getActiveTab,
setActiveTab,
@@ -358,6 +360,12 @@
else if (getKeymapOpen()) setKeymapOpen(false);
});
+ // Favorites export progress. Wired here rather than in FavoritesView
+ // so an export keeps reporting after the user switches tabs.
+ initExportEvents().catch(error =>
+ console.error('Favorites export events unavailable:', error)
+ );
+
// Listen for events from Go
(async () => {
try {
@@ -571,6 +579,7 @@
{/if}
+ setKeymapOpen(false)} />
+ import {
+ getExportState,
+ getExportResult,
+ dismissExportResult,
+ openExportFolder,
+ cancelExport,
+ } from '$lib/stores/favoritesExport.svelte';
+
+ let state = $derived(getExportState());
+ let result = $derived(getExportResult());
+ let percent = $derived(
+ state.total > 0
+ ? Math.min(100, Math.round((state.index / state.total) * 100))
+ : 0
+ );
+ let label = $derived(
+ state.phase === 'archive' ? 'Archiving' : 'Downloading'
+ );
+
+
+{#if state.active}
+
+