diff --git a/.gitignore b/.gitignore index 1431510..fd30b60 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ frontend/dist # build package *.exe *.dmg +ExifFrame + diff --git a/app.go b/app.go index faa64b5..760235d 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "log" "math" "net/http" "os" @@ -50,6 +51,18 @@ func NewApp() *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx + // Restart watcher if configured + settingsMu.RLock() + watchFolder := currentSettings.WatchFolder + settingsMu.RUnlock() + if watchFolder != "" { + a.updateWatcher(watchFolder) + } +} + +// shutdown is called at application termination +func (a *App) shutdown(ctx context.Context) { + a.updateWatcher("") // This properly closes the watcher and waits for its goroutine to exit } // getCurrentImagePath returns the path of the currently loaded image in a thread-safe manner. @@ -90,6 +103,11 @@ func (a *App) OpenImage() ExifResult { return ExifResult{Cancelled: true} // user cancelled } + return a.processImageFile(filePath) +} + +// processImageFile reads a file, validates it, and extracts EXIF +func (a *App) processImageFile(filePath string) ExifResult { const maxFileSize = 100 * 1024 * 1024 // 100 MB fileInfo, err := os.Stat(filePath) if err != nil { @@ -109,6 +127,10 @@ func (a *App) OpenImage() ExifResult { return ExifResult{Error: "Invalid file: selected file must be a JPG or PNG image."} } + return a.doOpenImage(filePath, fileBytes, mimeType) +} + +func (a *App) doOpenImage(filePath string, fileBytes []byte, mimeType string) ExifResult { // Store the file path for the HTTP handler to serve later. a.mu.Lock() a.currentImagePath = filePath @@ -303,6 +325,84 @@ func (a *App) SaveImage(isPng bool, defaultName string) SaveResult { return SaveResult{SaveToken: token} } +// SaveAutoImage bypasses the native dialog and prepares a save token for automated background saving. +func (a *App) SaveAutoImage(isPng bool, savePath string) SaveResult { + // Validate path is within export folder + settingsMu.RLock() + exportFolder := currentSettings.ExportFolder + settingsMu.RUnlock() + + if exportFolder == "" { + return SaveResult{Error: "Export folder is not configured"} + } + + // Resolve symlinks to prevent path traversal attacks + realExport, err := filepath.EvalSymlinks(filepath.Clean(exportFolder)) + if err != nil { + return SaveResult{Error: "Failed to resolve export folder path: " + err.Error()} + } + + // Walk up from the save directory to find the nearest existing ancestor. + // This allows saving into not-yet-created subdirectories under ExportFolder + // (e.g. ExportFolder/2026-05/photo.jpg where 2026-05/ doesn't exist yet). + cleanSave := filepath.Clean(savePath) + ancestor := filepath.Dir(cleanSave) + for { + if _, statErr := os.Stat(ancestor); statErr == nil { + break + } + parent := filepath.Dir(ancestor) + if parent == ancestor { + // Reached filesystem root without finding an existing directory + break + } + ancestor = parent + } + + realAncestor, err := filepath.EvalSymlinks(ancestor) + if err != nil { + return SaveResult{Error: "Failed to resolve save path: " + err.Error()} + } + rel, err := filepath.Rel(realExport, realAncestor) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return SaveResult{Error: "Save path is outside of the allowed export folder"} + } + + expectedMime := "image/jpeg" + if isPng { + expectedMime = "image/png" + } + if a.handler == nil { + return SaveResult{Error: "Internal error: image handler not initialized"} + } + token := a.handler.prepareSave(savePath, expectedMime) + return SaveResult{SaveToken: token} +} + +// SelectWatchFolder opens a directory dialog to pick a watch folder +func (a *App) SelectWatchFolder() string { + path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Select Watch Folder", + }) + if err != nil { + log.Println("Error opening directory dialog:", err) + return "" + } + return path +} + +// SelectExportFolder opens a directory dialog to pick an export folder +func (a *App) SelectExportFolder() string { + path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Select Export Folder", + }) + if err != nil { + log.Println("Error opening directory dialog:", err) + return "" + } + return path +} + func formatFocalLength(num, den int64) string { if den == 0 { return "" diff --git a/frontend/src/App.css b/frontend/src/App.css index 2c7670b..ac82501 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -370,3 +370,53 @@ body { opacity: 0.5; pointer-events: none; } + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; +} + +.modal-content { + background-color: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 2rem; + width: 90%; + max-width: 400px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); +} + +.modal-content h2 { + margin-top: 0; + margin-bottom: 1.5rem; + font-size: 1.2rem; + color: var(--text-primary); +} + +.modal-content .input-group { + margin-bottom: 1.2rem; +} + +.modal-content small { + display: block; + margin-top: 0.4rem; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + margin-top: 2rem; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9fbea0..81d5a79 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import './App.css'; -import { OpenImage, SaveImage } from '../wailsjs/go/main/App'; -import { WindowToggleMaximise, Environment } from '../wailsjs/runtime/runtime'; +import { OpenImage, SaveImage, GetSettings, SaveSettings, SaveAutoImage, SelectWatchFolder, SelectExportFolder } from '../wailsjs/go/main/App'; +import { main } from '../wailsjs/go/models'; +import { WindowToggleMaximise, Environment, EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; interface ExifData { camera: string; @@ -14,7 +15,208 @@ interface ExifData { const TOAST_DURATION_MS = 3000; +function renderImageToCanvas( + canvas: HTMLCanvasElement, + img: HTMLImageElement, + exif: ExifData, + settings: { + aspectRatioPreset: string; + customRatioW: number; + customRatioH: number; + orientation: "landscape" | "portrait"; + alignment: "top" | "center"; + } +) { + // 好みの左右・上の枠の最小太さ(例:幅の2.5%) + const minFramePadding = Math.floor(img.width * 0.025); + // 下部のテキスト領域に必要な最小スペース + const minBottomSpace = Math.floor(minFramePadding * 4.5); + + let targetRatio = 4300 / 3618; + if (settings.aspectRatioPreset === "custom") { + if (settings.customRatioW > 0 && settings.customRatioH > 0) { + targetRatio = settings.customRatioW / settings.customRatioH; + } else { + targetRatio = img.width / img.height; + } + } else { + const [w, h] = settings.aspectRatioPreset.split(':').map(Number); + if (w && h) targetRatio = w / h; + } + + // Apply orientation flip + if (settings.orientation === "portrait" && targetRatio > 1) { + targetRatio = 1 / targetRatio; + } else if (settings.orientation === "landscape" && targetRatio < 1) { + targetRatio = 1 / targetRatio; + } + + const minCanvasWidth = img.width + (minFramePadding * 2); + const minCanvasHeight = img.height + minFramePadding + minBottomSpace; + + // まず幅を基準に高さを計算 + let finalCanvasWidth = minCanvasWidth; + let finalCanvasHeight = Math.floor(finalCanvasWidth / targetRatio); + + if (finalCanvasHeight < minCanvasHeight) { + // 高さが足りない場合は、最小の高さを基準にして幅を拡張 + finalCanvasHeight = minCanvasHeight; + finalCanvasWidth = Math.floor(finalCanvasHeight * targetRatio); + } + + // ⚠️ CRITICAL: Must be set BEFORE getContext, otherwise context properties (colorSpace) are reset! + canvas.width = finalCanvasWidth; + canvas.height = finalCanvasHeight; + + // 余分な高さを計算 + const extraHeight = finalCanvasHeight - minCanvasHeight; + + // 画像の配置位置を計算 (左右中央、上固定または上下中央) + const drawX = Math.floor((finalCanvasWidth - img.width) / 2); + const drawY = settings.alignment === "center" ? minFramePadding + Math.floor(extraHeight / 2) : minFramePadding; + + // Enable P3 wide-gamut mode to prevent high-saturation color loss, with a fallback + let ctx: CanvasRenderingContext2D | null = null; + try { + ctx = canvas.getContext('2d', { colorSpace: 'display-p3' } as CanvasRenderingContext2DSettings); + } catch (e) { + // Context with colorSpace might throw in unsupported environments + } + if (!ctx) { + ctx = canvas.getContext('2d'); + } + if (!ctx) return; + + // Fill background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw image + ctx.drawImage(img, drawX, drawY); + + // 画像の下端座標 + const imgBottomY = drawY + img.height; + // 写真の下端からキャンバスの下端までの余白 + const bottomSpaceHeight = canvas.height - imgBottomY; + + // テキストの配置Y座標は、画像の下端とキャンバス下端の中央 + const textY = imgBottomY + (bottomSpaceHeight / 2); + + // テキストのサイズを(marginではなく)画像自体のサイズを基準にする + const baseScale = Math.min(img.width, img.height); + + // Settings for text + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Camera and Lens + const topElements = [exif.camera, exif.lens].filter(Boolean); + const topText = topElements.join(" | "); + + if (topText) { + const titleFontSize = Math.floor(baseScale * 0.035); // 画像サイズの約3.5% + ctx.font = `normal ${titleFontSize}px "Gill Sans", sans-serif`; + ctx.fillText(topText, canvas.width / 2, textY - (titleFontSize * 0.8)); + } + + // Settings (Aperture, SS, ISO etc) + const bottomElements = [exif.focalLength, exif.aperture, exif.shutterSpeed, exif.iso].filter(Boolean); + const bottomText = bottomElements.join(" | "); + + if (bottomText) { + const descFontSize = Math.floor(baseScale * 0.025); // 画像サイズの約2.5% + ctx.font = `normal ${descFontSize}px "Gill Sans", sans-serif`; + ctx.fillStyle = '#555555'; + ctx.fillText(bottomText, canvas.width / 2, textY + (descFontSize * 0.8)); + } + + // Draw a subtle line separator (just above the text) + ctx.beginPath(); + ctx.moveTo(canvas.width * 0.2, imgBottomY); + ctx.lineTo(canvas.width * 0.8, imgBottomY); + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = Math.max(1, Math.floor(baseScale * 0.0015)); + ctx.stroke(); +} + function App() { + const [showSettings, setShowSettings] = useState(false); + const [watchFolder, setWatchFolder] = useState(""); + const [exportFolder, setExportFolder] = useState(""); + + useEffect(() => { + EventsOn("process_file", async (data: any) => { + const { result, export: exportFolderStr } = data; + if (!result || !result.imageURL) return; + + try { + const currentSet = await GetSettings(); + + const img = new Image(); + img.onload = async () => { + const offscreenCanvas = document.createElement('canvas'); + const exifData = { + camera: result.camera || "", + lens: result.lens || "", + focalLength: result.focalLength || "", + aperture: result.aperture || "", + shutterSpeed: result.shutterSpeed || "", + iso: result.iso || "" + }; + + renderImageToCanvas(offscreenCanvas, img, exifData, { + aspectRatioPreset: currentSet.aspectRatioPreset || "4300:3618", + customRatioW: currentSet.customRatioW || 4300, + customRatioH: currentSet.customRatioH || 3618, + orientation: (currentSet.orientation as any) || "landscape", + alignment: (currentSet.alignment as any) || "top" + }); + + const isPng = result.mimeType === 'image/png'; + const targetMime = isPng ? 'image/png' : 'image/jpeg'; + const filenameMatch = result.filePath ? result.filePath.split(/[/\\]/).pop() : ""; + const baseName = (filenameMatch ? filenameMatch.replace(/\.[^/.]+$/, "") : "") || "exif-frame"; + let exportName = `${baseName}_ExifFrame`; + if (isPng) exportName += ".png"; else exportName += ".jpg"; + + const savePath = exportFolderStr + "/" + exportName; + + offscreenCanvas.toBlob(async (blob) => { + if (!blob) return; + try { + const resultSave = await SaveAutoImage(isPng, savePath); + if (resultSave.saveToken) { + const arrayBuffer = await blob.arrayBuffer(); + const resp = await fetch(`/api/save?token=${encodeURIComponent(resultSave.saveToken)}`, { + method: 'POST', + headers: { 'Content-Type': targetMime }, + body: arrayBuffer, + }); + if (!resp.ok) { + const errText = await resp.text(); + console.error("Background save failed:", resp.status, errText); + } else { + console.log("Background save complete:", savePath); + } + } else { + console.error("Auto save failed:", resultSave.error); + } + } catch (e) { + console.error(e); + } + }, targetMime, 1.0); + }; + img.src = result.imageURL; + } catch (e) { + console.error(e); + } + }); + + return () => { + EventsOff("process_file"); + }; + }, []); const canvasRef = useRef(null); const [imageLoaded, setImageLoaded] = useState(false); const [exif, setExif] = useState({ @@ -30,12 +232,25 @@ function App() { const [isSelecting, setIsSelecting] = useState(false); const isSelectingRef = useRef(false); const [filePath, setFilePath] = useState(""); + const isInitialLoad = useRef(true); const [isMac, setIsMac] = useState(false); const [sourceMimeType, setSourceMimeType] = useState(""); const [toastMessage, setToastMessage] = useState(null); const toastTimerRef = useRef(null); const toastRafRef = useRef(null); + // Toast cleanup + useEffect(() => { + return () => { + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + if (toastRafRef.current !== null) { + window.cancelAnimationFrame(toastRafRef.current); + } + }; + }, []); + const [aspectRatioPreset, setAspectRatioPreset] = useState("4300:3618"); const [customRatioW, setCustomRatioW] = useState(4300); const [customRatioH, setCustomRatioH] = useState(3618); @@ -65,16 +280,82 @@ function App() { }; useEffect(() => { + // Load settings on mount + GetSettings().then(s => { + if (s.watchFolder) setWatchFolder(s.watchFolder); + if (s.exportFolder) setExportFolder(s.exportFolder); + if (s.aspectRatioPreset) setAspectRatioPreset(s.aspectRatioPreset); + if (s.customRatioW) setCustomRatioW(s.customRatioW); + if (s.customRatioH) setCustomRatioH(s.customRatioH); + if (s.orientation) setOrientation(s.orientation as any); + if (s.alignment) setAlignment(s.alignment as any); + }).catch(err => { + console.error("Failed to load settings:", err); + }).finally(() => { + // Allow short delay before enabling auto-save to prevent initial trigger + setTimeout(() => { + isInitialLoad.current = false; + }, 100); + }); + + const unsubSettings = EventsOn("open_settings", () => { + console.log("open_settings event received"); + setShowSettings(true); + }); + + // Return cleanup return () => { - if (toastTimerRef.current !== null) { - window.clearTimeout(toastTimerRef.current); + if (typeof unsubSettings === 'function') { + unsubSettings(); + } else { + EventsOff("open_settings"); } - if (toastRafRef.current !== null) { - window.cancelAnimationFrame(toastRafRef.current); + }; + }, []); + + // Escape key to close settings modal + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setShowSettings(false); } }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, []); + // Save settings when aspect ratio etc changes + useEffect(() => { + if (isInitialLoad.current) return; + + const saveCurrentSettings = async () => { + const s = new main.Settings(); + s.watchFolder = watchFolder; + s.exportFolder = exportFolder; + s.aspectRatioPreset = aspectRatioPreset; + s.customRatioW = customRatioW; + s.customRatioH = customRatioH; + s.orientation = orientation; + s.alignment = alignment; + try { + const errStr = await SaveSettings(s); + if (errStr && errStr !== "") { + showToast(errStr); + } else { + showToast("Settings saved"); + } + } catch (e: any) { + showToast("Error saving settings"); + } + }; + + // Debounce saving settings when UI changes + const t = setTimeout(() => { + saveCurrentSettings(); + }, 500); + return () => clearTimeout(t); + }, [aspectRatioPreset, customRatioW, customRatioH, orientation, alignment, watchFolder, exportFolder]); + useEffect(() => { Environment().then(env => { if (env.platform === 'darwin') { @@ -142,122 +423,16 @@ function App() { } }; - - const drawCanvas = useCallback((img: HTMLImageElement) => { const canvas = canvasRef.current; if (!canvas) return; - // 好みの左右・上の枠の最小太さ(例:幅の2.5%) - const minFramePadding = Math.floor(img.width * 0.025); - // 下部のテキスト領域に必要な最小スペース - const minBottomSpace = Math.floor(minFramePadding * 4.5); - - let targetRatio = 4300 / 3618; - if (aspectRatioPreset === "custom") { - if (customRatioW > 0 && customRatioH > 0) { - targetRatio = customRatioW / customRatioH; - } else { - targetRatio = img.width / img.height; - } - } else { - const [w, h] = aspectRatioPreset.split(':').map(Number); - if (w && h) targetRatio = w / h; - } - - // Apply orientation flip - if (orientation === "portrait" && targetRatio > 1) { - targetRatio = 1 / targetRatio; - } else if (orientation === "landscape" && targetRatio < 1) { - targetRatio = 1 / targetRatio; - } - - const minCanvasWidth = img.width + (minFramePadding * 2); - const minCanvasHeight = img.height + minFramePadding + minBottomSpace; - - // まず幅を基準に高さを計算 - let finalCanvasWidth = minCanvasWidth; - let finalCanvasHeight = Math.floor(finalCanvasWidth / targetRatio); - - if (finalCanvasHeight < minCanvasHeight) { - // 高さが足りない場合は、最小の高さを基準にして幅を拡張 - finalCanvasHeight = minCanvasHeight; - finalCanvasWidth = Math.floor(finalCanvasHeight * targetRatio); - } - - // ⚠️ CRITICAL: Must be set BEFORE getContext, otherwise context properties (colorSpace) are reset! - canvas.width = finalCanvasWidth; - canvas.height = finalCanvasHeight; - - // 余分な高さを計算 - const extraHeight = finalCanvasHeight - minCanvasHeight; - - // 画像の配置位置を計算 (左右中央、上固定または上下中央) - const drawX = Math.floor((finalCanvasWidth - img.width) / 2); - const drawY = alignment === "center" ? minFramePadding + Math.floor(extraHeight / 2) : minFramePadding; - - // Enable P3 wide-gamut mode to prevent high-saturation color loss, with a fallback - let ctx: CanvasRenderingContext2D | null = null; - try { - ctx = canvas.getContext('2d', { colorSpace: 'display-p3' } as CanvasRenderingContext2DSettings); - } catch (e) { - // Context with colorSpace might throw in unsupported environments - } - if (!ctx) { - ctx = canvas.getContext('2d'); - } - if (!ctx) return; - - // Fill background - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Draw image - ctx.drawImage(img, drawX, drawY); - - // 画像の下端座標 - const imgBottomY = drawY + img.height; - // 写真の下端からキャンバスの下端までの余白 - const bottomSpaceHeight = canvas.height - imgBottomY; - - // テキストの配置Y座標は、画像の下端とキャンバス下端の中央 - const textY = imgBottomY + (bottomSpaceHeight / 2); - - // テキストのサイズを(marginではなく)画像自体のサイズを基準にする - const baseScale = Math.min(img.width, img.height); - - // Settings for text - ctx.fillStyle = '#000000'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - // Camera and Lens - const topElements = [exif.camera, exif.lens].filter(Boolean); - const topText = topElements.join(" | "); - - if (topText) { - const titleFontSize = Math.floor(baseScale * 0.035); // 画像サイズの約3.5% - ctx.font = `normal ${titleFontSize}px "Gill Sans", sans-serif`; - ctx.fillText(topText, canvas.width / 2, textY - (titleFontSize * 0.8)); - } - - // Settings (Aperture, SS, ISO etc) - const bottomElements = [exif.focalLength, exif.aperture, exif.shutterSpeed, exif.iso].filter(Boolean); - const bottomText = bottomElements.join(" | "); - - if (bottomText) { - const descFontSize = Math.floor(baseScale * 0.025); // 画像サイズの約2.5% - ctx.font = `normal ${descFontSize}px "Gill Sans", sans-serif`; - ctx.fillStyle = '#555555'; - ctx.fillText(bottomText, canvas.width / 2, textY + (descFontSize * 0.8)); - } - - // Draw a subtle line separator (just above the text) - ctx.beginPath(); - ctx.moveTo(canvas.width * 0.2, imgBottomY); - ctx.lineTo(canvas.width * 0.8, imgBottomY); - ctx.strokeStyle = '#e0e0e0'; - ctx.lineWidth = Math.max(1, Math.floor(baseScale * 0.0015)); - ctx.stroke(); + renderImageToCanvas(canvas, img, exif, { + aspectRatioPreset, + customRatioW, + customRatioH, + orientation, + alignment + }); }, [exif, aspectRatioPreset, customRatioW, customRatioH, orientation, alignment]); useEffect(() => { @@ -520,6 +695,53 @@ function App() { )} + + {showSettings && ( +
setShowSettings(false)}> +
e.stopPropagation()}> +

