Mixed origin workflows (Save To...) #17

Closed
opened 2026-02-05 18:30:36 +00:00 by forbes · 1 comment
Owner

Overview

Enable workflows where users work with documents across different origins. This includes migrating local documents to Silo, copying Silo documents to new part numbers, and saving Silo documents locally for offline use or sharing.

Key Insight: Since all documents live locally, "mixed origin" is really about:

  • Migration: Adding Silo tracking to an untracked local document
  • Copy: Creating a new Silo item from an existing Silo document
  • Export: Saving a Silo document without syncing (local-only save)

Parent Issue

Epic: #8 Unified File Origin System

Goals

  1. Handle "Save As" intelligently based on document state and target origin
  2. Support migrating local documents to Silo (add tracking)
  3. Support copying Silo documents (new part number)
  4. Support exporting Silo documents locally (offline/sharing)
  5. Clear UX for each workflow

Detailed Design

Save As Behavior Matrix

Document State Target Origin Action
Local (untracked) Local Standard Save As (file picker)
Local (untracked) Silo Migration: Part creation form → add Silo properties
Silo tracked Same Silo Copy: Part creation form → new part number
Silo tracked Different Silo Transfer: Part creation form on target → new part number
Silo tracked Local Export: File picker → save WITHOUT Silo properties

Migration Workflow: Local → Silo

User has a local document and wants to start tracking it in Silo:

┌─────────────────────────────────────────────────────────────────────┐
│ Add to Silo (Work)                                            [X]  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Current file: ~/Documents/my_bracket.FCStd                        │
│                                                                     │
│  This will register the document in Silo and enable:               │
│  • Revision tracking                                                │
│  • Cloud backup to MinIO                                           │
│  • Part number assignment                                          │
│                                                                     │
│  ── Part Information ─────────────────────────────────────────────  │
│                                                                     │
│  Category:      [Brackets & Mounts          ▼]                     │
│  Description:   [Mounting Bracket           ]                       │
│                                                                     │
│  Generated Part Number: B03-0042                                    │
│                                                                     │
│  ── File Location ────────────────────────────────────────────────  │
│                                                                     │
│  The file will be moved to:                                        │
│  ~/projects/cad/B03_brackets_mounts/B03-0042_Mounting_Bracket.FCStd│
│                                                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                    [Cancel]  [Add to Silo]         │
└─────────────────────────────────────────────────────────────────────┘

Implementation:

def _migrate_to_silo(self, doc):
    """Migrate a local document to Silo tracking."""
    # 1. Show category selection (from schema YAML)
    dialog = PartCreationDialog(self._sync)
    if dialog.exec() != QDialog.Accepted:
        return
    
    # 2. Create item in Silo database
    part_number = self._sync.create_item(
        category=dialog.category(),
        description=dialog.description()
    )
    
    # 3. Add Silo properties to document
    obj = get_or_create_tracked_object(doc)
    obj.SiloPartNumber = part_number
    obj.SiloRevision = 1
    obj.SiloOriginId = self._id
    
    # 4. Move to canonical path
    new_path = get_cad_file_path(part_number, dialog.description())
    old_path = doc.FileName
    doc.saveAs(str(new_path))
    if old_path and old_path != str(new_path):
        Path(old_path).unlink(missing_ok=True)
    
    # 5. Upload to MinIO
    self._sync.upload_file(part_number, new_path)

Copy Workflow: Silo → Silo (Same Instance)

User wants to create a variant or copy of an existing Silo document:

┌─────────────────────────────────────────────────────────────────────┐
│ Copy as New Part                                              [X]  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Original: F01-0001 - M3x10 Socket Head Cap Screw                  │
│                                                                     │
│  Creating a copy will:                                              │
│  • Generate a new part number                                       │
│  • Create independent revision history                              │
│  • Keep current document open (original unchanged)                  │
│                                                                     │
│  ── New Part Information ─────────────────────────────────────────  │
│                                                                     │
│  Category:      [Fasteners - Screws         ▼]  (same as original) │
│  Description:   [M3x12 Socket Head Cap Screw]                       │
│                                                                     │
│  Generated Part Number: F01-0156                                    │
│                                                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                    [Cancel]  [Create Copy]         │
└─────────────────────────────────────────────────────────────────────┘

