diff --git a/app.go b/app.go index 3a12dff..69a8496 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/omarchy" "aether/internal/platform" @@ -37,6 +38,7 @@ type App struct { writer *theme.Writer blueprints *blueprint.Service favorites *favorites.Service + favExport *favexport.Exporter wallhaven *wallhaven.Client batch *batch.Processor themeWatcher *theme.ThemeWatcher @@ -88,13 +90,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(), themeWatcher: theme.NewThemeWatcher(), } @@ -644,6 +648,79 @@ 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) { + 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 || 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/frontend/src/App.svelte b/frontend/src/App.svelte index e03ea2f..bd0f63d 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, @@ -279,6 +281,10 @@ 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(); + // Listen for events from Go (async () => { try { @@ -477,6 +483,7 @@ {/if} + setKeymapOpen(false)} /> + import { + getExportState, + cancelExport, + } from '$lib/stores/favoritesExport.svelte'; + + let state = $derived(getExportState()); + 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} + +
+
+ + {label} + {#if state.total > 0}{state.index}/{state.total}{/if} + + {#if state.name} + {state.name} + {:else} + + {/if} + +
+
+
+
+
+{/if} diff --git a/frontend/src/lib/components/favorites/FavoritesView.svelte b/frontend/src/lib/components/favorites/FavoritesView.svelte index 9a802bb..17979bd 100644 --- a/frontend/src/lib/components/favorites/FavoritesView.svelte +++ b/frontend/src/lib/components/favorites/FavoritesView.svelte @@ -15,6 +15,10 @@ getCachedFullImage, } from '$lib/stores/imagecache.svelte'; import {getLabels, getAssignments} from '$lib/stores/tags.svelte'; + import { + getExportState, + 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'; @@ -193,7 +197,15 @@ {/each} {/if} - startExport(filtered.map(f => f.path))} + title="Export the listed favorites as a .zip archive" + >Export .zip ({filtered.length}) + + {filtered.length}{filterTag ? `/${favorites.length}` : ''} diff --git a/frontend/src/lib/stores/favoritesExport.svelte.ts b/frontend/src/lib/stores/favoritesExport.svelte.ts new file mode 100644 index 0000000..4a13121 --- /dev/null +++ b/frontend/src/lib/stores/favoritesExport.svelte.ts @@ -0,0 +1,145 @@ +import {showToast} from '$lib/stores/ui.svelte'; +import type {main} from '../../../wailsjs/go/models'; + +export type ExportPhase = 'download' | 'archive'; + +export type ExportState = { + active: boolean; + phase: ExportPhase; + index: number; // 1-based, counts items handled in the current phase + total: number; + name: string; // file currently being handled + zipPath: string; +}; + +type Skip = {path: string; reason: string}; + +type ExportResult = { + zipPath: string; + total: number; + exported: number; + skipped: Skip[] | null; +}; + +const IDLE: ExportState = { + active: false, + phase: 'download', + index: 0, + total: 0, + name: '', + zipPath: '', +}; + +let state = $state({...IDLE}); +let eventsReady = false; +// Bumped by every terminal event. The backend starts working the moment the +// directory picker closes, which can be before ExportFavorites' promise +// settles, so startExport uses this to tell "my run is still going" from +// "my run already finished". +let runSeq = 0; + +export function getExportState(): ExportState { + return state; +} + +/** + * Starts an export of the given favorite paths. The backend opens the + * directory picker itself, so a dismissed dialog surfaces as a "cancelled" + * error we swallow — same convention as the theme import flow in ActionBar. + */ +export async function startExport(paths: string[]): Promise { + if (state.active || paths.length === 0) return; + + const seq = runSeq; + try { + const {ExportFavorites} = await import('../../../wailsjs/go/main/App'); + const zipPath = await ExportFavorites({ + paths, + } as unknown as main.ExportFavoritesRequest); + + if (runSeq !== seq) return; // already finished while we were awaiting + state = state.active + ? {...state, zipPath} // progress is already flowing; don't rewind it + : // Seed the bar so it shows up the moment the picker closes + // rather than only after the first download lands. + {...IDLE, active: true, total: paths.length, zipPath}; + } catch (e: any) { + const message = e?.message ?? String(e); + if (message.includes('cancelled')) return; + showToast(message || 'Export failed'); + } +} + +export async function cancelExport(): Promise { + if (!state.active) return; + try { + const {CancelFavoritesExport} = await import( + '../../../wailsjs/go/main/App' + ); + await CancelFavoritesExport(); + } catch { + // The backend either finished or was never running; the terminal event + // still resets the state. + } +} + +/** + * Subscribes to the backend's export events. Called once from App.svelte so + * progress survives switching tabs away from Favorites. + */ +export async function initExportEvents(): Promise { + if (eventsReady) return; + eventsReady = true; + + const {EventsOn, BrowserOpenURL} = await import( + '../../../wailsjs/runtime/runtime' + ); + + EventsOn( + 'favorites-export-progress', + (p: {phase: ExportPhase; index: number; total: number; name: string}) => + (state = {...state, active: true, ...p}) + ); + + EventsOn('favorites-export-completed', (result: ExportResult) => { + runSeq++; + state = {...IDLE}; + const skipped = result.skipped?.length ?? 0; + const summary = skipped + ? `Exported ${result.exported} of ${result.total} favorites` + : `Exported ${result.exported} favorite${result.exported === 1 ? '' : 's'}`; + const dir = result.zipPath.slice(0, result.zipPath.lastIndexOf('/')); + showToast(`${summary} to ${result.zipPath}`, { + duration: 8000, + action: { + label: 'Open folder', + run: () => BrowserOpenURL('file://' + dir), + }, + }); + }); + + EventsOn('favorites-export-failed', (p: {error: string}) => { + runSeq++; + state = {...IDLE}; + showToast(p?.error || 'Export failed'); + }); + + EventsOn('favorites-export-cancelled', () => { + runSeq++; + state = {...IDLE}; + showToast('Export cancelled'); + }); + + // The backend can already be exporting if the frontend reloaded mid-run + // (dev hot reload); progress events refill the details. + try { + const {IsFavoritesExportRunning} = await import( + '../../../wailsjs/go/main/App' + ); + if (await IsFavoritesExportRunning()) { + state = {...state, active: true}; + } + } catch { + // No backend (browser-only dev) — nothing to recover. + } +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cda7eec..2d1e137 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -28,6 +28,8 @@ export function CancelBatchProcessing(): Promise; export function CancelExternalImport(arg1: string): Promise; +export function CancelFavoritesExport(): Promise; + export function ChooseWallpaperFolder(): Promise; export function ClearTheme(): Promise; @@ -48,6 +50,10 @@ export function DeleteBlueprint(arg1: string): Promise; export function DownloadWallpaper(arg1: string): Promise; +export function ExportFavorites( + arg1: main.ExportFavoritesRequest +): Promise; + export function ExportTheme(arg1: main.ExportThemeRequest): Promise; export function ExtractColors( @@ -98,6 +104,8 @@ export function ImportFileDialog(arg1: string): Promise; export function IsFavorite(arg1: string): Promise; +export function IsFavoritesExportRunning(): Promise; + export function IsMacOS(): Promise; export function IsOmarchyInstalled(): Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 6fa53ec..8a15d9d 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,10 @@ export function CancelExternalImport(arg1) { return window['go']['main']['App']['CancelExternalImport'](arg1); } +export function CancelFavoritesExport() { + return window['go']['main']['App']['CancelFavoritesExport'](); +} + export function ChooseWallpaperFolder() { return window['go']['main']['App']['ChooseWallpaperFolder'](); } @@ -62,6 +66,10 @@ export function DownloadWallpaper(arg1) { return window['go']['main']['App']['DownloadWallpaper'](arg1); } +export function ExportFavorites(arg1) { + return window['go']['main']['App']['ExportFavorites'](arg1); +} + export function ExportTheme(arg1) { return window['go']['main']['App']['ExportTheme'](arg1); } @@ -150,6 +158,10 @@ export function IsFavorite(arg1) { return window['go']['main']['App']['IsFavorite'](arg1); } +export function IsFavoritesExportRunning() { + return window['go']['main']['App']['IsFavoritesExportRunning'](); +} + export function IsMacOS() { return window['go']['main']['App']['IsMacOS'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 14f24f7..5e16059 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -179,6 +179,18 @@ export namespace main { return a; } } + export class ExportFavoritesRequest { + paths: string[]; + + static createFrom(source: any = {}) { + return new ExportFavoritesRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.paths = source['paths']; + } + } export class ExportThemeRequest { name: string; includedApps: string[]; diff --git a/internal/favexport/exporter.go b/internal/favexport/exporter.go new file mode 100644 index 0000000..2cd3430 --- /dev/null +++ b/internal/favexport/exporter.go @@ -0,0 +1,454 @@ +// Package favexport bundles favorited wallpapers into a .zip archive. +// +// Favorites are not necessarily files: wallhaven entries store a remote URL as +// their path, so exporting has to fetch anything that is not on disk yet before +// it can archive it. That makes the operation slow enough to need progress +// reporting and cancellation, so it runs in a goroutine and emits Wails events +// the same way internal/batch does. +package favexport + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "aether/internal/platform" + + wailsrt "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// maxConcurrentDownloads bounds the fetch phase. Downloads dominate the wall +// clock, but hammering wallhaven with one request per favorite is rude. +const maxConcurrentDownloads = 4 + +// manifestName is the metadata file written alongside the images so an archive +// records where each wallpaper came from. +const manifestName = "favorites.json" + +// Item is one wallpaper to export. +type Item struct { + Path string // local file path or http(s) URL + Name string // preferred base name in the zip ("" derives from Path) + Meta map[string]interface{} // type/id/resolution, copied into the manifest +} + +// Skip records a favorite that could not be included. +type Skip struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + +// Result is the payload of the favorites-export-completed event. +type Result struct { + ZipPath string `json:"zipPath"` + Total int `json:"total"` + Exported int `json:"exported"` + Skipped []Skip `json:"skipped"` +} + +// Downloader fetches a remote wallpaper and returns its local path. +// *wallhaven.Client satisfies this. +type Downloader interface { + DownloadContext(ctx context.Context, url string) (string, error) +} + +// Exporter runs at most one export at a time. +type Exporter struct { + mu sync.Mutex + cancel context.CancelFunc + running bool + dl Downloader +} + +// New creates an exporter that resolves remote favorites through dl. +func New(dl Downloader) *Exporter { + return &Exporter{dl: dl} +} + +// Start validates the request, reserves a destination file name and kicks off +// the export in the background. It returns the path the archive will be +// written to; progress arrives via favorites-export-* events. +func (e *Exporter) Start(appCtx context.Context, items []Item, destDir string) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("no favorites to export") + } + if err := platform.EnsureDir(destDir); err != nil { + return "", fmt.Errorf("prepare export directory: %w", err) + } + + e.mu.Lock() + if e.running { + e.mu.Unlock() + return "", fmt.Errorf("an export is already running") + } + + zipPath, err := uniquePath(destDir, archiveBaseName(), ".zip") + if err != nil { + e.mu.Unlock() + return "", err + } + + // Derive from appCtx so app shutdown cancels an in-flight export. + ctx, cancel := context.WithCancel(appCtx) + e.cancel = cancel + e.running = true + e.mu.Unlock() + + go func() { + defer func() { + e.mu.Lock() + e.running = false + e.cancel = nil + e.mu.Unlock() + cancel() + }() + e.run(appCtx, ctx, items, zipPath) + }() + + return zipPath, nil +} + +// Cancel stops a running export. It is a no-op when nothing is running. +func (e *Exporter) Cancel() { + e.mu.Lock() + defer e.mu.Unlock() + if e.cancel != nil { + e.cancel() + } +} + +// IsRunning reports whether an export is in flight. +func (e *Exporter) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +// resolved pairs an item with the local file backing it, or the reason it has none. +type resolved struct { + item Item + local string + skip string +} + +// run performs the export. emitCtx is the app context used for events (it must +// stay alive after cancellation so the cancelled event still reaches the UI); +// ctx is the cancellable one that governs the work itself. +func (e *Exporter) run(emitCtx, ctx context.Context, items []Item, zipPath string) { + partPath := zipPath + ".part" + + results, cancelled := e.resolveAll(emitCtx, ctx, items) + if cancelled { + emit(emitCtx, "favorites-export-cancelled", nil) + return + } + + result, err := writeArchive(emitCtx, ctx, results, partPath, zipPath) + switch { + case err == context.Canceled: + _ = os.Remove(partPath) + emit(emitCtx, "favorites-export-cancelled", nil) + case err != nil: + _ = os.Remove(partPath) + emit(emitCtx, "favorites-export-failed", map[string]interface{}{"error": err.Error()}) + default: + emit(emitCtx, "favorites-export-completed", result) + } +} + +// resolveAll turns every item into a local file path, downloading remote ones +// with bounded concurrency. Results keep the input order. +func (e *Exporter) resolveAll(emitCtx, ctx context.Context, items []Item) ([]resolved, bool) { + results := make([]resolved, len(items)) + sem := make(chan struct{}, maxConcurrentDownloads) + + var ( + wg sync.WaitGroup + done int + mu sync.Mutex + ) + + for i, item := range items { + select { + case <-ctx.Done(): + wg.Wait() + return nil, true + case sem <- struct{}{}: + } + + wg.Add(1) + go func(i int, item Item) { + defer wg.Done() + defer func() { <-sem }() + + results[i] = e.resolve(ctx, item) + + mu.Lock() + done++ + progress := done + mu.Unlock() + + emitProgress(emitCtx, "download", progress, len(items), entryLabel(item)) + }(i, item) + } + + wg.Wait() + + if ctx.Err() != nil { + return nil, true + } + return results, false +} + +// resolve maps a single item to a local file, fetching it when remote. +func (e *Exporter) resolve(ctx context.Context, item Item) resolved { + if isRemote(item.Path) { + if e.dl == nil { + return resolved{item: item, skip: "no downloader available"} + } + local, err := e.dl.DownloadContext(ctx, item.Path) + if err != nil { + if ctx.Err() != nil { + return resolved{item: item, skip: "cancelled"} + } + return resolved{item: item, skip: "download failed: " + err.Error()} + } + return resolved{item: item, local: local} + } + + if !platform.FileExists(item.Path) { + return resolved{item: item, skip: "file not found"} + } + return resolved{item: item, local: item.Path} +} + +// manifestEntry describes one archived wallpaper. +type manifestEntry struct { + File string `json:"file"` + Source string `json:"source"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +// writeArchive streams the resolved files into a zip, writing to partPath and +// renaming to zipPath only once the archive is complete. +func writeArchive(emitCtx, ctx context.Context, results []resolved, partPath, zipPath string) (Result, error) { + out, err := os.Create(partPath) + if err != nil { + return Result{}, fmt.Errorf("create archive: %w", err) + } + // Closed explicitly below; the deferred close covers the error paths and a + // second Close on an already-closed file is harmless here. + defer out.Close() + + zw := zip.NewWriter(out) + used := make(map[string]bool, len(results)) + manifest := make([]manifestEntry, 0, len(results)) + result := Result{ZipPath: zipPath, Total: len(results)} + + for i, r := range results { + if ctx.Err() != nil { + _ = zw.Close() + return Result{}, context.Canceled + } + + if r.skip != "" { + result.Skipped = append(result.Skipped, Skip{Path: r.item.Path, Reason: r.skip}) + continue + } + + name := uniqueEntryName(entryLabel(r.item), used) + emitProgress(emitCtx, "archive", i+1, len(results), name) + + if err := addFile(zw, name, r.local); err != nil { + log.Printf("[favexport] %s: %v", r.item.Path, err) + result.Skipped = append(result.Skipped, Skip{Path: r.item.Path, Reason: err.Error()}) + continue + } + + result.Exported++ + manifest = append(manifest, manifestEntry{ + File: name, + Source: r.item.Path, + Meta: r.item.Meta, + }) + } + + if result.Exported == 0 { + _ = zw.Close() + return Result{}, fmt.Errorf("no favorites could be exported") + } + + if err := addManifest(zw, manifest); err != nil { + _ = zw.Close() + return Result{}, err + } + if err := zw.Close(); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + if err := out.Close(); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + if err := os.Rename(partPath, zipPath); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + + return result, nil +} + +// addFile copies one wallpaper into the archive. Images are stored, not +// deflated — they are already compressed, so deflate only burns CPU. +func addFile(zw *zip.Writer, name, srcPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("read failed") + } + defer src.Close() + + header := &zip.FileHeader{Name: name, Method: zip.Store} + // Without an explicit mtime every entry reports 1980-01-01, which archive + // tools surface as a corrupt-looking date. + if info, err := src.Stat(); err == nil { + header.Modified = info.ModTime() + } + + w, err := zw.CreateHeader(header) + if err != nil { + return fmt.Errorf("archive entry failed") + } + if _, err := io.Copy(w, src); err != nil { + return fmt.Errorf("copy failed") + } + return nil +} + +// addManifest writes the metadata sidecar. Unlike the images, JSON compresses. +func addManifest(zw *zip.Writer, entries []manifestEntry) error { + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return fmt.Errorf("build manifest: %w", err) + } + w, err := zw.CreateHeader(&zip.FileHeader{ + Name: manifestName, + Method: zip.Deflate, + Modified: time.Now(), + }) + if err != nil { + return fmt.Errorf("build manifest: %w", err) + } + if _, err := w.Write(data); err != nil { + return fmt.Errorf("build manifest: %w", err) + } + return nil +} + +// --- helpers --- + +func isRemote(path string) bool { + return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") +} + +// entryLabel is the preferred file name for an item inside the archive. +func entryLabel(item Item) string { + name := sanitizeName(item.Name) + if name == "" { + name = sanitizeName(filepath.Base(item.Path)) + } + if name == "" { + name = "wallpaper" + } + // A wallhaven id is used as the display name in the UI and carries no + // extension; borrow the one from the URL so the file stays openable. + // sanitizeName trims dots, so the separator is re-added by hand. + if filepath.Ext(name) == "" { + if ext := sanitizeName(filepath.Ext(item.Path)); ext != "" { + name += "." + ext + } + } + return name +} + +// sanitizeName strips path separators and anything else that would let a +// favorite path escape the archive root or produce an unusable file name. +func sanitizeName(name string) string { + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|', 0: + return -1 + } + if r < 32 { + return -1 + } + return r + }, name) + return strings.Trim(strings.TrimSpace(name), ".") +} + +// uniqueEntryName suffixes duplicates so two favorites with the same base name +// do not overwrite each other inside the archive. +func uniqueEntryName(name string, used map[string]bool) string { + if !used[name] { + used[name] = true + return name + } + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d%s", stem, i, ext) + if !used[candidate] { + used[candidate] = true + return candidate + } + } +} + +// archiveBaseName is the date-stamped stem for a favorites archive. +func archiveBaseName() string { + return "aether-favorites-" + time.Now().Format("2006-01-02") +} + +// uniquePath reserves an unused file name in dir, suffixing -2, -3, … so an +// export never silently overwrites an earlier one. +func uniquePath(dir, base, ext string) (string, error) { + for i := 1; i < 1000; i++ { + name := base + ext + if i > 1 { + name = fmt.Sprintf("%s-%d%s", base, i, ext) + } + path := filepath.Join(dir, name) + if !platform.FileExists(path) && !platform.FileExists(path+".part") { + return path, nil + } + } + return "", fmt.Errorf("could not find an unused file name in %s", dir) +} + +// emit publishes a Wails event, tolerating a context that carries no event +// manager. Wails' getEvents log.Fatalf's in that case, which would take the +// process down when the exporter runs outside the GUI (tests, CLI). +func emit(ctx context.Context, event string, payload interface{}) { + if ctx == nil || ctx.Value("events") == nil { + return + } + if payload == nil { + wailsrt.EventsEmit(ctx, event) + return + } + wailsrt.EventsEmit(ctx, event, payload) +} + +func emitProgress(ctx context.Context, phase string, index, total int, name string) { + emit(ctx, "favorites-export-progress", map[string]interface{}{ + "phase": phase, + "index": index, + "total": total, + "name": name, + }) +} diff --git a/internal/favexport/exporter_test.go b/internal/favexport/exporter_test.go new file mode 100644 index 0000000..6727873 --- /dev/null +++ b/internal/favexport/exporter_test.go @@ -0,0 +1,310 @@ +package favexport + +import ( + "archive/zip" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "testing" + "time" +) + +// stubDownloader stands in for the wallhaven client. Remote URLs map to files +// that already exist on disk, so the tests never touch the network. +type stubDownloader struct { + files map[string]string // url -> local path + block chan struct{} // when non-nil, downloads wait on it or on ctx + calls int + failAll bool +} + +func (s *stubDownloader) DownloadContext(ctx context.Context, url string) (string, error) { + s.calls++ + if s.block != nil { + select { + case <-s.block: + case <-ctx.Done(): + return "", ctx.Err() + } + } + if s.failAll { + return "", context.DeadlineExceeded + } + local, ok := s.files[url] + if !ok { + return "", os.ErrNotExist + } + return local, nil +} + +func writeFixture(t *testing.T, dir, name, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +// runExport starts an export and waits for it to settle. +func runExport(t *testing.T, e *Exporter, items []Item, destDir string) string { + t.Helper() + zipPath, err := e.Start(context.Background(), items, destDir) + if err != nil { + t.Fatalf("Start: %v", err) + } + waitIdle(t, e) + return zipPath +} + +func waitIdle(t *testing.T, e *Exporter) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for e.IsRunning() { + if time.Now().After(deadline) { + t.Fatal("export did not finish within 5s") + } + time.Sleep(2 * time.Millisecond) + } +} + +// zipEntries lists the archive's entry names, sorted. +func zipEntries(t *testing.T, zipPath string) []string { + t.Helper() + r, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open %s: %v", zipPath, err) + } + defer r.Close() + + names := make([]string, 0, len(r.File)) + for _, f := range r.File { + names = append(names, f.Name) + } + sort.Strings(names) + return names +} + +func readZipEntry(t *testing.T, zipPath, name string) []byte { + t.Helper() + r, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open %s: %v", zipPath, err) + } + defer r.Close() + + for _, f := range r.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatalf("open entry %s: %v", name, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read entry %s: %v", name, err) + } + return data + } + t.Fatalf("entry %s not found in %s", name, zipPath) + return nil +} + +func assertNoLeftovers(t *testing.T, zipPath string) { + t.Helper() + if _, err := os.Stat(zipPath); err == nil { + t.Errorf("expected no archive at %s", zipPath) + } + if _, err := os.Stat(zipPath + ".part"); err == nil { + t.Errorf("expected no partial archive at %s.part", zipPath) + } +} + +func TestExportArchivesLocalAndRemoteWithManifest(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + local := writeFixture(t, src, "sunset.jpg", "local-bytes") + remoteLocal := writeFixture(t, src, "wallhaven-abc123.jpg", "remote-bytes") + + dl := &stubDownloader{files: map[string]string{ + "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg": remoteLocal, + }} + + items := []Item{ + {Path: local, Name: "sunset.jpg", Meta: map[string]interface{}{"type": "local"}}, + { + Path: "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg", + Name: "abc123", + Meta: map[string]interface{}{"type": "wallhaven", "id": "abc123"}, + }, + } + + zipPath := runExport(t, New(dl), items, dest) + + got := zipEntries(t, zipPath) + want := []string{"abc123.jpg", manifestName, "sunset.jpg"} + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("entries = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("entries = %v, want %v", got, want) + } + } + + if body := string(readZipEntry(t, zipPath, "sunset.jpg")); body != "local-bytes" { + t.Errorf("sunset.jpg = %q, want %q", body, "local-bytes") + } + + var manifest []manifestEntry + if err := json.Unmarshal(readZipEntry(t, zipPath, manifestName), &manifest); err != nil { + t.Fatalf("manifest: %v", err) + } + if len(manifest) != 2 { + t.Fatalf("manifest has %d entries, want 2", len(manifest)) + } + if manifest[1].Source != items[1].Path { + t.Errorf("manifest source = %q, want %q", manifest[1].Source, items[1].Path) + } + if manifest[1].Meta["id"] != "abc123" { + t.Errorf("manifest meta id = %v, want abc123", manifest[1].Meta["id"]) + } +} + +// A wallhaven id has no extension; the one from the URL should be borrowed so +// the archived file stays openable. +func TestEntryLabelBorrowsExtensionFromPath(t *testing.T) { + got := entryLabel(Item{Path: "https://w.wallhaven.cc/full/ab/wallhaven-abc.png", Name: "abc"}) + if got != "abc.png" { + t.Errorf("entryLabel = %q, want abc.png", got) + } +} + +func TestExportDeduplicatesEntryNames(t *testing.T) { + root := t.TempDir() + dest := t.TempDir() + + a := writeFixture(t, filepath.Join(root, "a"), "wall.jpg", "a") + b := writeFixture(t, filepath.Join(root, "b"), "wall.jpg", "b") + + zipPath := runExport(t, New(nil), []Item{{Path: a}, {Path: b}}, dest) + + got := zipEntries(t, zipPath) + want := []string{manifestName, "wall-2.jpg", "wall.jpg"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("entries = %v, want %v", got, want) + } + } + if body := string(readZipEntry(t, zipPath, "wall-2.jpg")); body != "b" { + t.Errorf("wall-2.jpg = %q, want %q", body, "b") + } +} + +func TestExportSkipsUnreachableItemsButKeepsGoing(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + good := writeFixture(t, src, "good.jpg", "good") + dl := &stubDownloader{failAll: true} + + items := []Item{ + {Path: filepath.Join(src, "missing.jpg")}, + {Path: good}, + {Path: "https://w.wallhaven.cc/full/zz/wallhaven-zzz.jpg"}, + } + + zipPath := runExport(t, New(dl), items, dest) + + got := zipEntries(t, zipPath) + want := []string{manifestName, "good.jpg"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("entries = %v, want %v", got, want) + } +} + +func TestExportFailsWhenNothingIsExportable(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + items := []Item{{Path: filepath.Join(src, "nope.jpg")}} + zipPath := runExport(t, New(nil), items, dest) + + assertNoLeftovers(t, zipPath) +} + +func TestCancelLeavesNoArchiveBehind(t *testing.T) { + dest := t.TempDir() + dl := &stubDownloader{block: make(chan struct{})} + e := New(dl) + + zipPath, err := e.Start(context.Background(), []Item{ + {Path: "https://w.wallhaven.cc/full/aa/wallhaven-aaa.jpg"}, + }, dest) + if err != nil { + t.Fatalf("Start: %v", err) + } + + e.Cancel() + waitIdle(t, e) + assertNoLeftovers(t, zipPath) +} + +func TestStartRejectsEmptySelection(t *testing.T) { + if _, err := New(nil).Start(context.Background(), nil, t.TempDir()); err == nil { + t.Fatal("expected an error for an empty selection") + } +} + +func TestStartRejectsConcurrentExports(t *testing.T) { + dest := t.TempDir() + dl := &stubDownloader{block: make(chan struct{})} + e := New(dl) + + items := []Item{{Path: "https://w.wallhaven.cc/full/aa/wallhaven-aaa.jpg"}} + if _, err := e.Start(context.Background(), items, dest); err != nil { + t.Fatalf("first Start: %v", err) + } + if _, err := e.Start(context.Background(), items, dest); err == nil { + t.Error("expected the second Start to be rejected") + } + + e.Cancel() + waitIdle(t, e) +} + +func TestArchiveNameNeverOverwritesAnExistingExport(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + fixture := writeFixture(t, src, "wall.jpg", "x") + + e := New(nil) + first := runExport(t, e, []Item{{Path: fixture}}, dest) + second := runExport(t, e, []Item{{Path: fixture}}, dest) + + if first == second { + t.Fatalf("second export reused %s", first) + } + if _, err := os.Stat(first); err != nil { + t.Errorf("first archive was clobbered: %v", err) + } + if filepath.Base(second) != archiveBaseName()+"-2.zip" { + t.Errorf("second archive = %s, want %s-2.zip", filepath.Base(second), archiveBaseName()) + } +} + +func TestSanitizeNameStripsPathTraversal(t *testing.T) { + if got := entryLabel(Item{Path: "/tmp/x.jpg", Name: "../../etc/passwd"}); got != "etcpasswd.jpg" { + t.Errorf("entryLabel = %q, want etcpasswd.jpg", got) + } +} diff --git a/internal/wallhaven/client.go b/internal/wallhaven/client.go index 61379a8..530c791 100644 --- a/internal/wallhaven/client.go +++ b/internal/wallhaven/client.go @@ -1,6 +1,7 @@ package wallhaven import ( + "context" "encoding/json" "fmt" "io" @@ -19,14 +20,16 @@ const baseURL = "https://wallhaven.cc/api/v1" // Client is an HTTP client for the wallhaven.cc API. type Client struct { - http *http.Client - apiKey string + http *http.Client + download *http.Client + apiKey string } // NewClient creates a new wallhaven API client. func NewClient() *Client { return &Client{ - http: &http.Client{Timeout: 30 * time.Second}, + http: &http.Client{Timeout: 30 * time.Second}, + download: &http.Client{Timeout: 5 * time.Minute}, } } @@ -295,6 +298,14 @@ func (c *Client) Info(id string) (*WallpaperInfo, error) { // Download downloads a wallpaper image to the local downloads directory. // Returns the local file path. func (c *Client) Download(imageURL string) (string, error) { + return c.DownloadContext(context.Background(), imageURL) +} + +// DownloadContext downloads a wallpaper image to the local downloads +// directory, aborting the transfer when ctx is cancelled. Returns the local +// file path. Already-downloaded images short-circuit without touching the +// network, so repeat calls are cheap. +func (c *Client) DownloadContext(ctx context.Context, imageURL string) (string, error) { filename := filepath.Base(imageURL) if filename == "" || filename == "." || filename == "/" { return "", fmt.Errorf("cannot determine filename from URL: %s", imageURL) @@ -312,7 +323,12 @@ func (c *Client) Download(imageURL string) (string, error) { return destPath, nil } - resp, err := c.http.Get(imageURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return "", fmt.Errorf("wallpaper download failed: %w", err) + } + + resp, err := c.download.Do(req) if err != nil { return "", fmt.Errorf("wallpaper download failed: %w", err) }