Preferences

+
+ +
+ + + +
+ Images dropped here will be processed automatically. +
+
+ +
+ + + +
+ Auto-processed images will be saved here. +
+
+ +
+
+
+ )} ); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 0905252..0aa9c21 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -2,6 +2,16 @@ // This file is automatically generated. DO NOT EDIT import {main} from '../models'; +export function GetSettings():Promise; + export function OpenImage():Promise; +export function SaveAutoImage(arg1:boolean,arg2:string):Promise; + export function SaveImage(arg1:boolean,arg2:string):Promise; + +export function SaveSettings(arg1:main.Settings):Promise; + +export function SelectExportFolder():Promise; + +export function SelectWatchFolder():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 46e75d2..28671c4 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,10 +2,30 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function GetSettings() { + return window['go']['main']['App']['GetSettings'](); +} + export function OpenImage() { return window['go']['main']['App']['OpenImage'](); } +export function SaveAutoImage(arg1, arg2) { + return window['go']['main']['App']['SaveAutoImage'](arg1, arg2); +} + export function SaveImage(arg1, arg2) { return window['go']['main']['App']['SaveImage'](arg1, arg2); } + +export function SaveSettings(arg1) { + return window['go']['main']['App']['SaveSettings'](arg1); +} + +export function SelectExportFolder() { + return window['go']['main']['App']['SelectExportFolder'](); +} + +export function SelectWatchFolder() { + return window['go']['main']['App']['SelectWatchFolder'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 4890fa5..638be4e 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -48,6 +48,30 @@ export namespace main { this.saveToken = source["saveToken"]; } } + export class Settings { + watchFolder: string; + exportFolder: string; + aspectRatioPreset: string; + customRatioW: number; + customRatioH: number; + orientation: string; + alignment: string; + + static createFrom(source: any = {}) { + return new Settings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.watchFolder = source["watchFolder"]; + this.exportFolder = source["exportFolder"]; + this.aspectRatioPreset = source["aspectRatioPreset"]; + this.customRatioW = source["customRatioW"]; + this.customRatioH = source["customRatioH"]; + this.orientation = source["orientation"]; + this.alignment = source["alignment"]; + } + } } diff --git a/go.mod b/go.mod index 2ab43e3..1fedcce 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module ExifFrame go 1.23 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/wailsapp/wails/v2 v2.10.2 ) diff --git a/go.sum b/go.sum index a247a0d..76dca08 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= diff --git a/handler.go b/handler.go index 33db2a9..ba0bfc8 100644 --- a/handler.go +++ b/handler.go @@ -1,12 +1,18 @@ package main import ( + "context" "crypto/rand" "encoding/hex" "io" + "log" "mime" "net/http" "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "sync" "time" ) @@ -244,7 +250,24 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { } } + // Send the HTTP response immediately before showing the notification. w.WriteHeader(http.StatusOK) + + // Show a native notification on macOS (asynchronously to avoid blocking the response) + if runtime.GOOS == "darwin" { + go func() { + fileName := filepath.Base(savePath) + // Prevent AppleScript injection + fileName = strings.ReplaceAll(fileName, `\`, `\\`) + fileName = strings.ReplaceAll(fileName, `"`, `\"`) + msg := "Saved " + fileName + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "osascript", "-e", `display notification "`+msg+`" with title "ExifFrame"`).Run(); err != nil { + log.Println("Notification failed:", err) + } + }() + } } // generateToken returns a cryptographically random 16-byte hex string. diff --git a/main.go b/main.go index adfa325..7c8d423 100644 --- a/main.go +++ b/main.go @@ -2,22 +2,82 @@ package main import ( "embed" + "runtime" "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/mac" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) //go:embed all:frontend/dist var assets embed.FS +// buildMenu constructs the application menu +func buildMenu(app *App) *menu.Menu { + AppMenu := menu.NewMenu() + if runtime.GOOS == "darwin" { + // Manually construct the Application Menu (ExifFrame) to allow injecting Preferences + appMenu := menu.NewMenu() + appMenu.AddText("About ExifFrame", nil, func(_ *menu.CallbackData) {}) + appMenu.AddSeparator() + appMenu.AddText("Preferences...", keys.CmdOrCtrl(","), func(_ *menu.CallbackData) { + if app.ctx != nil { + wailsruntime.WindowUnminimise(app.ctx) + wailsruntime.WindowShow(app.ctx) + wailsruntime.EventsEmit(app.ctx, "open_settings") + } + }) + appMenu.AddSeparator() + appMenu.AddText("Hide ExifFrame", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) { + if app.ctx != nil { + wailsruntime.WindowHide(app.ctx) + } + }) + // Dummy item for standard Mac UI completeness + appMenu.AddText("Hide Others", keys.OptionOrAlt("h"), func(_ *menu.CallbackData) {}) + appMenu.AddText("Show All", nil, func(_ *menu.CallbackData) { + if app.ctx != nil { + wailsruntime.WindowShow(app.ctx) + } + }) + appMenu.AddSeparator() + appMenu.AddText("Quit ExifFrame", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { + if app.ctx != nil { + wailsruntime.Quit(app.ctx) + } + }) + + // macOS uses the first menu as the Application Menu and renames it to the App Name natively + AppMenu.Append(menu.SubMenu("ExifFrame", appMenu)) + + AppMenu.Append(menu.EditMenu()) + AppMenu.Append(menu.WindowMenu()) + } else { + prefsMenu := AppMenu.AddSubmenu("File") + prefsMenu.AddText("Preferences...", keys.CmdOrCtrl(","), func(_ *menu.CallbackData) { + if app.ctx != nil { + wailsruntime.WindowUnminimise(app.ctx) + wailsruntime.WindowShow(app.ctx) + wailsruntime.EventsEmit(app.ctx, "open_settings") + } + }) + } + return AppMenu +} + func main() { // Create an instance of the app structure app := NewApp() handler := NewImageHandler(app) app.handler = handler + // Setup application menu + AppMenu := buildMenu(app) + // Create application with options err := wails.Run(&options.App{ Title: "ExifFrame", @@ -32,7 +92,9 @@ func main() { Mac: &mac.Options{ TitleBar: mac.TitleBarHiddenInset(), }, + Menu: AppMenu, OnStartup: app.startup, + OnShutdown: app.shutdown, Bind: []interface{}{ app, }, diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..22cf9fa --- /dev/null +++ b/settings.go @@ -0,0 +1,171 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "sync" +) + +type Settings struct { + WatchFolder string `json:"watchFolder"` + ExportFolder string `json:"exportFolder"` + AspectRatioPreset string `json:"aspectRatioPreset"` + CustomRatioW int `json:"customRatioW"` + CustomRatioH int `json:"customRatioH"` + Orientation string `json:"orientation"` + Alignment string `json:"alignment"` +} + +var ( + settingsFile string + settingsMu sync.RWMutex + currentSettings Settings +) + +func init() { + configDir, err := os.UserConfigDir() + if err != nil { + log.Println("Warning: could not get config dir:", err) + } else { + appDir := filepath.Join(configDir, "ExifFrame") + if err := os.MkdirAll(appDir, 0755); err != nil { + log.Println("Warning: could not create config dir:", err) + } else { + settingsFile = filepath.Join(appDir, "settings.json") + } + } + + // Set defaults + currentSettings = Settings{ + AspectRatioPreset: "4300:3618", + CustomRatioW: 4300, + CustomRatioH: 3618, + Orientation: "landscape", + Alignment: "top", + } + loadSettings() +} + +func loadSettings() { + if settingsFile == "" { + return + } + data, err := os.ReadFile(settingsFile) + if err == nil { + // Initialize temp with current defaults so that fields not present + // in the JSON file retain their default values. + temp := currentSettings + if err := json.Unmarshal(data, &temp); err != nil { + log.Println("Error parsing settings.json:", err) + return + } + settingsMu.Lock() + currentSettings = temp + settingsMu.Unlock() + } +} + +func saveSettings() error { + if settingsFile == "" { + return fmt.Errorf("settings file path is not configured") + } + settingsMu.RLock() + data, err := json.MarshalIndent(currentSettings, "", " ") + settingsMu.RUnlock() + + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + if err := os.WriteFile(settingsFile, data, 0644); err != nil { + return fmt.Errorf("failed to write settings to disk: %w", err) + } + return nil +} + +// normalizePathForCompare normalizes a path for comparison. +// On case-insensitive filesystems (macOS, Windows), it lowercases the path. +func normalizePathForCompare(path string) string { + cleaned := filepath.Clean(path) + if goruntime.GOOS == "darwin" || goruntime.GOOS == "windows" { + return strings.ToLower(cleaned) + } + return cleaned +} + +// GetSettings is called from frontend to retrieve current settings +func (a *App) GetSettings() Settings { + settingsMu.RLock() + defer settingsMu.RUnlock() + return currentSettings +} + +// SaveSettings is called from frontend to save settings and restart watcher if needed +func (a *App) SaveSettings(s Settings) string { + // Validate folder hierarchy to prevent infinite loop (Task 9) + // Resolve symlinks for accurate comparison — plain filepath.Clean can be + // bypassed when one of the folders is a symlink. + if s.WatchFolder != "" && s.ExportFolder != "" { + watchReal, errW := filepath.EvalSymlinks(filepath.Clean(s.WatchFolder)) + exportReal, errE := filepath.EvalSymlinks(filepath.Clean(s.ExportFolder)) + + // If EvalSymlinks fails (e.g. folder doesn't exist yet), fall back to + // normalized string comparison so we still catch obvious conflicts. + if errW != nil { + watchReal = normalizePathForCompare(s.WatchFolder) + } else { + watchReal = normalizePathForCompare(watchReal) + } + if errE != nil { + exportReal = normalizePathForCompare(s.ExportFolder) + } else { + exportReal = normalizePathForCompare(exportReal) + } + + if watchReal == exportReal { + return "Error: Export folder cannot be the same as the Watch folder." + } + sep := string(filepath.Separator) + if (watchReal == sep && strings.HasPrefix(exportReal, sep)) || strings.HasPrefix(exportReal, watchReal+sep) { + return "Error: Export folder cannot be a subdirectory of the Watch folder." + } + if (exportReal == sep && strings.HasPrefix(watchReal, sep)) || strings.HasPrefix(watchReal, exportReal+sep) { + return "Error: Watch folder cannot be a subdirectory of the Export folder." + } + } + + settingsMu.Lock() + oldSettings := currentSettings + currentSettings = s + settingsMu.Unlock() + + if err := saveSettings(); err != nil { + // Rollback to previous settings on persistence failure + settingsMu.Lock() + currentSettings = oldSettings + settingsMu.Unlock() + log.Println("Error saving settings:", err) + return "Error: Failed to save settings: " + err.Error() + } + + if oldSettings.WatchFolder != s.WatchFolder { + if err := a.updateWatcher(s.WatchFolder); err != nil { + // Rollback to previous settings + settingsMu.Lock() + currentSettings = oldSettings + settingsMu.Unlock() + if rollbackErr := saveSettings(); rollbackErr != nil { + log.Println("Error rolling back settings file:", rollbackErr) + } + if watcherErr := a.updateWatcher(oldSettings.WatchFolder); watcherErr != nil { + log.Println("Error restoring previous watcher:", watcherErr) + } + return "Error: Failed to start watcher: " + err.Error() + } + } + return "" // Success +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..d0694fc --- /dev/null +++ b/watcher.go @@ -0,0 +1,142 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +var ( + watcher *fsnotify.Watcher + watcherMu sync.Mutex + processing sync.Map // track files being processed + watcherDone chan struct{} // track watcher goroutine exit +) + +// updateWatcher restarts the fsnotify watcher for the given directory +func (a *App) updateWatcher(folder string) error { + watcherMu.Lock() + defer watcherMu.Unlock() + + // Stop existing watcher + if watcher != nil { + watcher.Close() + if watcherDone != nil { + <-watcherDone // wait for goroutine to exit + } + watcher = nil + } + + if folder == "" { + return nil + } + + w, err := fsnotify.NewWatcher() + if err != nil { + log.Println("Error creating watcher:", err) + return err + } + watcher = w + + err = watcher.Add(folder) + if err != nil { + log.Println("Error adding watcher for folder:", folder, err) + watcher.Close() + watcher = nil + return err + } + + wDone := make(chan struct{}) + watcherDone = wDone + + go func() { + defer close(wDone) + for { + select { + case event, ok := <-w.Events: + if !ok { + return + } + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) { + // Check if file is image and not already processed + ext := strings.ToLower(filepath.Ext(event.Name)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" { + // Skip files generated by ExifFrame + if strings.Contains(event.Name, "_ExifFrame") { + continue + } + + // Basic debounce tracking + if _, loaded := processing.LoadOrStore(event.Name, true); loaded { + continue + } + + go func(filePath string) { + defer processing.Delete(filePath) + + // Wait for file size to stabilize (up to 5 seconds) + var lastSize int64 = -1 + for i := 0; i < 10; i++ { + info, err := os.Stat(filePath) + if err != nil { + time.Sleep(500 * time.Millisecond) + continue + } + if info.Size() == lastSize && lastSize > 0 { + break + } + lastSize = info.Size() + time.Sleep(500 * time.Millisecond) + } + + // Process the file + log.Println("Processing new file:", filePath) + a.processBackgroundFile(filePath) + }(event.Name) + } + } + case err, ok := <-w.Errors: + if !ok { + return + } + log.Println("Watcher error:", err) + } + } + }() + + return nil +} + +// processBackgroundFile extracts EXIF and sends it to the frontend quietly +func (a *App) processBackgroundFile(filePath string) { + if a.ctx == nil { + return + } + + // Make sure we have the settings for export + settingsMu.RLock() + exportFolder := currentSettings.ExportFolder + settingsMu.RUnlock() + if exportFolder == "" { + log.Println("No export folder configured") + return + } + + result := a.processImageFile(filePath) + if result.Error != "" { + log.Println("Background process error:", result.Error) + return + } + + // Emit event to frontend with file details + runtime.EventsEmit(a.ctx, "process_file", map[string]interface{}{ + "result": result, + "export": exportFolder, + }) +}