@@ -360,3 +360,218 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
360360
361361 print (f"\n { '=' * 60 } \n " )
362362 return True
363+
364+
365+ # ---------------------------------------------------------------------------
366+ # Wing-name normalization migration (#1675 follow-up)
367+ # ---------------------------------------------------------------------------
368+ #
369+ # normalize_wing_name now strips leading/trailing separators, so a path-encoded
370+ # dirname like ``-home-user-proj`` derives ``home_user_proj`` instead of
371+ # ``_home_user_proj``. Palaces built before that rule filed drawers under the
372+ # old, leading-underscore wing, which the new derivation no longer matches —
373+ # searches and diary reads under the new name miss the old memories.
374+ #
375+ # This migration re-keys the ``wing`` metadata field on drawers and closets to
376+ # the normalized form, merging collisions. Drawer/closet IDs embed the wing as
377+ # an opaque prefix that is never decoded back into a wing (verified: nothing
378+ # splits a wing out of an ID; mining idempotency keys on ``source_file``), so
379+ # the IDs are left untouched — closet ``→drawer_id`` pointers stay valid and
380+ # future mining still skips already-mined files. Tunnels resolve via existing
381+ # read-time normalization and need no rewrite. The pass is idempotent.
382+
383+
384+ def _normalized_wing_target (wing ):
385+ """Return the normalized wing if it differs from ``wing``, else ``None``.
386+
387+ ``None`` means "no migration needed" — either the value is not a non-empty
388+ string, normalization is a no-op, or it would normalize to empty.
389+ """
390+ from .config import normalize_wing_name
391+
392+ if not isinstance (wing , str ) or not wing :
393+ return None
394+ # Apply the full normalization and explicitly strip leading/trailing
395+ # separators. The strip is this migration's whole purpose (#1675); doing it
396+ # here rather than relying on normalize_wing_name keeps the migration correct
397+ # even when run against a build whose normalize_wing_name predates #1675, and
398+ # matches the post-#1675 derivation exactly.
399+ target = normalize_wing_name (wing ).strip ("_" )
400+ if not target or target == wing :
401+ return None
402+ return target
403+
404+
405+ def plan_wing_renames (items ):
406+ """Pure planner over ``(id, metadata)`` pairs.
407+
408+ Returns ``(summary, updates)`` where ``summary`` is ``{(old, new): count}``
409+ and ``updates`` is ``[(id, new_metadata), ...]`` for only the records whose
410+ wing changes. Metadata is copied; only the ``wing`` key is rewritten.
411+ """
412+ summary = defaultdict (int )
413+ updates = []
414+ for rec_id , meta in items :
415+ meta = dict (meta or {})
416+ target = _normalized_wing_target (meta .get ("wing" ))
417+ if target is None :
418+ continue
419+ summary [(meta ["wing" ], target )] += 1
420+ meta ["wing" ] = target
421+ updates .append ((rec_id , meta ))
422+ return summary , updates
423+
424+
425+ def _iter_collection_items (col , batch_size = 1000 ):
426+ """Yield ``(id, metadata)`` for every record in a backend collection."""
427+ total = col .count ()
428+ offset = 0
429+ while offset < total :
430+ batch = col .get (limit = batch_size , offset = offset , include = ["metadatas" ])
431+ ids = batch .ids if hasattr (batch , "ids" ) else batch ["ids" ]
432+ metas = batch .metadatas if hasattr (batch , "metadatas" ) else batch ["metadatas" ]
433+ if not ids :
434+ break
435+ for rec_id , meta in zip (ids , metas ):
436+ yield rec_id , meta
437+ offset += len (ids )
438+
439+
440+ def _apply_wing_updates (col , updates , batch_size = 500 ):
441+ """Re-label the ``wing`` metadata field in place for the planned updates."""
442+ for i in range (0 , len (updates ), batch_size ):
443+ chunk = updates [i : i + batch_size ]
444+ col .update (ids = [u [0 ] for u in chunk ], metadatas = [u [1 ] for u in chunk ])
445+
446+
447+ def _plan_topics_by_wing_renames ():
448+ """Return ``{old_wing: new_wing}`` for ``topics_by_wing`` keys to normalize."""
449+ try :
450+ from .miner import _load_known_entities_raw
451+
452+ reg = _load_known_entities_raw ()
453+ except Exception :
454+ return {}
455+ tbw = reg .get ("topics_by_wing" )
456+ if not isinstance (tbw , dict ):
457+ return {}
458+ renames = {}
459+ for wing in list (tbw .keys ()):
460+ target = _normalized_wing_target (wing )
461+ if target is not None :
462+ renames [wing ] = target
463+ return renames
464+
465+
466+ def _apply_topics_by_wing_renames (renames ):
467+ """Re-key ``topics_by_wing`` in known_entities.json, merging on collision."""
468+ if not renames :
469+ return
470+ import json
471+
472+ from .miner import _ENTITY_REGISTRY_PATH , _load_known_entities_raw
473+
474+ try :
475+ reg = _load_known_entities_raw ()
476+ except Exception :
477+ return
478+ tbw = reg .get ("topics_by_wing" )
479+ if not isinstance (tbw , dict ):
480+ return
481+ for old , new in renames .items ():
482+ if old not in tbw :
483+ continue
484+ old_topics = tbw .pop (old ) or []
485+ if new in tbw :
486+ merged = list (tbw [new ])
487+ for topic in old_topics :
488+ if topic not in merged :
489+ merged .append (topic )
490+ tbw [new ] = merged
491+ else :
492+ tbw [new ] = old_topics
493+ reg ["topics_by_wing" ] = tbw
494+ os .makedirs (os .path .dirname (_ENTITY_REGISTRY_PATH ), exist_ok = True )
495+ fd , tmp = tempfile .mkstemp (dir = os .path .dirname (_ENTITY_REGISTRY_PATH ), suffix = ".tmp" )
496+ try :
497+ with os .fdopen (fd , "w" , encoding = "utf-8" ) as f :
498+ json .dump (reg , f , ensure_ascii = False , indent = 2 )
499+ os .replace (tmp , _ENTITY_REGISTRY_PATH )
500+ except Exception :
501+ if os .path .exists (tmp ):
502+ os .remove (tmp )
503+ raise
504+
505+
506+ def migrate_wing_names (palace_path : str , dry_run : bool = False , confirm : bool = False ) -> bool :
507+ """Normalize legacy wing names in ``palace_path`` (strip leading/trailing
508+ separators), so palaces built before #1675 keep their memories discoverable.
509+
510+ Re-keys the ``wing`` metadata on drawers and closets in place (IDs untouched)
511+ and the ``topics_by_wing`` registry, merging collisions. Idempotent.
512+
513+ Returns True if anything was (or, in dry-run, would be) migrated.
514+ """
515+ from .palace import get_closets_collection , get_collection
516+
517+ try :
518+ drawers = get_collection (palace_path , create = False )
519+ except Exception as exc :
520+ print (f" No drawer collection found at { palace_path } ({ exc } )." )
521+ return False
522+
523+ d_items = list (_iter_collection_items (drawers ))
524+ all_wings = {(m or {}).get ("wing" ) for _ , m in d_items if (m or {}).get ("wing" )}
525+ d_summary , d_updates = plan_wing_renames (d_items )
526+
527+ closets = None
528+ c_summary , c_updates = defaultdict (int ), []
529+ try :
530+ closets = get_closets_collection (palace_path , create = False )
531+ c_summary , c_updates = plan_wing_renames (_iter_collection_items (closets ))
532+ except Exception :
533+ closets = None
534+
535+ topic_renames = _plan_topics_by_wing_renames ()
536+
537+ if not d_updates and not c_updates and not topic_renames :
538+ print (" All wing names are already normalized — nothing to migrate." )
539+ return False
540+
541+ print ("\n Wing-name migration plan:" )
542+ merged = defaultdict (lambda : [0 , 0 ])
543+ for key , count in d_summary .items ():
544+ merged [key ][0 ] = count
545+ for key , count in c_summary .items ():
546+ merged [key ][1 ] = count
547+ for (old , new ), (d_count , c_count ) in sorted (merged .items ()):
548+ note = " (MERGE into existing wing)" if new in all_wings else ""
549+ print (f" { old !r} -> { new !r} : { d_count } drawer(s), { c_count } closet(s){ note } " )
550+ if topic_renames :
551+ print (f" topics_by_wing: { len (topic_renames )} key(s) re-keyed" )
552+
553+ if dry_run :
554+ print ("\n DRY RUN — no changes made.\n " )
555+ return True
556+
557+ if not confirm :
558+ try :
559+ resp = input (" Apply this wing-name migration? [y/N] " ).strip ().lower ()
560+ except EOFError :
561+ resp = ""
562+ if resp not in ("y" , "yes" ):
563+ print (" Aborted." )
564+ return False
565+
566+ _apply_wing_updates (drawers , d_updates )
567+ if closets is not None and c_updates :
568+ _apply_wing_updates (closets , c_updates )
569+ _apply_topics_by_wing_renames (topic_renames )
570+
571+ parts = [f"{ len (d_updates )} drawer(s)" ]
572+ if c_updates :
573+ parts .append (f"{ len (c_updates )} closet(s)" )
574+ if topic_renames :
575+ parts .append (f"{ len (topic_renames )} topic key(s)" )
576+ print (f"\n Migrated { ', ' .join (parts )} .\n " )
577+ return True
0 commit comments