defmodule MoundHunters.Repo do @moduledoc """ Mnesia database helpers for tiles and geometries. """ require Logger # Tile statuses @type tile_status :: :processing | :ready | :error @doc """ Get a tile by its ID. Returns {:ok, tile} or {:error, :not_found} """ def get_tile(id) do case :mnesia.transaction(fn -> :mnesia.read(:tiles, id) end) do {:atomic, [tile]} -> {:ok, tile_to_map(tile)} {:atomic, []} -> {:error, :not_found} {:aborted, reason} -> Logger.error("Failed to read tile #{id}: #{inspect(reason)}") {:error, :database_error} end end @doc """ Get a tile that contains the given lat/lng coordinates. Returns {:ok, tile} or {:error, :not_found} If multiple tiles contain the point, returns the first match. """ def get_tile_at_coords(lat, lng) do match_spec = [ {{:tiles, :"$1", :"$2", :"$3", :"$4", :"$5", :"$6", :"$7", :"$8", :"$9", :"$10", :"$11"}, [ {:andalso, {:"=<", :"$2", lat}, {:"=<", lat, :"$3"}}, {:andalso, {:"=<", :"$4", lng}, {:"=<", lng, :"$5"}} ], [:"$_"]} ] case :mnesia.transaction(fn -> :mnesia.select(:tiles, match_spec) end) do {:atomic, [tile | _]} -> {:ok, tile_to_map(tile)} {:atomic, []} -> {:error, :not_found} {:aborted, reason} -> Logger.error("Failed to query tiles at #{lat}, #{lng}: #{inspect(reason)}") {:error, :database_error} end end @doc """ Insert or update a tile record. """ def upsert_tile(attrs) do tile_id = Map.fetch!(attrs, :id) now = System.system_time(:second) tile_record = {:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat), Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status), Map.get(attrs, :error_message), Map.get(attrs, :jpg_available, false), Map.get(attrs, :png_available, false), Map.get(attrs, :created_at, now), now} case :mnesia.transaction(fn -> :mnesia.write(tile_record) end) do {:atomic, :ok} -> {:ok, tile_to_map(tile_record)} {:aborted, reason} -> {:error, reason} end end @doc """ Get a shared geometry by ID. """ def get_geometry(geometry_id) do case :mnesia.transaction(fn -> :mnesia.read(:geometries, geometry_id) end) do {:atomic, [geometry]} -> {:ok, geometry_to_map(geometry)} {:atomic, []} -> {:error, :not_found} {:aborted, reason} -> {:error, reason} end end @doc """ Create a new shared geometry. """ def create_geometry(geojson) do geometry_id = generate_id() now = System.system_time(:second) geometry_record = {:geometries, geometry_id, geojson, now} case :mnesia.transaction(fn -> :mnesia.write(geometry_record) end) do {:atomic, :ok} -> {:ok, %{id: geometry_id}} {:aborted, reason} -> {:error, reason} end end # Convert Mnesia tile record to map defp tile_to_map( {:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, jpg_available, png_available, created_at, updated_at} ) do %{ id: id, min_lat: min_lat, max_lat: max_lat, min_lng: min_lng, max_lng: max_lng, status: status, error_message: error_message, jpg_available: jpg_available, png_available: png_available, created_at: created_at, updated_at: updated_at } end # Convert Mnesia geometry record to map defp geometry_to_map({:geometries, id, geojson, created_at}) do %{ id: id, geojson: geojson, created_at: created_at } end @doc """ Repopulate tiles table from .mound files in the configured directory. Skips tiles that already exist in the database. Returns {success_count, skip_count, error_count}. """ def repopulate_from_mounds do tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) mound_dir = Path.join(tile_dir, "MOUND") Logger.info("Scanning for .mound files in: #{mound_dir}") case File.ls(mound_dir) do {:ok, files} -> results = files |> Enum.filter(&String.ends_with?(&1, ".mound")) |> Enum.map(fn filename -> # Extract tile_id from filename (e.g., "BS18921654.mound" -> "BS18921654") tile_id = Path.rootname(filename) full_path = Path.join(mound_dir, filename) process_mound_file(tile_id, full_path) end) successes = Enum.count(results, &(&1 == :ok)) skips = Enum.count(results, &(&1 == :skipped)) errors = Enum.count(results, &match?({:error, _}, &1)) Logger.info( "Repopulation complete: #{successes} inserted, #{skips} skipped, #{errors} errors" ) {successes, skips, errors} {:error, reason} -> Logger.error("Failed to list directory #{mound_dir}: #{inspect(reason)}") {:error, reason} end end @doc """ Scan tile directories and update jpg_available and png_available flags. Returns {updated_count, not_found_count}. """ def update_tile_availability do tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) mound_dir = Path.join(tile_dir, "MOUND") png_dir = Path.join(tile_dir, "PNG") jpg_dir = Path.join(tile_dir, "JPG") Logger.info("Scanning tile availability in: #{mound_dir}") # Get all tile IDs from database case :mnesia.transaction(fn -> :mnesia.match_object({:tiles, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}) end) do {:atomic, tiles} -> results = tiles |> Enum.map(fn tile_record -> tile = tile_to_map(tile_record) tile_id = tile.id jpg_path = Path.join(jpg_dir, "#{tile_id}.jpg") png_path = Path.join(png_dir, "#{tile_id}.png") jpg_available = File.exists?(jpg_path) png_available = File.exists?(png_path) # Only update if availability changed if jpg_available != tile.jpg_available or png_available != tile.png_available do attrs = %{ id: tile_id, min_lat: tile.min_lat, max_lat: tile.max_lat, min_lng: tile.min_lng, max_lng: tile.max_lng, status: tile.status, error_message: tile.error_message, jpg_available: jpg_available, png_available: png_available, created_at: tile.created_at } case upsert_tile(attrs) do {:ok, _} -> Logger.debug("Updated #{tile_id}: jpg=#{jpg_available}, png=#{png_available}") :updated {:error, reason} -> Logger.error("Failed to update #{tile_id}: #{inspect(reason)}") {:error, reason} end else :unchanged end end) updated = Enum.count(results, &(&1 == :updated)) unchanged = Enum.count(results, &(&1 == :unchanged)) errors = Enum.count(results, &match?({:error, _}, &1)) Logger.info( "Availability scan complete: #{updated} updated, #{unchanged} unchanged, #{errors} errors" ) {updated, unchanged, errors} {:aborted, reason} -> Logger.error("Failed to read tiles: #{inspect(reason)}") {:error, reason} end end defp process_mound_file(tile_id, file_path) do # Check if tile already exists case get_tile(tile_id) do {:ok, _existing} -> Logger.debug("Skipping existing tile: #{tile_id}") :skipped {:error, :not_found} -> # Parse header and insert case MoundHunters.MoundParser.parse_header(file_path) do {:ok, header} -> # Convert Web Mercator coordinates to lat/lng bounds = MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{ min_x: header.min_x, max_x: header.max_x, min_y: header.min_y, max_y: header.max_y }) # Check for jpg and png availability tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) mound_dir = Path.join(tile_dir, "MOUND") jpg_available = File.exists?(Path.join(mound_dir, "#{tile_id}.jpg")) png_available = File.exists?(Path.join(mound_dir, "#{tile_id}.png")) attrs = %{ id: tile_id, min_lat: bounds.min_lat, max_lat: bounds.max_lat, min_lng: bounds.min_lng, max_lng: bounds.max_lng, status: :ready, error_message: nil, jpg_available: jpg_available, png_available: png_available } case upsert_tile(attrs) do {:ok, _tile} -> Logger.debug("Inserted tile: #{tile_id}") :ok {:error, reason} -> Logger.error("Failed to insert tile #{tile_id}: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to parse header for #{tile_id}: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to check if tile exists #{tile_id}: #{inspect(reason)}") {:error, reason} end end # Generate random 8-character alphanumeric ID defp generate_id do :crypto.strong_rand_bytes(6) |> Base.url_encode64(padding: false) |> binary_part(0, 8) end end