quarto-review-extension

Comprehensive Translation System Architecture Analysis

Executive Summary

The translation system is a multi-layered architecture with three key components:

  1. Translation State Management - Manages documents, sentences, and translation pairs
  2. UI Components - Render sentences and provide editing interfaces
  3. Button State Management - Controls button enabled/disabled states based on operational state

Key Findings

The translation system has sophisticated button state management and synced field handling, but there are some areas where button state may not be fully synchronized with operational state.


1. Translation Button State Management

Location: TranslationToolbar.ts

// Lines 378-410
setTranslating(translating: boolean, progressText?: string): void {
  if (!this.element) return;

  const progress = this.element.querySelector(
    '.review-translation-progress'
  ) as HTMLElement;
  const translateBtn = this.element.querySelector(
    '[data-action="translate-document"]'
  ) as HTMLButtonElement;
  const translateSentenceBtn = this.element.querySelector(
    '[data-action="translate-sentence"]'
  ) as HTMLButtonElement;

  if (progress) {
    progress.style.display = translating ? 'flex' : 'none';
    if (progressText) {
      const text = progress.querySelector(
        '.review-translation-progress-text'
      ) as HTMLElement;
      if (text) {
        text.textContent = progressText;
      }
    }
  }

  if (translateBtn) {
    translateBtn.disabled = translating;  // <- Button disabled when translating
  }

  if (translateSentenceBtn) {
    translateSentenceBtn.disabled = translating;  // <- Button disabled when translating
  }
}

How Button Disabling Works

Button Disabling Path:

  1. Translation Operation StartsTranslationController.translateDocument() or translateSentence()
  2. Call tosetTranslationBusy(true) (line 479 in TranslationController)
  3. Updates StateStorestateStore.setTranslationState({ busy: true })
  4. Triggers UI Update → Progress callback onBusyChange?.(busy)
  5. Sidebar or Toolbar → Calls toolbar.setTranslating(true)
  6. DOM Update → Sets button.disabled = true

Button Re-enabling Path:

  1. Translation Completes (success or error)
  2. Finally BlocksetTranslationBusy(false) (line 536 in TranslationController)
  3. Updates StateStorestateStore.setTranslationState({ busy: false })
  4. Triggers UI Update → Progress callback onBusyChange?.(false)
  5. Sidebar or Toolbar → Calls toolbar.setTranslating(false)
  6. DOM Update → Sets button.disabled = false

Potential Button Greying Issues

Issue 1: Missing Error Handling in Progress Updates

Issue 2: Button Disabled State Not Persisted Across UI Component Recreations

Issue 3: Limited Button State Granularity


2. “Synced” Translation Fields

Location: types.ts (Lines 13-19)

export type TranslationStatus =
  | 'untranslated'      // No translation yet
  | 'auto-translated'   // Automatically translated
  | 'manual'            // Manually translated
  | 'edited'            // Auto-translated then manually edited
  | 'out-of-sync'       // Source changed, translation outdated
  | 'synced';           // Translation up to date

Where “Synced” Status is Assigned

Location 1: TranslationView.ts (Line 601)

private getSentenceStatus(
  sentenceId: string,
  side: 'source' | 'target'
): string | null {
  if (!this.document) return null;

  const pairs = this.document.correspondenceMap.pairs.filter((pair) =>
    side === 'source'
      ? pair.sourceId === sentenceId
      : pair.targetId === sentenceId
  );

  if (pairs.length === 0) return 'untranslated';

  // Return the most relevant status
  const statuses = pairs.map((p) => p.status);
  if (statuses.includes('out-of-sync')) return 'out-of-sync';
  if (statuses.includes('manual')) return 'manual';
  if (statuses.includes('edited')) return 'edited';
  if (statuses.includes('auto-translated')) return 'auto-translated';
  return 'synced';  // <- Default status if no other matches
}

Status Mapping Logic:

  1. Get all pairs involving this sentence
  2. Check each pair’s status field
  3. Return priority-ordered status:
    • out-of-sync (highest priority)
    • manual
    • edited
    • auto-translated
    • synced (default/fallback)

How “Out-of-Sync” Status is Set

