Files
bDS2/lib/bds/projects.ex

266 lines
7.4 KiB
Elixir

defmodule BDS.Projects do
@moduledoc false
import Ecto.Query
alias BDS.Metadata
alias BDS.Persistence
alias BDS.Projects.Project
alias BDS.Repo
alias BDS.Slug
alias BDS.Templates
@default_project_id "default"
@default_project_name "My Blog"
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@typedoc "Summary map returned for the shell projects panel."
@type project_summary :: %{
id: String.t(),
name: String.t(),
slug: String.t(),
data_path: String.t() | nil,
is_active: boolean()
}
@typedoc "Snapshot returned to the desktop shell."
@type shell_snapshot :: %{
active_project_id: String.t() | nil,
projects: [project_summary()]
}
@spec list_projects() :: [Project.t()]
def list_projects do
Repo.all(from project in Project, order_by: [asc: project.created_at])
end
@spec get_active_project() :: Project.t() | nil
def get_active_project do
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
end
@spec shell_snapshot() :: shell_snapshot()
def shell_snapshot do
_ = ensure_default_project()
projects = list_projects()
active_project = Enum.find(projects, & &1.is_active)
%{
active_project_id: active_project && active_project.id,
projects: Enum.map(projects, &project_summary/1)
}
end
@spec get_project(String.t()) :: Project.t() | nil
def get_project(id), do: Repo.get(Project, id)
@spec get_project!(String.t()) :: Project.t()
def get_project!(id), do: Repo.get!(Project, id)
@spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()}
def ensure_default_project do
case Repo.get(Project, @default_project_id) do
%Project{} = project ->
{:ok, project}
nil ->
now = Persistence.now_ms()
is_active = not Repo.exists?(from project in Project, where: project.is_active == true)
Repo.transaction(fn ->
project =
%Project{}
|> Project.changeset(%{
id: @default_project_id,
name: @default_project_name,
slug: unique_slug(Slug.slugify(@default_project_name)),
description: nil,
data_path: nil,
created_at: now,
updated_at: now,
is_active: is_active
})
|> Repo.insert!()
{:ok, _templates} = Templates.rebuild_templates_from_files(project.id)
project
end)
|> case do
{:ok, project} -> {:ok, project}
{:error, reason} -> {:error, reason}
end
end
end
@spec project_data_dir(Project.t()) :: String.t()
def project_data_dir(%Project{} = project) do
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
end
@spec project_cache_dir(Project.t() | String.t()) :: String.t()
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
def project_cache_dir(project_id) when is_binary(project_id) do
Path.join([project_cache_root(), "projects", project_id])
end
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
def create_project(attrs) do
now = Persistence.now_ms()
name = attr(attrs, :name) || ""
slug = unique_slug(attr(attrs, :slug) || Slug.slugify(name))
Repo.transaction(fn ->
project =
%Project{}
|> Project.changeset(%{
id: Ecto.UUID.generate(),
name: name,
slug: slug,
description: attr(attrs, :description),
data_path: attr(attrs, :data_path),
created_at: now,
updated_at: now,
is_active: false
})
|> Repo.insert!()
{:ok, _templates} = Templates.rebuild_templates_from_files(project.id)
project
end)
|> case do
{:ok, project} -> sync_filesystem_metadata(project)
{:error, reason} -> {:error, reason}
end
end
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
def set_active_project(project_id) do
case Repo.get(Project, project_id) do
nil ->
{:error, :not_found}
project ->
now = Persistence.now_ms()
Repo.transaction(fn ->
Repo.update_all(
from(p in Project, where: p.is_active == true),
set: [is_active: false, updated_at: now]
)
project
|> Project.changeset(%{is_active: true, updated_at: now})
|> Repo.update!()
end)
|> case do
{:ok, active_project} -> {:ok, active_project}
{:error, reason} -> {:error, reason}
end
end
end
@spec delete_project(String.t()) ::
{:ok, Project.t()}
| {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
def delete_project(project_id) when is_binary(project_id) do
case Repo.get(Project, project_id) do
nil ->
{:error, :not_found}
%Project{id: @default_project_id} ->
{:error, :cannot_delete_default_project}
%Project{is_active: true} ->
{:error, :cannot_delete_active_project}
%Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil
cleanup_dirs = [internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
Repo.transaction(fn ->
Repo.delete!(project)
project
end)
|> case do
{:ok, deleted_project} ->
Enum.each(cleanup_dirs, fn dir ->
_ = File.rm_rf(dir)
end)
{:ok, deleted_project}
{:error, reason} ->
{:error, reason}
end
end
end
defp project_summary(%Project{} = project) do
%{
id: project.id,
name: project.name,
slug: project.slug,
data_path: project.data_path,
is_active: project.is_active
}
end
defp sync_filesystem_metadata(%Project{data_path: nil} = project), do: {:ok, project}
defp sync_filesystem_metadata(%Project{} = project) do
case Metadata.sync_project_metadata_from_filesystem(project.id) do
{:ok, _metadata} -> {:ok, get_project!(project.id)}
{:error, reason} -> {:error, reason}
end
end
defp unique_slug(base_slug) do
normalized = if base_slug in [nil, ""], do: "project", else: base_slug
if slug_available?(normalized) do
normalized
else
find_unique_slug(normalized, 2)
end
end
defp find_unique_slug(base_slug, suffix) do
candidate = "#{base_slug}-#{suffix}"
if slug_available?(candidate) do
candidate
else
find_unique_slug(base_slug, suffix + 1)
end
end
defp slug_available?(slug) do
not Repo.exists?(from project in Project, where: project.slug == ^slug)
end
defp repo_data_dir do
Application.fetch_env!(:bds, BDS.Repo)
|> Keyword.fetch!(:database)
|> Path.expand()
|> Path.dirname()
end
defp project_cache_root do
case Application.get_env(:bds, :project_cache_root) do
root when is_binary(root) -> Path.expand(root)
_other -> repo_data_dir()
end
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