diff --git a/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md b/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md
new file mode 100644
index 0000000..5bb9c57
--- /dev/null
+++ b/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md
@@ -0,0 +1,1986 @@
+
+
+
+
+# Character migrator workflow integration (2026-02-18 23:39Z)
+
+_**User**_
+
+We're going to do a major overhaul. Everything that came before is obsolete. I have designed a character migrator workflow. We will integrate it into this project. Here's the context for the workflow:@a:\1 Amazon_Active_Projects\260206_PAE_2026\.specstory\history\2026-02-17_17-20Z-linked-library-override-resync-issue.md
+
+---
+
+_**Agent (model default, mode Agent)**_
+
+Thought Process
+
+The user wants to do a major overhaul of the Dynamic-Link-Manager project. They've designed a "character migrator workflow" and want to integrate it. They've referenced a spec story history file that contains the context for the workflow. I need to:
+
+1. Read the referenced file to understand the character migrator workflow design
+2. Understand the current state of the Dynamic-Link-Manager project
+3. Create a plan for integrating the workflow into the project
+
+Let me start by reading the referenced file and exploring the current project structure. I'm in plan mode, so I should not make any edits - only gather information and create a plan.
+
+
+---
+
+
+Reading the workflow spec and exploring the project so we can plan integrating the character migrator.
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: a:\1 Amazon_Active_Projects\260206_PAE_2026\.specstory\history\2026-02-17_17-20Z-linked-library-override-resync-issue.md
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\blender_manifest.toml
+
+
+
+
+---
+
+Thought Process
+
+The spec story file is huge (over 1M characters). I need to read it in chunks or search for the character migrator workflow design. Let me read the beginning of the file to understand its structure, and search for key terms like "character migrator", "workflow", "migrator", etc.
+
+
+---
+
+
+Searching the spec file for the character migrator workflow and scanning the project structure.
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: a:\1 Amazon_Active_Projects\260206_PAE_2026\.specstory\history\2026-02-17_17-20Z-linked-library-override-resync-issue.md
+
+
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "character migrator|migrator workflow|migrator",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "contextBefore": 0,
+ "contextAfter": 0,
+ "caseInsensitive": true
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "character migrator|migrator workflow|migrator",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "outputMode": "content",
+ "totalFiles": 0,
+ "totalMatches": 0,
+ "topFiles": []
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+
+
+Tool use: **glob_file_search** • Searched codebase "undefined" • **13** results in **1** directory
+
+Directory: **T:\Dynamic-Link-Manager** (13 files)
+
+| File |
+|------|
+| `../blender_manifest.toml` |
+| `../.specstory\history\2025-08-20_22-43Z-creating-a-blender-extension-for-asset-replacement.md` |
+| `../__pycache__\operators.cpython-311.pyc` |
+| `../.specstory\.gitignore` |
+| `../.specstory\.project.json` |
+| `../.specstory/.gitignore` |
+| `../.cursorindexingignore` |
+| `../.specstory/history/2025-08-20_22-43Z-creating-a-blender-extension-for-asset-replacement.md` |
+| `../.gitignore` |
+| `../ui.py` |
+| `../.specstory\.what-is-this.md` |
+| `../__init__.py` |
+| `../operators.py` |
+
+
+
+
+---
+
+Thought Process
+
+The spec file is a long transcript about a linked library override resync issue in Blender - it doesn't seem to contain the words "character migrator" or "migrator workflow". The user said they "designed a character migrator workflow" and the context is in that file. Maybe the design appears later in the document, or under different terms. Let me search for other keywords: "workflow", "migrat", "design", "overhaul", "character", or look at the end of the file where conclusions/design might be.
+
+
+---
+
+
+Searching the spec for workflow design, migration, and character-related sections:
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "workflow|migrat|design|overhaul|character",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "contextBefore": 0,
+ "contextAfter": 0,
+ "caseInsensitive": true
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "workflow|migrat|design|overhaul|character",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 256,
+ "topFiles": [
+ {
+ "uri": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "matchCount": 80
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: a:\1 Amazon_Active_Projects\260206_PAE_2026\.specstory\history\2026-02-17_17-20Z-linked-library-override-resync-issue.md
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\__init__.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\operators.py
+
+
+
+
+---
+
+Thought Process
+
+I'm getting a clearer picture. The spec story is a long transcript of a Blender support session. It evolved into a **character replacement/migration workflow** that was run repeatedly for different characters (Una, Steve, Eddy, Hailey). The workflow is not explicitly named "character migrator" in the doc but it's clearly the same thing. Key elements from the transcript:
+
+**The workflow (steps 1–7):**
+1. **Copy attributes** – Copy rotation, location, scale on each armature object from original to replacement (and link/override characters if needed).
+2. **Migrate NLA** – Copy NLA tracks from original to replacement, with blend_type matched (e.g. COMBINE).
+3. **Custom properties** – Copy pose-bone custom props from original to replacement (exclude _RNA_UI, rigify_type, rigify_parameters).
+4. **Bone constraints** – Copy constraints to replacement; skip when target is same character's original; **retarget** when target is another character's original → that character's replacement; remove stale constraints on rep that target other's original first.
+5. **Retarget relations** – Parents, constraint targets, Armature modifiers pointing at original → point to replacement; also objects outside the character that target the original.
+6. **Replacement base body** – **Library override only** (NO single-user copy): for rep's base body mesh, if linked call `override_create()`, then assign shape-key action. Do NOT do ob.data.copy().
+7. **Remove duplicate constraints** – Trim rep's bone constraints so count matches original (remove from end).
+
+**Additional rules from the transcript:**
+- When replacing an armature, update any relations (objects/empties, constraints, modifiers, drivers, parenting) that point to the old armature to point to the new one.
+- Cross-character constraints: e.g. Eddy looking at Hailey → when copying to Eddy.001, retarget the constraint to Hailey.001; and remove from the rep any constraint that still targets the other character's *original* before re-adding with correct target.
+- NLA strip blend_type should be copied (e.g. COMBINE).
+- Optional: COPY_TRANSFORMS on control bones from original to replacement so they move together – but user also wanted "Steve.001 independent" (no dependency on Steve), so sometimes skip COPY_TRANSFORMS.
+
+**Current Dynamic-Link-Manager project:**
+- Blender add-on: "Dynamic Link Manager" – relink characters and library blends.
+- Has operators for: replace_linked_asset, scan_linked_assets, and likely more (relink, path management).
+- UI in ui.py, logic in operators.py.
+
+The user wants to:
+1. Treat everything before as obsolete.
+2. Integrate this **character migrator workflow** into the Dynamic-Link-Manager project.
+
+So the plan is: **Major overhaul** – replace or significantly reshape the add-on around the character migrator workflow (detect original/replacement armature pairs, run steps 1–7 with the rules above). I need to read more of the spec to see if there's a canonical step list or any more design (e.g. how to discover "pairs", UI, etc.) and also read more of operators.py and ui.py to see what to remove or keep.
+
+
+---
+
+
+Searching the spec for the full step list and discovery of character pairs, and reviewing the add-on's operators and UI.
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "Step [0-9]|step [0-9]|get_pairs|orig.*rep|workflow",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "contextBefore": 0,
+ "contextAfter": 0,
+ "caseInsensitive": true
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "Step [0-9]|step [0-9]|get_pairs|orig.*rep|workflow",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 396,
+ "topFiles": [
+ {
+ "uri": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "matchCount": 60
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\operators.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui.py
+
+
+
+
+---
+
+Thought Process
+
+I now have a good picture:
+
+**Character migrator workflow (from spec):**
+1. **Copy attributes** – Location, rotation, scale on armature object from original → replacement
+2. **Migrate NLA** – Copy NLA tracks to replacement, matching blend_type (e.g. COMBINE)
+3. **Custom properties** – Copy pose-bone custom props (exclude _RNA_UI, rigify_type, rigify_parameters)
+4. **Bone constraints** – Copy to rep; skip when target == same orig; retarget when target == other character's orig → other's rep; first remove from rep any constraint targeting other's orig
+5. **Retarget relations** – Parents, constraint targets, Armature modifiers, and any object targeting orig → point to rep
+6. **Replacement base body** – Library override only (override_create(), no single-user copy), then assign shape-key action
+7. **Remove duplicate constraints** – Trim rep constraints to match orig count
+
+**Pair discovery:** In the spec they use hardcoded names (Hailey/Eddy, Steve, Una) and pattern like `BaseName_Rigify` (original) and `BaseName_Rigify.001` (replacement). So pairs are (orig_armature, rep_armature) where rep is typically in a collection like "Character.001" and has name "Character_Rigify.001". The workflow can be run for one or multiple character pairs; the code built a list of pairs and an orig→rep map for cross-character retargeting.
+
+**Current add-on:** Dynamic Link Manager – focuses on scanning linked libraries, finding missing ones in folders, relinking, opening linked files. No character migration. The user wants to obsolete all of that and make the add-on centered on the character migrator workflow.
+
+I'll create a plan that:
+1. Summarizes the workflow from the spec (7 steps + rules)
+2. Proposes replacing/overhauling the add-on to be "Character Migrator" or keep the name but make migration the primary feature
+3. How to discover pairs (configurable or by convention: e.g. user selects orig + rep, or we scan for Name_Rigify / Name_Rigify.001)
+4. UI: run full workflow, maybe run individual steps, select character pair(s)
+5. What to remove (existing operators/UI that are obsolete) vs what to keep (e.g. search paths might still be useful if we ever need to link in assets – but user said "everything that came before is obsolete")
+
+Let me check if there's more in the spec about how they want to "integrate" (e.g. UI, selection) and read a bit more of operators.py to list all current operators.
+
+
+---
+
+
+Checking the rest of the operators and UI to list current features and locate the workflow's step 1 (copy attributes).
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "bl_idname|def execute|class DLM_",
+ "path": "T:\\Dynamic-Link-Manager\\operators.py",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "bl_idname|def execute|class DLM_",
+ "path": "T:\\Dynamic-Link-Manager\\operators.py",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 36,
+ "topFiles": [
+ {
+ "uri": "operators.py",
+ "matchCount": 36
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: a:\1 Amazon_Active_Projects\260206_PAE_2026\.specstory\history\2026-02-17_17-20Z-linked-library-override-resync-issue.md
+
+
+
+
+---
+
+Thought Process
+
+I have a clear picture. The spec document is a long transcript; the canonical workflow is the 7-step process that was refined over many messages (Hailey/Eddy run at the end). Key details:
+
+**Workflow steps (final form):**
+1. Copy attributes (location, rotation, scale) on armature object from original → replacement
+2. Migrate NLA (tracks + strips, blend_type, etc.) from original → replacement
+3. Custom properties on pose bones (exclude _RNA_UI, rigify_type, rigify_parameters)
+4. Bone constraints: skip target==orig; retarget target in orig_to_rep to rep; first remove from replacement any constraint that targets the other character's original
+5. Retarget relations: parents, constraint targets, Armature modifiers, and any object targeting orig → rep
+6. Replacement base body: library override only (override_create(), no copy), assign shape-key action
+7. Remove duplicate constraints (trim rep to match orig count)
+
+**Pair discovery:** Pairs are (base_name, orig_armature, rep_armature) where orig = e.g. `Name_Rigify`, rep = `Name_Rigify.001`. The code uses a hardcoded list of base names ('Hailey', 'Eddy') or ('Steve',) etc. For integration we need a way to get pairs - either user selection (two armatures) or scan by convention (find all Name_Rigify and Name_Rigify.001).
+
+**Optional:** COPY_TRANSFORMS on control bones was sometimes skipped to make the replacement "independent" (so original can be purged). So that could be an option in the add-on.
+
+Now I'll create the plan. The user said "everything that came before is obsolete" - so we're replacing the add-on's purpose with the character migrator. We could keep the add-on name "Dynamic Link Manager" and add the migrator as the main feature, or rename. I'll propose making the character migrator the core and either removing or demoting the old library-scan/relink features.
+
+
+---
+
+
+Checking how the spec discovers character pairs (beyond hardcoded names):
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "get_pairs|pairs.*=.*\\[|base.*Rigify|orig.*rep",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "contextBefore": 0,
+ "contextAfter": 0,
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "get_pairs|pairs.*=.*\\[|base.*Rigify|orig.*rep",
+ "path": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 233,
+ "topFiles": [
+ {
+ "uri": "a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md",
+ "matchCount": 30
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+The workflow in the spec always uses explicit character base names (Hailey, Eddy, Steve, Una) and the convention `BaseName_Rigify` (original) and `BaseName_Rigify.001` (replacement). So for integration we need either:
+- User-provided list of base names (e.g. from a preference or a text field), or
+- User selects two armatures (original and replacement) and we run for that single pair, or
+- Auto-detect: find all armatures named like *._Rigify and *._Rigify.001 and group by base name.
+
+I'll include in the plan that pair discovery can be: (A) selection-based (two armatures = one pair), or (B) convention-based (scan for Name_Rigify / Name_Rigify.001), with optional configurable base names.
+
+Creating the plan.
+
+
+---
+
+
+Creating the integration plan.
+
+
+---
+
+
+
+Tool use: **create_plan**
+
+
+Parameters:
+
+```json
+{
+ "plan": "# Character Migrator Workflow Integration\n\n## Source of truth\n\nThe workflow is fully specified in the SpecStory transcript you referenced: [2026-02-17_17-20Z-linked-library-override-resync-issue.md](a:\\1 Amazon_Active_Projects\\260206_PAE_2026\\.specstory\\history\\2026-02-17_17-20Z-linked-library-override-resync-issue.md). The transcript evolves from a library resync issue into a repeatable **character replacement** flow for Rigify characters (Una, Steve, Eddy, Hailey). The final 7-step formulation appears in the Hailey/Eddy run (steps 1–7) and the subsequent fixes (no single-user copy for body; cross-character constraint retargeting; remove stale constraints on rep that target other’s original).\n\n## Workflow summary (7 steps)\n\n| Step | What it does |\n|------|----------------|\n| **1** | Copy armature object attributes: location, rotation (quaternion/euler), scale from original to replacement. |\n| **2** | Migrate NLA: copy tracks and strips to replacement, preserving `blend_type`, `extrapolation`, `frame_end`, `blend_in`/`blend_out`, `repeat`. Create `animation_data` on rep if missing. |\n| **3** | Custom properties: copy pose-bone custom props from original to replacement; exclude `_RNA_UI`, `rigify_type`, `rigify_parameters`. |\n| **4** | Bone constraints: (a) On replacement, remove any constraint whose target is another character’s *original* armature. (b) Copy constraints from original to replacement: skip if target is same character’s original; if target is another character’s original, set new constraint target to that character’s *replacement*; else keep target. Copy name, mute, influence, subtarget, and common props (e.g. head_tail, use_bone_object, invert_*). (c) Trim replacement bone constraints so count per bone matches original (remove from end). |\n| **5** | Retarget relations: (a) Rep’s descendants: parent to rep if was parent to orig; constraint targets and Armature modifier object pointing at orig → rep. (b) Any object in the file whose parent is orig → parent to rep; any constraint target or Armature modifier pointing at orig → rep. |\n| **6** | Replacement base body: for each replacement’s base body mesh (mesh with “body” and “base” in name, skinned to rep): if mesh data is linked or override, call `override_create()` only (no single-user copy). Then assign the matching shape-key action if found (e.g. `{body_name}Action`, `{mesh_data_name}Action`, `{body_name}Action.001`). |\n| **7** | Implemented inside step 4: remove duplicate constraints on replacement so each bone’s constraint count matches the original. |\n\n## Pair discovery\n\nIn the spec, pairs are **(base_name, orig_armature, rep_armature)** with naming like `Name_Rigify` (original) and `Name_Rigify.001` (replacement). For integration, support at least:\n\n- **Selection-based:** User selects two armatures (active = replacement, other selected = original) and runs the workflow for that single pair. No need to know “base name”.\n- **Optional: convention-based** – Scan for armatures named `*_Rigify` and `*_Rigify.001` (same base) to build multiple pairs; use a single “Run migration” that runs steps 1–7 for all discovered pairs with a shared `orig_to_rep` map for cross-character retargeting in step 4.\n\nHelper used everywhere: **descendants(armature)** = objects whose parent chain leads to that armature.\n\n## Current add-on vs new direction\n\n- **Current:** [operators.py](T:\\Dynamic-Link-Manager\\operators.py) and [ui.py](T:\\Dynamic-Link-Manager\\ui.py) implement linked library scanning, missing/indirect detection, search paths, find-in-folders relink, reload, open blend, path relative/absolute, relocate single library. [blender_manifest.toml](T:\\Dynamic-Link-Manager\\blender_manifest.toml) describes the add-on as “Relink characters and library blends with ease.”\n- **Direction:** Treat existing behaviour as obsolete. Make the add-on’s primary purpose the **character migrator**. Either remove the old operators/UI or move them behind a “Legacy” / “Library tools” sub-panel so the main panel is migrator-only.\n\n## Proposed architecture\n\n```mermaid\nflowchart LR\n subgraph ui [UI]\n Panel[Main Panel]\n RunAll[Run full migration]\n PairList[Pair list or selection]\n end\n subgraph core [Core]\n Discover[Discover pairs]\n Step1[Step 1: Copy attributes]\n Step2[Step 2: NLA]\n Step3[Step 3: Custom props]\n Step4[Step 4: Constraints]\n Step5[Step 5: Retarget relations]\n Step6[Step 6: Base body override]\n end\n Panel --> RunAll\n Panel --> PairList\n RunAll --> Discover\n Discover --> Step1 --> Step2 --> Step3 --> Step4 --> Step5 --> Step6\n```\n\n- **New module (recommended):** Add `migrator.py` (or `character_migrator.py`) containing:\n - `get_pairs_from_selection(context)` → list of (base_name, orig, rep) from two selected armatures (optional: orig/rep order by name or by “.001”).\n - `get_pairs_by_convention(context, base_names)` → list of pairs for given base names using `Name_Rigify` / `Name_Rigify.001`.\n - `run_step_1(orig, rep)` … `run_step_6(orig, rep, rep_descendants, orig_to_rep_map)` with signatures that match the spec (step 4 and 5 need `orig_to_rep` for all pairs in the batch).\n - One entry point: `run_full_migration(context, pairs)` that builds `orig_to_rep`, then for each pair gets `descendants(rep)` and runs steps 1–6 (with step 7 folded into step 4).\n- **Operators:** One operator, e.g. `DLM_OT_run_character_migration`, that (a) gets pairs from selection or from a convention/list, (b) calls `run_full_migration`. Optionally a “Run step N” operator for debugging.\n- **UI:** Main panel: “Character Migrator” section with “Run migration (selected armatures)” and, if implemented, “Run for all pairs” and a way to configure base names or list pairs. Remove or collapse current “Linked Libraries Analysis” into a secondary panel/section.\n\n## Important details from the spec\n\n- **No single-user copy for body:** Step 6 must use library override only (`override_create()`); do not do `ob.data = ob.data.copy()`.\n- **Cross-character constraints:** When copying a constraint to replacement A, if the constraint targets the *other* character’s original armature, set the new constraint’s target to that character’s replacement (and remove from the replacement any existing constraint that still targeted the other’s original).\n- **Base body detection:** Rep’s base body = mesh in `descendants(rep)` with `\"body\"` and `\"base\"` in name (case-insensitive) and Armature modifier pointing at rep.\n- **Shape-key action naming:** Try `{body_obj_name}Action`, `{mesh_data_name}Action`, `{body_obj_name}Action.001` (and similar variants if present in the spec).\n\n## Optional / future\n\n- **COPY_TRANSFORMS:** The spec sometimes added COPY_TRANSFORMS on control bones so rep follows orig in real time; other times it was omitted so the replacement stays independent (e.g. to purge original). Can be an add-on option (e.g. “Make replacement follow original”) later.\n- **Copy shape key values:** The spec also copies current shape key *values* from original body to replacement body in some runs; step 6 currently focuses on override + action. Copying values can be an extra step or option if you want parity with those runs.\n\n## Files to touch\n\n| File | Action |\n|------|--------|\n| New `migrator.py` | Add discovery helpers and step 1–6 (with 7 inside 4) from spec. |\n| [operators.py](T:\\Dynamic-Link-Manager\\operators.py) | Add `DLM_OT_run_character_migration` (and optionally per-step operators). Optionally remove or keep old operators. |\n| [ui.py](T:\\Dynamic-Link-Manager\\ui.py) | Replace or reorder main panel: Character Migrator primary; move linked-library UI to secondary or remove. Add any props for pair source (selection vs list). |\n| [__init__.py](T:\\Dynamic-Link-Manager\\__init__.py) | Import and register migrator operators (and migrator module if needed). |\n| [blender_manifest.toml](T:\\Dynamic-Link-Manager\\blender_manifest.toml) | Update description/tagline to reflect character migrator as main feature. |\n\n## Implementation order\n\n1. Add **migrator.py** with pure logic: `descendants()`, pair discovery (selection first), then `run_step_1` … `run_step_6` and `run_full_migration()` using the exact rules from the spec (including orig_to_rep for steps 4 and 5).\n2. Add **operator** that gets pairs (e.g. from selection: two armatures → one pair) and calls `run_full_migration()`; report errors and “Migrated N pairs” in the operator’s report.\n3. **UI:** One button “Run character migration” in the main panel (and short instructions: “Select original and replacement armatures”).\n4. **Clean-up:** Remove or demote old linked-library operators/UI; update manifest and __init__ as needed.\n5. (Optional) Add convention-based discovery and “Run for all” plus any options (e.g. COPY_TRANSFORMS, copy shape key values).\n\nThis keeps the spec’s workflow canonical and makes the add-on a thin UI + operator layer over a single, testable migrator core.\n",
+ "name": "Character Migrator Integration",
+ "overview": "Replace the current Dynamic Link Manager add-on (linked library scan/relink) with the character migrator workflow from the spec story: a 7-step process that migrates animation, constraints, relations, and base body from an \"original\" character armature to a \"replacement\" (e.g. linked/overridden) armature so the replacement can replace the original and the original can be purged."
+}
+```
+Additional data:
+
+```json
+{
+ "reviewData": {
+ "status": "Requested",
+ "selectedOption": "none",
+ "isShowingInput": false
+ },
+ "planUri": "file:///c%3A/Users/Nathan/.cursor/plans/character_migrator_integration_cab95abc.plan.md",
+ "hasOpenedEditor": true
+}
+```
+Result:
+
+```json
+{
+ "rejected": {}
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+_**User**_
+
+Additions to the plan:
+
+Keep everything for linked libraries analysis and put its boxes in their own dropdown underneath CharMig.
+
+Discover pairs should have an automatic mode (already in the plan) and a manual mode where pairs are specified. Only one character should be migrated at a time. The migrator should have 2 fields. First should be the original character, second should be for the replacement character. These fields should have picker buttons that populate the field with the active armature when clicked. Many of the operators will need to manipulate datablocks other than the armature, so they should be able to view the linked collection that the armature is a part of in order to do so cleanly.
+
+Our project should mirror the structure of this extension: T:\AmazonCharacterTools
+
+---
+
+_**Agent (model default, mode Agent)**_
+
+Thought Process
+
+The user wants to update the plan with several additions:
+
+1. **Linked libraries:** Keep everything for linked libraries analysis and put its boxes in their own dropdown underneath "CharMig" (Character Migrator). So the main panel has CharMig first, then a collapsible section for linked libraries.
+
+2. **Pair discovery:**
+ - Automatic mode (already in plan)
+ - Manual mode where pairs are specified
+ - Only one character migrated at a time (not batch)
+
+3. **Migrator UI:**
+ - Two fields: "Original character" and "Replacement character"
+ - Picker buttons that populate the field with the active armature when clicked
+ - So it's manual selection via two fields + picker, not "select two objects and run"
+
+4. **Operators need to manipulate other datablocks:** They should be able to "view" the linked collection that the armature is part of - meaning when working with a linked/overridden armature, we need to consider the collection context (e.g. to find descendants, base body meshes that belong to that character). So the logic needs to work with the collection that contains the armature, not just the armature in isolation.
+
+5. **Project structure:** Mirror the structure of T:\AmazonCharacterTools. I need to look at that project's structure to understand what to mirror.
+
+Let me read the AmazonCharacterTools structure first, then update the plan file.
+
+
+---
+
+
+Checking the structure of T:\AmazonCharacterTools to mirror it, then updating the plan with your additions.
+
+
+---
+
+
+
+Tool use: **glob_file_search** • Searched codebase "undefined" • **107** results in **1** directory
+
+Directory: **T:\AmazonCharacterTools** (107 files)
+
+| File |
+|------|
+| `../..\AmazonCharacterTools\.git\FETCH_HEAD` |
+| `../..\AmazonCharacterTools\.git\ORIG_HEAD` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.10.1` |
+| `../..\AmazonCharacterTools\.git\logs\refs\remotes\origin\main` |
+| `../..\AmazonCharacterTools\.git\refs\remotes\origin\main` |
+| `../..\AmazonCharacterTools\.git\lfs\cache\locks\refs\heads\main\verifiable` |
+| `../..\AmazonCharacterTools\.git\logs\refs\heads\main` |
+| `../..\AmazonCharacterTools\.git\logs\HEAD` |
+| `../..\AmazonCharacterTools\.git\refs\heads\main` |
+| `../..\AmazonCharacterTools\.git\COMMIT_EDITMSG` |
+| `../..\AmazonCharacterTools\.specstory\.what-is-this.md` |
+| `../..\AmazonCharacterTools\.git\objects\49\9172bfb69fc27226e73bc1bc82c18d1ebebb0e` |
+| `../..\AmazonCharacterTools\changelog.md` |
+| `../..\AmazonCharacterTools\blender_manifest.toml` |
+| `../..\AmazonCharacterTools\.git\objects\a5\ed1c1c66223a4598c16810901ab953758deb0f` |
+| `../..\AmazonCharacterTools\ui\operators.py` |
+| `../..\AmazonCharacterTools\.specstory\history\2026-01-01_23-46Z-blender-5-0-api-bone-selection-issue.md` |
+| `../..\AmazonCharacterTools\.git\logs\refs\remotes\origin\HEAD` |
+| `../..\AmazonCharacterTools\.git\refs\remotes\origin\HEAD` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.10.0` |
+| `../..\AmazonCharacterTools\.git\objects\b0\df997989d6703d78f087274d4f48abed9ab80b` |
+| `../..\AmazonCharacterTools\.git\objects\a5\2be7a4fdecd2deff0fd8c9a23eae0516bd2d8e` |
+| `../..\AmazonCharacterTools\ops\__init__.py` |
+| `../..\AmazonCharacterTools\ops\Devices_FreshPlacement.py` |
+| `../..\AmazonCharacterTools\ops\Device_Replacement.py` |
+| `../..\AmazonCharacterTools\ops\hh_spawn.py` |
+| `../..\AmazonCharacterTools\ops\replace_cel_with_bsdf.py` |
+| `../..\AmazonCharacterTools\ops\remap_vector_fonts.py` |
+| `../..\AmazonCharacterTools\.git\objects\08\81792fcc238860360c31fa3bbfd24aa3bd7ade` |
+| `../..\AmazonCharacterTools\.git\objects\db\c3cebed267593b741981370b58bba5986a74d0` |
+| `../..\AmazonCharacterTools\.git\objects\info\commit-graphs\commit-graph-chain` |
+| `../..\AmazonCharacterTools\.git\objects\61\0ef9e5b54b0cf816ae1681c80b9980c73f689e` |
+| `../..\AmazonCharacterTools\.git\objects\6f\968d6a99a8d6bf29996e9c944f9b25107534c7` |
+| `../..\AmazonCharacterTools\utils\__init__.py` |
+| `../..\AmazonCharacterTools\.git\objects\cd\581b5fe0d145990eeda4b7c2b0aacf9f3a09f1` |
+| `../..\AmazonCharacterTools\.git\objects\f5\63a3de20e5f361c55d2cebfe650ee01d22e020` |
+| `../..\AmazonCharacterTools\__init__.py` |
+| `../..\AmazonCharacterTools\ui\preferences.py` |
+| `../..\AmazonCharacterTools\.git\objects\99\405af39eb1a87d4a7215900a17007db0366248` |
+| `../..\AmazonCharacterTools\.specstory\history\2026-01-12_23-06Z-powershell-c++-build-tools-necessity.md` |
+| `../..\AmazonCharacterTools\ui\__init__.py` |
+| `../..\AmazonCharacterTools\ops\SettingsBone.py` |
+| `../..\AmazonCharacterTools\.git\objects\05\0caf96ff58b977eefe5cd790d13d0af291311e` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.9.1` |
+| `../..\AmazonCharacterTools\.git\objects\49\02858db98ad72e5ace4ff886b114e8a4e52522` |
+| `../..\AmazonCharacterTools\.git\objects\8c\f1c0de414c5dfc33d06e6d644fe00099562e1a` |
+| `../..\AmazonCharacterTools\.git\objects\61\45befea944edbed776e65a0311e175a2e2b99b` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-10-16_22-09Z-port-addon-to-standard-blender-format.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-12-23_21-00Z-ssd-crash-log-explanation.md` |
+| `../..\AmazonCharacterTools\.git\objects\e2\d202e7e2f7f227479f72ed2e3689f1a1f200bb` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.9.0` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.8.0` |
+| `../..\AmazonCharacterTools\ui\panels.py` |
+| `../..\AmazonCharacterTools\.git\cursor\crepe\085b3f349bd61101198bebff24f48ad5c5c9a494\metadata.json` |
+| `../..\AmazonCharacterTools\.git\refs\tags\v0.7.0` |
+| `../..\AmazonCharacterTools\.gitea\workflows\release.yml` |
+| `../..\AmazonCharacterTools\.gitea\docker-compose.runner.yml` |
+| `../..\AmazonCharacterTools\.git\HEAD` |
+| `../..\AmazonCharacterTools\.gitignore` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-10-14_22-09Z-adding-new-features-to-blender-tools.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-08-15_17-39Z-fix-viewport-visibility-error-in-blender.md` |
+| `../..\AmazonCharacterTools\.git\config` |
+| `../..\AmazonCharacterTools\.gitattributes` |
+| `../..\AmazonCharacterTools\.git\hooks\post-commit` |
+| `../..\AmazonCharacterTools\.git\hooks\post-merge` |
+| `../..\AmazonCharacterTools\.git\hooks\post-checkout` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-push` |
+| `../..\AmazonCharacterTools\.specstory\.gitignore` |
+| `../..\AmazonCharacterTools\.specstory\.project.json` |
+| `../..\AmazonCharacterTools\ops\hh_mask.py` |
+| `../..\AmazonCharacterTools\ops\hh_settings.py` |
+| `../..\AmazonCharacterTools\ops\MaskSettings.py` |
+| `../..\AmazonCharacterTools\ops\hh_shapekey.py` |
+| `../..\AmazonCharacterTools\ops\hh_set_targets.py` |
+| `../..\AmazonCharacterTools\ops\apply_subdiv_wgt.py` |
+| `../..\AmazonCharacterTools\ops\BodyMasker.py` |
+| `../..\AmazonCharacterTools\ops\DevicesSettings.py` |
+| `../..\AmazonCharacterTools\ops\custom_vis.py` |
+| `../..\AmazonCharacterTools\ops\GeoSeparator.py` |
+| `../..\AmazonCharacterTools\.git\cursor_metadata.pb` |
+| `../..\AmazonCharacterTools\.git\packed-refs` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-03_19-28Z-create-geoseparator-script-for-organization.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-18_16-47Z-add-debug-to-serpens-addon-script.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-08-05_15-41Z-@custom-vis-py-why-the-traceback.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-01_22-15Z-troubleshooting-blender-operator-error.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-02_19-54Z-blender-keymap-and-save-settings-issue.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-02_16-34Z-adding-bone-widget-in-pose-mode.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-02_21-55Z-delete-and-create-blender-world.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-03_18-01Z-update-device-script-functionality.md` |
+| `../..\AmazonCharacterTools\.specstory\history\2025-07-03_16-24Z-get-scale-from-original-device-object.md` |
+| `../..\AmazonCharacterTools\.cursorindexingignore` |
+| `../..\AmazonCharacterTools\.git\info\exclude` |
+| `../..\AmazonCharacterTools\.git\hooks\update.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-merge-commit.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-commit.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-push.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-receive.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-rebase.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\prepare-commit-msg.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\push-to-checkout.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\sendemail-validate.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\fsmonitor-watchman.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\post-update.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\pre-applypatch.sample` |
+| `../..\AmazonCharacterTools\.git\description` |
+| `../..\AmazonCharacterTools\.git\hooks\applypatch-msg.sample` |
+| `../..\AmazonCharacterTools\.git\hooks\commit-msg.sample` |
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+I need to see the actual folder structure of AmazonCharacterTools (excluding .git and .specstory) to mirror it. Let me list the top-level and key subdirs.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\__init__.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\ui\__init__.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\ops\__init__.py
+
+
+
+
+---
+
+Thought Process
+
+Let me get the full structure - list_dir didn't work, I'll use list_dir on the path. Actually the glob already showed the structure. Key dirs:
+- T:\AmazonCharacterTools\
+ - __init__.py (main entry)
+ - blender_manifest.toml
+ - ui/
+ - __init__.py (exports OPERATOR_CLASSES, PANEL_CLASSES, AMZN_AddonPreferences)
+ - operators.py
+ - panels.py
+ - preferences.py
+ - ops/
+ - __init__.py (empty / just docstring)
+ - Various op files: Devices_FreshPlacement.py, Device_Replacement.py, etc.
+ - utils/
+ - __init__.py
+
+So the structure is:
+- Root: __init__.py, blender_manifest.toml
+- ui/: operators (UI-related?), panels, preferences; __init__ aggregates CLASSES
+- ops/: one file per feature (operators)
+- utils/: helpers
+
+I need to see how ops are registered - does __init__.py in root import from ops?
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\ui\panels.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\ui\operators.py
+
+
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "from \\.ops|import.*ops|OPERATOR",
+ "path": "T:\\AmazonCharacterTools",
+ "glob": "*.py",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "from \\.ops|import.*ops|OPERATOR",
+ "path": "T:\\AmazonCharacterTools",
+ "outputMode": "content",
+ "totalFiles": 3,
+ "totalMatches": 5,
+ "topFiles": [
+ {
+ "uri": "..\\AmazonCharacterTools\\ui\\operators.py",
+ "matchCount": 1
+ },
+ {
+ "uri": "..\\AmazonCharacterTools\\__init__.py",
+ "matchCount": 2
+ },
+ {
+ "uri": "..\\AmazonCharacterTools\\ui\\__init__.py",
+ "matchCount": 2
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+So AmazonCharacterTools structure:
+- **Root** `__init__.py`: imports from `.ui` only (OPERATOR_CLASSES, PANEL_CLASSES, AMZN_AddonPreferences), registers all.
+- **ui/**: `__init__.py` exports OPERATOR_CLASSES, PANEL_CLASSES, AMZN_AddonPreferences.
+ - `operators.py`: defines OP_SPECS (list of dicts: name, id, script, button, icon, panel); OPS_DIR = parent/ops; OPERATOR_CLASSES = dynamic operators that run scripts from ops/.
+ - `panels.py`: defines panels that reference OP_SPECS; PANEL_CLASSES.
+ - `preferences.py`: AMZN_AddonPreferences.
+- **ops/**: standalone Python scripts (e.g. SettingsBone.py) that get run via runpy from ui/operators.py. Each op is a spec that points to a script.
+
+So for Dynamic-Link-Manager we need to mirror:
+- **Root**: `__init__.py`, `blender_manifest.toml`
+- **ui/**: `__init__.py`, `operators.py` (or keep operators that are UI-triggered here), `panels.py`, `preferences.py`
+- **ops/**: one module per feature; for DLM we have (1) all the current library ops and (2) character migrator. So we could have ops/migrator.py or ops/character_migrator.py and ops/library_*.py or keep library in one file.
+- **utils/**: shared helpers (e.g. descendants, path resolution)
+
+The user said "mirror the structure" - so we need:
+- DLM root: __init__.py, blender_manifest.toml
+- ui/ with __init__, operators, panels, preferences
+- ops/ with migrator logic (and maybe library ops split out)
+- utils/ for helpers
+
+Now update the plan with all additions:
+
+1. **Linked libraries:** Keep everything; put in their own dropdown underneath CharMig.
+2. **Pair discovery:** Automatic mode + Manual mode. Only one character at a time (single pair).
+3. **Manual mode UI:** Two fields - "Original character", "Replacement character". Picker buttons that set the field to the active armature's name (or reference).
+4. **Collection context:** Operators need to be able to "view" the linked collection the armature is part of so they can manipulate other datablocks (descendants, meshes) cleanly. So we need to resolve "the collection that contains this armature" (e.g. the root collection of that character - often the armature's users_collection or the collection that has the same base name).
+5. **Project structure:** Mirror AmazonCharacterTools: ui/, ops/, utils/, root __init__.py.
+
+I'll update the plan file with these additions and the new structure.
+
+
+---
+
+
+Checking how properties are stored in the AmazonCharacterTools UI:
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\AmazonCharacterTools\ui\preferences.py
+
+
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "bpy\\.types\\.(Scene|Object)\\.",
+ "path": "T:\\Dynamic-Link-Manager",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "bpy\\.types\\.(Scene|Object)\\.",
+ "path": "T:\\Dynamic-Link-Manager",
+ "outputMode": "content",
+ "totalFiles": 3,
+ "totalMatches": 10,
+ "topFiles": [
+ {
+ "uri": ".specstory\\history\\2025-08-20_22-43Z-creating-a-blender-extension-for-asset-replacement.md",
+ "matchCount": 4
+ },
+ {
+ "uri": ".specstory/history/2025-08-20_22-43Z-creating-a-blender-extension-for-asset-replacement.md",
+ "matchCount": 4
+ },
+ {
+ "uri": "ui.py",
+ "matchCount": 2
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+I have enough. Updating the plan with:
+
+1. Linked libraries: keep everything, own dropdown underneath CharMig.
+2. Pair discovery: automatic (convention) + manual. Only one character at a time (single pair).
+3. Manual UI: two fields (original character, replacement character) + picker buttons that set from active armature.
+4. Collection context: migrator/operators should resolve the linked collection that the armature belongs to so they can work with descendants/other datablocks cleanly.
+5. Project structure: mirror AmazonCharacterTools → ui/, ops/, utils/, root __init__.py; ops/ for migrator (and library) logic; ui/ for panels, operators (that invoke ops), preferences.
+
+Writing the plan update.
+
+
+---
+
+
+Updating the plan with your additions and the AmazonCharacterTools structure.
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Now update the architecture section to reflect: (1) project structure mirroring AmazonCharacterTools, (2) manual vs automatic mode, (3) two fields + pickers, (4) collection context.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Now update the "Files to touch" and "Implementation order" sections to reflect the new structure (ui/, ops/, utils/) and the additions (linked libs kept in dropdown, manual/auto, pickers, collection context).
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The table has different formatting (extra spaces). Let me try replacing just the "## Files to touch" section through the end of implementation order in smaller chunks.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+I see - the table uses | with spaces. Let me do the replace in one go with the exact content.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The issue might be with the pipe characters in the table or some encoding. Let me try replacing just the "## Files to touch" header and the table and implementation order in two smaller replacements. First replace from "## Files to touch" through the table (up to and including the blank line before "## Implementation order").
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Good, that worked. Now replace the implementation order section.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The quotes might be different - curly vs straight. Let me try with the exact curly quotes from the fuzzy match.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The fuzzy match shows straight quotes in the file. Let me read the file again to get the exact bytes.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+I see - line 116 shows "Root init.py" - the ** got converted. And the quotes in the file might be Unicode curly quotes. Let me try copying exactly from the read_file output - the quotes look like standard ASCII in the display. I'll try replacing line by line or use a shorter unique string.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+That created a mess - I left the old step 1 in. Let me remove the old implementation steps and the "(Removed old step 1.)" and fix the numbering.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The file has curly quotes. Let me try with the exact characters - I'll try replacing just the erroneous line and the following old items.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+That's still wrong - I'm leaving duplicate/orphan content. Let me read the file and do one clean replacement to remove the leftover old steps and fix the closing sentence.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+I see - my replacement left "7. (Reserved.)" but the rest of the old step 1 was merged so now we have "7. (Reserved.) `descendants()`, pair discovery..." and then old steps 2-5 became 8-11. I need to remove lines 127-134 (the duplicate old steps and the old closing sentence) and change "7. (Reserved.)" to just the closing sentence.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The fuzzy match shows curly double quotes in the file (""). Let me try using grep to get the exact content and then do a run_terminal_cmd to do the replacement with sed or just do multiple small search_replace operations. Actually, let me try replace_all with a simpler pattern - replace line 7 onwards.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Let me try to remove from " `descendants()" through the end of the paragraph in chunks. First remove the tail of line 127.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Now remove lines 8-11 and replace with the closing sentence. Let me read the file again to get exact content.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+Let me read the current state of the file again.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+So the "7." was replaced with empty, and now we have a blank line then "2. Add **operator**" - the numbering got broken (old steps 2-5 without the "8." "9." etc). I need to delete from line 128 (blank) through 134 and replace the closing sentence with the new one.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The quotes in the file are definitely curly. Let me try writing the exact bytes - in the fuzzy match they show as " and ". I'll try copying from the fuzzy match output character by character. Actually, the simplest approach: use run_terminal_cmd to run a small Python or PowerShell script that does the replacement, or use a single-line search that's unique. Let me try replacing just "2. Add **operator**" with nothing and see if that's the right encoding - then do the rest in separate steps. Actually, I'll try with the curly quote character. In Unicode, the right double quote is U+201D. Let me try escaping or using a different approach - replace the closing sentence only first.
+
+
+---
+
+
+Trying a single-line replacement to remove the duplicate block:
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "Migrated N pairs|canonical and makes",
+ "path": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "Migrated N pairs|canonical and makes",
+ "path": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 2,
+ "topFiles": [
+ {
+ "uri": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "matchCount": 2
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+So the file does have the content. The issue is my search string uses straight quotes and the file has curly quotes. Let me try using run_terminal_cmd to replace. Actually, the run_terminal_cmd might have encoding issues too. Let me try search_replace with the Unicode curly quotes. In Python the right double quote is "\u201c" for left and "\u201d" for right. So "operator's" would be: " and '. Let me try replacing a smaller unique string that might be ascii - like "run_full_migration()" which is ascii.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+So the fuzzy match is not even that line - it's matching something else. The search_replace might be normalizing quotes. Let me try reading the raw file with a different approach - maybe the plan file is in the workspace. Let me try writing a small patch: read the full file, then write the full file with the duplicate block removed.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+I see - when I read the file, the quotes display as straight in the read_file output. So the file might have straight quotes. Let me try the replace again with the exact characters from the read - lines 128-134. I notice line 128 is blank and line 129 starts with "2. Add" - so there's no "7." in the current file. Let me try replacing the block from the blank line after step 6 through the end.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Now I need to remove the "(Removed duplicate) 2. Add **operator**" through the end of the old closing sentence.
+
+
+---
+
+
+
+
+
+---
+
+The string to replace was not found in the file.
+
+---
+
+Thought Process
+
+The file might have different quote characters. Let me try deleting line by line. First delete "(Removed duplicate) 2. Add **operator**"
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+---
+
+Thought Process
+
+So we have the correct closing paragraph already at 128-129. I just need to remove lines 130-134. The read_file shows straight apostrophe in "spec's" and "operator's" - so maybe the issue is the double-quote character. Let me try replacing only the first line of the duplicate block to see - maybe the " and " are Unicode. I'll try using run_terminal_cmd with Python to do the replacement.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **run_terminal_command_v2** • Run command: python -c "
+path = r'c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md'
+with open(path, 'r', encoding='utf-8') as f:
+ text = f.read()
+old = '''(Removed duplicate) 2. Add **operator** that gets pairs (e.g. from selection: two armatures → one pair) and calls `run_full_migration()`; report errors and \"Migrated N pairs\" in the operator's report.
+3. **UI:** One button \"Run character migration\" in the main panel (and short instructions: \"Select original and replacement armatures\").
+4. **Clean-up:** Remove or demote old linked-library operators/UI; update manifest and **init** as needed.
+5. (Optional) Add convention-based discovery and \"Run for all\" plus any options (e.g. COPY_TRANSFORMS, copy shape key values).
+
+This keeps the spec's workflow canonical and makes the add-on a thin UI + operator layer over a single, testable migrator core.'''
+if old in text:
+ text = text.replace(old, '')
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(text)
+ print('Replaced successfully')
+else:
+ print('Old string not found')
+ print('First 200 chars of what we look for:', repr(old[:200]))
+"
+
+```bash
+python -c "
+path = r'c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md'
+with open(path, 'r', encoding='utf-8') as f:
+ text = f.read()
+old = '''(Removed duplicate) 2. Add **operator** that gets pairs (e.g. from selection: two armatures → one pair) and calls `run_full_migration()`; report errors and \"Migrated N pairs\" in the operator's report.
+3. **UI:** One button \"Run character migration\" in the main panel (and short instructions: \"Select original and replacement armatures\").
+4. **Clean-up:** Remove or demote old linked-library operators/UI; update manifest and **init** as needed.
+5. (Optional) Add convention-based discovery and \"Run for all\" plus any options (e.g. COPY_TRANSFORMS, copy shape key values).
+
+This keeps the spec's workflow canonical and makes the add-on a thin UI + operator layer over a single, testable migrator core.'''
+if old in text:
+ text = text.replace(old, '')
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(text)
+ print('Replaced successfully')
+else:
+ print('Old string not found')
+ print('First 200 chars of what we look for:', repr(old[:200]))
+"
+```
+
+```
+ File "<string>", line 5
+ old = '''(Removed duplicate) 2. Add **operator** that gets pairs (e.g. from selection: two armatures \u2192 one pair) and calls
+ ^
+SyntaxError: unterminated triple-quoted string literal (detected at line 6)
+
+```
+
+
+
+---
+
+Thought Process
+
+The triple quote is conflicting. Let me read the file from Python and do the replacement with a simpler approach - read lines and write back lines 1-129 only.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **run_terminal_command_v2** • Run command: python -c "
+path = r'c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md'
+with open(path, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+# Keep lines 1-129 (0-indexed: 0-128)
+with open(path, 'w', encoding='utf-8') as f:
+ f.writelines(lines[:129])
+print('Trimmed to 129 lines')
+"
+
+```bash
+python -c "
+path = r'c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md'
+with open(path, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+# Keep lines 1-129 (0-indexed: 0-128)
+with open(path, 'w', encoding='utf-8') as f:
+ f.writelines(lines[:129])
+print('Trimmed to 129 lines')
+"
+```
+
+```
+Trimmed to 129 lines
+
+```
+
+
+
+---
+
+Thought Process
+
+Good. Now fix the typo in the plan: "Root init.py" should be "Root __init__.py" on line 116.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md
+
+
+
+
+
+---
+
+Thought Process
+
+Also fix the link in the plan - line 44 shows "T:AmazonCharacterTools" (single backslash eaten). Let me check - the plan said "Mirror the layout of [T:AmazonCharacterTools](T:\AmazonCharacterTools):" - the link might have been corrupted. Let me read that line.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "AmazonCharacterTools",
+ "path": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "AmazonCharacterTools",
+ "path": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 3,
+ "topFiles": [
+ {
+ "uri": "c:\\Users\\Nathan\\.cursor\\plans\\character_migrator_integration_cab95abc.plan.md",
+ "matchCount": 3
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+The link shows as (T:\AmazonCharacterTools) - in markdown the backslash might need to be escaped. It's fine for display. The plan is updated. Let me give the user a summary of what was added.
+
+
+---
+
+
+Summary of what was added to the plan:
+
+**1. Linked libraries**
+- All linked library analysis is kept. Its UI (scan, list, search paths, find in folders, reload, open, relocate, etc.) is in its **own dropdown/collapsible** below the CharMig section.
+
+**2. Pair discovery and single-character migration**
+- **Only one character is migrated at a time** (one pair per run).
+- **Automatic mode:** One pair is chosen by convention (`*_Rigify` / `*_Rigify.001`); user can pick from a list or use the first match.
+- **Manual mode:** The migrator has **two fields** – “Original character” and “Replacement character” – each with a **picker** that sets that field to the **active armature’s name** (or reference). Run migration uses exactly that (orig, rep) pair.
+
+**3. Collection context**
+- Operators that change more than the armature (meshes, constraints on other objects) must be able to **see the linked collection the armature belongs to**. The plan calls for a helper that resolves the **collection containing the armature** (e.g. via `users_collection` or name matching) so descendants and related datablocks can be found and updated correctly.
+
+**4. Project structure (mirror AmazonCharacterTools)**
+- **Root:** `__init__.py`, `blender_manifest.toml`
+- **ui/:** `__init__.py`, `operators.py`, `panels.py`, `preferences.py` (panels aggregate here; operators include migrator + library)
+- **ops/:** Feature logic (e.g. `ops/migrator.py`; library logic can live here too)
+- **utils/:** Helpers for `descendants(armature)` and **collection containing armature**
+
+**5. Files to touch and implementation order**
+- “Files to touch” is rewritten as a bullet list for the new layout (utils, ops, ui, root, manifest).
+- Implementation order is: (1) Restructure into ui/ops/utils, (2) utils, (3) ops/migrator, (4) migrator props and operators, (5) UI with CharMig + Linked Libraries dropdown, (6) manifest and registration.
+
+The plan file is at `c:\Users\Nathan\.cursor\plans\character_migrator_integration_cab95abc.plan.md`. A duplicate block at the end was removed so the implementation order and closing note are correct.
+
+---
+
+_**User**_
+
+Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself.
+
+To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos.
+
+---
+
+_**Agent (model default, mode Agent)**_
+
+Thought Process
+
+The user wants me to implement the Character Migrator Workflow Integration plan. Let me first check what todos exist and then work through them in order.
+
+I'll need to:
+1. Find the existing todos
+2. Restructure the project (ui/, ops/, utils/) - move existing code
+3. Add utils (descendants, collection helper)
+4. Implement ops/migrator.py
+5. Add migrator props and operators
+6. UI: CharMig section + Linked Libraries dropdown
+7. Update manifest and __init__
+
+Let me start by listing the project structure and finding the todos.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **todo_write** • Todo List
+
+- [-] Restructure: create ui/, ops/, utils/; move existing code
+- [ ] utils: descendants() and collection-containing-armature helper
+- [ ] ops/migrator.py: steps 1-6, get_pair_manual/automatic, run_full_migration
+- [ ] Migrator props and operators (run migration, pickers)
+- [ ] UI: CharMig section + Linked Libraries dropdown
+- [ ] Update blender_manifest.toml and __init__ registration
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\__init__.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui.py
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\operators.py
+
+
+
+
+---
+
+Thought Process
+
+I need to read the rest of operators.py to get all operators and understand the full register() list. Then I'll create the folder structure:
+- ui/ with operators.py, panels.py, preferences.py, __init__.py
+- ops/ with library logic (scan, find, etc.) and later migrator
+- utils/ with __init__.py
+
+The plan says to move "existing panel/operator/prefs and library logic" into ui/ and ops/. So:
+- ui/panels.py: panel classes (split from current ui.py), and the panel draw logic
+- ui/preferences.py: DynamicLinkManagerPreferences, SearchPathItem (from ui.py)
+- ui/operators.py: all DLM operators - both library and (later) migrator. The library operators can stay as operators that call into ops if we put the heavy logic in ops, or we can keep the logic in ui/operators for now and just move the file.
+- ops/: put the scan logic in ops/library.py or similar so ui/operators just invokes it. Actually re-reading the plan: "Library: move current library-scan/relink logic into ops/ (e.g. one or more modules) and have ui/operators call into them." So I'll have ops/library.py with the scan/find/relink logic, and ui/operators will define the Operator classes that call into ops.library.
+
+Let me read the full operators.py to see register() and all operator classes.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\operators.py
+
+
+
+
+---
+
+
+
+Tool use: **ripgrep_raw_search**
+
+
+Parameters:
+
+```json
+{
+ "pattern": "^class |^def register|^def unregister",
+ "path": "T:\\Dynamic-Link-Manager\\operators.py",
+ "caseInsensitive": false
+}
+```
+Additional data:
+
+```json
+{
+ "isPruned": true,
+ "pattern": "^class |^def register|^def unregister",
+ "path": "T:\\Dynamic-Link-Manager\\operators.py",
+ "outputMode": "content",
+ "totalFiles": 1,
+ "totalMatches": 15,
+ "topFiles": [
+ {
+ "uri": "operators.py",
+ "matchCount": 15
+ }
+ ]
+}
+```
+Status: **completed**
+
+
+
+
+
+---
+
+Thought Process
+
+There are two register() blocks in operators.py - the first one at 392 only registers 2 classes, then more classes are defined, and the second register() at 702 registers all. So the first register() (lines 392-394) is dead code. I'll create the new structure:
+
+1. utils/__init__.py - empty for now (will add descendants and collection helper in todo 2)
+2. ops/__init__.py - empty
+3. ops/library.py - I'll keep library logic in ui/operators for simplicity and fewer changes; the plan says "move current library-scan/relink logic into ops/" - I could either move the execute() bodies into ops.library and have operators call them, or keep operators as-is in ui/operators. To minimize breakage I'll keep the operator classes in ui/operators and not split the library logic into ops for now - we can do that refactor later. Actually the plan says "Library: move or re-export existing library operators here (or keep in ops and call from here)". So the operators stay in ui/operators, and we can put the heavy scan logic in ops/library.py. For the restructure, the minimal move is: create ui/, ops/, utils/, move the current ui.py content into ui/ (split into panels.py, preferences.py, and property groups/UIList can stay in panels or a separate file), move operators.py to ui/operators.py, then root __init__.py imports from .ui and registers. That way we don't have to split the library logic yet - we just reorganize files. Let me do that.
+
+Actually re-reading again: "Create ui/, ops/, utils/; move existing panel/operator/prefs and library logic into ui/ and ops/". So:
+- ui/ gets: panels, preferences, operators (all the current UI and operator classes)
+- ops/ gets: library logic - the plan says "move current library-scan/relink logic into ops/". So the scan_linked_assets execute() logic could go to ops/library.py scan_linked_assets(context) and the operator just calls it. Same for find_libraries_in_folders. That would make the code cleaner. Let me do the file move first without splitting the library logic (so ops/ can be empty except __init__.py), then add utils, then add migrator. We can optionally move library logic to ops in a later step. The implementation order says "move existing panel/operator/prefs and library logic into ui/ and ops/" - so I'll put the library scan/find logic into ops/library.py.
+
+Let me create:
+1. utils/__init__.py (empty for now)
+2. ops/__init__.py (empty)
+3. ops/library.py - with scan and find_libraries logic (extracted from operators)
+4. ui/__init__.py - exports OPERATOR_CLASSES, PANEL_CLASSES, preferences
+5. ui/preferences.py - SearchPathItem, DynamicLinkManagerPreferences
+6. ui/panels.py - PropertyGroups (LinkedDatablockItem, LinkedLibraryItem, DynamicLinkManagerProperties), DLM_UL_library_list, DLM_PT_main_panel. Note: the panel currently draws both CharMig (to be added) and Linked Libraries - for now I'll put the same content and add CharMig in step 5.
+7. ui/operators.py - all operator classes; scan and find_libraries will call ops.library
+8. Root __init__.py - import from .ui, register CLASSES, ensure_default_search_path, register scene props
+
+I need to fix the package references: operators use __package__ which in root is the addon name. When we move to ui/operators.py, __package__ will be addon.ui. So we need the preferences to be looked up with the root package. In Blender addons, the root __package__ is e.g. "dynamic_link_manager" (from manifest id or folder name). When we have from . import ui and ui has operators, the operators are still registered under the addon - and context.preferences.addons.get(__package__) in the root __init__ gets the addon. If operators are in ui.operators, when they do context.preferences.addons.get(__package__), __package__ will be "dynamic_link_manager.ui" or similar. So we need to use the root package for preferences. The root __init__.py has __name__ which could be "dynamic_link_manager" or "__main__". So in ui/operators.py we should use the parent package for addon prefs. We can do context.preferences.addons.get(__package__.split('.')[0]) or store the root package in a constant. Let me check the manifest - the id is "dynamiclinkmanager". So the folder is likely Dynamic_Link_Manager and the module name might be dynamic_link_manager. So __package__ in ui/operators will be "dynamic_link_manager.ui". So we need get(__package__.rsplit('.', 1)[0]) to get "dynamic_link_manager". I'll add that.
+
+Creating the files. First the directory structure and utils, ops (empty), then ui files.
+
+
+---
+
+
+Creating the `ui/`, `ops/`, and `utils/` structure and adding initial files.
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\utils\__init__.py
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ops\__init__.py
+
+
+
+
+
+---
+
+Thought Process
+
+Now I need to create ui/preferences.py with the preferences and SearchPathItem. Then ui/panels.py with the property groups, UIList, and panel. Then ui/operators.py with all operators. The operators reference context.scene.dynamic_link_manager and context.preferences.addons.get(__package__). When operators live in ui.operators, __package__ is "dynamic_link_manager.ui" (assuming the root package is dynamic_link_manager). So I need to get the root package. In Blender, the addon is typically loaded with a name - let me check the manifest: id = "dynamiclinkmanager". So the module name might be different. I'll use a helper: get the addon module from the operator's module's parent. So in ui/operators we can do:
+import sys
+_root = sys.modules.get(__name__.rsplit('.', 1)[0])
+addon_name = _root.__name__ if _root else __package__.rsplit('.', 1)[0]
+Then context.preferences.addons.get(addon_name). Actually in Blender, the addon's register() is called with __name__ being the root module. So when we're in dynamic_link_manager.ui.operators, we want to get "dynamic_link_manager" for the addon lookup. So addon_name = __package__.rsplit('.', 1)[0]. That should work.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ui\preferences.py
+
+
+
+
+
+---
+
+Thought Process
+
+I need a properties module for SearchPathItem so preferences can import it. Let me put SearchPathItem and the other property groups in ui/properties.py, then preferences and panels can import from it.
+
+
+---
+
+
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui.py
+
+
+
+
+---
+
+Thought Process
+
+I'll create ui/properties.py with SearchPathItem, LinkedDatablockItem, LinkedLibraryItem, DynamicLinkManagerProperties. Then I need to add migrator props (original_character, replacement_character) to DynamicLinkManagerProperties - the plan says scene or wm props. I'll add them in step 4. For now just the existing props.
+
+
+---
+
+
+
+
+
+---
+
+
+
+Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ui\properties.py
+
+
+
+
+
+---
+
+Thought Process
+
+I used PointerProperty for original_character and replacement_character so the picker can set the active object. Now fix preferences to use the correct import - I had from . import properties, and SearchPathItem is in properties. Good. Now create panels.py with the UIList and the main panel. The panel will draw CharMig section first (mode, two fields + pickers, Run migration) and then Linked Libraries in a dropdown. I'll add the CharMig UI in step 5; for the restructure I'll just have the Linked Libraries content in a dropdown (same as now but inside a collapsible). Let me create panels.py with the current
+
+---
+
diff --git a/__init__.py b/__init__.py
index 183aa0e..1c3410f 100644
--- a/__init__.py
+++ b/__init__.py
@@ -12,32 +12,36 @@
# along with this program. If not, see .
import bpy
-from bpy.props import StringProperty, BoolProperty, EnumProperty
-from bpy.types import Panel, Operator, PropertyGroup
-# Import local modules
-from . import operators
-from . import ui
+from .ui import CLASSES
+from .ui.properties import DynamicLinkManagerProperties
+from .ui.preferences import DynamicLinkManagerPreferences
+
def ensure_default_search_path():
- """Ensure there's always at least one search path"""
- prefs = bpy.context.preferences.addons.get(__name__)
- if prefs and len(prefs.preferences.search_paths) == 0:
- new_path = prefs.preferences.search_paths.add()
- new_path.path = "//" # Default to relative path
+ addon = bpy.context.preferences.addons.get(__name__)
+ if addon and len(addon.preferences.search_paths) == 0:
+ addon.preferences.search_paths.add().path = "//"
+
def register():
- operators.register()
- ui.register()
- # Ensure default search path exists
+ DynamicLinkManagerPreferences.bl_idname = __name__
+ for cls in CLASSES:
+ bpy.utils.register_class(cls)
+ bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
bpy.app.handlers.load_post.append(ensure_default_search_path)
+
def unregister():
- ui.unregister()
- operators.unregister()
- # Remove the handler
if ensure_default_search_path in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(ensure_default_search_path)
+ del bpy.types.Scene.dynamic_link_manager
+ for cls in reversed(CLASSES):
+ try:
+ bpy.utils.unregister_class(cls)
+ except RuntimeError:
+ pass
+
if __name__ == "__main__":
register()
diff --git a/blender_manifest.toml b/blender_manifest.toml
index c6a0ff3..f13cbc2 100644
--- a/blender_manifest.toml
+++ b/blender_manifest.toml
@@ -2,8 +2,8 @@ schema_version = "1.0.0"
id = "dynamiclinkmanager"
name = "Dynamic Link Manager"
-tagline = "Relink characters and library blends with ease"
-version = "0.0.1"
+tagline = "Character migrator and linked library tools"
+version = "0.0.2"
type = "add-on"
# Optional: Semantic Versioning
diff --git a/operators.py b/operators.py
deleted file mode 100644
index 8789694..0000000
--- a/operators.py
+++ /dev/null
@@ -1,728 +0,0 @@
-import bpy
-import os
-from bpy.types import Operator
-from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
-
-class DLM_OT_replace_linked_asset(Operator):
- """Replace a linked asset with a new file"""
- bl_idname = "dlm.replace_linked_asset"
- bl_label = "Replace Linked Asset"
- bl_options = {'REGISTER', 'UNDO'}
-
- filepath: StringProperty(
- name="File Path",
- description="Path to the new asset file",
- subtype='FILE_PATH'
- )
-
- def execute(self, context):
- obj = context.active_object
- if not obj:
- self.report({'ERROR'}, "No object selected")
- return {'CANCELLED'}
-
- # Comprehensive debug info
- debug_info = f"Object: {obj.name}, Type: {obj.type}"
-
- # Check object library
- if hasattr(obj, 'library'):
- debug_info += f", Object has library attr: {obj.library is not None}"
- if obj.library:
- debug_info += f", Object library: {obj.library.filepath}"
-
- # Check object data
- if obj.data:
- debug_info += f", Has data: {type(obj.data).__name__}, Name: {obj.data.name}"
-
- # Check data library attribute
- if hasattr(obj.data, 'library'):
- debug_info += f", Data.library exists: {obj.data.library is not None}"
- if obj.data.library:
- debug_info += f", Data.library.filepath: {obj.data.library.filepath}"
-
- # Check if data is in bpy.data collections
- if obj.type == 'ARMATURE' and obj.data.name in bpy.data.armatures:
- armature_data = bpy.data.armatures[obj.data.name]
- debug_info += f", Found in bpy.data.armatures"
- if hasattr(armature_data, 'library'):
- debug_info += f", bpy.data library: {armature_data.library is not None}"
- if armature_data.library:
- debug_info += f", bpy.data library path: {armature_data.library.filepath}"
-
- # Check if data is in bpy.data.objects
- if obj.data.name in bpy.data.objects:
- debug_info += f", Data also in bpy.data.objects"
-
- # Check if object itself is linked
- if hasattr(obj, 'library') and obj.library:
- self.report({'INFO'}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
- return {'FINISHED'}
-
- # Check if object's data is linked
- if obj.data and hasattr(obj.data, 'library') and obj.data.library:
- self.report({'INFO'}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
- return {'FINISHED'}
-
- # Check if armature data is linked through bpy.data system
- if obj.type == 'ARMATURE' and obj.data and obj.data.name in bpy.data.armatures:
- armature_data = bpy.data.armatures[obj.data.name]
- if hasattr(armature_data, 'library') and armature_data.library:
- self.report({'INFO'}, f"Armature '{obj.name}' data is linked from: {armature_data.library.filepath}")
- return {'FINISHED'}
-
- # If we get here, show debug info
- self.report({'WARNING'}, debug_info)
- self.report({'ERROR'}, "Selected object is not a linked asset")
- return {'CANCELLED'}
-
- def invoke(self, context, event):
- context.window_manager.fileselect_add(self)
- return {'RUNNING_MODAL'}
-
-class DLM_OT_scan_linked_assets(Operator):
- """Scan scene for directly linked libraries and their status"""
- bl_idname = "dlm.scan_linked_assets"
- bl_label = "Scan Linked Libraries"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- # Clear previous results
- context.scene.dynamic_link_manager.linked_libraries.clear()
-
- # Function to check if file exists
- def is_file_missing(filepath):
- if not filepath:
- return True
- try:
- abs_path = bpy.path.abspath(filepath)
- except Exception:
- abs_path = filepath
- return not os.path.isfile(abs_path)
-
- # Function to get library name from path
- def get_library_name(filepath):
- if not filepath:
- return "Unknown"
- return os.path.basename(filepath)
-
- # Helper: naive scan of a .blend file to find referenced library paths
- def scan_blend_for_missing_indirects(blend_path: str) -> bool:
- """Return True if the .blend likely references at least one missing
- library (.blend) path. This uses a conservative byte-string scan that
- never loads data into Blender and is thus context-free and safe.
-
- Strategy: look for substrings ending in .blend inside the file bytes
- and expand left/right to the nearest NUL byte or line break to
- reconstruct a plausible filesystem path. If any such path does not
- exist on disk, we consider it an indirect missing dependency.
- """
- try:
- if not blend_path or not os.path.exists(blend_path):
- return False
- with open(blend_path, 'rb') as f:
- data = f.read()
- except Exception:
- return False
-
- import re
- base_dir = os.path.dirname(blend_path)
- # Consider absolute Windows paths and Blender // relative paths
- patterns = [
- rb"[A-Za-z]:[\\/][^\r\n\0]*?\.blend",
- rb"\\\\[^\r\n\0]*?\.blend",
- rb"//[^\r\n\0]*?\.blend",
- ]
- for pat in patterns:
- for m in re.finditer(pat, data):
- try:
- s = m.group(0).decode('utf-8', errors='ignore').strip().strip('"\'')
- if s.startswith('//'):
- rel = s[2:].replace('/', os.sep).replace('\\', os.sep)
- candidate = os.path.normpath(os.path.join(base_dir, rel))
- else:
- candidate = s
- if not os.path.isfile(candidate):
- return True
- except Exception:
- continue
- return False
-
- # Reload all libraries up front so Blender populates parent links
- for lib in bpy.data.libraries:
- try:
- if lib.filepath:
- lib.reload()
- except Exception:
- pass
-
- # Use Blender's library graph: direct libraries have no parent; indirect
- # libraries have parent != None. We display only direct libraries.
- direct_libs = set()
- for lib in bpy.data.libraries:
- try:
- if lib.parent is None and lib.filepath:
- direct_libs.add(lib.filepath)
- except Exception:
- continue
-
- # Only show directly used libraries in the main list
- all_libraries = set(direct_libs)
-
- # Store results in scene properties
- context.scene.dynamic_link_manager.linked_assets_count = len(all_libraries)
-
- # Build set of direct-parent libraries that have at least one missing
- # indirect child, using the library.parent chain.
- missing_indirect_libs = set()
- for lib in bpy.data.libraries:
- try:
- if lib.parent is not None and lib.filepath:
- try:
- abs_child = bpy.path.abspath(lib.filepath)
- except Exception:
- abs_child = lib.filepath
- if not os.path.isfile(abs_child):
- # climb up to the root direct parent
- root = lib.parent
- while getattr(root, 'parent', None) is not None:
- root = root.parent
- if root and root.filepath:
- missing_indirect_libs.add(root.filepath)
- except Exception:
- continue
-
- # Additionally, mark any direct library as INDIRECT if any of its
- # linked ID users are flagged as missing by Blender (e.g., the source
- # .blend no longer contains the datablock). This catches cases where
- # the library file exists but its contents are missing.
- missing_ids_by_library = set()
- def check_missing_on(ids):
- for idb in ids:
- try:
- lib = getattr(idb, 'library', None)
- if lib and lib.filepath and getattr(idb, 'is_library_missing', False):
- missing_ids_by_library.add(lib.filepath)
- except Exception:
- continue
-
- check_missing_on(bpy.data.objects)
- check_missing_on(bpy.data.meshes)
- check_missing_on(bpy.data.armatures)
- check_missing_on(bpy.data.materials)
- check_missing_on(bpy.data.node_groups)
- check_missing_on(bpy.data.images)
- check_missing_on(bpy.data.texts)
- check_missing_on(bpy.data.collections)
- check_missing_on(bpy.data.cameras)
- check_missing_on(bpy.data.lights)
-
- # Create library items for the UI based on definitive indirect set
- library_items = []
- for filepath in sorted(all_libraries):
- if not filepath:
- continue
- lib_item = context.scene.dynamic_link_manager.linked_libraries.add()
- lib_item.filepath = filepath
- lib_item.name = get_library_name(filepath)
- lib_item.is_missing = is_file_missing(filepath)
-
- # INDIRECT if it has a missing indirect child OR any linked IDs are missing
- lib_item.is_indirect = (filepath in missing_indirect_libs) or (filepath in missing_ids_by_library)
-
- # Store for sorting
- library_items.append((lib_item, filepath))
-
- # Sort libraries: missing first, then by name
- library_items.sort(key=lambda x: (not x[0].is_missing, get_library_name(x[1]).lower()))
-
- # Clear and re-add in sorted order
- context.scene.dynamic_link_manager.linked_libraries.clear()
- for lib_item, filepath in library_items:
- new_item = context.scene.dynamic_link_manager.linked_libraries.add()
- new_item.filepath = filepath
- new_item.name = get_library_name(filepath) # Use the function directly
- new_item.is_missing = lib_item.is_missing
- new_item.is_indirect = lib_item.is_indirect
-
- # Show detailed info
- self.report({'INFO'}, f"Found {len(all_libraries)} unique linked library files")
- if all_libraries:
- for lib in sorted(all_libraries):
- self.report({'INFO'}, f"Library: {lib}")
-
- return {'FINISHED'}
-
-class DLM_OT_find_libraries_in_folders(Operator):
- """Find missing libraries in search folders and attempt to relocate them"""
- bl_idname = "dlm.find_libraries_in_folders"
- bl_label = "Find Libraries in Folders"
- bl_options = {'REGISTER', 'UNDO'}
-
- def execute(self, context):
- prefs = context.preferences.addons.get(__package__)
- if not prefs or not prefs.preferences.search_paths:
- self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
- return {'CANCELLED'}
-
- # Get missing libraries
- missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
- if not missing_libs:
- self.report({'INFO'}, "No missing libraries to find")
- return {'FINISHED'}
-
- self.report({'INFO'}, f"Searching for {len(missing_libs)} missing libraries in search paths...")
-
- # Recursive directory scanning (searches all subfolders)
- files_dir_list = []
- total_dirs_scanned = 0
-
- try:
- for search_path in prefs.preferences.search_paths:
- if search_path.path:
- # Handle both relative and absolute paths
- if search_path.path.startswith("//"):
- # Relative path - convert to absolute
- abs_path = bpy.path.abspath(search_path.path)
- else:
- # Absolute path - use as is
- abs_path = search_path.path
-
- self.report({'INFO'}, f"Scanning search path: {abs_path}")
-
- # Check if path exists and is accessible
- if not os.path.exists(abs_path):
- self.report({'WARNING'}, f"Search path does not exist or is not accessible: {abs_path}")
- continue
-
- if not os.path.isdir(abs_path):
- self.report({'WARNING'}, f"Search path is not a directory: {abs_path}")
- continue
-
- # Use os.walk to recursively scan all subdirectories
- for dirpath, dirnames, filenames in os.walk(abs_path):
- files_dir_list.append([dirpath, filenames])
- total_dirs_scanned += 1
-
- # Debug: Show what we're finding
- if len(filenames) > 0:
- blend_files = [f for f in filenames if f.endswith('.blend')]
- if blend_files:
- self.report({'INFO'}, f" Found {len(blend_files)} .blend files in: {dirpath}")
-
- # Limit to prevent excessive scanning (safety measure)
- if total_dirs_scanned > 1000:
- self.report({'WARNING'}, "Reached scan limit of 1000 directories. Some subfolders may not be scanned.")
- break
-
- self.report({'INFO'}, f"Scanned {total_dirs_scanned} directories from search path: {abs_path}")
-
- except FileNotFoundError as e:
- self.report({'ERROR'}, f"Error - Bad file path in search paths: {e}")
- return {'CANCELLED'}
- except Exception as e:
- self.report({'ERROR'}, f"Error scanning search paths: {e}")
- return {'CANCELLED'}
-
- self.report({'INFO'}, f"Total directories scanned: {total_dirs_scanned}")
-
- # Phase 1: Library finding with recursive search
- found_libraries = {} # Dictionary to store filename -> full path mapping
- found_count = 0
-
- self.report({'INFO'}, "=== PHASE 1: SEARCHING FOR LIBRARIES ===")
-
- for lib_item in missing_libs:
- lib_filename = os.path.basename(lib_item.filepath)
- self.report({'INFO'}, f"Looking for: {lib_filename}")
-
- for dir_info in files_dir_list:
- dirpath, filenames = dir_info
-
- # Exact filename match
- if lib_filename in filenames:
- new_path = os.path.join(dirpath, lib_filename)
- found_libraries[lib_filename] = new_path
- self.report({'INFO'}, f"✓ Found {lib_filename} at: {new_path}")
- found_count += 1
- break
-
- self.report({'INFO'}, f"=== SEARCH COMPLETE: Found {found_count} out of {len(missing_libs)} missing libraries ===")
-
- # Phase 2: Relinking found libraries
- if found_count > 0:
- self.report({'INFO'}, "=== PHASE 2: RELINKING LIBRARIES ===")
-
- # Manual, deterministic relink of exact filename matches.
- relinked_count = 0
- for lib in bpy.data.libraries:
- try:
- if not lib.filepath:
- continue
- lib_filename = os.path.basename(lib.filepath)
- if lib_filename in found_libraries:
- new_path = found_libraries[lib_filename]
- # Only update if currently missing and different
- current_abs = bpy.path.abspath(lib.filepath)
- if (not os.path.isfile(current_abs)) or (current_abs != new_path):
- lib.filepath = new_path
- try:
- lib.reload()
- except Exception:
- pass
- relinked_count += 1
- self.report({'INFO'}, f"Relinked {lib_filename} -> {new_path}")
- except Exception:
- continue
-
- if relinked_count == 0:
- self.report({'WARNING'}, "No libraries were relinked; ensure filenames match exactly in search paths.")
- else:
- self.report({'INFO'}, f"✓ Manually relinked {relinked_count} libraries")
- else:
- self.report({'WARNING'}, "No libraries found in search paths - nothing to relink")
-
- # Trigger a rescan so UI reflects current status immediately
- try:
- bpy.ops.dlm.scan_linked_assets()
- except Exception:
- pass
- self.report({'INFO'}, "=== OPERATION COMPLETE ===")
- return {'FINISHED'}
-
-def register():
- bpy.utils.register_class(DLM_OT_replace_linked_asset)
- bpy.utils.register_class(DLM_OT_scan_linked_assets)
-
-class DLM_OT_open_linked_file(Operator):
- """Open the linked file in a new Blender instance"""
- bl_idname = "dlm.open_linked_file"
- bl_label = "Open Linked File"
- bl_options = {'REGISTER'}
-
- filepath: StringProperty(
- name="File Path",
- description="Path to the linked file",
- default=""
- )
-
- def execute(self, context):
- if not self.filepath:
- self.report({'ERROR'}, "No file path specified")
- return {'CANCELLED'}
-
- # Try to open the linked file in a new Blender instance
- try:
- # Use Blender's built-in file browser to open the file
- bpy.ops.wm.path_open(filepath=self.filepath)
- self.report({'INFO'}, f"Opening linked file: {self.filepath}")
- except Exception as e:
- self.report({'ERROR'}, f"Failed to open linked file: {e}")
- return {'CANCELLED'}
-
- return {'FINISHED'}
-
-class DLM_OT_add_search_path(Operator):
- """Add a new search path for missing libraries"""
- bl_idname = "dlm.add_search_path"
- bl_label = "Add Search Path"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- prefs = context.preferences.addons.get(__package__)
- if prefs:
- new_path = prefs.preferences.search_paths.add()
- new_path.path = "//" # Default to relative path
- self.report({'INFO'}, f"Added search path: {new_path.path}")
- return {'FINISHED'}
-
-class DLM_OT_remove_search_path(Operator):
- """Remove a search path"""
- bl_idname = "dlm.remove_search_path"
- bl_label = "Remove Search Path"
- bl_options = {'REGISTER'}
-
- index: IntProperty(
- name="Index",
- description="Index of the search path to remove",
- default=0
- )
-
- def execute(self, context):
- prefs = context.preferences.addons.get(__package__)
- if prefs and prefs.preferences.search_paths:
- if 0 <= self.index < len(prefs.preferences.search_paths):
- prefs.preferences.search_paths.remove(self.index)
- self.report({'INFO'}, f"Removed search path at index {self.index}")
- else:
- self.report({'ERROR'}, f"Invalid index: {self.index}")
- return {'FINISHED'}
-
-class DLM_OT_attempt_relink(Operator):
- """Attempt to relink missing libraries using search paths"""
- bl_idname = "dlm.attempt_relink"
- bl_label = "Attempt Relink"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- prefs = context.preferences.addons.get(__package__)
- if not prefs or not prefs.preferences.search_paths:
- self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
- return {'CANCELLED'}
-
- # Get missing libraries
- missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
- if not missing_libs:
- self.report({'INFO'}, "No missing libraries to relink")
- return {'FINISHED'}
-
- self.report({'INFO'}, f"Attempting to relink {len(missing_libs)} missing libraries...")
-
- # Scan search paths for missing libraries (FMT-inspired approach)
- files_dir_list = []
- try:
- for search_path in prefs.preferences.search_paths:
- if search_path.path:
- for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
- files_dir_list.append([dirpath, filenames])
- except FileNotFoundError:
- self.report({'ERROR'}, f"Error - Bad file path in search paths")
- return {'CANCELLED'}
-
- # Try to find and relink each missing library
- relinked_count = 0
- indirect_errors = []
-
- for lib_item in missing_libs:
- lib_filename = os.path.basename(lib_item.filepath)
- found = False
-
- # Search through all directories
- for dir_info in files_dir_list:
- dirpath, filenames = dir_info
-
- # Look for exact filename match
- if lib_filename in filenames:
- new_path = os.path.join(dirpath, lib_filename)
- try:
- # Try to relink using Blender's system
- # This will naturally detect indirect links if they exist
- bpy.ops.file.find_missing_files()
- found = True
- relinked_count += 1
- break
- except Exception as e:
- error_msg = str(e)
- if "unable to relocate indirectly linked library" in error_msg:
- indirect_errors.append(lib_item.filepath)
- print(f"Indirect link detected for: {lib_item.filepath}")
- else:
- print(f"Error relinking {lib_item.filepath}: {e}")
-
- if found:
- break
-
- if not found:
- print(f"Could not find {lib_filename} in search paths")
-
- # Update the UI to show indirect links
- if indirect_errors:
- self.report({'WARNING'}, f"Found {len(indirect_errors)} indirectly linked libraries")
- # Mark these as having indirect missing
- for lib_item in context.scene.dynamic_link_manager.linked_libraries:
- if lib_item.filepath in indirect_errors:
- lib_item.has_indirect_missing = True
- lib_item.indirect_missing_count = 1
-
- self.report({'INFO'}, f"Relink attempt complete. Relinked: {relinked_count}, Indirect: {len(indirect_errors)}")
- return {'FINISHED'}
-
-
-
-class DLM_OT_browse_search_path(Operator):
- """Browse for a search path directory"""
- bl_idname = "dlm.browse_search_path"
- bl_label = "Browse Search Path"
- bl_options = {'REGISTER'}
-
- index: IntProperty(
- name="Index",
- description="Index of the search path to browse for",
- default=0
- )
-
- filepath: StringProperty(
- name="Search Path",
- description="Path to search for missing linked libraries",
- subtype='DIR_PATH'
- )
-
- def execute(self, context):
- prefs = context.preferences.addons.get(__package__)
- if prefs and prefs.preferences.search_paths:
- if 0 <= self.index < len(prefs.preferences.search_paths):
- prefs.preferences.search_paths[self.index].path = self.filepath
- self.report({'INFO'}, f"Updated search path {self.index + 1}: {self.filepath}")
- else:
- self.report({'ERROR'}, f"Invalid index: {self.index}")
- return {'FINISHED'}
-
- def invoke(self, context, event):
- prefs = context.preferences.addons.get(__package__)
- if prefs and prefs.preferences.search_paths:
- if 0 <= self.index < len(prefs.preferences.search_paths):
- # Set the current path as default
- self.filepath = prefs.preferences.search_paths[self.index].path
- context.window_manager.fileselect_add(self)
- return {'RUNNING_MODAL'}
-
-
-
-class DLM_OT_reload_libraries(Operator):
- """Reload all libraries using Blender's built-in reload operation"""
- bl_idname = "dlm.reload_libraries"
- bl_label = "Reload Libraries"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- try:
- # Try the outliner operation first
- bpy.ops.outliner.lib_operation(type='RELOAD')
- self.report({'INFO'}, "Library reload operation completed")
- except Exception as e:
- # Fallback: manually reload libraries
- try:
- for library in bpy.data.libraries:
- if library.filepath and os.path.exists(bpy.path.abspath(library.filepath)):
- library.reload()
- self.report({'INFO'}, "Libraries reloaded manually")
- except Exception as e2:
- self.report({'ERROR'}, f"Failed to reload libraries: {e2}")
- return {'CANCELLED'}
- return {'FINISHED'}
-
-class DLM_OT_make_paths_relative(Operator):
- """Make all file paths in the scene relative"""
- bl_idname = "dlm.make_paths_relative"
- bl_label = "Make Paths Relative"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- try:
- # Use Blender's built-in operator to make paths relative
- bpy.ops.file.make_paths_relative()
- self.report({'INFO'}, "All file paths made relative")
- except Exception as e:
- self.report({'ERROR'}, f"Failed to make paths relative: {e}")
- return {'CANCELLED'}
- return {'FINISHED'}
-
-class DLM_OT_make_paths_absolute(Operator):
- """Make all file paths in the scene absolute"""
- bl_idname = "dlm.make_paths_absolute"
- bl_label = "Make Paths Absolute"
- bl_options = {'REGISTER'}
-
- def execute(self, context):
- try:
- # Use Blender's built-in operator to make paths absolute
- bpy.ops.file.make_paths_absolute()
- self.report({'INFO'}, "All file paths made absolute")
- except Exception as e:
- self.report({'ERROR'}, f"Failed to make paths absolute: {e}")
- return {'CANCELLED'}
- return {'FINISHED'}
-
-class DLM_OT_relocate_single_library(Operator):
- """Relocate a single library by choosing a new .blend and reloading it (context-free)"""
- bl_idname = "dlm.relocate_single_library"
- bl_label = "Relocate Library"
- bl_options = {'REGISTER', 'UNDO'}
-
- # The library we want to relocate (current path as stored on the item)
- target_filepath: StringProperty(
- name="Current Library Path",
- description="Current path of the library to relocate",
- default=""
- )
-
- # New file chosen by the user via file selector
- filepath: StringProperty(
- name="New Library File",
- description="Choose the new .blend file for this library",
- subtype='FILE_PATH',
- default=""
- )
-
- def _find_library_by_path(self, path_to_match: str):
- abs_match = bpy.path.abspath(path_to_match) if path_to_match else ""
- for lib in bpy.data.libraries:
- try:
- if bpy.path.abspath(lib.filepath) == abs_match:
- return lib
- except Exception:
- # In case abspath fails for odd paths
- if lib.filepath == path_to_match:
- return lib
- return None
-
- def execute(self, context):
- if not self.target_filepath:
- self.report({'ERROR'}, "No target library specified")
- return {'CANCELLED'}
- if not self.filepath:
- self.report({'ERROR'}, "No new file selected")
- return {'CANCELLED'}
-
- library = self._find_library_by_path(self.target_filepath)
- if not library:
- self.report({'ERROR'}, "Could not resolve the selected library in bpy.data.libraries")
- return {'CANCELLED'}
-
- try:
- # Assign the new path and reload the library datablocks
- library.filepath = self.filepath
- library.reload()
- self.report({'INFO'}, f"Relocated to: {self.filepath}")
- except Exception as e:
- self.report({'ERROR'}, f"Failed to relocate: {e}")
- return {'CANCELLED'}
-
- return {'FINISHED'}
-
- def invoke(self, context, event):
- # Pre-populate the selector with the current path when possible
- if self.target_filepath:
- try:
- self.filepath = bpy.path.abspath(self.target_filepath)
- except Exception:
- self.filepath = self.target_filepath
- context.window_manager.fileselect_add(self)
- return {'RUNNING_MODAL'}
-
-def register():
- bpy.utils.register_class(DLM_OT_replace_linked_asset)
- bpy.utils.register_class(DLM_OT_scan_linked_assets)
- bpy.utils.register_class(DLM_OT_open_linked_file)
- bpy.utils.register_class(DLM_OT_add_search_path)
- bpy.utils.register_class(DLM_OT_remove_search_path)
- bpy.utils.register_class(DLM_OT_browse_search_path)
- bpy.utils.register_class(DLM_OT_attempt_relink)
- bpy.utils.register_class(DLM_OT_find_libraries_in_folders)
- bpy.utils.register_class(DLM_OT_reload_libraries)
- bpy.utils.register_class(DLM_OT_make_paths_relative)
- bpy.utils.register_class(DLM_OT_make_paths_absolute)
- bpy.utils.register_class(DLM_OT_relocate_single_library)
-
-def unregister():
- bpy.utils.unregister_class(DLM_OT_find_libraries_in_folders)
- bpy.utils.unregister_class(DLM_OT_attempt_relink)
- bpy.utils.unregister_class(DLM_OT_browse_search_path)
- bpy.utils.unregister_class(DLM_OT_remove_search_path)
- bpy.utils.unregister_class(DLM_OT_add_search_path)
- bpy.utils.unregister_class(DLM_OT_open_linked_file)
- bpy.utils.unregister_class(DLM_OT_scan_linked_assets)
- bpy.utils.unregister_class(DLM_OT_replace_linked_asset)
- bpy.utils.unregister_class(DLM_OT_reload_libraries)
- bpy.utils.unregister_class(DLM_OT_make_paths_relative)
- bpy.utils.unregister_class(DLM_OT_make_paths_absolute)
- bpy.utils.unregister_class(DLM_OT_relocate_single_library)
diff --git a/ops/__init__.py b/ops/__init__.py
new file mode 100644
index 0000000..664149f
--- /dev/null
+++ b/ops/__init__.py
@@ -0,0 +1,6 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+"""Feature logic (migrator, library) for Dynamic Link Manager."""
diff --git a/ops/library.py b/ops/library.py
new file mode 100644
index 0000000..cdf1578
--- /dev/null
+++ b/ops/library.py
@@ -0,0 +1,207 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+import os
+import re
+import bpy
+
+
+def _is_file_missing(filepath):
+ if not filepath:
+ return True
+ try:
+ abs_path = bpy.path.abspath(filepath)
+ except Exception:
+ abs_path = filepath
+ return not os.path.isfile(abs_path)
+
+
+def _get_library_name(filepath):
+ return os.path.basename(filepath) if filepath else "Unknown"
+
+
+def scan_linked_assets(context, report):
+ props = context.scene.dynamic_link_manager
+ props.linked_libraries.clear()
+
+ for lib in bpy.data.libraries:
+ try:
+ if lib.filepath:
+ lib.reload()
+ except Exception:
+ pass
+
+ direct_libs = set()
+ for lib in bpy.data.libraries:
+ try:
+ if getattr(lib, "parent", None) is None and lib.filepath:
+ direct_libs.add(lib.filepath)
+ except Exception:
+ continue
+ all_libraries = set(direct_libs)
+ props.linked_assets_count = len(all_libraries)
+
+ missing_indirect_libs = set()
+ for lib in bpy.data.libraries:
+ try:
+ if getattr(lib, "parent", None) is not None and lib.filepath:
+ try:
+ abs_child = bpy.path.abspath(lib.filepath)
+ except Exception:
+ abs_child = lib.filepath
+ if not os.path.isfile(abs_child):
+ root = lib.parent
+ while getattr(root, "parent", None) is not None:
+ root = root.parent
+ if root and root.filepath:
+ missing_indirect_libs.add(root.filepath)
+ except Exception:
+ continue
+
+ missing_ids_by_library = set()
+ for idb in list(bpy.data.objects) + list(bpy.data.meshes) + list(bpy.data.armatures) + list(bpy.data.materials) + list(bpy.data.node_groups) + list(bpy.data.images) + list(bpy.data.texts) + list(bpy.data.collections) + list(bpy.data.cameras) + list(bpy.data.lights):
+ try:
+ lib = getattr(idb, "library", None)
+ if lib and lib.filepath and getattr(idb, "is_library_missing", False):
+ missing_ids_by_library.add(lib.filepath)
+ except Exception:
+ continue
+
+ library_items = []
+ for filepath in sorted(all_libraries):
+ if not filepath:
+ continue
+ lib_item = props.linked_libraries.add()
+ lib_item.filepath = filepath
+ lib_item.name = _get_library_name(filepath)
+ lib_item.is_missing = _is_file_missing(filepath)
+ lib_item.is_indirect = (filepath in missing_indirect_libs) or (filepath in missing_ids_by_library)
+ library_items.append((lib_item, filepath))
+
+ library_items.sort(key=lambda x: (not x[0].is_missing, _get_library_name(x[1]).lower()))
+ props.linked_libraries.clear()
+ for lib_item, filepath in library_items:
+ new_item = props.linked_libraries.add()
+ new_item.filepath = filepath
+ new_item.name = _get_library_name(filepath)
+ new_item.is_missing = lib_item.is_missing
+ new_item.is_indirect = lib_item.is_indirect
+
+ report({"INFO"}, f"Found {len(all_libraries)} unique linked library files")
+ return {"FINISHED"}
+
+
+def find_libraries_in_folders(context, report, addon_name=None):
+ if addon_name is None:
+ addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
+ prefs = context.preferences.addons.get(addon_name)
+ if not prefs or not prefs.preferences.search_paths:
+ report({"ERROR"}, "No search paths configured. Add search paths in addon preferences.")
+ return {"CANCELLED"}
+
+ missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
+ if not missing_libs:
+ report({"INFO"}, "No missing libraries to find")
+ return {"FINISHED"}
+
+ report({"INFO"}, f"Searching for {len(missing_libs)} missing libraries in search paths...")
+ files_dir_list = []
+ total_dirs_scanned = 0
+ try:
+ for search_path in prefs.preferences.search_paths:
+ if not search_path.path:
+ continue
+ abs_path = bpy.path.abspath(search_path.path) if search_path.path.startswith("//") else search_path.path
+ report({"INFO"}, f"Scanning search path: {abs_path}")
+ if not os.path.exists(abs_path):
+ report({"WARNING"}, f"Search path does not exist: {abs_path}")
+ continue
+ if not os.path.isdir(abs_path):
+ report({"WARNING"}, f"Search path is not a directory: {abs_path}")
+ continue
+ for dirpath, dirnames, filenames in os.walk(abs_path):
+ files_dir_list.append([dirpath, filenames])
+ total_dirs_scanned += 1
+ if total_dirs_scanned > 1000:
+ report({"WARNING"}, "Reached scan limit of 1000 directories.")
+ break
+ except Exception as e:
+ report({"ERROR"}, f"Error scanning search paths: {e}")
+ return {"CANCELLED"}
+
+ found_libraries = {}
+ for lib_item in missing_libs:
+ lib_filename = os.path.basename(lib_item.filepath)
+ for dirpath, filenames in files_dir_list:
+ if lib_filename in filenames:
+ found_libraries[lib_filename] = os.path.join(dirpath, lib_filename)
+ report({"INFO"}, f"Found {lib_filename} at: {os.path.join(dirpath, lib_filename)}")
+ break
+
+ if found_libraries:
+ relinked_count = 0
+ for lib in bpy.data.libraries:
+ try:
+ if not lib.filepath:
+ continue
+ lib_filename = os.path.basename(lib.filepath)
+ if lib_filename in found_libraries:
+ new_path = found_libraries[lib_filename]
+ current_abs = bpy.path.abspath(lib.filepath)
+ if not os.path.isfile(current_abs) or current_abs != new_path:
+ lib.filepath = new_path
+ try:
+ lib.reload()
+ except Exception:
+ pass
+ relinked_count += 1
+ report({"INFO"}, f"Relinked {lib_filename} -> {new_path}")
+ except Exception:
+ continue
+ report({"INFO"}, f"Manually relinked {relinked_count} libraries")
+ else:
+ report({"WARNING"}, "No libraries found in search paths")
+ try:
+ bpy.ops.dlm.scan_linked_assets()
+ except Exception:
+ pass
+ report({"INFO"}, "Operation complete.")
+ return {"FINISHED"}
+
+
+def attempt_relink(context, report, addon_name=None):
+ if addon_name is None:
+ addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
+ prefs = context.preferences.addons.get(addon_name)
+ if not prefs or not prefs.preferences.search_paths:
+ report({"ERROR"}, "No search paths configured.")
+ return {"CANCELLED"}
+ missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
+ if not missing_libs:
+ report({"INFO"}, "No missing libraries to relink")
+ return {"FINISHED"}
+ report({"INFO"}, f"Attempting to relink {len(missing_libs)} missing libraries...")
+ files_dir_list = []
+ try:
+ for search_path in prefs.preferences.search_paths:
+ if search_path.path:
+ for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
+ files_dir_list.append([dirpath, filenames])
+ except FileNotFoundError:
+ report({"ERROR"}, "Bad file path in search paths")
+ return {"CANCELLED"}
+ relinked_count = 0
+ for lib_item in missing_libs:
+ lib_filename = os.path.basename(lib_item.filepath)
+ for dirpath, filenames in files_dir_list:
+ if lib_filename in filenames:
+ try:
+ bpy.ops.file.find_missing_files()
+ relinked_count += 1
+ except Exception:
+ pass
+ break
+ report({"INFO"}, f"Relink attempt complete. Relinked: {relinked_count}")
+ return {"FINISHED"}
diff --git a/ops/migrator.py b/ops/migrator.py
new file mode 100644
index 0000000..7de8a41
--- /dev/null
+++ b/ops/migrator.py
@@ -0,0 +1,211 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
+
+import bpy
+
+from ..utils import descendants
+
+
+def get_pair_manual(context):
+ """Return (orig_armature, rep_armature) from scene props, or (None, None)."""
+ props = getattr(context.scene, "dynamic_link_manager", None)
+ if not props:
+ return None, None
+ orig = getattr(props, "original_character", None)
+ rep = getattr(props, "replacement_character", None)
+ if orig and orig.type == "ARMATURE" and rep and rep.type == "ARMATURE":
+ return orig, rep
+ return None, None
+
+
+def get_pair_automatic(context):
+ """Discover one pair by convention: Name_Rigify and Name_Rigify.001. Returns (orig, rep) or (None, None)."""
+ pairs = []
+ for obj in bpy.data.objects:
+ if obj.type != "ARMATURE":
+ continue
+ name = obj.name
+ if name.endswith("_Rigify.001"):
+ base = name[:-len("_Rigify.001")]
+ orig = bpy.data.objects.get(f"{base}_Rigify")
+ if orig and orig.type == "ARMATURE" and orig != obj:
+ pairs.append((orig, obj))
+ return pairs[0] if pairs else (None, None)
+
+
+def run_step_1(orig, rep):
+ """Copy armature object attributes: location, rotation, scale."""
+ rep.location = orig.location.copy()
+ if orig.rotation_mode == "QUATERNION":
+ rep.rotation_quaternion = orig.rotation_quaternion.copy()
+ else:
+ rep.rotation_euler = orig.rotation_euler.copy()
+ rep.scale = orig.scale.copy()
+
+
+def run_step_2(orig, rep):
+ """Migrate NLA: copy tracks and strips to replacement."""
+ if not orig.animation_data or not orig.animation_data.nla_tracks:
+ return
+ if rep.animation_data is None:
+ rep.animation_data_create()
+ for track in list(rep.animation_data.nla_tracks):
+ rep.animation_data.nla_tracks.remove(track)
+ prev_track = None
+ for track in orig.animation_data.nla_tracks:
+ new_track = rep.animation_data.nla_tracks.new(prev=prev_track)
+ new_track.name = track.name
+ new_track.mute = track.mute
+ new_track.is_solo = track.is_solo
+ new_track.lock = track.lock
+ for strip in track.strips:
+ new_strip = new_track.strips.new(strip.name, int(strip.frame_start), strip.action)
+ new_strip.blend_type = strip.blend_type
+ new_strip.extrapolation = strip.extrapolation
+ new_strip.frame_end = strip.frame_end
+ new_strip.blend_in = strip.blend_in
+ new_strip.blend_out = strip.blend_out
+ new_strip.repeat = strip.repeat
+ prev_track = new_track
+
+
+EXCLUDE_PROPS = {"_RNA_UI", "rigify_type", "rigify_parameters"}
+
+
+def run_step_3(orig, rep):
+ """Custom properties: copy pose-bone custom props, exclude rigify internals."""
+ for pbone in orig.pose.bones:
+ if pbone.name not in rep.pose.bones:
+ continue
+ rbone = rep.pose.bones[pbone.name]
+ for key in list(pbone.keys()):
+ if key in EXCLUDE_PROPS:
+ continue
+ try:
+ rbone[key] = pbone[key]
+ except Exception:
+ pass
+
+
+def run_step_4(orig, rep, orig_to_rep):
+ """Bone constraints: remove stale on rep, copy from orig with retarget, trim duplicates."""
+ other_originals = [o for o in orig_to_rep if o != orig]
+ for pb in rep.pose.bones:
+ to_remove = [c for c in pb.constraints if getattr(c, "target", None) in other_originals]
+ for c in to_remove:
+ pb.constraints.remove(c)
+ for pbone in orig.pose.bones:
+ if pbone.name not in rep.pose.bones:
+ continue
+ rbone = rep.pose.bones[pbone.name]
+ for c in pbone.constraints:
+ if getattr(c, "target", None) == orig:
+ continue
+ nc = rbone.constraints.new(type=c.type)
+ nc.name = c.name
+ nc.mute = c.mute
+ nc.influence = c.influence
+ t = getattr(c, "target", None)
+ if t is not None and t in orig_to_rep:
+ nc.target = orig_to_rep[t]
+ elif getattr(nc, "target", None) is not None and t:
+ nc.target = t
+ if hasattr(c, "subtarget") and c.subtarget:
+ nc.subtarget = c.subtarget
+ for prop in ("head_tail", "use_bone_object", "invert_x", "invert_y", "invert_z"):
+ if hasattr(c, prop):
+ try:
+ setattr(nc, prop, getattr(c, prop))
+ except Exception:
+ pass
+ for pb in orig.pose.bones:
+ if pb.name not in rep.pose.bones:
+ continue
+ ro, rr = pb.constraints, rep.pose.bones[pb.name].constraints
+ while len(rr) > len(ro):
+ rr.remove(rr[-1])
+
+
+def run_step_5(orig, rep, rep_descendants, orig_to_rep):
+ """Retarget relations: parents, constraint targets, Armature modifiers to rep."""
+ candidates = set(rep_descendants)
+ for ob in bpy.data.objects:
+ if getattr(ob.parent, "name", None) == orig.name:
+ candidates.add(ob)
+ for c in getattr(ob, "constraints", []):
+ if getattr(c, "target", None) == orig:
+ candidates.add(ob)
+ for ob in candidates:
+ if ob.parent == orig:
+ ob.parent = rep
+ for c in getattr(ob, "constraints", []):
+ if getattr(c, "target", None) == orig:
+ c.target = rep
+ if ob.type == "MESH" and ob.modifiers:
+ for m in ob.modifiers:
+ if getattr(m, "object", None) == orig:
+ m.object = rep
+
+
+def run_step_6(orig, rep, rep_descendants):
+ """Replacement base body: library override only, then shape-key action."""
+ for ob in rep_descendants:
+ if ob.type != "MESH":
+ continue
+ name_lower = (ob.name + " " + (ob.data.name if ob.data else "")).lower()
+ if "body" not in name_lower or "base" not in name_lower:
+ continue
+ if ob.modifiers:
+ for m in ob.modifiers:
+ if m.type == "ARMATURE" and m.object == rep:
+ break
+ else:
+ continue
+ if getattr(ob.data, "library", None) or getattr(ob.data, "override_library", None):
+ try:
+ ob.data.override_create()
+ except Exception:
+ pass
+ if ob.data.shape_keys:
+ if not ob.data.shape_keys.animation_data:
+ ob.data.shape_keys.animation_data_create()
+ body_name = ob.name
+ action = (
+ bpy.data.actions.get(body_name + "Action")
+ or bpy.data.actions.get(ob.data.name + "Action")
+ or bpy.data.actions.get(body_name + "Action.001")
+ )
+ if action:
+ ob.data.shape_keys.animation_data.action = action
+
+
+def run_full_migration(context):
+ """
+ Run the full 7-step character migration for the single pair (manual or automatic).
+ Returns (True, message) on success, (False, error_message) on failure.
+ """
+ props = getattr(context.scene, "dynamic_link_manager", None)
+ use_auto = props and getattr(props, "migrator_mode", False)
+ orig, rep = (get_pair_automatic(context) if use_auto else get_pair_manual(context))
+ if not orig or not rep:
+ return False, "No character pair (set Original/Replacement or enable Automatic)."
+ if orig == rep:
+ return False, "Original and replacement must be different armatures."
+
+ orig_to_rep = {orig: rep}
+ rep_descendants = descendants(rep)
+
+ try:
+ run_step_1(orig, rep)
+ run_step_2(orig, rep)
+ run_step_3(orig, rep)
+ run_step_4(orig, rep, orig_to_rep)
+ run_step_5(orig, rep, rep_descendants, orig_to_rep)
+ run_step_6(orig, rep, rep_descendants)
+ except Exception as e:
+ return False, str(e)
+ return True, f"Migrated {orig.name} → {rep.name}"
diff --git a/ui.py b/ui.py
deleted file mode 100644
index 0a4b3fb..0000000
--- a/ui.py
+++ /dev/null
@@ -1,294 +0,0 @@
-import bpy
-from bpy.types import Panel, PropertyGroup, AddonPreferences, UIList
-from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty
-
-# Properties for search paths
-class SearchPathItem(PropertyGroup):
- path: StringProperty(
- name="Search Path",
- description="Path to search for missing linked libraries",
- subtype='DIR_PATH'
- )
-
-# Properties for individual linked datablocks
-class LinkedDatablockItem(PropertyGroup):
- name: StringProperty(name="Name", description="Name of the linked datablock")
- type: StringProperty(name="Type", description="Type of the linked datablock")
-
-# UIList for linked libraries
-class DLM_UL_library_list(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
- custom_icon = 'FILE_BLEND'
-
- if self.layout_type in {'DEFAULT', 'COMPACT'}:
- # Library name and status
- layout.scale_x = 0.4
- layout.label(text=item.name)
-
- # Status indicator
- layout.scale_x = 0.3
- if item.is_missing:
- layout.label(text="MISSING", icon='ERROR')
- elif item.is_indirect:
- layout.label(text="INDIRECT", icon='INFO')
- else:
- layout.label(text="OK", icon='FILE_BLEND')
-
- # File path (abbreviated)
- layout.scale_x = 0.3
- path_text = item.filepath
- if path_text.startswith("\\\\"):
- # Network path: show server name
- parts = path_text.split("\\")
- if len(parts) >= 3:
- short_path = f"\\\\{parts[2]}\\"
- else:
- short_path = "\\\\ (network)"
- elif len(path_text) >= 2 and path_text[1] == ':':
- # Drive path: show drive letter
- short_path = f"{path_text[:2]}\\"
- elif path_text.startswith("//"):
- # Relative path
- short_path = "// (relative)"
- else:
- short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
- layout.label(text=short_path, icon='FILE_FOLDER')
-
- elif self.layout_type in {'GRID'}:
- layout.alignment = 'CENTER'
- layout.label(text="", icon=custom_icon)
-
-# Properties for a single linked library file
-class LinkedLibraryItem(PropertyGroup):
- filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
- name: StringProperty(name="Name", description="Name of the linked .blend file")
- is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
- is_indirect: BoolProperty(name="Is Indirect", description="True if this is an indirectly linked library")
- is_expanded: BoolProperty(name="Expanded", description="Whether this library item is expanded in the UI", default=True)
- linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
-
-class DynamicLinkManagerProperties(PropertyGroup):
- linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
- linked_libraries_index: IntProperty(
- name="Linked Libraries Index",
- description="Index of the selected linked library",
- default=0
- )
- linked_assets_count: IntProperty(
- name="Linked Assets Count",
- description="Number of linked assets found in scene",
- default=0
- )
-
- linked_libraries_expanded: BoolProperty(
- name="Linked Libraries Expanded",
- description="Whether the linked libraries section is expanded",
- default=True
- )
-
- selected_asset_path: StringProperty(
- name="Selected Asset Path",
- description="Path to the currently selected asset",
- default=""
- )
-
-# Addon preferences for search paths
-class DynamicLinkManagerPreferences(AddonPreferences):
- bl_idname = __package__
-
- search_paths: CollectionProperty(
- type=SearchPathItem,
- name="Search Paths",
- description="Paths to search for missing linked libraries"
- )
-
- def draw(self, context):
- layout = self.layout
-
- # Search paths section
- box = layout.box()
- box.label(text="Default Search Paths for Missing Libraries")
-
- # Add button - right-justified
- row = box.row()
- row.alignment = 'RIGHT'
- row.operator("dlm.add_search_path", text="Add search path", icon='ADD')
-
- # List search paths
- for i, path_item in enumerate(self.search_paths):
- row = box.row()
- row.prop(path_item, "path", text=f"Search path {i+1}")
- # Folder icon for browsing
- row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
- # Remove button
- row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
-
-class DLM_PT_main_panel(Panel):
- bl_space_type = 'VIEW_3D'
- bl_region_type = 'UI'
- bl_category = 'Dynamic Link Manager'
- bl_label = "Dynamic Link Manager"
-
- def get_short_path(self, filepath):
- """Extract a short, readable path from a full filepath"""
- if not filepath:
- return "Unknown"
-
- # Handle relative paths
- if filepath.startswith("//"):
- return "// (relative)"
-
- # Handle Windows network paths
- if filepath.startswith("\\\\"):
- # Extract server name (e.g., \\NAS\ or \\NEXUS\)
- parts = filepath.split("\\")
- if len(parts) >= 3:
- return f"\\\\{parts[2]}\\"
- return "\\\\ (network)"
-
- # Handle Windows drive paths
- if len(filepath) >= 2 and filepath[1] == ':':
- return f"{filepath[:2]}\\"
-
- # Handle Unix-style paths
- if filepath.startswith("/"):
- parts = filepath.split("/")
- if len(parts) >= 2:
- return f"/{parts[1]}/"
- return "/ (root)"
-
- # Fallback
- return "Unknown"
-
- def draw_header(self, context):
- layout = self.layout
- layout.operator("preferences.addon_show", text="", icon='PREFERENCES').module = __package__
-
- def draw(self, context):
- layout = self.layout
- props = context.scene.dynamic_link_manager
-
- # Path management buttons
- row = layout.row()
- row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon='FILE_PARENT')
- row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon='FILE_FOLDER')
-
- # Main scan section
- box = layout.box()
- box.label(text="Linked Libraries Analysis")
- row = box.row()
- row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon='FILE_REFRESH')
-
- # Show total count and missing count
- missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
- if missing_count > 0:
- row.label(text=f"({props.linked_assets_count} libraries, {missing_count} missing)", icon='ERROR')
- else:
- row.label(text=f"({props.linked_assets_count} libraries)")
-
- # Show more detailed info if we have results
- if props.linked_assets_count > 0:
- # Linked Libraries section with single dropdown
- row = box.row(align=True)
-
- # Dropdown arrow for the entire section
- icon = 'DISCLOSURE_TRI_DOWN' if props.linked_libraries_expanded else 'DISCLOSURE_TRI_RIGHT'
- row.prop(props, "linked_libraries_expanded", text="", icon=icon, icon_only=True)
-
- # Section header
- row.label(text="Linked Libraries:")
- row.label(text=f"({props.linked_assets_count} libraries)")
-
- # Only show library details if section is expanded
- if props.linked_libraries_expanded:
- # Compact list view
- row = box.row()
- row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
-
- # Action buttons below the list
- row = box.row()
- row.operator("dlm.reload_libraries", text="Reload Libraries", icon='FILE_REFRESH')
-
- # Search Paths Management - integrated into Linked Libraries Analysis
- if props.linked_assets_count > 0:
- # Get preferences for search paths
- prefs = context.preferences.addons.get(__package__)
-
- # Search paths list - Each path gets its own row with folder icon
- if prefs and prefs.preferences.search_paths:
- for i, path_item in enumerate(prefs.preferences.search_paths):
- row = box.row()
- row.prop(path_item, "path", text=f"Search path {i+1}")
- # Folder icon for browsing
- row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
- # Remove button
- row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
-
- # Add button - Just the + button, right-justified
- row = box.row()
- row.alignment = 'RIGHT'
- row.operator("dlm.add_search_path", text="Add search path", icon='ADD')
-
- # Main action button
- if missing_count > 0:
- row = box.row()
- row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon='VIEWZOOM')
-
- # Show details of selected item at the bottom
- if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
- selected_lib = props.linked_libraries[props.linked_libraries_index]
- info_box = box.box()
-
- # Make entire box red if library is missing
- if selected_lib.is_missing:
- info_box.alert = True
-
- info_box.label(text=f"Selected: {selected_lib.name}")
-
- if selected_lib.is_missing:
- row = info_box.row()
- row.label(text="Status: MISSING", icon='ERROR')
- elif selected_lib.is_indirect:
- row = info_box.row()
- row.label(text="Status: INDIRECT", icon='INFO')
- else:
- row = info_box.row()
- row.label(text="Status: OK", icon='FILE_BLEND')
-
- # Show full path and Open Blend button
- row = info_box.row()
- row.label(text=f"Path: {selected_lib.filepath}", icon='FILE_FOLDER')
-
- row = info_box.row()
- row.operator("dlm.open_linked_file", text="Open Blend", icon='FILE_BLEND').filepath = selected_lib.filepath
-
- # Relocate (context-free): lets the user pick a new .blend and we reload the library
- row = info_box.row()
- op = row.operator("dlm.relocate_single_library", text="Relocate Library", icon='FILE_FOLDER')
- op.target_filepath = selected_lib.filepath
-
-
-
-def register():
- bpy.utils.register_class(SearchPathItem)
- bpy.utils.register_class(LinkedDatablockItem)
- bpy.utils.register_class(DLM_UL_library_list)
- bpy.utils.register_class(LinkedLibraryItem)
- bpy.utils.register_class(DynamicLinkManagerProperties)
- bpy.utils.register_class(DynamicLinkManagerPreferences)
- bpy.utils.register_class(DLM_PT_main_panel)
-
- # Register properties to scene
- bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
-
-def unregister():
- # Unregister properties from scene
- del bpy.types.Scene.dynamic_link_manager
-
- bpy.utils.unregister_class(DLM_PT_main_panel)
- bpy.utils.unregister_class(DynamicLinkManagerPreferences)
- bpy.utils.unregister_class(DynamicLinkManagerProperties)
- bpy.utils.unregister_class(LinkedLibraryItem)
- bpy.utils.unregister_class(DLM_UL_library_list)
- bpy.utils.unregister_class(LinkedDatablockItem)
- bpy.utils.unregister_class(SearchPathItem)
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 0000000..3a5d56e
--- /dev/null
+++ b/ui/__init__.py
@@ -0,0 +1,24 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+from .operators import OPERATOR_CLASSES
+from .panels import DLM_PT_main_panel, DLM_UL_library_list
+from .preferences import DynamicLinkManagerPreferences
+from . import properties
+
+PANEL_CLASSES = [DLM_PT_main_panel, DLM_UL_library_list]
+
+CLASSES = (
+ properties.SearchPathItem,
+ properties.LinkedDatablockItem,
+ properties.LinkedLibraryItem,
+ properties.DynamicLinkManagerProperties,
+ DynamicLinkManagerPreferences,
+ DLM_UL_library_list,
+ DLM_PT_main_panel,
+ *OPERATOR_CLASSES,
+)
+
+__all__ = ["CLASSES", "OPERATOR_CLASSES", "PANEL_CLASSES", "DynamicLinkManagerPreferences", "properties"]
diff --git a/ui/operators.py b/ui/operators.py
new file mode 100644
index 0000000..3167329
--- /dev/null
+++ b/ui/operators.py
@@ -0,0 +1,307 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+import bpy
+import os
+from bpy.types import Operator
+from bpy.props import StringProperty, IntProperty
+
+ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
+
+
+def _prefs(context):
+ return context.preferences.addons.get(ADDON_NAME)
+
+
+class DLM_OT_replace_linked_asset(Operator):
+ bl_idname = "dlm.replace_linked_asset"
+ bl_label = "Replace Linked Asset"
+ bl_options = {"REGISTER", "UNDO"}
+ filepath: StringProperty(name="File Path", description="Path to the new asset file", subtype="FILE_PATH")
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ self.report({"ERROR"}, "No object selected")
+ return {"CANCELLED"}
+ if getattr(obj, "library", None):
+ self.report({"INFO"}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
+ return {"FINISHED"}
+ if obj.data and getattr(obj.data, "library", None) and obj.data.library:
+ self.report({"INFO"}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
+ return {"FINISHED"}
+ if obj.type == "ARMATURE" and obj.data and obj.data.name in bpy.data.armatures:
+ ad = bpy.data.armatures[obj.data.name]
+ if getattr(ad, "library", None) and ad.library:
+ self.report({"INFO"}, f"Armature '{obj.name}' data is linked from: {ad.library.filepath}")
+ return {"FINISHED"}
+ self.report({"ERROR"}, "Selected object is not a linked asset")
+ return {"CANCELLED"}
+
+ def invoke(self, context, event):
+ context.window_manager.fileselect_add(self)
+ return {"RUNNING_MODAL"}
+
+
+class DLM_OT_scan_linked_assets(Operator):
+ bl_idname = "dlm.scan_linked_assets"
+ bl_label = "Scan Linked Libraries"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ from ..ops import library
+
+ return library.scan_linked_assets(context, self.report)
+
+
+class DLM_OT_find_libraries_in_folders(Operator):
+ bl_idname = "dlm.find_libraries_in_folders"
+ bl_label = "Find Libraries in Folders"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context):
+ from ..ops import library
+
+ return library.find_libraries_in_folders(context, self.report, ADDON_NAME)
+
+
+class DLM_OT_open_linked_file(Operator):
+ bl_idname = "dlm.open_linked_file"
+ bl_label = "Open Linked File"
+ bl_options = {"REGISTER"}
+ filepath: StringProperty(name="File Path", default="")
+
+ def execute(self, context):
+ if not self.filepath:
+ self.report({"ERROR"}, "No file path specified")
+ return {"CANCELLED"}
+ try:
+ bpy.ops.wm.path_open(filepath=self.filepath)
+ self.report({"INFO"}, f"Opening linked file: {self.filepath}")
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to open linked file: {e}")
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+
+class DLM_OT_add_search_path(Operator):
+ bl_idname = "dlm.add_search_path"
+ bl_label = "Add Search Path"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ prefs = _prefs(context)
+ if prefs:
+ p = prefs.preferences.search_paths.add()
+ p.path = "//"
+ self.report({"INFO"}, f"Added search path: {p.path}")
+ return {"FINISHED"}
+
+
+class DLM_OT_remove_search_path(Operator):
+ bl_idname = "dlm.remove_search_path"
+ bl_label = "Remove Search Path"
+ bl_options = {"REGISTER"}
+ index: IntProperty(name="Index", default=0)
+
+ def execute(self, context):
+ prefs = _prefs(context)
+ if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
+ prefs.preferences.search_paths.remove(self.index)
+ self.report({"INFO"}, f"Removed search path at index {self.index}")
+ return {"FINISHED"}
+
+
+class DLM_OT_attempt_relink(Operator):
+ bl_idname = "dlm.attempt_relink"
+ bl_label = "Attempt Relink"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ from ..ops import library
+
+ return library.attempt_relink(context, self.report, ADDON_NAME)
+
+
+class DLM_OT_browse_search_path(Operator):
+ bl_idname = "dlm.browse_search_path"
+ bl_label = "Browse Search Path"
+ bl_options = {"REGISTER"}
+ index: IntProperty(name="Index", default=0)
+ filepath: StringProperty(name="Search Path", subtype="DIR_PATH")
+
+ def execute(self, context):
+ prefs = _prefs(context)
+ if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
+ prefs.preferences.search_paths[self.index].path = self.filepath
+ self.report({"INFO"}, f"Updated search path {self.index + 1}: {self.filepath}")
+ return {"FINISHED"}
+
+ def invoke(self, context, event):
+ prefs = _prefs(context)
+ if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
+ self.filepath = prefs.preferences.search_paths[self.index].path
+ context.window_manager.fileselect_add(self)
+ return {"RUNNING_MODAL"}
+
+
+class DLM_OT_reload_libraries(Operator):
+ bl_idname = "dlm.reload_libraries"
+ bl_label = "Reload Libraries"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ try:
+ bpy.ops.outliner.lib_operation(type="RELOAD")
+ self.report({"INFO"}, "Library reload operation completed")
+ except Exception:
+ try:
+ for lib in bpy.data.libraries:
+ if lib.filepath and os.path.exists(bpy.path.abspath(lib.filepath)):
+ lib.reload()
+ self.report({"INFO"}, "Libraries reloaded manually")
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to reload libraries: {e}")
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+
+class DLM_OT_make_paths_relative(Operator):
+ bl_idname = "dlm.make_paths_relative"
+ bl_label = "Make Paths Relative"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ try:
+ bpy.ops.file.make_paths_relative()
+ self.report({"INFO"}, "All file paths made relative")
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to make paths relative: {e}")
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+
+class DLM_OT_make_paths_absolute(Operator):
+ bl_idname = "dlm.make_paths_absolute"
+ bl_label = "Make Paths Absolute"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ try:
+ bpy.ops.file.make_paths_absolute()
+ self.report({"INFO"}, "All file paths made absolute")
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to make paths absolute: {e}")
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+
+class DLM_OT_relocate_single_library(Operator):
+ bl_idname = "dlm.relocate_single_library"
+ bl_label = "Relocate Library"
+ bl_options = {"REGISTER", "UNDO"}
+ target_filepath: StringProperty(name="Current Library Path", default="")
+ filepath: StringProperty(name="New Library File", subtype="FILE_PATH", default="")
+
+ def execute(self, context):
+ if not self.target_filepath or not self.filepath:
+ self.report({"ERROR"}, "No target or new file specified")
+ return {"CANCELLED"}
+ abs_match = bpy.path.abspath(self.target_filepath) if self.target_filepath else ""
+ library = None
+ for lib in bpy.data.libraries:
+ try:
+ if lib.filepath and bpy.path.abspath(lib.filepath) == abs_match:
+ library = lib
+ break
+ except Exception:
+ if lib.filepath == self.target_filepath:
+ library = lib
+ break
+ if not library:
+ self.report({"ERROR"}, "Could not resolve the selected library")
+ return {"CANCELLED"}
+ try:
+ library.filepath = self.filepath
+ library.reload()
+ self.report({"INFO"}, f"Relocated to: {self.filepath}")
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to relocate: {e}")
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+ def invoke(self, context, event):
+ if self.target_filepath:
+ try:
+ self.filepath = bpy.path.abspath(self.target_filepath)
+ except Exception:
+ self.filepath = self.target_filepath
+ context.window_manager.fileselect_add(self)
+ return {"RUNNING_MODAL"}
+
+
+class DLM_OT_run_character_migration(Operator):
+ bl_idname = "dlm.run_character_migration"
+ bl_label = "Run Character Migration"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context):
+ from ..ops.migrator import run_full_migration
+
+ ok, msg = run_full_migration(context)
+ if ok:
+ self.report({"INFO"}, msg)
+ return {"FINISHED"}
+ self.report({"ERROR"}, msg)
+ return {"CANCELLED"}
+
+
+class DLM_OT_picker_original_character(Operator):
+ bl_idname = "dlm.picker_original_character"
+ bl_label = "Pick Original"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj or obj.type != "ARMATURE":
+ self.report({"WARNING"}, "Select an armature")
+ return {"CANCELLED"}
+ context.scene.dynamic_link_manager.original_character = obj
+ self.report({"INFO"}, f"Original: {obj.name}")
+ return {"FINISHED"}
+
+
+class DLM_OT_picker_replacement_character(Operator):
+ bl_idname = "dlm.picker_replacement_character"
+ bl_label = "Pick Replacement"
+ bl_options = {"REGISTER"}
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj or obj.type != "ARMATURE":
+ self.report({"WARNING"}, "Select an armature")
+ return {"CANCELLED"}
+ context.scene.dynamic_link_manager.replacement_character = obj
+ self.report({"INFO"}, f"Replacement: {obj.name}")
+ return {"FINISHED"}
+
+
+OPERATOR_CLASSES = [
+ DLM_OT_replace_linked_asset,
+ DLM_OT_scan_linked_assets,
+ DLM_OT_find_libraries_in_folders,
+ DLM_OT_open_linked_file,
+ DLM_OT_add_search_path,
+ DLM_OT_remove_search_path,
+ DLM_OT_browse_search_path,
+ DLM_OT_attempt_relink,
+ DLM_OT_reload_libraries,
+ DLM_OT_make_paths_relative,
+ DLM_OT_make_paths_absolute,
+ DLM_OT_relocate_single_library,
+ DLM_OT_run_character_migration,
+ DLM_OT_picker_original_character,
+ DLM_OT_picker_replacement_character,
+]
diff --git a/ui/panels.py b/ui/panels.py
new file mode 100644
index 0000000..d6e462e
--- /dev/null
+++ b/ui/panels.py
@@ -0,0 +1,138 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+import bpy
+from bpy.types import Panel, UIList
+from . import properties
+
+
+class DLM_UL_library_list(UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
+ if self.layout_type in {"DEFAULT", "COMPACT"}:
+ layout.scale_x = 0.4
+ layout.label(text=item.name)
+ layout.scale_x = 0.3
+ if item.is_missing:
+ layout.label(text="MISSING", icon="ERROR")
+ elif item.is_indirect:
+ layout.label(text="INDIRECT", icon="INFO")
+ else:
+ layout.label(text="OK", icon="FILE_BLEND")
+ layout.scale_x = 0.3
+ path_text = item.filepath or ""
+ if path_text.startswith("\\\\"):
+ parts = path_text.split("\\")
+ short_path = f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
+ elif len(path_text) >= 2 and path_text[1] == ":":
+ short_path = f"{path_text[:2]}\\"
+ elif path_text.startswith("//"):
+ short_path = "// (relative)"
+ else:
+ short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
+ layout.label(text=short_path, icon="FILE_FOLDER")
+ elif self.layout_type == "GRID":
+ layout.alignment = "CENTER"
+ layout.label(text="", icon="FILE_BLEND")
+
+
+def _get_short_path(filepath):
+ if not filepath:
+ return "Unknown"
+ if filepath.startswith("//"):
+ return "// (relative)"
+ if filepath.startswith("\\\\"):
+ parts = filepath.split("\\")
+ return f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
+ if len(filepath) >= 2 and filepath[1] == ":":
+ return f"{filepath[:2]}\\"
+ if filepath.startswith("/"):
+ parts = filepath.split("/")
+ return f"/{parts[1]}/" if len(parts) >= 2 else "/ (root)"
+ return "Unknown"
+
+
+class DLM_PT_main_panel(Panel):
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "UI"
+ bl_category = "Dynamic Link Manager"
+ bl_label = "Dynamic Link Manager"
+
+ def draw_header(self, context):
+ layout = self.layout
+ layout.operator("preferences.addon_show", text="", icon="PREFERENCES").module = __package__.rsplit(".", 1)[0]
+
+ def draw(self, context):
+ layout = self.layout
+ props = context.scene.dynamic_link_manager
+
+ # Path management
+ row = layout.row()
+ row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon="FILE_PARENT")
+ row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon="FILE_FOLDER")
+
+ # CharMig section (placeholder; full UI in implementation step 5)
+ box = layout.box()
+ box.label(text="Character Migrator")
+ row = box.row()
+ row.prop(props, "migrator_mode", text="Automatic pair discovery")
+ row = box.row()
+ row.prop(props, "original_character", text="Original")
+ row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")
+ row = box.row()
+ row.prop(props, "replacement_character", text="Replacement")
+ row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")
+ row = box.row()
+ row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
+
+ # Linked Libraries (own dropdown)
+ box = layout.box()
+ box.label(text="Linked Libraries Analysis")
+ row = box.row()
+ row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon="FILE_REFRESH")
+ missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
+ if missing_count > 0:
+ row.label(text=f"({props.linked_assets_count} libraries, {missing_count} missing)", icon="ERROR")
+ else:
+ row.label(text=f"({props.linked_assets_count} libraries)")
+ if props.linked_assets_count > 0:
+ row = box.row(align=True)
+ icon = "DISCLOSURE_TRI_DOWN" if props.linked_libraries_expanded else "DISCLOSURE_TRI_RIGHT"
+ row.prop(props, "linked_libraries_expanded", text="", icon=icon, icon_only=True)
+ row.label(text="Linked Libraries:")
+ row.label(text=f"({props.linked_assets_count} libraries)")
+ if props.linked_libraries_expanded:
+ row = box.row()
+ row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
+ row = box.row()
+ row.operator("dlm.reload_libraries", text="Reload Libraries", icon="FILE_REFRESH")
+ prefs = context.preferences.addons.get(__package__.rsplit(".", 1)[0])
+ if prefs and prefs.preferences.search_paths:
+ for i, path_item in enumerate(prefs.preferences.search_paths):
+ row = box.row()
+ row.prop(path_item, "path", text=f"Search path {i+1}")
+ row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
+ row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
+ row = box.row()
+ row.alignment = "RIGHT"
+ row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
+ if missing_count > 0:
+ row = box.row()
+ row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon="VIEWZOOM")
+ if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
+ selected_lib = props.linked_libraries[props.linked_libraries_index]
+ info_box = box.box()
+ if selected_lib.is_missing:
+ info_box.alert = True
+ info_box.label(text=f"Selected: {selected_lib.name}")
+ if selected_lib.is_missing:
+ info_box.label(text="Status: MISSING", icon="ERROR")
+ elif selected_lib.is_indirect:
+ info_box.label(text="Status: INDIRECT", icon="INFO")
+ else:
+ info_box.label(text="Status: OK", icon="FILE_BLEND")
+ info_box.label(text=f"Path: {selected_lib.filepath}", icon="FILE_FOLDER")
+ info_box.operator("dlm.open_linked_file", text="Open Blend", icon="FILE_BLEND").filepath = selected_lib.filepath
+ op = info_box.operator("dlm.relocate_single_library", text="Relocate Library", icon="FILE_FOLDER")
+ op.target_filepath = selected_lib.filepath
diff --git a/ui/preferences.py b/ui/preferences.py
new file mode 100644
index 0000000..bd846cb
--- /dev/null
+++ b/ui/preferences.py
@@ -0,0 +1,32 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+import bpy
+from bpy.types import AddonPreferences
+from bpy.props import StringProperty, CollectionProperty
+from . import properties
+
+
+class DynamicLinkManagerPreferences(AddonPreferences):
+ bl_idname = __package__.rsplit(".", 1)[0]
+
+ search_paths: CollectionProperty(
+ type=properties.SearchPathItem,
+ name="Search Paths",
+ description="Paths to search for missing linked libraries",
+ )
+
+ def draw(self, context):
+ layout = self.layout
+ box = layout.box()
+ box.label(text="Default Search Paths for Missing Libraries")
+ row = box.row()
+ row.alignment = "RIGHT"
+ row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
+ for i, path_item in enumerate(self.search_paths):
+ row = box.row()
+ row.prop(path_item, "path", text=f"Search path {i+1}")
+ row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
+ row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
diff --git a/ui/properties.py b/ui/properties.py
new file mode 100644
index 0000000..2380084
--- /dev/null
+++ b/ui/properties.py
@@ -0,0 +1,57 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+import bpy
+from bpy.types import PropertyGroup
+from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty, PointerProperty
+
+
+class SearchPathItem(PropertyGroup):
+ path: StringProperty(
+ name="Search Path",
+ description="Path to search for missing linked libraries",
+ subtype="DIR_PATH",
+ )
+
+
+class LinkedDatablockItem(PropertyGroup):
+ name: StringProperty(name="Name", description="Name of the linked datablock")
+ type: StringProperty(name="Type", description="Type of the linked datablock")
+
+
+class LinkedLibraryItem(PropertyGroup):
+ filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
+ name: StringProperty(name="Name", description="Name of the linked .blend file")
+ is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
+ is_indirect: BoolProperty(name="Is Indirect", description="True if this is an indirectly linked library")
+ is_expanded: BoolProperty(name="Expanded", default=True)
+ linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
+
+
+class DynamicLinkManagerProperties(PropertyGroup):
+ linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
+ linked_libraries_index: IntProperty(name="Linked Libraries Index", default=0)
+ linked_assets_count: IntProperty(name="Linked Assets Count", default=0)
+ linked_libraries_expanded: BoolProperty(name="Linked Libraries Expanded", default=True)
+ selected_asset_path: StringProperty(name="Selected Asset Path", default="")
+
+ # Character migrator (manual mode)
+ migrator_mode: BoolProperty(
+ name="Automatic",
+ description="Automatic: discover pair by Name_Rigify / Name_Rigify.001. Manual: use fields below",
+ default=False,
+ )
+ original_character: PointerProperty(
+ name="Original Character",
+ description="Armature to migrate from",
+ type=bpy.types.Object,
+ poll=lambda self, obj: obj and obj.type == "ARMATURE",
+ )
+ replacement_character: PointerProperty(
+ name="Replacement Character",
+ description="Armature to migrate to",
+ type=bpy.types.Object,
+ poll=lambda self, obj: obj and obj.type == "ARMATURE",
+ )
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..7d82175
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,39 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+
+"""Shared helpers for Dynamic Link Manager."""
+
+import bpy
+
+
+def descendants(armature):
+ """Return a set of objects whose parent chain leads to the given armature."""
+ out = set()
+ for ob in bpy.data.objects:
+ p = ob.parent
+ while p:
+ if p == armature:
+ out.add(ob)
+ break
+ p = p.parent
+ return out
+
+
+def collection_containing_armature(armature):
+ """
+ Return a collection that contains the armature (for linked character context).
+ Prefers a collection whose name matches the character base (e.g. "Steve" for Steve_Rigify).
+ """
+ if not armature or armature.name not in bpy.data.objects:
+ return None
+ colls = getattr(armature, "users_collection", []) or []
+ if not colls:
+ return None
+ name = armature.name
+ base = name.replace("_Rigify", "").replace(".001", "").rstrip("0123456789.")
+ for c in colls:
+ if c.name == base or base in c.name or c.name in name:
+ return c
+ return colls[0]