Location: TranslationState.ts (Lines 319-327)

When source sentence content changes:

updateSentence(
  sentenceId: string,
  newContent: string,
  isSource: boolean
): void {
  if (!this.document) return;

  const sentences = isSource
    ? this.document.sourceSentences
    : this.document.targetSentences;
  const sentence = sentences.find((s) => s.id === sentenceId);

  if (sentence) {
    const previous = sentence.content;
    sentence.content = newContent;
    sentence.hash = this.hashContent(newContent);

    // Mark corresponding translations as out-of-sync
    const pairs = this.document.correspondenceMap.pairs.filter((p) =>
      isSource ? p.sourceId === sentenceId : p.targetId === sentenceId
    );

    pairs.forEach((pair) => {
      pair.status = 'out-of-sync';  // <- Set to out-of-sync
      pair.lastModified = Date.now();
    });

    this.document.metadata.lastModified = Date.now();
    this.notifyListeners();
    return;
  }
}

How “Synced” Status is Achieved

Currently: 'synced' is the DEFAULT fallback status when:

Issue: There’s NO explicit mechanism to SET status to ‘synced’. It’s only returned as a fallback.

Expected Behavior: 'synced' should be set when:

  1. Source content matches original hash
  2. AND translation was previously out-of-sync
  3. AND user confirms/re-translates

Problem: This never happens! The status just defaults to ‘synced’ without active reconciliation.

Rendering of “Synced” Fields

Location: TranslationView.ts (Lines 465-576)

private createSentenceElement(
  sentence: Sentence,
  side: 'source' | 'target'
): HTMLElement {
  const sentenceElement = createDiv('review-translation-sentence');
  sentenceElement.dataset.sentenceId = sentence.id;
  sentenceElement.dataset.side = side;
  sentenceElement.tabIndex = -1;

  // Get translation status and pair info
  const status = this.getSentenceStatus(sentence.id, side);
  const pairInfo = this.getSentencePairInfo(sentence.id, side);

  if (status) {
    sentenceElement.dataset.status = status;
    // Add CSS class for styling based on status
    toggleClass(
      sentenceElement,
      `review-translation-sentence-${status}`,
      true
    );
    // ...
  }

  // ... Create content, status chip, indicators, spinners, error messages
}

Rendering includes:

Synced Visual Indicators (from user guide):


3. Translation Field Editing Architecture

Edit Flow: Segment Level (Preferred)

Location: TranslationController.ts (Lines 940-1002)

/**
 * Handle source segment edit - full segment level (PREFERRED)
 * Called when user edits an entire segment via "Edit Segment" button
 */
private async handleSourceSegmentEdit(
  elementId: string,
  newContent: string
): Promise<void> {
  try {
    // 1. Update segment content with re-segmentation
    this.translationModule.updateSegmentContent(
      elementId,
      newContent,
      'source'
    );
    
    // 2. Save to persistent storage
    this.translationModule.saveToStorageNow();
    
    // 3. Refresh view
    this.refreshViewFromState();

    // 4. Auto-retranslate if enabled
    if (this.config.translationModuleConfig.config.autoTranslateOnEdit) {
      // Get sentence IDs for error/loading tracking
      const document = this.translationModule.getDocument();
      const sentenceIds = document?.sourceSentences
        .filter((s) => s.elementId === elementId)
        .map((s) => s.id) ?? [];

      if (sentenceIds.length > 0) {
        // Mark as loading
        this.clearSentenceErrors(sentenceIds);
        this.markSentencesLoading(sentenceIds, true);
        
        try {
          // Translate all sentences in segment
          for (const sentenceId of sentenceIds) {
            await this.translationModule.translateSentence(sentenceId);
          }
          this.showNotification('Segment translated', 'success');
          this.refreshViewFromState();
          this.clearSentenceErrors(sentenceIds);
        } catch (error) {
          // Handle translation errors
          const message = error instanceof Error ? error.message : 'Failed';
          this.markSentencesError(sentenceIds, message);
          this.view?.setErrorBanner({
            message,
            onRetry: () => {
              void this.handleSourceSegmentEdit(elementId, newContent);
            },
          });
        } finally {
          this.markSentencesLoading(sentenceIds, false);
        }
      }
    }
  } catch (error) {
    logger.error('Failed to update source segment', error);
    this.showNotification('Failed to update segment', 'error');
  }
}

