Current section
Files
Jump to
Current section
Files
lib/phiaui_design/canvas/persistence.ex
defmodule PhiaUiDesign.Canvas.Persistence do
@moduledoc """
Save and load design scenes to/from `.phia.json` files.
Scenes are serialized as JSON containing a flat list of nodes with their
parent-child relationships. On load, the nodes are inserted back into a
fresh ETS scene table.
## File format
{
"version": 1,
"name": "My Dashboard",
"theme": "zinc",
"nodes": [
{
"id": "abc123",
"type": "phia_component",
"component": "button",
"attrs": {"variant": ":default"},
"slots": {"inner_block": "Click me"},
"parent_id": null,
"children": ["def456"],
"name": "My Button"
}
],
"root_children": ["abc123"]
}
"""
alias PhiaUiDesign.Canvas.{Scene, Node, Variables}
@format_version 2
# ---------------------------------------------------------------------------
# Save
# ---------------------------------------------------------------------------
@doc """
Save a scene to a `.phia.json` file.
Options:
- `:theme` — current theme atom (default: `:zinc`)
"""
@spec save(reference(), Path.t(), keyword()) :: :ok | {:error, term()}
def save(scene, path, opts \\ []) do
theme = Keyword.get(opts, :theme, :zinc)
meta = get_meta(scene)
nodes = Scene.all_nodes(scene)
root_children = Scene.get_root_children(scene)
variables = Variables.get_all(scene)
data = %{
"version" => @format_version,
"name" => meta[:name] || "Untitled",
"theme" => to_string(theme),
"root_children" => root_children,
"nodes" => Enum.map(nodes, &serialize_node/1),
"variables" => serialize_variables(variables)
}
dir = Path.dirname(path)
File.mkdir_p!(dir)
case Jason.encode(data, pretty: true) do
{:ok, json} -> File.write(path, json)
{:error, reason} -> {:error, reason}
end
end
# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------
@doc """
Load a scene from a `.phia.json` file.
Returns `{:ok, scene, metadata}` where metadata includes `:theme` and `:name`.
"""
@spec load(Path.t()) :: {:ok, reference(), map()} | {:error, term()}
def load(path) do
with {:ok, content} <- File.read(path),
{:ok, data} <- Jason.decode(content) do
scene = Scene.new()
nodes = data["nodes"] || []
root_children = data["root_children"] || []
# First pass: insert all nodes without parent relationships
for node_data <- nodes do
node = deserialize_node(node_data)
:ets.insert(scene, {node.id, node})
end
# Set root children
:ets.insert(scene, {:__root_children__, root_children})
# Load variables (backward compatible — v1 files have no "variables" key)
variables = deserialize_variables(data["variables"] || %{})
Variables.put_all(scene, variables)
# Update meta
:ets.insert(scene, {:__meta__, %{
name: data["name"] || "Untitled",
created_at: DateTime.utc_now()
}})
metadata = %{
theme: safe_to_atom(data["theme"] || "zinc"),
name: data["name"] || "Untitled",
version: data["version"] || 1
}
{:ok, scene, metadata}
end
end
# ---------------------------------------------------------------------------
# List projects
# ---------------------------------------------------------------------------
@doc """
List all `.phia.json` files in a directory.
"""
@spec list_projects(Path.t()) :: [map()]
def list_projects(dir) do
dir
|> Path.join("*.phia.json")
|> Path.wildcard()
|> Enum.map(fn path ->
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, data} ->
%{
path: path,
name: data["name"] || Path.basename(path, ".phia.json"),
node_count: length(data["nodes"] || []),
theme: data["theme"]
}
_ ->
nil
end
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
defp serialize_node(%Node{} = node) do
base = %{
"id" => node.id,
"type" => to_string(node.type),
"name" => node.name,
"parent_id" => node.parent_id,
"children" => node.children,
"locked" => node.locked,
"visible" => node.visible
}
case node.type do
:phia_component ->
base
|> Map.put("component", to_string(node.component))
|> Map.put("module", if(node.module, do: inspect(node.module)))
|> Map.put("attrs", serialize_attrs(node.attrs))
|> Map.put("slots", serialize_attrs(node.slots))
:html_element ->
base
|> Map.put("tag", node.tag)
|> Map.put("classes", node.classes)
|> Map.put("attrs", serialize_attrs(node.attrs))
:text ->
Map.put(base, "text_content", node.text_content)
:frame ->
base
|> Map.put("classes", node.classes)
|> serialize_layout_fields(node)
_ ->
base
end
end
defp serialize_layout_fields(map, node) do
layout_fields = [:layout, :gap, :padding, :justify_content, :align_items, :wrap, :width, :height]
Enum.reduce(layout_fields, map, fn field, acc ->
case Map.get(node, field) do
nil -> acc
false when field == :wrap -> acc
value -> Map.put(acc, to_string(field), serialize_layout_value(value))
end
end)
end
defp serialize_layout_value(v) when is_atom(v), do: to_string(v)
defp serialize_layout_value(v), do: v
defp serialize_variables(vars) when is_map(vars) do
Map.new(vars, fn {name, definition} ->
{name, %{
"value" => definition.value,
"type" => to_string(definition.type),
"theme_overrides" => Map.new(definition.theme_overrides, fn {k, v} ->
{to_string(k), v}
end)
}}
end)
end
defp serialize_variables(_), do: %{}
defp serialize_attrs(map) when is_map(map) do
Map.new(map, fn {k, v} ->
{to_string(k), serialize_value(v)}
end)
end
defp serialize_attrs(_), do: %{}
defp serialize_value(v) when is_atom(v) and not is_nil(v) and not is_boolean(v),
do: ":#{v}"
defp serialize_value(v), do: v
# ---------------------------------------------------------------------------
# Deserialization
# ---------------------------------------------------------------------------
defp deserialize_node(data) do
type = safe_to_atom(data["type"])
base = %Node{
id: data["id"],
type: type,
name: data["name"] || "",
parent_id: data["parent_id"],
children: data["children"] || [],
locked: data["locked"] || false,
visible: Map.get(data, "visible", true)
}
case type do
:phia_component ->
component = safe_to_atom(data["component"])
module = resolve_module(data["module"])
%{base |
component: component,
module: module,
attrs: deserialize_attrs(data["attrs"] || %{}),
slots: deserialize_attrs(data["slots"] || %{})
}
:html_element ->
%{base |
tag: data["tag"],
classes: data["classes"],
attrs: deserialize_attrs(data["attrs"] || %{})
}
:text ->
%{base | text_content: data["text_content"]}
:frame ->
%{base | classes: data["classes"]}
|> deserialize_layout_fields(data)
_ ->
base
end
end
defp deserialize_layout_fields(node, data) do
node
|> maybe_set_layout(data)
|> maybe_set_int_field(:gap, data["gap"])
|> maybe_set_padding(data["padding"])
|> maybe_set_atom_field(:justify_content, data["justify_content"])
|> maybe_set_atom_field(:align_items, data["align_items"])
|> maybe_set_bool_field(:wrap, data["wrap"])
|> maybe_set_size_field(:width, data["width"])
|> maybe_set_size_field(:height, data["height"])
end
defp maybe_set_layout(node, %{"layout" => dir}) when is_binary(dir) do
%{node | layout: safe_to_atom(dir)}
end
defp maybe_set_layout(node, _), do: node
defp maybe_set_int_field(node, _field, nil), do: node
defp maybe_set_int_field(node, field, value) when is_integer(value) do
Map.put(node, field, value)
end
defp maybe_set_int_field(node, field, value) when is_binary(value) do
case Integer.parse(value) do
{i, ""} -> Map.put(node, field, i)
_ -> Map.put(node, field, value)
end
end
defp maybe_set_int_field(node, _field, _), do: node
defp maybe_set_padding(node, nil), do: node
defp maybe_set_padding(node, value) when is_integer(value), do: %{node | padding: value}
defp maybe_set_padding(node, value) when is_list(value), do: %{node | padding: value}
defp maybe_set_padding(node, _), do: node
defp maybe_set_atom_field(node, _field, nil), do: node
defp maybe_set_atom_field(node, field, value) when is_binary(value) do
Map.put(node, field, safe_to_atom(value))
end
defp maybe_set_atom_field(node, _field, _), do: node
defp maybe_set_bool_field(node, _field, nil), do: node
defp maybe_set_bool_field(node, field, value) when is_boolean(value), do: Map.put(node, field, value)
defp maybe_set_bool_field(node, _field, _), do: node
defp maybe_set_size_field(node, _field, nil), do: node
defp maybe_set_size_field(node, field, value) when is_integer(value) do
Map.put(node, field, value)
end
defp maybe_set_size_field(node, field, value) when is_binary(value) do
case value do
"fill" -> Map.put(node, field, :fill)
"hug" -> Map.put(node, field, :hug)
_ ->
case Integer.parse(value) do
{i, ""} -> Map.put(node, field, i)
_ -> Map.put(node, field, value)
end
end
end
defp maybe_set_size_field(node, _field, _), do: node
defp deserialize_variables(vars) when is_map(vars) do
Map.new(vars, fn {name, def_map} ->
{name, %{
value: def_map["value"],
type: safe_to_atom(def_map["type"] || "string"),
theme_overrides: Map.new(def_map["theme_overrides"] || %{}, fn {k, v} ->
{safe_to_atom(k), v}
end)
}}
end)
end
defp deserialize_variables(_), do: %{}
defp deserialize_attrs(map) when is_map(map) do
Map.new(map, fn {k, v} ->
{safe_to_atom(k), deserialize_value(v)}
end)
end
defp deserialize_attrs(_), do: %{}
defp deserialize_value(":" <> rest), do: safe_to_atom(rest)
defp deserialize_value("true"), do: true
defp deserialize_value("false"), do: false
defp deserialize_value(v), do: v
defp resolve_module(nil), do: nil
defp resolve_module(module_string) when is_binary(module_string) do
module_string
|> String.trim_leading("Elixir.")
|> then(fn s -> "Elixir." <> s end)
|> String.to_existing_atom()
rescue
ArgumentError -> nil
end
defp safe_to_atom(str) when is_binary(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> String.to_atom(str)
end
defp safe_to_atom(atom) when is_atom(atom), do: atom
defp get_meta(scene) do
case :ets.lookup(scene, :__meta__) do
[{:__meta__, meta}] -> meta
_ -> %{}
end
end
end