diff --git a/chatmap-api/main.py b/chatmap-api/main.py
index 9d6f726..41e0049 100644
--- a/chatmap-api/main.py
+++ b/chatmap-api/main.py
@@ -40,7 +40,8 @@
from sqlalchemy import func, select
from geoalchemy2.shape import to_shape
from hotosm_auth_fastapi import setup_auth, CurrentUser, CurrentUserOptional
-
+import csv
+from datetime import datetime
# Logs
logging.basicConfig(
@@ -771,16 +772,40 @@ async def me(user: CurrentUser):
'username': user.username,
}
-# Export
+
+# Export media
+async def export_media(features, zf):
+ async with httpx.AsyncClient() as client:
+ for feature in features:
+ fileUrl = feature['properties']['file']
+ if not fileUrl:
+ continue
+ try:
+ response = await client.get(fileUrl)
+ response.raise_for_status() # Raise for 4xx/5xx
+ content = response.content
+ # Get filename
+ filename = fileUrl.replace("media?filename=", "/")
+ filename = filename[filename.rfind("/") + 1:]
+ # Update file properties
+ feature['properties']['file_url'] = feature['properties']['file']
+ feature['properties']['file'] = filename
+ # Add file to zip
+ zf.writestr(filename, content)
+ except httpx.HTTPError as e:
+ logger.error(f"Failed to download: {str(e)}")
+
+
+# Export map as Zip (GeoJSON + media)
@api_router.get("/export/{map_id}", response_model=None)
-async def get_public_map(
+async def export(
map_id: str,
request: Request,
user: CurrentUserOptional,
db: Session = Depends(get_db_session),
):
"""
- Export map for download (Zip) for a given map ID.
+ Export map for download (Zip w/ GeoJSON and media) for a given map ID.
Args:
map_id (str): Unique identifier of the map.
@@ -797,33 +822,104 @@ async def get_public_map(
if map_obj and owner:
map = map_response(db, map_obj, owner)
memory_file = io.BytesIO()
-
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
- async with httpx.AsyncClient() as client:
- # Get map files
- for feature in map['features']:
- fileUrl = feature['properties']['file']
- if not fileUrl:
- continue
- try:
- response = await client.get(fileUrl)
- response.raise_for_status() # Raise for 4xx/5xx
- content = response.content
- # Get filename
- filename = fileUrl.replace("media?filename=", "/")
- filename = filename[filename.rfind("/") + 1:]
- # Update file properties
- feature['properties']['file_url'] = feature['properties']['file']
- feature['properties']['file'] = filename
- # Add file to zip
- zf.writestr(filename, content)
- except httpx.HTTPError as e:
- logger.error(f"Failed to download: {str(e)}")
+ # Get map files
+ await export_media(map['features'], zf)
# Replace 'id' by '_chatmapId'
map['_chatmapId'] = map['id']
del map['id']
zf.writestr(f"chatmap_{map_id}.geojson", json.dumps(map, default=str))
+ memory_file.seek(0)
+ return StreamingResponse(
+ memory_file,
+ media_type="application/zip",
+ headers={"Content-Disposition": f"attachment; filename=chatmap_{map_id}.zip"}
+ )
+ else:
+ # Map is not public – reject the request
+ raise HTTPException(
+ status_code=401,
+ detail="Unauthorized: the requested map is not publicly shared."
+ )
+
+def map_to_csv(features):
+ """
+ Convert a map dictionary to a CSV.
+
+ Columns: file, lon, lat, capture_time (RFC 3339)
+ """
+ rows = []
+ for feature in features:
+ props = feature.get("properties", {})
+ geometry = feature.get("geometry", {})
+ coordinates = geometry.get("coordinates", [])
+
+ # Extract fields
+ file_name = props.get("file", "")
+ lon = coordinates[0] if len(coordinates) >= 2 else ""
+ lat = coordinates[1] if len(coordinates) >= 2 else ""
+
+ # Time parsing
+ time_input = props.get("time", "")
+ capture_time = None
+ if time_input:
+ try:
+ capture_time = time_input.strftime("%Y-%m-%dT%H:%M:%SZ")
+ except ValueError:
+ capture_time = None
+
+ if file_name and lon and lat and capture_time:
+ rows.append({
+ "file": file_name,
+ "lon": lon,
+ "lat": lat,
+ "capture_time": capture_time
+ })
+
+ # Write output
+ output_buffer = io.StringIO()
+ fieldnames = ["file", "lon", "lat", "capture_time"]
+ writer = csv.DictWriter(output_buffer, fieldnames=fieldnames)
+ writer.writeheader()
+ writer.writerows(rows)
+ csv_string = output_buffer.getvalue()
+ output_buffer.close()
+
+ return csv_string
+
+# Export map as Zip (CSV + media)
+@api_router.get("/export/csv/{map_id}", response_model=None)
+async def export(
+ map_id: str,
+ request: Request,
+ user: CurrentUserOptional,
+ db: Session = Depends(get_db_session),
+):
+ """
+ Export map for download (Zip w/ CSV and media) for a given map ID.
+
+ Args:
+ map_id (str): Unique identifier of the map.
+ request (Request): FastAPI request object.
+ db (Session): Database session.
+
+ Returns:
+ StreamingResponse
+ """
+ # Get map
+ map_obj: Map = db.get(Map, map_id)
+ owner = (user and map_obj.owner_id == user.id) or False
+ if map_obj and owner:
+ map = map_response(db, map_obj, owner)
+ memory_file = io.BytesIO()
+ with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
+ # Get map files
+ await export_media(map['features'], zf)
+ # Replace 'id' by '_chatmapId'
+ map['_chatmapId'] = map['id']
+ del map['id']
+ zf.writestr(f"chatmap_{map_id}.csv", map_to_csv(map['features']))
memory_file.seek(0)
return StreamingResponse(
memory_file,
diff --git a/chatmap-ui/src/components/DownloadButton/index.jsx b/chatmap-ui/src/components/DownloadButton/index.jsx
index 2729462..e8a8b5d 100644
--- a/chatmap-ui/src/components/DownloadButton/index.jsx
+++ b/chatmap-ui/src/components/DownloadButton/index.jsx
@@ -50,7 +50,7 @@ function createAndDownloadZip(data, dataFiles) {
});
}
-function DownloadButton({ data, dataFiles, url, className, disabled }) {
+function DownloadButton({ data, dataFiles, url, className, disabled, format, label}) {
const handleClick = () => {
if (url) {
@@ -69,10 +69,10 @@ function DownloadButton({ data, dataFiles, url, className, disabled }) {
size="small"
onClick={handleClick}
>
-
+ />} {format && `(${format})`}
);
diff --git a/chatmap-ui/src/pages/mapView/index.jsx b/chatmap-ui/src/pages/mapView/index.jsx
index 6266730..1b7ab6d 100644
--- a/chatmap-ui/src/pages/mapView/index.jsx
+++ b/chatmap-ui/src/pages/mapView/index.jsx
@@ -207,13 +207,26 @@ function MapView() {
>}
{/* Data access */}
- { mapData.owner &&
- 0} url={`${config.API_URL}/export/${mapData.id}`} className="map__options_button" />
+ { mapData.owner && <>
+ 0}
+ url={`${config.API_URL}/export/${mapData.id}`}
+ className="map__options_button"
+ format="GeoJSON"
+ />
+ 0}
+ url={`${config.API_URL}/export/csv/${mapData.id}`}
+ className="map__options_button"
+ format="CSV"
+ />
+ >
}
API link
diff --git a/chatmap-ui/src/styles/main.css b/chatmap-ui/src/styles/main.css
index 9f2e2a7..56be8bc 100644
--- a/chatmap-ui/src/styles/main.css
+++ b/chatmap-ui/src/styles/main.css
@@ -403,6 +403,7 @@ https://github.com/KarimMokhtar/react-drag-drop-files/issues/121 */
margin-bottom: var(--hot-spacing-small);
}
+
/* Error boundary */
.errorMessage {
@@ -680,15 +681,6 @@ https://github.com/KarimMokhtar/react-drag-drop-files/issues/121 */
height: 100vh;
}
- .header__rest sl-button::part(base) {
- width: 40px;
- padding-inline-start: 0;
- }
-
- .header__rest sl-button::part(label) {
- display: none;
- }
-
.connectFormWrapper {
text-align: center;
}