defmodule OrgGarden.Export do @moduledoc """ Org-to-Markdown export via Emacs batch + ox-hugo. Provides both single-file and batch export, plus a helper to compute the expected `.md` output path for a given `.org` source file. """ require Logger @doc """ Export a single `.org` file to Markdown via `emacs --batch` + ox-hugo. Returns `{:ok, exit_code}` with the emacs exit code (0 = success), or `{:error, reason}` if the command could not be executed. """ @spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()} def export_file(orgfile, notes_dir, output_dir) do section = orgfile |> Path.dirname() |> Path.relative_to(notes_dir) # ox-hugo requires static/ to exist for image asset copying File.mkdir_p!(Path.join(output_dir, "static")) {output, exit_code} = System.cmd( "emacs", [ "--batch", "--eval", "(require 'ox-hugo)", "--eval", """ (org-cite-register-processor 'passthrough :export-citation (lambda (citation _style _backend _info) (let ((keys (mapcar (lambda (ref) (concat "@" (org-element-property :key ref))) (org-cite-get-references citation)))) (format "[cite:%s]" (string-join keys ";"))))) """, "--eval", "(setq org-cite-export-processors '((t passthrough)))", "--eval", ~s[(setq org-hugo-base-dir "#{output_dir}")], "--eval", ~s[(setq org-hugo-default-section-directory "#{section}")], "--visit", orgfile, "--funcall", "org-hugo-export-to-md" ], stderr_to_stdout: true ) filtered = output |> String.split("\n") |> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/)) |> Enum.join("\n") if filtered != "", do: Logger.info("emacs: #{filtered}") if exit_code == 0 do {:ok, exit_code} else {:error, {:emacs_exit, exit_code, filtered}} end rescue e -> {:error, e} end @doc """ Export all `.org` files found under `notes_dir`. Returns `{:ok, count}` where `count` is the number of successfully exported files, or `{:error, failures}` if any files failed. """ @spec export_all(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, list()} def export_all(notes_dir, output_dir) do org_files = Path.join(notes_dir, "**/*.org") |> Path.wildcard() if org_files == [] do Logger.warning("No .org files found in #{notes_dir}") {:ok, 0} else Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir}") results = Enum.map(org_files, fn orgfile -> IO.puts(" exporting: #{orgfile}") {orgfile, export_file(orgfile, notes_dir, output_dir)} end) failures = Enum.filter(results, fn {_, {:ok, _}} -> false {_, {:error, _}} -> true end) if failures == [] do {:ok, length(results)} else {:error, failures} end end end @doc """ Compute the expected `.md` path for a given `.org` file. Uses the same section-mapping logic as ox-hugo: the relative directory of the `.org` file within `notes_dir` becomes the section directory under `content_dir`. ## Examples iex> OrgGarden.Export.expected_md_path("/notes/bus/emt.org", "/notes", "/out/content") "/out/content/bus/emt.md" iex> OrgGarden.Export.expected_md_path("/notes/top-level.org", "/notes", "/out/content") "/out/content/top-level.md" """ @spec expected_md_path(String.t(), String.t(), String.t()) :: String.t() def expected_md_path(orgfile, notes_dir, content_dir) do section = orgfile |> Path.dirname() |> Path.relative_to(notes_dir) basename = Path.basename(orgfile, ".org") <> ".md" case section do "." -> Path.join(content_dir, basename) _ -> Path.join([content_dir, section, basename]) end end end