coderuday21 Cursor commited on
Commit
214c544
·
1 Parent(s): 19f24a5

Implement unlimited-depth tree library architecture for dev DDA.

Browse files

Replace dual year-folder and zone/village models with dda_tree_nodes, image_library, and audit_logs. Adds tree CRUD APIs, node upload, Delhi seed migration, recursive TreeView UI, and wires compare/detect to tree paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

DEPLOYMENT.md CHANGED
@@ -133,7 +133,8 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
133
  | Variable | Purpose |
134
  |----------|---------|
135
  | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
136
- | `LOCAL_LIBRARY_ROOT` | Path to year folders (default: `library_sources/` in project) |
 
137
  | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
138
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
139
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
@@ -148,13 +149,33 @@ Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
148
 
149
  ---
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  ## UAT checklist (satdetect-dev)
152
 
153
  Run before promoting any DDA feature to production:
154
 
155
  1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
156
- 2. **Library** — Upload GeoTIFF to year folder; Refresh shows image; thumb loads
157
- 3. **Compare** — Select T1/T2; Run Detection completes (async job or sync fallback)
158
  4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
159
  5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
160
  6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
 
133
  | Variable | Purpose |
134
  |----------|---------|
135
  | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
136
+ | `STORAGE_ROOT` | Tree library root directory (default: `data/library_sources/`) |
137
+ | `LOCAL_LIBRARY_ROOT` | Alias for storage root override |
138
  | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
139
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
140
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
 
149
 
150
  ---
151
 
152
+ ## Tree library layout (unlimited depth)
153
+
154
+ Images are organized in a **configurable tree** (zone → area → year → image type, or any depth):
155
+
156
+ ```text
157
+ library_sources/
158
+ central_delhi/
159
+ karol_bagh/
160
+ 2025/
161
+ Images/
162
+ satellite.tif
163
+ ```
164
+
165
+ - **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
166
+ - **UI:** Recursive tree sidebar, **Manage** (admin) for create/rename/move/delete
167
+ - **Storage:** Slug-based disk paths; display names in `node_path`
168
+ - **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
169
+
170
+ ---
171
+
172
  ## UAT checklist (satdetect-dev)
173
 
174
  Run before promoting any DDA feature to production:
175
 
176
  1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
177
+ 2. **Tree library** — Create zone area → year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
178
+ 3. **Compare** — Select T1/T2 from tree library; Run Detection completes
179
  4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
180
  5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
181
  6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
app/dda/admin_routes.py CHANGED
@@ -12,7 +12,7 @@ from ..database import DATA_DIR, get_db
12
  from ..models import DetectionRun, User
13
  from .dda_auth import current_dda_user, get_user_role, require_min_role
14
  from .job_runner import is_job_runner_busy, reconcile_stale_jobs
15
- from .local_library import scan_images
16
  from .models import DetectionJob, RegionReview
17
 
18
  logger = logging.getLogger(__name__)
@@ -43,7 +43,7 @@ def admin_status(
43
  disk = shutil.disk_usage(DATA_DIR)
44
  return {
45
  "role": get_user_role(db, user),
46
- "libraryImages": len(scan_images()),
47
  "detectionRuns": runs,
48
  "jobsQueued": jobs_queued,
49
  "jobsRunning": jobs_running,
 
12
  from ..models import DetectionRun, User
13
  from .dda_auth import current_dda_user, get_user_role, require_min_role
14
  from .job_runner import is_job_runner_busy, reconcile_stale_jobs
15
+ from .tree.image_service import list_all_images
16
  from .models import DetectionJob, RegionReview
17
 
18
  logger = logging.getLogger(__name__)
 
43
  disk = shutil.disk_usage(DATA_DIR)
44
  return {
45
  "role": get_user_role(db, user),
46
+ "libraryImages": len(list_all_images(db)),
47
  "detectionRuns": runs,
48
  "jobsQueued": jobs_queued,
49
  "jobsRunning": jobs_running,
app/dda/bootstrap.py CHANGED
@@ -4,7 +4,7 @@ from fastapi import FastAPI
4
  from sqlalchemy import text as sa_text
5
 
6
  from ..database import engine
7
- from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
8
  from .admin_routes import router as admin_router
9
  from .jobs_routes import router as jobs_router
10
  from .library_routes import router as library_router
@@ -12,7 +12,7 @@ from .local_routes import router as local_router
12
  from .reports_routes import router as reports_router
13
  from .review_routes import router as review_router
14
  from .training_routes import router as training_router
15
- from .seed import seed_delhi_hierarchy
16
  from .dda_auth import seed_dda_admin
17
 
18
  logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ def init_dda_database():
23
  if not IS_DDA_MODE:
24
  return
25
  ensure_library_dirs()
26
- ensure_local_year_folders()
27
  try:
28
  with engine.connect() as conn:
29
  for stmt in (
@@ -46,33 +46,51 @@ def init_dda_database():
46
  except Exception as exc:
47
  logger.warning("DDA schema migration skipped: %s", exc)
48
 
49
- from ..database import SessionLocal
 
 
 
 
 
 
 
 
 
 
50
  db = SessionLocal()
51
  try:
52
- seed_delhi_hierarchy(db)
53
  seed_dda_admin(db)
 
 
 
54
  from .job_runner import reconcile_stale_jobs
55
  reconcile_stale_jobs(db)
56
  finally:
57
  db.close()
58
 
59
  try:
60
- from .local_library import library_debug_info, scan_images
61
- info = library_debug_info()
 
 
 
 
 
62
  logger.info(
63
- "DDA library ready (hosted=%s): %d images, writable=%s",
64
  is_hf_hosted(),
65
- len(scan_images()),
66
- info.get("roots", [{}])[0].get("path") if info.get("roots") else "?",
67
  )
68
  except Exception as exc:
69
- logger.warning("Library scan at startup failed: %s", exc)
70
 
71
 
72
  def setup_dda(app: FastAPI) -> None:
73
  if not IS_DDA_MODE:
74
  logger.info("APP_MODE=legacy — DDA routes disabled")
75
  return
 
76
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
77
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
78
  app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
@@ -80,4 +98,4 @@ def setup_dda(app: FastAPI) -> None:
80
  app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
81
  app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
82
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
83
- logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, review, training, admin, local)")
 
4
  from sqlalchemy import text as sa_text
5
 
6
  from ..database import engine
7
+ from .config import IS_DDA_MODE, ensure_library_dirs, get_storage_root, is_hf_hosted
8
  from .admin_routes import router as admin_router
9
  from .jobs_routes import router as jobs_router
10
  from .library_routes import router as library_router
 
12
  from .reports_routes import router as reports_router
13
  from .review_routes import router as review_router
14
  from .training_routes import router as training_router
15
+ from .tree.routes import router as tree_router
16
  from .dda_auth import seed_dda_admin
17
 
18
  logger = logging.getLogger(__name__)
 
23
  if not IS_DDA_MODE:
24
  return
25
  ensure_library_dirs()
26
+ get_storage_root().mkdir(parents=True, exist_ok=True)
27
  try:
28
  with engine.connect() as conn:
29
  for stmt in (
 
46
  except Exception as exc:
47
  logger.warning("DDA schema migration skipped: %s", exc)
48
 
49
+ from ..database import Base, SessionLocal
50
+ from .tree.models import AuditLog, ImageLibrary, TreeNode # noqa: F401
51
+ try:
52
+ Base.metadata.create_all(bind=engine, tables=[
53
+ TreeNode.__table__,
54
+ ImageLibrary.__table__,
55
+ AuditLog.__table__,
56
+ ])
57
+ except Exception as exc:
58
+ logger.warning("Tree table create skipped: %s", exc)
59
+
60
  db = SessionLocal()
61
  try:
 
62
  seed_dda_admin(db)
63
+ from .tree.migration import run_tree_migration
64
+ mig = run_tree_migration(db)
65
+ logger.info("Tree migration: %s", mig)
66
  from .job_runner import reconcile_stale_jobs
67
  reconcile_stale_jobs(db)
68
  finally:
69
  db.close()
70
 
71
  try:
72
+ from .tree.image_service import list_all_images
73
+ from ..database import SessionLocal as SL
74
+ sdb = SL()
75
+ try:
76
+ total = len(list_all_images(sdb))
77
+ finally:
78
+ sdb.close()
79
  logger.info(
80
+ "DDA tree library ready (hosted=%s): %d images, storage=%s",
81
  is_hf_hosted(),
82
+ total,
83
+ get_storage_root(),
84
  )
85
  except Exception as exc:
86
+ logger.warning("Tree library scan at startup failed: %s", exc)
87
 
88
 
89
  def setup_dda(app: FastAPI) -> None:
90
  if not IS_DDA_MODE:
91
  logger.info("APP_MODE=legacy — DDA routes disabled")
92
  return
93
+ app.include_router(tree_router, prefix="/api/dda", tags=["dda-tree"])
94
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
95
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
96
  app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
 
98
  app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
99
  app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
100
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
101
+ logger.info("APP_MODE=dda — DDA routes enabled (tree, library, jobs, reports, review, training, admin, local)")
app/dda/config.py CHANGED
@@ -44,12 +44,26 @@ def get_writable_library_root() -> Path:
44
  return (DATA_DIR / "library_sources").resolve()
45
 
46
 
 
 
 
 
 
 
 
 
 
 
47
  def get_library_roots() -> List[Path]:
48
- """Folders scanned for year-based images."""
49
  roots: List[Path] = []
 
 
 
50
  if os.environ.get("LOCAL_LIBRARY_ROOT"):
51
- roots.append(Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve())
52
- # Writable data dir first on Hugging Face (where uploads land)
 
53
  if is_hf_hosted():
54
  wr = get_writable_library_root()
55
  if wr not in roots:
 
44
  return (DATA_DIR / "library_sources").resolve()
45
 
46
 
47
+ def get_storage_root() -> Path:
48
+ """Tree library root (doc: root_directory / STORAGE_ROOT)."""
49
+ explicit = os.environ.get("STORAGE_ROOT", "").strip()
50
+ if explicit:
51
+ return Path(explicit).resolve()
52
+ if os.environ.get("LOCAL_LIBRARY_ROOT"):
53
+ return Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
54
+ return get_writable_library_root()
55
+
56
+
57
  def get_library_roots() -> List[Path]:
58
+ """Folders scanned for library images (tree storage root)."""
59
  roots: List[Path] = []
60
+ sr = get_storage_root()
61
+ if sr not in roots:
62
+ roots.append(sr)
63
  if os.environ.get("LOCAL_LIBRARY_ROOT"):
64
+ p = Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
65
+ if p not in roots:
66
+ roots.append(p)
67
  if is_hf_hosted():
68
  wr = get_writable_library_root()
69
  if wr not in roots:
app/dda/job_runner.py CHANGED
@@ -17,7 +17,7 @@ from ..models import DetectionRun
17
  from .config import get_detection_max_side
18
  from .detect_service import run_detection_and_save
19
  from .geotiff_io import load_rgb_pil
20
- from .local_library import safe_resolve
21
  from .models import DetectionJob
22
 
23
  logger = logging.getLogger(__name__)
 
17
  from .config import get_detection_max_side
18
  from .detect_service import run_detection_and_save
19
  from .geotiff_io import load_rgb_pil
20
+ from .local_routes import safe_resolve
21
  from .models import DetectionJob
22
 
23
  logger = logging.getLogger(__name__)
app/dda/jobs_routes.py CHANGED
@@ -17,7 +17,7 @@ from .job_runner import (
17
  is_job_runner_busy,
18
  job_to_dict,
19
  )
20
- from .local_library import safe_resolve
21
  from .models import DetectionJob
22
 
23
  logger = logging.getLogger(__name__)
 
17
  is_job_runner_busy,
18
  job_to_dict,
19
  )
20
+ from .local_routes import safe_resolve
21
  from .models import DetectionJob
22
 
23
  logger = logging.getLogger(__name__)
app/dda/library_routes.py CHANGED
@@ -65,7 +65,7 @@ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "
65
  @router.get("/config")
66
  def dda_config():
67
  _require_dda()
68
- from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
@@ -77,8 +77,9 @@ def dda_config():
77
  "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
78
  "geotiffEnabled": geotiff_io_available(),
79
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
80
- "hierarchyMode": "admin",
81
- "librarySource": "local_folder",
 
82
  "localLibraryPaths": [str(r) for r in get_library_roots()],
83
  }
84
 
@@ -86,34 +87,8 @@ def dda_config():
86
  @router.get("/hierarchy")
87
  def get_hierarchy(db: Session = Depends(get_db)):
88
  _require_dda()
89
- zones = db.query(DdaZone).order_by(DdaZone.name).all()
90
- tree = []
91
- for zone in zones:
92
- villages = (
93
- db.query(DdaVillage)
94
- .filter(DdaVillage.zone_id == zone.id)
95
- .order_by(DdaVillage.name)
96
- .all()
97
- )
98
- image_counts = {}
99
- for v in villages:
100
- cnt = db.query(ImageAsset).filter(ImageAsset.village_id == v.id).count()
101
- if cnt:
102
- image_counts[v.id] = cnt
103
- tree.append({
104
- "id": zone.id,
105
- "name": zone.name,
106
- "mode": zone.mode,
107
- "villages": [
108
- {
109
- "id": v.id,
110
- "name": v.name,
111
- "imageCount": image_counts.get(v.id, 0),
112
- }
113
- for v in villages
114
- ],
115
- })
116
- return {"zones": tree}
117
 
118
 
119
  @router.get("/images")
@@ -195,6 +170,10 @@ async def upload_image(
195
  db: Session = Depends(get_db),
196
  ):
197
  _require_dda()
 
 
 
 
198
  ensure_library_dirs()
199
  user = get_or_create_guest_user(db)
200
 
 
65
  @router.get("/config")
66
  def dda_config():
67
  _require_dda()
68
+ from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots, get_storage_root
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
 
77
  "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
78
  "geotiffEnabled": geotiff_io_available(),
79
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
80
+ "hierarchyMode": "tree",
81
+ "librarySource": "tree_library",
82
+ "storageRoot": str(get_storage_root()),
83
  "localLibraryPaths": [str(r) for r in get_library_roots()],
84
  }
85
 
 
87
  @router.get("/hierarchy")
88
  def get_hierarchy(db: Session = Depends(get_db)):
89
  _require_dda()
90
+ from .tree.tree_service import build_tree
91
+ return {"tree": build_tree(db)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
 
94
  @router.get("/images")
 
170
  db: Session = Depends(get_db),
171
  ):
172
  _require_dda()
173
+ raise HTTPException(
174
+ status_code=410,
175
+ detail="Deprecated: use POST /api/dda/tree/nodes/{node_id}/images/upload",
176
+ )
177
  ensure_library_dirs()
178
  user = get_or_create_guest_user(db)
179
 
app/dda/local_routes.py CHANGED
@@ -1,16 +1,16 @@
1
- """API for reading images from local library_sources/ year folders."""
2
  import logging
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
7
  from fastapi.responses import FileResponse
8
  from PIL import Image
9
  from sqlalchemy.orm import Session
10
 
11
  from ..database import get_db
12
  from ..models import User
13
- from .dda_auth import current_dda_user, require_min_role
14
  from .detect_service import run_detection_and_save
15
  from .geotiff_io import load_rgb_pil
16
 
@@ -20,19 +20,12 @@ from .config import (
20
  geotiff_io_available,
21
  get_detection_max_side,
22
  get_library_roots,
23
- get_writable_library_root,
24
  is_hf_hosted,
25
- max_upload_bytes_for_extension,
26
  )
27
- from .local_library import (
28
- entry_to_dict,
29
- get_or_build_thumb,
30
- library_debug_info,
31
- safe_resolve,
32
- scan_images,
33
- scan_years,
34
- )
35
- from .upload_io import stream_upload_to_file
36
 
37
  logger = logging.getLogger(__name__)
38
  router = APIRouter()
@@ -46,37 +39,35 @@ def _require_dda():
46
  )
