Files
ebaysnipeextension/.kiro/specs/blacklist-feature/design.md
Kenso Grimm 216a972fef chore: initialize project repository with core extension files
- Add .gitignore to exclude node_modules, dist, logs, and system files
- Add comprehensive project documentation including README, deployment guide, and development setup
- Add .kiro project specifications for amazon-product-bar-extension, appwrite-cloud-storage, appwrite-userid-repair, blacklist-feature, and enhanced-item-management
- Add .kiro steering documents for product, structure, styling, and tech guidelines
- Add VSCode settings configuration for consistent development environment
- Add manifest.json and babel/vite configuration for extension build setup
- Add complete source code implementation including AppWrite integration, storage managers, UI components, and services
- Add comprehensive test suite with Jest configuration and 30+ test files covering all major modules
- Add test HTML files for integration testing and validation
- Add coverage reports and build validation scripts
- Add AppWrite setup and repair documentation for database schema management
- Add migration guides and responsive accessibility implementation documentation
- Establish foundation for Amazon product bar extension with full feature set including blacklist management, enhanced item workflows, and real-time synchronization
2026-01-12 17:46:42 +01:00

21 KiB
Raw Blame History

Design Document: Blacklist Feature

Overview

Die Blacklist-Funktion erweitert die Amazon Product Bar Extension um die Möglichkeit, Markennamen zu verwalten und Produkte dieser Marken visuell in der Product_Bar zu kennzeichnen. Die Funktion nutzt einen neuen Menüpunkt "Blacklist" im StaggeredMenu, speichert Daten im Local Storage und zeigt Marken-Logos bei geblacklisteten Produkten an.

Architecture

graph TD
    A[StaggeredMenu] --> B[Blacklist Menu Item]
    B --> C[Blacklist Panel Manager]
    C --> D[Blacklist Storage Manager]
    D --> E[Local Storage]
    C --> F[Brand Input UI]
    C --> G[Brand List UI]
    
    H[Product Card Detector] --> I[Brand Extractor]
    I --> J[Blacklist Matcher]
    J --> D
    J --> K[Brand Icon Manager]
    K --> L[Product Bar]
    
    M[Brand Logo Registry] --> K

Die Blacklist-Funktion besteht aus:

  1. Blacklist Panel Manager - UI-Verwaltung für das Blacklist-Panel
  2. Blacklist Storage Manager - CRUD-Operationen für geblacklistete Marken
  3. Brand Extractor - Extraktion von Markennamen aus Produktkarten
  4. Blacklist Matcher - Case-insensitive Vergleich von Marken
  5. Brand Icon Manager - Verwaltung der Marken-Icons in Product_Bars
  6. Brand Logo Registry - Vordefinierte Logos für bekannte Marken

Components and Interfaces

1. Blacklist Storage Manager

// BlacklistStorageManager.js
class BlacklistStorageManager {
  constructor() {
    this.STORAGE_KEY = 'amazon_ext_blacklist';
  }

  // Speichert eine Marke in der Blacklist
  async addBrand(brandName) {
    const brands = await this.getBrands();
    const normalizedName = brandName.trim();
    
    // Case-insensitive Duplikat-Check
    const exists = brands.some(b => 
      b.name.toLowerCase() === normalizedName.toLowerCase()
    );
    
    if (exists) {
      throw new Error('Brand already exists');
    }
    
    brands.push({
      id: this.generateId(),
      name: normalizedName,
      addedAt: new Date().toISOString()
    });
    
    await this.saveBrands(brands);
    return brands;
  }

  // Holt alle geblacklisteten Marken
  async getBrands() {
    const data = localStorage.getItem(this.STORAGE_KEY);
    return data ? JSON.parse(data) : [];
  }

  // Löscht eine Marke aus der Blacklist
  async deleteBrand(brandId) {
    const brands = await this.getBrands();
    const filtered = brands.filter(b => b.id !== brandId);
    await this.saveBrands(filtered);
    return filtered;
  }

  // Prüft ob eine Marke geblacklistet ist (case-insensitive)
  async isBrandBlacklisted(brandName) {
    const brands = await this.getBrands();
    return brands.some(b => 
      b.name.toLowerCase() === brandName.toLowerCase()
    );
  }

  // Speichert Marken im Local Storage
  async saveBrands(brands) {
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(brands));
    
