coderuday21 Cursor commited on
Commit
4595db8
·
1 Parent(s): c9672b1

Use local library_sources year folders instead of web upload library

Browse files
DEPLOYMENT.md CHANGED
@@ -133,6 +133,7 @@ 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
  | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **2048** = 2 GB on dev) |
137
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
138
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
 
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 **2048** = 2 GB on dev) |
138
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
139
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=26
25
  ENV APP_BUILD=${APP_BUILD}
26
  ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=27
25
  ENV APP_BUILD=${APP_BUILD}
26
  ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
app/dda/bootstrap.py CHANGED
@@ -4,8 +4,9 @@ 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
8
  from .library_routes import router as library_router
 
9
  from .seed import seed_delhi_hierarchy
10
 
11
  logger = logging.getLogger(__name__)
@@ -16,6 +17,7 @@ def init_dda_database():
16
  if not IS_DDA_MODE:
17
  return
18
  ensure_library_dirs()
 
19
  try:
20
  with engine.connect() as conn:
21
  try:
@@ -39,4 +41,5 @@ def setup_dda(app: FastAPI) -> None:
39
  logger.info("APP_MODE=legacy — DDA routes disabled")
40
  return
41
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
42
- logger.info("APP_MODE=dda — DDA library routes enabled")
 
 
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
8
  from .library_routes import router as library_router
9
+ from .local_routes import router as local_router
10
  from .seed import seed_delhi_hierarchy
11
 
12
  logger = logging.getLogger(__name__)
 
17
  if not IS_DDA_MODE:
18
  return
19
  ensure_library_dirs()
20
+ ensure_local_year_folders()
21
  try:
22
  with engine.connect() as conn:
23
  try:
 
41
  logger.info("APP_MODE=legacy — DDA routes disabled")
42
  return
43
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
44
+ app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
45
+ logger.info("APP_MODE=dda — DDA routes enabled (local folder library + legacy upload API)")
app/dda/config.py CHANGED
@@ -6,9 +6,18 @@ from ..database import DATA_DIR
6
  APP_MODE = os.environ.get("APP_MODE", "legacy").strip().lower()
7
  IS_DDA_MODE = APP_MODE == "dda"
8
 
 
 
 
 
 
 
 
 
9
  LIBRARY_DIR = DATA_DIR / "library"
10
  THUMBS_DIR = LIBRARY_DIR / "thumbs"
11
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
 
12
 
13
  # GeoTIFF library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
14
  MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
@@ -20,13 +29,24 @@ ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
20
 
21
 
22
  def ensure_library_dirs() -> None:
23
- for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR):
24
  try:
25
  d.mkdir(parents=True, exist_ok=True)
26
  except OSError:
27
  pass
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
30
  def max_upload_bytes_for_extension(ext: str) -> int:
31
  if ext in (".tif", ".tiff"):
32
  return MAX_GEOTIFF_BYTES
 
6
  APP_MODE = os.environ.get("APP_MODE", "legacy").strip().lower()
7
  IS_DDA_MODE = APP_MODE == "dda"
8
 
9
+ # Project root: change_detection_webapp/
10
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
11
+
12
+ # Local folder library — drop images into year subfolders here (no web upload required)
13
+ LOCAL_LIBRARY_ROOT = Path(
14
+ os.environ.get("LOCAL_LIBRARY_ROOT", str(PROJECT_ROOT / "library_sources"))
15
+ ).resolve()
16
+
17
  LIBRARY_DIR = DATA_DIR / "library"
18
  THUMBS_DIR = LIBRARY_DIR / "thumbs"
19
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
20
+ LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
21
 
22
  # GeoTIFF library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
23
  MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
 
29
 
30
 
31
  def ensure_library_dirs() -> None:
32
+ for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR, LOCAL_THUMB_CACHE, LOCAL_LIBRARY_ROOT):
33
  try:
34
  d.mkdir(parents=True, exist_ok=True)
35
  except OSError:
36
  pass
37
 
38
 
39
+ def ensure_local_year_folders() -> None:
40
+ """Create default year folders under library_sources if missing."""
41
+ from datetime import datetime
42
+ current = datetime.now().year
43
+ for year in (current - 1, current, current + 1):
44
+ try:
45
+ (LOCAL_LIBRARY_ROOT / str(year)).mkdir(parents=True, exist_ok=True)
46
+ except OSError:
47
+ pass
48
+
49
+
50
  def max_upload_bytes_for_extension(ext: str) -> int:
51
  if ext in (".tif", ".tiff"):
52
  return MAX_GEOTIFF_BYTES
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
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
@@ -76,6 +76,8 @@ def dda_config():
76
  "geotiffEnabled": geotiff_io_available(),
77
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
78
  "hierarchyMode": "admin",
 
 
79
  }
80
 
