Merge pull request 'feat: Implement directory browsing functionality' (#14) from feature/server-side-folder-browser into dev
All checks were successful
CI / skip-ci-check (pull_request) Successful in 9s
CI / lint-and-type-check (pull_request) Successful in 1m12s
CI / python-lint (pull_request) Successful in 36s
CI / test-backend (pull_request) Successful in 2m33s
CI / build (pull_request) Successful in 3m50s
CI / secret-scanning (pull_request) Successful in 18s
CI / dependency-scan (pull_request) Successful in 18s
CI / sast-scan (pull_request) Successful in 1m30s
CI / workflow-summary (pull_request) Successful in 8s
All checks were successful
CI / skip-ci-check (pull_request) Successful in 9s
CI / lint-and-type-check (pull_request) Successful in 1m12s
CI / python-lint (pull_request) Successful in 36s
CI / test-backend (pull_request) Successful in 2m33s
CI / build (pull_request) Successful in 3m50s
CI / secret-scanning (pull_request) Successful in 18s
CI / dependency-scan (pull_request) Successful in 18s
CI / sast-scan (pull_request) Successful in 1m30s
CI / workflow-summary (pull_request) Successful in 8s
Reviewed-on: #14
This commit is contained in:
commit
41bed0b680
@ -147,6 +147,15 @@ export const photosApi = {
|
||||
return data
|
||||
},
|
||||
|
||||
browseDirectory: async (path: string): Promise<BrowseDirectoryResponse> => {
|
||||
// Axios automatically URL-encodes query parameters
|
||||
const { data } = await apiClient.get<BrowseDirectoryResponse>(
|
||||
'/api/v1/photos/browse-directory',
|
||||
{ params: { path } }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
getPhotoImageBlob: async (photoId: number): Promise<string> => {
|
||||
// Fetch image as blob with authentication
|
||||
const response = await apiClient.get(
|
||||
@ -182,3 +191,16 @@ export interface SearchPhotosResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DirectoryItem {
|
||||
name: string
|
||||
path: string
|
||||
is_directory: boolean
|
||||
is_file: boolean
|
||||
}
|
||||
|
||||
export interface BrowseDirectoryResponse {
|
||||
current_path: string
|
||||
parent_path: string | null
|
||||
items: DirectoryItem[]
|
||||
}
|
||||
|
||||
|
||||
293
admin-frontend/src/components/FolderBrowser.tsx
Normal file
293
admin-frontend/src/components/FolderBrowser.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { photosApi, BrowseDirectoryResponse } from '../api/photos'
|
||||
|
||||
interface FolderBrowserProps {
|
||||
onSelectPath: (path: string) => void
|
||||
initialPath?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function FolderBrowser({
|
||||
onSelectPath,
|
||||
initialPath = '/',
|
||||
onClose,
|
||||
}: FolderBrowserProps) {
|
||||
const [currentPath, setCurrentPath] = useState(initialPath)
|
||||
const [items, setItems] = useState<BrowseDirectoryResponse['items']>([])
|
||||
const [parentPath, setParentPath] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pathInput, setPathInput] = useState(initialPath)
|
||||
|
||||
const loadDirectory = useCallback(async (path: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
console.log('Loading directory:', path)
|
||||
const data = await photosApi.browseDirectory(path)
|
||||
console.log('Directory loaded:', data)
|
||||
setCurrentPath(data.current_path)
|
||||
setPathInput(data.current_path)
|
||||
setParentPath(data.parent_path)
|
||||
setItems(data.items)
|
||||
} catch (err: any) {
|
||||
console.error('Error loading directory:', err)
|
||||
console.error('Error response:', err?.response)
|
||||
console.error('Error status:', err?.response?.status)
|
||||
console.error('Error data:', err?.response?.data)
|
||||
|
||||
// Handle FastAPI validation errors (422) - they have a different structure
|
||||
let errorMsg = 'Failed to load directory'
|
||||
if (err?.response?.data) {
|
||||
const data = err.response.data
|
||||
// FastAPI validation errors have detail as an array
|
||||
if (Array.isArray(data.detail)) {
|
||||
errorMsg = data.detail.map((d: any) => d.msg || JSON.stringify(d)).join(', ')
|
||||
} else if (typeof data.detail === 'string') {
|
||||
errorMsg = data.detail
|
||||
} else if (data.message) {
|
||||
errorMsg = data.message
|
||||
} else if (typeof data === 'string') {
|
||||
errorMsg = data
|
||||
}
|
||||
} else if (err?.message) {
|
||||
errorMsg = err.message
|
||||
}
|
||||
|
||||
setError(errorMsg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('FolderBrowser mounted, loading initial path:', initialPath)
|
||||
loadDirectory(initialPath)
|
||||
}, [initialPath, loadDirectory])
|
||||
|
||||
const handleItemClick = (item: BrowseDirectoryResponse['items'][0]) => {
|
||||
if (item.is_directory) {
|
||||
loadDirectory(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParentClick = () => {
|
||||
if (parentPath) {
|
||||
loadDirectory(parentPath)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePathInputSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
loadDirectory(pathInput)
|
||||
}
|
||||
|
||||
const handleSelectCurrentPath = () => {
|
||||
onSelectPath(currentPath)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Build breadcrumb path segments
|
||||
const pathSegments = currentPath.split('/').filter(Boolean)
|
||||
const breadcrumbPaths: string[] = []
|
||||
pathSegments.forEach((_segment, index) => {
|
||||
const path = '/' + pathSegments.slice(0, index + 1).join('/')
|
||||
breadcrumbPaths.push(path)
|
||||
})
|
||||
|
||||
console.log('FolderBrowser render - loading:', loading, 'error:', error, 'items:', items.length)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
onClick={(e) => {
|
||||
// Close modal when clicking backdrop
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Select Folder
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path Input */}
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<form onSubmit={handlePathInputSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
placeholder="Enter or navigate to folder path"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="px-6 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
onClick={() => loadDirectory('/')}
|
||||
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
Root
|
||||
</button>
|
||||
{pathSegments.map((segment, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<span className="text-gray-400">/</span>
|
||||
<button
|
||||
onClick={() => loadDirectory(breadcrumbPaths[index])}
|
||||
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="px-6 py-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-800">{String(error)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory Listing */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">Directory is empty</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{parentPath && (
|
||||
<button
|
||||
onClick={handleParentClick}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-700 font-medium">.. (Parent)</span>
|
||||
</button>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2 ${
|
||||
item.is_directory ? 'cursor-pointer' : 'cursor-default opacity-60'
|
||||
}`}
|
||||
disabled={!item.is_directory}
|
||||
>
|
||||
{item.is_directory ? (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-gray-700">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Current path:</span>{' '}
|
||||
<span className="font-mono">{currentPath}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectCurrentPath}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { photosApi, PhotoImportRequest } from '../api/photos'
|
||||
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
|
||||
import FolderBrowser from '../components/FolderBrowser'
|
||||
|
||||
interface JobProgress {
|
||||
id: string
|
||||
@ -15,7 +16,7 @@ export default function Scan() {
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isBrowsing, setIsBrowsing] = useState(false)
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false)
|
||||
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
|
||||
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
|
||||
const [importResult, setImportResult] = useState<{
|
||||
@ -35,154 +36,14 @@ export default function Scan() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFolderBrowse = async () => {
|
||||
setIsBrowsing(true)
|
||||
const handleFolderBrowse = () => {
|
||||
setError(null)
|
||||
setShowFolderBrowser(true)
|
||||
}
|
||||
|
||||
const handleFolderSelect = (selectedPath: string) => {
|
||||
setFolderPath(selectedPath)
|
||||
setError(null)
|
||||
|
||||
// Try backend API first (uses tkinter for native folder picker with full path)
|
||||
try {
|
||||
console.log('Attempting to open native folder picker...')
|
||||
const result = await photosApi.browseFolder()
|
||||
console.log('Backend folder picker result:', result)
|
||||
|
||||
if (result.success && result.path) {
|
||||
// Ensure we have a valid absolute path (not just folder name)
|
||||
const path = result.path.trim()
|
||||
if (path && path.length > 0) {
|
||||
// Verify it looks like an absolute path:
|
||||
// - Unix/Linux: starts with / (includes mounted network shares like /mnt/...)
|
||||
// - Windows local: starts with drive letter like C:\
|
||||
// - Windows UNC: starts with \\ (network paths like \\server\share\folder)
|
||||
const isUnixPath = path.startsWith('/')
|
||||
const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path)
|
||||
const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//')
|
||||
|
||||
if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) {
|
||||
setFolderPath(path)
|
||||
setIsBrowsing(false)
|
||||
return
|
||||
} else {
|
||||
// Backend validated it, so trust it even if it doesn't match our patterns
|
||||
// (might be a valid path format we didn't account for)
|
||||
console.warn('Backend returned path with unexpected format:', path)
|
||||
setFolderPath(path)
|
||||
setIsBrowsing(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, result.success was false or path was empty
|
||||
console.warn('Backend folder picker returned no path:', result)
|
||||
if (result.success === false && result.message) {
|
||||
setError(result.message || 'No folder was selected. Please try again.')
|
||||
} else {
|
||||
setError('No folder was selected. Please try again.')
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
} catch (err: any) {
|
||||
// Backend API failed, fall back to browser picker
|
||||
console.warn('Backend folder picker unavailable, using browser fallback:', err)
|
||||
|
||||
// Extract error message from various possible locations
|
||||
const errorMsg = err?.response?.data?.detail ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
String(err) ||
|
||||
''
|
||||
|
||||
console.log('Error details:', {
|
||||
status: err?.response?.status,
|
||||
detail: err?.response?.data?.detail,
|
||||
message: err?.message,
|
||||
fullError: err
|
||||
})
|
||||
|
||||
// Check if it's a display/availability issue
|
||||
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) {
|
||||
// Show user-friendly message about display issue
|
||||
setError('Native folder picker unavailable. Using browser fallback.')
|
||||
} else if (err?.response?.status === 503) {
|
||||
// 503 Service Unavailable - likely tkinter or display issue
|
||||
setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.')
|
||||
} else {
|
||||
// Other error - log it but continue to browser fallback
|
||||
console.error('Error calling backend folder picker:', err)
|
||||
setError('Native folder picker unavailable. Using browser fallback.')
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use browser-based folder picker
|
||||
// This code runs if backend API failed or returned no path
|
||||
console.log('Attempting browser fallback folder picker...')
|
||||
|
||||
// Use File System Access API if available (modern browsers)
|
||||
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
console.log('Using File System Access API...')
|
||||
const directoryHandle = await (window as any).showDirectoryPicker()
|
||||
// Get the folder name from the handle
|
||||
const folderName = directoryHandle.name
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
console.log('Selected folder name:', folderName)
|
||||
|
||||
// Browser picker only gives folder name, not full path
|
||||
// Set the folder name and show helpful message
|
||||
setFolderPath(folderName)
|
||||
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
|
||||
} catch (err: any) {
|
||||
// User cancelled the picker
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error selecting folder:', err)
|
||||
setError('Error opening folder picker: ' + err.message)
|
||||
} else {
|
||||
// User cancelled - clear any previous error
|
||||
setError(null)
|
||||
}
|
||||
} finally {
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
} else {
|
||||
// Fallback: use a hidden directory input
|
||||
// Note: This will show a browser confirmation dialog that cannot be removed
|
||||
console.log('Using file input fallback...')
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.setAttribute('webkitdirectory', '')
|
||||
input.setAttribute('directory', '')
|
||||
input.setAttribute('multiple', '')
|
||||
input.style.display = 'none'
|
||||
|
||||
input.onchange = (e: any) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
const firstFile = files[0]
|
||||
const relativePath = firstFile.webkitRelativePath
|
||||
const pathParts = relativePath.split('/')
|
||||
const rootFolder = pathParts[0]
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
console.log('Selected folder name:', rootFolder)
|
||||
|
||||
// Browser picker only gives folder name, not full path
|
||||
// Set the folder name and show helpful message
|
||||
setFolderPath(rootFolder)
|
||||
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
|
||||
}
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
|
||||
input.oncancel = () => {
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanFolder = async () => {
|
||||
@ -332,10 +193,10 @@ export default function Scan() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFolderBrowse}
|
||||
disabled={isImporting || isBrowsing}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBrowsing ? 'Opening...' : 'Browse'}
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
@ -455,6 +316,15 @@ export default function Scan() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder Browser Modal */}
|
||||
{showFolderBrowser && (
|
||||
<FolderBrowser
|
||||
onSelectPath={handleFolderSelect}
|
||||
initialPath={folderPath || '/'}
|
||||
onClose={() => setShowFolderBrowser(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,6 +29,8 @@ from backend.schemas.photos import (
|
||||
BulkDeletePhotosResponse,
|
||||
BulkRemoveFavoritesRequest,
|
||||
BulkRemoveFavoritesResponse,
|
||||
BrowseDirectoryResponse,
|
||||
DirectoryItem,
|
||||
)
|
||||
from backend.schemas.search import (
|
||||
PhotoSearchResult,
|
||||
@ -436,6 +438,112 @@ async def upload_photos(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse-directory", response_model=BrowseDirectoryResponse)
|
||||
def browse_directory(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
path: str = Query("/", description="Directory path to list"),
|
||||
) -> BrowseDirectoryResponse:
|
||||
"""List directories and files in a given path.
|
||||
|
||||
No GUI required - uses os.listdir() to read filesystem.
|
||||
Returns JSON with directory structure for web-based folder browser.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (can be relative or absolute)
|
||||
|
||||
Returns:
|
||||
BrowseDirectoryResponse with current path, parent path, and items list
|
||||
|
||||
Raises:
|
||||
HTTPException: If path doesn't exist, is not a directory, or access is denied
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# Convert to absolute path
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
# Normalize path separators
|
||||
abs_path = os.path.normpath(abs_path)
|
||||
|
||||
# Security: Optional - restrict to certain base paths
|
||||
# For now, allow any path (server admin should configure file permissions)
|
||||
# You can uncomment and customize this for production:
|
||||
# allowed_bases = ["/home", "/mnt", "/opt/punimtag", "/media"]
|
||||
# if not any(abs_path.startswith(base) for base in allowed_bases):
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_403_FORBIDDEN,
|
||||
# detail=f"Path not allowed: {abs_path}"
|
||||
# )
|
||||
|
||||
if not os.path.exists(abs_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Path does not exist: {abs_path}",
|
||||
)
|
||||
|
||||
if not os.path.isdir(abs_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Path is not a directory: {abs_path}",
|
||||
)
|
||||
|
||||
# Read directory contents
|
||||
items = []
|
||||
try:
|
||||
for item in os.listdir(abs_path):
|
||||
item_path = os.path.join(abs_path, item)
|
||||
full_path = os.path.abspath(item_path)
|
||||
|
||||
# Skip if we can't access it (permission denied)
|
||||
try:
|
||||
is_dir = os.path.isdir(full_path)
|
||||
is_file = os.path.isfile(full_path)
|
||||
except (OSError, PermissionError):
|
||||
# Skip items we can't access
|
||||
continue
|
||||
|
||||
items.append(
|
||||
DirectoryItem(
|
||||
name=item,
|
||||
path=full_path,
|
||||
is_directory=is_dir,
|
||||
is_file=is_file,
|
||||
)
|
||||
)
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission denied reading directory: {abs_path}",
|
||||
)
|
||||
|
||||
# Sort: directories first, then files, both alphabetically
|
||||
items.sort(key=lambda x: (not x.is_directory, x.name.lower()))
|
||||
|
||||
# Get parent path (None if at root)
|
||||
parent_path = None
|
||||
if abs_path != "/" and abs_path != os.path.dirname(abs_path):
|
||||
parent_path = os.path.dirname(abs_path)
|
||||
# Normalize parent path
|
||||
parent_path = os.path.normpath(parent_path)
|
||||
|
||||
return BrowseDirectoryResponse(
|
||||
current_path=abs_path,
|
||||
parent_path=parent_path,
|
||||
items=items,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading directory: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/browse-folder")
|
||||
def browse_folder() -> dict:
|
||||
"""Open native folder picker dialog and return selected folder path.
|
||||
|
||||
@ -91,3 +91,20 @@ class BulkDeletePhotosResponse(BaseModel):
|
||||
description="Photo IDs that were requested but not found",
|
||||
)
|
||||
|
||||
|
||||
class DirectoryItem(BaseModel):
|
||||
"""Directory item (file or folder) in a directory listing."""
|
||||
|
||||
name: str = Field(..., description="Name of the item")
|
||||
path: str = Field(..., description="Full absolute path to the item")
|
||||
is_directory: bool = Field(..., description="Whether this is a directory")
|
||||
is_file: bool = Field(..., description="Whether this is a file")
|
||||
|
||||
|
||||
class BrowseDirectoryResponse(BaseModel):
|
||||
"""Response for directory browsing."""
|
||||
|
||||
current_path: str = Field(..., description="Current directory path")
|
||||
parent_path: Optional[str] = Field(None, description="Parent directory path (None if at root)")
|
||||
items: List[DirectoryItem] = Field(..., description="List of items in the directory")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user