Implementation:

def _copy_silo_document(self, doc):
    """Create a copy with new part number."""
    # 1. Get original info for pre-fill
    original_pn = get_part_number(doc)
    original_item = self._sync.get_item(original_pn)
    
    # 2. Show dialog with pre-filled values
    dialog = PartCreationDialog(self._sync)
    dialog.set_category(original_item["category"])
    dialog.set_description(original_item["description"] + " (copy)")
    
    if dialog.exec() != QDialog.Accepted:
        return
    
    # 3. Create new item
    new_pn = self._sync.create_item(
        category=dialog.category(),
        description=dialog.description()
    )
    
    # 4. Save copy (keep original open)
    new_path = get_cad_file_path(new_pn, dialog.description())
    
    # Create copy without modifying original
    import shutil
    shutil.copy2(doc.FileName, new_path)
    
    # Open copy and update properties
    new_doc = FreeCAD.openDocument(str(new_path))
    obj = get_tracked_object(new_doc)
    obj.SiloPartNumber = new_pn
    obj.SiloRevision = 1
    new_doc.save()
    
    # 5. Upload copy
    self._sync.upload_file(new_pn, new_path)

Export Workflow: Silo → Local

User wants to save a copy without Silo tracking (for sharing, offline, etc.):

┌─────────────────────────────────────────────────────────────────────┐
│ Export to Local File                                          [X]  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Source: F01-0001 - M3x10 Socket Head Cap Screw [Silo: Work]       │
│                                                                     │
│  Export options:                                                    │
│                                                                     │
│  ○ Export copy (keep Silo tracking in original)                    │
│  ● Export and detach (remove Silo tracking from this document)     │
│                                                                     │
│  ⚠️ Detaching will:                                                │
│  • Remove part number and revision tracking                         │
│  • Stop syncing changes to Silo                                    │
│  • The item will remain in Silo database                           │
│                                                                     │
│  Save to: [~/Documents/shared/m3_screw.FCStd    ] [Browse...]      │
│                                                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                    [Cancel]  [Export]              │
└─────────────────────────────────────────────────────────────────────┘

Implementation:

def _export_to_local(self, doc):
    """Export Silo document as local file."""
    dialog = ExportDialog()
    dialog.set_source(get_part_number(doc))
    
    if dialog.exec() != QDialog.Accepted:
        return
    
    if dialog.detach_mode():
        # Remove Silo properties and save to new location
        obj = get_tracked_object(doc)
        obj.removeProperty("SiloPartNumber")
        obj.removeProperty("SiloRevision")
        obj.removeProperty("SiloOriginId")
        doc.saveAs(dialog.destination_path())
    else:
        # Copy file without modifying original
        import shutil
        shutil.copy2(doc.FileName, dialog.destination_path())

Save As Command Logic

void StdCmdSaveAs::activated(int) {
    App::Document* doc = App::GetApplication().getActiveDocument();
    FileOrigin* docOrigin = OriginManager::instance()->originForDocument(doc);
    FileOrigin* currentOrigin = OriginManager::instance()->currentOrigin();
    
    if (docOrigin == currentOrigin) {
        // Same origin - delegate to origin's saveDocumentAs
        currentOrigin->saveDocumentAs(doc);
    } else {
        // Cross-origin - show origin selection or handle specially
        if (currentOrigin->type() == OriginType::Silo && 
            docOrigin->type() == OriginType::Local) {
            // Local → Silo: Migration
            currentOrigin->saveDocumentAs(doc);  // Will show migration dialog
        } else if (currentOrigin->type() == OriginType::Local &&
                   docOrigin->type() == OriginType::Silo) {
            // Silo → Local: Export
            showExportDialog(doc);
        } else {
            // Silo → Different Silo: Transfer
            currentOrigin->saveDocumentAs(doc);  // Will show migration dialog
        }
    }
}

