defmodule OrgGarden.Server do @moduledoc """ Manages the serve mode lifecycle. This GenServer encapsulates the full serve mode workflow: 1. Run initial export pipeline 2. Start supervised Watcher + Quartz under DynamicSupervisor 3. Handle graceful shutdown on termination ## Usage # Start the server (blocks until stopped) {:ok, pid} = OrgGarden.Server.start_link( notes_dir: "/path/to/notes", output_dir: "/path/to/output" ) # Stop gracefully OrgGarden.Server.stop() """ use GenServer require Logger alias OrgGarden.Config @transforms [OrgGarden.Transforms.Citations] # ------------------------------------------------------------------- # Client API # ------------------------------------------------------------------- @doc """ Start the serve mode server. ## Options * `:notes_dir` — path to org-roam notes (required) * `:output_dir` — output base directory (required) * `:content_dir` — markdown output directory (default: output_dir/content) * `:port` — HTTP server port (default: from config) * `:ws_port` — WebSocket port (default: from config) """ def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Stop the server gracefully. """ def stop(timeout \\ 10_000) do GenServer.stop(__MODULE__, :normal, timeout) end @doc """ Check if the server is running. """ def running? do Process.whereis(__MODULE__) != nil end # ------------------------------------------------------------------- # GenServer callbacks # ------------------------------------------------------------------- @impl true def init(opts) do Process.flag(:trap_exit, true) notes_dir = Keyword.fetch!(opts, :notes_dir) output_dir = Keyword.fetch!(opts, :output_dir) content_dir = Keyword.get(opts, :content_dir, Path.join(output_dir, "content")) http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080)) ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001)) pipeline_opts = Config.pipeline_opts() state = %{ notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts, http_port: http_port, ws_port: ws_port, supervisor_pid: nil } # Run initial pipeline synchronously :ok = run_initial_pipeline(state) # Start supervised components case start_supervisor(state) do {:ok, sup_pid} -> Logger.info("Server started on http://localhost:#{http_port}") Logger.info("Watching #{notes_dir} for changes") {:ok, %{state | supervisor_pid: sup_pid}} {:error, reason} -> {:stop, reason} end end @impl true def handle_info({:EXIT, pid, reason}, %{supervisor_pid: pid} = state) do Logger.error("Supervisor exited: #{inspect(reason)}") {:stop, {:supervisor_exit, reason}, state} end @impl true def handle_info({:EXIT, _pid, reason}, state) do Logger.warning("Linked process exited: #{inspect(reason)}") {:noreply, state} end @impl true def terminate(reason, state) do Logger.info("Server shutting down: #{inspect(reason)}") if state.supervisor_pid do # Stop the supervisor gracefully DynamicSupervisor.terminate_child(OrgGarden.DynamicSupervisor, state.supervisor_pid) end :ok end # ------------------------------------------------------------------- # Private functions # ------------------------------------------------------------------- defp run_initial_pipeline(state) do %{ notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts } = state Logger.info("Running initial export pipeline...") # Wipe content directory wipe(content_dir) # Export all org files case OrgGarden.Export.export_all(notes_dir, output_dir) do {:ok, 0, []} -> Logger.warning("No .org files found in #{notes_dir}") # Still generate index (will be empty or have default content) OrgGarden.Index.generate(content_dir) :ok {:ok, count, []} -> Logger.info("Exported #{count} file(s)") # Run transforms {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts) Enum.each(stats, fn {mod, c} -> Logger.info("#{inspect(mod)}: #{c} file(s) modified") end) # Generate index OrgGarden.Index.generate(content_dir) :ok {:ok, count, failures} -> Logger.warning("Exported #{count} file(s), #{length(failures)} failed") Enum.each(failures, fn {f, {:error, {:emacs_exit, code}}} -> Logger.warning(" failed: #{Path.basename(f)} (emacs exit code #{code})") end) # Continue with transforms and index anyway {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts) Enum.each(stats, fn {mod, c} -> Logger.info("#{inspect(mod)}: #{c} file(s) modified") end) # Generate index OrgGarden.Index.generate(content_dir) :ok end end defp start_supervisor(state) do %{ notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts, http_port: http_port, ws_port: ws_port } = state child_spec = { OrgGarden.Supervisor, [ notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts, transforms: @transforms, port: http_port, ws_port: ws_port ] } DynamicSupervisor.start_child(OrgGarden.DynamicSupervisor, child_spec) end defp wipe(content_dir) do Logger.info("Wiping #{content_dir}") File.mkdir_p!(content_dir) content_dir |> File.ls!() |> Enum.reject(&(&1 == ".gitkeep")) |> Enum.each(fn entry -> Path.join(content_dir, entry) |> File.rm_rf!() end) end end