Skip to content

Commit c1748f5

Browse files
lsteinLincoln Stein
andauthored
[feature] Add directory path browser GUI (#69)
* add directory browser UI to album manager * move directory browsing code into its own js file * make directory browser active on first launch * improve album directory editing - Whenever a directory path is added, removed or changed, re-index the album. - Gracefully handle edge case in which a directory path is added that already contains an (out of date) photomap index. - "Poke" the filesystem before traversing so that automounted directories appear in the directory seelector. * update album editing documentation for new directory browsing feature * improve directory browsing UX - Show spinner when loading a directory listing is taking time. - Pressing <enter> in a directory path field will save the field and - open a new one. * move filetree css into own file * improve appearance of album card during editing * fix getting/setting of locationIQ key --------- Co-authored-by: Lincoln Stein <lstein@gmail.com>
1 parent cd8b021 commit c1748f5

20 files changed

Lines changed: 979 additions & 126 deletions

File tree

docs/img/photomap_album_add.png

-3.97 KB
Loading

docs/user-guide/albums.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ To add an album, press the green <span class="green-button-text">Add Album</span
2020
- **Album Key** - This is a short mnemonic text that is used to uniquely identify the album. You can add it to PhotoMapAI's URL in order to go directly to the album of your choice, so it is best to avoid spaces and symbols. Once the key is assigned, you can't change it.
2121
- **Display Name** - This is the name of the album that will be displayed in the settings Album popup menu and the browser tab window title.
2222
- **Description** (optional) - A description of the album.
23-
- **Image Paths** - One or more filesystem paths to the folders that contain image files to incorporate into the album.
23+
- **Image Folder(s)** - One or more filesystem paths to the folders that contain image files to incorporate into the album.
2424

2525
<img src="../../img/photomap_album_add.png" width="640" class="img-hover-zoom">
2626

27+
At least one image folder needs to be defined. You can type the path in manually, or browse the filesystem for a folder by clicking on the folder icon to the right of the image folder field. Each time you enter a path, a new empty field will appear, allowing you to add additional folders to the album. You can remove a previously-entered folder by clicking a trash icon that appears next to it.
28+
2729
You are free to organize your image files in any way you wish. You can dump them into a single big folder, or organize them into multiple nested subfolders. During indexing, PhotoMapAI will traverse the folder structure and identify all image files of type JPEG, PNG, TIFF, HEIF, and HEIC.
2830

2931
## Indexing Albums

photomap/backend/metadata_modules/exif_formatter.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ def format_exif_metadata(
3030
Returns:
3131
SlideSummary: structured metadata appropriate for an image with EXIF data.
3232
"""
33-
logger.info(f"locationiq_api_key: {locationiq_api_key}")
3433
if not metadata:
3534
slide_data.description = "<i>No EXIF metadata available.</i>"
3635
return slide_data
@@ -58,9 +57,6 @@ def format_exif_metadata(
5857
)
5958
# Check if the API key worked
6059
api_key_valid = coord_str is not None
61-
logger.info(
62-
f"API key: key={locationiq_api_key} valid={api_key_valid}, place name={coord_str}"
63-
)
6460

6561
coord_str = coord_str if coord_str else f"{gps_lat:.6f}, {gps_lon:.6f}"
6662

photomap/backend/photomap_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .config import get_config_manager
1616
from .constants import get_package_resource_path
1717
from .routers.album import album_router
18+
from .routers.filetree import filetree_router
1819
from .routers.index import index_router
1920
from .routers.search import search_router
2021
from .routers.umap import umap_router
@@ -26,7 +27,7 @@
2627
app = FastAPI(title="PhotoMapAI")
2728

2829
# Include routers
29-
for router in [umap_router, search_router, index_router, album_router]:
30+
for router in [umap_router, search_router, index_router, album_router, filetree_router]:
3031
app.include_router(router)
3132

3233
# Mount static files and templates
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# init file for photomap.backend.routers
1+
# init file for photomap.backend.routers

photomap/backend/routers/album.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,16 @@ def validate_image_access(album_config, image_path: Path) -> bool:
262262
for p in album_config.image_paths
263263
]
264264
)
265+
266+
267+
@album_router.get("/filetree/home", tags=["File Management"])
268+
async def get_home_directory():
269+
"""Get the home directory path for the current user."""
270+
# In a real application, you would determine the home directory based on the user's
271+
# profile or configuration. Here, we just return a fixed path for demonstration.
272+
try:
273+
home_dir = str(Path.home())
274+
return {"homePath": home_dir}
275+
except Exception as e:
276+
logger.error(f"Error getting home directory: {e}")
277+
return {"homePath": ""}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import logging
2+
import os
3+
import platform
4+
from pathlib import Path
5+
from typing import Dict, List
6+
7+
from fastapi import APIRouter, HTTPException
8+
from fastapi.responses import JSONResponse
9+
10+
from .index import check_album_lock
11+
12+
logger = logging.getLogger(__name__)
13+
filetree_router = APIRouter()
14+
15+
# On Windows, allow browsing all drives; on Unix, use a root directory
16+
if platform.system() == "Windows":
17+
ROOT_DIR = None # Special case for Windows - browse all drives
18+
else:
19+
ROOT_DIR = os.environ.get("PHOTOMAP_ALBUM_ROOT", "/")
20+
21+
22+
def get_windows_drives():
23+
"""Get list of available Windows drives"""
24+
import string
25+
26+
drives = []
27+
for letter in string.ascii_uppercase:
28+
drive_path = Path(f"{letter}:\\")
29+
if drive_path.exists():
30+
try:
31+
# Test if we can access the drive
32+
list(drive_path.iterdir())
33+
drives.append(
34+
{
35+
"name": f"{letter}: Drive",
36+
"path": str(drive_path.resolve()), # Return absolute path
37+
"hasChildren": True,
38+
}
39+
)
40+
except (OSError, PermissionError):
41+
# Skip inaccessible drives
42+
continue
43+
return drives
44+
45+
46+
def is_path_safe(path_str: str) -> bool:
47+
"""Check if path is safe to access"""
48+
if platform.system() == "Windows":
49+
# On Windows, allow any valid drive path
50+
try:
51+
path = Path(path_str)
52+
# Must be absolute and exist
53+
return path.is_absolute() and path.exists()
54+
except:
55+
return False
56+
else:
57+
# On Unix, check if it's within ROOT_DIR or if it's an absolute path we want to allow
58+
try:
59+
path = Path(path_str).resolve()
60+
61+
# If ROOT_DIR is set, check if path is within it
62+
if ROOT_DIR:
63+
root_path = Path(ROOT_DIR).resolve()
64+
# Allow paths within ROOT_DIR or absolute paths for browsing
65+
return path.is_relative_to(root_path) or path.exists()
66+
else:
67+
# If no ROOT_DIR restriction, allow any existing absolute path
68+
return path.exists()
69+
except:
70+
return False
71+
72+
73+
@filetree_router.get("/filetree/directories", tags=["FileTree"])
74+
async def get_directories(path: str = "", show_hidden: bool = False):
75+
"""Get directories in the specified path"""
76+
check_album_lock() # May raise a 403 exception
77+
78+
# --- Path parsing and validation ---
79+
try:
80+
# Handle Windows drives
81+
if platform.system() == "Windows" and not path:
82+
drives = get_windows_drives()
83+
return JSONResponse(
84+
content={"currentPath": "", "directories": drives, "isRoot": True}
85+
)
86+
87+
# Handle regular directory browsing
88+
if platform.system() == "Windows":
89+
if path.endswith(":"):
90+
dir_path = Path(f"{path}\\")
91+
else:
92+
dir_path = Path(path)
93+
else:
94+
assert ROOT_DIR is not None
95+
if not path:
96+
dir_path = Path(ROOT_DIR)
97+
else:
98+
if Path(path).is_absolute():
99+
dir_path = Path(path)
100+
else:
101+
dir_path = Path(ROOT_DIR) / path
102+
103+
# Security check
104+
if not is_path_safe(str(dir_path)):
105+
raise HTTPException(status_code=403, detail="Access denied")
106+
107+
# If the path doesn't exist or isn't a directory, return 404
108+
if not dir_path.exists() or not dir_path.is_dir():
109+
raise HTTPException(status_code=404, detail="Directory not found")
110+
111+
except Exception as e:
112+
logger.error(f"Invalid path or path error: {e}")
113+
raise HTTPException(status_code=404, detail="Invalid or non-existent directory")
114+
115+
# --- Directory listing logic ---
116+
try:
117+
# Try to trigger automount for autofs directories
118+
logger.info("calling os.listdir to trigger automount if needed")
119+
try:
120+
os.listdir(str(dir_path))
121+
except Exception:
122+
pass
123+
124+
directories = []
125+
logger.info("Listing directories in: %s", dir_path)
126+
for entry in sorted(dir_path.iterdir()):
127+
if entry.is_dir():
128+
if not show_hidden and entry.name.startswith("."):
129+
continue
130+
try:
131+
abs_path = str(entry.resolve())
132+
has_children = False
133+
try:
134+
has_children = any(child.is_dir() for child in entry.iterdir())
135+
except (OSError, PermissionError):
136+
pass
137+
directories.append(
138+
{
139+
"name": entry.name,
140+
"path": abs_path,
141+
"hasChildren": has_children,
142+
}
143+
)
144+
except (OSError, PermissionError):
145+
continue
146+
147+
current_display = str(dir_path.resolve())
148+
logger.info(
149+
f"Current directory: {current_display}, found {len(directories)} subdirectories"
150+
)
151+
return JSONResponse(
152+
content={
153+
"currentPath": current_display,
154+
"directories": directories,
155+
"isRoot": not path,
156+
}
157+
)
158+
except Exception as e:
159+
logger.error(f"FileTree error: {e}")
160+
return JSONResponse(content={"error": str(e)}, status_code=500)
161+
162+
163+
@filetree_router.get("/filetree/home", tags=["FileTree"])
164+
async def get_home_directory():
165+
"""Get the user's home directory path"""
166+
try:
167+
home_path = str(Path.home().resolve())
168+
return JSONResponse(content={"homePath": home_path})
169+
except Exception as e:
170+
logger.error(f"Error getting home directory: {e}")
171+
return JSONResponse(content={"error": str(e)}, status_code=500)

photomap/frontend/static/css/about.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,4 @@
6161
.about-links-row svg {
6262
vertical-align: middle;
6363
fill: #FFD600;
64-
}
64+
}

photomap/frontend/static/css/album-manager.css

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,37 @@
137137
margin-bottom: 0.5em;
138138
}
139139