Implementation Tasks

  • Implement migration workflow in SiloOrigin._migrate_to_silo()
  • Implement copy workflow in SiloOrigin._copy_silo_document()
  • Create ExportDialog for Silo → Local workflow
  • Update LocalFileOrigin.saveDocumentAs() for export handling
  • Modify StdCmdSaveAs to detect cross-origin scenarios
  • Create PartCreationDialog (reuse from Silo_New)
  • Handle file move during migration
  • Test all workflow combinations

Files to Create/Modify

  • mods/silo/pkg/freecad/silo_origin.py (migration, copy methods)
  • mods/silo/pkg/freecad/dialogs.py (PartCreationDialog, ExportDialog)
  • src/Gui/CommandDoc.cpp (SaveAs cross-origin logic)
  • src/Gui/LocalFileOrigin.cpp (export handling)

Acceptance Criteria

  • Local → Silo migration adds tracking and moves file
  • Silo → Silo copy creates new part number
  • Silo → Local export removes/preserves tracking as selected
  • File is moved to canonical path on migration
  • Original document unchanged during copy
  • Part creation form uses schema YAML from Silo
  • Cross-Silo transfer creates new part in target

Dependencies

  • #9 Origin abstraction layer
  • #10 Local filesystem origin
  • #11 Silo origin adapter
  • #12 Modify Std_* commands
  • #16 Document origin tracking

Blocking

None - this completes Phase 3