Edit Flow Steps:

  1. User clicks “Edit Segment” button (TranslationView.ts line 439)
    editButton.addEventListener('click', () => {
      void this.enableSegmentEdit(
        sectionElement,
        elementId,
        sectionSentences,
        side
      );
    });
    
  2. UI Opens Inline Editor (TranslationView.ts lines 1149-1386)
    • Creates Milkdown editor container
    • Hides rendered sentences
    • Shows save/cancel buttons
    • Initializes editor bridge
  3. User Edits Content
    • Milkdown editor tracks changes
    • Content available via module.getContent()
  4. User Clicks Save
    • Calls enableSegmentEdit.save() function
    • Validates content via editorBridge.saveSegmentEdit()
    • Calls callback: onSourceSegmentEdit() or onTargetSegmentEdit()
  5. Controller Processes Edit
    • Updates segment via translationModule.updateSegmentContent()
    • Re-segments sentences
    • Updates correspondence mappings
    • Saves to storage
    • Auto-translates if enabled
  6. View Refreshes
    • Reloads document via view.loadDocument(document)
    • Re-renders all sentences with new status

Editing Callbacks in TranslationView

Location: TranslationView.ts (Lines 1250-1268)

// Call the segment edit callback
try {
  if (side === 'source' && this.callbacks.onSourceSegmentEdit) {
    await this.callbacks.onSourceSegmentEdit(elementId, newContent);
  } else if (
    side === 'target' &&
    this.callbacks.onTargetSegmentEdit
  ) {
    await this.callbacks.onTargetSegmentEdit(elementId, newContent);
  } else {
    logger.warn('No callback registered for segment edit', {
      side,
      hasSourceCallback: Boolean(this.callbacks.onSourceSegmentEdit),
      hasTargetCallback: Boolean(this.callbacks.onTargetSegmentEdit),
    });
    return false;
  }
} catch (error) {
  logger.error('Error in segment edit callback', {
    error,
    elementId,
    side,
  });
  return false;
}

Where Callbacks are Registered

Location: TranslationController.ts (Lines 407-422)

this.view = new TranslationView(
  {
    showCorrespondenceLines: config.showCorrespondenceLines,
    highlightOnHover: config.highlightOnHover,
  },
  {
    // Segment-level editing (primary)
    onSourceSegmentEdit: (elementId: string, content: string) =>
      this.handleSourceSegmentEdit(elementId, content),
    onTargetSegmentEdit: (elementId: string, content: string) =>
      this.handleTargetSegmentEdit(elementId, content),
    // Sentence-level editing (deprecated, kept for compatibility)
    onSourceSentenceEdit: (sentenceId: string, content: string) =>
      this.handleSourceSentenceEdit(sentenceId, content),
    onTargetSentenceEdit: (sentenceId: string, content: string) =>
      this.handleTargetSentenceEdit(sentenceId, content),
  },
  markdown,
  this.editorBridge,
  this.stateStore || undefined
);

Editor Bridge Integration

Location: TranslationEditorBridge.ts

/**
 * Initialize editor for an entire segment (element)
 * This is the preferred method for segment-based editing
 */
async initializeSegmentEditor(
  container: HTMLElement,
  elementId: string,
  content: string,
  side: 'source' | 'target'
): Promise<void> {
  this.currentElementId = elementId;
  this.currentSegmentContent = content;
  this.currentLanguage = side;

  // Clear any sentence-level state
  this.currentSentenceId = null;

  const options: InitializeOptions = {
    container,
    content,
    diffHighlights: this.editorConfig.showDiffHighlights ? [] : undefined,
    elementType: 'Para',
    onContentChange: (markdown: string) => {
      // Track changes as user types
      this.onEditorContentChange(markdown);
    },
  };

  try {
    await this.initialize(options);
    logger.info('Segment editor initialized', {
      elementId,
      side,
      contentLength: content.length,
    });
  } catch (error) {
    logger.error('Failed to initialize segment editor', error);
    throw error;
  }
}