81
 
 
65
  @router.get("/config")
66
  def dda_config():
67
  _require_dda()
68
+ from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, LOCAL_LIBRARY_ROOT
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
 
76
  "geotiffEnabled": geotiff_io_available(),
77
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
78
  "hierarchyMode": "admin",
79
+ "librarySource": "local_folder",
80
+ "localLibraryPath": str(LOCAL_LIBRARY_ROOT),
81
  }
82
 
83
 
app/dda/local_library.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Read satellite/drone images from local year-based folders.
3
+
4
+ Folder layout (under library_sources/):
5
+
6
+ library_sources/
7
+ 2024/
8
+ image_a.tif
9
+ site_1/
10
+ image_b.tif
11
+ 2025/
12
+ image_c.tif
13
+
14
+ Copy or save files directly into the year folder on disk; the app scans on load / refresh.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import logging
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import List, Optional
23
+
24
+ from fastapi import HTTPException
25
+
26
+ from .config import ALLOWED_EXTENSIONS, LOCAL_LIBRARY_ROOT, LOCAL_THUMB_CACHE
27
+ from .geotiff_io import inspect_image, raster_to_preview_png
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class LocalImageEntry:
34
+ path: str # relative posix path, e.g. 2025/aerial.tif
35
+ year: int
36
+ filename: str
37
+ file_size_bytes: int
38
+
39
+
40
+ def _is_year_dir(name: str) -> bool:
41
+ return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
42
+
43
+
44
+ def safe_resolve(relative_path: str) -> Path:
45
+ """Resolve a library-relative path; block path traversal."""
46
+ rel = relative_path.replace("\\", "/").lstrip("/")
47
+ if not rel or ".." in rel.split("/"):
48
+ raise HTTPException(status_code=400, detail="Invalid image path")
49
+ root = LOCAL_LIBRARY_ROOT.resolve()
50
+ full = (root / rel).resolve()
51
+ try:
52
+ full.relative_to(root)
53
+ except ValueError:
54
+ raise HTTPException(status_code=400, detail="Invalid image path")
55
+ if not full.is_file():
56
+ raise HTTPException(status_code=404, detail="Image file not found")
57
+ if full.suffix.lower() not in ALLOWED_EXTENSIONS:
58
+ raise HTTPException(status_code=400, detail="Unsupported image format")
59
+ return full
60
+
61
+
62
+ def scan_years() -> List[dict]:
63
+ """List year folders and image counts."""
64
+ ensure_root()
65
+ years = []
66
+ if not LOCAL_LIBRARY_ROOT.exists():
67
+ return years
68
+ for entry in sorted(LOCAL_LIBRARY_ROOT.iterdir()):
69
+ if not entry.is_dir() or not _is_year_dir(entry.name):
70
+ continue
71
+ count = sum(
72
+ 1
73
+ for p in entry.rglob("*")
74
+ if p.is_file() and p.suffix.lower() in ALLOWED_EXTENSIONS
75
+ )
76
+ years.append({"year": int(entry.name), "imageCount": count})
77
+ return years
78
+
79
+
80
+ def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
81
+ """Scan library_sources for images, optionally filtered by year and filename."""
82
+ ensure_root()
83
+ results: List[LocalImageEntry] = []
84
+ root = LOCAL_LIBRARY_ROOT
85
+ if not root.exists():
86
+ return results
87
+
88
+ q = (query or "").strip().lower()
89
+ year_dirs: List[Path]
90
+ if year is not None:
91
+ ydir = root / str(year)
92
+ year_dirs = [ydir] if ydir.is_dir() else []
93
+ else:
94
+ year_dirs = [d for d in sorted(root.iterdir()) if d.is_dir() and _is_year_dir(d.name)]
95
+
96
+ for ydir in year_dirs:
97
+ y = int(ydir.name)
98
+ for path in sorted(ydir.rglob("*")):
99
+ if not path.is_file() or path.suffix.lower() not in ALLOWED_EXTENSIONS:
100
+ continue
101
+ rel = path.relative_to(root).as_posix()
102
+ if q and q not in rel.lower():
103
+ continue
104
+ try:
105
+ size = path.stat().st_size
106
+ except OSError:
107
+ continue
108
+ results.append(
109
+ LocalImageEntry(
110
+ path=rel,
111
+ year=y,
112
+ filename=path.name,
113
+ file_size_bytes=size,
114
+ )
115
+ )
116
+ return results
117
+
118
+
119
+ def entry_to_dict(entry: LocalImageEntry, include_meta: bool = False) -> dict:
120
+ out = {
121
+ "path": entry.path,
122
+ "year": entry.year,
123
+ "filename": entry.filename,
124
+ "fileSizeBytes": entry.file_size_bytes,
125
+ "thumbUrl": f"/api/dda/local/thumb?path={entry.path}",
126
+ "source": "local_folder",
127
+ }
128
+ if include_meta:
129
+ try:
130
+ full = safe_resolve(entry.path)
131
+ meta = inspect_image(full)
132
+ out.update({
133
+ "width": meta.width,
134
+ "height": meta.height,
135
+ "hasGeoref": meta.has_georef,
136
+ "format": meta.format,
137
+ })
138
+ except Exception as exc:
139
+ logger.warning("Metadata read failed for %s: %s", entry.path, exc)
140
+ return out
141
+
142
+
143
+ def thumb_cache_path(relative_path: str) -> Path:
144
+ key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
145
+ return LOCAL_THUMB_CACHE / f"{key}.png"
146
+
147
+
148
+ def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
149
+ full = safe_resolve(relative_path)
150
+ cache = thumb_cache_path(relative_path)
151
+ if cache.exists():
152
+ return cache
153
+ cache.parent.mkdir(parents=True, exist_ok=True)
154
+ raster_to_preview_png(full, cache, max_side=max_side)
155
+ return cache
156
+
157
+
158
+ def ensure_root() -> None:
159
+ LOCAL_LIBRARY_ROOT.mkdir(parents=True, exist_ok=True)
160
+ LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
app/dda/local_routes.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API for reading images from local library_sources/ year folders."""
2
+ from typing import Optional
3
+
4
+ from fastapi import APIRouter, HTTPException, Query
5
+ from fastapi.responses import FileResponse
6
+
7
+ from .config import IS_DDA_MODE, LOCAL_LIBRARY_ROOT, geotiff_io_available
8
+ from .local_library import (
9
+ entry_to_dict,
10
+ get_or_build_thumb,
11
+ safe_resolve,
12
+ scan_images,
13
+ scan_years,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def _require_dda():
20
+ if not IS_DDA_MODE:
21
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
22
+
23
+
24
+ @router.get("/local/config")
25
+ def local_library_config():
26
+ _require_dda()
27
+ return {
28
+ "source": "local_folder",
29
+ "rootPath": str(LOCAL_LIBRARY_ROOT),
30
+ "instructions": (
31
+ "Copy .tif / .tiff images into year folders under library_sources/ "
32
+ "(e.g. library_sources/2025/my_image.tif), then click Refresh."
33
+ ),
34
+ "geotiffEnabled": geotiff_io_available(),
35
+ }
36
+
37
+
38
+ @router.get("/local/years")
39
+ def local_years():
40
+ _require_dda()
41
+ return {"years": scan_years(), "rootPath": str(LOCAL_LIBRARY_ROOT)}
42
+
43
+
44
+ @router.get("/local/images")
45
+ def local_images(
46
+ year: Optional[int] = Query(None),
47
+ q: Optional[str] = Query(None),
48
+ ):
49
+ _require_dda()
50
+ entries = scan_images(year=year, query=q)
51
+ return [entry_to_dict(e) for e in entries]
52
+
53
+
54
+ @router.get("/local/images/detail")
55
+ def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
56
+ _require_dda()
57
+ entries = scan_images()
58
+ match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
59
+ if not match:
60
+ safe_resolve(path) # raises 404 if missing
61
+ entries = scan_images()
62
+ match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
63
+ if not match:
64
+ raise HTTPException(status_code=404, detail="Image not found in library scan")
65
+ return entry_to_dict(match, include_meta=True)
66
+
67
+
68
+ @router.get("/local/thumb")
69
+ def local_thumb(path: str = Query(...)):
70
+ _require_dda()
71
+ try:
72
+ thumb = get_or_build_thumb(path)
73
+ return FileResponse(thumb, media_type="image/png")
74
+ except HTTPException:
75
+ raise
76
+ except Exception as exc:
77
+ raise HTTPException(status_code=500, detail=f"Thumbnail failed: {exc}") from exc
78
+
79
+
80
+ @router.post("/local/rescan")
81
+ def local_rescan():
82
+ _require_dda()
83
+ years = scan_years()
84
+ total = sum(y["imageCount"] for y in years)
85
+ return {"ok": True, "years": years, "totalImages": total, "rootPath": str(LOCAL_LIBRARY_ROOT)}
library_sources/2024/.gitkeep ADDED
File without changes
library_sources/2025/.gitkeep ADDED
File without changes
library_sources/2026/.gitkeep ADDED
File without changes
library_sources/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local Image Library
2
+
3
+ Place satellite / drone images here **by year**. The app reads directly from this folder — no web upload needed.
4
+
5
+ ## Folder structure
6
+
7
+ ```
8
+ library_sources/
9
+ 2024/
10
+ your_image.tif
11
+ optional_subfolder/
12
+ another_image.tif
13
+ 2025/
14
+ site_a.tif
15
+ 2026/
16
+ ```
17
+
18
+ ## Supported formats
19
+
20
+ - `.tif` / `.tiff` (GeoTIFF — preferred)
21
+ - `.png`, `.jpg`, `.jpeg` (for testing)
22
+
23
+ ## How to use
24
+
25
+ 1. Copy your images into the correct **year** folder (e.g. `library_sources/2025/`).
26
+ 2. Run the app (`python run.py` or open the dev Space).
27
+ 3. Open **Image Library** → click **Refresh** if you added files while the app was running.
28
+ 4. Select a year in the sidebar to view images.
29
+
30
+ ## Large files
31
+
32
+ GeoTIFF files up to **2 GB** are supported when read from disk. Copy files via Explorer/Finder — much faster than browser upload.
33
+
34
+ ## Custom location
35
+
36
+ Set environment variable `LOCAL_LIBRARY_ROOT` to use a different folder path.
static/css/dda.css CHANGED
@@ -65,7 +65,53 @@
65
  font-size: 0.88rem;
66
  }
67
  .dda-tree { font-size: 0.88rem; }
68
- .dda-tree-zone { margin-bottom: 0.5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  .dda-tree-zone > button {
70
  width: 100%;
71
  text-align: left;
 
65
  font-size: 0.88rem;
66
  }
67
  .dda-tree { font-size: 0.88rem; }
68
+ .dda-tree-year {
69
+ display: block;
70
+ width: 100%;
71
+ text-align: left;
72
+ padding: 0.4rem 0.55rem;
73
+ margin-bottom: 0.2rem;
74
+ border: none;
75
+ background: transparent;
76
+ color: var(--text-muted);
77
+ cursor: pointer;
78
+ border-radius: 6px;
79
+ font-size: 0.9rem;
80
+ font-weight: 500;
81
+ }
82
+ .dda-tree-year:hover, .dda-tree-year.active {
83
+ background: var(--bg-hover);
84
+ color: var(--grad-start);
85
+ }
86
+
87
+ .dda-folder-path {
88
+ font-size: 0.78rem;
89
+ margin-bottom: 0.6rem;
90
+ word-break: break-all;
91
+ }
92
+ .dda-path-code {
93
+ background: var(--bg);
94
+ border: 1px solid var(--border);
95
+ border-radius: 6px;
96
+ padding: 0.6rem 0.75rem;
97
+ font-size: 0.8rem;
98
+ overflow-x: auto;
99
+ margin: 0.5rem 0 0;
100
+ }
101
+ .dda-instructions .sub code {
102
+ font-size: 0.85em;
103
+ background: var(--bg);
104
+ padding: 0.1rem 0.35rem;
105
+ border-radius: 4px;
106
+ }
107
+
108
+ .card-header {
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ gap: 0.5rem;
113
+ flex-wrap: wrap;
114
+ }
115
  .dda-tree-zone > button {
116
  width: 100%;
117
  text-align: left;
static/js/dda/app.js CHANGED
@@ -30,21 +30,23 @@ function showDdaSuccess(msg) {
30
  setTimeout(() => el.classList.add('hidden'), 4000);
31
  }
32
 
 
 
 
 
 
 
33
  let ddaConfig = null;
34
- let ddaHierarchy = null;
35
- let selectedVillageId = null;
36
- let selectedZoneId = null;
37
 
38
  window.ddaState = {
39
- get hierarchy() { return ddaHierarchy; },
40
  get config() { return ddaConfig; },
41
- get selectedVillageId() { return selectedVillageId; },
42
- get selectedZoneId() { return selectedZoneId; },
43
- setSelection(zoneId, villageId) {
44
- selectedZoneId = zoneId;
45
- selectedVillageId = villageId;
46
- },
47
  refreshImages: () => loadLibraryImages(),
 
48
  };
49
 
50
  document.querySelectorAll('.dda-tab').forEach((btn) => {
@@ -57,51 +59,68 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
57
  });
58
  });
59
 
 
 
 
 
 
 
 
 
60
  async function initDda() {
61
  hideDdaError();
62
  try {
63
  ddaConfig = await ddaApi('GET', '/api/dda/config');
 
 
64
  const hint = document.getElementById('lib-config-hint');
65
  if (hint) {
66
- const gb = ddaConfig.maxUploadGb;
67
- const label = gb >= 1 ? `${gb} GB GeoTIFF` : `${ddaConfig.maxGeotiffMb || ddaConfig.maxUploadMb} MB`;
68
- hint.textContent = `Max ${label} · GeoTIFF engine: ${ddaConfig.geotiffEnabled ? 'yes' : 'limited'}`;
69
  }
70
- ddaHierarchy = await ddaApi('GET', '/api/dda/hierarchy');
71
- if (typeof renderHierarchy === 'function') renderHierarchy(ddaHierarchy);
72
- if (typeof populateUploadSelects === 'function') populateUploadSelects(ddaHierarchy);
 
 
 
 
 
73
  await loadLibraryImages();
74
  } catch (err) {
75
- showDdaError(err.message || 'Failed to load DDA configuration');
76
  }
77
  }
78
 
79
  async function loadLibraryImages() {
80
  const grid = document.getElementById('lib-grid');
 
81
  if (!grid) return;
82
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
83
  const params = new URLSearchParams();
84
- if (selectedVillageId) params.set('village_id', String(selectedVillageId));
85
- else if (selectedZoneId) params.set('zone_id', String(selectedZoneId));
86
  if (q) params.set('q', q);
 
 
 
87
  try {
88
- const items = await ddaApi('GET', '/api/dda/images?' + params.toString());
89
  if (!items.length) {
90
- grid.innerHTML = '<p class="dim">No images yet. Upload a GeoTIFF or image above.</p>';
91
  return;
92
  }
93
  grid.innerHTML = items.map((img) => `
94
- <div class="dda-card-img" draggable="true" data-image-id="${img.id}" title="${img.originalFilename}">
95
  ${img.thumbUrl ? `<img src="${img.thumbUrl}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
96
  <div class="meta">
97
- <strong>${img.year || '—'}</strong><br/>
98
- ${img.captureDate || ''}<br/>
99
- ${img.villageName || img.zoneName || ''}
100
  </div>
101
  </div>`).join('');
102
  grid.querySelectorAll('.dda-card-img').forEach((card) => {
103
  card.addEventListener('dragstart', (e) => {
104
- e.dataTransfer.setData('text/plain', card.dataset.imageId);
 
105
  });
106
  });
107
  } catch (err) {
@@ -114,4 +133,17 @@ document.getElementById('lib-filter')?.addEventListener('input', () => {
114
  window._libFilterTimer = setTimeout(loadLibraryImages, 300);
115
  });
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  initDda();
 
30
  setTimeout(() => el.classList.add('hidden'), 4000);
31
  }
32
 
33
+ function formatBytes(n) {
34
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
35
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
36
+ return (n / 1024).toFixed(0) + ' KB';
37
+ }
38
+
39
  let ddaConfig = null;
40
+ let localYears = [];
41
+ let selectedYear = null;
 
42
 
43
  window.ddaState = {
 
44
  get config() { return ddaConfig; },
45
+ get years() { return localYears; },
46
+ get selectedYear() { return selectedYear; },
47
+ setYear(year) { selectedYear = year; },
 
 
 
48
  refreshImages: () => loadLibraryImages(),
49
+ rescan: () => rescanLibrary(),
50
  };
51
 
52
  document.querySelectorAll('.dda-tab').forEach((btn) => {
 
59
  });
60
  });
61
 
62
+ async function rescanLibrary() {
63
+ const data = await ddaApi('POST', '/api/dda/local/rescan');
64
+ localYears = data.years || [];
65
+ if (typeof renderYearTree === 'function') renderYearTree(localYears);
66
+ await loadLibraryImages();
67
+ return data;
68
+ }
69
+
70
  async function initDda() {
71
  hideDdaError();
72
  try {
73
  ddaConfig = await ddaApi('GET', '/api/dda/config');
74
+ const localCfg = await ddaApi('GET', '/api/dda/local/config');
75
+
76
  const hint = document.getElementById('lib-config-hint');
77
  if (hint) {
78
+ hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
 
 
79
  }
80
+ const pathEl = document.getElementById('lib-path-display');
81
+ if (pathEl) pathEl.textContent = localCfg.rootPath || ddaConfig.localLibraryPath || '';
82
+ const folderPath = document.getElementById('lib-folder-path');
83
+ if (folderPath) folderPath.textContent = 'Folder: library_sources/';
84
+
85
+ const yearsData = await ddaApi('GET', '/api/dda/local/years');
86
+ localYears = yearsData.years || [];
87
+ if (typeof renderYearTree === 'function') renderYearTree(localYears);
88
  await loadLibraryImages();
89
  } catch (err) {
90
+ showDdaError(err.message || 'Failed to load library');
91
  }
92
  }
93
 
94
  async function loadLibraryImages() {
95
  const grid = document.getElementById('lib-grid');
96
+ const title = document.getElementById('lib-grid-title');
97
  if (!grid) return;
98
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
99
  const params = new URLSearchParams();
100
+ if (selectedYear) params.set('year', String(selectedYear));
 
101
  if (q) params.set('q', q);
102
+ if (title) {
103
+ title.textContent = selectedYear ? `Images — ${selectedYear}` : 'Images — all years';
104
+ }
105
  try {
106
+ const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
107
  if (!items.length) {
108
+ grid.innerHTML = `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
109
  return;
110
  }
111
  grid.innerHTML = items.map((img) => `
112
+ <div class="dda-card-img" draggable="true" data-image-path="${img.path}" title="${img.filename}">
113
  ${img.thumbUrl ? `<img src="${img.thumbUrl}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
114
  <div class="meta">
115
+ <strong>${img.year}</strong><br/>
116
+ ${img.filename}<br/>
117
+ <span class="dim">${formatBytes(img.fileSizeBytes)}</span>
118
  </div>
119
  </div>`).join('');
120
  grid.querySelectorAll('.dda-card-img').forEach((card) => {
121
  card.addEventListener('dragstart', (e) => {
122
+ e.dataTransfer.setData('application/x-dda-image-path', card.dataset.imagePath);
123
+ e.dataTransfer.setData('text/plain', card.dataset.imagePath);
124
  });
125
  });
126
  } catch (err) {
 
133
  window._libFilterTimer = setTimeout(loadLibraryImages, 300);
134
  });
135
 
136
+ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () => {
137
+ const btn = document.getElementById('btn-refresh-lib');
138
+ btn.disabled = true;
139
+ try {
140
+ const data = await rescanLibrary();
141
+ showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s) found.`);
142
+ } catch (err) {
143
+ showDdaError(err.message);
144
+ } finally {
145
+ btn.disabled = false;
146
+ }
147
+ });
148
+
149
  initDda();
static/js/dda/library.js CHANGED
@@ -1,169 +1,33 @@
1
- function renderHierarchy(data) {
2
  const tree = document.getElementById('lib-tree');
3
- if (!tree || !data?.zones) return;
4
  const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
5
 
6
- tree.innerHTML = data.zones
7
- .filter((z) => !filter || z.name.toLowerCase().includes(filter) ||
8
- z.villages.some((v) => v.name.toLowerCase().includes(filter)))
9
- .map((zone) => `
10
- <div class="dda-tree-zone" data-zone-id="${zone.id}">
11
- <button type="button" class="dda-zone-toggle">${zone.name}</button>
12
- <div class="dda-tree-villages">
13
- ${zone.villages
14
- .filter((v) => !filter || v.name.toLowerCase().includes(filter) || zone.name.toLowerCase().includes(filter))
15
- .map((v) => `
16
- <button type="button" class="dda-tree-village" data-zone-id="${zone.id}" data-village-id="${v.id}">
17
- ${v.name}${v.imageCount ? ` (${v.imageCount})` : ''}
18
- </button>`).join('')}
19
- </div>
20
- </div>`).join('');
21
 
22
- tree.querySelectorAll('.dda-zone-toggle').forEach((btn) => {
23
- btn.addEventListener('click', () => {
24
- const zoneEl = btn.closest('.dda-tree-zone');
25
- zoneEl?.classList.toggle('open');
26
- const zoneId = parseInt(zoneEl?.dataset.zoneId, 10);
27
- window.ddaState.setSelection(zoneId, null);
28
- window.ddaState.refreshImages();
29
- });
30
- });
31
 
32
- tree.querySelectorAll('.dda-tree-village').forEach((btn) => {
 
 
33
  btn.addEventListener('click', () => {
34
- tree.querySelectorAll('.dda-tree-village').forEach((b) => b.classList.remove('active'));
35
  btn.classList.add('active');
36
- btn.closest('.dda-tree-zone')?.classList.add('open');
37
- window.ddaState.setSelection(
38
- parseInt(btn.dataset.zoneId, 10),
39
- parseInt(btn.dataset.villageId, 10),
40
- );
41
  window.ddaState.refreshImages();
42
  });
43
  });
44
  }
45
 
46
  document.getElementById('lib-tree-search')?.addEventListener('input', () => {
47
- if (window.ddaState?.hierarchy) renderHierarchy(window.ddaState.hierarchy);
48
  });
49
-
50
- function populateUploadSelects(data) {
51
- const zoneSel = document.getElementById('up-zone');
52
- const villageSel = document.getElementById('up-village');
53
- if (!zoneSel || !villageSel || !data?.zones) return;
54
-
55
- zoneSel.innerHTML = '<option value="">— Select —</option>';
56
- data.zones.forEach((z) => {
57
- const opt = document.createElement('option');
58
- opt.value = z.id;
59
- opt.textContent = z.name;
60
- zoneSel.appendChild(opt);
61
- });
62
-
63
- if (!zoneSel.dataset.bound) {
64
- zoneSel.dataset.bound = '1';
65
- zoneSel.addEventListener('change', () => {
66
- const hierarchy = window.ddaState?.hierarchy;
67
- const zid = parseInt(zoneSel.value, 10);
68
- villageSel.innerHTML = '<option value="">— Select —</option>';
69
- villageSel.disabled = !zid;
70
- if (!zid || !hierarchy) return;
71
- const zone = hierarchy.zones.find((z) => z.id === zid);
72
- (zone?.villages || []).forEach((v) => {
73
- const opt = document.createElement('option');
74
- opt.value = v.id;
75
- opt.textContent = v.name;
76
- villageSel.appendChild(opt);
77
- });
78
- });
79
- }
80
- }
81
-
82
- function formatBytes(n) {
83
- if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
84
- if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
85
- return (n / 1024).toFixed(0) + ' KB';
86
- }
87
-
88
- function uploadWithProgress(url, formData, onProgress) {
89
- return new Promise((resolve, reject) => {
90
- const xhr = new XMLHttpRequest();
91
- xhr.open('POST', url);
92
- xhr.withCredentials = true;
93
- xhr.upload.addEventListener('progress', (e) => {
94
- if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
95
- });
96
- xhr.addEventListener('load', () => {
97
- let data = null;
98
- try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (_) {}
99
- if (xhr.status >= 200 && xhr.status < 300) resolve(data);
100
- else reject(new Error(data?.detail || xhr.statusText || 'Upload failed'));
101
- });
102
- xhr.addEventListener('error', () => reject(new Error('Network error during upload')));
103
- xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
104
- xhr.send(formData);
105
- });
106
- }
107
-
108
- document.getElementById('form-upload')?.addEventListener('submit', async (e) => {
109
- e.preventDefault();
110
- hideDdaError?.();
111
- const fileInput = document.getElementById('up-file');
112
- const file = fileInput?.files?.[0];
113
- if (!file) {
114
- showDdaError?.('Select a file to upload.');
115
- return;
116
- }
117
-
118
- const form = new FormData();
119
- form.append('file', file);
120
- form.append('zone_id', document.getElementById('up-zone').value);
121
- form.append('village_id', document.getElementById('up-village').value);
122
- form.append('area_name', document.getElementById('up-area').value || '');
123
- form.append('year', document.getElementById('up-year').value);
124
- form.append('capture_date', document.getElementById('up-date').value);
125
- form.append('source', document.getElementById('up-source').value);
126
- form.append('manual_bounds_json', document.getElementById('up-manual-bounds').value || '');
127
-
128
- const btn = document.getElementById('btn-upload');
129
- const progWrap = document.getElementById('upload-progress');
130
- const progFill = document.getElementById('upload-progress-fill');
131
- const progLabel = document.getElementById('upload-progress-label');
132
-
133
- btn.disabled = true;
134
- btn.textContent = 'Uploading…';
135
- progWrap?.classList.remove('hidden');
136
- if (progFill) progFill.style.width = '0%';
137
- if (progLabel) progLabel.textContent = `Uploading ${file.name} (${formatBytes(file.size)})… 0%`;
138
-
139
- try {
140
- const data = await uploadWithProgress('/api/dda/images/upload', form, (loaded, total) => {
141
- const pct = total ? Math.round((loaded / total) * 100) : 0;
142
- if (progFill) progFill.style.width = pct + '%';
143
- if (progLabel) progLabel.textContent = `Uploading… ${pct}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
144
- });
145
- if (progFill) progFill.style.width = '100%';
146
- showDdaSuccess?.(data?.status === 'success' ? 'Image uploaded to library.' : 'Upload complete.');
147
- document.getElementById('form-upload').reset();
148
- document.getElementById('up-year').value = '2025';
149
- fileInput.value = '';
150
- const dateInput = document.getElementById('up-date');
151
- if (dateInput) dateInput.value = new Date().toISOString().slice(0, 10);
152
- window.ddaState.hierarchy = await ddaApi('GET', '/api/dda/hierarchy');
153
- renderHierarchy(window.ddaState.hierarchy);
154
- populateUploadSelects(window.ddaState.hierarchy);
155
- await window.ddaState.refreshImages();
156
- } catch (err) {
157
- showDdaError?.(err.message || 'Upload failed');
158
- } finally {
159
- btn.disabled = false;
160
- btn.textContent = 'Upload to Library';
161
- setTimeout(() => progWrap?.classList.add('hidden'), 1500);
162
- }
163
- });
164
-
165
- // Default capture date = today
166
- const dateInput = document.getElementById('up-date');
167
- if (dateInput && !dateInput.value) {
168
- dateInput.value = new Date().toISOString().slice(0, 10);
169
- }
 
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
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=2" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
@@ -25,70 +25,35 @@
25
  <div id="dda-error" class="alert alert-error hidden"></div>
26
  <div id="dda-success" class="alert alert-success hidden"></div>
27
 
28
- <!-- Tab: Image Library (FR-01, FR-02) -->
29
  <section id="tab-library" class="dda-panel active" role="tabpanel">
30
  <div class="dda-layout">
31
  <aside class="dda-sidebar card">
32
- <div class="card-header"><h3>Hierarchy</h3></div>
33
- <input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter zones…" />
 
 
 
 
34
  <div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
35
  </aside>
36
  <main class="dda-main">
37
- <div class="card">
38
  <div class="card-header">
39
- <h3>Upload Image</h3>
40
  <span class="dim" id="lib-config-hint"></span>
41
  </div>
42
- <form id="form-upload" class="dda-upload-form">
43
- <div class="location-row">
44
- <div class="form-group">
45
- <label for="up-zone">Zone</label>
46
- <select id="up-zone" required><option value="">— Select —</option></select>
47
- </div>
48
- <div class="form-group">
49
- <label for="up-village">Village / Area</label>
50
- <select id="up-village" required disabled><option value="">— Select zone —</option></select>
51
- </div>
52
- <div class="form-group">
53
- <label for="up-area">Area name</label>
54
- <input type="text" id="up-area" placeholder="Optional sub-area" />
55
- </div>
56
- <div class="form-group">
57
- <label for="up-year">Year</label>
58
- <input type="number" id="up-year" required min="2000" max="2100" value="2025" />
59
- </div>
60
- </div>
61
- <div class="location-row">
62
- <div class="form-group">
63
- <label for="up-date">Capture date</label>
64
- <input type="date" id="up-date" required />
65
- </div>
66
- <div class="form-group">
67
- <label for="up-source">Source</label>
68
- <select id="up-source"><option value="satellite">Satellite</option><option value="drone">Drone</option></select>
69
- </div>
70
- <div class="form-group">
71
- <label for="up-file">Image file (.tif up to 2 GB)</label>
72
- <input type="file" id="up-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
73
- </div>
74
- </div>
75
- <div id="upload-progress" class="dda-upload-progress hidden">
76
- <div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
77
- <span id="upload-progress-label" class="dim">Uploading… 0%</span>
78
- </div>
79
- <div class="form-group">
80
- <label for="up-manual-bounds">Manual bounds (WGS84)</label>
81
- <input type="text" id="up-manual-bounds" placeholder="west,south,east,north — required if GeoTIFF has no georef" />
82
- </div>
83
- <button type="submit" class="btn btn-primary" id="btn-upload">Upload to Library</button>
84
- </form>
85
  </div>
86
  <div class="card">
87
  <div class="card-header">
88
- <h3>Library Images</h3>
89
- <input type="search" id="lib-filter" class="dda-search" placeholder="Search…" />
90
  </div>
91
- <div id="lib-grid" class="dda-grid"><p class="dim">Select a village or upload an image.</p></div>
92
  </div>
93
  </main>
94
  </div>
@@ -123,7 +88,7 @@
123
  </section>
124
  </div>
125
 
126
- <script src="/static/js/dda/app.js?v=2"></script>
127
- <script src="/static/js/dda/library.js?v=2"></script>
128
  </body>
129
  </html>
 
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=3" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
 
25
  <div id="dda-error" class="alert alert-error hidden"></div>
26
  <div id="dda-success" class="alert alert-success hidden"></div>
27
 
28
+ <!-- Tab: Image Library reads from library_sources/ year folders -->
29
  <section id="tab-library" class="dda-panel active" role="tabpanel">
30
  <div class="dda-layout">
31
  <aside class="dda-sidebar card">
32
+ <div class="card-header">
33
+ <h3>Years</h3>
34
+ <button type="button" class="btn btn-secondary btn-sm" id="btn-refresh-lib" title="Rescan folder">Refresh</button>
35
+ </div>
36
+ <div id="lib-folder-path" class="dda-folder-path dim"></div>
37
+ <input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter years…" />
38
  <div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
39
  </aside>
40
  <main class="dda-main">
41
+ <div class="card dda-instructions">
42
  <div class="card-header">
43
+ <h3>Local folder library</h3>
44
  <span class="dim" id="lib-config-hint"></span>
45
  </div>
46
+ <p class="sub" id="lib-instructions">
47
+ Copy <code>.tif</code> images into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh</strong>.
48
+ </p>
49
+ <pre id="lib-path-display" class="dda-path-code"></pre>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  </div>
51
  <div class="card">
52
  <div class="card-header">
53
+ <h3 id="lib-grid-title">Images</h3>
54
+ <input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
55
  </div>
56
+ <div id="lib-grid" class="dda-grid"><p class="dim">Select a year or add images to library_sources/.</p></div>
57
  </div>
58
  </main>
59
  </div>
 
88
  </section>
89
  </div>
90
 
91
+ <script src="/static/js/dda/app.js?v=3"></script>
92
+ <script src="/static/js/dda/library.js?v=3"></script>
93
  </body>
94
  </html>