forked from github/quartz
237 lines
7.2 KiB
Elixir
237 lines
7.2 KiB
Elixir
defmodule OrgGarden.Watcher do
|
|
@moduledoc """
|
|
File-watching GenServer that detects `.org` file changes and triggers
|
|
incremental export + transform for only the affected files.
|
|
|
|
Uses the `file_system` package (inotify on Linux, fsevents on macOS)
|
|
to watch the notes directory. Events are debounced per-file (500ms)
|
|
to coalesce rapid writes (e.g., Emacs auto-save).
|
|
|
|
## Lifecycle
|
|
|
|
Started dynamically by `OrgGarden.CLI` after the initial batch export.
|
|
Transforms are initialized once at startup and reused across all
|
|
incremental rebuilds to avoid repeated Zotero probes and BibTeX loads.
|
|
|
|
## Usage
|
|
|
|
OrgGarden.Watcher.start_link(
|
|
notes_dir: "/path/to/notes",
|
|
output_dir: "/path/to/output",
|
|
content_dir: "/path/to/output/content",
|
|
pipeline_opts: %{zotero_url: "...", ...},
|
|
transforms: [OrgGarden.Transforms.Citations]
|
|
)
|
|
"""
|
|
|
|
use GenServer
|
|
|
|
require Logger
|
|
|
|
@debounce_ms 500
|
|
|
|
# -------------------------------------------------------------------
|
|
# Client API
|
|
# -------------------------------------------------------------------
|
|
|
|
@doc """
|
|
Start the watcher as a linked process.
|
|
|
|
## Options
|
|
|
|
* `:notes_dir` — directory to watch for `.org` changes (required)
|
|
* `:output_dir` — ox-hugo base dir (required)
|
|
* `:content_dir` — directory where `.md` files are written (required)
|
|
* `:pipeline_opts` — opts map passed to transforms (required)
|
|
* `:transforms` — list of transform modules (default: `[OrgGarden.Transforms.Citations]`)
|
|
"""
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# GenServer callbacks
|
|
# -------------------------------------------------------------------
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
notes_dir = Keyword.fetch!(opts, :notes_dir)
|
|
output_dir = Keyword.fetch!(opts, :output_dir)
|
|
content_dir = Keyword.fetch!(opts, :content_dir)
|
|
pipeline_opts = Keyword.fetch!(opts, :pipeline_opts)
|
|
transforms = Keyword.get(opts, :transforms, [OrgGarden.Transforms.Citations])
|
|
|
|
# Initialize transforms once — reused for all incremental rebuilds
|
|
initialized_transforms = OrgGarden.init_transforms(transforms, pipeline_opts)
|
|
|
|
# Start the file system watcher
|
|
{:ok, watcher_pid} = FileSystem.start_link(dirs: [notes_dir], recursive: true)
|
|
FileSystem.subscribe(watcher_pid)
|
|
|
|
Logger.info("Watcher: monitoring #{notes_dir} for .org changes")
|
|
|
|
{:ok,
|
|
%{
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
watcher_pid: watcher_pid,
|
|
initialized_transforms: initialized_transforms,
|
|
pending: %{}
|
|
}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:file_event, _pid, {path, events}}, state) do
|
|
path = to_string(path)
|
|
|
|
if org_file?(path) and not temporary_file?(path) do
|
|
event_type = classify_events(events)
|
|
Logger.debug("Watcher: #{event_type} event for #{path}")
|
|
{:noreply, schedule_debounce(path, event_type, state)}
|
|
else
|
|
{:noreply, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:file_event, _pid, :stop}, state) do
|
|
Logger.warning("Watcher: file system monitor stopped unexpectedly")
|
|
{:stop, :watcher_stopped, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:debounced, path, event_type}, state) do
|
|
state = %{state | pending: Map.delete(state.pending, path)}
|
|
|
|
case event_type do
|
|
:deleted ->
|
|
handle_delete(path, state)
|
|
|
|
_created_or_modified ->
|
|
handle_change(path, state)
|
|
end
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, state) do
|
|
OrgGarden.teardown_transforms(state.initialized_transforms)
|
|
:ok
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# Event handling
|
|
# -------------------------------------------------------------------
|
|
|
|
defp handle_change(orgfile, state) do
|
|
%{
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
initialized_transforms: initialized_transforms
|
|
} = state
|
|
|
|
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
IO.puts("==> Changed: #{Path.relative_to(orgfile, notes_dir)}")
|
|
|
|
case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
|
|
{:ok, _} ->
|
|
IO.puts(" exported: #{Path.relative_to(md_path, content_dir)}")
|
|
|
|
{:ok, stats} = OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
|
|
|
|
Enum.each(stats, fn {mod, count} ->
|
|
if count > 0, do: IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
|
end)
|
|
|
|
regenerate_index(content_dir)
|
|
IO.puts("==> Done")
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}")
|
|
end
|
|
end
|
|
|
|
defp handle_delete(orgfile, state) do
|
|
%{notes_dir: notes_dir, content_dir: content_dir} = state
|
|
|
|
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
IO.puts("==> Deleted: #{Path.relative_to(orgfile, notes_dir)}")
|
|
|
|
if File.exists?(md_path) do
|
|
File.rm!(md_path)
|
|
IO.puts(" removed: #{Path.relative_to(md_path, content_dir)}")
|
|
|
|
# Clean up empty parent directories left behind
|
|
cleanup_empty_dirs(Path.dirname(md_path), content_dir)
|
|
end
|
|
|
|
regenerate_index(content_dir)
|
|
IO.puts("==> Done")
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# Index generation
|
|
# -------------------------------------------------------------------
|
|
|
|
defp regenerate_index(content_dir) do
|
|
OrgGarden.Index.regenerate(content_dir)
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# Helpers
|
|
# -------------------------------------------------------------------
|
|
|
|
defp schedule_debounce(path, event_type, state) do
|
|
# Cancel any existing timer for this path
|
|
case Map.get(state.pending, path) do
|
|
nil -> :ok
|
|
old_ref -> Process.cancel_timer(old_ref)
|
|
end
|
|
|
|
ref = Process.send_after(self(), {:debounced, path, event_type}, @debounce_ms)
|
|
%{state | pending: Map.put(state.pending, path, ref)}
|
|
end
|
|
|
|
defp org_file?(path), do: String.ends_with?(path, ".org")
|
|
|
|
defp temporary_file?(path) do
|
|
basename = Path.basename(path)
|
|
# Emacs creates temp files like .#file.org and #file.org#
|
|
String.starts_with?(basename, ".#") or
|
|
(String.starts_with?(basename, "#") and String.ends_with?(basename, "#"))
|
|
end
|
|
|
|
defp classify_events(events) do
|
|
cond do
|
|
:removed in events or :deleted in events -> :deleted
|
|
:created in events -> :created
|
|
:modified in events or :changed in events -> :modified
|
|
# renamed can mean created or deleted depending on context;
|
|
# if the file exists it was renamed into the watched dir
|
|
:renamed in events -> :modified
|
|
true -> :modified
|
|
end
|
|
end
|
|
|
|
defp cleanup_empty_dirs(dir, stop_at) do
|
|
dir = Path.expand(dir)
|
|
stop_at = Path.expand(stop_at)
|
|
|
|
if dir != stop_at and File.dir?(dir) do
|
|
case File.ls!(dir) do
|
|
[] ->
|
|
File.rmdir!(dir)
|
|
cleanup_empty_dirs(Path.dirname(dir), stop_at)
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
end
|
|
end
|
|
end
|