trilium/apps/web-clipper-manifestv3/docs/MIGRATION-PATTERNS.md
Octech2722 1f444ebc69 docs: add comprehensive development workflow documentation
Copilot Integration:
- Streamlined instructions (70% shorter than original)
- Task templates for common operations
- Three-tier usage strategy (free/strategic/manual)
- Optimized for GitHub Copilot Basic tier limits

Development Resources:
- Common task workflows with time estimates
- Feature parity checklist with priorities
- Debugging and troubleshooting guides
- Testing scenarios and checklists
- Code quality standards

Workflow Optimization:
- Efficient Copilot task budgeting
- Real-world implementation examples
- Performance and success metrics
- Project completion roadmap

Reduces repetitive context in prompts.
Maximizes limited Copilot task budget.
2025-10-18 12:21:13 -05:00

14 KiB

MV2 to MV3 Migration Patterns

Quick reference for common migration scenarios when implementing features from the legacy extension.


Pattern 1: Background Page → Service Worker

MV2 (Don't Use)

// Persistent background page with global state
let cachedData = {};

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  cachedData[msg.id] = msg.data;
  sendResponse({success: true});
});

MV3 (Use This)

// Stateless service worker with chrome.storage
import { Logger } from '@/shared/utils';
const logger = Logger.create('BackgroundHandler', 'background');

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    try {
      // Store in chrome.storage, not memory
      await chrome.storage.local.set({ [msg.id]: msg.data });
      logger.info('Data stored', { id: msg.id });
      sendResponse({ success: true });
    } catch (error) {
      logger.error('Storage failed', error);
      sendResponse({ success: false, error: error.message });
    }
  })();
  return true; // Required for async sendResponse
});

Key Changes:

  • No global state (service worker can terminate)
  • Use chrome.storage for persistence
  • Always return true for async handlers
  • Centralized logging for debugging

Pattern 2: Content Script DOM Manipulation

MV2 Pattern

// Simple DOM access
const content = document.body.innerHTML;

MV3 Pattern (Same, but with error handling)

import { Logger } from '@/shared/utils';
const logger = Logger.create('ContentExtractor', 'content');

function extractContent(): string {
  try {
    if (!document.body) {
      logger.warn('Document body not available');
      return '';
    }
    
    const content = document.body.innerHTML;
    logger.debug('Content extracted', { length: content.length });
    return content;
  } catch (error) {
    logger.error('Content extraction failed', error);
    return '';
  }
}

Key Changes:

  • Add null checks for DOM elements
  • Use centralized logging
  • Handle errors gracefully

Pattern 3: Screenshot Capture

MV2 Pattern

chrome.tabs.captureVisibleTab(null, {format: 'png'}, (dataUrl) => {
  // Crop using canvas
  const canvas = document.createElement('canvas');
  // ... cropping logic
});

MV3 Pattern

import { Logger } from '@/shared/utils';
const logger = Logger.create('ScreenshotCapture', 'background');

async function captureAndCrop(
  tabId: number, 
  cropRect: { x: number; y: number; width: number; height: number }
): Promise<string> {
  try {
    // Step 1: Capture full tab
    const dataUrl = await chrome.tabs.captureVisibleTab(null, { 
      format: 'png' 
    });
    logger.info('Screenshot captured', { tabId });
    
    // Step 2: Crop using OffscreenCanvas (MV3 service worker compatible)
    const response = await fetch(dataUrl);
    const blob = await response.blob();
    const bitmap = await createImageBitmap(blob);
    
    const offscreen = new OffscreenCanvas(cropRect.width, cropRect.height);
    const ctx = offscreen.getContext('2d');
    
    if (!ctx) {
      throw new Error('Could not get canvas context');
    }
    
    ctx.drawImage(
      bitmap,
      cropRect.x, cropRect.y, cropRect.width, cropRect.height,
      0, 0, cropRect.width, cropRect.height
    );
    
    const croppedBlob = await offscreen.convertToBlob({ type: 'image/png' });
    const reader = new FileReader();
    
    return new Promise((resolve, reject) => {
      reader.onloadend = () => resolve(reader.result as string);
      reader.onerror = reject;
      reader.readAsDataURL(croppedBlob);
    });
  } catch (error) {
    logger.error('Screenshot crop failed', error);
    throw error;
  }
}

Key Changes:

  • Use OffscreenCanvas (available in service workers)
  • No DOM canvas manipulation in background
  • Full async/await pattern
  • Comprehensive error handling

Pattern 4: Image Processing

MV2 Pattern

