Skip to content
Open
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
141 changes: 126 additions & 15 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,92 @@ func (a *App) OpenImage() ExifResult {
return a.ProcessImageFile(filePath)
}

// OpenImages opens a native file dialog for multiple files or directories, reads EXIF metadata, and returns
// a list of HTTP URLs and metadata for the frontend.
func (a *App) OpenImages() []ExifResult {
filePaths, err := application.Get().Dialog.OpenFile().
SetTitle("Select Photos or Folders").
AddFilter("Images", "*.jpg;*.jpeg;*.png").
CanChooseDirectories(true).
CanChooseFiles(true).
PromptForMultipleSelection()
if err != nil {
return []ExifResult{{Error: err.Error()}}
}
if len(filePaths) == 0 {
return []ExifResult{{Cancelled: true}}
}

return a.ProcessPaths(filePaths)
}

// OpenFolder opens a native directory dialog and processes all valid images within.
func (a *App) OpenFolder() []ExifResult {
folderPath, err := application.Get().Dialog.OpenFile().
SetTitle("Select Folder").
CanChooseDirectories(true).
CanChooseFiles(false).
PromptForSingleSelection()
if err != nil {
return []ExifResult{{Error: err.Error()}}
}
if folderPath == "" {
return []ExifResult{{Cancelled: true}} // user cancelled
}

return a.ProcessPaths([]string{folderPath})
}

// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string

for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}

if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
validPaths = append(validPaths, path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
validPaths = append(validPaths, p)
}
}

for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}

if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}

return results
}
Comment on lines +155 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

重複選択時に同一画像が二重取り込みされます。

Line 160-186 では validPaths の重複排除がないため、フォルダとその配下ファイルを同時選択したケースで同じパスが複数回 ProcessImageFile されます。filmstrip と一括エクスポート結果が重複します。

