Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
85 changes: 84 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions docs/favorites.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -571,6 +579,7 @@
<TargetAppsStrip />
{/if}
<ActionBar />
<ExportProgress />
<Toast />
<KeymapDialog open={getKeymapOpen()} onclose={() => setKeymapOpen(false)} />
<CommandPalette
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/lib/components/favorites/ExportProgress.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<script lang="ts">
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'
);
</script>

{#if state.active}
<!--
Sits directly above the ActionBar footer (h-10). App chrome, not an image
overlay, so it uses theme tokens and stays legible in light mode.
-->
<div
class="bg-bg-secondary border-border fixed bottom-10 left-0 right-0 z-[90] border-t"
>
<div class="flex items-center gap-3 px-3 py-1.5">
<span class="text-fg-secondary shrink-0 text-[11px]">
{label}
{#if state.total > 0}{state.index}/{state.total}{/if}
</span>
{#if state.name}
<span class="text-fg-dimmed min-w-0 flex-1 truncate text-[11px]"
>{state.name}</span
>
{:else}
<span class="min-w-0 flex-1"></span>
{/if}
<button
class="text-destructive hover:bg-bg-hover shrink-0 px-2 py-1 text-[11px] transition-colors duration-100"
onclick={cancelExport}>Cancel</button
>
</div>
<div
class="bg-bg-surface h-1 w-full"
role="progressbar"
aria-label="Favorites export progress"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="bg-accent h-full transition-[width] duration-150"
style:width="{percent}%"
></div>
</div>
</div>
{:else if result}
<div
class="bg-bg-secondary border-border fixed bottom-10 left-0 right-0 z-[90] border-t px-3 py-2 text-xs"
>
<div class="flex items-center gap-3">
<p class="text-fg-primary min-w-0 flex-1" role="status">
Exported {result.exported} of {result.total} favorites
</p>
<button
class="text-accent shrink-0 px-2 py-1"
onclick={openExportFolder}>Open folder</button
>
<button
class="text-fg-secondary shrink-0 px-2 py-1"
onclick={dismissExportResult}>Dismiss</button
>
</div>
{#if result.skipped?.length}
<details class="text-fg-secondary mt-1">
<summary class="cursor-pointer py-1"
>{result.skipped.length} skipped files</summary
>
<ul class="mt-1 max-h-32 space-y-1 overflow-y-auto">
{#each result.skipped as item}
<li class="break-words">
<span class="font-mono">{item.path}</span>: {item.reason}
</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
14 changes: 13 additions & 1 deletion frontend/src/lib/components/favorites/FavoritesView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
getCachedFullImage,
} from '$lib/stores/imagecache.svelte';
import {getLabels, getAssignments} from '$lib/stores/tags.svelte';
import {
getExportBusy,
startExport,
} from '$lib/stores/favoritesExport.svelte';
import WallpaperTile from '$lib/components/shared/WallpaperTile.svelte';
import ImagePreview from '$lib/components/shared/ImagePreview.svelte';
import EmptyState from '$lib/components/shared/EmptyState.svelte';
Expand Down Expand Up @@ -193,7 +197,15 @@
{/each}
{/if}

<span class="text-fg-dimmed ml-auto text-[10px]"
<button
class="bg-accent text-accent-fg hover:bg-accent-hover ml-auto px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 disabled:opacity-50"
disabled={filtered.length === 0 || getExportBusy()}
onclick={() => startExport(filtered.map(f => f.path))}
title="Export the listed favorites as a .zip archive"
>Export .zip ({filtered.length})</button
>

<span class="text-fg-dimmed text-[10px]"
>{filtered.length}{filterTag ? `/${favorites.length}` : ''}</span
>
</ViewHeader>
Expand Down
Loading
Loading