140+
.album-path-row {
141+
display: flex;
142+
align-items: center;
143+
margin-bottom: 0.5em;
144+
gap: 0.5em;
145+
}
146+
147+
.album-path-input {
148+
flex: 1;
149+
background: #222;
150+
color: #faea0e;
151+
border: 1px solid #444;
152+
border-radius: 4px;
153+
padding: 8px;
154+
}
155+
156+
.open-folder-btn,
157+
.remove-path-btn {
158+
background: none;
159+
border: none;
160+
font-size: 1.2em;
161+
cursor: pointer;
162+
padding: 4px;
163+
}
164+
165+
.open-folder-btn:hover,
166+
.remove-path-btn:hover {
167+
background: rgba(250, 234, 14, 0.1);
168+
border-radius: 4px;
169+
}
170+
140171
.indexing-section {
141172
display: flex;
142173
flex-direction: column;
@@ -194,6 +225,13 @@
194225
align-self: flex-end;
195226
}
196227

228+
.album-card.editing {
229+
/* background: #353535 !important; */
230+
background: #505050 !important;
231+
box-shadow: 0 0 8px #222;
232+
transition: background 0.2s;
233+
}
234+
197235
.edit-form {
198236
display: none;
199237
}
@@ -353,3 +391,18 @@
353391
grid-template-columns: 1fr;
354392
}
355393
}
394+
395+
#addDirBtn {
396+
background: #0a6a0a;
397+
color: white;
398+
}
399+
400+
#addDirBtn:hover {
401+
background: #0a8a0a;
402+
}
403+
404+
.edit-album-paths-container.input-error {
405+
border: 2px solid #b00020;
406+
border-radius: 4px;
407+
padding: 4px;
408+
}

0 commit comments

Comments
 (0)