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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ frontend/dist
# build package
*.exe
*.dmg
ExifFrame

100 changes: 100 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"log"
"math"
"net/http"
"os"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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"}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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 ""
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.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;
}
Loading