Current section
Files
Jump to
Current section
Files
lib/mime_description/generator.ex
defmodule MimeDescription.Generator do
@moduledoc """
Generates Elixir module with embedded MIME type descriptions from freedesktop.org.
"""
require Logger
@db_url "https://gitlab.freedesktop.org/xdg/shared-mime-info/-/raw/master/data/freedesktop.org.xml.in"
@cache_dir ".mime_cache"
@etag_file Path.join(@cache_dir, "etag.txt")
@data_hash_file Path.join(@cache_dir, "data_hash.txt")
@output_file "lib/mime_description/data.ex"
@doc """
Generates or updates the embedded MIME data module.
Only fetches new data if the remote has changed since last generation.
"""
def generate(force \\ false) do
ensure_cache_dir()
Logger.info("Starting MIME description generator...")
case fetch_if_needed(force) do
{:ok, xml_data} ->
Logger.info("Parsing XML data...")
descriptions = parse_mime_xml(xml_data)
Logger.info("Successfully parsed #{map_size(descriptions)} MIME descriptions")
# Check if the actual data has changed
if data_changed?(descriptions) do
Logger.info("Data has changed, generating Elixir module at #{@output_file}...")
generate_elixir_module(descriptions)
save_data_hash(descriptions)
Logger.info("Generator finished successfully")
else
Logger.info("Data content unchanged, skipping generation")
end
:ok
{:not_modified} ->
Logger.info("MIME database is up to date, no changes needed")
:ok
{:error, reason} ->
Logger.error("Failed to generate MIME data: #{inspect(reason)}")
{:error, reason}
end
end
defp fetch_if_needed(force) do
if force do
Logger.info("Force flag set, fetching fresh data...")
fetch_and_cache_xml()
else
check_and_fetch()
end
end
defp check_and_fetch do
stored_etag = read_cached_etag()
Logger.info("Checking for updates from #{@db_url}...")
headers = if stored_etag, do: [{"if-none-match", stored_etag}], else: []
case Req.head(@db_url, headers: headers) do
{:ok, %{status: 304}} ->
{:not_modified}
{:ok, %{headers: headers, status: 200}} ->
new_etag = get_header_value(headers, "etag")
if new_etag == stored_etag do
{:not_modified}
else
Logger.info("New version available, downloading...")
fetch_and_cache_xml()
end
{:ok, %{status: status}} ->
{:error, "Unexpected status code: #{status}"}
{:error, reason} ->
{:error, reason}
end
end
defp fetch_and_cache_xml do
case Req.get(@db_url) do
{:ok, %{body: body, headers: headers, status: 200}} ->
etag = get_header_value(headers, "etag")
if etag, do: File.write!(@etag_file, etag)
{:ok, body}
{:ok, %{status: status}} ->
{:error, "Failed to fetch MIME database: HTTP #{status}"}
{:error, reason} ->
{:error, "Failed to fetch MIME database: #{inspect(reason)}"}
end
end
defp parse_mime_xml(xml_data) do
import SweetXml
xml_data
|> xpath(~x"//mime-type"l,
type: ~x"./@type"s,
comment: ~x"./comment/text()"s
)
|> Enum.reduce(%{}, fn
%{comment: comment, type: type}, acc when comment != "" ->
Map.put(acc, type, escape_string(comment))
_, acc ->
acc
end)
end
defp escape_string(s) do
s
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
defp data_changed?(descriptions) do
new_hash = compute_data_hash(descriptions)
old_hash = read_data_hash()
new_hash != old_hash
end
defp compute_data_hash(descriptions) do
descriptions
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16()
end
defp read_data_hash do
case File.read(@data_hash_file) do
{:ok, hash} -> String.trim(hash)
_ -> nil
end
end
defp save_data_hash(descriptions) do
hash = compute_data_hash(descriptions)
File.write!(@data_hash_file, hash)
end
defp generate_elixir_module(descriptions) do
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
content = """
# Code generated by mix mime_description.generate; DO NOT EDIT.
# This file was generated on #{timestamp}
defmodule MimeDescription.Data do
@moduledoc false
# mime_data is a map of MIME types to their human-friendly descriptions.
# It is sourced from the freedesktop.org shared-mime-info database.
@mime_data %{
#{format_mime_data(descriptions)}
}
def get_all, do: @mime_data
def get(mime_type), do: Map.get(@mime_data, mime_type)
end
"""
File.write!(@output_file, content)
:ok
end
defp format_mime_data(descriptions) do
descriptions
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map_join(",\n", fn {mime, desc} ->
~s( "#{mime}" => "#{desc}")
end)
end
defp ensure_cache_dir do
File.mkdir_p!(@cache_dir)
end
defp read_cached_etag do
case File.read(@etag_file) do
{:ok, etag} -> String.trim(etag)
_ -> nil
end
end
defp get_header_value(headers, name) do
Enum.find_value(headers, fn
{header_name, value} when is_binary(header_name) ->
if String.downcase(header_name) == name, do: value
_ ->
nil
end)
end
end