322 lines
9.6 KiB
Elixir
322 lines
9.6 KiB
Elixir
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
|