feat: more work on backend content and generation
This commit is contained in:
@@ -7,6 +7,8 @@ defmodule BDS.Application do
|
|||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
BDS.Repo,
|
BDS.Repo,
|
||||||
|
BDS.Tasks,
|
||||||
|
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||||
BDS.Scripting.JobStore,
|
BDS.Scripting.JobStore,
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobSupervisor
|
||||||
|
|||||||
77
lib/bds/generation.ex
Normal file
77
lib/bds/generation.ex
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
defmodule BDS.Generation do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Generation.GeneratedFileHash
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
def write_generated_file(project_id, relative_path, content)
|
||||||
|
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
content_hash = sha256(content)
|
||||||
|
now = System.system_time(:second)
|
||||||
|
|
||||||
|
case Repo.get_by(GeneratedFileHash, project_id: project_id, relative_path: relative_path) do
|
||||||
|
%GeneratedFileHash{content_hash: ^content_hash} ->
|
||||||
|
{:ok, %{relative_path: relative_path, content_hash: content_hash, written?: false}}
|
||||||
|
|
||||||
|
_existing ->
|
||||||
|
full_path = output_path(project, relative_path)
|
||||||
|
:ok = File.mkdir_p(Path.dirname(full_path))
|
||||||
|
:ok = File.write(full_path, content)
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
project_id: project_id,
|
||||||
|
relative_path: relative_path,
|
||||||
|
content_hash: content_hash,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
%GeneratedFileHash{}
|
||||||
|
|> GeneratedFileHash.changeset(attrs)
|
||||||
|
|> Repo.insert!(
|
||||||
|
on_conflict: [set: [content_hash: content_hash, updated_at: now]],
|
||||||
|
conflict_target: [:project_id, :relative_path]
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, %{relative_path: relative_path, content_hash: content_hash, written?: true}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_generated_files(project_id) when is_binary(project_id) do
|
||||||
|
{:ok,
|
||||||
|
Repo.all(
|
||||||
|
from generated_file in GeneratedFileHash,
|
||||||
|
where: generated_file.project_id == ^project_id,
|
||||||
|
order_by: [asc: generated_file.relative_path]
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_generated_file(project_id, relative_path) when is_binary(project_id) and is_binary(relative_path) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
|
case File.rm(output_path(project, relative_path)) do
|
||||||
|
:ok -> :ok
|
||||||
|
{:error, :enoent} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
Repo.delete_all(
|
||||||
|
from generated_file in GeneratedFileHash,
|
||||||
|
where: generated_file.project_id == ^project_id and generated_file.relative_path == ^relative_path
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp output_path(project, relative_path) do
|
||||||
|
Path.join([Projects.project_data_dir(project), "html", relative_path])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sha256(content) do
|
||||||
|
:crypto.hash(:sha256, content)
|
||||||
|
|> Base.encode16(case: :lower)
|
||||||
|
end
|
||||||
|
end
|
||||||
23
lib/bds/generation/generated_file_hash.ex
Normal file
23
lib/bds/generation/generated_file_hash.ex
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
defmodule BDS.Generation.GeneratedFileHash do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key false
|
||||||
|
@foreign_key_type :string
|
||||||
|
|
||||||
|
schema "generated_file_hashes" do
|
||||||
|
field :project_id, :string
|
||||||
|
field :relative_path, :string
|
||||||
|
field :content_hash, :string
|
||||||
|
field :updated_at, :integer
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(record, attrs) do
|
||||||
|
record
|
||||||
|
|> cast(attrs, [:project_id, :relative_path, :content_hash, :updated_at], empty_values: [nil])
|
||||||
|
|> validate_required([:project_id, :relative_path, :content_hash, :updated_at])
|
||||||
|
|> unique_constraint(:relative_path, name: :generated_file_hashes_project_path_idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
200
lib/bds/menu.ex
Normal file
200
lib/bds/menu.ex
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
defmodule BDS.Menu do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Record
|
||||||
|
|
||||||
|
alias BDS.Projects
|
||||||
|
|
||||||
|
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
|
||||||
|
Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl"))
|
||||||
|
|
||||||
|
@valid_kinds [:page, :submenu, :category_archive, :home]
|
||||||
|
|
||||||
|
def get_menu(project_id) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
{:ok, load_menu(project)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_menu(project_id, items) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
menu = %{items: normalize_menu_items(items)}
|
||||||
|
:ok = write_menu_file(project, menu)
|
||||||
|
{:ok, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_menu_from_filesystem(project_id) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
menu = load_menu(project)
|
||||||
|
:ok = write_menu_file(project, menu)
|
||||||
|
{:ok, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_menu(project) do
|
||||||
|
case File.read(menu_path(project)) do
|
||||||
|
{:ok, contents} ->
|
||||||
|
%{items: parse_opml(contents) |> normalize_menu_items()}
|
||||||
|
|
||||||
|
{:error, :enoent} ->
|
||||||
|
%{items: normalize_menu_items([])}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write_menu_file(project, menu) do
|
||||||
|
meta_dir = Path.dirname(menu_path(project))
|
||||||
|
:ok = File.mkdir_p(meta_dir)
|
||||||
|
|
||||||
|
path = menu_path(project)
|
||||||
|
temp_path = path <> ".tmp"
|
||||||
|
|
||||||
|
:ok = File.write(temp_path, serialize_opml(menu.items))
|
||||||
|
File.rename(temp_path, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp menu_path(project) do
|
||||||
|
Path.join([Projects.project_data_dir(project), "meta", "menu.opml"])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_menu_items(items) do
|
||||||
|
without_home =
|
||||||
|
items
|
||||||
|
|> Enum.map(&normalize_menu_item/1)
|
||||||
|
|> Enum.reject(&(&1.kind == :home))
|
||||||
|
|
||||||
|
[%{kind: :home, label: "Home", slug: nil} | without_home]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_menu_item(item) do
|
||||||
|
kind = normalize_kind(attr(item, :kind))
|
||||||
|
children = attr(item, :children)
|
||||||
|
|
||||||
|
base = %{
|
||||||
|
kind: kind,
|
||||||
|
label: attr(item, :label) || "",
|
||||||
|
slug: normalize_optional_string(attr(item, :slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == :submenu do
|
||||||
|
Map.put(base, :children, Enum.map(children || [], &normalize_menu_item/1))
|
||||||
|
else
|
||||||
|
base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serialize_opml(items) do
|
||||||
|
rendered_items =
|
||||||
|
items
|
||||||
|
|> Enum.map(&render_item(&1, 2))
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
[
|
||||||
|
~s(<?xml version="1.0" encoding="UTF-8"?>),
|
||||||
|
~s(<opml version="2.0">),
|
||||||
|
~s( <body>),
|
||||||
|
rendered_items,
|
||||||
|
~s( </body>),
|
||||||
|
~s(</opml>),
|
||||||
|
""
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_item(item, level) do
|
||||||
|
indent = String.duplicate(" ", level)
|
||||||
|
attrs = render_attributes(item)
|
||||||
|
|
||||||
|
case Map.get(item, :children) do
|
||||||
|
children when is_list(children) and children != [] ->
|
||||||
|
child_markup =
|
||||||
|
children
|
||||||
|
|> Enum.map(&render_item(&1, level + 1))
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
[
|
||||||
|
"#{indent}<outline#{attrs}>",
|
||||||
|
child_markup,
|
||||||
|
"#{indent}</outline>"
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
_children ->
|
||||||
|
"#{indent}<outline#{attrs} />"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_attributes(item) do
|
||||||
|
[
|
||||||
|
{"kind", item.kind},
|
||||||
|
{"text", item.label},
|
||||||
|
{"slug", item.slug}
|
||||||
|
]
|
||||||
|
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|
||||||
|
|> Enum.map_join("", fn {key, value} -> ~s( #{key}="#{xml_escape(to_string(value))}") end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_opml(contents) do
|
||||||
|
{document, _rest} = :xmerl_scan.string(String.to_charlist(contents))
|
||||||
|
|
||||||
|
:xmerl_xpath.string(~c"/opml/body/outline", document)
|
||||||
|
|> Enum.map(&parse_outline/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_outline(element) do
|
||||||
|
kind = element |> xml_attr(:kind) |> normalize_kind()
|
||||||
|
|
||||||
|
base = %{
|
||||||
|
kind: kind,
|
||||||
|
label: xml_attr(element, :text) || "",
|
||||||
|
slug: normalize_optional_string(xml_attr(element, :slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
children =
|
||||||
|
:xmerl_xpath.string(~c"./outline", element)
|
||||||
|
|> Enum.map(&parse_outline/1)
|
||||||
|
|
||||||
|
if kind == :submenu do
|
||||||
|
Map.put(base, :children, children)
|
||||||
|
else
|
||||||
|
base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp xml_attr(element, name) do
|
||||||
|
element
|
||||||
|
|> xmlElement(:attributes)
|
||||||
|
|> Enum.find_value(fn attribute ->
|
||||||
|
if xmlAttribute(attribute, :name) == name do
|
||||||
|
attribute |> xmlAttribute(:value) |> to_string()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_kind(kind) when is_atom(kind) and kind in @valid_kinds, do: kind
|
||||||
|
|
||||||
|
defp normalize_kind(kind) when is_binary(kind) do
|
||||||
|
kind
|
||||||
|
|> String.to_existing_atom()
|
||||||
|
|> normalize_kind()
|
||||||
|
rescue
|
||||||
|
_error -> :page
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_optional_string(nil), do: nil
|
||||||
|
defp normalize_optional_string(""), do: nil
|
||||||
|
defp normalize_optional_string(value), do: to_string(value)
|
||||||
|
|
||||||
|
defp xml_escape(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace(~s(") , """)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp attr(attrs, key) do
|
||||||
|
cond do
|
||||||
|
Map.has_key?(attrs, key) -> Map.get(attrs, key)
|
||||||
|
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
294
lib/bds/tasks.ex
Normal file
294
lib/bds/tasks.ex
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
defmodule BDS.Tasks do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@default_max_concurrent 3
|
||||||
|
@default_progress_throttle_ms 250
|
||||||
|
|
||||||
|
def start_link(_opts) do
|
||||||
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit_task(name, work, attrs \\ %{}) when is_binary(name) and is_function(work, 1) and is_map(attrs) do
|
||||||
|
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_task(task_id) when is_binary(task_id) do
|
||||||
|
GenServer.call(__MODULE__, {:get_task, task_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_task(task_id) when is_binary(task_id) do
|
||||||
|
GenServer.call(__MODULE__, {:cancel_task, task_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_external_task(name, attrs \\ %{}) when is_binary(name) and is_map(attrs) do
|
||||||
|
GenServer.call(__MODULE__, {:register_external_task, name, attrs})
|
||||||
|
end
|
||||||
|
|
||||||
|
def report_progress(task_id, value, message) when is_binary(task_id) do
|
||||||
|
GenServer.call(__MODULE__, {:report_progress, task_id, value, message})
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_task(task_id) when is_binary(task_id) do
|
||||||
|
GenServer.call(__MODULE__, {:complete_task, task_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
def fail_task(task_id, error_message) when is_binary(task_id) do
|
||||||
|
GenServer.call(__MODULE__, {:fail_task, task_id, error_message})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_state) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
tasks: %{},
|
||||||
|
queue: [],
|
||||||
|
running: %{},
|
||||||
|
ref_to_task: %{}
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:submit_task, name, work, attrs}, _from, state) do
|
||||||
|
task = new_task(name, :pending, attrs)
|
||||||
|
next_state = put_in(state, [:tasks, task.id], task)
|
||||||
|
|
||||||
|
if map_size(next_state.running) < max_concurrent() do
|
||||||
|
{:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)}
|
||||||
|
else
|
||||||
|
{:reply, {:ok, public_task(task)}, %{next_state | queue: next_state.queue ++ [{task.id, work}]}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:get_task, task_id}, _from, state) do
|
||||||
|
{:reply, state.tasks[task_id] && public_task(state.tasks[task_id]), state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:cancel_task, task_id}, _from, state) do
|
||||||
|
cond do
|
||||||
|
Map.has_key?(state.running, task_id) ->
|
||||||
|
%{pid: pid, ref: ref} = state.running[task_id]
|
||||||
|
Process.exit(pid, :kill)
|
||||||
|
|
||||||
|
next_state =
|
||||||
|
state
|
||||||
|
|> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()})
|
||||||
|
|> remove_running(task_id, ref)
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:reply, :ok, next_state}
|
||||||
|
|
||||||
|
Enum.any?(state.queue, fn {queued_id, _work} -> queued_id == task_id end) ->
|
||||||
|
next_state =
|
||||||
|
state
|
||||||
|
|> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()})
|
||||||
|
|> Map.update!(:queue, fn queue -> Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end) end)
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:reply, :ok, next_state}
|
||||||
|
|
||||||
|
state.tasks[task_id] == nil ->
|
||||||
|
{:reply, {:error, :not_found}, state}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:reply, {:error, :not_running}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:register_external_task, name, attrs}, _from, state) do
|
||||||
|
task = new_task(name, :running, attrs) |> Map.put(:started_at, DateTime.utc_now())
|
||||||
|
next_state = put_in(state, [:tasks, task.id], task)
|
||||||
|
{:reply, {:ok, public_task(task)}, next_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:report_progress, task_id, value, message}, _from, state) do
|
||||||
|
{:reply, :ok, maybe_report_progress(state, task_id, value, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:complete_task, task_id}, _from, state) do
|
||||||
|
next_state =
|
||||||
|
state
|
||||||
|
|> update_task(task_id, %{status: :completed, progress: 1.0, finished_at: DateTime.utc_now()})
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:reply, :ok, next_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:fail_task, task_id, error_message}, _from, state) do
|
||||||
|
next_state =
|
||||||
|
state
|
||||||
|
|> update_task(task_id, %{status: :failed, message: error_message, finished_at: DateTime.utc_now()})
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:reply, :ok, next_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:task_progress, task_id, value, message}, state) do
|
||||||
|
{:noreply, maybe_report_progress(state, task_id, value, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({ref, result}, state) do
|
||||||
|
case state.ref_to_task[ref] do
|
||||||
|
nil ->
|
||||||
|
{:noreply, state}
|
||||||
|
|
||||||
|
task_id ->
|
||||||
|
Process.demonitor(ref, [:flush])
|
||||||
|
task = state.tasks[task_id]
|
||||||
|
|
||||||
|
next_state =
|
||||||
|
case task.status do
|
||||||
|
:cancelled ->
|
||||||
|
state
|
||||||
|
|
||||||
|
_status ->
|
||||||
|
attrs =
|
||||||
|
case normalize_result(result) do
|
||||||
|
{:ok, value} -> %{status: :completed, result: value, progress: 1.0, finished_at: DateTime.utc_now()}
|
||||||
|
{:error, reason} -> %{status: :failed, error: reason, finished_at: DateTime.utc_now()}
|
||||||
|
end
|
||||||
|
|
||||||
|
update_task(state, task_id, attrs)
|
||||||
|
end
|
||||||
|
|> remove_running(task_id, ref)
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:noreply, next_state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
|
||||||
|
case state.ref_to_task[ref] do
|
||||||
|
nil ->
|
||||||
|
{:noreply, state}
|
||||||
|
|
||||||
|
task_id ->
|
||||||
|
task = state.tasks[task_id]
|
||||||
|
|
||||||
|
next_state =
|
||||||
|
cond do
|
||||||
|
task.status == :cancelled ->
|
||||||
|
state
|
||||||
|
|
||||||
|
reason == :normal ->
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
update_task(state, task_id, %{status: :failed, error: reason, finished_at: DateTime.utc_now()})
|
||||||
|
end
|
||||||
|
|> remove_running(task_id, ref)
|
||||||
|
|> start_queued_tasks()
|
||||||
|
|
||||||
|
{:noreply, next_state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_task(state, task_id, work) do
|
||||||
|
reporter = fn value, message -> send(__MODULE__, {:task_progress, task_id, value, message}) end
|
||||||
|
|
||||||
|
task =
|
||||||
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
|
work.(reporter)
|
||||||
|
end)
|
||||||
|
|
||||||
|
state
|
||||||
|
|> update_task(task_id, %{status: :running, started_at: DateTime.utc_now()})
|
||||||
|
|> put_in([:running, task_id], %{pid: task.pid, ref: task.ref})
|
||||||
|
|> put_in([:ref_to_task, task.ref], task_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_queued_tasks(state) do
|
||||||
|
cond do
|
||||||
|
map_size(state.running) >= max_concurrent() ->
|
||||||
|
state
|
||||||
|
|
||||||
|
state.queue == [] ->
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
[{task_id, work} | remaining] = state.queue
|
||||||
|
state
|
||||||
|
|> Map.put(:queue, remaining)
|
||||||
|
|> start_task(task_id, work)
|
||||||
|
|> start_queued_tasks()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp remove_running(state, task_id, ref) do
|
||||||
|
state
|
||||||
|
|> update_in([:running], &Map.delete(&1, task_id))
|
||||||
|
|> update_in([:ref_to_task], &Map.delete(&1, ref))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_report_progress(state, task_id, value, message) do
|
||||||
|
case state.tasks[task_id] do
|
||||||
|
nil ->
|
||||||
|
state
|
||||||
|
|
||||||
|
task ->
|
||||||
|
now_ms = System.monotonic_time(:millisecond)
|
||||||
|
last_reported_at = Map.get(task, :last_reported_at)
|
||||||
|
|
||||||
|
if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or value == 1.0 do
|
||||||
|
update_task(state, task_id, %{progress: value, message: message, last_reported_at: now_ms})
|
||||||
|
else
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp new_task(name, status, attrs) do
|
||||||
|
%{
|
||||||
|
id: "task-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])),
|
||||||
|
name: name,
|
||||||
|
status: status,
|
||||||
|
progress: nil,
|
||||||
|
message: nil,
|
||||||
|
group_id: attr(attrs, :group_id),
|
||||||
|
group_name: attr(attrs, :group_name),
|
||||||
|
created_at: DateTime.utc_now(),
|
||||||
|
started_at: nil,
|
||||||
|
finished_at: nil,
|
||||||
|
result: nil,
|
||||||
|
error: nil,
|
||||||
|
last_reported_at: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_task(state, task_id, attrs) do
|
||||||
|
update_in(state, [:tasks, task_id], fn
|
||||||
|
nil -> nil
|
||||||
|
task -> Map.merge(task, attrs)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp public_task(nil), do: nil
|
||||||
|
|
||||||
|
defp public_task(task) do
|
||||||
|
Map.drop(task, [:last_reported_at])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_result({:ok, _value} = result), do: result
|
||||||
|
defp normalize_result({:error, _reason} = result), do: result
|
||||||
|
defp normalize_result(value), do: {:ok, value}
|
||||||
|
|
||||||
|
defp max_concurrent do
|
||||||
|
Application.get_env(:bds, :tasks, [])
|
||||||
|
|> Keyword.get(:max_concurrent, @default_max_concurrent)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp progress_throttle_ms do
|
||||||
|
Application.get_env(:bds, :tasks, [])
|
||||||
|
|> Keyword.get(:progress_throttle_ms, @default_progress_throttle_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp attr(attrs, key) do
|
||||||
|
cond do
|
||||||
|
Map.has_key?(attrs, key) -> Map.get(attrs, key)
|
||||||
|
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
47
test/bds/generation_test.exs
Normal file
47
test/bds/generation_test.exs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
defmodule BDS.GenerationTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "Generation", data_path: temp_dir})
|
||||||
|
%{project: project, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "write_generated_file writes under html output and skips unchanged content by hash", %{project: project, temp_dir: temp_dir} do
|
||||||
|
assert {:ok, first_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>")
|
||||||
|
assert first_write.written? == true
|
||||||
|
|
||||||
|
output_path = Path.join([temp_dir, "html", "index.html"])
|
||||||
|
assert File.read!(output_path) == "<html>hello</html>"
|
||||||
|
|
||||||
|
assert {:ok, [tracked_file]} = BDS.Generation.list_generated_files(project.id)
|
||||||
|
assert tracked_file.relative_path == "index.html"
|
||||||
|
assert tracked_file.content_hash == first_write.content_hash
|
||||||
|
|
||||||
|
assert {:ok, second_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>")
|
||||||
|
assert second_write.written? == false
|
||||||
|
assert second_write.content_hash == first_write.content_hash
|
||||||
|
|
||||||
|
assert {:ok, third_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>updated</html>")
|
||||||
|
assert third_write.written? == true
|
||||||
|
assert third_write.content_hash != first_write.content_hash
|
||||||
|
assert File.read!(output_path) == "<html>updated</html>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_generated_file removes tracked output and forgets its hash", %{project: project, temp_dir: temp_dir} do
|
||||||
|
assert {:ok, _write} = BDS.Generation.write_generated_file(project.id, "tag/elixir/index.html", "<html>tag</html>")
|
||||||
|
|
||||||
|
output_path = Path.join([temp_dir, "html", "tag", "elixir", "index.html"])
|
||||||
|
assert File.exists?(output_path)
|
||||||
|
|
||||||
|
assert :ok = BDS.Generation.delete_generated_file(project.id, "tag/elixir/index.html")
|
||||||
|
refute File.exists?(output_path)
|
||||||
|
|
||||||
|
assert {:ok, files} = BDS.Generation.list_generated_files(project.id)
|
||||||
|
assert files == []
|
||||||
|
end
|
||||||
|
end
|
||||||
90
test/bds/menu_test.exs
Normal file
90
test/bds/menu_test.exs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
defmodule BDS.MenuTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-menu-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "Menu", data_path: temp_dir})
|
||||||
|
%{project: project, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items", %{project: project, temp_dir: temp_dir} do
|
||||||
|
assert {:ok, menu} =
|
||||||
|
BDS.Menu.update_menu(project.id, [
|
||||||
|
%{kind: :page, label: "About", slug: "about"},
|
||||||
|
%{
|
||||||
|
kind: :submenu,
|
||||||
|
label: "Sections",
|
||||||
|
children: [
|
||||||
|
%{kind: :category_archive, label: "Notes", slug: "notes"},
|
||||||
|
%{kind: :page, label: "Contact", slug: "contact"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
%{kind: :home, label: "Ignored Home"}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert hd(menu.items) == %{kind: :home, label: "Home", slug: nil}
|
||||||
|
assert Enum.at(menu.items, 1) == %{kind: :page, label: "About", slug: "about"}
|
||||||
|
|
||||||
|
assert Enum.at(menu.items, 2) == %{
|
||||||
|
kind: :submenu,
|
||||||
|
label: "Sections",
|
||||||
|
slug: nil,
|
||||||
|
children: [
|
||||||
|
%{kind: :category_archive, label: "Notes", slug: "notes"},
|
||||||
|
%{kind: :page, label: "Contact", slug: "contact"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
opml_path = Path.join([temp_dir, "meta", "menu.opml"])
|
||||||
|
assert File.exists?(opml_path)
|
||||||
|
|
||||||
|
contents = File.read!(opml_path)
|
||||||
|
assert contents =~ ~s(<outline kind="home" text="Home")
|
||||||
|
assert contents =~ ~s(<outline kind="page" text="About" slug="about")
|
||||||
|
assert contents =~ ~s(<outline kind="submenu" text="Sections")
|
||||||
|
assert contents =~ ~s(<outline kind="category_archive" text="Notes" slug="notes")
|
||||||
|
|
||||||
|
assert {:ok, loaded} = BDS.Menu.get_menu(project.id)
|
||||||
|
assert loaded == menu
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{project: project, temp_dir: temp_dir} do
|
||||||
|
meta_dir = Path.join(temp_dir, "meta")
|
||||||
|
File.mkdir_p!(meta_dir)
|
||||||
|
|
||||||
|
File.write!(
|
||||||
|
Path.join(meta_dir, "menu.opml"),
|
||||||
|
[
|
||||||
|
~s(<?xml version="1.0" encoding="UTF-8"?>),
|
||||||
|
~s(<opml version="2.0">),
|
||||||
|
~s( <body>),
|
||||||
|
~s( <outline kind="page" text="Blog" slug="blog" />),
|
||||||
|
~s( <outline kind="submenu" text="Topics">),
|
||||||
|
~s( <outline kind="category_archive" text="Elixir" slug="elixir" />),
|
||||||
|
~s( </outline>),
|
||||||
|
~s( </body>),
|
||||||
|
~s(</opml>)
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, menu} = BDS.Menu.sync_menu_from_filesystem(project.id)
|
||||||
|
|
||||||
|
assert menu.items == [
|
||||||
|
%{kind: :home, label: "Home", slug: nil},
|
||||||
|
%{kind: :page, label: "Blog", slug: "blog"},
|
||||||
|
%{
|
||||||
|
kind: :submenu,
|
||||||
|
label: "Topics",
|
||||||
|
slug: nil,
|
||||||
|
children: [
|
||||||
|
%{kind: :category_archive, label: "Elixir", slug: "elixir"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
136
test/bds/tasks_test.exs
Normal file
136
test/bds/tasks_test.exs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
defmodule BDS.TasksTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
original = Application.get_env(:bds, :tasks, [])
|
||||||
|
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:bds, :tasks, original)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submitted tasks respect max concurrency and FIFO queue order" do
|
||||||
|
runner = self()
|
||||||
|
|
||||||
|
work = fn name ->
|
||||||
|
fn _report ->
|
||||||
|
send(runner, {:started, name, self()})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:release, ^name} -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, name}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:ok, first} = BDS.Tasks.submit_task("first", work.("first"))
|
||||||
|
assert {:ok, second} = BDS.Tasks.submit_task("second", work.("second"))
|
||||||
|
assert {:ok, third} = BDS.Tasks.submit_task("third", work.("third"))
|
||||||
|
assert {:ok, fourth} = BDS.Tasks.submit_task("fourth", work.("fourth"))
|
||||||
|
|
||||||
|
started = for _ <- 1..3, do: receive_started()
|
||||||
|
assert Enum.sort(Enum.map(started, &elem(&1, 0))) == ["first", "second", "third"]
|
||||||
|
|
||||||
|
started_by_name = Map.new(started, fn {name, pid} -> {name, pid} end)
|
||||||
|
|
||||||
|
assert BDS.Tasks.get_task(first.id).status == :running
|
||||||
|
assert BDS.Tasks.get_task(second.id).status == :running
|
||||||
|
assert BDS.Tasks.get_task(third.id).status == :running
|
||||||
|
assert BDS.Tasks.get_task(fourth.id).status == :pending
|
||||||
|
|
||||||
|
send(started_by_name["first"], {:release, "first"})
|
||||||
|
|
||||||
|
assert wait_for_task(first.id, &(&1.status == :completed)).result == "first"
|
||||||
|
{"fourth", fourth_pid} = receive_started()
|
||||||
|
assert wait_for_task(fourth.id, &(&1.status == :running)).status == :running
|
||||||
|
|
||||||
|
send(started_by_name["second"], {:release, "second"})
|
||||||
|
send(started_by_name["third"], {:release, "third"})
|
||||||
|
send(fourth_pid, {:release, "fourth"})
|
||||||
|
|
||||||
|
assert wait_for_task(second.id, &(&1.status == :completed)).result == "second"
|
||||||
|
assert wait_for_task(third.id, &(&1.status == :completed)).result == "third"
|
||||||
|
assert wait_for_task(fourth.id, &(&1.status == :completed)).result == "fourth"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cancel_task cancels pending and running tasks" do
|
||||||
|
runner = self()
|
||||||
|
|
||||||
|
blocking = fn name ->
|
||||||
|
fn _report ->
|
||||||
|
send(runner, {:started, name, self()})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:release, ^name} -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, name}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:ok, first} = BDS.Tasks.submit_task("one", blocking.("one"))
|
||||||
|
assert {:ok, second} = BDS.Tasks.submit_task("two", blocking.("two"))
|
||||||
|
assert {:ok, third} = BDS.Tasks.submit_task("three", blocking.("three"))
|
||||||
|
assert {:ok, pending} = BDS.Tasks.submit_task("four", blocking.("four"))
|
||||||
|
|
||||||
|
started = for _ <- 1..3, do: receive_started()
|
||||||
|
started_by_name = Map.new(started, fn {name, pid} -> {name, pid} end)
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.cancel_task(pending.id)
|
||||||
|
assert wait_for_task(pending.id, &(&1.status == :cancelled)).status == :cancelled
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.cancel_task(first.id)
|
||||||
|
assert wait_for_task(first.id, &(&1.status == :cancelled)).status == :cancelled
|
||||||
|
|
||||||
|
send(started_by_name["two"], {:release, "two"})
|
||||||
|
send(started_by_name["three"], {:release, "three"})
|
||||||
|
|
||||||
|
assert wait_for_task(second.id, &(&1.status == :completed)).status == :completed
|
||||||
|
assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed
|
||||||
|
end
|
||||||
|
|
||||||
|
test "external tasks are registered as running and can report progress and complete" do
|
||||||
|
assert {:ok, task} = BDS.Tasks.register_external_task("preview build", %{group_id: "generation", group_name: "Generation"})
|
||||||
|
|
||||||
|
assert task.status == :running
|
||||||
|
assert task.group_id == "generation"
|
||||||
|
assert task.group_name == "Generation"
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
|
||||||
|
|
||||||
|
progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway"))
|
||||||
|
assert progressed.status == :running
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.complete_task(task.id)
|
||||||
|
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == :completed
|
||||||
|
end
|
||||||
|
|
||||||
|
defp receive_started do
|
||||||
|
receive do
|
||||||
|
{:started, name, pid} -> {name, pid}
|
||||||
|
after
|
||||||
|
1_000 -> flunk("task did not start")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_task(task_id, predicate, attempts \\ 100)
|
||||||
|
|
||||||
|
defp wait_for_task(task_id, predicate, attempts) when attempts > 0 do
|
||||||
|
task = BDS.Tasks.get_task(task_id)
|
||||||
|
|
||||||
|
if predicate.(task) do
|
||||||
|
task
|
||||||
|
else
|
||||||
|
Process.sleep(20)
|
||||||
|
wait_for_task(task_id, predicate, attempts - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_task(_task_id, _predicate, 0) do
|
||||||
|
flunk("task did not reach expected state")
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user