@@ -6,11 +6,25 @@ defmodule ShuttleWeb.FeltEditController do
66 resolves to this daemon, so a viewer's tag edit on a remote-owned card routes
77 here over the SSH tunnel instead of editing the viewer's own loom mirror.
88 Single-writer at the document holds: the owner daemon is the lone writer of a
9- fiber it owns, and `felt edit` is the single felt-native tag writer (the same
10- CLI Portolan shells for local cards).
9+ fiber it owns, and `felt edit` is the single felt-native writer (the same CLI
10+ Portolan shells for local cards) for tags, opaque scalar frontmatter, and the
11+ native `due:` date.
1112
1213 `POST /api/v1/felt-edit` body: `{ "fiber_id": "...", "add": [...],
13- "remove": [...] }`. An empty diff is a 200 no-op.
14+ "remove": [...], "set": {"key": scalar, ...}, "unset": [...], "due": "..." }`.
15+
16+ * `add` / `remove` — tag diff (`felt edit --tag/--untag`).
17+ * `set` — opaque top-level scalar frontmatter (`felt edit --set key=value`);
18+ the felt CLI reads each value as a YAML scalar so booleans/numbers keep
19+ their type. Used by the cross-host kanban horizon edit (`horizon`/`cold`).
20+ * `unset` — remove opaque top-level keys (`felt edit --unset key`).
21+ * `due` — the native date. Absent leaves it; `null` clears it
22+ (`--due ""`); a string sets it (`--due <value>`).
23+
24+ felt itself owns the validation (native-key guard, scalar-only, structured
25+ clobber refusal) and surfaces a loud non-zero exit, so the daemon does not
26+ re-implement those rails. An empty diff (no tags, no set/unset, no `due` key)
27+ is a 200 no-op.
1428 """
1529
1630 use Phoenix.Controller , formats: [ :json ]
@@ -20,9 +34,12 @@ defmodule ShuttleWeb.FeltEditController do
2034 def create ( conn , % { "fiber_id" => fiber_id } = params ) when is_binary ( fiber_id ) do
2135 add = string_list ( params [ "add" ] )
2236 remove = string_list ( params [ "remove" ] )
37+ unset = string_list ( params [ "unset" ] )
2338
24- with { :ok , host , address } <- host_for_fiber ( fiber_id ) ,
25- { :ok , output } <- run ( host , address , add , remove ) do
39+ with { :ok , set_pairs } <- set_pairs ( params [ "set" ] ) ,
40+ { :ok , due_args } <- due_args ( params ) ,
41+ { :ok , host , address } <- host_for_fiber ( fiber_id ) ,
42+ { :ok , output } <- run ( host , address , add , remove , unset , set_pairs , due_args ) do
2643 conn
2744 |> put_resp_content_type ( "text/plain" )
2845 |> send_resp ( 200 , output )
@@ -52,13 +69,16 @@ defmodule ShuttleWeb.FeltEditController do
5269 end
5370 end
5471
55- # An empty diff is a no-op, mirroring Portolan's local `runFeltTagEdit` .
56- defp run ( _host , _fiber_id , [ ] , [ ] ) , do: { :ok , "" }
72+ # An empty diff is a no-op, mirroring Portolan's local felt-edit path .
73+ defp run ( _host , _fiber_id , [ ] , [ ] , [ ] , [ ] , [ ] ) , do: { :ok , "" }
5774
58- defp run ( host , fiber_id , add , remove ) do
75+ defp run ( host , fiber_id , add , remove , unset , set_pairs , due_args ) do
5976 args = [ "-C" , host , "edit" , fiber_id ]
6077 args = Enum . reduce ( remove , args , fn tag , acc -> acc ++ [ "--untag" , tag ] end )
6178 args = Enum . reduce ( add , args , fn tag , acc -> acc ++ [ "--tag" , tag ] end )
79+ args = Enum . reduce ( unset , args , fn key , acc -> acc ++ [ "--unset" , key ] end )
80+ args = Enum . reduce ( set_pairs , args , fn pair , acc -> acc ++ [ "--set" , pair ] end )
81+ args = args ++ due_args
6282
6383 case System . cmd ( "felt" , args , stderr_to_stdout: true ) do
6484 { output , 0 } -> { :ok , output }
@@ -68,6 +88,39 @@ defmodule ShuttleWeb.FeltEditController do
6888 e in ErlangError -> { :error , Exception . message ( e ) }
6989 end
7090
91+ # `set` is a map of opaque scalar frontmatter. Render each entry as the
92+ # `key=value` argument `felt edit --set` expects; felt re-parses the value as
93+ # a YAML scalar (so a JSON boolean `true` lands as the YAML boolean `true`).
94+ # Non-scalar values are refused here with a 400 rather than handed to felt.
95+ defp set_pairs ( nil ) , do: { :ok , [ ] }
96+
97+ defp set_pairs ( map ) when is_map ( map ) do
98+ Enum . reduce_while ( map , { :ok , [ ] } , fn { key , value } , { :ok , acc } ->
99+ case scalar_string ( value ) do
100+ { :ok , encoded } -> { :cont , { :ok , acc ++ [ "#{ key } =#{ encoded } " ] } }
101+ :error -> { :halt , { :error , "set value for #{ key } must be a scalar" } }
102+ end
103+ end )
104+ end
105+
106+ defp set_pairs ( _ ) , do: { :error , "set must be an object of key/value pairs" }
107+
108+ defp scalar_string ( value ) when is_binary ( value ) , do: { :ok , value }
109+ defp scalar_string ( value ) when is_boolean ( value ) , do: { :ok , to_string ( value ) }
110+ defp scalar_string ( value ) when is_number ( value ) , do: { :ok , to_string ( value ) }
111+ defp scalar_string ( _ ) , do: :error
112+
113+ # `due`: absent leaves the date untouched, `null` clears it (`--due ""`), a
114+ # string sets it. felt validates the date format and rejects loudly.
115+ defp due_args ( params ) do
116+ case Map . fetch ( params , "due" ) do
117+ :error -> { :ok , [ ] }
118+ { :ok , nil } -> { :ok , [ "--due" , "" ] }
119+ { :ok , value } when is_binary ( value ) -> { :ok , [ "--due" , value ] }
120+ { :ok , _ } -> { :error , "due must be a string or null" }
121+ end
122+ end
123+
71124 defp string_list ( values ) when is_list ( values ) do
72125 values
73126 |> Enum . filter ( & is_binary / 1 )
0 commit comments