## Overview Enable workflows where users work with documents across different origins. This includes migrating local documents to Silo, copying Silo documents to new part numbers, and saving Silo documents locally for offline use or sharing. **Key Insight**: Since all documents live locally, "mixed origin" is really about: - **Migration**: Adding Silo tracking to an untracked local document - **Copy**: Creating a new Silo item from an existing Silo document - **Export**: Saving a Silo document without syncing (local-only save) ## Parent Issue Epic: #8 Unified File Origin System ## Goals 1. Handle "Save As" intelligently based on document state and target origin 2. Support migrating local documents to Silo (add tracking) 3. Support copying Silo documents (new part number) 4. Support exporting Silo documents locally (offline/sharing) 5. Clear UX for each workflow ## Detailed Design ### Save As Behavior Matrix | Document State | Target Origin | Action | |---------------|---------------|--------| | Local (untracked) | Local | Standard Save As (file picker) | | Local (untracked) | Silo | **Migration**: Part creation form → add Silo properties | | Silo tracked | Same Silo | **Copy**: Part creation form → new part number | | Silo tracked | Different Silo | **Transfer**: Part creation form on target → new part number | | Silo tracked | Local | **Export**: File picker → save WITHOUT Silo properties | ### Migration Workflow: Local → Silo User has a local document and wants to start tracking it in Silo: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Add to Silo (Work) [X] │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Current file: ~/Documents/my_bracket.FCStd │ │ │ │ This will register the document in Silo and enable: │ │ • Revision tracking │ │ • Cloud backup to MinIO │ │ • Part number assignment │ │ │ │ ── Part Information ───────────────────────────────────────────── │ │ │ │ Category: [Brackets & Mounts ▼] │ │ Description: [Mounting Bracket ] │ │ │ │ Generated Part Number: B03-0042 │ │ │ │ ── File Location ──────────────────────────────────────────────── │ │ │ │ The file will be moved to: │ │ ~/projects/cad/B03_brackets_mounts/B03-0042_Mounting_Bracket.FCStd│ │ │ ├─────────────────────────────────────────────────────────────────────┤ │ [Cancel] [Add to Silo] │ └─────────────────────────────────────────────────────────────────────┘ ``` **Implementation**: ```python def _migrate_to_silo(self, doc): """Migrate a local document to Silo tracking.""" # 1. Show category selection (from schema YAML) dialog = PartCreationDialog(self._sync) if dialog.exec() != QDialog.Accepted: return # 2. Create item in Silo database part_number = self._sync.create_item( category=dialog.category(), description=dialog.description() ) # 3. Add Silo properties to document obj = get_or_create_tracked_object(doc) obj.SiloPartNumber = part_number obj.SiloRevision = 1 obj.SiloOriginId = self._id # 4. Move to canonical path new_path = get_cad_file_path(part_number, dialog.description()) old_path = doc.FileName doc.saveAs(str(new_path)) if old_path and old_path != str(new_path): Path(old_path).unlink(missing_ok=True) # 5. Upload to MinIO self._sync.upload_file(part_number, new_path) ``` ### Copy Workflow: Silo → Silo (Same Instance) User wants to create a variant or copy of an existing Silo document: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Copy as New Part [X] │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Original: F01-0001 - M3x10 Socket Head Cap Screw │ │ │ │ Creating a copy will: │ │ • Generate a new part number │ │ • Create independent revision history │ │ • Keep current document open (original unchanged) │ │ │ │ ── New Part Information ───────────────────────────────────────── │ │ │ │ Category: [Fasteners - Screws ▼] (same as original) │ │ Description: [M3x12 Socket Head Cap Screw] │ │ │ │ Generated Part Number: F01-0156 │ │ │ ├─────────────────────────────────────────────────────────────────────┤ │ [Cancel] [Create Copy] │ └─────────────────────────────────────────────────────────────────────┘ ``` **Implementation**: ```python def _copy_silo_document(self, doc): """Create a copy with new part number.""" # 1. Get original info for pre-fill original_pn = get_part_number(doc) original_item = self._sync.get_item(original_pn) # 2. Show dialog with pre-filled values dialog = PartCreationDialog(self._sync) dialog.set_category(original_item["category"]) dialog.set_description(original_item["description"] + " (copy)") if dialog.exec() != QDialog.Accepted: return # 3. Create new item new_pn = self._sync.create_item( category=dialog.category(), description=dialog.description() ) # 4. Save copy (keep original open) new_path = get_cad_file_path(new_pn, dialog.description()) # Create copy without modifying original import shutil shutil.copy2(doc.FileName, new_path) # Open copy and update properties new_doc = FreeCAD.openDocument(str(new_path)) obj = get_tracked_object(new_doc) obj.SiloPartNumber = new_pn obj.SiloRevision = 1 new_doc.save() # 5. Upload copy self._sync.upload_file(new_pn, new_path) ``` ### Export Workflow: Silo → Local User wants to save a copy without Silo tracking (for sharing, offline, etc.): ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Export to Local File [X] │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Source: F01-0001 - M3x10 Socket Head Cap Screw [Silo: Work] │ │ │ │ Export options: │ │ │ │ ○ Export copy (keep Silo tracking in original) │ │ ● Export and detach (remove Silo tracking from this document) │ │ │ │ ⚠️ Detaching will: │ │ • Remove part number and revision tracking │ │ • Stop syncing changes to Silo │ │ • The item will remain in Silo database │ │ │ │ Save to: [~/Documents/shared/m3_screw.FCStd ] [Browse...] │ │ │ ├─────────────────────────────────────────────────────────────────────┤ │ [Cancel] [Export] │ └─────────────────────────────────────────────────────────────────────┘ ``` **Implementation**: ```python def _export_to_local(self, doc): """Export Silo document as local file.""" dialog = ExportDialog() dialog.set_source(get_part_number(doc)) if dialog.exec() != QDialog.Accepted: return if dialog.detach_mode(): # Remove Silo properties and save to new location obj = get_tracked_object(doc) obj.removeProperty("SiloPartNumber") obj.removeProperty("SiloRevision") obj.removeProperty("SiloOriginId") doc.saveAs(dialog.destination_path()) else: # Copy file without modifying original import shutil shutil.copy2(doc.FileName, dialog.destination_path()) ``` ### Save As Command Logic ```cpp void StdCmdSaveAs::activated(int) { App::Document* doc = App::GetApplication().getActiveDocument(); FileOrigin* docOrigin = OriginManager::instance()->originForDocument(doc); FileOrigin* currentOrigin = OriginManager::instance()->currentOrigin(); if (docOrigin == currentOrigin) { // Same origin - delegate to origin's saveDocumentAs currentOrigin->saveDocumentAs(doc); } else { // Cross-origin - show origin selection or handle specially if (currentOrigin->type() == OriginType::Silo && docOrigin->type() == OriginType::Local) { // Local → Silo: Migration currentOrigin->saveDocumentAs(doc); // Will show migration dialog } else if (currentOrigin->type() == OriginType::Local && docOrigin->type() == OriginType::Silo) { // Silo → Local: Export showExportDialog(doc); } else { // Silo → Different Silo: Transfer currentOrigin->saveDocumentAs(doc); // Will show migration dialog } } } ``` ## Implementation Tasks - [ ] Implement migration workflow in `SiloOrigin._migrate_to_silo()` - [ ] Implement copy workflow in `SiloOrigin._copy_silo_document()` - [ ] Create `ExportDialog` for Silo → Local workflow - [ ] Update `LocalFileOrigin.saveDocumentAs()` for export handling - [ ] Modify `StdCmdSaveAs` to detect cross-origin scenarios - [ ] Create `PartCreationDialog` (reuse from Silo_New) - [ ] Handle file move during migration - [ ] Test all workflow combinations ## Files to Create/Modify - `mods/silo/pkg/freecad/silo_origin.py` (migration, copy methods) - `mods/silo/pkg/freecad/dialogs.py` (PartCreationDialog, ExportDialog) - `src/Gui/CommandDoc.cpp` (SaveAs cross-origin logic) - `src/Gui/LocalFileOrigin.cpp` (export handling) ## Acceptance Criteria - [ ] Local → Silo migration adds tracking and moves file - [ ] Silo → Silo copy creates new part number - [ ] Silo → Local export removes/preserves tracking as selected - [ ] File is moved to canonical path on migration - [ ] Original document unchanged during copy - [ ] Part creation form uses schema YAML from Silo - [ ] Cross-Silo transfer creates new part in target ## Dependencies - #9 Origin abstraction layer - #10 Local filesystem origin - #11 Silo origin adapter - #12 Modify Std_* commands - #16 Document origin tracking ## Blocking None - this completes Phase 3
Author
Owner

Complete. Closing with code references:

  • src/Gui/CommandDoc.cpp:780-804Std_SaveAs detects cross-origin scenarios:
    • Local → PLM: Migration workflow (add Silo tracking to local document)
    • PLM → Local: Export workflow (save without tracking)
    • PLM → Different PLM: Transfer workflow
  • Each scenario delegates to the target origin's saveDocumentAsInteractive()
  • src/Gui/FileOrigin.hsaveDocumentAsInteractive() virtual method for origin-specific dialogs
Complete. Closing with code references: - `src/Gui/CommandDoc.cpp:780-804` — `Std_SaveAs` detects cross-origin scenarios: - **Local → PLM**: Migration workflow (add Silo tracking to local document) - **PLM → Local**: Export workflow (save without tracking) - **PLM → Different PLM**: Transfer workflow - Each scenario delegates to the target origin's `saveDocumentAsInteractive()` - `src/Gui/FileOrigin.h` — `saveDocumentAsInteractive()` virtual method for origin-specific dialogs
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/create#17