47
 
48
 
49
- def _safe_basename(filename: str) -> str:
50
- name = Path(filename or "upload").name
51
- if not name or name in (".", ".."):
52
- raise HTTPException(status_code=400, detail="Invalid filename")
53
- return name
 
 
54
 
55
 
56
  @router.get("/local/config")
57
  def local_library_config():
58
  _require_dda()
59
- roots = [str(r) for r in get_library_roots()]
60
- writable = str(get_writable_library_root())
61
  hosted = is_hf_hosted()
62
- if hosted:
63
- instructions = (
64
- "On Hugging Face, images must be uploaded below (saved to persistent storage) "
65
- "or copied into the writable folder shown. Files on your PC are not visible here."
66
- )
67
- else:
68
- instructions = (
69
- "Copy .tif images into library_sources/YEAR/ in your project folder, then Refresh. "
70
- "Or use Upload to save into data/library_sources/."
71
- )
72
  return {
73
- "source": "local_folder",
74
  "isHosted": hosted,
75
  "spaceId": __import__("os").environ.get("SPACE_ID", ""),
76
  "appMode": "dda" if IS_DDA_MODE else "legacy",
77
- "rootPath": roots[0] if roots else "",
78
- "rootPaths": roots,
79
- "writablePath": writable,
 
80
  "instructions": instructions,
81
  "geotiffEnabled": geotiff_io_available(),
82
  "detectionMaxSide": get_detection_max_side(),
@@ -86,40 +77,14 @@ def local_library_config():
86
  }
87
 
88
 
89
- @router.get("/local/debug")
90
- def local_debug():
91
- _require_dda()
92
- return library_debug_info()
93
-
94
-
95
- @router.get("/local/years")
96
- def local_years():
97
- _require_dda()
98
- return {"years": scan_years(), "rootPaths": [str(r) for r in get_library_roots()]}
99
-
100
-
101
  @router.get("/local/images")
102
  def local_images(
103
- year: Optional[int] = Query(None),
104
  q: Optional[str] = Query(None),
 
105
  ):
106
  _require_dda()
107
- entries = scan_images(year=year, query=q)
108
- return [entry_to_dict(e) for e in entries]
109
-
110
-
111
- @router.get("/local/images/detail")
112
- def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
113
- _require_dda()
114
- entries = scan_images()
115
- norm = path.replace("\\", "/")
116
- match = next((e for e in entries if e.path == norm), None)
117
- if not match:
118
- safe_resolve(path)
119
- match = next((e for e in scan_images() if e.path == norm), None)
120
- if not match:
121
- raise HTTPException(status_code=404, detail="Image not found in library scan")
122
- return entry_to_dict(match, include_meta=True)
123
 
124
 
125
  @router.get("/local/thumb")
@@ -128,8 +93,6 @@ def local_thumb(path: str = Query(...)):
128
  try:
129
  thumb = get_or_build_thumb(path)
130
  return FileResponse(thumb, media_type="image/png")
131
- except HTTPException:
132
- raise
133
  except Exception as exc:
134
  logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
135
  from .config import LOCAL_THUMB_CACHE
@@ -140,66 +103,16 @@ def local_thumb(path: str = Query(...)):
140
  return FileResponse(cache, media_type="image/png")
141
 
142
 
143
- @router.post("/local/upload")
144
- async def local_upload(
145
- request: Request,
146
- file: UploadFile = File(...),
147
- year: int = Form(...),
148
- db: Session = Depends(get_db),
149
- user: User = Depends(current_dda_user),
150
- ):
151
- """Upload GeoTIFF into persistent library_sources/YEAR/ (required on HF)."""
152
- _require_dda()
153
- require_min_role(user, db, "uploader")
154
- if year < 1990 or year > 2100:
155
- raise HTTPException(status_code=400, detail="year must be between 1990 and 2100")
156
-
157
- original = _safe_basename(file.filename or "upload")
158
- ext = Path(original).suffix.lower()
159
- from .config import ALLOWED_EXTENSIONS
160
- if ext not in ALLOWED_EXTENSIONS:
161
- raise HTTPException(status_code=400, detail=f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
162
-
163
- root = get_writable_library_root()
164
- dest = root / str(year) / original
165
- if dest.exists():
166
- stem = Path(original).stem
167
- suffix = Path(original).suffix
168
- n = 1
169
- while dest.exists():
170
- dest = root / str(year) / f"{stem}_{n}{suffix}"
171
- n += 1
172
-
173
- size = await stream_upload_to_file(file, dest, max_upload_bytes_for_extension(ext))
174
- rel = dest.relative_to(root).as_posix()
175
- logger.info("Library upload: %s (%d bytes) -> %s", original, size, dest)
176
-
177
- entries = scan_images(year=year)
178
- match = next((e for e in entries if e.path == rel or e.filename == dest.name), None)
179
- if match:
180
- return {"status": "success", "path": match.path, "image": entry_to_dict(match)}
181
- return {
182
- "status": "success",
183
- "path": f"{year}/{dest.name}",
184
- "fileSizeBytes": size,
185
- "writablePath": str(dest),
186
- }
187
-
188
-
189
  @router.post("/local/rescan")
190
- def local_rescan():
191
  _require_dda()
192
- years = scan_years()
193
- total = sum(y["imageCount"] for y in years)
194
- info = library_debug_info()
195
- logger.info("Library rescan: %d images, roots=%s", total, info.get("roots"))
196
  return {
197
  "ok": True,
198
- "years": years,
199
- "totalImages": total,
200
- "rootPaths": [str(r) for r in get_library_roots()],
201
- "writablePath": str(get_writable_library_root()),
202
- "debug": info,
203
  }
204
 
205
 
@@ -220,7 +133,7 @@ async def detect_from_library(
220
  db: Session = Depends(get_db),
221
  user: User = Depends(current_dda_user),
222
  ):
223
- """Run change detection on two library images by relative path (e.g. 2025/aerial.tif)."""
224
  _require_dda()
225
  base_norm = base_path.replace("\\", "/").strip()
226
  comp_norm = comparison_path.replace("\\", "/").strip()
@@ -245,12 +158,9 @@ async def detect_from_library(
245
  except Exception as exc:
246
  raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
247
 
248
- # Match dimensions so registration and overlay align with the before image
249
  if before_pil.size != after_pil.size:
250
  after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
251
 
252
- max_side = get_detection_max_side()
253
-
254
  if title == "Untitled run":
255
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
256
 
@@ -268,7 +178,7 @@ async def detect_from_library(
268
  detection_sensitivity=detection_sensitivity,
269
  min_region_area=min_region_area,
270
  notify_email=notify_email,
271
- max_size=max_side,
272
  geo_bounds_path=base_file,
273
  user_id=user.id,
274
  )
 
1
+ """Slim local helpers: thumb, resolve, detect backed by tree library."""
2
  import logging
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
7
  from fastapi.responses import FileResponse
8
  from PIL import Image
9
  from sqlalchemy.orm import Session
10
 
11
  from ..database import get_db
12
  from ..models import User
13
+ from .dda_auth import current_dda_user
14
  from .detect_service import run_detection_and_save
15
  from .geotiff_io import load_rgb_pil
16
 
 
20
  geotiff_io_available,
21
  get_detection_max_side,
22
  get_library_roots,
23
+ get_storage_root,
24
  is_hf_hosted,
 
25
  )
26
+ from .tree.image_service import get_or_build_thumb, list_all_images
27
+ from .tree.path_service import resolve_file
28
+ from .tree.tree_service import build_tree
 
 
 
 
 
 
29
 
30
  logger = logging.getLogger(__name__)
31
  router = APIRouter()
 
39
  )
40
 
41
 
42
+ def safe_resolve(relative_path: str) -> Path:
43
+ try:
44
+ return resolve_file(relative_path)
45
+ except FileNotFoundError:
46
+ raise HTTPException(status_code=404, detail="Image file not found")
47
+ except ValueError as exc:
48
+ raise HTTPException(status_code=400, detail=str(exc))
49
 
50
 
51
  @router.get("/local/config")
52
  def local_library_config():
53
  _require_dda()
54
+ storage = str(get_storage_root())
 
55
  hosted = is_hf_hosted()
56
+ instructions = (
57
+ "Select a node in the tree, choose image type, and upload. "
58
+ "Files are stored under {zone}/{area}/…/Images/ in persistent storage."
59
+ if hosted
60
+ else "Use the tree library to organize images by zone/area/year, or upload via the form."
61
+ )
 
 
 
 
62
  return {
63
+ "source": "tree_library",
64
  "isHosted": hosted,
65
  "spaceId": __import__("os").environ.get("SPACE_ID", ""),
66
  "appMode": "dda" if IS_DDA_MODE else "legacy",
67
+ "rootPath": storage,
68
+ "rootPaths": [str(r) for r in get_library_roots()],
69
+ "writablePath": storage,
70
+ "storageRoot": storage,
71
  "instructions": instructions,
72
  "geotiffEnabled": geotiff_io_available(),
73
  "detectionMaxSide": get_detection_max_side(),
 
77
  }
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  @router.get("/local/images")
81
  def local_images(
82
+ node_id: Optional[int] = Query(None),
83
  q: Optional[str] = Query(None),
84
+ db: Session = Depends(get_db),
85
  ):
86
  _require_dda()
87
+ return list_all_images(db, node_id=node_id, query=q)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
 
90
  @router.get("/local/thumb")
 
93
  try:
94
  thumb = get_or_build_thumb(path)
95
  return FileResponse(thumb, media_type="image/png")
 
 
96
  except Exception as exc:
97
  logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
98
  from .config import LOCAL_THUMB_CACHE
 
103
  return FileResponse(cache, media_type="image/png")
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  @router.post("/local/rescan")
107
+ def local_rescan(db: Session = Depends(get_db)):
108
  _require_dda()
109
+ tree = build_tree(db)
110
+ images = list_all_images(db)
 
 
111
  return {
112
  "ok": True,
113
+ "tree": tree,
114
+ "totalImages": len(images),
115
+ "storageRoot": str(get_storage_root()),
 
 
116
  }
117
 
118
 
 
133
  db: Session = Depends(get_db),
134
  user: User = Depends(current_dda_user),
135
  ):
136
+ """Run change detection on two library images by relative path."""
137
  _require_dda()
138
  base_norm = base_path.replace("\\", "/").strip()
139
  comp_norm = comparison_path.replace("\\", "/").strip()
 
158
  except Exception as exc:
159
  raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
160
 
 
161
  if before_pil.size != after_pil.size:
162
  after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
163
 
 
 
164
  if title == "Untitled run":
