@@ -1973,6 +1973,317 @@ def _print_move_result(data, drawer_id, requested_wing=None, requested_room=None
19731973 print ()
19741974
19751975
1976+ # ── mempalace bulk-move ───────────────────────────────────────────────
1977+ #
1978+ # Bulk metadata relocation: the multi-drawer complement to `move`. Selects
1979+ # drawers by source wing/room (``GET /list`` with offset pagination) and
1980+ # PATCHes each one independently to a target wing/room. Same verbatim-always
1981+ # constraint as `move` — only metadata moves, never drawer text, so there
1982+ # is no ``--content`` flag.
1983+ #
1984+ # The safety model is deliberately conservative because this mutates many
1985+ # drawers at once:
1986+ # * a source filter (--wing and/or --room) is *required* — never operate
1987+ # on the whole palace by accident;
1988+ # * a target (--to-wing and/or --to-room) is required;
1989+ # * dry-run is the DEFAULT — you must pass --apply to mutate;
1990+ # * --apply prompts for confirmation on a TTY (skip with --yes) and
1991+ # *refuses* to run unattended (non-TTY without --yes) so a pipeline
1992+ # can't silently mass-mutate the palace;
1993+ # * one drawer's PATCH failing does not abort the batch — failures are
1994+ # collected and reported, exit 2 if any failed.
1995+
1996+
1997+ def _resolve_bulk_move_format (args ) -> str :
1998+ """Pick ``mempalace bulk-move`` output format. ``--format`` wins, then ``--json``."""
1999+ fmt = getattr (args , "format" , None )
2000+ if fmt :
2001+ return fmt
2002+ if getattr (args , "json" , False ):
2003+ return "json"
2004+ return "table"
2005+
2006+
2007+ def _gather_bulk_move_matches (wing , room , want_json ):
2008+ """Page through ``GET /list`` collecting every drawer matching wing/room.
2009+
2010+ Returns the list of drawer dicts (id + current wing/room). Exits the
2011+ process on daemon failure, mirroring ``cmd_list`` exit codes:
2012+ * DaemonError / None return (404/401/403) → exit 1 (sibling parity);
2013+ * inner-error envelope (``data["error"]`` and no drawers) → exit 2.
2014+ """
2015+ matches : list [dict ] = []
2016+ offset = 0
2017+ page = _LIST_LIMIT_MAX
2018+ while True :
2019+ params : dict = {"limit" : page , "offset" : offset }
2020+ if wing :
2021+ params ["wing" ] = wing
2022+ if room :
2023+ params ["room" ] = room
2024+ try :
2025+ data = _call_daemon_rest ("/list" , params )
2026+ except DaemonError as e :
2027+ if want_json :
2028+ _emit_json ({"error" : str (e ), "source" : "daemon" })
2029+ else :
2030+ print (
2031+ f"palace daemon unreachable at { _daemon_url ()} — "
2032+ f"see mempalace status for diagnostics ({ e } )" ,
2033+ file = sys .stderr ,
2034+ )
2035+ sys .exit (1 )
2036+
2037+ if data is None :
2038+ if want_json :
2039+ _emit_json ({"error" : "daemon /list unavailable" , "source" : "daemon" })
2040+ else :
2041+ print (
2042+ f"palace daemon unreachable at { _daemon_url ()} — "
2043+ "see mempalace status for diagnostics" ,
2044+ file = sys .stderr ,
2045+ )
2046+ sys .exit (1 )
2047+
2048+ drawers = data .get ("drawers" ) or []
2049+ if "error" in data and not drawers :
2050+ if want_json :
2051+ _emit_json (data )
2052+ else :
2053+ print (f"\n { data ['error' ]} " , file = sys .stderr )
2054+ sys .exit (2 )
2055+
2056+ matches .extend (drawers )
2057+
2058+ total = int (data .get ("total" , len (matches )) or 0 )
2059+ offset += len (drawers )
2060+ # Stop when we've collected everything, or the daemon returned an
2061+ # empty page (defensive — avoids an infinite loop if total is stale).
2062+ if not drawers or offset >= total :
2063+ break
2064+ return matches
2065+
2066+
2067+ def _bulk_move_drawer_id (drawer : dict ) -> str :
2068+ """Pull the id out of a /list drawer dict (daemon uses ``drawer_id``)."""
2069+ return drawer .get ("drawer_id" ) or drawer .get ("id" ) or ""
2070+
2071+
2072+ def _bulk_move_label (wing , room ) -> str :
2073+ """``wing/room`` label for prompts/previews; ``*`` marks an unconstrained side."""
2074+ return f"{ wing or '*' } /{ room or '*' } "
2075+
2076+
2077+ def _validate_bulk_move_args (src_wing , src_room , to_wing , to_room , want_json ):
2078+ """Enforce the safety preconditions; exit 2 with guidance if violated.
2079+
2080+ Source filter required (never touch the whole palace), target required,
2081+ daemon required — mirrors ``cmd_move``'s no-change / daemon-required
2082+ exit-2 contract.
2083+ """
2084+ if src_wing is None and src_room is None :
2085+ msg = (
2086+ "bulk-move requires a source filter: at least one of "
2087+ "--wing / --room (refusing to operate on the whole palace)."
2088+ )
2089+ if want_json :
2090+ _emit_json ({"error" : "no_source_filter" , "hint" : msg })
2091+ else :
2092+ print (f"\n ERROR: { msg } " , file = sys .stderr )
2093+ sys .exit (2 )
2094+
2095+ if to_wing is None and to_room is None :
2096+ msg = "bulk-move requires at least one of --to-wing / --to-room (nothing to change)."
2097+ if want_json :
2098+ _emit_json ({"error" : "no_change" , "hint" : msg })
2099+ else :
2100+ print (f"\n ERROR: { msg } " , file = sys .stderr )
2101+ sys .exit (2 )
2102+
2103+ if not _daemon_url ():
2104+ msg = (
2105+ "bulk-move requires the palace-daemon. Set PALACE_DAEMON_URL "
2106+ "(or daemon_url in ~/.mempalace/config.json) and retry."
2107+ )
2108+ if want_json :
2109+ _emit_json ({"error" : "daemon_required" , "hint" : msg })
2110+ else :
2111+ print (f"\n ERROR: { msg } " , file = sys .stderr )
2112+ sys .exit (2 )
2113+
2114+
2115+ def _print_bulk_move_preview (matches , to_wing , to_room , src_label ) -> None :
2116+ """Human-readable dry-run preview: one ``id cur → target`` line per drawer."""
2117+ print ()
2118+ print (f" Matched { len (matches )} drawer(s) in { src_label } " )
2119+ for d in matches :
2120+ did = _bulk_move_drawer_id (d )
2121+ short = did [:12 ] if did else "(no-id)"
2122+ cur_w = d .get ("wing" ) or ""
2123+ cur_r = d .get ("room" ) or ""
2124+ tgt_w = to_wing if to_wing is not None else cur_w
2125+ tgt_r = to_room if to_room is not None else cur_r
2126+ print (f" { short } { cur_w } /{ cur_r } → { tgt_w } /{ tgt_r } " )
2127+ print (f"\n DRY RUN — re-run with --apply to move { len (matches )} drawers.\n " )
2128+
2129+
2130+ def _bulk_move_confirm (args , matched , src_label , dst_label , want_json ) -> bool :
2131+ """Confirmation gate before a mass mutation.
2132+
2133+ Returns True to proceed. ``--yes`` skips the gate. On a TTY (and not
2134+ json) we prompt and proceed only on y/yes. Non-interactive / json
2135+ without ``--yes`` is *refused* (exit 2) — no silent mass mutation in a
2136+ pipeline. Returns False on an interactive decline (caller prints
2137+ ``aborted`` and exits 0).
2138+ """
2139+ if bool (getattr (args , "yes" , False )):
2140+ return True
2141+ if want_json or not sys .stdin .isatty ():
2142+ msg = (
2143+ f"refusing to bulk-move { matched } drawers without --yes "
2144+ "in a non-interactive shell"
2145+ )
2146+ if want_json :
2147+ _emit_json ({"error" : "confirmation_required" , "hint" : msg , "matched" : matched })
2148+ else :
2149+ print (f"\n ERROR: { msg } " , file = sys .stderr )
2150+ sys .exit (2 )
2151+ answer = input (f"Move { matched } drawers { src_label } → { dst_label } ? [y/N] " ).strip ().lower ()
2152+ return answer in ("y" , "yes" )
2153+
2154+
2155+ def _bulk_move_execute (matches , body_template ):
2156+ """PATCH each drawer independently; never abort the batch on one failure.
2157+
2158+ Returns ``(moved_ids, failures)`` where a failure is a
2159+ ``{"id", "error"}`` dict (DaemonError, None return, or
2160+ ``success is False``).
2161+ """
2162+ moved : list [str ] = []
2163+ failed : list [dict ] = []
2164+ for d in matches :
2165+ did = _bulk_move_drawer_id (d )
2166+ if not did :
2167+ failed .append ({"id" : "" , "error" : "missing drawer id in /list response" })
2168+ continue
2169+ try :
2170+ result = _patch_daemon_rest (f"/memory/{ did } " , dict (body_template ))
2171+ except DaemonError as e :
2172+ failed .append ({"id" : did , "error" : str (e )})
2173+ continue
2174+ if result is None :
2175+ failed .append ({"id" : did , "error" : "daemon PATCH /memory unavailable" })
2176+ continue
2177+ if result .get ("success" ) is False :
2178+ failed .append ({"id" : did , "error" : result .get ("error" , "move failed" )})
2179+ continue
2180+ moved .append (did )
2181+ return moved , failed
2182+
2183+
2184+ def cmd_bulk_move (args ):
2185+ """Bulk drawer relocation by source wing/room (issue #191).
2186+
2187+ The multi-drawer complement to ``move``. Selects drawers via
2188+ ``GET /list`` (offset-paginated) and PATCHes each match to the target
2189+ wing/room. Dry-run by default; ``--apply`` mutates (TTY prompt unless
2190+ ``--yes``; refuses unattended without ``--yes``). Verbatim-always:
2191+ metadata only, no ``--content`` flag. Daemon unreachable / 404 / 401 /
2192+ 403 during listing → exit 1; selection/target missing or any PATCH
2193+ failure → exit 2.
2194+ """
2195+ fmt = _resolve_bulk_move_format (args )
2196+ want_json = fmt == "json"
2197+
2198+ src_wing = getattr (args , "wing" , None )
2199+ src_room = getattr (args , "room" , None )
2200+ to_wing = getattr (args , "to_wing" , None )
2201+ to_room = getattr (args , "to_room" , None )
2202+
2203+ _validate_bulk_move_args (src_wing , src_room , to_wing , to_room , want_json )
2204+
2205+ # Only the supplied target keys go into each PATCH body / the json target.
2206+ body_template : dict = {}
2207+ if to_wing is not None :
2208+ body_template ["wing" ] = to_wing
2209+ if to_room is not None :
2210+ body_template ["room" ] = to_room
2211+ target_obj = dict (body_template )
2212+ source_obj = {"wing" : src_wing , "room" : src_room }
2213+
2214+ src_label = _bulk_move_label (src_wing , src_room )
2215+ dst_label = _bulk_move_label (
2216+ to_wing if to_wing is not None else src_wing ,
2217+ to_room if to_room is not None else src_room ,
2218+ )
2219+
2220+ matches = _gather_bulk_move_matches (src_wing , src_room , want_json )
2221+ matched = len (matches )
2222+
2223+ # Dry-run is the DEFAULT — preview and exit, no PATCH calls.
2224+ if not bool (getattr (args , "apply" , False )):
2225+ if want_json :
2226+ _emit_json (
2227+ {
2228+ "matched" : matched ,
2229+ "dry_run" : True ,
2230+ "moved" : [],
2231+ "failed" : [],
2232+ "target" : target_obj ,
2233+ "source" : source_obj ,
2234+ }
2235+ )
2236+ return
2237+ _print_bulk_move_preview (matches , to_wing , to_room , src_label )
2238+ return
2239+
2240+ if matched == 0 :
2241+ # Nothing to do — report and exit cleanly. No prompt, no PATCH.
2242+ if want_json :
2243+ _emit_json (
2244+ {
2245+ "matched" : 0 ,
2246+ "dry_run" : False ,
2247+ "moved" : [],
2248+ "failed" : [],
2249+ "target" : target_obj ,
2250+ "source" : source_obj ,
2251+ }
2252+ )
2253+ return
2254+ print (f"\n No drawers match { src_label } — nothing to move.\n " )
2255+ return
2256+
2257+ if not _bulk_move_confirm (args , matched , src_label , dst_label , want_json ):
2258+ print (" aborted" )
2259+ return
2260+
2261+ moved , failed = _bulk_move_execute (matches , body_template )
2262+
2263+ if want_json :
2264+ _emit_json (
2265+ {
2266+ "matched" : matched ,
2267+ "dry_run" : False ,
2268+ "moved" : moved ,
2269+ "failed" : failed ,
2270+ "target" : target_obj ,
2271+ "source" : source_obj ,
2272+ }
2273+ )
2274+ else :
2275+ print (f"\n moved { len (moved )} , failed { len (failed )} " )
2276+ if failed :
2277+ print (" failed drawers:" )
2278+ for f in failed :
2279+ fid = (f .get ("id" ) or "" )[:12 ] or "(no-id)"
2280+ print (f" { fid } { f .get ('error' , '' )} " )
2281+ print ()
2282+
2283+ if failed :
2284+ sys .exit (2 )
2285+
2286+
19762287# ── mempalace graph ───────────────────────────────────────────────────
19772288#
19782289# Pre-aggregated structural snapshot: wings, rooms, passive tunnels,
@@ -4392,6 +4703,54 @@ def main():
43924703 ),
43934704 )
43944705
4706+ # bulk-move — multi-drawer metadata relocation by source wing/room
4707+ p_bulk_move = sub .add_parser (
4708+ "bulk-move" ,
4709+ help = "Relocate many drawers (by source wing/room) to a target wing/room (metadata only)" ,
4710+ )
4711+ p_bulk_move .add_argument (
4712+ "--wing" ,
4713+ default = None ,
4714+ help = "Source: select drawers in this wing (at least one of --wing/--room required)" ,
4715+ )
4716+ p_bulk_move .add_argument (
4717+ "--room" ,
4718+ default = None ,
4719+ help = "Source: select drawers in this room (at least one of --wing/--room required)" ,
4720+ )
4721+ p_bulk_move .add_argument (
4722+ "--to-wing" ,
4723+ dest = "to_wing" ,
4724+ default = None ,
4725+ help = "Target wing (at least one of --to-wing/--to-room required)" ,
4726+ )
4727+ p_bulk_move .add_argument (
4728+ "--to-room" ,
4729+ dest = "to_room" ,
4730+ default = None ,
4731+ help = "Target room (at least one of --to-wing/--to-room required)" ,
4732+ )
4733+ p_bulk_move .add_argument (
4734+ "--apply" ,
4735+ action = "store_true" ,
4736+ help = "Actually move the drawers. Without this, bulk-move only previews (dry run)." ,
4737+ )
4738+ p_bulk_move .add_argument (
4739+ "--yes" ,
4740+ "-y" ,
4741+ action = "store_true" ,
4742+ help = "Skip the confirmation prompt (required to --apply in a non-interactive shell)" ,
4743+ )
4744+ p_bulk_move .add_argument (
4745+ "--format" ,
4746+ choices = ("table" , "json" ),
4747+ default = None ,
4748+ help = (
4749+ "Output format: table (default, preview/summary), "
4750+ "json (machine-readable; same as --json)"
4751+ ),
4752+ )
4753+
43954754 # graph
43964755 p_graph = sub .add_parser (
43974756 "graph" ,
@@ -4878,6 +5237,7 @@ def _nonneg_int(value: str) -> int:
48785237 "search" : cmd_search ,
48795238 "list" : cmd_list ,
48805239 "move" : cmd_move ,
5240+ "bulk-move" : cmd_bulk_move ,
48815241 "graph" : cmd_graph ,
48825242 "cypher" : cmd_cypher ,
48835243 "export" : cmd_export ,
0 commit comments