defmodule Yog.IO.JSON do @moduledoc """ JSON format import/export for graph data exchange. This module provides comprehensive JSON serialization capabilities for graph data, supporting multiple formats used by popular visualization libraries, as well as import functionality for round-trip serialization. ## Format Support - **Generic**: Full metadata with type preservation - **D3Force**: D3.js force-directed graphs - **Cytoscape**: Cytoscape.js network visualization - **VisJs**: vis.js network format - **NetworkX**: Python NetworkX compatibility ## Examples ### Export to JSON iex> graph = Yog.directed() ...> |> Yog.add_node(1, "Alice") ...> |> Yog.add_node(2, "Bob") ...> |> Yog.add_edge_ensure(from: 1, to: 2, with: "follows") iex> iex> json_string = Yog.IO.JSON.to_json(graph) iex> String.contains?(json_string, "Alice") true ### Import from JSON iex> json = ~s|{"graph_type":"directed","nodes":[{"id":1,"data":"Alice"},{"id":2,"data":"Bob"}],"edges":[{"source":1,"target":2,"weight":"follows"}]}| iex> {:ok, graph} = Yog.IO.JSON.from_json(json) iex> Yog.Model.order(graph) 2 ### Import from Map (PostgreSQL JSONB) iex> map = %{ ...> "graph_type" => "undirected", ...> "nodes" => [%{"id" => 1, "data" => "A"}, %{"id" => 2, "data" => "B"}], ...> "edges" => [%{"source" => 1, "target" => 2, "weight" => 1}] ...> } iex> {:ok, graph} = Yog.IO.JSON.from_map(map) iex> Yog.Model.edge_count(graph) 1 """ alias Yog.Model @doc """ Creates default export options for String node and edge data. """ def default_export_options do {:json_export_options, :yog_generic, true, &default_node_serializer/1, &default_edge_serializer/1, false, %{}, &Yog.Utils.safe_string/1, &Yog.Utils.safe_string/1} end @doc """ Creates export options with custom serializers for generic types. """ def export_options_with(node_serializer, edge_serializer, opts \\ []) do node_fmt = Keyword.get(opts, :node_formatter, &Yog.Utils.safe_string/1) edge_fmt = Keyword.get(opts, :edge_formatter, &Yog.Utils.safe_string/1) {:json_export_options, :yog_generic, true, node_serializer, edge_serializer, false, %{}, node_fmt, edge_fmt} end @doc """ Converts a graph to a JSON string according to options. """ def to_json(graph, options \\ default_export_options()) do {format, include_metadata?, node_ser, edge_ser, _pretty?, _meta, node_fmt, edge_fmt} = case options do {:json_export_options, f, im, ns, es, p, m, nf, ef} -> {f, im, ns, es, p, m, nf, ef} {:json_export_options, f, im, ns, es, p, m} -> {f, im, ns, es, p, m, &Yog.Utils.safe_string/1, &Yog.Utils.safe_string/1} end case format do :yog_generic -> to_generic_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) :network_x -> to_networkx_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) :d3_force -> to_d3_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) :cytoscape -> to_cytoscape_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) :visjs -> to_visjs_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) _ -> to_generic_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) end |> Jason.encode!() end @doc """ Exports a graph to a JSON file. """ def to_json_file(graph, path, options \\ default_export_options()) do json_string = to_json(graph, options) case File.write(path, json_string) do :ok -> {:ok, nil} error -> error end end @doc """ Quick export for D3.js force-directed graphs with default settings. """ def to_d3_json(graph, node_serializer, edge_serializer) do options = {:json_export_options, :d3_force, false, node_serializer, edge_serializer, false, %{}} to_json(graph, options) end @doc """ Quick export for Cytoscape.js with default settings. """ def to_cytoscape_json(graph, node_serializer, edge_serializer) do options = {:json_export_options, :cytoscape, false, node_serializer, edge_serializer, false, %{}} to_json(graph, options) end @doc """ Quick export for vis.js networks with default settings. """ def to_visjs_json(graph, node_serializer, edge_serializer) do options = {:json_export_options, :visjs, false, node_serializer, edge_serializer, false, %{}} to_json(graph, options) end @doc """ Writes a graph to a JSON file using default export options. """ def write(path, graph) do to_json_file(graph, path, default_export_options()) end @doc """ Writes a graph to a JSON file with custom export options. """ def write_with(path, options, graph) do to_json_file(graph, path, options) end @doc """ Converts a multigraph to a JSON string. """ def to_json_multi(graph, options \\ default_export_options()) do {_format, include_metadata?, node_ser, edge_ser, _pretty?, _meta, node_fmt, edge_fmt} = case options do {:json_export_options, f, im, ns, es, p, m, nf, ef} -> {f, im, ns, es, p, m, nf, ef} {:json_export_options, f, im, ns, es, p, m} -> {f, im, ns, es, p, m, &Yog.Utils.safe_string/1, &Yog.Utils.safe_string/1} end to_generic_multi_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) |> Jason.encode!() end @doc """ Exports a multigraph to a JSON file. """ def to_json_file_multi(graph, path, options \\ default_export_options()) do json_string = to_json_multi(graph, options) File.write(path, json_string) end @doc """ Converts a JsonError to a string. """ def error_to_string(error) do inspect(error) end # Private functions defp default_node_serializer(data) do data end defp default_edge_serializer(data) do data end # Convert Gleam JSON iolist to Elixir term defp gleam_json_to_term(iolist) do iolist |> IO.iodata_to_binary() |> Jason.decode!() end # Serialize data using the provided serializer defp serialize_data(data, serializer, formatter) do result = serializer.(data) # Check if result is a Gleam JSON iolist (starts with numbers or nested lists) if is_list(result) and (is_integer(List.first(result)) or is_list(List.first(result))) do gleam_json_to_term(result) else deep_clean(result, formatter) end end defp deep_clean(data, formatter) do case data do m when is_map(m) -> Map.new(m, fn {k, v} -> {deep_clean(k, formatter), deep_clean(v, formatter)} end) l when is_list(l) -> Enum.map(l, &deep_clean(&1, formatter)) v when is_binary(v) or is_number(v) or is_boolean(v) or is_nil(v) -> v v -> formatter.(v) end end defp to_generic_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) do %Yog.Graph{kind: type, nodes: nodes_map} = graph graph_type = if type == :directed, do: "directed", else: "undirected" edges = Model.all_edges(graph) nodes_json = Enum.map(nodes_map, fn {id, data} -> %{ id: node_fmt.(id), data: serialize_data(data, node_ser, node_fmt) } end) edges_json = Enum.map(edges, fn {from, to, weight} -> %{ source: node_fmt.(from), target: node_fmt.(to), weight: serialize_data(weight, edge_ser, edge_fmt) } end) result = %{ format: "yog-generic", version: "2.0", graph_type: graph_type, nodes: nodes_json, edges: edges_json } if include_metadata? do Map.put(result, :metadata, build_metadata(graph)) else result end end defp to_networkx_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) do %Yog.Graph{kind: type, nodes: nodes_map} = graph directed = type == :directed edges = Model.all_edges(graph) nodes_json = Enum.map(nodes_map, fn {id, data} -> %{ id: node_fmt.(id), data: serialize_data(data, node_ser, node_fmt) } end) links_json = Enum.map(edges, fn {from, to, weight} -> %{ source: node_fmt.(from), target: node_fmt.(to), weight: serialize_data(weight, edge_ser, edge_fmt) } end) result = %{ directed: directed, multigraph: false, graph: %{}, nodes: nodes_json, links: links_json } if include_metadata? do Map.put(result, :metadata, build_metadata(graph)) else result end end defp to_d3_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) do %Yog.Graph{nodes: nodes_map} = graph edges = Model.all_edges(graph) nodes_json = Enum.map(nodes_map, fn {id, data} -> %{ id: node_fmt.(id), data: serialize_data(data, node_ser, node_fmt) } end) links_json = Enum.map(edges, fn {from, to, weight} -> %{ source: node_fmt.(from), target: node_fmt.(to), weight: serialize_data(weight, edge_ser, edge_fmt) } end) %{ nodes: nodes_json, links: links_json } end defp to_cytoscape_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) do %Yog.Graph{nodes: nodes_map} = graph edges = Model.all_edges(graph) nodes_elements = Enum.map(nodes_map, fn {id, data} -> %{ data: %{ id: node_fmt.(id), label: serialize_data(data, node_ser, node_fmt) } } end) edges_elements = Enum.map(edges, fn {from, to, weight} -> %{ data: %{ source: node_fmt.(from), target: node_fmt.(to), weight: serialize_data(weight, edge_ser, edge_fmt) } } end) %{ elements: nodes_elements ++ edges_elements } end defp to_visjs_format(graph, node_ser, edge_ser, node_fmt, edge_fmt) do %Yog.Graph{nodes: nodes_map} = graph edges = Model.all_edges(graph) nodes_json = Enum.map(nodes_map, fn {id, data} -> %{ id: node_fmt.(id), label: serialize_data(data, node_ser, node_fmt) } end) edges_json = Enum.map(edges, fn {from, to, weight} -> %{ from: node_fmt.(from), to: node_fmt.(to), label: serialize_data(weight, edge_ser, edge_fmt) } end) %{ nodes: nodes_json, edges: edges_json } end defp to_generic_multi_format(graph, node_ser, edge_ser, include_metadata?, node_fmt, edge_fmt) do # Note: using direct field access as this function handles both Yog.Graph and Yog.Multi.Graph # This will be replaced with protocol dispatch when protocols are implemented graph_type = if graph.kind == :directed, do: "directed", else: "undirected" nodes = Map.to_list(graph.nodes) # Collect all edges with their IDs edges = graph.edges |> Map.to_list() |> Enum.map(fn {edge_id, {from, to, weight}} -> {edge_id, from, to, weight} end) nodes_json = Enum.map(nodes, fn {id, data} -> %{ id: node_fmt.(id), data: serialize_data(data, node_ser, node_fmt) } end) edges_json = Enum.map(edges, fn {edge_id, from, to, weight} -> %{ id: node_fmt.(edge_id), source: node_fmt.(from), target: node_fmt.(to), weight: serialize_data(weight, edge_ser, edge_fmt) } end) result = %{ format: "yog-generic", version: "2.0", graph_type: graph_type, multigraph: true, nodes: nodes_json, edges: edges_json, edge_count: length(edges) } if include_metadata? do Map.put(result, :metadata, build_multi_metadata(graph)) else result end end defp build_metadata(graph) do %Yog.Graph{kind: type} = graph %{ node_count: Yog.Model.order(graph), edge_count: length(Model.all_edges(graph)), directed: type == :directed } end defp build_multi_metadata(graph) do %{ # Note: using direct field access as this function handles both Yog.Graph and Yog.Multi.Graph node_count: map_size(graph.nodes), edge_count: map_size(graph.edges), directed: graph.kind == :directed } end # ============= Detection Functions ============= @doc """ Detects the JSON graph format of the given input. Supports detection from both JSON strings and decoded maps. ## Parameters - `input` - JSON string or map to detect the format of ## Returns - `{:ok, type}` - One of `:yog_generic`, `:network_x`, `:d3_force`, `:cytoscape`, `:visjs`, or `:simple` - `{:error, reason}` - If input is a string and parsing fails ## Examples iex> json = ~s|{"graph_type":"directed","nodes":[],"edges":[]}| iex> Yog.IO.JSON.json_type(json) {:ok, :yog_generic} iex> network_x_map = %{"nodes" => [], "links" => [], "directed" => true} iex> Yog.IO.JSON.json_type(network_x_map) {:ok, :network_x} iex> d3_force_map = %{"nodes" => [], "links" => []} iex> Yog.IO.JSON.json_type(d3_force_map) {:ok, :d3_force} """ @spec json_type(String.t() | map()) :: {:ok, atom()} | {:error, Jason.DecodeError.t()} def json_type(input) when is_binary(input) do case Jason.decode(input) do {:ok, map} -> json_type(map) {:error, _} = error -> error end end def json_type(map) when is_map(map) do {:ok, detect_format(map)} end @doc """ Detects the JSON graph format, raising on error for string input. ## Examples iex> json = ~s|{"elements": []}| iex> Yog.IO.JSON.json_type!(json) :cytoscape """ @spec json_type!(String.t() | map()) :: atom() def json_type!(input) do case json_type(input) do {:ok, type} -> type {:error, reason} -> raise ArgumentError, "Failed to detect JSON format: #{inspect(reason)}" end end # ============= Import Functions ============= @doc """ Parses a JSON string and creates a graph. Supports the generic Yog format and common variations. ## Parameters - `json_string` - JSON string to parse ## Returns - `{:ok, graph}` - Successfully parsed graph - `{:error, reason}` - Parsing failed ## Examples iex> json = ~s|{"graph_type":"directed","nodes":[{"id":1,"data":"A"}],"edges":[]}| iex> {:ok, graph} = Yog.IO.JSON.from_json(json) iex> Yog.Model.order(graph) 1 iex> # NetworkX format iex> nx_json = ~s|{"directed":true,"multigraph":false,"nodes":[{"id":1}],"links":[]}| iex> {:ok, graph} = Yog.IO.JSON.from_json(nx_json) iex> Yog.Model.type(graph) :directed iex> # D3 format (nodes + links) iex> d3_json = ~s|{"nodes":[{"id":1},{"id":2}],"links":[{"source":1,"target":2,"weight":5}]}| iex> {:ok, graph} = Yog.IO.JSON.from_json(d3_json) iex> Yog.Model.edge_count(graph) 1 iex> # Cytoscape format iex> cy_json = ~s|{"elements":[{"data":{"id":1}},{"data":{"id":2}},{"data":{"source":1,"target":2}}]}| iex> {:ok, graph} = Yog.IO.JSON.from_json(cy_json) iex> Yog.Model.order(graph) 2 """ @spec from_json(String.t()) :: {:ok, Yog.graph()} | {:error, String.t()} def from_json(json_string) when is_binary(json_string) do case Jason.decode(json_string) do {:ok, map} -> from_map(map) {:error, _} = error -> error end end @doc """ Parses a JSON string and creates a graph, raising on error. ## Examples iex> json = ~s|{"graph_type":"undirected","nodes":[],"edges":[]}| iex> graph = Yog.IO.JSON.from_json!(json) iex> Yog.Model.order(graph) 0 """ @spec from_json!(String.t()) :: Yog.graph() def from_json!(json_string) when is_binary(json_string) do case from_json(json_string) do {:ok, graph} -> graph {:error, reason} -> raise ArgumentError, "Failed to parse JSON: #{inspect(reason)}" end end @doc """ Creates a graph from a map (useful for PostgreSQL JSONB). Auto-detects format based on keys present in the map. ## Parameters - `map` - Map containing graph data ## Supported Formats - **Yog Generic**: `%{"graph_type" => "directed", "nodes" => [...], "edges" => [...]}` - **NetworkX**: `%{"directed" => true, "nodes" => [...], "links" => [...]}` - **D3**: `%{"nodes" => [...], "links" => [...]}` - **Cytoscape**: `%{"elements" => [...]}` - **VisJs**: `%{"nodes" => [...], "edges" => [...]}` (with `from`/`to`) ## Examples iex> map = %{ ...> "graph_type" => "undirected", ...> "nodes" => [%{"id" => 1, "data" => "Node A"}], ...> "edges" => [] ...> } iex> {:ok, graph} = Yog.IO.JSON.from_map(map) iex> Yog.Model.order(graph) 1 iex> # From PostgreSQL JSONB (simple format) iex> simple = %{ ...> "type" => "directed", ...> "nodes" => [%{"id" => 1}, %{"id" => 2}], ...> "edges" => [%{"from" => 1, "to" => 2}] ...> } iex> {:ok, graph} = Yog.IO.JSON.from_map(simple) iex> Yog.has_edge?(graph, 1, 2) true """ @spec from_map(map()) :: {:ok, Yog.graph()} | {:error, String.t()} def from_map(map) when is_map(map) do graph = do_from_map(map) {:ok, graph} rescue e -> {:error, Exception.message(e)} end defp do_from_map(map) do case detect_format(map) do :cytoscape -> parse_cytoscape_format(map) :visjs -> parse_visjs_format(map) :network_x -> parse_networkx_format(map) :yog_generic -> parse_generic_format(map) :d3_force -> parse_d3_format(map) :simple -> parse_simple_format(map) end end defp detect_format(map) do cond do # Cytoscape format: elements array Map.has_key?(map, "elements") -> :cytoscape # VisJs format: nodes and edges with from/to Map.has_key?(map, "nodes") and Map.has_key?(map, "edges") and has_visjs_edges?(map["edges"]) -> :visjs # NetworkX format: directed + multigraph + links Map.has_key?(map, "directed") -> :network_x # Yog generic format: graph_type + edges Map.has_key?(map, "graph_type") or Map.has_key?(map, "edges") -> :yog_generic # D3 format: nodes + links (no type indicator) Map.has_key?(map, "nodes") and Map.has_key?(map, "links") -> :d3_force # Fallback: try to interpret as simple graph true -> :simple end end defp has_visjs_edges?(edges) when is_list(edges) do case List.first(edges) do nil -> false edge -> Map.has_key?(edge, "from") and Map.has_key?(edge, "to") end end defp has_visjs_edges?(_), do: false defp parse_generic_format(map) do graph_type = parse_graph_type(map["graph_type"] || map["type"] || "undirected") nodes = map["nodes"] || [] edges = map["edges"] || [] base = Yog.new(graph_type) graph = Enum.reduce(nodes, base, fn node, g -> id = parse_id(node["id"]) data = node["data"] || node["label"] || nil Yog.add_node(g, id, data) end) Enum.reduce(edges, graph, fn edge, g -> from = parse_id(edge["source"] || edge["from"]) to = parse_id(edge["target"] || edge["to"]) weight = edge["weight"] || edge["label"] || 1 Yog.add_edge!(g, from, to, weight) end) end defp parse_networkx_format(map) do graph_type = if map["directed"], do: :directed, else: :undirected nodes = map["nodes"] || [] links = map["links"] || map["edges"] || [] base = Yog.new(graph_type) graph = Enum.reduce(nodes, base, fn node, g -> id = parse_id(node["id"]) data = node["data"] || nil Yog.add_node(g, id, data) end) Enum.reduce(links, graph, fn link, g -> from = parse_id(link["source"]) to = parse_id(link["target"]) weight = link["weight"] || 1 Yog.add_edge!(g, from, to, weight) end) end defp parse_d3_format(map) do # D3 format doesn't specify directed/undirected, assume undirected nodes = map["nodes"] || [] links = map["links"] || [] base = Yog.new(:undirected) graph = Enum.reduce(nodes, base, fn node, g -> id = parse_id(node["id"]) data = node["data"] || node["label"] || nil Yog.add_node(g, id, data) end) Enum.reduce(links, graph, fn link, g -> from = parse_id(link["source"]) to = parse_id(link["target"]) weight = link["weight"] || link["value"] || 1 Yog.add_edge!(g, from, to, weight) end) end defp parse_cytoscape_format(map) do elements = map["elements"] || [] # Separate nodes and edges {nodes, edges} = Enum.split_with(elements, fn elem -> data = elem["data"] || %{} Map.has_key?(data, "id") and not Map.has_key?(data, "source") end) # Try to detect if directed graph_type = :undirected base = Yog.new(graph_type) graph = Enum.reduce(nodes, base, fn elem, g -> data = elem["data"] || %{} id = parse_id(data["id"]) label = data["label"] || data["name"] || nil Yog.add_node(g, id, label) end) Enum.reduce(edges, graph, fn elem, g -> data = elem["data"] || %{} from = parse_id(data["source"]) to = parse_id(data["target"]) weight = data["weight"] || data["label"] || 1 Yog.add_edge!(g, from, to, weight) end) end defp parse_visjs_format(map) do nodes = map["nodes"] || [] edges = map["edges"] || [] # VisJs can be directed or undirected, assume undirected by default graph_type = :undirected base = Yog.new(graph_type) graph = Enum.reduce(nodes, base, fn node, g -> id = parse_id(node["id"]) label = node["label"] || node["name"] || nil Yog.add_node(g, id, label) end) Enum.reduce(edges, graph, fn edge, g -> from = parse_id(edge["from"]) to = parse_id(edge["to"]) weight = edge["label"] || edge["weight"] || 1 Yog.add_edge!(g, from, to, weight) end) end defp parse_simple_format(map) do # Most basic format - just try to extract nodes and edges nodes = map["nodes"] || [] edges = map["edges"] || map["links"] || [] base = Yog.new(:undirected) graph = Enum.reduce(nodes, base, fn node, g -> id = if is_map(node) do parse_id(node["id"] || node["node_id"]) else parse_id(node) end data = if is_map(node), do: node["data"], else: nil Yog.add_node(g, id, data) end) Enum.reduce(edges, graph, fn edge, g -> {from, to, weight} = cond do is_map(edge) -> f = parse_id(edge["from"] || edge["source"] || edge["node1"]) t = parse_id(edge["to"] || edge["target"] || edge["node2"]) w = edge["weight"] || edge["value"] || 1 {f, t, w} is_tuple(edge) and tuple_size(edge) == 2 -> {parse_id(elem(edge, 0)), parse_id(elem(edge, 1)), 1} is_tuple(edge) and tuple_size(edge) == 3 -> {parse_id(elem(edge, 0)), parse_id(elem(edge, 1)), elem(edge, 2)} is_list(edge) and length(edge) >= 2 -> {parse_id(Enum.at(edge, 0)), parse_id(Enum.at(edge, 1)), Enum.at(edge, 2, 1)} true -> {nil, nil, 1} end if from != nil and to != nil do Yog.add_edge!(g, from, to, weight) else g end end) end defp parse_graph_type("directed"), do: :directed defp parse_graph_type("undirected"), do: :undirected defp parse_graph_type("digraph"), do: :directed defp parse_graph_type("graph"), do: :undirected defp parse_graph_type(true), do: :directed defp parse_graph_type(_), do: :undirected defp parse_id(id) when is_binary(id) do case Integer.parse(id) do {int, ""} -> int _ -> id end end defp parse_id(id), do: id end