// Download image and convert to base64
function processImage(imgSrc) {
  return new Promise((resolve) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', imgSrc);
    xhr.responseType = 'blob';
    xhr.onload = () => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.readAsDataURL(xhr.response);
    };
    xhr.send();
  });
}

MV3 Pattern

import { Logger } from '@/shared/utils';
const logger = Logger.create('ImageProcessor', 'background');

async function downloadAndEncodeImage(
  imgSrc: string, 
  baseUrl: string
): Promise<string> {
  try {
    // Resolve relative URLs
    const absoluteUrl = new URL(imgSrc, baseUrl).href;
    logger.debug('Downloading image', { url: absoluteUrl });
    
    // Use fetch API (modern, async)
    const response = await fetch(absoluteUrl);
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    const blob = await response.blob();
    
    // Convert to base64
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result as string);
      reader.onerror = () => reject(new Error('FileReader failed'));
      reader.readAsDataURL(blob);
    });
  } catch (error) {
    logger.warn('Image download failed', { url: imgSrc, error });
    // Return original URL as fallback
    return imgSrc;
  }
}

Key Changes:

  • Use fetch() instead of XMLHttpRequest
  • Handle CORS errors gracefully
  • Return original URL on failure (don't break the note)
  • Resolve relative URLs properly

Pattern 5: Context Menu Creation

MV2 Pattern

chrome.contextMenus.create({
  id: "save-selection",
  title: "Save to Trilium",
  contexts: ["selection"]
});

MV3 Pattern (Same API, better structure)

import { Logger } from '@/shared/utils';
const logger = Logger.create('ContextMenu', 'background');

interface MenuConfig {
  id: string;
  title: string;
  contexts: chrome.contextMenus.ContextType[];
}

const MENU_ITEMS: MenuConfig[] = [
  { id: 'save-selection', title: 'Save Selection to Trilium', contexts: ['selection'] },
  { id: 'save-page', title: 'Save Page to Trilium', contexts: ['page'] },
  { id: 'save-link', title: 'Save Link to Trilium', contexts: ['link'] },
  { id: 'save-image', title: 'Save Image to Trilium', contexts: ['image'] },
  { id: 'save-screenshot', title: 'Save Screenshot to Trilium', contexts: ['page'] }
];

async function setupContextMenus(): Promise<void> {
  try {
    // Remove existing menus
    await chrome.contextMenus.removeAll();
    
    // Create all menu items
    for (const item of MENU_ITEMS) {
      await chrome.contextMenus.create(item);
      logger.debug('Context menu created', { id: item.id });
    }
    
    logger.info('Context menus initialized', { count: MENU_ITEMS.length });
  } catch (error) {
    logger.error('Context menu setup failed', error);
  }
}

// Call during service worker initialization
chrome.runtime.onInstalled.addListener(() => {
  setupContextMenus();
});

Key Changes:

  • Centralized menu configuration
  • Clear typing with interfaces
  • Proper error handling
  • Logging for debugging

Pattern 6: Sending Messages from Content to Background

MV2 Pattern

chrome.runtime.sendMessage({type: 'SAVE', data: content}, (response) => {
  console.log('Saved:', response);
});

MV3 Pattern

import { Logger } from '@/shared/utils';
const logger = Logger.create('ContentScript', 'content');

interface SaveMessage {
  type: 'SAVE_SELECTION' | 'SAVE_PAGE' | 'SAVE_LINK';
  data: {
    content: string;
    metadata: {
      title: string;
      url: string;
    };
  };
}

interface SaveResponse {
  success: boolean;
  noteId?: string;
  error?: string;
}

async function sendToBackground(message: SaveMessage): Promise<SaveResponse> {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage(message, (response: SaveResponse) => {
      if (chrome.runtime.lastError) {
        logger.error('Message send failed', chrome.runtime.lastError);
        reject(new Error(chrome.runtime.lastError.message));
        return;
      }
      
      if (!response.success) {
        logger.warn('Background operation failed', { error: response.error });
        reject(new Error(response.error));
        return;
      }
      
      logger.info('Message handled successfully', { noteId: response.noteId });
      resolve(response);
    });
  });
}

// Usage
try {
  const result = await sendToBackground({
    type: 'SAVE_SELECTION',
    data: {
      content: selectedHtml,
      metadata: {
        title: document.title,
        url: window.location.href
      }
    }
  });
  
  showToast(`Saved to Trilium: ${result.noteId}`);
} catch (error) {
  logger.error('Save failed', error);
  showToast('Failed to save to Trilium', 'error');
}