165
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
166
 
 
178
  detection_sensitivity=detection_sensitivity,
179
  min_region_area=min_region_area,
180
  notify_email=notify_email,
181
+ max_size=get_detection_max_side(),
182
  geo_bounds_path=base_file,
183
  user_id=user.id,
184
  )
app/dda/tree/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Unlimited-depth tree library for DDA (dev)."""
app/dda/tree/audit_service.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audit logging for tree mutations."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Optional
6
+
7
+ from sqlalchemy.orm import Session
8
+
9
+ from .models import AuditLog
10
+
11
+
12
+ def log_action(
13
+ db: Session,
14
+ action: str,
15
+ *,
16
+ node_id: Optional[int] = None,
17
+ old_value: Any = None,
18
+ new_value: Any = None,
19
+ action_by: str = "",
20
+ ) -> None:
21
+ entry = AuditLog(
22
+ action=action,
23
+ node_id=node_id,
24
+ old_value=json.dumps(old_value, default=str) if old_value is not None else "",
25
+ new_value=json.dumps(new_value, default=str) if new_value is not None else "",
26
+ action_by=action_by or "",
27
+ )
28
+ db.add(entry)
app/dda/tree/image_service.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image upload and listing for tree nodes."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import logging
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+ from urllib.parse import quote
10
+
11
+ from fastapi import HTTPException, UploadFile
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ..config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, max_upload_bytes_for_extension
15
+ from ..geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png, write_placeholder_png
16
+ from ..upload_io import stream_upload_to_file
17
+ from .audit_service import log_action
18
+ from .models import ImageLibrary, TreeNode
19
+ from .path_service import images_dir, resolve_file, storage_root
20
+ from .tree_service import get_node_or_404
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ IMAGE_TYPES = {"Satellite", "Drone", "Orthomosaic", "DEM", "GeoTIFF", "Raster", "PNG", "JPEG"}
25
+
26
+
27
+ def _safe_basename(filename: str) -> str:
28
+ name = Path(filename or "upload").name
29
+ if not name or name in (".", ".."):
30
+ raise HTTPException(status_code=400, detail="Invalid filename")
31
+ return name
32
+
33
+
34
+ def _thumb_cache_path(relative_path: str) -> Path:
35
+ key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
36
+ return LOCAL_THUMB_CACHE / f"{key}.png"
37
+
38
+
39
+ def image_to_dict(img: ImageLibrary, node: Optional[TreeNode] = None) -> dict:
40
+ encoded = quote(img.file_path, safe="/")
41
+ return {
42
+ "id": img.id,
43
+ "nodeId": img.node_id,
44
+ "path": img.file_path,
45
+ "filename": img.image_name,
46
+ "imageName": img.image_name,
47
+ "imageType": img.image_type,
48
+ "nodePath": node.node_path if node else "",
49
+ "breadcrumb": f"{node.node_path}/{img.image_name}" if node else img.image_name,
50
+ "fileSizeBytes": img.file_size_bytes,
51
+ "captureDate": img.capture_date.isoformat() if img.capture_date else None,
52
+ "uploadedBy": img.uploaded_by,
53
+ "uploadedOn": img.uploaded_on.isoformat() if img.uploaded_on else None,
54
+ "thumbUrl": f"/api/dda/local/thumb?path={encoded}",
55
+ "hasGeoref": img.has_georef,
56
+ "width": img.width,
57
+ "height": img.height,
58
+ "format": img.format,
59
+ "source": "tree_library",
60
+ }
61
+
62
+
63
+ async def upload_image(
64
+ db: Session,
65
+ node_id: int,
66
+ file: UploadFile,
67
+ *,
68
+ image_type: str,
69
+ capture_date: Optional[str],
70
+ uploaded_by: str,
71
+ ) -> ImageLibrary:
72
+ node = get_node_or_404(db, node_id)
73
+ itype = image_type.strip() or "GeoTIFF"
74
+ if itype not in IMAGE_TYPES:
75
+ raise HTTPException(status_code=400, detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}")
76
+
77
+ original = _safe_basename(file.filename or "upload")
78
+ ext = Path(original).suffix.lower()
79
+ if ext not in ALLOWED_EXTENSIONS:
80
+ raise HTTPException(status_code=400, detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
81
+
82
+ dest_dir = images_dir(node.physical_path)
83
+ dest_dir.mkdir(parents=True, exist_ok=True)
84
+ dest = dest_dir / original
85
+ if dest.exists():
86
+ stem, suffix = Path(original).stem, Path(original).suffix
87
+ n = 1
88
+ while dest.exists():
89
+ dest = dest_dir / f"{stem}_{n}{suffix}"
90
+ n += 1
91
+
92
+ size = await stream_upload_to_file(file, dest, max_upload_bytes_for_extension(ext))
93
+ rel = dest.relative_to(storage_root()).as_posix()
94
+
95
+ cap = None
96
+ if capture_date:
97
+ try:
98
+ cap = datetime.fromisoformat(capture_date.strip())
99
+ except ValueError:
100
+ try:
101
+ cap = datetime.combine(date.fromisoformat(capture_date.strip()), datetime.min.time())
102
+ except ValueError:
103
+ raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
104
+
105
+ meta = inspect_image(dest)
106
+ img = ImageLibrary(
107
+ node_id=node.id,
108
+ image_name=dest.name,
109
+ image_type=itype,
110
+ file_path=rel,
111
+ capture_date=cap,
112
+ uploaded_by=uploaded_by or "",
113
+ file_size_bytes=size,
114
+ thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
115
+ width=meta.width,
116
+ height=meta.height,
117
+ has_georef=meta.has_georef,
118
+ bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
119
+ format=meta.format,
120
+ )
121
+ db.add(img)
122
+ log_action(db, "upload", node_id=node.id, new_value={"file": rel, "type": itype}, action_by=uploaded_by)
123
+ db.commit()
124
+ db.refresh(img)
125
+ logger.info("Uploaded %s -> %s", original, rel)
126
+ return img
127
+
128
+
129
+ def list_images_for_node(db: Session, node_id: int) -> List[dict]:
130
+ node = get_node_or_404(db, node_id)
131
+ rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
132
+ return [image_to_dict(r, node) for r in rows]
133
+
134
+
135
+ def list_all_images(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None) -> List[dict]:
136
+ q = db.query(ImageLibrary, TreeNode).join(TreeNode, ImageLibrary.node_id == TreeNode.id).filter(TreeNode.is_active == True) # noqa: E712
137
+ if node_id:
138
+ q = q.filter(ImageLibrary.node_id == node_id)
139
+ if query:
140
+ like = f"%{query.strip().lower()}%"
141
+ q = q.filter(
142
+ (ImageLibrary.image_name.ilike(like)) | (TreeNode.node_path.ilike(like))
143
+ )
144
+ q = q.order_by(ImageLibrary.uploaded_on.desc())
145
+ return [image_to_dict(img, node) for img, node in q.all()]
146
+
147
+
148
+ def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
149
+ full = resolve_file(relative_path)
150
+ cache = _thumb_cache_path(relative_path)
151
+ try:
152
+ if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
153
+ return cache
154
+ cache.parent.mkdir(parents=True, exist_ok=True)
155
+ raster_to_preview_png(full, cache, max_side=max_side)
156
+ return cache
157
+ except Exception as exc:
158
+ logger.warning("Thumb failed for %s: %s", relative_path, exc)
159
+ write_placeholder_png(cache, Path(relative_path).name, max_side)
160
+ return cache
app/dda/tree/migration.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-time migration: Delhi seed + flat year folders -> tree."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
12
+ from ..seed_data import DELHI_ZONES
13
+ from .models import ImageLibrary, TreeNode
14
+ from .path_service import ensure_node_directory, storage_root
15
+ from .path_slugs import unique_slug
16
+ from .tree_service import create_node
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ LEGACY_ZONE = "Unassigned"
21
+ LEGACY_AREA = "Legacy"
22
+
23
+
24
+ def _is_year_dir(name: str) -> bool:
25
+ return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
26
+
27
+
28
+ def seed_delhi_tree(db: Session) -> dict:
29
+ if db.query(TreeNode).count() > 0:
30
+ return {"seeded": False, "nodes": db.query(TreeNode).count()}
31
+
32
+ zones_created = 0
33
+ areas_created = 0
34
+ for zone_name, areas in DELHI_ZONES.items():
35
+ zone = create_node(db, parent_id=None, node_name=zone_name, node_type="Zone", created_by="system")
36
+ zones_created += 1
37
+ for area_name in areas:
38
+ create_node(db, parent_id=zone.id, node_name=area_name, node_type="Area", created_by="system")
39
+ areas_created += 1
40
+
41
+ return {"seeded": True, "zones": zones_created, "areas": areas_created}
42
+
43
+
44
+ def _find_or_create_legacy_branch(db: Session) -> TreeNode:
45
+ zone = db.query(TreeNode).filter(TreeNode.parent_id == None, TreeNode.node_name == LEGACY_ZONE).first() # noqa: E711
46
+ if not zone:
47
+ zone = create_node(db, parent_id=None, node_name=LEGACY_ZONE, node_type="Zone", created_by="system")
48
+ area = db.query(TreeNode).filter(TreeNode.parent_id == zone.id, TreeNode.node_name == LEGACY_AREA).first()
49
+ if not area:
50
+ area = create_node(db, parent_id=zone.id, node_name=LEGACY_AREA, node_type="Area", created_by="system")
51
+ return area
52
+
53
+
54
+ def _find_or_create_year_node(db: Session, parent: TreeNode, year: str) -> TreeNode:
55
+ existing = db.query(TreeNode).filter(TreeNode.parent_id == parent.id, TreeNode.node_name == year).first()
56
+ if existing:
57
+ return existing
58
+ return create_node(db, parent_id=parent.id, node_name=year, node_type="Year", created_by="system")
59
+
60
+
61
+ def migrate_flat_year_folders(db: Session) -> dict:
62
+ moved = 0
63
+ indexed = 0
64
+ legacy_area = _find_or_create_legacy_branch(db)
65
+ root = get_storage_root()
66
+
67
+ for lib_root in get_library_roots():
68
+ if not lib_root.exists() or lib_root.resolve() != root.resolve():
69
+ continue
70
+ for entry in sorted(lib_root.iterdir()):
71
+ if not entry.is_dir() or not _is_year_dir(entry.name):
72
+ continue
73
+ year_node = _find_or_create_year_node(db, legacy_area, entry.name)
74
+ dest_images = ensure_node_directory(year_node.physical_path) / "Images"
75
+ for path in sorted(entry.iterdir()):
76
+ if not path.is_file() or path.suffix.lower() not in ALLOWED_EXTENSIONS:
77
+ continue
78
+ target = dest_images / path.name
79
+ if target.exists():
80
+ stem, suffix = path.stem, path.suffix
81
+ n = 1
82
+ while target.exists():
83
+ target = dest_images / f"{stem}_{n}{suffix}"
84
+ n += 1
85
+ if path.resolve() != target.resolve():
86
+ shutil.move(str(path), str(target))
87
+ rel = target.relative_to(root).as_posix()
88
+ if not db.query(ImageLibrary).filter(ImageLibrary.file_path == rel).first():
89
+ db.add(ImageLibrary(
90
+ node_id=year_node.id,
91
+ image_name=target.name,
92
+ image_type="GeoTIFF",
93
+ file_path=rel,
94
+ uploaded_by="migration",
95
+ file_size_bytes=target.stat().st_size,
96
+ ))
97
+ indexed += 1
98
+ moved += 1
99
+ try:
100
+ if entry.is_dir() and not any(entry.iterdir()):
101
+ entry.rmdir()
102
+ except OSError:
103
+ pass
104
+
105
+ if moved or indexed:
106
+ db.commit()
107
+ logger.info("Tree migration: moved=%d indexed=%d", moved, indexed)
108
+ return {"moved": moved, "indexed": indexed}
109
+
110
+
111
+ def run_tree_migration(db: Session) -> dict:
112
+ seed_result = seed_delhi_tree(db)
113
+ migrate_result = migrate_flat_year_folders(db)
114
+ return {"seed": seed_result, "migrate": migrate_result}
app/dda/tree/models.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy models for unlimited-depth tree library."""
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
5
+ from sqlalchemy.orm import relationship
6
+
7
+ from ...database import Base
8
+
9
+
10
+ def _utcnow():
11
+ return datetime.now(timezone.utc)
12
+
13
+
14
+ class TreeNode(Base):
15
+ __tablename__ = "dda_tree_nodes"
16
+
17
+ id = Column(Integer, primary_key=True, index=True)
18
+ parent_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=True, index=True)
19
+ node_name = Column(String(500), nullable=False)
20
+ node_type = Column(String(100), default="Folder")
21
+ node_level = Column(Integer, default=0)
22
+ node_path = Column(Text, default="") # display path: North Zone/Area A/2025
23
+ slug = Column(String(64), nullable=False, index=True)
24
+ physical_path = Column(Text, default="") # relative slug path from storage root
25
+ created_by = Column(String(100), default="")
26
+ created_at = Column(DateTime, default=_utcnow)
27
+ is_active = Column(Boolean, default=True, index=True)
28
+
29
+ parent = relationship("TreeNode", remote_side=[id], backref="children")
30
+ images = relationship("ImageLibrary", back_populates="node", cascade="all, delete-orphan")
31
+
32
+
33
+ class ImageLibrary(Base):
34
+ __tablename__ = "dda_image_library"
35
+
36
+ id = Column(Integer, primary_key=True, index=True)
37
+ node_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=False, index=True)
38
+ image_name = Column(String(500), nullable=False)
39
+ image_type = Column(String(100), default="GeoTIFF")
40
+ file_path = Column(Text, nullable=False, unique=True) # relative to storage root
41
+ capture_date = Column(DateTime, nullable=True)
42
+ uploaded_by = Column(String(100), default="")
43
+ uploaded_on = Column(DateTime, default=_utcnow)
44
+ file_size_bytes = Column(Integer, default=0)
45
+ thumb_cache_key = Column(String(64), default="")
46
+ width = Column(Integer, default=0)
47
+ height = Column(Integer, default=0)
48
+ has_georef = Column(Boolean, default=False)
49
+ bounds_json = Column(Text, default="")
50
+ format = Column(String(32), default="")
51
+
52
+ node = relationship("TreeNode", back_populates="images")
53
+
54
+
55
+ class AuditLog(Base):
56
+ __tablename__ = "dda_audit_logs"
57
+
58
+ id = Column(Integer, primary_key=True, index=True)
59
+ action = Column(String(100), nullable=False)
60
+ node_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=True, index=True)
61
+ old_value = Column(Text, default="")
62
+ new_value = Column(Text, default="")
63
+ action_date = Column(DateTime, default=_utcnow)
64
+ action_by = Column(String(100), default="")
app/dda/tree/path_service.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Physical directory sync for tree nodes."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from ..config import get_storage_root
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def storage_root() -> Path:
14
+ root = get_storage_root()
15
+ root.mkdir(parents=True, exist_ok=True)
16
+ return root
17
+
18
+
19
+ def absolute_path(relative_physical_path: str) -> Path:
20
+ rel = (relative_physical_path or "").replace("\\", "/").strip("/")
21
+ return storage_root() / rel if rel else storage_root()
22
+
23
+
24
+ def images_dir(relative_physical_path: str) -> Path:
25
+ return absolute_path(relative_physical_path) / "Images"
26
+
27
+
28
+ def ensure_node_directory(relative_physical_path: str) -> Path:
29
+ path = absolute_path(relative_physical_path)
30
+ path.mkdir(parents=True, exist_ok=True)
31
+ (path / "Images").mkdir(parents=True, exist_ok=True)
32
+ return path
33
+
34
+
35
+ def move_directory(old_rel: str, new_rel: str) -> None:
36
+ old_abs = absolute_path(old_rel)
37
+ new_abs = absolute_path(new_rel)
38
+ if not old_abs.exists():
39
+ ensure_node_directory(new_rel)
40
+ return
41
+ new_abs.parent.mkdir(parents=True, exist_ok=True)
42
+ if new_abs.exists():
43
+ raise OSError(f"Destination already exists: {new_rel}")
44
+ shutil.move(str(old_abs), str(new_abs))
45
+ logger.info("Moved tree folder %s -> %s", old_rel, new_rel)
46
+
47
+
48
+ def delete_directory(relative_physical_path: str) -> None:
49
+ path = absolute_path(relative_physical_path)
50
+ if path.exists() and path.is_dir():
51
+ shutil.rmtree(path)
52
+ logger.info("Deleted tree folder %s", relative_physical_path)
53
+
54
+
55
+ def resolve_file(relative_file_path: str) -> Path:
56
+ rel = relative_file_path.replace("\\", "/").lstrip("/")
57
+ if not rel or ".." in rel.split("/"):
58
+ raise ValueError("Invalid file path")
59
+ full = (storage_root() / rel).resolve()
60
+ try:
61
+ full.relative_to(storage_root().resolve())
62
+ except ValueError as exc:
63
+ raise ValueError("Path escapes storage root") from exc
64
+ if not full.is_file():
65
+ raise FileNotFoundError(relative_file_path)
66
+ return full
app/dda/tree/path_slugs.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Filesystem-safe slugs for tree node paths."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import unicodedata
6
+ from typing import Set
7
+
8
+ RESERVED = frozenset({"images", "thumbs", "cache", "legacy"})
9
+
10
+
11
+ def slugify(name: str, *, fallback: str = "node") -> str:
12
+ text = unicodedata.normalize("NFKD", (name or "").strip())
13
+ text = text.encode("ascii", "ignore").decode("ascii")
14
+ text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
15
+ text = re.sub(r"[\s_-]+", "_", text).strip("_").lower()
16
+ if not text:
17
+ text = fallback
18
+ if text in RESERVED:
19
+ text = f"{text}_1"
20
+ return text[:64]
21
+
22
+
23
+ def unique_slug(base: str, existing: Set[str]) -> str:
24
+ slug = slugify(base)
25
+ if slug not in existing:
26
+ return slug
27
+ n = 2
28
+ while True:
29
+ candidate = f"{slug}-{n}"[:64]
30
+ if candidate not in existing:
31
+ return candidate
32
+ n += 1
app/dda/tree/routes.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI routes for unlimited-depth tree library."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy.orm import Session
9
+
10
+ from ...database import get_db
11
+ from ...models import User
12
+ from ..config import IS_DDA_MODE
13
+ from ..dda_auth import current_dda_user, get_user_role, require_min_role
14
+ from .image_service import IMAGE_TYPES, image_to_dict, list_all_images, list_images_for_node, upload_image
15
+ from .tree_service import build_tree, create_node, delete_node, get_node_or_404, move_node, rename_node
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ def _require_dda():
21
+ if not IS_DDA_MODE:
22
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
23
+
24
+
25
+ class NodeCreateBody(BaseModel):
26
+ parent_id: Optional[int] = None
27
+ node_name: str = Field(..., min_length=1, max_length=500)
28
+ node_type: str = Field(default="Folder", max_length=100)
29
+
30
+
31
+ class NodeRenameBody(BaseModel):
32
+ node_name: str = Field(..., min_length=1, max_length=500)
33
+ rename_physical: bool = False
34
+
35
+
36
+ class NodeMoveBody(BaseModel):
37
+ parent_id: Optional[int] = None
38
+
39
+
40
+ class NodeDeleteBody(BaseModel):
41
+ delete_files: bool = False
42
+
43
+
44
+ @router.get("/me")
45
+ def tree_me(user: User = Depends(current_dda_user), db: Session = Depends(get_db)):
46
+ _require_dda()
47
+ return {"userId": user.id, "role": get_user_role(db, user), "email": user.email}
48
+
49
+
50
+ @router.get("/tree")
51
+ def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
52
+ _require_dda()
53
+ return {"tree": build_tree(db), "imageTypes": sorted(IMAGE_TYPES)}
54
+
55
+
56
+ @router.post("/tree/nodes")
57
+ def api_create_node(
58
+ body: NodeCreateBody,
59
+ db: Session = Depends(get_db),
60
+ user: User = Depends(current_dda_user),
61
+ ):
62
+ _require_dda()
63
+ require_min_role(user, db, "admin")
64
+ node = create_node(
65
+ db,
66
+ parent_id=body.parent_id,
67
+ node_name=body.node_name,
68
+ node_type=body.node_type,
69
+ created_by=user.email or str(user.id),
70
+ )
71
+ return {"status": True, "message": "Node Created Successfully", "node": {
72
+ "id": node.id, "name": node.node_name, "nodePath": node.node_path,
73
+ }}
74
+
75
+
76
+ @router.put("/tree/nodes/{node_id}/rename")
77
+ def api_rename_node(
78
+ node_id: int,
79
+ body: NodeRenameBody,
80
+ db: Session = Depends(get_db),
81
+ user: User = Depends(current_dda_user),
82
+ ):
83
+ _require_dda()
84
+ require_min_role(user, db, "admin")
85
+ node = rename_node(
86
+ db, node_id, body.node_name,
87
+ action_by=user.email or str(user.id),
88
+ rename_physical=body.rename_physical,
89
+ )
90
+ return {"status": True, "message": "Node Renamed Successfully", "node": {
91
+ "id": node.id, "name": node.node_name, "nodePath": node.node_path,
92
+ }}
93
+
94
+
95
+ @router.post("/tree/nodes/{node_id}/move")
96
+ def api_move_node(
97
+ node_id: int,
98
+ body: NodeMoveBody,
99
+ db: Session = Depends(get_db),
100
+ user: User = Depends(current_dda_user),
101
+ ):
102
+ _require_dda()
103
+ require_min_role(user, db, "admin")
104
+ node = move_node(db, node_id, body.parent_id, action_by=user.email or str(user.id))
105
+ return {"status": True, "message": "Node Moved Successfully", "node": {
106
+ "id": node.id, "parentId": node.parent_id, "nodePath": node.node_path,
107
+ }}
108
+
109
+
110
+ @router.delete("/tree/nodes/{node_id}")
111
+ def api_delete_node(
112
+ node_id: int,
113
+ body: NodeDeleteBody = NodeDeleteBody(),
114
+ db: Session = Depends(get_db),
115
+ user: User = Depends(current_dda_user),
116
+ ):
117
+ _require_dda()
118
+ require_min_role(user, db, "admin")
119
+ result = delete_node(db, node_id, delete_files=body.delete_files, action_by=user.email or str(user.id))
120
+ return {"status": True, "message": "Node Deleted Successfully", **result}
121
+
122
+
123
+ @router.get("/tree/nodes/{node_id}")
124
+ def api_get_node(node_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
125
+ _require_dda()
126
+ node = get_node_or_404(db, node_id)
127
+ return {
128
+ "id": node.id,
129
+ "parentId": node.parent_id,
130
+ "name": node.node_name,
131
+ "nodeType": node.node_type,
132
+ "nodePath": node.node_path,
133
+ "physicalPath": node.physical_path,
134
+ }
135
+
136
+
137
+ @router.get("/tree/nodes/{node_id}/images")
138
+ def api_list_node_images(node_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
139
+ _require_dda()
140
+ return {"images": list_images_for_node(db, node_id)}
141
+
142
+
143
+ @router.get("/tree/images")
144
+ def api_list_images(
145
+ node_id: Optional[int] = None,
146
+ q: Optional[str] = None,
147
+ db: Session = Depends(get_db),
148
+ user: User = Depends(current_dda_user),
149
+ ):
150
+ _require_dda()
151
+ return list_all_images(db, node_id=node_id, query=q)
152
+
153
+
154
+ @router.post("/tree/nodes/{node_id}/images/upload")
155
+ async def api_upload_image(
156
+ node_id: int,
157
+ file: UploadFile = File(...),
158
+ image_type: str = Form("GeoTIFF"),
159
+ capture_date: str = Form(""),
160
+ db: Session = Depends(get_db),
161
+ user: User = Depends(current_dda_user),
162
+ ):
163
+ _require_dda()
164
+ require_min_role(user, db, "uploader")
165
+ img = await upload_image(
166
+ db, node_id, file,
167
+ image_type=image_type,
168
+ capture_date=capture_date or None,
169
+ uploaded_by=user.email or str(user.id),
170
+ )
171
+ node = get_node_or_404(db, node_id)
172
+ return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
app/dda/tree/tree_service.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tree node CRUD and recursive tree building."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import List, Optional
6
+
7
+ from fastapi import HTTPException
8
+ from sqlalchemy.orm import Session
9
+
10
+ from .audit_service import log_action
11
+ from .models import ImageLibrary, TreeNode
12
+ from .path_service import delete_directory, ensure_node_directory, move_directory
13
+ from .path_slugs import unique_slug
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _sibling_slugs(db: Session, parent_id: Optional[int]) -> set:
19
+ q = db.query(TreeNode).filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
20
+ return {n.slug for n in q.all() if n.slug}
21
+
22
+
23
+ def _sibling_names(db: Session, parent_id: Optional[int], exclude_id: Optional[int] = None) -> set:
24
+ q = db.query(TreeNode).filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
25
+ if exclude_id:
26
+ q = q.filter(TreeNode.id != exclude_id)
27
+ return {n.node_name.strip().lower() for n in q.all()}
28
+
29
+
30
+ def _node_to_dict(node: TreeNode, *, image_count: int = 0, children: Optional[list] = None) -> dict:
31
+ return {
32
+ "id": node.id,
33
+ "parentId": node.parent_id,
34
+ "name": node.node_name,
35
+ "nodeName": node.node_name,
36
+ "nodeType": node.node_type,
37
+ "nodeLevel": node.node_level,
38
+ "nodePath": node.node_path,
39
+ "slug": node.slug,
40
+ "physicalPath": node.physical_path,
41
+ "imageCount": image_count,
42
+ "children": children or [],
43
+ }
44
+
45
+
46
+ def build_tree(db: Session, parent_id: Optional[int] = None) -> List[dict]:
47
+ nodes = (
48
+ db.query(TreeNode)
49
+ .filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
50
+ .order_by(TreeNode.node_name)
51
+ .all()
52
+ )
53
+ result = []
54
+ for node in nodes:
55
+ children = build_tree(db, node.id)
56
+ img_count = db.query(ImageLibrary).filter(ImageLibrary.node_id == node.id).count()
57
+ child_img = sum(c.get("imageCount", 0) for c in children)
58
+ result.append(_node_to_dict(node, image_count=img_count + child_img, children=children))
59
+ return result
60
+
61
+
62
+ def get_node_or_404(db: Session, node_id: int) -> TreeNode:
63
+ node = db.query(TreeNode).filter(TreeNode.id == node_id, TreeNode.is_active == True).first() # noqa: E712
64
+ if not node:
65
+ raise HTTPException(status_code=404, detail="Node not found")
66
+ return node
67
+
68
+
69
+ def create_node(
70
+ db: Session,
71
+ *,
72
+ parent_id: Optional[int],
73
+ node_name: str,
74
+ node_type: str,
75
+ created_by: str,
76
+ ) -> TreeNode:
77
+ name = node_name.strip()
78
+ if not name:
79
+ raise HTTPException(status_code=400, detail="node_name is required")
80
+ if name.lower() in _sibling_names(db, parent_id):
81
+ raise HTTPException(status_code=400, detail="Duplicate node name under same parent")
82
+
83
+ parent = None
84
+ level = 0
85
+ parent_physical = ""
86
+ parent_display = ""
87
+ if parent_id is not None:
88
+ parent = get_node_or_404(db, parent_id)
89
+ level = parent.node_level + 1
90
+ parent_physical = parent.physical_path or ""
91
+ parent_display = parent.node_path or parent.node_name
92
+
93
+ slug = unique_slug(name, _sibling_slugs(db, parent_id))
94
+ physical_path = f"{parent_physical}/{slug}".strip("/") if parent_physical else slug
95
+ node_path = f"{parent_display}/{name}".strip("/") if parent_display else name
96
+
97
+ node = TreeNode(
98
+ parent_id=parent_id,
99
+ node_name=name,
100
+ node_type=node_type or "Folder",
101
+ node_level=level,
102
+ node_path=node_path,
103
+ slug=slug,
104
+ physical_path=physical_path,
105
+ created_by=created_by or "",
106
+ )
107
+ db.add(node)
108
+ db.flush()
109
+ ensure_node_directory(physical_path)
110
+ log_action(db, "create", node_id=node.id, new_value={"name": name, "path": node_path}, action_by=created_by)
111
+ db.commit()
112
+ db.refresh(node)
113
+ logger.info("Created tree node %s (%s)", node_path, physical_path)
114
+ return node
115
+
116
+
117
+ def rename_node(
118
+ db: Session,
119
+ node_id: int,
120
+ new_name: str,
121
+ *,
122
+ action_by: str,
123
+ rename_physical: bool = False,
124
+ ) -> TreeNode:
125
+ node = get_node_or_404(db, node_id)
126
+ name = new_name.strip()
127
+ if not name:
128
+ raise HTTPException(status_code=400, detail="node_name is required")
129
+ if name.lower() in _sibling_names(db, node.parent_id, exclude_id=node_id):
130
+ raise HTTPException(status_code=400, detail="Duplicate node name under same parent")
131
+
132
+ old = {"name": node.node_name, "path": node.node_path}
133
+ node.node_name = name
134
+
135
+ if node.parent_id:
136
+ parent = get_node_or_404(db, node.parent_id)
137
+ node.node_path = f"{parent.node_path}/{name}"
138
+ else:
139
+ node.node_path = name
140
+
141
+ if rename_physical:
142
+ old_physical = node.physical_path
143
+ parent_physical = ""
144
+ if node.parent_id:
145
+ parent = get_node_or_404(db, node.parent_id)
146
+ parent_physical = parent.physical_path or ""
147
+ new_slug = unique_slug(name, _sibling_slugs(db, node.parent_id) - {node.slug})
148
+ new_physical = f"{parent_physical}/{new_slug}".strip("/") if parent_physical else new_slug
149
+ if old_physical != new_physical:
150
+ move_directory(old_physical, new_physical)
151
+ node.slug = new_slug
152
+ node.physical_path = new_physical
153
+ _update_descendant_paths(db, node)
154
+
155
+ log_action(db, "rename", node_id=node.id, old_value=old, new_value={"name": name, "path": node.node_path}, action_by=action_by)
156
+ db.commit()
157
+ db.refresh(node)
158
+ return node
159
+
160
+
161
+ def _update_descendant_paths(db: Session, parent: TreeNode) -> None:
162
+ children = db.query(TreeNode).filter(TreeNode.parent_id == parent.id, TreeNode.is_active == True).all() # noqa: E712
163
+ for child in children:
164
+ child.node_path = f"{parent.node_path}/{child.node_name}"
165
+ child.physical_path = f"{parent.physical_path}/{child.slug}".strip("/")
166
+ ensure_node_directory(child.physical_path)
167
+ _update_descendant_paths(db, child)
168
+
169
+
170
+ def move_node(
171
+ db: Session,
172
+ node_id: int,
173
+ new_parent_id: Optional[int],
174
+ *,
175
+ action_by: str,
176
+ ) -> TreeNode:
177
+ node = get_node_or_404(db, node_id)
178
+ if new_parent_id == node.id:
179
+ raise HTTPException(status_code=400, detail="Cannot move node under itself")
180
+
181
+ if new_parent_id is not None:
182
+ new_parent = get_node_or_404(db, new_parent_id)
183
+ # prevent cycle
184
+ cursor = new_parent
185
+ while cursor.parent_id is not None:
186
+ if cursor.parent_id == node.id:
187
+ raise HTTPException(status_code=400, detail="Cannot move node under its descendant")
188
+ cursor = get_node_or_404(db, cursor.parent_id)
189
+
190
+ if node.node_name.lower() in _sibling_names(db, new_parent_id, exclude_id=node_id):
191
+ raise HTTPException(status_code=400, detail="Duplicate node name under target parent")
192
+
193
+ old = {"parentId": node.parent_id, "path": node.node_path, "physical": node.physical_path}
194
+ old_physical = node.physical_path
195
+
196
+ node.parent_id = new_parent_id
197
+ if new_parent_id is None:
198
+ node.node_level = 0
199
+ node.node_path = node.node_name
200
+ new_physical = node.slug
201
+ else:
202
+ new_parent = get_node_or_404(db, new_parent_id)
203
+ node.node_level = new_parent.node_level + 1
204
+ node.node_path = f"{new_parent.node_path}/{node.node_name}"
205
+ new_physical = f"{new_parent.physical_path}/{node.slug}".strip("/")
206
+
207
+ if old_physical != new_physical:
208
+ move_directory(old_physical, new_physical)
209
+ node.physical_path = new_physical
210
+ _update_descendant_paths(db, node)
211
+
212
+ log_action(db, "move", node_id=node.id, old_value=old, new_value={"parentId": new_parent_id, "path": node.node_path}, action_by=action_by)
213
+ db.commit()
214
+ db.refresh(node)
215
+ return node
216
+
217
+
218
+ def _descendant_ids(db: Session, node_id: int) -> List[int]:
219
+ ids = [node_id]
220
+ children = db.query(TreeNode.id).filter(TreeNode.parent_id == node_id, TreeNode.is_active == True).all() # noqa: E712
221
+ for (cid,) in children:
222
+ ids.extend(_descendant_ids(db, cid))
223
+ return ids
224
+
225
+
226
+ def delete_node(
227
+ db: Session,
228
+ node_id: int,
229
+ *,
230
+ delete_files: bool,
231
+ action_by: str,
232
+ ) -> dict:
233
+ node = get_node_or_404(db, node_id)
234
+ ids = _descendant_ids(db, node_id)
235
+ img_count = db.query(ImageLibrary).filter(ImageLibrary.node_id.in_(ids)).count()
236
+ if img_count > 0 and not delete_files:
237
+ raise HTTPException(status_code=400, detail="Node has images; set delete_files=true to remove")
238
+
239
+ old = {"path": node.node_path, "physical": node.physical_path}
240
+ if delete_files:
241
+ for nid in reversed(ids):
242
+ n = db.query(TreeNode).filter(TreeNode.id == nid).first()
243
+ if n and n.physical_path:
244
+ delete_directory(n.physical_path)
245
+ db.query(ImageLibrary).filter(ImageLibrary.node_id == nid).delete()
246
+ if n:
247
+ n.is_active = False
248
+ else:
249
+ child_count = len(ids) - 1
250
+ if child_count > 0:
251
+ raise HTTPException(status_code=400, detail="Node has child nodes; set delete_files=true or remove children first")
252
+ if img_count > 0:
253
+ raise HTTPException(status_code=400, detail="Node has images")
254
+ node.is_active = False
255
+
256
+ log_action(db, "delete", node_id=node_id, old_value=old, new_value={"delete_files": delete_files}, action_by=action_by)
257
+ db.commit()
258
+ return {"ok": True, "deletedIds": ids}
app/main.py CHANGED
@@ -29,6 +29,7 @@ from .database import Base, engine, get_db, DATA_DIR
29
  from .models import User, DetectionRun
30
  from . import dda as _dda_pkg # noqa: F401 — register DDA tables
31
  from .dda.models import DdaZone, DdaVillage, ImageAsset, DetectionJob, RegionReview # noqa: F401
 
32
  from .dda.config import IS_DDA_MODE
33
  from .dda.bootstrap import init_dda_database, setup_dda
34
  from .notifier import send_notification, send_test_email
@@ -118,9 +119,15 @@ def health():
118
  if IS_DDA_MODE:
119
  try:
120
  from .dda.job_runner import is_job_runner_busy
121
- from .dda.local_library import scan_images
 
 
 
 
 
 
122
  payload["dda"] = {
123
- "libraryImages": len(scan_images()),
124
  "jobRunnerBusy": is_job_runner_busy(),
125
  }
126
  except Exception as exc:
 
29
  from .models import User, DetectionRun
30
  from . import dda as _dda_pkg # noqa: F401 — register DDA tables
31
  from .dda.models import DdaZone, DdaVillage, ImageAsset, DetectionJob, RegionReview # noqa: F401
32
+ from .dda.tree.models import TreeNode, ImageLibrary, AuditLog # noqa: F401
33
  from .dda.config import IS_DDA_MODE
34
  from .dda.bootstrap import init_dda_database, setup_dda
35
  from .notifier import send_notification, send_test_email
 
119
  if IS_DDA_MODE:
120
  try:
121
  from .dda.job_runner import is_job_runner_busy
122
+ from .dda.tree.image_service import list_all_images
123
+ from .database import SessionLocal
124
+ _sdb = SessionLocal()
125
+ try:
126
+ lib_count = len(list_all_images(_sdb))
127
+ finally:
128
+ _sdb.close()
129
  payload["dda"] = {
130
+ "libraryImages": lib_count,
131
  "jobRunnerBusy": is_job_runner_busy(),
132
  }
133
  except Exception as exc:
static/css/dda.css CHANGED
@@ -83,6 +83,35 @@
83
  background: var(--bg-hover);
84
  color: var(--grad-start);
85
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  .dda-folder-path {
88
  font-size: 0.78rem;
 
83
  background: var(--bg-hover);
84
  color: var(--grad-start);
85
  }
86
+ .dda-sidebar-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
87
+ .dda-tree-all, .dda-tree-node-btn {
88
+ display: block;
89
+ width: 100%;
90
+ text-align: left;
91
+ padding: 0.35rem 0.5rem;
92
+ margin-bottom: 0.2rem;
93
+ border: none;
94
+ background: transparent;
95
+ color: var(--text-muted);
96
+ cursor: pointer;
97
+ border-radius: 6px;
98
+ font-size: 0.88rem;
99
+ }
100
+ .dda-tree-all:hover, .dda-tree-all.active,
101
+ .dda-tree-node-btn:hover, .dda-tree-node-btn.active {
102
+ background: var(--bg-hover);
103
+ color: var(--grad-start);
104
+ }
105
+ .dda-tree-node-wrap { margin-bottom: 0.15rem; }
106
+ .dda-tree-node-summary { list-style: none; cursor: pointer; }
107
+ .dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
108
+ .dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
109
+ .dda-tree-leaf { padding-left: 0.75rem; }
110
+ .dda-crumb { font-size: 0.78rem; line-height: 1.3; }
111
+ .dda-manage-panel { max-width: 540px; width: 100%; }
112
+ .dda-manage-section { margin-bottom: 1rem; }
113
+ .dda-manage-section h4 { margin: 0 0 0.5rem; font-size: 0.9rem; }
114
+ .dda-manage-section .location-row { flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
115
 
116
  .dda-folder-path {
117
  font-size: 0.78rem;
static/js/dda/app.js CHANGED
@@ -55,15 +55,16 @@ function formatBytes(n) {
55
  }
56
 
57
  let ddaConfig = null;
58
- let localYears = [];
59
- let selectedYear = null;
60
 
61
  window.ddaState = {
62
  get config() { return ddaConfig; },
63
  get localCfg() { return window._localCfg; },
64
- get years() { return localYears; },
65
- get selectedYear() { return selectedYear; },
66
- setYear(year) { selectedYear = year; },
 
 
67
  refreshImages: () => loadLibraryImages(),
68
  rescan: () => rescanLibrary(),
69
  };
@@ -83,8 +84,8 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
83
 
84
  async function rescanLibrary() {
85
  const data = await ddaApi('POST', '/api/dda/local/rescan');
86
- localYears = data.years || [];
87
- if (typeof renderYearTree === 'function') renderYearTree(localYears);
88
  await loadLibraryImages();
89
  return data;
90
  }
@@ -92,125 +93,91 @@ async function rescanLibrary() {
92
  async function initDda() {
93
  hideDdaError();
94
  try {
 
 
 
 
 
 
 
95
  ddaConfig = await ddaApi('GET', '/api/dda/config');
96
  const localCfg = await ddaApi('GET', '/api/dda/local/config');
97
  window._localCfg = localCfg;
98
 
 
 
99
  const hint = document.getElementById('lib-config-hint');
100
- if (hint) {
101
- hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
102
- }
103
- const paths = (localCfg.rootPaths || []).filter(Boolean);
104
  const pathEl = document.getElementById('lib-path-display');
105
  if (pathEl) {
106
  pathEl.textContent = [
107
- localCfg.isHosted ? 'HF writable storage:' : 'Local folders:',
108
- localCfg.writablePath || paths[0] || '',
109
- ...paths.filter((p) => p !== localCfg.writablePath),
110
  ].filter(Boolean).join('\n');
111
  }
 
112
  const folderPath = document.getElementById('lib-folder-path');
113
  if (folderPath) {
114
  folderPath.textContent = localCfg.isHosted
115
- ? 'Hugging Face — upload files below'
116
- : (paths[0] ? `Scanning: ${paths[0]}` : '');
117
  }
 
118
  const instr = document.getElementById('lib-instructions');
119
  if (instr && localCfg.instructions) instr.textContent = localCfg.instructions;
120
 
121
- const hfUpload = document.getElementById('hf-upload-card');
122
- if (hfUpload) hfUpload.classList.toggle('hidden', !localCfg.isHosted);
123
-
124
- const resHint = document.getElementById('dda-detect-res-hint');
125
- if (resHint && localCfg.detectionMaxSide) {
126
- resHint.textContent = `Detection runs at up to ${localCfg.detectionMaxSide}px per side for sharper results (set DETECTION_MAX_SIDE to change).`;
127
- }
128
-
129
- const uploadLimit = document.getElementById('hf-upload-limit');
130
  if (uploadLimit && localCfg.maxUploadGb) {
131
- uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
132
  }
133
 
134
- const appMode = ddaConfig.appMode || ddaConfig.mode || localCfg.appMode || 'dda';
135
- if (!localCfg.isHosted && appMode !== 'dda') {
136
- showDdaError('DDA mode is off. Run locally with: python run.py');
137
  }
138
 
139
  const urlTab = new URLSearchParams(window.location.search).get('tab');
140
- if (urlTab) {
141
- document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
142
- }
143
 
144
- const yearsData = await ddaApi('GET', '/api/dda/local/years');
145
- localYears = yearsData.years || [];
146
- if (typeof renderYearTree === 'function') renderYearTree(localYears);
147
  await loadLibraryImages();
148
- await loadHierarchyTree();
149
  } catch (err) {
150
  showDdaError(err.message || 'Failed to load library');
151
  }
152
  }
153
 
154
- async function loadHierarchyTree() {
155
- const el = document.getElementById('lib-hierarchy');
156
- if (!el) return;
157
- try {
158
- const data = await ddaApi('GET', '/api/dda/hierarchy');
159
- const zones = data.zones || [];
160
- if (!zones.length) {
161
- el.innerHTML = '<p class="dim">No zones seeded.</p>';
162
- return;
163
- }
164
- el.innerHTML = `<p class="dim" style="margin-bottom:0.5rem">DB hierarchy (upload API). Local library uses year folders.</p>` +
165
- zones.map((z) => {
166
- const villages = z.villages || [];
167
- const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
168
- const villageItems = villages.map((v) =>
169
- `<li class="dda-hierarchy-village">${v.name}${v.imageCount ? ` <span class="dim">(${v.imageCount})</span>` : ''}</li>`
170
- ).join('');
171
- return `
172
- <details class="dda-hierarchy-zone" open>
173
- <summary>${z.name}${zoneCount ? ` <span class="dim">(${zoneCount})</span>` : ''}</summary>
174
- <ul class="dda-hierarchy-list">${villageItems || '<li class="dim">No villages</li>'}</ul>
175
- </details>`;
176
- }).join('');
177
- } catch (_) {
178
- el.innerHTML = '<p class="dim">Zone tree unavailable.</p>';
179
- }
180
  }
181
 
182
- window.loadHierarchyTree = loadHierarchyTree;
183
-
184
  async function loadLibraryImages() {
185
  const grid = document.getElementById('lib-grid');
186
  const title = document.getElementById('lib-grid-title');
187
  if (!grid) return;
188
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
189
  const params = new URLSearchParams();
190
- if (selectedYear) params.set('year', String(selectedYear));
191
  if (q) params.set('q', q);
192
- if (title) {
193
- title.textContent = selectedYear ? `Images — ${selectedYear}` : 'Images — all years';
194
- }
195
  try {
196
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
197
  window.ddaState.libraryItems = items;
198
  if (!items.length) {
199
- const hf = window.ddaState?.localCfg?.isHosted;
200
- grid.innerHTML = hf
201
- ? `<p class="dim">No images on this Space yet. Use <strong>Upload to Space storage</strong> above (2025 / 2026), then click Refresh.</p>`
202
- : `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
203
  return;
204
  }
205
  grid.innerHTML = items.map((img) => {
206
- const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
 
207
  return `
208
  <div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '&quot;')}" title="${escapeHtml(img.filename)}">
209
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
210
  <div class="meta">
211
- <strong>${escapeHtml(String(img.year))}</strong><br/>
212
- ${escapeHtml(img.filename)}<br/>
213
- <span class="dim">${formatBytes(img.fileSizeBytes)}</span>
214
  </div>
215
  </div>`;
216
  }).join('');
@@ -236,7 +203,7 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
236
  btn.disabled = true;
237
  try {
238
  const data = await rescanLibrary();
239
- showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s) found.`);
240
  } catch (err) {
241
  showDdaError(err.message);
242
  } finally {
 
55
  }
56
 
57
  let ddaConfig = null;
58
+ const selectedNode = { id: null, path: '' };
 
59
 
60
  window.ddaState = {
61
  get config() { return ddaConfig; },
62
  get localCfg() { return window._localCfg; },
63
+ get selectedNode() { return { ...selectedNode }; },
64
+ get userRole() { return window._ddaUserRole || 'analyst'; },
65
+ set userRole(r) { window._ddaUserRole = r; },
66
+ setNode(n) { selectedNode.id = n.id; selectedNode.path = n.path || ''; },
67
+ clearNode() { selectedNode.id = null; selectedNode.path = ''; },
68
  refreshImages: () => loadLibraryImages(),
69
  rescan: () => rescanLibrary(),
70
  };
 
84
 
85
  async function rescanLibrary() {
86
  const data = await ddaApi('POST', '/api/dda/local/rescan');
87
+ if (typeof renderTree === 'function') renderTree({ tree: data.tree }, []);
88
+ if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
89
  await loadLibraryImages();
90
  return data;
91
  }
 
93
  async function initDda() {
94
  hideDdaError();
95
  try {
96
+ try {
97
+ const me = await ddaApi('GET', '/api/dda/me');
98
+ window.ddaState.userRole = me.role || 'analyst';
99
+ } catch (_) {
100
+ window.ddaState.userRole = 'analyst';
101
+ }
102
+
103
  ddaConfig = await ddaApi('GET', '/api/dda/config');
104
  const localCfg = await ddaApi('GET', '/api/dda/local/config');
105
  window._localCfg = localCfg;
106
 
107
+ document.getElementById('btn-manage-library')?.classList.toggle('hidden', window.ddaState.userRole !== 'admin');
108
+
109
  const hint = document.getElementById('lib-config-hint');
110
+ if (hint) hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
111
+
 
 
112
  const pathEl = document.getElementById('lib-path-display');
113
  if (pathEl) {
114
  pathEl.textContent = [
115
+ 'Storage root:',
116
+ localCfg.storageRoot || localCfg.writablePath || '',
117
+ 'Layout: {zone}/{area}/…/Images/file.tif',
118
  ].filter(Boolean).join('\n');
119
  }
120
+
121
  const folderPath = document.getElementById('lib-folder-path');
122
  if (folderPath) {
123
  folderPath.textContent = localCfg.isHosted
124
+ ? 'Hugging Face — tree library'
125
+ : `Storage: ${localCfg.storageRoot || ''}`;
126
  }
127
+
128
  const instr = document.getElementById('lib-instructions');
129
  if (instr && localCfg.instructions) instr.textContent = localCfg.instructions;
130
 
131
+ const uploadLimit = document.getElementById('upload-limit-hint');
 
 
 
 
 
 
 
 
132
  if (uploadLimit && localCfg.maxUploadGb) {
133
+ uploadLimit.textContent = `Select a tree node and image type. Max ${localCfg.maxUploadGb} GB per GeoTIFF.`;
134
  }
135
 
136
+ const resHint = document.getElementById('dda-detect-res-hint');
137
+ if (resHint && localCfg.detectionMaxSide) {
138
+ resHint.textContent = `Detection runs at up to ${localCfg.detectionMaxSide}px per side.`;
139
  }
140
 
141
  const urlTab = new URLSearchParams(window.location.search).get('tab');
142
+ if (urlTab) document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
 
 
143
 
144
+ if (typeof loadTree === 'function') await loadTree();
 
 
145
  await loadLibraryImages();
 
146
  } catch (err) {
147
  showDdaError(err.message || 'Failed to load library');
148
  }
149
  }
150
 
151
+ function selectionTitle() {
152
+ return selectedNode.path || 'All images';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
 
 
155
  async function loadLibraryImages() {
156
  const grid = document.getElementById('lib-grid');
157
  const title = document.getElementById('lib-grid-title');
158
  if (!grid) return;
159
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
160
  const params = new URLSearchParams();
161
+ if (selectedNode.id) params.set('node_id', String(selectedNode.id));
162
  if (q) params.set('q', q);
163
+ if (title) title.textContent = `Images — ${selectionTitle()}`;
164
+
 
165
  try {
166
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
167
  window.ddaState.libraryItems = items;
168
  if (!items.length) {
169
+ grid.innerHTML = `<p class="dim">No images in <strong>${escapeHtml(selectionTitle())}</strong>. Select a node and upload, or use Manage to create folders.</p>`;
 
 
 
170
  return;
171
  }
172
  grid.innerHTML = items.map((img) => {
173
+ const thumb = img.thumbUrl || '';
174
+ const crumb = img.breadcrumb || img.nodePath || img.filename;
175
  return `
176
  <div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '&quot;')}" title="${escapeHtml(img.filename)}">
177
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
178
  <div class="meta">
179
+ <span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
180
+ <span class="dim">${img.imageType || ''} · ${formatBytes(img.fileSizeBytes)}</span>
 
181
  </div>
182
  </div>`;
183
  }).join('');
 
203
  btn.disabled = true;
204
  try {
205
  const data = await rescanLibrary();
206
+ showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s).`);
207
  } catch (err) {
208
  showDdaError(err.message);
209
  } finally {
static/js/dda/compare.js CHANGED
@@ -49,7 +49,7 @@ function renderSlotPreview(slotKey, selection) {
49
  const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
50
  wrap.innerHTML = `
51
  <img class="dda-slot-preview" src="${thumb}" alt="" />
52
- <div class="dda-slot-meta"><strong>${selection.year}</strong> ${selection.filename}</div>`;
53
  }
54
 
55
  function populateSelects(items) {
@@ -59,7 +59,7 @@ function populateSelects(items) {
59
  const current = sel.value;
60
  const label = id === 'select-t1' ? '— Choose base image —' : '— Choose comparison image —';
61
  sel.innerHTML = `<option value="">${label}</option>` +
62
- items.map((img) => `<option value="${encodePath(img.path)}">${img.year} ${img.filename}</option>`).join('');
63
  if (current) sel.value = current;
64
  });
65
  }
@@ -68,7 +68,7 @@ function setSlot(slotKey, img) {
68
  if (!img || !img.path) return;
69
  const item = {
70
  path: img.path,
71
- year: img.year,
72
  filename: img.filename,
73
  thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
74
  };
@@ -95,7 +95,7 @@ function findLibraryItem(path) {
95
  const items = ensureDdaState().libraryItems || [];
96
  return items.find((i) => i.path === norm) || {
97
  path: norm,
98
- year: parseInt(norm.split('/')[0], 10) || '',
99
  filename: norm.split('/').pop(),
100
  thumbUrl: thumbUrlFor(norm),
101
  };
@@ -136,7 +136,7 @@ async function loadCompareLibraryGrid() {
136
  <div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
137
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
138
  <div class="meta">
139
- <strong>${img.year}</strong><br/>
140
  ${safeName}<br/>
141
  <span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
142
  </div>
@@ -191,7 +191,7 @@ async function openPicker(slotKey) {
191
  return `
192
  <button type="button" class="dda-picker-item" data-path="${enc}">
193
  <img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" />
194
- <span><strong>${img.year}</strong><br/>${img.filename}</span>
195
  </button>`;
196
  }).join('');
197
  list.querySelectorAll('.dda-picker-item').forEach((btn) => {
 
49
  const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
50
  wrap.innerHTML = `
51
  <img class="dda-slot-preview" src="${thumb}" alt="" />
52
+ <div class="dda-slot-meta">${escapeHtml(selection.label || selection.filename)}</div>`;
53
  }
54
 
55
  function populateSelects(items) {
 
59
  const current = sel.value;
60
  const label = id === 'select-t1' ? '— Choose base image —' : '— Choose comparison image —';
61
  sel.innerHTML = `<option value="">${label}</option>` +
62
+ items.map((img) => `<option value="${encodePath(img.path)}">${escapeHtml(img.breadcrumb || img.nodePath || img.filename)}</option>`).join('');
63
  if (current) sel.value = current;
64
  });
65
  }
 
68
  if (!img || !img.path) return;
69
  const item = {
70
  path: img.path,
71
+ label: img.breadcrumb || img.nodePath || img.filename,
72
  filename: img.filename,
73
  thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
74
  };
 
95
  const items = ensureDdaState().libraryItems || [];
96
  return items.find((i) => i.path === norm) || {
97
  path: norm,
98
+ label: norm.split('/').pop(),
99
  filename: norm.split('/').pop(),
100
  thumbUrl: thumbUrlFor(norm),
101
  };
 
136
  <div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
137
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
138
  <div class="meta">
139
+ <span class="dim">${escapeHtml(img.breadcrumb || img.nodePath || '')}</span><br/>
140
  ${safeName}<br/>
141
  <span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
142
  </div>
 
191
  return `
192
  <button type="button" class="dda-picker-item" data-path="${enc}">
193
  <img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" />
194
+ <span>${escapeHtml(img.breadcrumb || img.filename)}</span>
195
  </button>`;
196
  }).join('');
197
  list.querySelectorAll('.dda-picker-item').forEach((btn) => {
static/js/dda/library.js CHANGED
@@ -1,37 +1,3 @@
1
- function renderYearTree(years) {
2
- const tree = document.getElementById('lib-tree');
3
- if (!tree) return;
4
- const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
5
-
6
- const allBtn = `
7
- <button type="button" class="dda-tree-year ${window.ddaState.selectedYear === null ? 'active' : ''}" data-year="">
8
- All years
9
- </button>`;
10
-
11
- const yearBtns = (years || [])
12
- .filter((y) => !filter || String(y.year).includes(filter))
13
- .map((y) => `
14
- <button type="button" class="dda-tree-year ${window.ddaState.selectedYear === y.year ? 'active' : ''}" data-year="${y.year}">
15
- ${y.year} <span class="dim">(${y.imageCount})</span>
16
- </button>`).join('');
17
-
18
- tree.innerHTML = allBtn + yearBtns;
19
-
20
- tree.querySelectorAll('.dda-tree-year').forEach((btn) => {
21
- btn.addEventListener('click', () => {
22
- tree.querySelectorAll('.dda-tree-year').forEach((b) => b.classList.remove('active'));
23
- btn.classList.add('active');
24
- const raw = btn.dataset.year;
25
- window.ddaState.setYear(raw ? parseInt(raw, 10) : null);
26
- window.ddaState.refreshImages();
27
- });
28
- });
29
- }
30
-
31
- document.getElementById('lib-tree-search')?.addEventListener('input', () => {
32
- if (window.ddaState?.years) renderYearTree(window.ddaState.years);
33
- });
34
-
35
  function uploadWithProgress(url, formData, onProgress) {
36
  return new Promise((resolve, reject) => {
37
  const xhr = new XMLHttpRequest();
@@ -51,53 +17,47 @@ function uploadWithProgress(url, formData, onProgress) {
51
  });
52
  }
53
 
54
- function formatBytes(n) {
55
- if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
56
- if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
57
- return (n / 1024).toFixed(0) + ' KB';
58
- }
59
-
60
- document.getElementById('form-hf-upload')?.addEventListener('submit', async (e) => {
61
  e.preventDefault();
62
  hideDdaError?.();
63
- const fileInput = document.getElementById('hf-file');
 
 
64
  const file = fileInput?.files?.[0];
65
- if (!file) {
66
- showDdaError?.('Select a .tif file.');
67
- return;
68
- }
69
 
70
  const maxBytes = window.ddaState?.localCfg?.maxGeotiffBytes
71
  || (window.ddaState?.localCfg?.maxGeotiffMb || 5120) * 1024 * 1024;
72
  if (file.size > maxBytes) {
73
- showDdaError?.(`File is ${formatBytes(file.size)} — maximum upload size is ${formatBytes(maxBytes)}.`);
74
- return;
75
  }
76
 
77
  const form = new FormData();
78
  form.append('file', file);
79
- form.append('year', document.getElementById('hf-year').value);
 
80
 
81
- const btn = document.getElementById('btn-hf-upload');
82
- const progWrap = document.getElementById('hf-upload-progress');
83
- const progFill = document.getElementById('hf-upload-progress-fill');
84
- const progLabel = document.getElementById('hf-upload-progress-label');
85
 
86
  btn.disabled = true;
87
  progWrap?.classList.remove('hidden');
88
  if (progFill) progFill.style.width = '0%';
89
 
90
  try {
91
- await uploadWithProgress('/api/dda/local/upload', form, (loaded, total) => {
92
  const pct = total ? Math.round((loaded / total) * 100) : 0;
93
  if (progFill) progFill.style.width = pct + '%';
94
- if (progLabel) progLabel.textContent = `Uploading… ${pct}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
95
  });
96
- showDdaSuccess?.('Uploaded to Space library. Click Refresh if images do not appear.');
97
  fileInput.value = '';
98
  await window.ddaState.rescan();
99
  } catch (err) {
100
- showDdaError?.(err.message || 'Upload failed. Large files may exceed HF timeout — try a smaller file or run locally.');
101
  } finally {
102
  btn.disabled = false;
103
  setTimeout(() => progWrap?.classList.add('hidden'), 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  function uploadWithProgress(url, formData, onProgress) {
2
  return new Promise((resolve, reject) => {
3
  const xhr = new XMLHttpRequest();
 
17
  });
18
  }
19
 
20
+ document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
 
 
 
 
 
 
21
  e.preventDefault();
22
  hideDdaError?.();
23
+
24
+ const nodeId = document.getElementById('upload-node')?.value;
25
+ const fileInput = document.getElementById('upload-file');
26
  const file = fileInput?.files?.[0];
27
+ if (!nodeId) return showDdaError?.('Select a tree node.');
28
+ if (!file) return showDdaError?.('Select a file.');
 
 
29
 
30
  const maxBytes = window.ddaState?.localCfg?.maxGeotiffBytes
31
  || (window.ddaState?.localCfg?.maxGeotiffMb || 5120) * 1024 * 1024;
32
  if (file.size > maxBytes) {
33
+ return showDdaError?.(`File is ${formatBytes(file.size)} — max ${formatBytes(maxBytes)}.`);
 
34
  }
35
 
36
  const form = new FormData();
37
  form.append('file', file);
38
+ form.append('image_type', document.getElementById('upload-image-type')?.value || 'GeoTIFF');
39
+ form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
40
 
41
+ const btn = document.getElementById('btn-tree-upload');
42
+ const progWrap = document.getElementById('upload-progress');
43
+ const progFill = document.getElementById('upload-progress-fill');
44
+ const progLabel = document.getElementById('upload-progress-label');
45
 
46
  btn.disabled = true;
47
  progWrap?.classList.remove('hidden');
48
  if (progFill) progFill.style.width = '0%';
49
 
50
  try {
51
+ await uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, (loaded, total) => {
52
  const pct = total ? Math.round((loaded / total) * 100) : 0;
53
  if (progFill) progFill.style.width = pct + '%';
54
+ if (progLabel) progLabel.textContent = `Uploading… ${pct}%`;
55
  });
56
+ showDdaSuccess?.('Uploaded. Refreshing…');
57
  fileInput.value = '';
58
  await window.ddaState.rescan();
59
  } catch (err) {
60
+ showDdaError?.(err.message || 'Upload failed.');
61
  } finally {
62
  btn.disabled = false;
63
  setTimeout(() => progWrap?.classList.add('hidden'), 2000);
static/js/dda/tree.js ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Unlimited-depth tree library UI + admin management. */
2
+
3
+ let treeData = null;
4
+ let imageTypes = [];
5
+
6
+ function isAdmin() {
7
+ return window.ddaState?.userRole === 'admin';
8
+ }
9
+
10
+ function renderTreeNodes(nodes, depth = 0) {
11
+ if (!nodes || !nodes.length) return '';
12
+ const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
13
+ const sel = window.ddaState?.selectedNode;
14
+
15
+ return nodes.map((node) => {
16
+ const name = node.name || node.nodeName;
17
+ const children = node.children || [];
18
+ const matches = !filter ||
19
+ name.toLowerCase().includes(filter) ||
20
+ (node.nodePath || '').toLowerCase().includes(filter) ||
21
+ children.some((c) => JSON.stringify(c).toLowerCase().includes(filter));
22
+
23
+ if (!matches && filter) return '';
24
+
25
+ const active = sel?.id === node.id;
26
+ const hasKids = children.length > 0;
27
+ const count = node.imageCount ? ` <span class="dim">(${node.imageCount})</span>` : '';
28
+ const indent = depth > 0 ? ` style="padding-left:${depth * 0.5}rem"` : '';
29
+
30
+ if (hasKids) {
31
+ return `
32
+ <details class="dda-tree-node-wrap" open${indent ? '' : ''}>
33
+ <summary class="dda-tree-node-summary ${active ? 'active' : ''}" data-node-id="${node.id}">
34
+ <button type="button" class="dda-tree-node-btn ${active ? 'active' : ''}" data-node-id="${node.id}"
35
+ data-node-path="${escapeHtml(node.nodePath || name)}">${escapeHtml(name)}${count}</button>
36
+ </summary>
37
+ <div class="dda-tree-children">${renderTreeNodes(children, depth + 1)}</div>
38
+ </details>`;
39
+ }
40
+ return `
41
+ <button type="button" class="dda-tree-node-btn dda-tree-leaf ${active ? 'active' : ''}" data-node-id="${node.id}"
42
+ data-node-path="${escapeHtml(node.nodePath || name)}"${indent}>${escapeHtml(name)}${count}</button>`;
43
+ }).join('');
44
+ }
45
+
46
+ function renderTree(tree, types) {
47
+ const el = document.getElementById('lib-tree');
48
+ if (!el) return;
49
+ treeData = tree;
50
+ if (types) imageTypes = types;
51
+
52
+ const nodes = tree?.tree || tree || [];
53
+ const allActive = !window.ddaState?.selectedNode?.id;
54
+ let html = `<button type="button" class="dda-tree-all ${allActive ? 'active' : ''}" id="btn-tree-all">All images</button>`;
55
+ html += renderTreeNodes(nodes);
56
+ if (!nodes.length) {
57
+ html += '<p class="dim">No nodes yet. Admins can use Manage to add zones.</p>';
58
+ }
59
+ el.innerHTML = html;
60
+
61
+ document.getElementById('btn-tree-all')?.addEventListener('click', () => {
62
+ window.ddaState.clearNode();
63
+ window.ddaState.refreshImages();
64
+ renderTree(treeData, imageTypes);
65
+ syncUploadNodeSelect();
66
+ });
67
+
68
+ el.querySelectorAll('.dda-tree-node-btn').forEach((btn) => {
69
+ btn.addEventListener('click', (e) => {
70
+ e.stopPropagation();
71
+ window.ddaState.setNode({
72
+ id: parseInt(btn.dataset.nodeId, 10),
73
+ path: btn.dataset.nodePath,
74
+ });
75
+ window.ddaState.refreshImages();
76
+ renderTree(treeData, imageTypes);
77
+ syncUploadNodeSelect();
78
+ });
79
+ });
80
+ }
81
+
82
+ document.getElementById('lib-tree-search')?.addEventListener('input', () => {
83
+ if (treeData) renderTree(treeData, imageTypes);
84
+ });
85
+
86
+ async function loadTree() {
87
+ const data = await ddaApi('GET', '/api/dda/tree');
88
+ renderTree(data, data.imageTypes);
89
+ populateNodeSelects(data.tree);
90
+ if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
91
+ return data;
92
+ }
93
+
94
+ window.loadTree = loadTree;
95
+ window.renderTree = renderTree;
96
+
97
+ function flattenNodes(nodes, out = []) {
98
+ (nodes || []).forEach((n) => {
99
+ out.push(n);
100
+ flattenNodes(n.children, out);
101
+ });
102
+ return out;
103
+ }
104
+
105
+ function populateNodeSelects(tree) {
106
+ const flat = flattenNodes(tree || []);
107
+ const opts = '<option value="">— Select node —</option>' +
108
+ flat.map((n) => `<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('');
109
+
110
+ ['upload-node', 'manage-parent', 'move-parent', 'add-child-parent'].forEach((id) => {
111
+ const el = document.getElementById(id);
112
+ if (el) el.innerHTML = id === 'manage-parent' ? '<option value="">— Root —</option>' + flat.map((n) =>
113
+ `<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('') : opts;
114
+ });
115
+
116
+ const typeSel = document.getElementById('upload-image-type');
117
+ if (typeSel && imageTypes.length) {
118
+ typeSel.innerHTML = imageTypes.map((t) => `<option value="${t}">${t}</option>`).join('');
119
+ }
120
+ syncUploadNodeSelect();
121
+ }
122
+
123
+ function syncUploadNodeSelect() {
124
+ const sel = document.getElementById('upload-node');
125
+ const node = window.ddaState?.selectedNode;
126
+ if (sel && node?.id) sel.value = String(node.id);
127
+ }
128
+
129
+ /* Manage modal */
130
+ function openManage() {
131
+ document.getElementById('dda-manage-modal')?.classList.remove('hidden');
132
+ if (treeData) populateNodeSelects(treeData.tree || treeData);
133
+ }
134
+
135
+ function closeManage() {
136
+ document.getElementById('dda-manage-modal')?.classList.add('hidden');
137
+ }
138
+
139
+ document.getElementById('btn-manage-library')?.addEventListener('click', openManage);
140
+ document.getElementById('dda-manage-close')?.addEventListener('click', closeManage);
141
+
142
+ document.getElementById('btn-add-node')?.addEventListener('click', async () => {
143
+ const parentVal = document.getElementById('add-child-parent')?.value;
144
+ const name = document.getElementById('add-node-name')?.value?.trim();
145
+ const type = document.getElementById('add-node-type')?.value || 'Folder';
146
+ if (!name) return showDdaError('Enter a node name.');
147
+ const body = { node_name: name, node_type: type, parent_id: parentVal ? parseInt(parentVal, 10) : null };
148
+ try {
149
+ await ddaApi('POST', '/api/dda/tree/nodes', { body: JSON.stringify(body) });
150
+ document.getElementById('add-node-name').value = '';
151
+ showDdaSuccess('Node created.');
152
+ await window.ddaState.rescan();
153
+ } catch (err) {
154
+ showDdaError(err.message);
155
+ }
156
+ });
157
+
158
+ document.getElementById('btn-rename-node')?.addEventListener('click', async () => {
159
+ const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
160
+ const name = document.getElementById('rename-node-name')?.value?.trim();
161
+ if (!id || !name) return showDdaError('Select a node and enter a new name.');
162
+ try {
163
+ await ddaApi('PUT', `/api/dda/tree/nodes/${id}/rename`, { body: JSON.stringify({ node_name: name }) });
164
+ showDdaSuccess('Node renamed.');
165
+ await window.ddaState.rescan();
166
+ } catch (err) {
167
+ showDdaError(err.message);
168
+ }
169
+ });
170
+
171
+ document.getElementById('btn-delete-node')?.addEventListener('click', async () => {
172
+ const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
173
+ if (!id || !confirm('Delete this node? Check delete files if removing images.')) return;
174
+ const deleteFiles = document.getElementById('delete-files-check')?.checked;
175
+ try {
176
+ await ddaApi('DELETE', `/api/dda/tree/nodes/${id}`, { body: JSON.stringify({ delete_files: !!deleteFiles }) });
177
+ showDdaSuccess('Node deleted.');
178
+ await window.ddaState.rescan();
179
+ } catch (err) {
180
+ showDdaError(err.message);
181
+ }
182
+ });
183
+
184
+ document.getElementById('btn-move-node')?.addEventListener('click', async () => {
185
+ const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
186
+ const parentVal = document.getElementById('move-parent')?.value;
187
+ if (!id) return showDdaError('Select a node to move.');
188
+ try {
189
+ await ddaApi('POST', `/api/dda/tree/nodes/${id}/move`, {
190
+ body: JSON.stringify({ parent_id: parentVal ? parseInt(parentVal, 10) : null }),
191
+ });
192
+ showDdaSuccess('Node moved.');
193
+ await window.ddaState.rescan();
194
+ } catch (err) {
195
+ showDdaError(err.message);
196
+ }
197
+ });
198
+
199
+ function populateManageNodeSelect() {
200
+ if (!treeData) return;
201
+ const flat = flattenNodes(treeData.tree || treeData || []);
202
+ const sel = document.getElementById('manage-node-select');
203
+ if (sel) sel.innerHTML = flat.map((n) =>
204
+ `<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('');
205
+ }
206
+
207
+ document.getElementById('dda-manage-modal')?.addEventListener('click', (e) => {
208
+ if (e.target.id === 'dda-manage-modal') closeManage();
209
+ });
210
+
211
+ window.populateManageNodeSelect = populateManageNodeSelect;
templates/index_dda.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
- <link rel="stylesheet" href="/static/css/dda.css?v=11" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
@@ -32,55 +32,62 @@
32
  <div id="dda-error" class="alert alert-error hidden"></div>
33
  <div id="dda-success" class="alert alert-success hidden"></div>
34
 
35
- <!-- Tab: Image Library — reads from library_sources/ year folders -->
36
  <section id="tab-library" class="dda-panel active" role="tabpanel">
37
  <div class="dda-layout">
38
  <aside class="dda-sidebar card">
39
  <div class="card-header">
40
- <h3>Years</h3>
41
- <button type="button" class="btn btn-secondary btn-sm" id="btn-refresh-lib" title="Rescan folder">Refresh</button>
 
 
 
42
  </div>
43
  <div id="lib-folder-path" class="dda-folder-path dim"></div>
44
- <input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter years…" />
45
  <div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
46
- <div class="dda-hierarchy-wrap">
47
- <h4 class="dda-hierarchy-title">DDA zones</h4>
48
- <div id="lib-hierarchy" class="dda-hierarchy"><p class="dim">Loading…</p></div>
49
- </div>
50
  </aside>
51
  <main class="dda-main">
52
  <div class="card dda-instructions">
53
  <div class="card-header">
54
- <h3>Local folder library</h3>
55
  <span class="dim" id="lib-config-hint"></span>
56
  </div>
57
- <p class="sub" id="lib-instructions">
58
- Copy <code>.tif</code> images into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh</strong>.
59
- </p>
60
  <pre id="lib-path-display" class="dda-path-code"></pre>
61
  </div>
62
- <div class="card hidden" id="hf-upload-card">
63
  <div class="card-header">
64
- <h3>Upload to Space storage</h3>
65
- <span class="dim">Required on Hugging Face</span>
66
  </div>
67
- <p class="sub" id="hf-upload-limit">Files on your computer are not on the server. Upload .tif images here (up to 5 GB each).</p>
68
- <form id="form-hf-upload" class="dda-upload-form">
69
  <div class="location-row">
70
  <div class="form-group">
71
- <label for="hf-year">Year folder</label>
72
- <input type="number" id="hf-year" required min="2000" max="2100" value="2025" />
 
 
 
 
 
 
 
73
  </div>
74
  <div class="form-group">
75
- <label for="hf-file">GeoTIFF file</label>
76
- <input type="file" id="hf-file" accept=".tif,.tiff" required />
 
 
 
 
77
  </div>
78
  </div>
79
- <div id="hf-upload-progress" class="dda-upload-progress hidden">
80
- <div class="dda-progress-bar"><div id="hf-upload-progress-fill" class="dda-progress-fill"></div></div>
81
- <span id="hf-upload-progress-label" class="dim">Uploading…</span>
82
  </div>
83
- <button type="submit" class="btn btn-primary" id="btn-hf-upload">Upload to library</button>
84
  </form>
85
  </div>
86
  <div class="card">
@@ -88,7 +95,7 @@
88
  <h3 id="lib-grid-title">Images</h3>
89
  <input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
90
  </div>
91
- <div id="lib-grid" class="dda-grid"><p class="dim">Select a year or add images to library_sources/.</p></div>
92
  </div>
93
  </main>
94
  </div>
@@ -259,8 +266,45 @@
259
  </div>
260
  </div>
261
 
262
- <script src="/static/js/dda/app.js?v=12"></script>
263
- <script src="/static/js/dda/library.js?v=6"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  <script src="/static/js/dda/result.js?v=6"></script>
265
  <script src="/static/js/dda/compare.js?v=9"></script>
266
  <script src="/static/js/dda/reports.js?v=4"></script>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=13" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
 
32
  <div id="dda-error" class="alert alert-error hidden"></div>
33
  <div id="dda-success" class="alert alert-success hidden"></div>
34
 
35
+ <!-- Tab: Image Library — unlimited-depth tree -->
36
  <section id="tab-library" class="dda-panel active" role="tabpanel">
37
  <div class="dda-layout">
38
  <aside class="dda-sidebar card">
39
  <div class="card-header">
40
+ <h3>Library tree</h3>
41
+ <div class="dda-sidebar-actions">
42
+ <button type="button" class="btn btn-secondary btn-sm hidden" id="btn-manage-library">Manage</button>
43
+ <button type="button" class="btn btn-secondary btn-sm" id="btn-refresh-lib">Refresh</button>
44
+ </div>
45
  </div>
46
  <div id="lib-folder-path" class="dda-folder-path dim"></div>
47
+ <input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter nodes…" />
48
  <div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
 
 
 
 
49
  </aside>
50
  <main class="dda-main">
51
  <div class="card dda-instructions">
52
  <div class="card-header">
53
+ <h3>Tree library</h3>
54
  <span class="dim" id="lib-config-hint"></span>
55
  </div>
56
+ <p class="sub" id="lib-instructions">Organize images in a configurable zone → area → year → type hierarchy.</p>
 
 
57
  <pre id="lib-path-display" class="dda-path-code"></pre>
58
  </div>
59
+ <div class="card" id="upload-card">
60
  <div class="card-header">
61
+ <h3>Upload image</h3>
 
62
  </div>
63
+ <p class="sub" id="upload-limit-hint">Select a tree node and image type.</p>
64
+ <form id="form-tree-upload" class="dda-upload-form">
65
  <div class="location-row">
66
  <div class="form-group">
67
+ <label for="upload-node">Tree node</label>
68
+ <select id="upload-node" required><option value="">— Select node —</option></select>
69
+ </div>
70
+ <div class="form-group">
71
+ <label for="upload-image-type">Image type</label>
72
+ <select id="upload-image-type">
73
+ <option>GeoTIFF</option><option>Satellite</option><option>Drone</option>
74
+ <option>Orthomosaic</option><option>DEM</option><option>Raster</option>
75
+ </select>
76
  </div>
77
  <div class="form-group">
78
+ <label for="upload-capture-date">Capture date</label>
79
+ <input type="date" id="upload-capture-date" />
80
+ </div>
81
+ <div class="form-group">
82
+ <label for="upload-file">File</label>
83
+ <input type="file" id="upload-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
84
  </div>
85
  </div>
86
+ <div id="upload-progress" class="dda-upload-progress hidden">
87
+ <div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
88
+ <span id="upload-progress-label" class="dim">Uploading…</span>
89
  </div>
90
+ <button type="submit" class="btn btn-primary" id="btn-tree-upload">Upload to node</button>
91
  </form>
92
  </div>
93
  <div class="card">
 
95
  <h3 id="lib-grid-title">Images</h3>
96
  <input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
97
  </div>
98
+ <div id="lib-grid" class="dda-grid"><p class="dim">Select a node in the tree.</p></div>
99
  </div>
100
  </main>
101
  </div>
 
266
  </div>
267
  </div>
268
 
269
+ <div id="dda-manage-modal" class="dda-modal hidden" role="dialog">
270
+ <div class="dda-modal-panel card dda-manage-panel">
271
+ <div class="card-header">
272
+ <h3>Manage tree</h3>
273
+ <button type="button" class="btn btn-secondary btn-sm" id="dda-manage-close">Close</button>
274
+ </div>
275
+ <div class="dda-manage-section">
276
+ <h4>Add node</h4>
277
+ <div class="location-row">
278
+ <select id="add-child-parent"><option value="">— Root —</option></select>
279
+ <input type="text" id="add-node-name" placeholder="Node name" />
280
+ <select id="add-node-type">
281
+ <option>Zone</option><option>Area</option><option>Year</option>
282
+ <option>Satellite Images</option><option>Drone Survey</option><option>Change Detection</option>
283
+ <option>Folder</option>
284
+ </select>
285
+ <button type="button" class="btn btn-primary btn-sm" id="btn-add-node">Add</button>
286
+ </div>
287
+ </div>
288
+ <div class="dda-manage-section">
289
+ <h4>Edit / move / delete</h4>
290
+ <select id="manage-node-select"></select>
291
+ <div class="location-row">
292
+ <input type="text" id="rename-node-name" placeholder="Rename to…" />
293
+ <button type="button" class="btn btn-secondary btn-sm" id="btn-rename-node">Rename</button>
294
+ </div>
295
+ <div class="location-row">
296
+ <select id="move-parent"><option value="">— Move to root —</option></select>
297
+ <button type="button" class="btn btn-secondary btn-sm" id="btn-move-node">Move</button>
298
+ </div>
299
+ <label class="dda-check"><input type="checkbox" id="delete-files-check" /> Delete files on disk</label>
300
+ <button type="button" class="btn btn-secondary btn-sm" id="btn-delete-node">Delete node</button>
301
+ </div>
302
+ </div>
303
+ </div>
304
+
305
+ <script src="/static/js/dda/app.js?v=14"></script>
306
+ <script src="/static/js/dda/tree.js?v=1"></script>
307
+ <script src="/static/js/dda/library.js?v=8"></script>
308
  <script src="/static/js/dda/result.js?v=6"></script>
309
  <script src="/static/js/dda/compare.js?v=9"></script>
310
  <script src="/static/js/dda/reports.js?v=4"></script>