/**
 * Save edited segment
 * Returns true if content changed and was saved
 */
public saveSegmentEdit(
  elementId: string,
  newContent: string,
  side: 'source' | 'target'
): boolean {
  if (!this.currentElementId || this.currentElementId !== elementId) {
    logger.warn('No matching segment editor active for save', {
      currentElementId: this.currentElementId,
      requestedElementId: elementId,
    });
    return false;
  }

  const editor = this.getEditor();
  const module = this.getModule();

  if (!editor || !module) {
    logger.error('Editor not initialized');
    return false;
  }

  // Check if content actually changed
  if (this.currentSegmentContent === newContent) {
    logger.debug('No content change detected');
    return false;
  }

  // Validation passes - controller handles ChangesModule
  logger.info('Segment edit validated', {
    elementId,
    side,
    contentLength: newContent.length,
  });

  return true;
}

4. Translation State Management Architecture

StateStore Integration

Location: UIState.ts (Lines 54-85)

export interface TranslationState {
  /** Whether translation mode is active */
  isActive: boolean;
  /** Currently selected source sentence ID */
  selectedSourceSentenceId: string | null;
  /** Currently selected target sentence ID */
  selectedTargetSentenceId: string | null;
  /** Translation mode: manual entry or automatic translation */
  mode: 'manual' | 'automatic';
  /** Whether a translation operation is in progress */
  busy: boolean;
  /** Source language code */
  sourceLanguage: string;
  /** Target language code */
  targetLanguage: string;
  /** Currently active translation provider */
  activeProvider: string;
  /** Show correspondence lines between sentences */
  showCorrespondenceLines: boolean;
  /** Highlight corresponding sentences on hover */
  highlightOnHover: boolean;
  /** IDs of sentences currently being translated */
  loadingSentences: Set<string>;
  /** Error messages for sentences that failed translation */
  sentenceErrors: Map<string, string>;
  /** Current translation progress status */
  progressStatus: {
    phase: 'idle' | 'running' | 'success' | 'error';
    message: string;
    percent?: number;
  } | null;
}

Translation State Updates

Location: TranslationController.ts

Progress Updates (Lines 887-901):

private notifyProgress(status: TranslationProgressStatus): void {
  this.view?.setDocumentProgress(status);
  this.config.onProgressUpdate?.(status);

  // Update StateStore with progress status
  if (this.stateStore) {
    this.stateStore.setTranslationState({
      progressStatus: {
        phase: status.phase,
        message: status.message,
        percent: status.percent,
      },
    });
  }
}

Busy State Updates (Lines 903-912):

private setTranslationBusy(busy: boolean): void {
  this.config.onBusyChange?.(busy);

  // Update StateStore with busy state
  if (this.stateStore) {
    this.stateStore.setTranslationState({
      busy: busy,
    });
  }
}

Selection State Updates (Lines 737-750):

private selectSentence(sentenceId: string, side: 'source' | 'target'): void {
  // Clear previous selection
  this.clearSelection();

  // Set selected
  this.selectedSentence = { id: sentenceId, side };

  // Update StateStore if available
  if (this.stateStore) {
    this.stateStore.setTranslationState({
      selectedSourceSentenceId: side === 'source' ? sentenceId : null,
      selectedTargetSentenceId: side === 'target' ? sentenceId : null,
    });
  }
  // ...
}

State Subscription in TranslationView

Location: TranslationView.ts (Lines 116-124)

// Subscribe to StateStore translation state changes
if (this.stateStore) {
  this.stateStoreUnsubscribe = this.stateStore.on<TranslationState>(
    'translation:changed',
    (state: Readonly<TranslationState>) => {
      this.handleStateStoreUpdate(state);
    }
  );
}

Handling State Updates (Lines 130-178):