Key Changes:

  • Strong typing for messages and responses
  • Promise wrapper for callback API
  • Always check chrome.runtime.lastError
  • Handle errors at both send and response levels

Pattern 7: Storage Operations

MV2 Pattern

// Mix of localStorage and chrome.storage
localStorage.setItem('setting', value);
chrome.storage.local.get(['data'], (result) => {
  console.log(result.data);
});

MV3 Pattern

import { Logger } from '@/shared/utils';
const logger = Logger.create('StorageManager', 'background');

// NEVER use localStorage in service workers - it doesn't exist

interface StorageData {
  settings: {
    triliumUrl: string;
    authToken: string;
    saveFormat: 'html' | 'markdown' | 'both';
  };
  cache: {
    lastSync: number;
    noteIds: string[];
  };
}

async function loadSettings(): Promise<StorageData['settings']> {
  try {
    const { settings } = await chrome.storage.local.get(['settings']);
    logger.debug('Settings loaded', { hasToken: !!settings?.authToken });
    return settings || getDefaultSettings();
  } catch (error) {
    logger.error('Settings load failed', error);
    return getDefaultSettings();
  }
}

async function saveSettings(settings: Partial<StorageData['settings']>): Promise<void> {
  try {
    const current = await loadSettings();
    const updated = { ...current, ...settings };
    await chrome.storage.local.set({ settings: updated });
    logger.info('Settings saved', { keys: Object.keys(settings) });
  } catch (error) {
    logger.error('Settings save failed', error);
    throw error;
  }
}

function getDefaultSettings(): StorageData['settings'] {
  return {
    triliumUrl: '',
    authToken: '',
    saveFormat: 'html'
  };
}

Key Changes:

  • NEVER use localStorage (not available in service workers)
  • Use chrome.storage.local for all data
  • Use chrome.storage.sync for user preferences (sync across devices)
  • Full TypeScript typing for stored data
  • Default values for missing data

Pattern 8: Trilium API Communication

MV2 Pattern

function saveToTrilium(content, metadata) {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', triliumUrl + '/api/notes');
  xhr.setRequestHeader('Authorization', token);
  xhr.send(JSON.stringify({content, metadata}));
}

MV3 Pattern

import { Logger } from '@/shared/utils';
const logger = Logger.create('TriliumAPI', 'background');

interface TriliumNote {
  title: string;
  content: string;
  type: 'text';
  mime: 'text/html' | 'text/markdown';
  parentNoteId?: string;
}

interface TriliumResponse {
  note: {
    noteId: string;
    title: string;
  };
}

async function createNote(
  note: TriliumNote,
  triliumUrl: string,
  authToken: string
): Promise<string> {
  try {
    const url = `${triliumUrl}/api/create-note`;
    
    logger.debug('Creating note in Trilium', { 
      title: note.title, 
      contentLength: note.content.length 
    });
    
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': authToken,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(note)
    });
    
    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`HTTP ${response.status}: ${errorText}`);
    }
    
    const data: TriliumResponse = await response.json();
    logger.info('Note created successfully', { noteId: data.note.noteId });
    
    return data.note.noteId;
  } catch (error) {
    logger.error('Note creation failed', error);
    throw error;
  }
}

Key Changes:

  • Use fetch() API (modern, promise-based)
  • Full TypeScript typing for requests/responses
  • Comprehensive error handling
  • Detailed logging for debugging

Quick Reference: When to Use Each Pattern

Task Pattern Files Typically Modified
Add capture feature Pattern 1, 6, 8 background/index.ts, content/index.ts
Process images Pattern 4 background/index.ts
Add context menu Pattern 5 background/index.ts
Screenshot with crop Pattern 3 background/index.ts, possibly content/screenshot.ts
Settings management Pattern 7 options/index.ts, background/index.ts
Trilium communication Pattern 8 background/index.ts

Common Gotchas

  1. Service Worker Termination

    • Don't store state in global variables
    • Use chrome.storage or chrome.alarms
  2. Async Message Handlers

    • Always return true in listener
    • Always check chrome.runtime.lastError
  3. Canvas in Service Workers

    • Use OffscreenCanvas, not regular <canvas>
    • No DOM access in background scripts
  4. CORS Issues

    • Handle fetch failures gracefully
    • Provide fallbacks for external resources
  5. Type Safety

    • Define interfaces for all messages
    • Type all chrome.storage data structures

Usage: When implementing a feature, find the relevant pattern above and adapt it. Don't copy MV2 code directly—use these proven MV3 patterns instead.