@@ -42,6 +42,17 @@ class SyncReport(TypedDict):
4242 by_source : dict [str , int ]
4343
4444
45+ class UnmineReport (TypedDict ):
46+ source_file : str
47+ wing : Optional [str ]
48+ matched_drawers : int
49+ matched_closets : int
50+ removed_drawers : int
51+ removed_closets : int
52+ affected_wings : list [str ]
53+ dry_run : bool
54+
55+
4556def _resolve_project_root (source_file : Path , project_roots : list ) -> Optional [Path ]:
4657 """Return the longest project_root that source_file lives under.
4758
@@ -184,22 +195,134 @@ def _normalize_project_dirs(project_dirs) -> list:
184195 return sorted (resolved , key = lambda p : (- len (str (p )), str (p )))
185196
186197
187- def _delete_in_batches (col , ids : list , batch_size : int , wal_log : Optional [Callable ]):
188- """Delete drawer IDs in batches, optionally logging each batch to WAL."""
198+ def _delete_in_batches (
199+ col ,
200+ ids : list ,
201+ batch_size : int ,
202+ wal_log : Optional [Callable ],
203+ * ,
204+ operation : str = "sync_prune" ,
205+ params : Optional [dict ] = None ,
206+ ):
207+ """Delete IDs in batches, optionally logging each batch to WAL."""
189208 deleted = 0
190209 for i in range (0 , len (ids ), batch_size ):
191210 chunk = ids [i : i + batch_size ]
192211 col .delete (ids = chunk )
193212 deleted += len (chunk )
194213 if wal_log is not None :
214+ wal_params = dict (params or {})
215+ wal_params ["first_id" ] = chunk [0 ]
195216 wal_log (
196- "sync_prune" ,
197- { "first_id" : chunk [ 0 ]} ,
217+ operation ,
218+ wal_params ,
198219 {"removed_count" : len (chunk )},
199220 )
200221 return deleted
201222
202223
224+ def _source_where (source_file : str , wing : Optional [str ]):
225+ if wing :
226+ return {"$and" : [{"source_file" : source_file }, {"wing" : wing }]}
227+ return {"source_file" : source_file }
228+
229+
230+ def _matching_source_rows (col , source_file : str , wing : Optional [str ]) -> tuple [list [str ], set [str ]]:
231+ """Return IDs and observed wings for one exact source-file match."""
232+ ids : list [str ] = []
233+ wings : set [str ] = set ()
234+ offset = 0
235+ where = _source_where (source_file , wing )
236+ while True :
237+ batch = col .get (
238+ where = where ,
239+ limit = _BATCH ,
240+ offset = offset ,
241+ include = ["metadatas" ],
242+ )
243+ batch_ids = batch .get ("ids" ) or []
244+ metadatas = batch .get ("metadatas" ) or []
245+ if not batch_ids :
246+ break
247+ ids .extend (batch_ids )
248+ for meta in metadatas :
249+ value = (meta or {}).get ("wing" )
250+ if isinstance (value , str ) and value :
251+ wings .add (value )
252+ offset += len (batch_ids )
253+ return ids , wings
254+
255+
256+ def unmine_source (
257+ palace_path : str ,
258+ source_file : str ,
259+ wing : Optional [str ] = None ,
260+ dry_run : bool = True ,
261+ batch_size : int = _BATCH ,
262+ wal_log : Optional [Callable ] = None ,
263+ ) -> UnmineReport :
264+ """Remove every drawer and closet filed from one exact source path.
265+
266+ Dry-run is the default. The operation is source-scoped rather than
267+ content-scoped on purpose: it gives operators a precise recovery path
268+ for accidental/duplicate mines without defining a global policy for when
269+ repeated text is semantically redundant. ``wing`` can further narrow a
270+ source that was intentionally filed into more than one wing.
271+
272+ Registry sentinels are ordinary drawer rows carrying ``source_file`` and
273+ are removed too, so a later mine of the source is not falsely skipped.
274+ """
275+ if not source_file :
276+ raise ValueError ("source_file must be a non-empty path" )
277+
278+ with mine_palace_lock (palace_path ):
279+ col = get_collection (palace_path , create = False )
280+ drawer_ids , affected_wings = _matching_source_rows (col , source_file , wing )
281+
282+ closets_col = None
283+ closet_ids : list [str ] = []
284+ try :
285+ closets_col = get_closets_collection (palace_path , create = False )
286+ except Exception as exc :
287+ logger .debug ("Unmine closet lookup skipped (collection unavailable): %s" , exc )
288+ if closets_col is not None :
289+ closet_ids , closet_wings = _matching_source_rows (closets_col , source_file , wing )
290+ affected_wings .update (closet_wings )
291+
292+ report : UnmineReport = {
293+ "source_file" : source_file ,
294+ "wing" : wing ,
295+ "matched_drawers" : len (drawer_ids ),
296+ "matched_closets" : len (closet_ids ),
297+ "removed_drawers" : 0 ,
298+ "removed_closets" : 0 ,
299+ "affected_wings" : sorted (affected_wings ),
300+ "dry_run" : dry_run ,
301+ }
302+ if dry_run :
303+ return report
304+
305+ wal_params = {"source_file" : source_file }
306+ if wing :
307+ wal_params ["wing" ] = wing
308+ report ["removed_drawers" ] = _delete_in_batches (
309+ col ,
310+ drawer_ids ,
311+ batch_size ,
312+ wal_log ,
313+ operation = "unmine_source" ,
314+ params = wal_params ,
315+ )
316+ if closets_col is not None :
317+ report ["removed_closets" ] = _delete_in_batches (
318+ closets_col ,
319+ closet_ids ,
320+ batch_size ,
321+ None ,
322+ )
323+ return report
324+
325+
203326def sync_palace (
204327 palace_path : str ,
205328 project_dirs : Optional [list ] = None ,
@@ -317,5 +440,7 @@ def sync_palace(
317440__all__ = [
318441 "MineAlreadyRunning" ,
319442 "SyncReport" ,
443+ "UnmineReport" ,
320444 "sync_palace" ,
445+ "unmine_source" ,
321446]
0 commit comments