private handleStateStoreUpdate(state: Readonly<TranslationState>): void {
  logger.debug('StateStore translation state updated in view', {
    busy: state.busy,
    progressPhase: state.progressStatus?.phase,
    hasSelectedSource: !!state.selectedSourceSentenceId,
    hasSelectedTarget: !!state.selectedTargetSentenceId,
  });

  // Update progress status if it changed
  if (
    state.progressStatus &&
    (state.progressStatus.phase !== this.progressStatus?.phase ||
      state.progressStatus.message !== this.progressStatus?.message ||
      state.progressStatus.percent !== this.progressStatus?.percent)
  ) {
    this.setDocumentProgress({
      phase: state.progressStatus.phase,
      message: state.progressStatus.message,
      percent: state.progressStatus.percent,
    });
  }

  // Update selected sentence if it changed
  if (
    state.selectedSourceSentenceId &&
    state.selectedSourceSentenceId !== this.selectedSentence?.id
  ) {
    this.selectedSentence = {
      id: state.selectedSourceSentenceId,
      side: 'source',
    };
    // Re-apply selection UI if already rendered
    if (this.element) {
      this.restoreSelection();
    }
  } else if (
    state.selectedTargetSentenceId &&
    state.selectedTargetSentenceId !== this.selectedSentence?.id
  ) {
    this.selectedSentence = {
      id: state.selectedTargetSentenceId,
      side: 'target',
    };
    // Re-apply selection UI if already rendered
    if (this.element) {
      this.restoreSelection();
    }
  }
}

5. Identified Issues and Recommendations

Issue 1: Missing “Synced” Status Reconciliation

Problem:

Location: TranslationState.ts doesn’t have a method to set status back to synced

Recommendation:

// Add to TranslationState.ts
setSentencePairSynced(pairId: string): void {
  if (!this.document) return;
  
  const pair = this.document.correspondenceMap.pairs.find(p => p.id === pairId);
  if (pair && pair.status === 'out-of-sync') {
    pair.status = 'synced';
    pair.lastModified = Date.now();
    this.document.metadata.lastModified = Date.now();
    this.notifyListeners();
  }
}

Issue 2: Button State Not Persisted

Problem:

Recommendation:

Issue 3: Incomplete Error Recovery

Problem:

Recommendation:

Issue 4: No Granular Button State for Multiple Operations

Problem:

Recommendation:

Issue 5: Editor Save Errors Not Propagated

Problem:

Recommendation:

Issue 6: StateStore Subscription Cleanup

Problem:

Recommendation:


6. Summary Table

Aspect Status Issue Level
Button disabled on translation ✓ Working Low
Button re-enabled after completion ✓ Working Low
Button re-enabled on error ✓ Working Low
Synced status assignment ✗ Missing High
Synced status persistence ✓ Rendered Medium
Edit field callbacks ✓ Implemented Low
Error recovery ~ Partial Medium
State persistence on recreate ✗ Missing High
Button state per-operation ✗ Missing Medium

7. Architecture Diagram

User Action (Edit/Translate)
    ↓
TranslationView (UI Component)
    ↓
TranslationController (Coordinator)
    ↓
TranslationModule (Business Logic)
    ↓
TranslationState (State Management)
    ↓
TranslationDocument (Data Model)
    ├── sourceSentences[]
    ├── targetSentences[]
    └── correspondenceMap
        ├── pairs[] (with status: 'out-of-sync' | 'synced' | etc.)
        ├── forwardMapping (source → target)
        └── reverseMapping (target → source)
    ↓
StateStore (Global State)
    ├── busy (button disabled state)
    ├── progressStatus
    ├── selectedSourceSentenceId
    └── selectedTargetSentenceId
    ↓
DOM/UI Update
    ├── Button disabled state
    ├── Progress bar
    ├── Status chips/indicators
    └── Error messages

8. File Reference Guide

File Purpose Key Methods
TranslationView.ts Render sentences, manage editing createSentenceElement(), enableSegmentEdit(), getSentenceStatus()
TranslationController.ts Coordinate UI and module handleSourceSegmentEdit(), handleTargetSegmentEdit(), setTranslationBusy()
TranslationToolbar.ts Toolbar UI with buttons setTranslating()
TranslationEditorBridge.ts Milkdown editor integration initializeSegmentEditor(), saveSegmentEdit()
TranslationState.ts Translation document state updateSentence(), addTranslationPair()
UIState.ts UI state definitions TranslationState interface
types.ts Type definitions TranslationStatus, TranslationPair