    // Event für UI-Updates emittieren
    if (window.amazonExtEventBus) {
      window.amazonExtEventBus.emit('blacklist:updated', brands);
    }
  }

  generateId() {
    return 'bl_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
  }
}

2. Brand Extractor

// BrandExtractor.js
class BrandExtractor {
  // Extrahiert Markennamen aus einer Produktkarte
  extractBrand(productCard) {
    // Methode 1: "by [Brand]" Text
    const byBrandElement = productCard.querySelector('.a-row.a-size-base.a-color-secondary');
    if (byBrandElement) {
      const byMatch = byBrandElement.textContent.match(/by\s+([^,\n]+)/i);
      if (byMatch) {
        return byMatch[1].trim();
      }
    }

    // Methode 2: Brand-Link
    const brandLink = productCard.querySelector('a[href*="/stores/"], .a-link-normal[href*="brand="]');
    if (brandLink) {
      return brandLink.textContent.trim();
    }

    // Methode 3: Aus Produkttitel extrahieren (erstes Wort oft die Marke)
    const titleElement = productCard.querySelector('h2 a span, .a-text-normal');
    if (titleElement) {
      const title = titleElement.textContent.trim();
      const firstWord = title.split(/\s+/)[0];
      // Nur wenn es wie ein Markenname aussieht (Großbuchstabe am Anfang)
      if (firstWord && /^[A-Z]/.test(firstWord)) {
        return firstWord;
      }
    }

    return null;
  }
}

3. Brand Logo Registry

// BrandLogoRegistry.js
class BrandLogoRegistry {
  constructor() {
    // Vordefinierte SVG-Logos für bekannte Marken
    this.logos = {
      'nike': this.createNikeLogo(),
      'adidas': this.createAdidasLogo(),
      'puma': this.createPumaLogo(),
      'apple': this.createAppleLogo(),
      'samsung': this.createSamsungLogo()
    };
    
    this.defaultBlockedIcon = this.createBlockedIcon();
  }

  // Holt Logo für eine Marke (case-insensitive)
  getLogo(brandName) {
    const normalized = brandName.toLowerCase();
    return this.logos[normalized] || this.defaultBlockedIcon;
  }

  // Prüft ob ein spezifisches Logo existiert
  hasLogo(brandName) {
    return brandName.toLowerCase() in this.logos;
  }