🔧 修正案(重複パス除去)
 func (a *App) ProcessPaths(paths []string) []ExifResult {
 	var results []ExifResult
 	var validPaths []string
+	seen := make(map[string]struct{})
+	addValidPath := func(p string) {
+		clean := filepath.Clean(p)
+		if _, ok := seen[clean]; ok {
+			return
+		}
+		seen[clean] = struct{}{}
+		validPaths = append(validPaths, clean)
+	}
 
 	for _, p := range paths {
 		info, err := os.Stat(p)
@@
 				if !info.IsDir() {
 					lower := strings.ToLower(path)
 					if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
-						validPaths = append(validPaths, path)
+						addValidPath(path)
 					}
 				}
 				return nil
@@
 		} else {
-			validPaths = append(validPaths, p)
+			addValidPath(p)
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}
if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
validPaths = append(validPaths, path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
validPaths = append(validPaths, p)
}
}
for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}
if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}
return results
}
// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string
seen := make(map[string]struct{})
addValidPath := func(p string) {
clean := filepath.Clean(p)
if _, ok := seen[clean]; ok {
return
}
seen[clean] = struct{}{}
validPaths = append(validPaths, clean)
}
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}
if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
addValidPath(path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
addValidPath(p)
}
}
for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}
if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}
return results
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.go` around lines 155 - 203, ProcessPaths collects duplicate file paths
into validPaths causing duplicate processing; fix by normalizing each candidate
path (use filepath.Clean and preferably filepath.Abs) and deduplicating before
calling ProcessImageFile: maintain a map[string]struct{} seen and when adding to
validPaths check seen[normalized]==false then mark seen[normalized]=struct{}{}
and append; you can apply this either when you append inside the Walk callback
and the else branch (replace direct validPaths.append with the normalized+seen
check) or run a short dedupe pass over validPaths before the final loop; keep
using a.ProcessImageFile(path) afterward with the normalized path.


// ProcessImageFile reads a file, validates it, and extracts EXIF
func (a *App) ProcessImageFile(filePath string) ExifResult {
f, err := os.Open(filePath)
Expand Down Expand Up @@ -332,21 +418,9 @@ func (a *App) SaveImage(isPng bool, defaultName string) SaveResult {
return SaveResult{Cancelled: true}
}

ext := strings.ToLower(filepath.Ext(savePath))
if ext == "" {
// User omitted extension, append the correct one
if isPng {
savePath += ".png"
} else {
savePath += ".jpg"
}
} else {
// User provided an extension, make sure it matches the output format
if isPng && ext != ".png" {
return SaveResult{Error: "Invalid extension. Please save as .png"}
} else if !isPng && ext != ".jpg" && ext != ".jpeg" {
return SaveResult{Error: "Invalid extension. Please save as .jpg or .jpeg"}
}
savePath, err = ensureValidExtension(savePath, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}

// Signal the HTTP handler that a save path is ready.
Expand Down Expand Up @@ -413,6 +487,25 @@ func (a *App) SaveAutoImage(isPng bool, savePath string) SaveResult {
return SaveResult{SaveToken: token}
}

// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
savePath := filepath.Join(exportDir, exportName)
savePath, err := ensureValidExtension(savePath, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}

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}
}
Comment on lines +490 to +507

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SaveBatchImage が保存先ディレクトリ境界を検証していません。

Line 492-493 は filepath.Join(exportDir, exportName) を未検証で使っており、exportName../ や絶対パスが入ると exportDir 外へ保存可能です。さらに exportDir が空の場合、相対パス保存になります。バッチ保存API側で境界検証を入れてください。

🛡️ 修正案(空値チェック + ディレクトリ外書き込み防止)
 func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
-	savePath := filepath.Join(exportDir, exportName)
-	savePath, err := ensureValidExtension(savePath, isPng)
+	if strings.TrimSpace(exportDir) == "" {
+		return SaveResult{Error: "Export directory is required"}
+	}
+	if strings.TrimSpace(exportName) == "" {
+		return SaveResult{Error: "Export filename is required"}
+	}
+
+	baseDir := filepath.Clean(exportDir)
+	candidate := filepath.Join(baseDir, exportName)
+	rel, err := filepath.Rel(baseDir, candidate)
+	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		return SaveResult{Error: "Invalid export filename"}
+	}
+
+	savePath, err := ensureValidExtension(candidate, isPng)
 	if err != nil {
 		return SaveResult{Error: err.Error()}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
savePath := filepath.Join(exportDir, exportName)
savePath, err := ensureValidExtension(savePath, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}
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}
}
// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
if strings.TrimSpace(exportDir) == "" {
return SaveResult{Error: "Export directory is required"}
}
if strings.TrimSpace(exportName) == "" {
return SaveResult{Error: "Export filename is required"}
}
baseDir := filepath.Clean(exportDir)
candidate := filepath.Join(baseDir, exportName)
rel, err := filepath.Rel(baseDir, candidate)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return SaveResult{Error: "Invalid export filename"}
}
savePath, err := ensureValidExtension(candidate, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}
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}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.go` around lines 490 - 507, SaveBatchImage currently builds savePath with
filepath.Join(exportDir, exportName) without validating the target is inside
exportDir; fix SaveBatchImage to (1) reject empty exportDir, (2) reject
exportName if filepath.IsAbs(exportName) or if filepath.Clean(exportName)
contains ".." path elements, (3) construct candidate := filepath.Join(exportDir,
exportName), resolve abs paths with filepath.Abs and filepath.Clean for both
exportDir and candidate, then use filepath.Rel(exportDirAbs, candidateAbs) and
ensure the relative path does not start with ".." (or return a SaveResult
error), and only then call ensureValidExtension(candidate, isPng) and proceed to
prepareSave; update references to savePath in the function accordingly so no
unvalidated path reaches handler.prepareSave.


