wrolpi/modules/map/api.py
Roland c726654de9
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Map search added to global search. Preview cards are displayed for all results in new Map tab.
Map viewer has simple search.

Map search databases can be downloaded from CDN, or built locally. Map searches can be built offline, but will not have enriched things like state names downloaded from Wikidata.
2026-04-13 12:17:08 -06:00

160 lines
5.8 KiB
Python

from http import HTTPStatus
from sanic import Request, response, Blueprint
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.map import lib, schema, search
from modules.map.pins import get_map_pins_config
from wrolpi.api_utils import json_response
from wrolpi.common import wrol_mode_check
from wrolpi.events import Events
map_bp = Blueprint('Map', '/api/map')
@map_bp.get('/files')
@openapi.description('List available PMTiles map files')
def get_files(_: Request):
files = lib.get_pmtiles_files()
return json_response(dict(files=files), HTTPStatus.OK)
@map_bp.delete('/files/<filename:str>')
@openapi.description('Delete a PMTiles map file')
@wrol_mode_check
async def delete_file(_: Request, filename: str):
try:
deleted = lib.delete_pmtiles_file(filename)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
if deleted:
return response.empty()
return response.json({'error': 'File not found'}, HTTPStatus.NOT_FOUND)
@map_bp.get('/subscribe')
@openapi.description('Get map catalog and current subscriptions')
def get_subscriptions(request: Request):
catalog = lib.get_map_catalog()
subscriptions = lib.get_map_subscriptions(request.ctx.session)
return json_response(dict(catalog=catalog, subscriptions=subscriptions), HTTPStatus.OK)
@map_bp.post('/subscribe')
@openapi.description('Subscribe to a map region for automatic downloads')
@validate(schema.MapSubscribeRequest)
@wrol_mode_check
async def post_subscribe(request: Request, body: schema.MapSubscribeRequest):
try:
await lib.subscribe(request.ctx.session, body.name, body.region)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
Events.send_created(f'Map subscription created for {body.name}')
return response.empty(HTTPStatus.CREATED)
@map_bp.delete('/subscribe/<region:str>')
@openapi.description('Unsubscribe from a map region')
@wrol_mode_check
async def delete_subscription(request: Request, region: str):
try:
await lib.unsubscribe(request.ctx.session, region)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.NOT_FOUND)
return response.empty(HTTPStatus.NO_CONTENT)
@map_bp.get('/pins')
@openapi.description('Get all map pins')
def get_pins(_: Request):
pins = get_map_pins_config().pins
return json_response(dict(pins=pins), HTTPStatus.OK)
@map_bp.post('/pins')
@openapi.description('Add a map pin')
@validate(schema.MapPinRequest)
async def add_pin(_: Request, body: schema.MapPinRequest):
pin = get_map_pins_config().add_pin(body.lat, body.lon, body.label, body.color)
return json_response(dict(pin=pin), HTTPStatus.CREATED)
@map_bp.delete('/pins/<pin_id:int>')
@openapi.description('Delete a map pin by ID')
async def delete_pin(_: Request, pin_id: int):
if get_map_pins_config().delete_pin(pin_id):
return response.empty(HTTPStatus.NO_CONTENT)
return response.json({'error': 'Pin not found'}, HTTPStatus.NOT_FOUND)
@map_bp.put('/pins/<pin_id:int>')
@openapi.description('Update a map pin by ID')
@validate(schema.MapPinUpdateRequest)
async def update_pin(_: Request, pin_id: int, body: schema.MapPinUpdateRequest):
if get_map_pins_config().update_pin(pin_id, body.label, body.color):
return response.empty(HTTPStatus.NO_CONTENT)
return response.json({'error': 'Pin not found'}, HTTPStatus.NOT_FOUND)
@map_bp.get('/search')
@openapi.description('Search for places in map search indexes')
async def search_places(request: Request):
q = request.args.get('q', '')
if not q or len(q.strip()) < 1:
return json_response({'error': 'Query parameter "q" is required'}, HTTPStatus.BAD_REQUEST)
try:
limit = int(request.args.get('limit', 12))
offset = int(request.args.get('offset', 0))
lat = float(request.args.get('lat')) if request.args.get('lat') is not None else None
lon = float(request.args.get('lon')) if request.args.get('lon') is not None else None
except (ValueError, TypeError):
return json_response({'error': 'Invalid query parameters'}, HTTPStatus.BAD_REQUEST)
data = search.search_places(q.strip(), limit=limit, offset=offset, lat=lat, lon=lon)
return json_response(data, HTTPStatus.OK)
@map_bp.get('/search/estimate')
@openapi.description('Get estimated count of map search results')
async def get_search_estimate(request: Request):
q = request.args.get('q', '')
if not q or len(q.strip()) < 1:
return json_response({'map_places': 0}, HTTPStatus.OK)
count = search.search_places_count(q.strip())
return json_response({'map_places': count}, HTTPStatus.OK)
@map_bp.get('/search/status')
@openapi.description('Get status of map search indexes')
async def get_search_status(_: Request):
status = search.get_search_status()
return json_response(status, HTTPStatus.OK)
@map_bp.post('/search/rebuild')
@openapi.description('Rebuild all map search indexes')
@wrol_mode_check
async def rebuild_search_indexes(_: Request):
started = search.rebuild_all_search_indexes()
if started:
return json_response({'message': 'Search index rebuild started'}, HTTPStatus.ACCEPTED)
return json_response({'error': 'No PMTiles files found'}, HTTPStatus.NOT_FOUND)
@map_bp.post('/search/rebuild/<filename:str>')
@openapi.description('Rebuild search index for a single PMTiles file')
@wrol_mode_check
async def rebuild_single_search_index(_: Request, filename: str):
try:
started = search.rebuild_search_index(filename)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
if started:
return json_response({'message': f'Search index rebuild started for {filename}'}, HTTPStatus.ACCEPTED)
return json_response({'error': 'File not found'}, HTTPStatus.NOT_FOUND)