  createNikeLogo() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <path fill="currentColor" d="M1.5 9.5c-.3.1-.5.4-.5.7 0 .2.1.4.3.5l.2.1c.1 0 .2 0 .3-.1l12-5.5c.2-.1.3-.3.3-.5 0-.3-.2-.5-.5-.6L1.5 9.5z"/>
    </svg>`;
  }

  createAdidasLogo() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <path fill="currentColor" d="M2 12h3V6L2 12zm4 0h3V4L6 12zm4 0h3V2l-3 10z"/>
    </svg>`;
  }

  createPumaLogo() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <path fill="currentColor" d="M8 2C4.7 2 2 4.7 2 8s2.7 6 6 6 6-2.7 6-6-2.7-6-6-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z"/>
    </svg>`;
  }

  createAppleLogo() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <path fill="currentColor" d="M11.2 4.2c-.6-.7-1.4-1.1-2.3-1.2.1-.8.5-1.5 1.1-2-.6.1-1.2.4-1.6.9-.4-.5-1-.8-1.6-.9.6.5 1 1.2 1.1 2-.9.1-1.7.5-2.3 1.2C4.5 5.4 4 6.7 4 8c0 2.2 1.3 5 3 5 .5 0 .9-.2 1.3-.5.4.3.8.5 1.3.5 1.7 0 3-2.8 3-5 0-1.3-.5-2.6-1.4-3.8z"/>
    </svg>`;
  }

  createSamsungLogo() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <rect fill="currentColor" x="2" y="6" width="12" height="4" rx="1"/>
    </svg>`;
  }

  createBlockedIcon() {
    return `<svg viewBox="0 0 16 16" width="16" height="16">
      <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
      <line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5"/>
    </svg>`;
  }
}

4. Brand Icon Manager

// BrandIconManager.js
class BrandIconManager {
  constructor(blacklistStorage, brandExtractor, logoRegistry) {
    this.blacklistStorage = blacklistStorage;
    this.brandExtractor = brandExtractor;
    this.logoRegistry = logoRegistry;
  }

  // Aktualisiert alle Product_Bars auf der Seite
  async updateAllBars() {
    const productBars = document.querySelectorAll('.amazon-ext-product-bar');
    const brands = await this.blacklistStorage.getBrands();
    const blacklistedNames = brands.map(b => b.name.toLowerCase());

    productBars.forEach(bar => {
      const productCard = bar.closest('[data-asin]');
      if (!productCard) return;

      const brand = this.brandExtractor.extractBrand(productCard);
      if (brand && blacklistedNames.includes(brand.toLowerCase())) {
        this.addBrandIcon(bar, brand);
      } else {
        this.removeBrandIcon(bar);
      }
    });
  }

  // Fügt Brand-Icon zu einer Product_Bar hinzu
  addBrandIcon(productBar, brandName) {
    let iconContainer = productBar.querySelector('.brand-icon');
    
    if (!iconContainer) {
      iconContainer = document.createElement('div');
      iconContainer.className = 'brand-icon';
      productBar.insertBefore(iconContainer, productBar.firstChild);
    }

    const logo = this.logoRegistry.getLogo(brandName);
    iconContainer.innerHTML = logo;
    iconContainer.title = `Blacklisted: ${brandName}`;
    iconContainer.style.display = 'flex';
  }

  // Entfernt Brand-Icon von einer Product_Bar
  removeBrandIcon(productBar) {
    const iconContainer = productBar.querySelector('.brand-icon');
    if (iconContainer) {
      iconContainer.style.display = 'none';
    }
  }

  // Fügt Icon zu allen Produkten einer bestimmten Marke hinzu
  async addIconForBrand(brandName) {
    const productBars = document.querySelectorAll('.amazon-ext-product-bar');
    
    productBars.forEach(bar => {
      const productCard = bar.closest('[data-asin]');
      if (!productCard) return;

      const brand = this.brandExtractor.extractBrand(productCard);
      if (brand && brand.toLowerCase() === brandName.toLowerCase()) {
        this.addBrandIcon(bar, brand);
      }
    });
  }

  // Entfernt Icon von allen Produkten einer bestimmten Marke
  async removeIconForBrand(brandName) {
    const productBars = document.querySelectorAll('.amazon-ext-product-bar');
    
    productBars.forEach(bar => {
      const productCard = bar.closest('[data-asin]');
      if (!productCard) return;

      const brand = this.brandExtractor.extractBrand(productCard);
      if (brand && brand.toLowerCase() === brandName.toLowerCase()) {
        this.removeBrandIcon(bar);
      }
    });
  }
}

5. Blacklist Panel Manager

// BlacklistPanelManager.js
class BlacklistPanelManager {
  constructor(blacklistStorage, logoRegistry) {
    this.blacklistStorage = blacklistStorage;
    this.logoRegistry = logoRegistry;
    this.container = null;
  }

  createBlacklistContent() {
    const container = document.createElement('div');
    container.className = 'amazon-ext-blacklist-content';
    
    container.innerHTML = `
      <div class="blacklist-header">
        <h2>Blacklist</h2>
        <p class="blacklist-description">Markennamen hinzufügen, um Produkte zu markieren</p>
      </div>
      
      <div class="add-brand-form">
        <input 
          type="text" 
          class="brand-input" 
          placeholder="Markenname eingeben (z.B. Nike, Adidas)..."
        />
        <button class="add-brand-btn">Hinzufügen</button>
      </div>
      
      <div class="brand-list-container">
        <div class="brand-list"></div>
      </div>
      
      <div class="blacklist-message" style="display: none;"></div>
    `;

    this.container = container;
    this.setupEventListeners();
    this.loadBrands();
    
    return container;
  }

  setupEventListeners() {
    const input = this.container.querySelector('.brand-input');
    const addBtn = this.container.querySelector('.add-brand-btn');

    addBtn.addEventListener('click', () => this.handleAddBrand());
    input.addEventListener('keypress', (e) => {
      if (e.key === 'Enter') this.handleAddBrand();
    });
  }

  async handleAddBrand() {
    const input = this.container.querySelector('.brand-input');
    const brandName = input.value.trim();

    if (!brandName) {
      this.showMessage('Bitte einen Markennamen eingeben', 'error');
      return;
    }

    try {
      await this.blacklistStorage.addBrand(brandName);
      input.value = '';
      this.showMessage(`"${brandName}" zur Blacklist hinzugefügt`, 'success');
      this.loadBrands();
    } catch (error) {
      if (error.message === 'Brand already exists') {
        this.showMessage('Diese Marke ist bereits in der Blacklist', 'error');
      } else {
        this.showMessage('Fehler beim Speichern', 'error');
      }
    }
  }

  async loadBrands() {
    const brands = await this.blacklistStorage.getBrands();
    const listContainer = this.container.querySelector('.brand-list');
    
    if (brands.length === 0) {
      listContainer.innerHTML = '<p class="empty-message">Keine Marken in der Blacklist</p>';
      return;
    }

    listContainer.innerHTML = brands.map(brand => `
      <div class="brand-item" data-id="${brand.id}">
        <div class="brand-logo">
          ${this.logoRegistry.getLogo(brand.name)}
        </div>
        <span class="brand-name">${brand.name}</span>
        <button class="delete-brand-btn" data-id="${brand.id}">×</button>
      </div>
    `).join('');

    // Delete-Button Event Listeners
    listContainer.querySelectorAll('.delete-brand-btn').forEach(btn => {
      btn.addEventListener('click', (e) => {
        const brandId = e.target.dataset.id;
        this.handleDeleteBrand(brandId);
      });
    });
  }

  async handleDeleteBrand(brandId) {
    const brands = await this.blacklistStorage.getBrands();
    const brand = brands.find(b => b.id === brandId);
    
    await this.blacklistStorage.deleteBrand(brandId);
    this.showMessage(`"${brand?.name}" entfernt`, 'success');
    this.loadBrands();
  }

  showMessage(text, type) {
    const messageEl = this.container.querySelector('.blacklist-message');
    messageEl.textContent = text;
    messageEl.className = `blacklist-message ${type}`;
    messageEl.style.display = 'block';
    
    setTimeout(() => {
      messageEl.style.display = 'none';
    }, 3000);
  }

  showBlacklistPanel() {
    this.loadBrands();
  }

  hideBlacklistPanel() {
    // Cleanup wenn nötig
  }
}

Data Models

BlacklistedBrand

interface BlacklistedBrand {
  id: string;           // Eindeutige ID (bl_timestamp_random)
  name: string;         // Markenname (originale Schreibweise)
  addedAt: string;      // ISO-Timestamp der Hinzufügung
}

Local Storage Structure

{
  "amazon_ext_blacklist": [
    {
      "id": "bl_1699123456789_abc123def",
      "name": "Nike",
      "addedAt": "2024-01-15T10:30:00.000Z"
    },
    {
      "id": "bl_1699123456790_xyz789ghi",
      "name": "Adidas",
      "addedAt": "2024-01-15T10:31:00.000Z"
    }
  ]
}

CSS Styling

/* Blacklist Panel Styles */
.amazon-ext-blacklist-content {
  color: white;
  padding: 2rem;
  height: 100%;
  overflow-y: auto;
}

.blacklist-header h2 {
  margin: 0 0 0.5rem 0;
  font-size: 2rem;
  font-weight: 700;
}

.blacklist-description {
  color: #888;
  margin: 0 0 1.5rem 0;
}

.add-brand-form {
  display: flex;
  gap: 1rem;
  margin-bottom: 2rem;
}

.add-brand-form .brand-input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #333;
  background: #222;
  color: white;
  border-radius: 4px;
  font-size: 1rem;
}

.add-brand-form .add-brand-btn {
  padding: 0.75rem 1.5rem;
  background: #ff9900;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 600;
}

.add-brand-form .add-brand-btn:hover {
  background: #e68a00;
}

.brand-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.brand-item {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem 1rem;
  background: #222;
  border-radius: 4px;
  border: 1px solid #333;
}

.brand-logo {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #ff9900;
}

.brand-name {
  flex: 1;
  font-size: 1rem;
}

.delete-brand-btn {
  background: none;
  border: none;
  color: #888;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0 0.5rem;
  line-height: 1;
}

.delete-brand-btn:hover {
  color: #ff4444;
}

.empty-message {
  color: #666;
  text-align: center;
  padding: 2rem;
}

.blacklist-message {
  padding: 0.75rem 1rem;
  border-radius: 4px;
  margin-top: 1rem;
  text-align: center;
}

.blacklist-message.success {
  background: #1a4d1a;
  color: #4ade4a;
}

.blacklist-message.error {
  background: #4d1a1a;
  color: #ff6b6b;
}

/* Brand Icon in Product Bar */
.amazon-ext-product-bar .brand-icon {
  position: absolute;
  left: 5px;
  top: 50%;
  transform: translateY(-50%);
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #ff4444;
}

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: Brand Saving Round-Trip

For any valid brand name, saving it to the blacklist and then retrieving all brands should include that brand with the same name.

Validates: Requirements 2.2, 2.3

Property 2: Case-Insensitive Comparison

For any two brand name strings that differ only in letter case (e.g., "Nike" vs "nike" vs "NIKE"), the isBrandBlacklisted function should return the same result for both.

Validates: Requirements 4.1, 4.2

Property 3: Duplicate Prevention

For any brand name already in the blacklist, attempting to add a case-variant of that name should throw an error and not increase the blacklist size.

Validates: Requirements 2.5, 4.3

Property 4: Whitespace Trimming

For any brand name with leading or trailing whitespace, the saved brand name should have no leading or trailing whitespace.

Validates: Requirements 2.6

Property 5: Original Case Preservation

For any brand name saved to the blacklist, the retrieved brand name should preserve the exact original case as entered.

Validates: Requirements 4.4

Property 6: Brand List Rendering Completeness

For any set of N saved brands, the rendered brand list should contain exactly N brand items.

Validates: Requirements 3.1

Property 7: Logo Selection Consistency

For any brand name, if the brand has a predefined logo in the registry, getLogo should return that specific logo; otherwise, it should return the default blocked icon.

Validates: Requirements 3.2, 6.3, 7.3, 7.4

Property 8: Delete Button Presence

For any rendered brand item in the blacklist panel, it should contain exactly one delete button element.

Validates: Requirements 3.3

Property 9: Deletion Completeness

For any brand deleted from the blacklist, it should no longer appear in storage or in the UI after deletion.

Validates: Requirements 3.4

Property 10: Brand Extraction Determinism

For any product card DOM element with brand information, the extractBrand function should return a non-null string representing the brand.

Validates: Requirements 5.1, 5.2, 5.3

Property 11: No Marking Without Brand

For any product card where brand extraction returns null, no blacklist icon should be added to the product bar.

Validates: Requirements 5.4

Property 12: Blacklist Icon Display

For any product whose extracted brand matches a blacklisted brand (case-insensitive), the product bar should display a brand icon.

Validates: Requirements 6.1

Property 13: Real-Time Icon Updates

For any brand added to or removed from the blacklist, all visible product bars with matching brands should immediately reflect the change (icon added or removed).

Validates: Requirements 6.4, 6.5

Error Handling

Scenario Handling
Empty brand name entered Display error message, prevent saving
Brand already exists Display "already exists" message, prevent duplicate
Local storage quota exceeded Display warning, suggest cleanup
Brand extraction fails Skip blacklist marking for that product
Invalid DOM structure Graceful degradation, log warning
Logo not found for brand Use default blocked icon

Testing Strategy

Unit Tests

  • BlacklistStorageManager CRUD operations
  • Case-insensitive comparison logic
  • Whitespace trimming
  • BrandExtractor with various DOM structures
  • BrandLogoRegistry logo retrieval
  • BlacklistPanelManager UI rendering

Property-Based Tests

  • Property 1: Generate random brand names, save and retrieve
  • Property 2: Generate brand name pairs differing only in case
  • Property 3: Generate brands, add twice with case variants
  • Property 4: Generate brand names with various whitespace patterns
  • Property 5: Generate brand names with mixed case, verify preservation
  • Property 6: Generate sets of brands, verify list count
  • Property 7: Test known brands and unknown brands for logo selection
  • Property 8: Render brand items, verify delete button presence
  • Property 9: Add and delete brands, verify complete removal
  • Property 10: Generate product card DOMs with brand info
  • Property 11: Generate product cards without brand info
  • Property 12: Generate products with blacklisted brands
  • Property 13: Add/remove brands, verify icon updates

Integration Tests

  • End-to-end: Add brand → see icon on matching products → delete brand → icon removed
  • Menu navigation: Open menu → click Blacklist → verify panel content
  • Persistence: Add brands → reload page → verify brands persist

Testing Framework

  • Jest für Unit Tests
  • fast-check für Property-Based Tests
  • JSDOM für DOM-Simulation

Test Configuration

  • Minimum 100 Iterationen pro Property Test
  • Tag-Format: Feature: blacklist-feature, Property {number}: {property_text}
  • Jede Correctness Property wird durch einen einzelnen Property-Based Test implementiert