// SelectWatchFolder opens a directory dialog to pick a watch folder
func (a *App) SelectWatchFolder() string {
path, err := application.Get().Dialog.OpenFile().
Expand Down Expand Up @@ -457,6 +550,24 @@ func formatAperture(num, den int64) string {
return fmt.Sprintf("f/%.1f", val)
}

// ensureValidExtension checks the file path and appends or validates the required extension.
func ensureValidExtension(savePath string, isPng bool) (string, error) {
ext := strings.ToLower(filepath.Ext(savePath))
if ext == "" {
if isPng {
return savePath + ".png", nil
}
return savePath + ".jpg", nil
}

if isPng && ext != ".png" {
return "", fmt.Errorf("Invalid extension. Please save as .png")
} else if !isPng && ext != ".jpg" && ext != ".jpeg" {
return "", fmt.Errorf("Invalid extension. Please save as .jpg or .jpeg")
}
return savePath, nil
}

func gcd(a, b int64) int64 {
if a < 0 {
a = -a
Expand Down
157 changes: 156 additions & 1 deletion frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,65 @@ body {
color: #fff;
}

/* Button Groups & Dropdowns */
.btn-group {
display: flex;
align-items: stretch;
}

.btn-group .btn:not(:last-child),
.btn-group .dropdown:not(:last-child) .btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid rgba(255, 255, 255, 0.1);
}

.btn-group .btn:not(:first-child),
.btn-group .dropdown:not(:first-child) .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 0.5rem;
padding-right: 0.5rem;
}

.dropdown {
position: relative;
display: flex;
}

.dropdown-menu {
position: absolute;
right: 0;
top: 100%;
margin-top: 0.25rem;
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 1000;
min-width: 140px;
padding: 0.25rem 0;
}

.dropdown-item {
display: block;
width: 100%;
padding: 0.5rem 1rem;
text-align: left;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
transition: background-color 0.2s, color 0.2s;
}

.dropdown-item:hover, .dropdown-item:focus {
background-color: var(--accent-color);
color: white;
outline: none;
}

/* Main Workspace */
.workspace {
display: flex;
Expand All @@ -160,6 +219,7 @@ body {
.preview-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--bg-workspace);
Expand Down Expand Up @@ -211,7 +271,8 @@ body {
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}

.preview-canvas {
Expand All @@ -220,6 +281,42 @@ body {
object-fit: contain;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
background: #fff;
opacity: 1;
transition: opacity 0.1s ease; /* fast restore */
}

.preview-canvas.loading {
opacity: 0.6;
transition: opacity 0.2s ease 0.1s; /* wait 100ms before dimming */
}

.hidden-canvas {
opacity: 0 !important;
box-shadow: none !important;
visibility: hidden;
}

/* Delayed Spinner Overlay */
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease 0.15s; /* wait 150ms before showing spinner */
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-primary);
background-color: rgba(0,0,0,0.5);
padding: 1.5rem;
border-radius: 12px;
backdrop-filter: blur(4px);
}

.loading-overlay.visible {
opacity: 1;
}

/* Drag and Drop Overlay */
Expand Down Expand Up @@ -254,6 +351,64 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

.filmstrip-area {
display: flex;
gap: 0.5rem;
padding: 1.5rem 0 1rem 0; /* Added bottom padding to prevent scrollbar overlap */
margin-top: 1.5rem;
width: 100%;
overflow-x: auto;
background-color: transparent;
border-top: 1px solid var(--border-color);
box-sizing: border-box;
}

/* Custom thin scrollbar for filmstrip */
.filmstrip-area::-webkit-scrollbar {
height: 8px;
}
.filmstrip-area::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.filmstrip-area::-webkit-scrollbar-track {
background: transparent;
}

.filmstrip-item {
height: 60px;
width: 80px;
flex-shrink: 0;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s ease, opacity 0.2s ease;
opacity: 0.6;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
}

.filmstrip-item:hover {
opacity: 0.8;
}

.filmstrip-item.selected {
border-color: var(--accent-color);
opacity: 1;
}

.filmstrip-item img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
-webkit-user-drag: none;
user-select: none;
}

/* Sidebar */
.sidebar {
width: 320px;
Expand Down
Loading