feat(travel-proxy): 앨범 지역 변경 API + 좌표 메타 API + region_map_extra 리팩토링
- PUT /api/travel/albums/{album}/region: 앨범의 지역 변경 (extra 파일 기반)
- PUT /api/travel/regions/{region_id}: 커스텀 지역 이름/좌표 수정 (Phase 2 준비)
- _load_extra/_save_extra 헬퍼 분리, _removes 키로 원본 오버라이드 지원
- regions API: 모든 커스텀 지역 동적 병합 + Point geometry 지원
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,51 +77,144 @@ def _load_region_map_merged(region_map_path: Path, thumb_root: Path) -> dict:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# 오버라이드 파일 병합
|
# 오버라이드 파일 병합
|
||||||
extra_path = thumb_root / "region_map_extra.json"
|
extra = _load_extra(thumb_root)
|
||||||
if extra_path.exists():
|
if extra:
|
||||||
try:
|
# _removes: 원본에서 제거할 항목 적용
|
||||||
with open(extra_path, "r", encoding="utf-8") as f:
|
removes = extra.get("_removes", {})
|
||||||
extra = json.load(f)
|
for region_id, remove_albums in removes.items():
|
||||||
|
if region_id in region_map:
|
||||||
|
existing = region_map[region_id]
|
||||||
|
if isinstance(existing, list):
|
||||||
|
region_map[region_id] = [a for a in existing if a not in remove_albums]
|
||||||
|
elif isinstance(existing, dict) and "albums" in existing:
|
||||||
|
existing["albums"] = [a for a in existing["albums"] if a not in remove_albums]
|
||||||
|
|
||||||
|
# 나머지 키 병합 (_removes, _regions_meta 제외)
|
||||||
for k, v in extra.items():
|
for k, v in extra.items():
|
||||||
|
if k.startswith("_"):
|
||||||
|
continue
|
||||||
if k in region_map:
|
if k in region_map:
|
||||||
# 기존 지역에 앨범 추가 (중복 방지)
|
|
||||||
existing = region_map[k]
|
existing = region_map[k]
|
||||||
if isinstance(existing, list) and isinstance(v, list):
|
if isinstance(existing, list) and isinstance(v, list):
|
||||||
merged = list(dict.fromkeys(existing + v))
|
merged = list(dict.fromkeys(existing + v))
|
||||||
region_map[k] = merged
|
region_map[k] = merged
|
||||||
else:
|
else:
|
||||||
region_map[k] = v
|
region_map[k] = v
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
logger.warning("Failed to load region_map_extra: %s", e)
|
|
||||||
|
|
||||||
return region_map
|
return region_map
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_path(thumb_root: Path) -> Path:
|
||||||
|
return thumb_root / "region_map_extra.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_extra(thumb_root: Path) -> dict:
|
||||||
|
"""RW 오버라이드 파일 로드."""
|
||||||
|
path = _extra_path(thumb_root)
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_extra(thumb_root: Path, data: dict) -> None:
|
||||||
|
"""RW 오버라이드 파일 저장."""
|
||||||
|
path = _extra_path(thumb_root)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def _save_extra_albums(thumb_root: Path, new_folders: List[str]) -> None:
|
def _save_extra_albums(thumb_root: Path, new_folders: List[str]) -> None:
|
||||||
"""신규 발견 폴더를 RW 영역의 오버라이드 파일에 저장."""
|
"""신규 발견 폴더를 RW 영역의 오버라이드 파일에 저장."""
|
||||||
extra_path = thumb_root / "region_map_extra.json"
|
extra = _load_extra(thumb_root)
|
||||||
extra = {}
|
|
||||||
if extra_path.exists():
|
|
||||||
try:
|
|
||||||
with open(extra_path, "r", encoding="utf-8") as f:
|
|
||||||
extra = json.load(f)
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
extra = {}
|
|
||||||
|
|
||||||
uncategorized = extra.get("미분류", [])
|
uncategorized = extra.get("미분류", [])
|
||||||
|
if isinstance(uncategorized, dict):
|
||||||
|
uncategorized = uncategorized.get("albums", [])
|
||||||
existing = set(uncategorized)
|
existing = set(uncategorized)
|
||||||
added = [f for f in new_folders if f not in existing]
|
added = [f for f in new_folders if f not in existing]
|
||||||
if added:
|
if added:
|
||||||
uncategorized.extend(added)
|
uncategorized.extend(added)
|
||||||
extra["미분류"] = uncategorized
|
extra["미분류"] = uncategorized
|
||||||
try:
|
try:
|
||||||
with open(extra_path, "w", encoding="utf-8") as f:
|
_save_extra(thumb_root, extra)
|
||||||
json.dump(extra, f, ensure_ascii=False, indent=2)
|
|
||||||
logger.info("Saved %d new folders to region_map_extra.json → 미분류", len(added))
|
logger.info("Saved %d new folders to region_map_extra.json → 미분류", len(added))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Cannot write region_map_extra: %s", e)
|
logger.warning("Cannot write region_map_extra: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def move_album_region(
|
||||||
|
thumb_root: Path,
|
||||||
|
region_map_path: Path,
|
||||||
|
album: str,
|
||||||
|
new_region: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
앨범의 지역을 변경. extra 파일에서 이전 지역에서 제거 + 새 지역에 추가.
|
||||||
|
원본 region_map.json의 매핑을 오버라이드 하기 위해
|
||||||
|
extra에 _remove 키로 원본에서 제거할 항목을 기록.
|
||||||
|
|
||||||
|
Returns: {"album": str, "old_region": str, "new_region": str}
|
||||||
|
"""
|
||||||
|
# 현재 병합 맵에서 앨범의 기존 지역 조회
|
||||||
|
merged = _load_region_map_merged(region_map_path, thumb_root)
|
||||||
|
old_region = None
|
||||||
|
for region_id, v in merged.items():
|
||||||
|
album_list = v if isinstance(v, list) else v.get("albums", [])
|
||||||
|
if album in album_list:
|
||||||
|
old_region = region_id
|
||||||
|
break
|
||||||
|
|
||||||
|
extra = _load_extra(thumb_root)
|
||||||
|
|
||||||
|
# 1. 기존 지역에서 제거
|
||||||
|
if old_region:
|
||||||
|
# extra에 해당 지역의 앨범 목록이 있으면 거기서 제거
|
||||||
|
if old_region in extra:
|
||||||
|
old_list = extra[old_region]
|
||||||
|
if isinstance(old_list, dict):
|
||||||
|
old_list = old_list.get("albums", [])
|
||||||
|
if album in old_list:
|
||||||
|
old_list.remove(album)
|
||||||
|
extra[old_region] = old_list
|
||||||
|
|
||||||
|
# 원본 region_map에 있는 경우 _overrides로 제거 기록
|
||||||
|
try:
|
||||||
|
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||||
|
original = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
original = {}
|
||||||
|
|
||||||
|
orig_list = original.get(old_region, [])
|
||||||
|
if isinstance(orig_list, dict):
|
||||||
|
orig_list = orig_list.get("albums", [])
|
||||||
|
if album in orig_list:
|
||||||
|
removes = extra.setdefault("_removes", {})
|
||||||
|
remove_list = removes.setdefault(old_region, [])
|
||||||
|
if album not in remove_list:
|
||||||
|
remove_list.append(album)
|
||||||
|
|
||||||
|
# 2. 새 지역에 추가
|
||||||
|
new_list = extra.get(new_region, [])
|
||||||
|
if isinstance(new_list, dict):
|
||||||
|
new_list = new_list.get("albums", [])
|
||||||
|
if album not in new_list:
|
||||||
|
new_list.append(album)
|
||||||
|
extra[new_region] = new_list
|
||||||
|
|
||||||
|
# _removes에서 새 지역의 해당 앨범은 제거 (되돌리기 대응)
|
||||||
|
if "_removes" in extra and new_region in extra["_removes"]:
|
||||||
|
if album in extra["_removes"][new_region]:
|
||||||
|
extra["_removes"][new_region].remove(album)
|
||||||
|
|
||||||
|
_save_extra(thumb_root, extra)
|
||||||
|
logger.info("Moved album '%s': %s → %s", album, old_region or "없음", new_region)
|
||||||
|
|
||||||
|
return {"album": album, "old_region": old_region or "미분류", "new_region": new_region}
|
||||||
|
|
||||||
|
|
||||||
def sync(
|
def sync(
|
||||||
travel_root: Path,
|
travel_root: Path,
|
||||||
thumb_root: Path,
|
thumb_root: Path,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, mark_thumb_done
|
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, mark_thumb_done
|
||||||
from .indexer import sync, _load_region_map_merged
|
from .indexer import sync, _load_region_map_merged, move_album_region
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -109,6 +109,15 @@ class CoverRequest(BaseModel):
|
|||||||
filename: str
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegionRequest(BaseModel):
|
||||||
|
region: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegionMetaRequest(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
coordinates: list[float] | None = None # [lng, lat]
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -120,18 +129,40 @@ def health():
|
|||||||
@app.get("/api/travel/regions")
|
@app.get("/api/travel/regions")
|
||||||
def regions():
|
def regions():
|
||||||
geojson = load_regions_geojson()
|
geojson = load_regions_geojson()
|
||||||
# 미분류 지역이 region_map에 있으면 GeoJSON에도 동적 추가
|
|
||||||
region_map = load_region_map()
|
region_map = load_region_map()
|
||||||
|
|
||||||
|
# GeoJSON에 이미 있는 지역 ID 수집
|
||||||
existing_ids = {
|
existing_ids = {
|
||||||
f.get("properties", {}).get("id")
|
f.get("properties", {}).get("id")
|
||||||
for f in geojson.get("features", [])
|
for f in geojson.get("features", [])
|
||||||
}
|
}
|
||||||
if "미분류" in region_map and "미분류" not in existing_ids:
|
|
||||||
|
# region_map에 있지만 GeoJSON에 없는 커스텀 지역을 동적 추가
|
||||||
|
# _regions_meta에 좌표가 있으면 Point geometry로, 없으면 null
|
||||||
|
from .indexer import _load_extra
|
||||||
|
extra = _load_extra(THUMB_ROOT)
|
||||||
|
regions_meta = extra.get("_regions_meta", {})
|
||||||
|
|
||||||
|
for region_id in region_map:
|
||||||
|
if region_id in existing_ids:
|
||||||
|
continue
|
||||||
|
meta = regions_meta.get(region_id, {})
|
||||||
|
coords = meta.get("coordinates") # [lng, lat] or None
|
||||||
|
geometry = (
|
||||||
|
{"type": "Point", "coordinates": coords}
|
||||||
|
if coords and len(coords) == 2
|
||||||
|
else None
|
||||||
|
)
|
||||||
geojson.setdefault("features", []).append({
|
geojson.setdefault("features", []).append({
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"properties": {"id": "미분류", "name": "미분류"},
|
"properties": {
|
||||||
"geometry": None,
|
"id": region_id,
|
||||||
|
"name": meta.get("name", region_id),
|
||||||
|
"custom": True,
|
||||||
|
},
|
||||||
|
"geometry": geometry,
|
||||||
})
|
})
|
||||||
|
|
||||||
return geojson
|
return geojson
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +247,32 @@ def albums_list():
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/travel/regions/{region_id}")
|
||||||
|
def update_region_meta(region_id: str, body: RegionMetaRequest):
|
||||||
|
"""커스텀 지역의 이름/좌표 수정 (Phase 2: 지도 핀 표시용)."""
|
||||||
|
from .indexer import _load_extra, _save_extra
|
||||||
|
extra = _load_extra(THUMB_ROOT)
|
||||||
|
meta = extra.setdefault("_regions_meta", {})
|
||||||
|
entry = meta.setdefault(region_id, {})
|
||||||
|
if body.name is not None:
|
||||||
|
entry["name"] = body.name
|
||||||
|
if body.coordinates is not None:
|
||||||
|
if len(body.coordinates) != 2:
|
||||||
|
raise HTTPException(400, "coordinates must be [lng, lat]")
|
||||||
|
entry["coordinates"] = body.coordinates
|
||||||
|
_save_extra(THUMB_ROOT, extra)
|
||||||
|
return {"region_id": region_id, **entry}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/travel/albums/{album}/region")
|
||||||
|
def set_region(album: str, body: RegionRequest):
|
||||||
|
new_region = body.region.strip()
|
||||||
|
if not new_region:
|
||||||
|
raise HTTPException(400, "Region name cannot be empty")
|
||||||
|
result = move_album_region(THUMB_ROOT, REGION_MAP_PATH, album, new_region)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/travel/albums/{album}/cover")
|
@app.put("/api/travel/albums/{album}/cover")
|
||||||
def set_cover(album: str, body: CoverRequest):
|
def set_cover(album: str, body: CoverRequest):
|
||||||
ok = set_album_cover(album, body.filename)
|
ok = set_album_cover(album, body.filename)
|
||||||
|
|||||||
Reference in New Issue
Block a user