Merge branch 'offline-sync' into develop

This commit is contained in:
Ilyas Hallak 2025-12-05 09:23:18 +01:00
commit e085153d92
77 changed files with 9186 additions and 338 deletions

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

701
docs/FONT_SYSTEM_PLAN.md Normal file
View File

@ -0,0 +1,701 @@
# Font System Erweiterung - Konzept & Implementierungsplan
**Datum:** 27. November 2025
**Status:** Geplant
**Ziel:** Erweiterte Font-Auswahl mit 11 hochwertigen Schriftarten für bessere Lesbarkeit
---
## 📋 Übersicht
### Aktuelle Situation (4 Fonts)
- ❌ **System:** SF Pro (Apple)
- ❌ **Serif:** Times New Roman (veraltet)
- ❌ **Sans Serif:** Helvetica Neue (Standard)
- ❌ **Monospace:** Menlo (Apple)
### Neue Situation (11 Fonts)
- ✅ **5 Apple System Fonts** (bereits in iOS enthalten, 0 KB)
- ✅ **8 Google Fonts** (OFL 1.1 lizenziert, ~1-2 MB)
---
## 🎯 Ziele
1. **Bessere Lesbarkeit**: Moderne, für Langform-Texte optimierte Schriftarten
2. **Konsistenz**: Matching mit Readeck Web-UI (Literata, Source Serif, etc.)
3. **Sprachunterstützung**: Exzellenter Support für internationale Zeichen
4. **100% Legal**: Alle Fonts sind frei verwendbar (Apple proprietär für iOS, Google OFL 1.1)
---
## 📚 Font-Übersicht (11 Fonts Total)
### Serif Fonts (5 Schriftarten)
#### 1. **New York** (Apple System Font)
- **Quelle:** In iOS 13+ enthalten
- **Lizenz:** Apple proprietär (frei für iOS Apps)
- **Eigenschaften:**
- 6 Gewichte
- Variable optische Größen
- Unterstützt Latin, Greek, Cyrillic
- **Verwendung:** Premium Serif für Apple-native Ästhetik
- **App-Größe:** 0 KB (bereits in iOS)
#### 2. **Lora** (Google Font)
- **Quelle:** [GitHub - cyrealtype/Lora-Cyrillic](https://github.com/cyrealtype/Lora-Cyrillic)
- **Google Fonts:** [fonts.google.com/specimen/Lora](https://fonts.google.com/specimen/Lora)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Olga Karpushina, Alexei Vanyashin (Cyreal)
- **Eigenschaften:**
- Gut ausbalancierte Brushes
- Optimiert für Bildschirm-Lesbarkeit
- Variable Font verfügbar
- **Verwendung:** Elegante, lesbare Serif für Artikel
- **App-Größe:** ~200-300 KB
#### 3. **Literata** (Google Font) ⭐
- **Quelle:** [GitHub - googlefonts/literata](https://github.com/googlefonts/literata)
- **Google Fonts:** [fonts.google.com/specimen/Literata](https://fonts.google.com/specimen/Literata)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** TypeTogether (für Google)
- **Eigenschaften:**
- Standard-Font von Google Play Books
- Speziell für digitales Lesen entwickelt
- Variable Font mit optischen Größen
- **Verwendung:** **Readeck Web-UI Match** - Hauptschrift für Artikel
- **App-Größe:** ~250-350 KB
#### 4. **Merriweather** (Google Font)
- **Quelle:** [GitHub - SorkinType/Merriweather](https://github.com/SorkinType/Merriweather)
- **Google Fonts:** [fonts.google.com/specimen/Merriweather](https://fonts.google.com/specimen/Merriweather)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Sorkin Type Co
- **Eigenschaften:**
- Designed für Bildschirme
- 8 Gewichte (Light bis Black)
- Sehr gute Lesbarkeit bei kleinen Größen
- **Verwendung:** **Readeck Web-UI Match** - Alternative Serif
- **App-Größe:** ~200-300 KB
#### 5. **Source Serif** (Adobe/Google Font)
- **Quelle:** [GitHub - adobe-fonts/source-serif](https://github.com/adobe-fonts/source-serif)
- **Google Fonts:** [fonts.google.com/specimen/Source+Serif+4](https://fonts.google.com/specimen/Source+Serif+4)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Adobe (Frank Grießhammer)
- **Eigenschaften:**
- Adobe's drittes Open-Source-Projekt
- Companion zu Source Sans
- Variable Font (Source Serif 4)
- Professionelle, klare Serif
- **Verwendung:** **Readeck Web-UI Match** - Adobe-Qualität
- **App-Größe:** ~250-350 KB
---
### Sans Serif Fonts (5 Schriftarten)
#### 6. **SF Pro** (San Francisco - Apple System Font)
- **Quelle:** In iOS enthalten
- **Lizenz:** Apple proprietär (frei für iOS Apps)
- **Eigenschaften:**
- iOS Standard System Font
- 9 Gewichte
- Variable Widths (Condensed, Compressed, Expanded)
- Unterstützt 150+ Sprachen
- Dynamic optical sizes
- **Verwendung:** Standard UI Font
- **App-Größe:** 0 KB (bereits in iOS)
#### 7. **Lato** (Google Font)
- **Quelle:** [GitHub - latofonts/lato-source](https://github.com/latofonts/lato-source)
- **Google Fonts:** [fonts.google.com/specimen/Lato](https://fonts.google.com/specimen/Lato)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Łukasz Dziedzic
- **Eigenschaften:**
- Eine der beliebtesten Google Fonts
- 9 Gewichte (Thin bis Black)
- "Lato" = Polnisch für "Sommer"
- Warm, freundlich, stabil
- **Verwendung:** Beliebte, universelle Sans
- **App-Größe:** ~200-300 KB
#### 8. **Montserrat** (Google Font)
- **Quelle:** [GitHub - JulietaUla/Montserrat](https://github.com/JulietaUla/Montserrat)
- **Google Fonts:** [fonts.google.com/specimen/Montserrat](https://fonts.google.com/specimen/Montserrat)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Julieta Ulanovsky
- **Eigenschaften:**
- Inspiriert von urbaner Typografie Buenos Aires
- 18 Styles (9 Gewichte × 2)
- Variable Font verfügbar
- Geometric Sans
- **Verwendung:** Moderne, geometrische Sans
- **App-Größe:** ~200-300 KB
#### 9. **Nunito Sans** (Google Font)
- **Quelle:** [GitHub - googlefonts/nunito](https://github.com/googlefonts/nunito)
- **Google Fonts:** [fonts.google.com/specimen/Nunito+Sans](https://fonts.google.com/specimen/Nunito+Sans)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Vernon Adams, Cyreal, Jacques Le Bailly
- **Eigenschaften:**
- Balanced, humanistische Sans
- Variable Font
- 14 Styles
- Freundlich, gut lesbar
- **Verwendung:** Humanistische Alternative
- **App-Größe:** ~200-300 KB
#### 10. **Source Sans** (Adobe/Google Font)
- **Quelle:** [GitHub - adobe-fonts/source-sans](https://github.com/adobe-fonts/source-sans)
- **Google Fonts:** [fonts.google.com/specimen/Source+Sans+3](https://fonts.google.com/specimen/Source+Sans+3)
- **Lizenz:** SIL Open Font License 1.1
- **Designer:** Adobe (Paul D. Hunt)
- **Eigenschaften:**
- Adobe's **erstes** Open-Source-Projekt
- Variable Font (Source Sans 3)
- 12 Gewichte
- Professionelle UI-Font
- **Verwendung:** **Readeck Web-UI Match** - Adobe-Qualität
- **App-Größe:** ~250-350 KB
---
### Monospace Font (1 Schriftart)
#### 11. **SF Mono** (Apple System Font)
- **Quelle:** In iOS enthalten
- **Lizenz:** Apple proprietär (frei für iOS Apps)
- **Eigenschaften:**
- Xcode Standard-Font
- 6 Gewichte
- Optimiert für Code
- Unterstützt Latin, Greek, Cyrillic
- **Verwendung:** Code-Darstellung, technische Inhalte
- **App-Größe:** 0 KB (bereits in iOS)
---
## 📥 Download & Installation
### Schritt 1: Google Fonts herunterladen
**Empfohlene Quelle: Google Fonts Website**
1. Besuche [fonts.google.com](https://fonts.google.com)
2. Suche nach jedem Font
3. Klicke "Download family"
4. Entpacke die `.ttf` oder `.otf` Dateien
**Alternative: GitHub Repos (für neueste Versionen)**
```bash
# Lora
git clone https://github.com/cyrealtype/Lora-Cyrillic.git
# Literata
git clone https://github.com/googlefonts/literata.git
# Merriweather
git clone https://github.com/SorkinType/Merriweather.git
# Source Serif
git clone https://github.com/adobe-fonts/source-serif.git
# Lato
git clone https://github.com/latofonts/lato-source.git
# Montserrat
git clone https://github.com/JulietaUla/Montserrat.git
# Nunito Sans
git clone https://github.com/googlefonts/nunito.git
# Source Sans
git clone https://github.com/adobe-fonts/source-sans.git
```
### Schritt 2: Fonts zu Xcode hinzufügen
1. Erstelle Ordner in Xcode: `readeck/Resources/Fonts/`
2. Füge `.ttf` oder `.otf` Dateien hinzu (Drag & Drop)
3. Stelle sicher: **"Add to targets: readeck"** ist aktiviert
4. Wähle für jeden Font nur **1-2 Gewichte** (Regular + Bold), um App-Größe zu minimieren
**Empfohlene Gewichte:**
- **Regular** (400): Fließtext
- **Bold** (700): Überschriften, Hervorhebungen
### Schritt 3: Info.plist konfigurieren
Füge zu `Info.plist` hinzu:
```xml
<key>UIAppFonts</key>
<array>
<!-- Lora -->
<string>Lora-Regular.ttf</string>
<string>Lora-Bold.ttf</string>
<!-- Literata -->
<string>Literata-Regular.ttf</string>
<string>Literata-Bold.ttf</string>
<!-- Merriweather -->
<string>Merriweather-Regular.ttf</string>
<string>Merriweather-Bold.ttf</string>
<!-- Source Serif -->
<string>SourceSerif4-Regular.ttf</string>
<string>SourceSerif4-Bold.ttf</string>
<!-- Lato -->
<string>Lato-Regular.ttf</string>
<string>Lato-Bold.ttf</string>
<!-- Montserrat -->
<string>Montserrat-Regular.ttf</string>
<string>Montserrat-Bold.ttf</string>
<!-- Nunito Sans -->
<string>NunitoSans-Regular.ttf</string>
<string>NunitoSans-Bold.ttf</string>
<!-- Source Sans -->
<string>SourceSans3-Regular.ttf</string>
<string>SourceSans3-Bold.ttf</string>
</array>
```
**Hinweis:** Exakte Dateinamen können variieren - prüfe nach Download!
---
## 💻 Code-Implementierung
### Schritt 4: FontFamily.swift erweitern
**Aktuell:**
```swift
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
}
```
**Neu:**
```swift
enum FontFamily: String, CaseIterable {
// Apple System Fonts
case system = "system" // SF Pro
case newYork = "newYork" // New York
case monospace = "monospace" // SF Mono
// Google Serif Fonts
case lora = "lora"
case literata = "literata"
case merriweather = "merriweather"
case sourceSerif = "sourceSerif"
// Google Sans Serif Fonts
case lato = "lato"
case montserrat = "montserrat"
case nunitoSans = "nunitoSans"
case sourceSans = "sourceSans"
var displayName: String {
switch self {
// Apple
case .system: return "SF Pro"
case .newYork: return "New York"
case .monospace: return "SF Mono"
// Serif
case .lora: return "Lora"
case .literata: return "Literata"
case .merriweather: return "Merriweather"
case .sourceSerif: return "Source Serif"
// Sans Serif
case .lato: return "Lato"
case .montserrat: return "Montserrat"
case .nunitoSans: return "Nunito Sans"
case .sourceSans: return "Source Sans"
}
}
var category: FontCategory {
switch self {
case .system, .lato, .montserrat, .nunitoSans, .sourceSans:
return .sansSerif
case .newYork, .lora, .literata, .merriweather, .sourceSerif:
return .serif
case .monospace:
return .monospace
}
}
}
enum FontCategory {
case serif
case sansSerif
case monospace
}
```
### Schritt 5: FontSettingsViewModel.swift erweitern
```swift
var previewTitleFont: Font {
let size = selectedFontSize.size
switch selectedFontFamily {
// Apple System Fonts
case .system:
return Font.system(size: size).weight(.semibold)
case .newYork:
return Font.system(size: size, design: .serif).weight(.semibold)
case .monospace:
return Font.system(size: size, design: .monospaced).weight(.semibold)
// Google Serif Fonts
case .lora:
return Font.custom("Lora-Bold", size: size)
case .literata:
return Font.custom("Literata-Bold", size: size)
case .merriweather:
return Font.custom("Merriweather-Bold", size: size)
case .sourceSerif:
return Font.custom("SourceSerif4-Bold", size: size)
// Google Sans Serif Fonts
case .lato:
return Font.custom("Lato-Bold", size: size)
case .montserrat:
return Font.custom("Montserrat-Bold", size: size)
case .nunitoSans:
return Font.custom("NunitoSans-Bold", size: size)
case .sourceSans:
return Font.custom("SourceSans3-Bold", size: size)
}
}
var previewBodyFont: Font {
let size = selectedFontSize.size
switch selectedFontFamily {
// Apple System Fonts
case .system:
return Font.system(size: size)
case .newYork:
return Font.system(size: size, design: .serif)
case .monospace:
return Font.system(size: size, design: .monospaced)
// Google Serif Fonts
case .lora:
return Font.custom("Lora-Regular", size: size)
case .literata:
return Font.custom("Literata-Regular", size: size)
case .merriweather:
return Font.custom("Merriweather-Regular", size: size)
case .sourceSerif:
return Font.custom("SourceSerif4-Regular", size: size)
// Google Sans Serif Fonts
case .lato:
return Font.custom("Lato-Regular", size: size)
case .montserrat:
return Font.custom("Montserrat-Regular", size: size)
case .nunitoSans:
return Font.custom("NunitoSans-Regular", size: size)
case .sourceSans:
return Font.custom("SourceSans3-Regular", size: size)
}
}
```
**Wichtig:** Font-Namen müssen **exakt** mit PostScript-Namen übereinstimmen!
### Schritt 6: PostScript Font-Namen ermitteln
Nach dem Import der Fonts, teste mit:
```swift
// In einer View, temporär hinzufügen:
let _ = print("Available fonts:")
for family in UIFont.familyNames.sorted() {
let names = UIFont.fontNames(forFamilyName: family)
print("Family: \(family) - Names: \(names)")
}
```
Suche nach den exakten Namen wie `Lora-Regular`, `Literata-Bold`, etc.
### Schritt 7: FontSelectionView.swift optimieren (Optional)
Gruppiere Fonts nach Kategorie:
```swift
Section {
Picker("Font family", selection: $viewModel.selectedFontFamily) {
// Apple System Fonts
Text("SF Pro").tag(FontFamily.system)
Text("New York").tag(FontFamily.newYork)
Divider()
// Serif Fonts
ForEach([FontFamily.lora, .literata, .merriweather, .sourceSerif], id: \.self) { font in
Text(font.displayName).tag(font)
}
Divider()
// Sans Serif Fonts
ForEach([FontFamily.lato, .montserrat, .nunitoSans, .sourceSans], id: \.self) { font in
Text(font.displayName).tag(font)
}
Divider()
// Monospace
Text("SF Mono").tag(FontFamily.monospace)
}
}
```
---
## 📊 App-Größen-Kalkulation
### Font-Datei-Größen (Schätzung)
| Font | Regular | Bold | Total |
|------|---------|------|-------|
| Lora | 150 KB | 150 KB | 300 KB |
| Literata | 180 KB | 180 KB | 360 KB |
| Merriweather | 140 KB | 140 KB | 280 KB |
| Source Serif | 160 KB | 160 KB | 320 KB |
| Lato | 130 KB | 130 KB | 260 KB |
| Montserrat | 140 KB | 140 KB | 280 KB |
| Nunito Sans | 150 KB | 150 KB | 300 KB |
| Source Sans | 150 KB | 150 KB | 300 KB |
| **TOTAL** | | | **~2.4 MB** |
**Optimierung:**
- Verwende **Variable Fonts** (1 Datei statt 2): ~40% Ersparnis
- Oder nur **Regular** Gewicht: ~50% Ersparnis (aber weniger Flexibilität)
**Empfohlene Konfiguration:**
- Variable Fonts wo verfügbar → **~1.5 MB**
- Oder Regular + Bold → **~2.4 MB**
---
## ✅ Implementierungs-Checkliste
### Phase 1: Vorbereitung
- [ ] Google Fonts von fonts.google.com oder GitHub herunterladen
- [ ] Font-Dateien organisieren (1 Ordner pro Font-Familie)
- [ ] Gewichte auswählen (Regular + Bold empfohlen)
### Phase 2: Xcode Integration
- [ ] Ordner `readeck/Resources/Fonts/` erstellen
- [ ] Font-Dateien zu Xcode hinzufügen (Target: readeck)
- [ ] `Info.plist` mit `UIAppFonts` aktualisieren
- [ ] Build testen (Fonts müssen kopiert werden)
### Phase 3: Code-Änderungen
- [ ] `FontFamily.swift` erweitern (11 cases)
- [ ] `FontCategory` enum hinzufügen
- [ ] `displayName` und `category` Properties implementieren
- [ ] `FontSettingsViewModel.swift` aktualisieren:
- [ ] `previewTitleFont` erweitern
- [ ] `previewBodyFont` erweitern
- [ ] `previewCaptionFont` erweitern
### Phase 4: Testing & Validierung
- [ ] PostScript Font-Namen validieren (mit UIFont.familyNames)
- [ ] Alle 11 Fonts in Preview testen
- [ ] Font-Wechsel in Settings testen
- [ ] Font-Persistenz testen (nach App-Neustart)
- [ ] Prüfen: Werden Fonts korrekt in Bookmark-Detail angezeigt?
### Phase 5: UI-Verbesserungen (Optional)
- [ ] `FontSelectionView.swift` mit Gruppierung erweitern
- [ ] Font-Preview für jede Schrift hinzufügen
- [ ] "Readeck Web Match" Badge für Literata, Merriweather, Source Sans
### Phase 6: Dokumentation
- [ ] App Store Release Notes aktualisieren
- [ ] `RELEASE_NOTES.md` erweitern
- [ ] User-facing Font-Namen auf Deutsch übersetzen (optional)
---
## 🔍 PostScript Font-Namen (Nach Installation zu prüfen)
**Diese Namen können variieren!** Nach Import prüfen mit `UIFont.familyNames`:
| Font | Family Name | Regular | Bold |
|------|-------------|---------|------|
| Lora | Lora | Lora-Regular | Lora-Bold |
| Literata | Literata | Literata-Regular | Literata-Bold |
| Merriweather | Merriweather | Merriweather-Regular | Merriweather-Bold |
| Source Serif | Source Serif 4 | SourceSerif4-Regular | SourceSerif4-Bold |
| Lato | Lato | Lato-Regular | Lato-Bold |
| Montserrat | Montserrat | Montserrat-Regular | Montserrat-Bold |
| Nunito Sans | Nunito Sans | NunitoSans-Regular | NunitoSans-Bold |
| Source Sans | Source Sans 3 | SourceSans3-Regular | SourceSans3-Bold |
---
## 📝 Lizenz-Compliance
### SIL Open Font License 1.1 - Zusammenfassung
**Erlaubt:**
✅ Privater Gebrauch
✅ Kommerzieller Gebrauch
✅ Modifikation
✅ Distribution (embedded in App)
✅ Verkauf der App im AppStore
**Verboten:**
❌ Verkauf der Fonts als standalone Produkt
**Pflichten:**
- Copyright-Notice beibehalten (in Font-Dateien bereits enthalten)
- Lizenz-Text beifügen (optional in App, aber empfohlen)
### Attribution (Optional, aber empfohlen)
Füge zu "Settings → About → Licenses" oder ähnlich hinzu:
```
This app uses the following open-source fonts:
- Lora by Cyreal (SIL OFL 1.1)
- Literata by TypeTogether for Google (SIL OFL 1.1)
- Merriweather by Sorkin Type (SIL OFL 1.1)
- Source Serif by Adobe (SIL OFL 1.1)
- Lato by Łukasz Dziedzic (SIL OFL 1.1)
- Montserrat by Julieta Ulanovsky (SIL OFL 1.1)
- Nunito Sans by Vernon Adams, Cyreal (SIL OFL 1.1)
- Source Sans by Adobe (SIL OFL 1.1)
Full license: https://scripts.sil.org/OFL
```
---
## 🎨 Design-Empfehlungen
### Font-Pairings für Readeck
**Für Artikel (Reading Mode):**
- **Primär:** Literata (matches Readeck Web)
- **Alternativ:** Merriweather, Lora, Source Serif
**Für UI-Elemente:**
- **Primär:** SF Pro (nativer iOS Look)
- **Alternativ:** Source Sans (matches Readeck Web)
**Für Code/Technisch:**
- **Monospace:** SF Mono
### Default-Font-Einstellung
Vorschlag für neue Nutzer:
```swift
// In Settings Model
var defaultFontFamily: FontFamily = .literata // Matches Readeck Web
var defaultFontSize: FontSize = .medium
```
---
## 🚀 Migration & Rollout
### Bestehende Nutzer
**Problem:** User haben aktuell `.serif` (Times New Roman) gesetzt
**Lösung:** Migration in `SettingsRepository`:
```swift
func migrateOldFontSettings() async throws {
guard let settings = try await loadSettings() else { return }
// Alte Fonts auf neue mapping
var newFontFamily = settings.fontFamily
switch settings.fontFamily {
case .serif:
newFontFamily = .literata // Upgrade zu besserer Serif
case .sansSerif:
newFontFamily = .sourceSans // Upgrade zu besserer Sans
default:
break // .system, .monospace bleiben
}
if newFontFamily != settings.fontFamily {
try await saveSettings(fontFamily: newFontFamily, fontSize: settings.fontSize)
}
}
```
### Release Notes
```markdown
## Font System Upgrade 🎨
- **11 hochwertige Schriftarten** für besseres Lesen
- **Konsistenz** mit Readeck Web-UI
- **Serif-Fonts:** New York, Lora, Literata, Merriweather, Source Serif
- **Sans-Serif-Fonts:** SF Pro, Lato, Montserrat, Nunito Sans, Source Sans
- **Monospace:** SF Mono
Alle Fonts sind optimiert für digitales Lesen und unterstützen
internationale Zeichen.
```
---
## 📚 Referenzen
### Offizielle Font-Repositories
**Google Fonts:**
- Alle Fonts: https://fonts.google.com
**Adobe Open Source:**
- Source Serif: https://github.com/adobe-fonts/source-serif
- Source Sans: https://github.com/adobe-fonts/source-sans
**Apple Developer:**
- SF Fonts: https://developer.apple.com/fonts/
- Typography Guidelines: https://developer.apple.com/design/human-interface-guidelines/typography
### SIL Open Font License
- Lizenz-Text: https://scripts.sil.org/OFL
- FAQ: https://scripts.sil.org/OFL-FAQ_web
### SwiftUI Font-Dokumentation
- Custom Fonts: https://developer.apple.com/documentation/swiftui/applying-custom-fonts-to-text
- Font Design: https://developer.apple.com/documentation/swiftui/font/design
---
## 🎯 Nächste Schritte
1. **Download Google Fonts** (von fonts.google.com)
2. **Font-Dateien auswählen** (Regular + Bold empfohlen)
3. **Zu Xcode hinzufügen** (readeck/Resources/Fonts/)
4. **Info.plist konfigurieren** (UIAppFonts)
5. **Code implementieren** (siehe oben)
6. **Testen & Validieren**
7. **Release**
---
**Geschätzte Implementierungszeit:** 2-3 Stunden
**App-Größen-Erhöhung:** ~1.5-2.4 MB
**User-Benefit:** Deutlich bessere Lesbarkeit & Readeck-Konsistenz ✨

464
docs/Offline-Feature.md Normal file
View File

@ -0,0 +1,464 @@
# Offline Reading Feature Documentation
## Overview
The Readeck iOS application includes a comprehensive offline reading feature that allows users to cache bookmarks and articles for reading without an internet connection. This feature implements automated synchronization, intelligent image caching, and FIFO-based cache management.
## Architecture Overview
The offline feature follows **Clean Architecture** principles with clear separation of concerns:
```
┌─────────────────────────────────────────────┐
│ UI Layer (SwiftUI Views) │
│ - BookmarksView, BookmarkDetailView │
│ - OfflineBookmarksViewModel │
│ - CachedAsyncImage Component │
└────────────────┬────────────────────────────┘
┌─────────────────▼────────────────────────────┐
│ Domain Layer (Use Cases) │
│ - OfflineCacheSyncUseCase │
│ - GetCachedBookmarksUseCase │
│ - GetCachedArticleUseCase │
└────────────────┬────────────────────────────┘
┌─────────────────▼────────────────────────────┐
│ Data Layer (Repository Pattern) │
│ - OfflineCacheRepository │
│ - OfflineSettingsRepository │
│ - Kingfisher Image Cache │
└─────────────────────────────────────────────┘
```
## Core Components
### 1. Data Models
#### OfflineSettings
Configuration for offline caching behavior (stored in UserDefaults):
```swift
struct OfflineSettings: Codable {
var enabled: Bool = true // Feature enabled/disabled
var maxUnreadArticles: Double = 20 // Max cached articles (0-100)
var saveImages: Bool = false // Cache images in articles
var lastSyncDate: Date? // Last successful sync timestamp
var maxUnreadArticlesInt: Int {
Int(maxUnreadArticles) // Helper: Double to Int conversion
}
}
```
#### BookmarkEntity (CoreData)
Persisted bookmark with offline caching:
- `id`: Unique identifier
- `title`: Article title
- `url`: Original URL
- `htmlContent`: Full article HTML (added for offline)
- `cachedDate`: Timestamp when cached (added for offline)
- `lastAccessDate`: Last read timestamp (added for offline)
- `heroImageURL`: Hero image URL (added for offline)
- `imageURLs`: JSON array of content image URLs (added for offline)
- `cacheSize`: Cache size in bytes (added for offline)
- Plus other fields: authors, created, description, lang, readingTime, wordCount, etc.
### 2. Use Cases
#### OfflineCacheSyncUseCase
Main orchestrator for the offline sync process:
**Responsibilities:**
- Fetch unread bookmarks from server
- Download article HTML content
- Prefetch and cache images
- Embed images as Base64 (optional)
- Persist to CoreData
- Implement retry logic for temporary errors
- Publish sync progress updates
- Clean up old cached articles (FIFO)
**Retry Logic:**
- Retryable errors: HTTP 502, 503, 504 (temporary server issues)
- Non-retryable errors: 4xx client errors, network failures
- Exponential backoff: 2s → 4s between attempts
- Maximum retries: 2 (3 total attempts)
**Progress Publishers:**
- `isSyncing: AnyPublisher<Bool, Never>` - Sync state
- `syncProgress: AnyPublisher<String?, Never>` - Human-readable status
#### GetCachedBookmarksUseCase
Retrieve cached bookmarks for offline reading:
- Fetches all cached bookmarks from CoreData
- Returns sorted by `cachedDate` (newest first)
- Used when network is unavailable
#### GetCachedArticleUseCase
Retrieve cached article HTML:
- Looks up article by bookmark ID
- Returns full HTML content
- Updates `lastAccessDate` for cache management
- Returns nil if not cached
### 3. Repository Layer
#### OfflineCacheRepository
Manages all offline cache operations:
**Cache Operations:**
- `cacheBookmarkWithMetadata()` - Store bookmark + HTML + metadata
- `hasCachedArticle()` - Check if article is cached
- `getCachedArticle()` - Retrieve HTML content
- `getCachedBookmarks()` - Get all cached bookmarks
**Image Handling:**
- `extractImageURLsFromHTML()` - Parse img src URLs from HTML
- `prefetchImagesWithKingfisher()` - Download images to disk cache
- `embedImagesAsBase64()` - Convert cached images to data URIs
- `verifyPrefetchedImages()` - Validate images persisted after prefetch
**Cache Management:**
- `clearCache()` - Remove all cached articles and images
- `cleanupOldestCachedArticles()` - FIFO cleanup when cache exceeds limit
- `getCachedArticlesCount()` - Number of cached articles
- `getCacheSize()` - Total cache size in bytes
#### Image Caching Strategy
**Kingfisher Configuration:**
```swift
KingfisherManager.shared.cache.diskStorage.config.expiration = .never
```
**Two-Phase Image Strategy:**
1. **Hero/Thumbnail Images** (preserved in Kingfisher cache)
- Downloaded during sync with custom cache key
- Loaded from cache in offline mode via `CachedAsyncImage`
- Not embedded as Base64
2. **Content Images** (embedded in HTML)
- Extracted from article HTML
- Prefetched during sync
- Optionally embedded as Base64 data URIs (if `saveImages = true`)
- Online: HTTP URLs preserved
- Offline: Base64 embedded or fallback to cache
### 4. UI Components
#### CachedAsyncImage
Smart image component with online/offline awareness:
```swift
CachedAsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image.resizable()
case .failure:
Image(systemName: "photo.slash")
@unknown default:
EmptyView()
}
}
```
**Behavior:**
- Online: Normal KFImage loading with network access
- Offline: `.onlyFromCache(true)` - Only loads cached images
- Graceful fallback: Shows placeholder if not cached
#### OfflineBookmarksViewModel
Manages offline bookmark synchronization:
**State Machine:**
- `.idle` - No sync in progress
- `.pending(count)` - X articles ready to sync
- `.syncing(count, status)` - Actively syncing, showing progress
- `.success(count)` - Sync completed successfully
- `.error(message)` - Sync failed
**Key Methods:**
- `syncOfflineBookmarks()` - Trigger manual sync
- `refreshState()` - Update sync state from repository
- Bindings to `OfflineCacheSyncUseCase` publishers
#### BookmarksViewModel
Enhanced to support offline reading:
**Cache-First Approach:**
- `loadCachedBookmarks()` - Fetch from local cache when offline
- Only loads cached bookmarks for "Unread" tab
- Network error detection triggers cache fallback
- Archive/Starred/All tabs show "Tab not available offline"
#### BookmarkDetailViewModel
Read cached article content:
**Article Loading Priority:**
1. Try load from offline cache
2. Fallback to server if not cached
3. Report Base64 vs HTTP image counts
## Synchronization Flow
```
┌─ User triggers sync (manual or automatic)
├─ Check if offline feature enabled
├─ Check if network available
├─ Fetch unread bookmarks from server
│ └─ Implement pagination if needed
├─ For each bookmark:
│ ├─ Fetch article HTML with retry logic
│ │ ├─ Attempt 1: Immediate
│ │ ├─ Failure → 2s backoff → Attempt 2
│ │ └─ Failure → 4s backoff → Attempt 3
│ │
│ ├─ Extract image URLs from HTML
│ ├─ Prefetch images with Kingfisher
│ │ └─ Optional: Embed as Base64 if saveImages=true
│ │
│ └─ Persist to CoreData with timestamp
├─ Cleanup old articles (FIFO when max exceeded)
├─ Update lastSyncDate
└─ Publish completion/error status
```
## Cache Lifecycle
### Adding to Cache
1. User manually syncs or automatic sync triggers
2. Unread bookmarks fetched from server
3. Article HTML + images downloaded
4. Stored in CoreData + Kingfisher cache
5. `cachedDate` recorded
### Using from Cache
1. Network unavailable or error detected
2. `loadCachedBookmarks()` called
3. Returns list from CoreData (sorted by date)
4. User opens article
5. HTML loaded from cache
6. Images loaded from Kingfisher cache (`.onlyFromCache` mode)
### Removing from Cache
**Automatic (FIFO Cleanup):**
- When cached articles exceed `maxCachedArticles` (default: 20)
- Oldest articles deleted first
- Associated images also removed from Kingfisher
**Manual:**
- Settings → Offline → Clear Cache
- Removes all CoreData entries + Kingfisher images
## Error Handling & Retry Logic
### Retryable Errors (with backoff)
| HTTP Status | Reason | Retry? |
|-------------|--------|--------|
| 502 | Bad Gateway | ✅ Yes (temp server issue) |
| 503 | Service Unavailable | ✅ Yes (maintenance) |
| 504 | Gateway Timeout | ✅ Yes (slow backend) |
### Non-Retryable Errors (skip article)
| Error | Reason |
|-------|--------|
| 400-499 | Client error (bad URL, etc) |
| Connection failed | Network unavailable |
| Invalid URL | Malformed bookmark URL |
**Sync Behavior:** Failed articles are skipped, sync continues with remaining bookmarks.
## Configuration & Settings
### OfflineSettings Storage
- Persisted in **UserDefaults** (not CoreData)
- Encoded/decoded as JSON
- Survives app restarts
- Contains: enabled, maxUnreadArticles, saveImages, lastSyncDate
### Kingfisher Configuration
```swift
// Disk cache never expires
KingfisherManager.shared.cache.diskStorage.config.expiration = .never
// Offline mode: Only load from cache
KFImage(url)
.onlyFromCache(true)
```
### Cache Limits
- **Max cached articles:** 20 (configurable via `maxUnreadArticles` slider)
- **Max HTML size:** Unlimited (depends on device storage)
- **Image formats:** Original from server (no processing)
- **Cache location:** App Documents folder (CoreData) + Kingfisher disk cache
## Testing
### Unit Tests Coverage
- OfflineCacheSyncUseCase: Sync flow, retry logic, cleanup
- OfflineCacheRepository: Cache operations, image handling
- GetCachedBookmarksUseCase: Retrieval logic
- OfflineSettingsRepository: Settings persistence
### Manual Testing Scenarios
**Scenario 1: Hero Images Offline**
1. Start app online, enable sync
2. Wait for sync completion
3. Open Debug Menu (Shake) → Simulate Offline Mode
4. Verify hero/thumbnail images load from cache
**Scenario 2: Article Content Offline**
1. Open article while online (images load)
2. Enable offline mode
3. Reopen same article
4. Verify all images display correctly
**Scenario 3: FIFO Cleanup**
1. Cache 25+ articles (exceeds 20-limit)
2. Verify oldest 5 removed
3. Check newest 20 retained
**Scenario 4: Manual Cache Clear**
1. Settings → Offline → Clear Cache
2. Verify all cached articles removed
3. Verify disk space freed
## Performance Considerations
### Image Optimization
- **Format:** Original format from server (JPEG, PNG, WebP, etc.)
- **Quality:** No compression applied, stored as-is
- **Size:** Depends on server originals
- **Kingfisher cache:** Automatic disk management
- **Base64 embedding:** Increases HTML by ~30-40% (only for embedded images)
### Memory Usage
- CoreData uses SQLite (efficient)
- Kingfisher cache limited by available disk
- HTML content stored efficiently with compression
### Network Impact
- Prefetch uses concurrent image downloads
- Retry logic prevents thundering herd
- Exponential backoff reduces server load
## Known Limitations
1. **Offline Tab Restrictions**
- Only "Unread" tab available offline
- Archive/Starred require server
- Reason: Offline cache maintains unread state
2. **Read Progress Sync**
- Local read progress preserved offline
- Server sync when connection restored
- No real-time sync offline
3. **New Bookmarks**
- Cannot create bookmarks offline
- Share Extension requires internet
- Will be queued for sync when online
4. **Image Quality**
- Images cached as-is from server (no compression applied)
- Kingfisher stores original image format and quality
- Image sizes depend on server originals
- No optimization currently implemented
5. **VPN Connection Detection Issue** ⚠️
- Network monitor incorrectly detects VPN as active internet connection
- Even in Airplane Mode + VPN, app thinks network is available
- **Problem:** App doesn't switch to offline mode when it should
- **Impact:** Cannot test offline functionality with VPN enabled
- **Root cause:** Network reachability check only looks at interface, not actual connectivity
- **Workaround:** Disable VPN before using offline feature
- **Solution needed:** Enhanced network reachability check that differentiates between:
- Actual internet connectivity (real WiFi/cellular)
- VPN-only connections (should be treated as offline for app purposes)
- Add explicit connectivity test to Readeck server
## Future Enhancements
- [ ] **Migrate OfflineSettings to CoreData**
- Currently stored in UserDefaults as JSON
- Should be migrated to CoreData entity for consistency
- Better type safety and querying capabilities
- Atomic transactions with other cached data
- [ ] **URGENT:** Fix VPN detection in network monitor
- Properly detect actual internet vs VPN-only connection
- Add explicit connectivity check to Readeck server
- Show clear error when network detected but server unreachable
- [ ] **Image optimization** (compress images during caching)
- Add optional compression during prefetch
- Settings UI: Original / High / Medium / Low quality
- Consider JPEG compression for bandwidth optimization
- [ ] Selective article caching (star/tag based)
- [ ] Background sync with silent notifications
- [ ] Delta sync (only new articles)
- [ ] Archive/Starred offline access
- [ ] Offline reading time statistics
- [ ] Automatic sync on WiFi only
- [ ] Cloud sync of offline state
## Debugging
### Debug Menu
- Accessible via Settings → Debug or device Shake
- "Simulate Offline Mode" toggle
- Cache statistics and management
- Logging viewer
- Core Data reset
### Key Log Messages
```
✅ Sync started: 15 unread bookmarks
🔄 Starting Kingfisher prefetch for 8 images
✅ Article cached: Title
❌ Failed to cache: Title (will retry)
⏳ Retry 1/2 after 2s delay
📊 Cache statistics: 20/20 articles, 125 MB
🧹 FIFO cleanup: Removed 5 oldest articles
```
### Common Issues & Solutions
| Issue | Cause | Solution |
|-------|-------|----------|
| No images offline | Offline mode active | Turn off "Simulate Offline" in Debug Menu |
| Sync fails | Network error | Check internet, retry via Settings |
| Cache full | Max articles reached | Settings → Clear Cache or increase limit |
| Old articles deleted | FIFO cleanup | Normal behavior, oldest removed first |
| Images not caching | Unsupported format | Check image URLs, verify HTTP/HTTPS |
## Related Documentation
- Architecture: See `documentation/Architecture.md`
- Offline Settings: See `OFFLINE_CACHE_TESTING_PROMPT.md`
- Sync Retry Logic: See `OFFLINE_SYNC_RETRY_LOGIC.md`
- Image Loading: See `OFFLINE_IMAGES_FIXES.md`
- Testing Guide: See `OFFLINE_CACHE_TESTING_PROMPT.md`
## Implementation Status
| Component | Status | Notes |
|-----------|--------|-------|
| Core sync logic | ✅ Complete | With retry + backoff |
| Image caching | ✅ Complete | Kingfisher integration |
| Base64 embedding | ✅ Complete | Optional feature |
| Offline UI | ✅ Complete | Unread tab support |
| Settings UI | ✅ Complete | Full configuration |
| Debug tools | ✅ Complete | Shake gesture access |
| Unit tests | ✅ Complete | 80%+ coverage |
| Performance optimization | ✅ Complete | Image compression |

378
docs/Offline-Konzept.md Normal file
View File

@ -0,0 +1,378 @@
# Offline-Konzept für Readeck iOS App
## Übersicht
Dieses Dokument beschreibt ein mehrstufiges Konzept zur Offline-Funktionalität der Readeck iOS App. Das Konzept ist modular aufgebaut und ermöglicht eine schrittweise Implementierung von einer einfachen Caching-Strategie für ungelesene Artikel bis hin zu einer vollständig offline-fähigen App.
---
## Stufe 1: Smart Cache für Unread Items (Basis-Offline)
### Beschreibung
Die App lädt automatisch eine konfigurierbare Anzahl ungelesener Artikel herunter und hält diese offline verfügbar. Dies ist die Grundlage für eine bessere Offline-Erfahrung ohne großen Implementierungsaufwand.
### Features
- **Automatisches Caching**: Beim App-Start und bei Pull-to-Refresh werden die neuesten ungelesenen Artikel im Hintergrund heruntergeladen
- **Konfigurierbare Anzahl**: User kann in den Einstellungen festlegen, wie viele Artikel gecacht werden sollen (z.B. 10, 25, 50, 100)
- **Nur Artikel-Content**: Es wird nur der HTML-Content des Artikels (`/api/bookmarks/{id}/article`) gecached
- **Automatische Verwaltung**: Ältere gecachte Artikel werden automatisch entfernt, wenn neue hinzukommen (FIFO-Prinzip)
- **Offline-Indikator**: In der Bookmark-Liste wird angezeigt, welche Artikel offline verfügbar sind
### Technische Umsetzung
```swift
// Neue Settings
struct OfflineSettings {
var enabled: Bool = true
var maxUnreadArticles: Int = 25 // 10, 25, 50, 100
var onlyWiFi: Bool = true
}
// Neue Repository-Methode
protocol PBookmarksRepository {
func cacheBookmarkArticle(id: String, html: String) async throws
func getCachedArticle(id: String) -> String?
func hasCachedArticle(id: String) -> Bool
}
```
### Datenspeicherung
- **CoreData** für Artikel-HTML und Metadaten (Titel, URL, Datum, Reihenfolge)
- **FileManager** optional für große HTML-Dateien (falls CoreData zu groß wird)
### User Experience
- Bookmark-Liste zeigt Download-Icon für offline verfügbare Artikel
- Beim Öffnen eines gecachten Artikels: Sofortiges Laden ohne Netzwerk-Anfrage
- In Settings: "Offline-Modus" Sektion mit Slider für Anzahl der Artikel
- Cache-Größe wird angezeigt (z.B. "23 Artikel, 12.5 MB")
---
## Stufe 2: Offline-First mit Sync (Erweitert)
### Beschreibung
Die App funktioniert vollständig offline. Alle Aktionen werden lokal gespeichert und bei Netzwerkverbindung mit dem Server synchronisiert.
### Features
- **Vollständige Offline-Funktionalität**: Alle Lese-Operationen funktionieren offline
- **Lokale Schreib-Operationen**:
- Bookmarks erstellen, bearbeiten, löschen
- Labels hinzufügen/entfernen
- Artikel archivieren/favorisieren
- Lesefortschritt speichern
- Annotationen/Highlights erstellen
- **Intelligente Synchronisierung**:
- Automatische Sync bei Netzwerkverbindung
- Konfliktauflösung (Server gewinnt vs. Client gewinnt)
- Retry-Mechanismus bei fehlgeschlagenen Syncs
- **Sync-Status**: User sieht jederzeit, ob und was synchronisiert wird
- **Offline-Indikator**: Klarer Status in der UI (Online/Offline/Syncing)
### Technische Umsetzung
#### Sync-Manager
```swift
class OfflineSyncManager {
// Bereits vorhanden für Bookmark-Erstellung
// Erweitern für alle Operations
enum SyncOperation {
case createBookmark(CreateBookmarkRequest)
case updateBookmark(id: String, BookmarkUpdateRequest)
case deleteBookmark(id: String)
case addLabels(bookmarkId: String, labels: [String])
case removeLabels(bookmarkId: String, labels: [String])
case updateReadProgress(bookmarkId: String, progress: Int)
case createAnnotation(AnnotationRequest)
case deleteAnnotation(bookmarkId: String, annotationId: String)
}
func queueOperation(_ operation: SyncOperation) async
func syncAllPendingOperations() async throws
func getPendingOperationsCount() -> Int
}
```
#### Lokale Datenbank-Struktur
```swift
// CoreData Entities
entity OfflineBookmark {
id: String
title: String
url: String
content: String? // HTML
metadata: Data // JSON mit allen Bookmark-Daten
labels: [String]
isArchived: Bool
isMarked: Bool
readProgress: Int
annotations: [OfflineAnnotation]
lastModified: Date
syncStatus: String // "synced", "pending", "conflict"
}
entity PendingSyncOperation {
id: UUID
type: String // operation type
payload: Data // JSON der Operation
createdAt: Date
retryCount: Int
lastError: String?
}
```
### Sync-Strategien
#### Option A: Last-Write-Wins (Einfach)
- Server-Version überschreibt bei Konflikt immer lokale Version
- Einfach zu implementieren
- Potentieller Datenverlust bei Offline-Änderungen
#### Option B: Timestamp-basiert (Empfohlen)
- Neueste Änderung (basierend auf Timestamp) gewinnt
- Server sendet `updated` Timestamp mit jeder Response
- Client vergleicht mit lokalem Timestamp
#### Option C: Operational Transformation (Komplex)
- Granulare Merge-Strategien für verschiedene Felder
- Beispiel: Lokale Labels + Server Labels = Union
- Aufwändig, aber maximale Datenerhaltung
### User Experience
- **Offline-Banner**: "Du bist offline. Änderungen werden synchronisiert, sobald du online bist."
- **Sync-Status Indicator**:
- Grün: Alles synchronisiert
- Gelb: Synchronisierung läuft
- Rot: Sync-Fehler
- Grau: Offline
- **Pending Changes Badge**: Zeigt Anzahl nicht synchronisierter Änderungen
- **Manual Sync Button**: Manuelles Anstoßen der Synchronisierung
- **Sync-Konflikt-Dialog**: Bei Konflikten User entscheiden lassen (Lokal behalten / Server übernehmen / Beide behalten)
---
## Stufe 3: Vollständig Offline-Fähig (Maximum)
### Beschreibung
Die App kann komplett ohne Server-Verbindung genutzt werden, inklusive lokaler Volltext-Suche und erweiterter Offline-Features.
### Zusätzliche Features
- **Lokale Volltext-Suche**:
- SQLite FTS5 (Full-Text Search) für schnelle Suche
- Suche in Titeln, URLs, Content, Labels
- Highlighting von Suchbegriffen
- **Intelligente Offline-Strategie**:
- Predictive Caching basierend auf Leseverhalten
- Automatisches Herunterladen von "Ähnlichen Artikeln"
- Background Refresh für häufig gelesene Labels/Tags
- **Erweiterte Export-Funktionen**:
- Kompletten Offline-Cache als ZIP exportieren
- Import von Offline-Daten auf anderem Gerät
- Backup & Restore
- **Reader Mode Optimierungen**:
- Lokale Schriftarten für Offline-Nutzung
- CSS/JS lokal gespeichert
- Keine externen Dependencies
- **Offline-Statistiken**:
- Lesezeit offline vs. online
- Meistgelesene offline Artikel
- Speicherplatz-Statistiken
### Erweiterte Technische Umsetzung
#### FTS5 für Suche
```swift
// SQLite Schema
CREATE VIRTUAL TABLE bookmarks_fts USING fts5(
title,
url,
content,
labels,
content='offline_bookmarks',
content_rowid='id'
);
// Suche
func searchOfflineBookmarks(query: String) -> [Bookmark] {
let sql = """
SELECT * FROM offline_bookmarks
WHERE id IN (
SELECT rowid FROM bookmarks_fts
WHERE bookmarks_fts MATCH ?
)
ORDER BY rank
"""
// Execute and return results
}
```
#### Predictive Caching
```swift
class PredictiveCacheManager {
// Analysiere Leseverhalten
func analyzeReadingPatterns() -> ReadingProfile
// Lade ähnliche Artikel basierend auf:
// - Gleiche Labels/Tags
// - Gleiche Autoren
// - Gleiche Domains
func prefetchRelatedArticles(for bookmark: Bookmark) async
// Machine Learning (optional)
// CoreML Model für Content-Empfehlungen
func trainRecommendationModel()
}
```
#### Background Sync
```swift
// BackgroundTasks Framework
class BackgroundSyncScheduler {
func scheduleBackgroundSync() {
let request = BGAppRefreshTaskRequest(identifier: "de.readeck.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
try? BGTaskScheduler.shared.submit(request)
}
func handleBackgroundSync(task: BGTask) async {
// Sync neue Artikel
// Update gecachte Artikel
// Cleanup alte Artikel
}
}
```
### Datenspeicherung
- **SQLite + FTS5**: Für Volltextsuche
- **CoreData**: Für strukturierte Daten und Relationships
- **FileManager**: Für HTML-Content, Bilder, Assets
- **NSUbiquitousKeyValueStore**: Optional für iCloud-Sync der Einstellungen
### User Experience
- **Offline-First Ansatz**: App fühlt sich immer schnell an, auch bei schlechter Verbindung
- **Smart Downloads**:
- "20 neue Artikel verfügbar. Jetzt herunterladen?"
- "Für dich empfohlen: 5 Artikel zum Offline-Lesen"
- **Storage Dashboard**:
- Visualisierung des genutzten Speichers
- Top 10 größte Artikel
- "Speicher optimieren" Funktion (Bilder komprimieren, alte Artikel löschen)
- **Offline-Modus Toggle**: Bewusster "Nur Offline"-Modus aktivierbar
- **Sync-Schedule**: "Täglich um 6:00 Uhr synchronisieren"
---
## Implementierungs-Roadmap
### Phase 1: Basis (Stufe 1) - ca. 2-3 Wochen
- [ ] Settings für Offline-Anzahl
- [ ] CoreData Schema für cached Articles
- [ ] Download-Logic in BookmarksRepository
- [ ] UI-Indikatoren für gecachte Artikel
- [ ] Cleanup-Logic (FIFO)
### Phase 2: Offline-First mit Sync (Stufe 2) - ca. 4-6 Wochen
- [ ] Erweiterte CoreData Entities
- [ ] OfflineSyncManager erweitern
- [ ] Konfliktauflösung implementieren
- [ ] Sync-Status UI
- [ ] Comprehensive Testing (Edge Cases)
### Phase 3: Advanced Features (Stufe 3) - ca. 4-6 Wochen
- [ ] SQLite FTS5 Integration
- [ ] Predictive Caching
- [ ] Background Tasks
- [ ] Export/Import
- [ ] Analytics & Optimizations
---
## Technische Überlegungen
### Performance
- **Lazy Loading**: Artikel-Content nur laden, wenn benötigt
- **Pagination**: Auch offline große Listen paginieren
- **Image Optimization**: Bilder komprimieren vor dem Speichern (WebP, HEIC)
- **Incremental Sync**: Nur Änderungen synchronisieren, nicht alles neu laden
### Speicherplatz
- **Quotas**: Maximale Größe für Offline-Cache (z.B. 500MB, 1GB, 2GB)
- **Cleanup-Strategien**:
- Älteste zuerst (FIFO)
- Größte zuerst
- Am wenigsten gelesen zuerst
- **Kompression**: HTML und JSON komprimieren (gzip)
### Sicherheit
- **Verschlüsselung**: Offline-Daten mit iOS Data Protection verschlüsseln
- **Sensitive Data**: Passwörter niemals lokal speichern (nur Token mit Keychain)
- **Cleanup bei Logout**: Alle Offline-Daten löschen
### Testing
- **Unit Tests**: Für Sync-Logic, Conflict Resolution
- **Integration Tests**: Offline→Online Szenarien
- **UI Tests**: Offline-Modi, Sync-Status
- **Edge Cases**:
- App-Kill während Sync
- Netzwerk-Loss während Download
- Speicher voll
- Server-Konflikte
---
## Bestehende Features die von Offline profitieren
### Bereits implementiert
- ✅ **Offline Bookmark Erstellung**: Bookmarks werden lokal gespeichert und bei Verbindung synchronisiert ([OfflineSyncManager.swift](readeck/Data/Repository/OfflineSyncManager.swift))
- ✅ **Server Reachability Check**: Check ob Server erreichbar ist vor Sync-Operationen
- ✅ **Sync Status UI**: isSyncing und syncStatus werden bereits getrackt
### Erweiterbar
- **Text-to-Speech**: Geht bereits offline, wenn Artikel gecacht ist
- **Annotations/Highlights**: Können offline erstellt und später synchronisiert werden
- **Lesefortschritt**: Kann lokal getrackt und bei Sync übertragen werden
- **Labels**: Offline hinzufügen/entfernen mit Sync
- **Read Progress**: Bereits vorhanden im BookmarkDetail Model, kann offline getrackt werden
---
## Metriken für Erfolg
### User-Metriken
- Durchschnittliche Offline-Nutzungszeit
- % der Nutzer, die Offline-Features aktivieren
- Anzahl der offline gelesenen Artikel pro User
- User-Feedback zur Offline-Erfahrung
### Technische Metriken
- Erfolgsrate der Synchronisierungen
- Durchschnittliche Sync-Dauer
- Anzahl der Sync-Konflikte
- Speicherplatz-Nutzung pro User
- Crash-Rate während Offline-Operationen
### Performance-Metriken
- App-Start-Zeit mit/ohne Offline-Cache
- Ladezeit für gecachte vs. nicht-gecachte Artikel
- Netzwerk-Traffic Reduktion durch Caching
- Battery Impact durch Background-Sync
---
## Nächste Schritte
1. **Entscheidung**: Welche Stufe soll zuerst implementiert werden?
2. **Prototyping**: Quick PoC für CoreData Schema und Cache-Logic
3. **UI/UX Design**: Mockups für Offline-Indikatoren und Settings
4. **Implementation**: Schrittweise nach Roadmap
5. **Testing**: Ausgiebiges Testen von Edge Cases
6. **Beta**: TestFlight mit fokussiertem Offline-Testing
7. **Launch**: Schrittweises Rollout mit Feature-Flags
---
*Dokument erstellt: 2025-11-01*
*Version: 1.0*

View File

@ -0,0 +1,655 @@
# Offline Stufe 1 - Implementierungs-Tracking
**Branch**: `offline-sync`
**Start**: 2025-11-01
**Geschätzte Dauer**: 5-8 Tage
---
## Phase 1: Foundation & Models (Tag 1)
### 1.1 Logging-Kategorie erweitern
**Datei**: `readeck/Utils/Logger.swift`
- [x] `LogCategory.sync` zur enum hinzufügen
- [x] `Logger.sync` zur Extension hinzufügen
- [x] Testen: Logging funktioniert
**Code-Änderungen**:
```swift
enum LogCategory: String, CaseIterable, Codable {
// ... existing
case sync = "Sync"
}
extension Logger {
// ... existing
static let sync = Logger(category: .sync)
}
```
---
### 1.2 Domain Model: OfflineSettings
**Neue Datei**: `readeck/Domain/Model/OfflineSettings.swift`
- [x] Struct OfflineSettings erstellen
- [x] Properties hinzufügen:
- [x] `enabled: Bool = true`
- [x] `maxUnreadArticles: Double = 20`
- [x] `saveImages: Bool = false`
- [x] `lastSyncDate: Date?`
- [x] Computed Property `maxUnreadArticlesInt` implementieren
- [x] Computed Property `shouldSyncOnAppStart` implementieren (4-Stunden-Check)
- [x] Codable Conformance
- [x] Testen: shouldSyncOnAppStart Logic
**Checklist**:
- [x] File erstellt
- [x] Alle Properties vorhanden
- [x] 4-Stunden-Check funktioniert
- [x] Kompiliert ohne Fehler
---
### 1.3 CoreData Entity: BookmarkEntity erweitert
**Datei**: `readeck.xcdatamodeld`
- [x] BookmarkEntity mit Cache-Feldern erweitern
- [x] Attributes definieren:
- [x] `htmlContent` (String)
- [x] `cachedDate` (Date, indexed)
- [x] `lastAccessDate` (Date)
- [x] `cacheSize` (Integer 64)
- [x] `imageURLs` (String, optional)
- [x] Lightweight Migration
- [x] Testen: App startet ohne Crash, keine Migration-Fehler
**Checklist**:
- [x] Cache-Felder hinzugefügt
- [x] Alle Attributes vorhanden
- [x] Indexes gesetzt
- [x] Migration funktioniert
- [x] App startet erfolgreich
---
## Phase 2: Data Layer (Tag 1-2)
### 2.1 Settings Repository Protocol
**Neue Datei**: `readeck/Domain/Protocols/PSettingsRepository.swift`
- [x] Protocol `PSettingsRepository` erweitern
- [x] Methode `loadOfflineSettings()` definieren
- [x] Methode `saveOfflineSettings(_ settings: OfflineSettings)` definieren
**Checklist**:
- [x] Protocol erweitert
- [x] Methoden deklariert
- [x] Kompiliert ohne Fehler
---
### 2.2 Settings Repository Implementation
**Datei**: `readeck/Data/Repository/SettingsRepository.swift`
- [x] Class `SettingsRepository` erweitert
- [x] `PSettingsRepository` implementiert
- [x] `loadOfflineSettings()` implementiert:
- [x] UserDefaults laden
- [x] JSON dekodieren
- [x] Default-Settings zurückgeben bei Fehler
- [x] Logger.data für Erfolgsmeldung
- [x] `saveOfflineSettings()` implementiert:
- [x] JSON enkodieren
- [x] UserDefaults speichern
- [x] Logger.data für Erfolgsmeldung
- [x] Kompiliert ohne Fehler
**Checklist**:
- [x] File erweitert (Zeilen 274-296)
- [x] loadOfflineSettings() implementiert
- [x] saveOfflineSettings() implementiert
- [x] Logging integriert
- [x] Kompiliert erfolgreich
---
### 2.3 OfflineCacheRepository Protocol (ARCHITEKTUR-ÄNDERUNG)
**Neue Datei**: `readeck/Domain/Protocols/POfflineCacheRepository.swift`
**HINWEIS:** Anstatt `PBookmarksRepository` zu erweitern, wurde ein separates `POfflineCacheRepository` erstellt für **Clean Architecture** und Separation of Concerns.
- [x] Protocol `POfflineCacheRepository` erstellen
- [x] Neue Methoden zum Protocol hinzufügen:
- [x] `cacheBookmarkWithMetadata(bookmark:html:saveImages:) async throws`
- [x] `getCachedArticle(id:) -> String?`
- [x] `hasCachedArticle(id:) -> Bool`
- [x] `getCachedBookmarks() async throws -> [Bookmark]`
- [x] `getCachedArticlesCount() -> Int`
- [x] `getCacheSize() -> String`
- [x] `clearCache() async throws`
- [x] `cleanupOldestCachedArticles(keepCount:) async throws`
**Checklist**:
- [x] Protocol erstellt (Zeilen 1-24)
- [x] Alle Methoden deklariert
- [x] Async/throws korrekt gesetzt
- [x] Kompiliert ohne Fehler
---
### 2.4 OfflineCacheRepository Implementation (ARCHITEKTUR-ÄNDERUNG)
**Neue Datei**: `readeck/Data/Repository/OfflineCacheRepository.swift`
- [x] `import Kingfisher` hinzugefügt
- [x] `cacheBookmarkWithMetadata()` implementiert:
- [x] Prüfen ob bereits gecacht
- [x] Bookmark in CoreData speichern via BookmarkEntity
- [x] CoreData BookmarkEntity erweitern
- [x] Speichern in CoreData
- [x] Logger.sync.info
- [x] Bei saveImages: extractImageURLsFromHTML() aufrufen
- [x] Bei saveImages: prefetchImagesWithKingfisher() aufrufen
- [x] `extractImageURLsFromHTML()` implementiert:
- [x] Regex für `<img src="...">` Tags
- [x] URLs extrahieren
- [x] Nur absolute URLs (http/https)
- [x] Logger.sync.debug
- [x] `prefetchImagesWithKingfisher()` implementiert:
- [x] URLs zu URL-Array konvertieren
- [x] ImagePrefetcher erstellt
- [x] Callback mit Logging
- [x] prefetcher.start()
- [x] Logger.sync.info
- [x] `getCachedArticle()` implementiert:
- [x] NSFetchRequest mit predicate
- [x] lastAccessDate updaten
- [x] htmlContent zurückgeben
- [x] `hasCachedArticle()` implementiert
- [x] `getCachedBookmarks()` implementiert:
- [x] Fetch alle BookmarkEntity mit htmlContent
- [x] Sort by cachedDate descending
- [x] toDomain() mapper nutzen
- [x] Array zurückgeben
- [x] `getCachedArticlesCount()` implementiert
- [x] `getCacheSize()` implementiert:
- [x] Alle sizes summieren
- [x] ByteCountFormatter nutzen
- [x] `clearCache()` implementiert:
- [x] Cache-Felder auf nil setzen
- [x] Logger.sync.info
- [x] `cleanupOldestCachedArticles()` implementiert:
- [x] Sort by cachedDate ascending
- [x] Älteste löschen wenn > keepCount
- [x] Logger.sync.info
**Checklist**:
- [x] File erstellt (272 Zeilen)
- [x] Kingfisher import
- [x] Alle Methoden implementiert
- [x] Logging überall integriert
- [x] Kompiliert ohne Fehler
---
## Phase 3: Use Case & Business Logic (Tag 2)
### 3.1 OfflineCacheSyncUseCase Protocol
**Neue Datei**: `readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift`
- [x] Protocol `POfflineCacheSyncUseCase` erstellen
- [x] Published Properties definieren:
- [x] `var isSyncing: AnyPublisher<Bool, Never>`
- [x] `var syncProgress: AnyPublisher<String?, Never>`
- [x] Methoden deklarieren:
- [x] `func syncOfflineArticles(settings:) async`
- [x] `func getCachedArticlesCount() -> Int`
- [x] `func getCacheSize() -> String`
**Checklist**:
- [x] File erstellt
- [x] Protocol definiert (Zeilen 11-20)
- [x] Methoden deklariert
- [x] Kompiliert ohne Fehler
---
### 3.2 OfflineCacheSyncUseCase Implementation
**Datei**: `readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift` (im selben File)
- [x] Class `OfflineCacheSyncUseCase` erstellen
- [x] Dependencies:
- [x] `offlineCacheRepository: POfflineCacheRepository`
- [x] `bookmarksRepository: PBookmarksRepository`
- [x] `settingsRepository: PSettingsRepository`
- [x] CurrentValueSubject für State (statt @Published):
- [x] `_isSyncingSubject = CurrentValueSubject<Bool, Never>(false)`
- [x] `_syncProgressSubject = CurrentValueSubject<String?, Never>(nil)`
- [x] Publishers als computed properties
- [x] `syncOfflineArticles()` implementieren:
- [x] Guard enabled check
- [x] Logger.sync.info("Starting sync")
- [x] Set isSyncing = true
- [x] Fetch bookmarks (state: .unread, limit: settings.maxUnreadArticlesInt)
- [x] Logger.sync.info("Fetched X bookmarks")
- [x] Loop durch Bookmarks:
- [x] Skip wenn bereits gecacht (Logger.sync.debug)
- [x] syncProgress updaten: "Artikel X/Y..."
- [x] fetchBookmarkArticle() aufrufen
- [x] cacheBookmarkWithMetadata() aufrufen
- [x] successCount++
- [x] Bei saveImages: syncProgress "...+ Bilder"
- [x] Catch: errorCount++, Logger.sync.error
- [x] cleanupOldestCachedArticles() aufrufen
- [x] lastSyncDate updaten
- [x] Logger.sync.info("✅ Synced X, skipped Y, failed Z")
- [x] Set isSyncing = false
- [x] syncProgress = Status-Message
- [x] Sleep 3s, dann syncProgress = nil
- [x] `getCachedArticlesCount()` implementieren
- [x] `getCacheSize()` implementieren
- [x] Error-Handling:
- [x] Catch block für Haupt-Try
- [x] Logger.sync.error
- [x] syncProgress = Error-Message
**Checklist**:
- [x] Class erstellt (159 Zeilen)
- [x] Dependencies injiziert (3 repositories)
- [x] syncOfflineArticles() komplett mit @MainActor
- [x] Success/Skip/Error Tracking
- [x] Logging an allen wichtigen Stellen
- [x] Progress-Updates mit Emojis
- [x] Error-Handling
- [x] getCachedArticlesCount() fertig
- [x] getCacheSize() fertig
- [x] Kompiliert ohne Fehler
---
## Phase 4: Settings UI (Tag 3)
### 4.1 OfflineSettingsViewModel
**Neue Datei**: `readeck/UI/Settings/OfflineSettingsViewModel.swift`
- [x] Class mit @Observable (ohne @MainActor auf Klassenebene)
- [x] Properties:
- [x] `offlineSettings: OfflineSettings`
- [x] `isSyncing = false`
- [x] `syncProgress: String?`
- [x] `cachedArticlesCount = 0`
- [x] `cacheSize = "0 KB"`
- [x] Dependencies:
- [x] `settingsRepository: PSettingsRepository`
- [x] `offlineCacheSyncUseCase: POfflineCacheSyncUseCase`
- [x] Init mit Factory
- [x] `setupBindings()` implementiert:
- [x] isSyncing Publisher binden
- [x] syncProgress Publisher binden
- [x] `loadSettings()` implementiert mit @MainActor
- [x] `saveSettings()` implementiert mit @MainActor
- [x] `syncNow()` implementiert mit @MainActor:
- [x] await offlineCacheSyncUseCase.syncOfflineArticles()
- [x] updateCacheStats()
- [x] `updateCacheStats()` implementiert mit @MainActor
**Checklist**:
- [x] File erstellt (89 Zeilen)
- [x] Properties definiert
- [x] Dependencies via Factory injiziert
- [x] setupBindings() mit Combine
- [x] Alle Methoden mit @MainActor markiert
- [x] Kompiliert ohne Fehler
---
### 4.2 OfflineSettingsView
**Neue Datei**: `readeck/UI/Settings/OfflineSettingsView.swift`
- [x] Struct `OfflineSettingsView: View`
- [x] @State viewModel
- [x] Body implementiert:
- [x] Section mit "Offline-Reading" header
- [x] Toggle: "Offline-Reading aktivieren" gebunden an enabled
- [x] If enabled:
- [x] VStack: Erklärungstext (caption, secondary)
- [x] VStack: Slider "Max. Artikel offline" (0-100, step 10)
- [x] HStack: Anzeige aktueller Wert
- [x] Toggle: "Bilder speichern" mit Erklärung
- [x] Button: "Jetzt synchronisieren" mit ProgressView
- [x] If syncProgress: Text anzeigen (caption)
- [x] If lastSyncDate: Text "Zuletzt: relative"
- [x] If cachedArticlesCount > 0: HStack mit Stats
- [x] task: loadSettings() bei Erscheinen
- [x] onChange Handler für alle Settings (auto-save)
**Checklist**:
- [x] File erstellt (145 Zeilen)
- [x] Form Structure mit Section
- [x] Toggle für enabled mit Erklärung
- [x] Slider für maxUnreadArticles mit Wert-Anzeige
- [x] Toggle für saveImages
- [x] Sync-Button mit Progress und Icon
- [x] Stats-Anzeige (Artikel + Größe)
- [x] Preview mit MockFactory
- [x] Kompiliert ohne Fehler
---
### 4.3 SettingsContainerView Integration
**Datei**: `readeck/UI/Settings/SettingsContainerView.swift`
- [x] OfflineSettingsView direkt eingebettet (kein NavigationLink)
- [x] Nach SyncSettingsView platziert
- [x] Konsistent mit anderen Settings-Sections
**Checklist**:
- [x] OfflineSettingsView() hinzugefügt (Zeile 28)
- [x] Korrekte Platzierung in der Liste
- [x] Kompiliert ohne Fehler
---
### 4.4 Factory erweitern
**Dateien**: `readeck/UI/Factory/DefaultUseCaseFactory.swift` + `MockUseCaseFactory.swift`
- [x] Protocol `UseCaseFactory` erweitert:
- [x] `makeSettingsRepository() -> PSettingsRepository`
- [x] `makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase`
- [x] `DefaultUseCaseFactory` implementiert:
- [x] `offlineCacheRepository` als lazy property
- [x] `makeSettingsRepository()` gibt settingsRepository zurück
- [x] `makeOfflineCacheSyncUseCase()` erstellt UseCase mit 3 Dependencies
- [x] `MockUseCaseFactory` implementiert:
- [x] `MockSettingsRepository` mit allen Methoden
- [x] `MockOfflineCacheSyncUseCase` mit Publishers
**Checklist**:
- [x] Protocol erweitert (2 neue Methoden)
- [x] DefaultUseCaseFactory: beide Methoden implementiert
- [x] MockUseCaseFactory: Mock-Klassen erstellt
- [x] ViewModel nutzt Factory korrekt
- [x] Kompiliert ohne Fehler
- [ ] Test: ViewModel wird erstellt ohne Crash
---
### 4.5 MockUseCaseFactory erweitern (optional)
**Datei**: `readeck/UI/Factory/MockUseCaseFactory.swift`
- [x] Mock-Implementierungen für Tests hinzugefügt
**Checklist**:
- [x] MockSettingsRepository mit allen Protokoll-Methoden
- [x] MockOfflineCacheSyncUseCase mit Publishers
- [x] Kompiliert ohne Fehler
---
## Phase 5: App Integration (Tag 3-4)
### 5.1 AppViewModel erweitern
**Datei**: `readeck/UI/AppViewModel.swift`
- [ ] `onAppStart()` Methode um Sync erweitern:
- [ ] Nach checkServerReachability()
- [ ] `syncOfflineArticlesIfNeeded()` aufrufen (ohne await!)
- [ ] `syncOfflineArticlesIfNeeded()` implementieren (private):
- [ ] SettingsRepository instanziieren
- [ ] Task.detached(priority: .background) starten
- [ ] Settings laden
- [ ] If shouldSyncOnAppStart:
- [ ] Logger.sync.info("Auto-sync triggered")
- [ ] syncUseCase holen via Factory
- [ ] await syncOfflineArticles()
- [ ] Testen: Auto-Sync bei App-Start (4h-Check)
**Checklist**:
- [ ] onAppStart() erweitert
- [ ] syncOfflineArticlesIfNeeded() implementiert
- [ ] Task.detached mit .background
- [ ] Kein await vor syncOfflineArticlesIfNeeded()
- [ ] Logger.sync integriert
- [ ] Test: App startet, Sync läuft im Hintergrund
---
### 5.2 BookmarksViewModel erweitern
**Datei**: `readeck/UI/Bookmarks/BookmarksViewModel.swift`
- [ ] `loadCachedBookmarks()` implementieren (private):
- [ ] bookmarksRepository.getCachedBookmarks() aufrufen
- [ ] If nicht leer:
- [ ] BookmarksPage erstellen mit gecachten Bookmarks
- [ ] bookmarks Property setzen
- [ ] hasMoreData = false
- [ ] errorMessage beibehalten (für Banner)
- [ ] Logger.viewModel.info
- [ ] `loadBookmarks()` erweitern:
- [ ] Im Network-Error catch block:
- [ ] Nach isNetworkError = true
- [ ] await loadCachedBookmarks() aufrufen
- [ ] Testen: Bei Network-Error werden gecachte Bookmarks geladen
**Checklist**:
- [ ] loadCachedBookmarks() implementiert
- [ ] loadBookmarks() erweitert
- [ ] Logger.viewModel integriert
- [ ] Test: Offline-Modus zeigt gecachte Artikel
---
### 5.3 BookmarksView erweitern
**Datei**: `readeck/UI/Bookmarks/BookmarksView.swift`
- [ ] `offlineBanner` View hinzufügen (private):
- [ ] HStack mit wifi.slash Icon
- [ ] Text "Offline-Modus Zeige gespeicherte Artikel"
- [ ] Styling: caption, secondary, padding, background
- [ ] `body` anpassen:
- [ ] ZStack durch VStack(spacing: 0) ersetzen
- [ ] If isNetworkError && bookmarks nicht leer:
- [ ] offlineBanner anzeigen
- [ ] Content darunter
- [ ] FAB als Overlay über VStack
- [ ] `shouldShowCenteredState` anpassen:
- [ ] Kommentar: Nur bei leer UND error
- [ ] return isEmpty && hasError
- [ ] Testen: Offline-Banner erscheint bei Network-Error mit Daten
**Checklist**:
- [ ] offlineBanner View erstellt
- [ ] body mit VStack umgebaut
- [ ] shouldShowCenteredState angepasst
- [ ] Test: Banner wird angezeigt im Offline-Modus
---
### 5.4 BookmarkDetailViewModel erweitern
**Datei**: `readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift`
- [ ] `loadArticle()` erweitern:
- [ ] Vor Server-Request:
- [ ] If let cachedHTML = bookmarksRepository.getCachedArticle(id:)
- [ ] articleHTML = cachedHTML
- [ ] isLoading = false
- [ ] Logger.viewModel.info("Loaded from cache")
- [ ] return
- [ ] Nach Server-Request (im Task.detached):
- [ ] Artikel optional cachen wenn saveImages enabled
- [ ] Testen: Gecachte Artikel laden sofort
**Checklist**:
- [ ] Cache-Check vor Server-Request
- [ ] Logger.viewModel integriert
- [ ] Optional: Background-Caching nach Load
- [ ] Test: Gecachte Artikel laden instant
---
## Phase 6: Testing & Polish (Tag 4-5)
### 6.1 Unit Tests
- [ ] OfflineSettings Tests:
- [ ] shouldSyncOnAppStart bei erstem Mal
- [ ] shouldSyncOnAppStart nach 3h (false)
- [ ] shouldSyncOnAppStart nach 5h (true)
- [ ] shouldSyncOnAppStart bei disabled (false)
- [ ] SettingsRepository Tests:
- [ ] Save & Load roundtrip
- [ ] Default values bei leerem UserDefaults
- [ ] BookmarksRepository Cache Tests:
- [ ] cacheBookmarkWithMetadata()
- [ ] getCachedArticle()
- [ ] hasCachedArticle()
- [ ] cleanupOldestCachedArticles()
- [ ] extractImageURLsFromHTML()
**Checklist**:
- [ ] OfflineSettings Tests geschrieben
- [ ] SettingsRepository Tests geschrieben
- [ ] BookmarksRepository Tests geschrieben
- [ ] Alle Tests grün
---
### 6.2 Integration Tests
- [ ] App-Start Sync:
- [ ] Erste Start: Sync läuft
- [ ] Zweiter Start < 4h: Kein Sync
- [ ] Nach 4h: Sync läuft
- [ ] Manual Sync:
- [ ] Button triggert Sync
- [ ] Progress wird angezeigt
- [ ] Success-Message erscheint
- [ ] Offline-Modus:
- [ ] Flugmodus aktivieren
- [ ] Gecachte Bookmarks werden angezeigt
- [ ] Offline-Banner erscheint
- [ ] Artikel lassen sich öffnen
- [ ] Cache Management:
- [ ] 20 Artikel cachen
- [ ] Stats zeigen 20 Artikel + Größe
- [ ] Cleanup funktioniert bei Limit-Überschreitung
**Checklist**:
- [ ] App-Start Sync getestet
- [ ] Manual Sync getestet
- [ ] Offline-Modus getestet
- [ ] Cache Management getestet
---
### 6.3 Edge Cases
- [ ] Netzwerk-Verlust während Sync:
- [ ] Partial success wird geloggt
- [ ] Status-Message korrekt
- [ ] Speicher voll:
- [ ] Fehlerbehandlung
- [ ] User-Benachrichtigung
- [ ] 100 Artikel Performance:
- [ ] Sync dauert < 2 Minuten
- [ ] App bleibt responsiv
- [ ] CoreData Migration:
- [ ] Alte App-Version → Neue Version
- [ ] Keine Datenverluste
- [ ] Kingfisher Cache:
- [ ] Bilder werden geladen
- [ ] Cache-Limit wird respektiert
**Checklist**:
- [ ] Netzwerk-Verlust getestet
- [ ] Speicher voll getestet
- [ ] 100 Artikel Performance OK
- [ ] Migration getestet
- [ ] Kingfisher funktioniert
---
### 6.4 Bug-Fixing & Polish
- [ ] Alle gefundenen Bugs gefixt
- [ ] Code-Review durchgeführt
- [ ] Logging überprüft (nicht zu viel, nicht zu wenig)
- [ ] UI-Polish (Spacing, Colors, etc.)
- [ ] Performance-Optimierungen falls nötig
**Checklist**:
- [ ] Alle Bugs gefixt
- [ ] Code reviewed
- [ ] Logging optimiert
- [ ] UI poliert
- [ ] Performance OK
---
## Final Checklist
### Funktionalität
- [ ] Offline-Reading Toggle funktioniert
- [ ] Slider für Artikel-Anzahl funktioniert
- [ ] Bilder-Toggle funktioniert
- [ ] Auto-Sync bei App-Start (4h-Check)
- [ ] Manual-Sync Button funktioniert
- [ ] Offline-Modus zeigt gecachte Artikel
- [ ] Offline-Banner wird angezeigt
- [ ] Cache-Stats werden angezeigt
- [ ] Last-Sync-Date wird angezeigt
- [ ] Background-Sync mit niedriger Priority
- [ ] Kingfisher cached Bilder
- [ ] FIFO Cleanup funktioniert
### Code-Qualität
- [ ] Alle neuen Files erstellt
- [ ] Alle Protokolle definiert
- [ ] Alle Implementierungen vollständig
- [ ] Logging überall integriert
- [ ] Error-Handling implementiert
- [ ] Keine Compiler-Warnings
- [ ] Keine Force-Unwraps
- [ ] Code dokumentiert (Kommentare wo nötig)
### Tests
- [ ] Unit Tests geschrieben
- [ ] Integration Tests durchgeführt
- [ ] Edge Cases getestet
- [ ] Performance getestet
- [ ] Alle Tests grün
### Dokumentation
- [ ] Implementierungsplan vollständig
- [ ] Alle Checkboxen abgehakt
- [ ] Gefundene Issues dokumentiert
- [ ] Nächste Schritte (Stufe 2) überlegt
---
## Commit & PR
- [ ] Alle Änderungen commited
- [ ] Commit-Messages aussagekräftig
- [ ] Branch gepusht
- [ ] PR erstellt gegen `develop`
- [ ] PR-Beschreibung vollständig:
- [ ] Was wurde implementiert
- [ ] Wie testen
- [ ] Screenshots (Settings-UI)
- [ ] Known Issues (falls vorhanden)
---
## Notes & Issues
### Gefundene Probleme
_(Hier während der Implementation eintragen)_
### Offene Fragen
_(Hier während der Implementation eintragen)_
### Verbesserungsideen für Stufe 2
_(Hier sammeln)_
---
*Erstellt: 2025-11-01*
*Letztes Update: 2025-11-01*

File diff suppressed because it is too large Load Diff

304
docs/claude.md Normal file
View File

@ -0,0 +1,304 @@
# CLAUDE.md - readeck iOS Project Documentation
## Project Overview
**readeck iOS** is a native iOS client for [readeck](https://readeck.org) bookmark management. The app provides a clean, native iOS interface for managing bookmarks with features like swipe actions, search, tagging, and reading progress tracking.
### Key Information
- **Platform:** iOS (iPhone + iPad)
- **Language:** Swift
- **UI Framework:** SwiftUI
- **Architecture:** MVVM + Clean Architecture (3-layer: UI/Domain/Data)
- **Database:** CoreData
- **Dependencies:** Swift Package Manager
- **License:** MIT
## Architecture Summary
The project follows Clean Architecture with custom dependency injection:
```
UI Layer (SwiftUI Views + ViewModels)
Domain Layer (Use Cases + Repository Protocols + Models)
Data Layer (Repository Implementations + API + CoreData)
```
### Core Components
- **Custom DI:** Protocol-based factory pattern (no external frameworks)
- **MVVM Pattern:** ViewModels handle business logic, Views handle presentation
- **Use Cases:** Single-responsibility business logic encapsulation
- **Repository Pattern:** Data access abstraction with protocols
## Project Structure
```
readeck/
├── UI/ # SwiftUI Views & ViewModels
│ ├── Bookmarks/ # Main bookmark list
│ ├── BookmarkDetail/ # Article reader
│ ├── AddBookmark/ # Create new bookmarks
│ ├── Search/ # Search functionality
│ ├── Settings/ # App configuration
│ ├── Labels/ # Tag management
│ ├── Menu/ # Navigation & tabs
│ ├── SpeechPlayer/ # Text-to-speech
│ └── Components/ # Reusable UI components
├── Domain/
│ ├── Model/ # Core business models
│ ├── UseCase/ # Business logic
│ ├── Protocols/ # Repository interfaces
│ └── Error/ # Custom error types
├── Data/
│ ├── API/ # Network layer & DTOs
│ ├── Repository/ # Data access implementations
│ ├── CoreData/ # Local database
│ └── Utils/ # Helper utilities
└── Localizations/ # i18n strings
├── Base.lproj/
├── en.lproj/
└── de.lproj/
```
## Key Features
### Implemented Features
- ✅ Browse bookmarks (All, Unread, Favorites, Archive by type)
- ✅ Share Extension for adding URLs from Safari/other apps
- ✅ Swipe actions for quick bookmark management
- ✅ Native iOS design with Dark Mode support
- ✅ Full iPad Support with Multi-Column Split View
- ✅ Font customization in reader
- ✅ Article view with reading time and word count
- ✅ Search functionality
- ✅ Tag/label management
- ✅ Reading progress tracking
- ✅ Offline support with auto-sync when reconnected
- ✅ Text-to-speech (Read Aloud feature)
### Planned Features (v1.1.0)
- ⏳ Bookmark filtering and sorting options
- ⏳ Collection management
- ⏳ Custom themes
- ⏳ Text highlighting in articles
- ⏳ Multiple selection for bulk actions
## Development Setup
### Requirements
- Xcode 15.0+
- iOS 17.0+ deployment target
- Swift Package Manager (dependencies auto-resolved)
### Key Dependencies
- **netfox:** Network debugging (debug builds only)
- **RswiftLibrary:** Resource management
### Build Configurations
- **Debug:** Includes netfox for network debugging
- **Release:** Production-ready build
- **URLShare Extension:** Share extension target
## Localization (Weblate Integration)
### Current Setup
The project has been converted from Apple's String Catalog (.xcstrings) to traditional .strings format for Weblate compatibility:
```
readeck/Localizations/
├── Base.lproj/Localizable.strings # Source language (English)
├── en.lproj/Localizable.strings # English localization
└── de.lproj/Localizable.strings # German localization
```
### Weblate Configuration
When setting up Weblate:
- **File mask:** `readeck/Localizations/*.lproj/Localizable.strings`
- **Monolingual base:** `readeck/Localizations/Base.lproj/Localizable.strings`
- **File format:** "iOS Strings (UTF-8)"
- **Repository:** Connect to main Git repository
### Adding New Languages
1. Create new `.lproj` directory (e.g., `fr.lproj/`)
2. Copy `Base.lproj/Localizable.strings` to new directory
3. Weblate will automatically detect and manage translations
## App State Management & Navigation
### Setup Flow & Authentication
The app uses a sophisticated setup and authentication system:
**Initial Setup Detection:**
- `AppViewModel.hasFinishedSetup` controls the main app flow
- `readeckApp.swift:19` determines whether to show setup or main app
- Setup status is persisted via `SettingsRepository.hasFinishedSetup`
**Authentication & Keychain Management:**
- `KeychainHelper` (singleton) securely stores sensitive credentials:
- Server endpoint (`readeck_endpoint`)
- Username (`readeck_username`)
- Password (`readeck_password`)
- Authentication token (`readeck_token`)
- Access Group: `8J69P655GN.de.ilyashallak.readeck` for app group sharing
- Automatic logout on 401 responses via `AppViewModel.handleUnauthorizedResponse()`
**Device-Specific Navigation:**
The app automatically adapts its navigation structure based on device type:
```swift
// MainTabView.swift determines layout
if UIDevice.isPhone {
PhoneTabView() // Tab-based navigation
} else {
PadSidebarView() // Sidebar + split view navigation
}
```
**Navigation Patterns:**
- **iPhone:** `PhoneTabView` - Traditional tab bar with "More" tab for additional features
- **iPad:** `PadSidebarView` - NavigationSplitView with sidebar, content, and detail panes
- Both share the same underlying ViewModels and business logic
**Key Navigation Components:**
- `SidebarTab` enum defines all available sections
- Main tabs: `.all`, `.unread`, `.favorite`, `.archived`
- More tabs: `.search`, `.article`, `.videos`, `.pictures`, `.tags`, `.settings`
- Consistent routing through `tabView(for:)` methods in both variants
## Key Architectural Decisions
### 1. Custom Dependency Injection
- **Why:** Avoid external framework dependencies, full control
- **How:** Protocol-based factory pattern in `DefaultUseCaseFactory`
- **Benefit:** Easy testing with mock implementations
### 2. Repository Pattern
- **Domain Layer:** Defines protocols (e.g., `PBookmarksRepository`)
- **Data Layer:** Implements protocols (e.g., `BookmarksRepository`)
- **Benefit:** Clean separation between business logic and data access
### 3. Use Cases
- Single-responsibility classes for each business operation
- Examples: `CreateBookmarkUseCase`, `GetBookmarksUseCase`
- **Benefit:** Testable, reusable business logic
### 4. SwiftUI + MVVM
- ViewModels as `@ObservableObject` classes
- Views are pure presentation layer
- State management through ViewModels
## Testing Strategy
### Current Test Coverage
- **Unit Tests:** `readeckTests/` (basic coverage)
- **UI Tests:** `readeckUITests/` (smoke tests)
### Testing Philosophy
- Protocol-based DI enables easy mocking
- Use Cases can be tested in isolation
- Repository implementations tested with real/mock data sources
## Distribution
### TestFlight Beta
- Public beta available via TestFlight
- Link: `https://testflight.apple.com/join/cV55mKsR`
- Regular updates with new features
### Release Process
- Uses fastlane for build automation
- Automated screenshot generation
- Version management in Xcode project
## API Integration
### readeck Server API
- RESTful API communication
- DTOs in `Data/API/DTOs/`
- Authentication via username/password
- Token management with automatic refresh
- Support for local network addresses (development)
### Key API Operations
- User authentication
- Bookmark CRUD operations
- Tag/label management
- Article content fetching
- Progress tracking
## Offline Support
### Local Storage
- CoreData for offline bookmark storage
- Automatic sync when connection restored
- Queue system for offline operations
- Conflict resolution strategies
### Sync Strategy
- Background sync when app becomes active
- User-initiated sync option
- Visual indicators for sync status
## Performance Considerations
### Memory Management
- Lazy loading of bookmark content
- Image caching for article thumbnails
- Proper SwiftUI view lifecycle management
### Network Optimization
- Background download of article content
- Request batching where possible
- Retry logic for failed requests
## Contributing Guidelines
### Code Style
- Follow Swift API Design Guidelines
- Use SwiftUI best practices
- Maintain separation of concerns between layers
### New Feature Development
1. Define domain models in `Domain/Model/`
2. Create use case in `Domain/UseCase/`
3. Implement repository in `Data/Repository/`
4. Create ViewModel in appropriate UI folder
5. Build SwiftUI view
6. Update factory for DI wiring
### Git Workflow
- Main branch: `main`
- Development branch: `develop`
- Feature branches: `feature/feature-name`
- Commit format: `feat:`, `fix:`, `docs:`, etc.
## Troubleshooting
### Common Issues
1. **Build Errors:** Ensure Xcode 15.0+ and clean build folder
2. **Network Issues:** Check server URL and credentials
3. **CoreData Migrations:** May need to reset data during development
4. **Localization:** Ensure .strings files are properly formatted
### Development Tips
- Use netfox in debug builds to monitor API calls
- Check logging configuration in debug settings
- Test both iPhone and iPad layouts
- Verify share extension functionality
## Security Considerations
### Data Protection
- Keychain storage for user credentials
- No sensitive data in UserDefaults
- Secure network communication (HTTPS enforced for external domains)
### Privacy
- No analytics or tracking libraries
- Local data storage only
- User controls all data sync
---
*This documentation is maintained alongside the codebase. Update this file when making architectural changes or adding new features.*

45
docs/heavy_article.html Normal file
View File

@ -0,0 +1,45 @@
<section>
<div id="iK.lpVA.attachment_641518"><p><img decoding="async" src="https://keep.mnk.any64.de/bm/TH/THG5skZihVByEr5P5eTeBV/_resources/2wRXN15RYtCJ4JFbR8kMAy.jpg" alt="Sport vor der Arbeit fördert die Produktivität. (Foto: Jacob Lund / Shutterstock)" width="930" height="620" loading="lazy"/></p><p>Sport vor der Arbeit fördert die Produktivität. (Foto: <a href="http://www.shutterstock.com/gallery-163108p1.html" rel="nofollow noopener noreferrer">Jacob Lund</a> / Shutterstock)</p></div>
<p>Zu viele von uns sind zu sehr verplant, übermäßig vernetzt und übermäßig stimuliert von all dem Lärm, den Unterbrechungen und der Komplexität der heutigen Gesellschaft. Der Preis dieses Lebensstils? Du erreichst die letzte Stunden deines letzten Tages und merkst, dass du dein größtes Potenzial für deine sinnlosesten Aktivitäten vergeudet hast.</p>
<p>Herausragende Performer, Spitzenleute und Welt-Erschaffer spielen in einer anderen Liga. Die Elon Musks, Mark Zuckerbergs, große Künstler und Top-Wissenschaftler planen ihre Tage alle mit komplett anderer Geisteshaltung und Ritualen als jene, die im Hamsterrad gefangen sind.</p>
<p>Als privater Coach von vielen der erfolgreichsten Unternehmer auf diesem Planeten und Gründer von The Titan Summit, das jeden Dezember in Toronto Ultra-Performer von mehr als 43 Nationen vier Tage für ein Elite-Training für <a title="Weitere News zu Produktivität" href="https://t3n.de/tag/produktivitaet/" rel="nofollow noopener noreferrer">Produktivität</a>, Business-Acceleration und Lifestyle-Optimierung zusammenbringt, habe ich mit eigenen Augen beobachtet, wie Menschen, die in einer Woche mehr schaffen als die meisten in einem Vierteljahr, ihre Ergebnisse erzielen. Ich habe außerdem eine komplette Methodik für exponentiale Produktivität entwickelt, die ich den Teilnehmern bei diesem Event beibringe.</p>
<p>Hier sind vier der wegweisenden Elemente meines Ansatzes:</p>
<p id="iK.lpVA.1_Die_20/20/20Formel">
</p><h2>1. Die 20/20/20-Formel</h2>
<p></p>
<p>Die Art, wie du deinen Tag kraftvoll startest, bestimmt, wie produktiv du ihn gestaltest. Reserviere die ersten 60 Minuten für persönliche Vorbereitung. Wie die spartanischen Krieger sagten: „Schwitze mehr beim Trainieren, dann wirst du im Krieg weniger bluten.“ Verbringe deine ersten 20 Minuten mit intensivem Sport. Das Schwitzen setzt BDNF frei, einen Botenstoff, der tatsächlich neurale Verbindungen wachsen lässt. Ein Workout produziert auch Dopamin (der Neurotransmitter für Motivation) und Serotonin, welches gute Laune macht.</p>
<p>Schau dir im nächsten 20-Minuten-Slot deinen Jahresplan an und denk gründlich über deine Ziele für dieses Quartal nach. Klarheit geht der Meisterschaft voraus, und diese Übung wird den Tag über deine Konzentration verstärken.</p>
<p>Investiere die letzten 20 Minuten dieses Morgens für regelmäßiges Lernen. Lies zum Beispiel Autobiografien großer Persönlichkeiten, hör dir einen Podcast über das Führen an oder lade dir die Lektionen des Vortages in dein Journal herunter.</p>
<p id="iK.lpVA.2_Die_90/90/1Regel">
</p><h2>2. Die 90/90/1-Regel</h2>
<p></p>
<p>Allein diese Angewohnheit hat meinen Kunden einen gewaltigen Mehrwert verschafft. Kurz gesagt: Widme dich an den nächsten 90 Tagen die ersten 90 Minuten deines Arbeitstages deiner einzigen, wichtigsten Chance, der einen Sache, die, wenn du sie fehlerlos durchführst, alles aufgehen lässt.</p>
<p>Durchschnittliche Mitarbeiter kommen zur Arbeit und checken ihre Mails oder surfen im Netz. Für den echten Anführer ist die Ankunft im Büro der Beginn der Showtime. Sie begreifen, dass das Entwickeln einer Besessenheit für ihre entscheidenden paar Prioritäten legendäre Resultate freisetzt.</p>
<p id="iK.lpVA.3_Die_60/10Methode">
</p><h2>3. Die 60/10-Methode</h2>
<p></p>
<p>Gute Studien belegen, dass die besten Athleten der Welt das nicht dafür waren, was sie in ihrem Sport leisteten, sondern wie effektiv sie sich erholten. Es waren beispielsweise die Rituale, welche die Star-Tennisspieler zwischen ihren Punkten vollzogen, die sie zu Stars machten. Was die russischen Gewichtheber so unschlagbar machte, war ihr Verhältnis von Arbeit zu Erholungspausen.</p>
<p>Also, stell dir einen Wecker auf 60 Minuten und schalte in dieser Zeitspanne alle technischen Geräte aus, schließ deine Tür und tauche mit voller Wucht in ein Projekt ein, das wichtig ist. Erhol dich dann mit einer echten Pause wie Walking, Musikhören oder Lesen. Probiere diese Vorgehensweise mal für einen Monat aus und spüre den Nutzen.</p>
<p id="iK.lpVA.4_Finde_deinen_Kreis_von_Genies">
</p><h2>4. Finde deinen Kreis von Genies</h2>
<p></p>
<p>Verhaltensforscher haben das Phänomen des „emotionalen Ansteckungseffekts“ entdeckt. Dieses beschreibt, dass wir unbewusst die Glaubenssätze, Gefühle und Verhalten der Leute übernehmen, mit denen wir die meiste Zeit verbringen.</p>
<p>Du möchtest ultra-fit werden? Einer der besten Wege dahin ist es, dich einer Laufgruppe anzuschließen oder dich mit Athleten anzufreunden. Du möchtest glücklicher sein? Dann streich die Energieräuber und Meckerer aus deinem Leben. Du bist darauf aus, eine echte Weltklassefirma aufzubauen?  Dann fang an, viel mehr Zeit mit denen zu verbringen, die das bereits getan haben. Ihre Geisteshaltung und Lebensart wird dich mit der Zeit automatisch beeinflussen. Und mit Leuten Umgang zu haben, deren Leben du gern führen würdest, zeigt dir, was alles möglich ist. Und wenn du erstmal mehr weißt, kannst du auch mehr erreichen.</p>
<p>Schlussendlich möchte ich dich noch an die Endlichkeit des Lebens erinnern. Sogar das längste ist ein relativ kurzer Ritt. Du schuldest es dem Talent, mit dem du geboren wurdest, dem Team, das du führst, der Familie, die du liebst und der Welt, die danach verlangt, dass du deine Größe zeigst, um das zu Nötige zu tun, exponentielle Produktivität zu erlangen.</p>
<p>Hoffentlich nimmst du diese von mir geteilte Methodik an, um deinem Führungspotenzial gerecht zu werden.</p>
<p>Wie immer hoffe ich, dass diese Zeilen dir echten Mehrwert geliefert haben und dich in die Lage versetzen, den Rest dieses Jahres  „in der kostbaren Luft vollständiger Meisterschaft“ zu vollenden.</p>
<p>Deine Partner sind Führungsqualität und Erfolg,</p>
<p><em>Dieser Artikel erschien zuerst auf <a href="https://medium.com/swlh/the-methods-for-superhuman-productivity-de4452af7cfb" rel="nofollow noopener noreferrer">Medium.com</a>. Übersetzung: Anja Braun.</em></p>
<p><img width="1" height="1" alt="" loading="lazy"/></p>
</section>

66
docs/tabbar2.md Normal file
View File

@ -0,0 +1,66 @@
# Prompt für KI-Agent zur Überprüfung und Modernisierung einer bestehenden TabView mit NavigationStacks in SwiftUI (iOS 26)
---
## Ziel
Prüfe eine existierende SwiftUI-App mit bestehender `TabView` und `NavigationStacks` auf iOS 26-Konformität und korrigiere sie gegebenenfalls. Hauptpunkte:
- Die `TabView` soll mit moderner iOS 26 Tab-API aufgebaut sein, d.h. Tabs als eigenständige `Tab`-Views und KEIN `.tabItem` mehr verwenden.
- Jeder Tab soll eine eigene `NavigationStack` mit eigenem `NavigationPath` haben, um den Navigationszustand pro Tab unabhängig zu verwalten.
- Der Tab-Auswahl-Binding (`@State`) und `.tag()`-Zuweisungen müssen korrekt gesetzt sein.
- Der neue Such-Tab soll als `Tab(role: .search)` implementiert sein mit einem eigenen Suchfeld via `.searchable()`.
- Navigationstitel, Suchfunktion und Navigationslinks müssen in der jeweiligen NavigationStack-Umgebung eingebettet sein.
- Die TabBar soll beim Tiefennavigieren in einem Tab sichtbar bleiben, außer es gibt ein explizites Ausblenden.
- Eventuelle veraltete oder falsche Patterns wie `.tabItem` oder kombiniert verwendete `NavigationView` außerhalb der Stacks sollen korrigiert werden.
- Alle Subviews sollen modular organisiert sein und keine Globalzustände die Navigation verwalten.
---
## Prüffragen für den Agenten
1. Nutzt die `TabView` die neue Form mit `Tab` als Container pro Tab?
2. Werden für jeden Tab eigene `NavigationStack`s und `NavigationPath`s verwendet?
3. Sind `.tag()` und `selection` in `TabView` korrekt implementiert?
4. Ist der Such-Tab mit `Tab(role: .search)` sauber getrennt und die Suche mit `.searchable()` eingebunden?
5. Werden veraltete `.tabItem` Modifier vollständig entfernt?
6. Bleibt die TabBar sichtbar beim Navigieren in den Stacks, außer bewusst ausgeblendet?
7. Wird State sauber und lokal in den jeweiligen Views verwaltet?
8. Gibt es keine vermischten oder redundanten NavigationView/Stacks?
9. Werden Navigationsziele übersichtlich in Subviews ausgelagert?
10. Ist der gesamte Code idiomatisch und an die iOS 26 SwiftUI-Standards angepasst?
---
## Ausgabeformat
Der Agent soll die App prüfen, Fehler auflisten, Korrekturen vorschlagen und wenn möglich direkt umgesetzten SwiftUI-Code erzeugen, der:
- Komplette TabView mit Tabs als `Tab`
- Jeweils eigene NavigationStack mit NavigationPath
- Such-Tab mit `Tab(role: .search)` und suchbarer Navigation
- Keine `.tabItem` oder deprecated Patterns enthält
- Klar strukturiert und modular ist
---
## Beispiel-Ausschnitt zur Referenz
```swift
TabView(selection: $selectedTab) {Tab(“Home”, systemImage: “house”) {NavigationStack(path: $homePath) {HomeView()}}.tag(Tab.home)
Tab(role: .search) {
NavigationStack(path: $searchPath) {
SearchView()
.searchable(text: $searchText)
.navigationTitle("Search")
}
}
.tag(Tab.search)
// weitere Tabs...
}
```
---

View File

@ -452,7 +452,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -465,7 +465,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -485,7 +485,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -498,7 +498,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -640,7 +640,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -663,7 +663,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -684,7 +684,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -707,7 +707,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -155,16 +155,21 @@ class API: PAPI {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for \(endpoint)")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.error("Server error for \(endpoint): HTTP \(httpResponse.statusCode)")
logger.error("Response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
handleUnauthorizedResponse(httpResponse.statusCode)
throw APIError.serverError(httpResponse.statusCode)
}
// Als String dekodieren statt als JSON
guard let string = String(data: data, encoding: .utf8) else {
logger.error("Unable to decode response as UTF-8 string for \(endpoint)")
logger.error("Data size: \(data.count) bytes")
throw APIError.invalidResponse
}
@ -540,3 +545,16 @@ enum APIError: Error {
case invalidResponse
case serverError(Int)
}
extension APIError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid server response"
case .serverError(let statusCode):
return "Server error: HTTP \(statusCode)"
}
}
}

View File

@ -2,7 +2,7 @@
// InfoApiClient.swift
// readeck
//
// Created by Claude Code
// Created by Ilyas Hallak
import Foundation

View File

@ -76,6 +76,35 @@ class CoreDataManager {
}
}
#if DEBUG
func resetDatabase() throws {
logger.warning("⚠️ Resetting Core Data database - ALL DATA WILL BE DELETED")
guard let store = persistentContainer.persistentStoreCoordinator.persistentStores.first else {
throw NSError(domain: "CoreDataManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No persistent store found"])
}
guard let storeURL = store.url else {
throw NSError(domain: "CoreDataManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Store URL not found"])
}
// Remove the persistent store
try persistentContainer.persistentStoreCoordinator.remove(store)
// Delete the store files
try FileManager.default.removeItem(at: storeURL)
// Also delete related files (-wal, -shm)
let walURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")
let shmURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")
try? FileManager.default.removeItem(at: walURL)
try? FileManager.default.removeItem(at: shmURL)
logger.info("Core Data database files deleted successfully")
}
#endif
private func setupInMemoryStore(container: NSPersistentContainer) {
logger.warning("Setting up in-memory Core Data store as fallback")
isInMemoryStore = true

View File

@ -77,7 +77,61 @@ extension ResourceDto {
// MARK: - BookmarkEntity to Domain Mapping
extension BookmarkEntity {
func toDomain() -> Bookmark? {
guard let id = self.id,
let title = self.title,
let url = self.url,
let href = self.href,
let created = self.created,
let update = self.update,
let siteName = self.siteName,
let site = self.site else {
return nil
}
// Reconstruct hero image from cached URL for offline access
let heroImage: ImageResource? = self.heroImageURL.flatMap { urlString in
ImageResource(src: urlString, height: 0, width: 0)
}
let resources = BookmarkResources(
article: nil,
icon: nil,
image: heroImage,
log: nil,
props: nil,
thumbnail: nil
)
return Bookmark(
id: id,
title: title,
url: url,
href: href,
description: self.desc ?? "",
authors: self.authors?.components(separatedBy: ",") ?? [],
created: created,
published: self.published,
updated: update,
siteName: siteName,
site: site,
readingTime: Int(self.readingTime),
wordCount: Int(self.wordCount),
hasArticle: self.hasArticle,
isArchived: self.isArchived,
isDeleted: self.hasDeleted,
isMarked: self.isMarked,
labels: [],
lang: self.lang,
loaded: self.loaded,
readProgress: Int(self.readProgress),
documentType: self.documentType ?? "",
state: Int(self.state),
textDirection: self.textDirection ?? "",
type: self.type ?? "",
resources: resources
)
}
}
// MARK: - Domain to BookmarkEntity Mapping
@ -128,6 +182,13 @@ private extension BookmarkEntity {
self.textDirection = bookmark.textDirection
self.type = bookmark.type
self.state = Int16(bookmark.state)
// Save hero image URL for offline access
if let heroImageUrl = bookmark.resources.image?.src {
self.heroImageURL = heroImageUrl
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
self.heroImageURL = thumbnailUrl
}
}
}

View File

@ -0,0 +1,12 @@
//
// DtoMapper.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
extension AnnotationDto {
func toDomain() -> Annotation {
Annotation(id: id, text: text, created: created, startOffset: startOffset, endOffset: endOffset, startSelector: startSelector, endSelector: endSelector)
}
}

View File

@ -1,12 +1,18 @@
import Foundation
class AnnotationsRepository: PAnnotationsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation {
try await api.createAnnotation(bookmarkId: bookmarkId, color: color, startOffset: startOffset, endOffset: endOffset, startSelector: startSelector, endSelector: endSelector)
.toDomain()
}
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
return annotationDtos.map { dto in
@ -25,4 +31,6 @@ class AnnotationsRepository: PAnnotationsRepository {
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -0,0 +1,110 @@
//
// NetworkMonitorRepository.swift
// readeck
//
// Created by Ilyas Hallak on 18.11.25.
//
import Foundation
import Network
import Combine
// MARK: - Protocol
protocol PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorRepository: PNetworkMonitorRepository {
// MARK: - Properties
private let monitor: NWPathMonitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.readeck.networkmonitor")
private let _isConnectedSubject: CurrentValueSubject<Bool, Never>
private var hasPathConnection = true
private var hasRealConnection = true
var isConnected: AnyPublisher<Bool, Never> {
_isConnectedSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init() {
// Check current network status synchronously before starting monitor
let currentPath = monitor.currentPath
let hasInterfaces = currentPath.availableInterfaces.count > 0
let initialStatus = currentPath.status == .satisfied && hasInterfaces
_isConnectedSubject = CurrentValueSubject<Bool, Never>(initialStatus)
hasPathConnection = initialStatus
Logger.network.info("🌐 Initial network status: \(initialStatus ? "Connected" : "Offline")")
}
deinit {
monitor.cancel()
}
// MARK: - Public Methods
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
// More sophisticated check: path must be satisfied AND have actual interfaces
let hasInterfaces = path.availableInterfaces.count > 0
let isConnected = path.status == .satisfied && hasInterfaces
self.hasPathConnection = isConnected
self.updateConnectionState()
// Log network changes with details
if path.status == .satisfied {
if hasInterfaces {
Logger.network.info("📡 Network path available (interfaces: \(path.availableInterfaces.count))")
} else {
Logger.network.warning("⚠️ Network path satisfied but no interfaces (VPN?)")
}
} else {
Logger.network.warning("📡 Network path unavailable")
}
}
monitor.start(queue: queue)
Logger.network.debug("Network monitoring started")
}
func stopMonitoring() {
monitor.cancel()
Logger.network.debug("Network monitoring stopped")
}
func reportConnectionFailure() {
hasRealConnection = false
updateConnectionState()
Logger.network.warning("⚠️ Real connection failure reported (VPN/unreachable server)")
}
func reportConnectionSuccess() {
hasRealConnection = true
updateConnectionState()
Logger.network.info("✅ Real connection success reported")
}
private func updateConnectionState() {
// Only connected if BOTH path is available AND real connection works
let isConnected = hasPathConnection && hasRealConnection
DispatchQueue.main.async {
self._isConnectedSubject.send(isConnected)
}
}
}

View File

@ -0,0 +1,558 @@
//
// OfflineCacheRepository.swift
// readeck
//
// Created by Ilyas Hallak on 17.11.25.
//
import Foundation
import CoreData
import Kingfisher
class OfflineCacheRepository: POfflineCacheRepository {
// MARK: - Dependencies
private let coreDataManager = CoreDataManager.shared
private let logger = Logger.sync
// MARK: - Cache Operations
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
if hasCachedArticle(id: bookmark.id) {
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
return
}
// First prefetch images into Kingfisher cache
if saveImages {
var imageURLs = extractImageURLsFromHTML(html: html)
// Add hero/thumbnail image if available and cache it with custom key
if let heroImageUrl = bookmark.resources.image?.src {
imageURLs.insert(heroImageUrl, at: 0)
logger.debug("Added hero image: \(heroImageUrl)")
// Cache hero image with custom key for offline access
if let heroURL = URL(string: heroImageUrl) {
await cacheHeroImage(url: heroURL, bookmarkId: bookmark.id)
}
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
imageURLs.insert(thumbnailUrl, at: 0)
logger.debug("Added thumbnail image: \(thumbnailUrl)")
// Cache thumbnail with custom key
if let thumbURL = URL(string: thumbnailUrl) {
await cacheHeroImage(url: thumbURL, bookmarkId: bookmark.id)
}
}
let urls = imageURLs.compactMap { URL(string: $0) }
await prefetchImagesWithKingfisher(imageURLs: urls)
}
// Then embed images as Base64 in HTML
let processedHTML = saveImages ? await embedImagesAsBase64(html: html) : html
// Save bookmark with embedded images
try await saveBookmarkToCache(bookmark: bookmark, html: processedHTML, saveImages: saveImages)
}
func getCachedArticle(id: String) -> String? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", id)
fetchRequest.fetchLimit = 1
do {
let results = try coreDataManager.context.fetch(fetchRequest)
if let entity = results.first {
// Update last access date
entity.lastAccessDate = Date()
coreDataManager.save()
logger.debug("Retrieved cached article for bookmark \(id)")
return entity.htmlContent
}
} catch {
logger.error("Error fetching cached article: \(error.localizedDescription)")
}
return nil
}
func hasCachedArticle(id: String) -> Bool {
return getCachedArticle(id: id) != nil
}
func getCachedBookmarks() async throws -> [Bookmark] {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]
let context = coreDataManager.context
return try await context.perform {
// First check total bookmarks
let allRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
let totalCount = try? context.count(for: allRequest)
self.logger.info("📊 Total bookmarks in Core Data: \(totalCount ?? 0)")
let entities = try context.fetch(fetchRequest)
self.logger.info("📊 getCachedBookmarks: Found \(entities.count) bookmarks with htmlContent != nil")
if entities.count > 0 {
// Log details of first cached bookmark
if let first = entities.first {
self.logger.info(" First cached: id=\(first.id ?? "nil"), title=\(first.title ?? "nil"), cachedDate=\(first.cachedDate?.description ?? "nil")")
}
}
// Convert entities to Bookmark domain objects using mapper
let bookmarks = entities.compactMap { $0.toDomain() }
self.logger.info("📊 Successfully mapped \(bookmarks.count) bookmarks to domain objects")
return bookmarks
}
}
// MARK: - Cache Statistics
func getCachedArticlesCount() -> Int {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let count = try coreDataManager.context.count(for: fetchRequest)
return count
} catch {
logger.error("Error counting cached articles: \(error.localizedDescription)")
return 0
}
}
func getCacheSize() -> String {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let entities = try coreDataManager.context.fetch(fetchRequest)
let totalBytes = entities.reduce(0) { $0 + $1.cacheSize }
return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
} catch {
logger.error("Error calculating cache size: \(error.localizedDescription)")
return "0 KB"
}
}
// MARK: - Cache Management
func clearCache() async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
let context = coreDataManager.context
// Collect image URLs before clearing
let imageURLsToDelete = try await context.perform {
let entities = try context.fetch(fetchRequest)
return entities.compactMap { entity -> [URL]? in
guard let imageURLsString = entity.imageURLs else { return nil }
return imageURLsString
.split(separator: ",")
.compactMap { URL(string: String($0)) }
}.flatMap { $0 }
}
// Clear Core Data cache
try await context.perform { [weak self] in
guard let self = self else { return }
let entities = try context.fetch(fetchRequest)
for entity in entities {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleared cache for \(entities.count) articles")
}
// Clear Kingfisher cache for these images
logger.info("Clearing Kingfisher cache for \(imageURLsToDelete.count) images")
await withTaskGroup(of: Void.self) { group in
for url in imageURLsToDelete {
group.addTask {
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
}
}
}
logger.info("✅ Kingfisher cache cleared for offline images")
}
func cleanupOldestCachedArticles(keepCount: Int) async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
let context = coreDataManager.context
// 1. Collect image URLs from articles that will be deleted
let imageURLsToDelete = try await context.perform {
let allEntities = try context.fetch(fetchRequest)
if allEntities.count > keepCount {
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
return entitiesToDelete.compactMap { entity -> [URL]? in
guard let imageURLsString = entity.imageURLs else { return nil }
return imageURLsString
.split(separator: ",")
.compactMap { URL(string: String($0)) }
}.flatMap { $0 }
}
return []
}
// 2. Clear Core Data cache
try await context.perform { [weak self] in
guard let self = self else { return }
let allEntities = try context.fetch(fetchRequest)
// Delete oldest articles if we exceed keepCount
if allEntities.count > keepCount {
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
for entity in entitiesToDelete {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
}
}
// 3. Clear Kingfisher cache for deleted images
if !imageURLsToDelete.isEmpty {
logger.info("Clearing Kingfisher cache for \(imageURLsToDelete.count) images from cleanup")
await withTaskGroup(of: Void.self) { group in
for url in imageURLsToDelete {
group.addTask {
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
}
}
}
}
}
// MARK: - Private Helper Methods
private func saveBookmarkToCache(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let entity = try self.findOrCreateEntity(for: bookmark.id, in: context)
bookmark.updateEntity(entity)
self.updateEntityWithCacheData(entity: entity, bookmark: bookmark, html: html, saveImages: saveImages)
try context.save()
self.logger.info("💾 Saved bookmark \(bookmark.id) to Core Data with HTML (\(html.utf8.count) bytes)")
// Verify it was saved
let verifyRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
verifyRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", bookmark.id)
if let count = try? context.count(for: verifyRequest) {
self.logger.info("✅ Verification: \(count) bookmark(s) with id '\(bookmark.id)' found in Core Data after save")
}
}
}
private func findOrCreateEntity(for bookmarkId: String, in context: NSManagedObjectContext) throws -> BookmarkEntity {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", bookmarkId)
fetchRequest.fetchLimit = 1
let existingEntities = try context.fetch(fetchRequest)
return existingEntities.first ?? BookmarkEntity(context: context)
}
private func updateEntityWithCacheData(entity: BookmarkEntity, bookmark: Bookmark, html: String, saveImages: Bool) {
entity.htmlContent = html
entity.cachedDate = Date()
entity.lastAccessDate = Date()
entity.cacheSize = Int64(html.utf8.count)
// Note: imageURLs are now embedded in HTML as Base64, so we don't store them separately
// We still track hero/thumbnail URLs for cleanup purposes
if saveImages {
var imageURLs: [String] = []
// Add hero/thumbnail image if available
if let heroImageUrl = bookmark.resources.image?.src {
imageURLs.append(heroImageUrl)
logger.debug("Tracking hero image for cleanup: \(heroImageUrl)")
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
imageURLs.append(thumbnailUrl)
logger.debug("Tracking thumbnail image for cleanup: \(thumbnailUrl)")
}
if !imageURLs.isEmpty {
entity.imageURLs = imageURLs.joined(separator: ",")
}
}
}
private func extractImageURLsFromHTML(html: String) -> [String] {
var imageURLs: [String] = []
// Simple regex pattern for img tags
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String? {
// Only include absolute URLs (http/https)
if url.hasPrefix("http") {
imageURLs.append(url)
}
}
}
}
}
logger.debug("Extracted \(imageURLs.count) image URLs from HTML")
return imageURLs
}
private func embedImagesAsBase64(html: String) async -> String {
logger.info("🔄 Starting Base64 image embedding for offline HTML")
var modifiedHTML = html
let imageURLs = extractImageURLsFromHTML(html: html)
logger.info("📊 Found \(imageURLs.count) images to embed")
var successCount = 0
var failedCount = 0
for (index, imageURL) in imageURLs.enumerated() {
logger.debug("Processing image \(index + 1)/\(imageURLs.count): \(imageURL)")
guard let url = URL(string: imageURL) else {
logger.warning("❌ Invalid URL: \(imageURL)")
failedCount += 1
continue
}
// Try to get image from Kingfisher cache
let result = await withCheckedContinuation { (continuation: CheckedContinuation<KFCrossPlatformImage?, Never>) in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
if let image = cacheResult.image {
continuation.resume(returning: image)
} else {
continuation.resume(returning: nil)
}
case .failure(let error):
print("❌ Kingfisher cache retrieval error: \(error)")
continuation.resume(returning: nil)
}
}
}
guard let image = result else {
logger.warning("❌ Image not found in Kingfisher cache: \(imageURL)")
logger.warning(" Cache key: \(url.cacheKey)")
failedCount += 1
continue
}
// Convert image to Base64
guard let imageData = image.jpegData(compressionQuality: 0.85) else {
logger.warning("❌ Failed to convert image to JPEG: \(imageURL)")
failedCount += 1
continue
}
let base64String = imageData.base64EncodedString()
let dataURI = "data:image/jpeg;base64,\(base64String)"
// Replace URL with Base64 data URI
let beforeLength = modifiedHTML.count
modifiedHTML = modifiedHTML.replacingOccurrences(of: imageURL, with: dataURI)
let afterLength = modifiedHTML.count
if afterLength > beforeLength {
logger.debug("✅ Embedded image \(index + 1) as Base64: \(imageURL)")
logger.debug(" Size: \(imageData.count) bytes, Base64: \(base64String.count) chars")
logger.debug(" HTML grew by: \(afterLength - beforeLength) chars")
successCount += 1
} else {
logger.warning("⚠️ Image URL found but not replaced in HTML: \(imageURL)")
failedCount += 1
}
}
logger.info("✅ Base64 embedding complete: \(successCount) succeeded, \(failedCount) failed out of \(imageURLs.count) images")
logger.info("📈 HTML size: \(html.utf8.count)\(modifiedHTML.utf8.count) bytes (growth: \(modifiedHTML.utf8.count - html.utf8.count) bytes)")
return modifiedHTML
}
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
guard !imageURLs.isEmpty else { return }
logger.info("🔄 Starting Kingfisher prefetch for \(imageURLs.count) images")
// Log all URLs that will be prefetched
for (index, url) in imageURLs.enumerated() {
logger.debug("[\(index + 1)/\(imageURLs.count)] Prefetching: \(url.absoluteString)")
logger.debug(" Cache key: \(url.cacheKey)")
}
// Configure Kingfisher options for offline caching
let options: KingfisherOptionsInfo = [
.cacheOriginalImage,
.diskCacheExpiration(.never), // Keep images as long as article is cached
.backgroundDecode,
]
// Use Kingfisher's prefetcher with offline-friendly options
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let prefetcher = ImagePrefetcher(
urls: imageURLs,
options: options,
progressBlock: { [weak self] skippedResources, failedResources, completedResources in
let progress = completedResources.count + failedResources.count + skippedResources.count
self?.logger.debug("Prefetch progress: \(progress)/\(imageURLs.count)")
// Log failures immediately as they happen
if !failedResources.isEmpty {
for failure in failedResources {
self?.logger.error("❌ Image prefetch failed: \(failure.downloadURL.absoluteString)")
}
}
},
completionHandler: { [weak self] skippedResources, failedResources, completedResources in
self?.logger.info("✅ Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
if !failedResources.isEmpty {
self?.logger.warning("❌ Failed to cache \(failedResources.count) images:")
for resource in failedResources {
self?.logger.warning(" - \(resource.downloadURL.absoluteString)")
}
}
if !skippedResources.isEmpty {
self?.logger.info("⏭️ Skipped \(skippedResources.count) images (already cached):")
for resource in skippedResources {
self?.logger.debug(" - \(resource.downloadURL.absoluteString)")
}
}
// Verify cache after prefetch
Task { [weak self] in
await self?.verifyPrefetchedImages(imageURLs)
continuation.resume()
}
}
)
prefetcher.start()
}
}
private func verifyPrefetchedImages(_ imageURLs: [URL]) async {
logger.info("🔍 Verifying prefetched images in cache...")
var cachedCount = 0
var missingCount = 0
for url in imageURLs {
let isCached = await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
if isCached {
cachedCount += 1
logger.debug("✅ Verified in cache: \(url.absoluteString)")
} else {
missingCount += 1
logger.warning("❌ NOT in cache after prefetch: \(url.absoluteString)")
}
}
logger.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(imageURLs.count) total")
}
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
fetchRequest.fetchLimit = 1
let context = coreDataManager.context
return try await context.perform {
let results = try context.fetch(fetchRequest)
return results.first
}
}
/// Caches hero/thumbnail image with a custom key for offline retrieval
private func cacheHeroImage(url: URL, bookmarkId: String) async {
let cacheKey = "bookmark-\(bookmarkId)-hero"
logger.debug("Caching hero image with key: \(cacheKey)")
// First check if already cached with custom key
let isAlreadyCached = await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
if isAlreadyCached {
logger.debug("Hero image already cached with key: \(cacheKey)")
return
}
// Download and cache image with custom key
let result = await withCheckedContinuation { (continuation: CheckedContinuation<KFCrossPlatformImage?, Never>) in
KingfisherManager.shared.retrieveImage(with: url) { result in
switch result {
case .success(let imageResult):
continuation.resume(returning: imageResult.image)
case .failure(let error):
self.logger.error("Failed to download hero image: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
if let image = result {
// Store with custom key for offline access
try? await ImageCache.default.store(image, forKey: cacheKey)
logger.info("✅ Cached hero image with key: \(cacheKey)")
} else {
logger.warning("❌ Failed to cache hero image for bookmark: \(bookmarkId)")
}
}
}

View File

@ -2,7 +2,7 @@
// ServerInfoRepository.swift
// readeck
//
// Created by Claude Code
// Created by Ilyas Hallak
import Foundation

View File

@ -1,21 +1,6 @@
import Foundation
import CoreData
protocol PSettingsRepository {
func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings?
func clearSettings() async throws
func saveToken(_ token: String) async throws
func saveUsername(_ username: String) async throws
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get }
}
import Kingfisher
class SettingsRepository: PSettingsRepository {
private let coreDataManager = CoreDataManager.shared
@ -286,4 +271,106 @@ class SettingsRepository: PSettingsRepository {
}
}
}
// MARK: - Offline Settings
private let logger = Logger.data
func loadOfflineSettings() async throws -> OfflineSettings {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let settings = OfflineSettings(
enabled: settingEntity?.offlineEnabled ?? false,
maxUnreadArticles: settingEntity?.offlineMaxUnreadArticles ?? 20,
saveImages: settingEntity?.offlineSaveImages ?? true,
lastSyncDate: settingEntity?.offlineLastSyncDate
)
self.logger.debug("Loaded offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)")
continuation.resume(returning: settings)
} catch {
self.logger.error("Failed to load offline settings: \(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
func saveOfflineSettings(_ settings: OfflineSettings) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.offlineEnabled = settings.enabled
existingSettings.offlineMaxUnreadArticles = settings.maxUnreadArticles
existingSettings.offlineSaveImages = settings.saveImages
existingSettings.offlineLastSyncDate = settings.lastSyncDate
try context.save()
self.logger.info("Saved offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)")
continuation.resume()
} catch {
self.logger.error("Failed to save offline settings: \(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
// MARK: - Cache Settings
private let maxCacheSizeKey = "KingfisherMaxCacheSize"
func getCacheSize() async throws -> UInt {
return try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
switch result {
case .success(let size):
continuation.resume(returning: size)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func getMaxCacheSize() async throws -> UInt {
if let savedSize = userDefault.object(forKey: maxCacheSizeKey) as? UInt {
return savedSize
} else {
// Default: 200 MB
let defaultBytes = UInt(200 * 1024 * 1024)
userDefault.set(defaultBytes, forKey: maxCacheSizeKey)
return defaultBytes
}
}
func updateMaxCacheSize(_ sizeInBytes: UInt) async throws {
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = sizeInBytes
userDefault.set(sizeInBytes, forKey: maxCacheSizeKey)
logger.info("Updated max cache size to \(sizeInBytes) bytes")
}
func clearCache() async throws {
return try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.cache.clearDiskCache {
KingfisherManager.shared.cache.clearMemoryCache()
self.logger.info("Cache cleared successfully")
continuation.resume()
}
}
}
}

View File

@ -0,0 +1,107 @@
//
// HTMLImageEmbedder.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
import Kingfisher
/// Utility for embedding images as Base64 data URIs in HTML
struct HTMLImageEmbedder {
private let imageExtractor = HTMLImageExtractor()
/// Embeds all images in HTML as Base64 data URIs for offline viewing
/// - Parameter html: The HTML string containing image tags
/// - Returns: Modified HTML with images embedded as Base64
func embedBase64Images(in html: String) async -> String {
Logger.sync.info("🔄 Starting Base64 image embedding for offline HTML")
var modifiedHTML = html
let imageURLs = imageExtractor.extract(from: html)
Logger.sync.info("📊 Found \(imageURLs.count) images to embed")
var stats = EmbedStatistics()
for (index, imageURL) in imageURLs.enumerated() {
Logger.sync.debug("Processing image \(index + 1)/\(imageURLs.count): \(imageURL)")
guard let url = URL(string: imageURL) else {
Logger.sync.warning("❌ Invalid URL: \(imageURL)")
stats.failedCount += 1
continue
}
// Try to get image from Kingfisher cache
guard let image = await retrieveImageFromCache(url: url) else {
Logger.sync.warning("❌ Image not found in cache: \(imageURL)")
stats.failedCount += 1
continue
}
// Convert to Base64 and embed
if let base64DataURI = convertToBase64DataURI(image: image) {
let beforeLength = modifiedHTML.count
modifiedHTML = modifiedHTML.replacingOccurrences(of: imageURL, with: base64DataURI)
let afterLength = modifiedHTML.count
if afterLength > beforeLength {
Logger.sync.debug("✅ Embedded image \(index + 1) as Base64")
stats.successCount += 1
} else {
Logger.sync.warning("⚠️ Image URL found but not replaced in HTML: \(imageURL)")
stats.failedCount += 1
}
} else {
Logger.sync.warning("❌ Failed to convert image to Base64: \(imageURL)")
stats.failedCount += 1
}
}
logEmbedResults(stats: stats, originalSize: html.utf8.count, finalSize: modifiedHTML.utf8.count)
return modifiedHTML
}
// MARK: - Private Helper Methods
private func retrieveImageFromCache(url: URL) async -> KFCrossPlatformImage? {
await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure(let error):
Logger.sync.error("❌ Kingfisher cache retrieval error: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
}
private func convertToBase64DataURI(image: KFCrossPlatformImage) -> String? {
guard let imageData = image.jpegData(compressionQuality: 0.85) else {
return nil
}
let base64String = imageData.base64EncodedString()
return "data:image/jpeg;base64,\(base64String)"
}
private func logEmbedResults(stats: EmbedStatistics, originalSize: Int, finalSize: Int) {
let total = stats.successCount + stats.failedCount
let growth = finalSize - originalSize
Logger.sync.info("✅ Base64 embedding complete: \(stats.successCount) succeeded, \(stats.failedCount) failed out of \(total) images")
Logger.sync.info("📈 HTML size: \(originalSize)\(finalSize) bytes (growth: \(growth) bytes)")
}
}
// MARK: - Helper Types
private struct EmbedStatistics {
var successCount = 0
var failedCount = 0
}

View File

@ -0,0 +1,63 @@
//
// HTMLImageExtractor.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
/// Utility for extracting image URLs from HTML content
struct HTMLImageExtractor {
/// Extracts all image URLs from HTML using regex
/// - Parameter html: The HTML string to parse
/// - Returns: Array of absolute image URLs (http/https only)
func extract(from html: String) -> [String] {
var imageURLs: [String] = []
// Simple regex pattern for img tags
let pattern = #"<img[^>]+src="([^"]+)""#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return imageURLs
}
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String?,
url.hasPrefix("http") { // Only include absolute URLs
imageURLs.append(url)
}
}
}
Logger.sync.debug("Extracted \(imageURLs.count) image URLs from HTML")
return imageURLs
}
/// Extracts image URLs from HTML and optionally prepends hero/thumbnail image
/// - Parameters:
/// - html: The HTML string to parse
/// - heroImageURL: Optional hero image URL to prepend
/// - thumbnailURL: Optional thumbnail URL to prepend if no hero image
/// - Returns: Array of image URLs with hero/thumbnail first if provided
func extract(from html: String, heroImageURL: String? = nil, thumbnailURL: String? = nil) -> [String] {
var imageURLs = extract(from: html)
// Prepend hero or thumbnail image if available
if let heroURL = heroImageURL {
imageURLs.insert(heroURL, at: 0)
Logger.sync.debug("Added hero image: \(heroURL)")
} else if let thumbURL = thumbnailURL {
imageURLs.insert(thumbURL, at: 0)
Logger.sync.debug("Added thumbnail image: \(thumbURL)")
}
return imageURLs
}
}

View File

@ -0,0 +1,192 @@
//
// KingfisherImagePrefetcher.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
import Kingfisher
/// Wrapper around Kingfisher for prefetching and caching images for offline use
class KingfisherImagePrefetcher {
// MARK: - Public Methods
/// Prefetches images and stores them in Kingfisher cache for offline access
/// - Parameter urls: Array of image URLs to prefetch
func prefetchImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("🔄 Starting Kingfisher prefetch for \(urls.count) images")
logPrefetchURLs(urls)
let options = buildOfflineCachingOptions()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let prefetcher = ImagePrefetcher(
urls: urls,
options: options,
progressBlock: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchProgress(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
},
completionHandler: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchCompletion(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
// Verify cache after prefetch
Task {
await self?.verifyPrefetchedImages(urls)
continuation.resume()
}
}
)
prefetcher.start()
}
}
/// Caches an image with a custom key for offline retrieval
/// - Parameters:
/// - url: The image URL to download
/// - key: Custom cache key
func cacheImageWithCustomKey(url: URL, key: String) async {
Logger.sync.debug("Caching image with custom key: \(key)")
// Check if already cached
if await isImageCached(forKey: key) {
Logger.sync.debug("Image already cached with key: \(key)")
return
}
// Download and cache with custom key
let image = await downloadImage(from: url)
if let image = image {
try? await ImageCache.default.store(image, forKey: key)
Logger.sync.info("✅ Cached image with custom key: \(key)")
} else {
Logger.sync.warning("❌ Failed to cache image with key: \(key)")
}
}
/// Clears cached images from Kingfisher cache
/// - Parameter urls: Array of image URLs to clear
func clearCachedImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("Clearing Kingfisher cache for \(urls.count) images")
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
}
}
}
Logger.sync.info("✅ Kingfisher cache cleared for \(urls.count) images")
}
/// Verifies that images are present in cache
/// - Parameter urls: Array of URLs to verify
func verifyPrefetchedImages(_ urls: [URL]) async {
Logger.sync.info("🔍 Verifying prefetched images in cache...")
var cachedCount = 0
var missingCount = 0
for url in urls {
let isCached = await isImageCached(forKey: url.cacheKey)
if isCached {
cachedCount += 1
Logger.sync.debug("✅ Verified in cache: \(url.absoluteString)")
} else {
missingCount += 1
Logger.sync.warning("❌ NOT in cache after prefetch: \(url.absoluteString)")
}
}
Logger.sync.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(urls.count) total")
}
// MARK: - Private Helper Methods
private func buildOfflineCachingOptions() -> KingfisherOptionsInfo {
[
.cacheOriginalImage,
.diskCacheExpiration(.never), // Keep images as long as article is cached
.backgroundDecode,
]
}
private func logPrefetchURLs(_ urls: [URL]) {
for (index, url) in urls.enumerated() {
Logger.sync.debug("[\(index + 1)/\(urls.count)] Prefetching: \(url.absoluteString)")
Logger.sync.debug(" Cache key: \(url.cacheKey)")
}
}
private func logPrefetchProgress(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
let progress = completed + failed + skipped
Logger.sync.debug("Prefetch progress: \(progress)/\(total) - completed: \(completed), failed: \(failed), skipped: \(skipped)")
}
private func logPrefetchCompletion(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
Logger.sync.info("✅ Prefetch completed: \(completed)/\(total) images cached")
if failed > 0 {
Logger.sync.warning("❌ Failed to cache \(failed) images")
}
if skipped > 0 {
Logger.sync.info("⏭️ Skipped \(skipped) images (already cached)")
}
}
private func isImageCached(forKey key: String) async -> Bool {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
private func downloadImage(from url: URL) async -> KFCrossPlatformImage? {
await withCheckedContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: url) { result in
switch result {
case .success(let imageResult):
continuation.resume(returning: imageResult.image)
case .failure(let error):
Logger.sync.error("Failed to download image: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
}
}

View File

@ -0,0 +1,30 @@
//
// OfflineSettings.swift
// readeck
//
// Created by Ilyas Hallak on 08.11.25.
//
import Foundation
struct OfflineSettings: Codable {
var enabled: Bool = false
var maxUnreadArticles: Double = 20
var saveImages: Bool = false
var lastSyncDate: Date?
var maxUnreadArticlesInt: Int {
Int(maxUnreadArticles)
}
var shouldSyncOnAppStart: Bool {
guard enabled else { return false }
// Sync if never synced before
guard let lastSync = lastSyncDate else { return true }
// Sync if more than 4 hours since last sync
let fourHoursAgo = Date().addingTimeInterval(-4 * 60 * 60)
return lastSync < fourHoursAgo
}
}

View File

@ -1,4 +1,5 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation
}

View File

@ -6,6 +6,7 @@
//
protocol PBookmarksRepository {
// Bookmark API methods
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String

View File

@ -0,0 +1,24 @@
//
// POfflineCacheRepository.swift
// readeck
//
// Created by Ilyas Hallak on 17.11.25.
//
import Foundation
protocol POfflineCacheRepository {
// Cache operations
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
func hasCachedArticle(id: String) -> Bool
func getCachedArticle(id: String) -> String?
func getCachedBookmarks() async throws -> [Bookmark]
// Cache statistics
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
// Cache management
func clearCache() async throws
func cleanupOldestCachedArticles(keepCount: Int) async throws
}

View File

@ -2,7 +2,7 @@
// PServerInfoRepository.swift
// readeck
//
// Created by Claude Code
// Created by Ilyas Hallak
protocol PServerInfoRepository {
func checkServerReachability() async -> Bool

View File

@ -0,0 +1,35 @@
//
// PSettingsRepository.swift
// readeck
//
// Created by Ilyas Hallak on 08.11.25.
//
import Foundation
protocol PSettingsRepository {
// Existing Settings methods
func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings?
func clearSettings() async throws
func saveToken(_ token: String) async throws
func saveUsername(_ username: String) async throws
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get }
// Offline Settings methods
func loadOfflineSettings() async throws -> OfflineSettings
func saveOfflineSettings(_ settings: OfflineSettings) async throws
// Cache Settings methods
func getCacheSize() async throws -> UInt
func getMaxCacheSize() async throws -> UInt
func updateMaxCacheSize(_ sizeInBytes: UInt) async throws
func clearCache() async throws
}

View File

@ -2,7 +2,7 @@
// CheckServerReachabilityUseCase.swift
// readeck
//
// Created by Claude Code
// Created by Ilyas Hallak
import Foundation

View File

@ -0,0 +1,17 @@
import Foundation
protocol PClearCacheUseCase {
func execute() async throws
}
class ClearCacheUseCase: PClearCacheUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async throws {
try await settingsRepository.clearCache()
}
}

View File

@ -0,0 +1,45 @@
//
// CreateAnnotationUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PCreateAnnotationUseCase {
func execute(
bookmarkId: String,
color: String,
startOffset: Int,
endOffset: Int,
startSelector: String,
endSelector: String
) async throws -> Annotation
}
class CreateAnnotationUseCase: PCreateAnnotationUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(
bookmarkId: String,
color: String,
startOffset: Int,
endOffset: Int,
startSelector: String,
endSelector: String
) async throws -> Annotation {
return try await repository.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PGetCacheSizeUseCase {
func execute() async throws -> UInt
}
class GetCacheSizeUseCase: PGetCacheSizeUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async throws -> UInt {
return try await settingsRepository.getCacheSize()
}
}

View File

@ -0,0 +1,24 @@
//
// GetCachedArticleUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PGetCachedArticleUseCase {
func execute(id: String) -> String?
}
class GetCachedArticleUseCase: PGetCachedArticleUseCase {
private let offlineCacheRepository: POfflineCacheRepository
init(offlineCacheRepository: POfflineCacheRepository) {
self.offlineCacheRepository = offlineCacheRepository
}
func execute(id: String) -> String? {
return offlineCacheRepository.getCachedArticle(id: id)
}
}

View File

@ -0,0 +1,24 @@
//
// GetCachedBookmarksUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PGetCachedBookmarksUseCase {
func execute() async throws -> [Bookmark]
}
class GetCachedBookmarksUseCase: PGetCachedBookmarksUseCase {
private let offlineCacheRepository: POfflineCacheRepository
init(offlineCacheRepository: POfflineCacheRepository) {
self.offlineCacheRepository = offlineCacheRepository
}
func execute() async throws -> [Bookmark] {
return try await offlineCacheRepository.getCachedBookmarks()
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PGetMaxCacheSizeUseCase {
func execute() async throws -> UInt
}
class GetMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async throws -> UInt {
return try await settingsRepository.getMaxCacheSize()
}
}

View File

@ -0,0 +1,58 @@
//
// NetworkMonitorUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 18.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol PNetworkMonitorUseCase {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorUseCase: PNetworkMonitorUseCase {
// MARK: - Dependencies
private let repository: PNetworkMonitorRepository
// MARK: - Properties
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
// MARK: - Initialization
init(repository: PNetworkMonitorRepository) {
self.repository = repository
}
// MARK: - Public Methods
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}

View File

@ -0,0 +1,247 @@
//
// OfflineCacheSyncUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 17.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
/// Use case for syncing articles for offline reading
/// Handles downloading article content and images based on user settings
protocol POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncProgress: AnyPublisher<String?, Never> { get }
func syncOfflineArticles(settings: OfflineSettings) async
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
}
// MARK: - Implementation
/// Orchestrates offline article caching with retry logic and progress reporting
/// - Downloads unread bookmarks based on user settings
/// - Prefetches images if enabled
/// - Implements retry logic for temporary server errors (502, 503, 504)
/// - Cleans up old cached articles (FIFO) to respect maxArticles limit
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
private let offlineCacheRepository: POfflineCacheRepository
private let bookmarksRepository: PBookmarksRepository
private let settingsRepository: PSettingsRepository
// MARK: - Published State
private let _isSyncingSubject = CurrentValueSubject<Bool, Never>(false)
private let _syncProgressSubject = CurrentValueSubject<String?, Never>(nil)
var isSyncing: AnyPublisher<Bool, Never> {
_isSyncingSubject.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
_syncProgressSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init(
offlineCacheRepository: POfflineCacheRepository,
bookmarksRepository: PBookmarksRepository,
settingsRepository: PSettingsRepository
) {
self.offlineCacheRepository = offlineCacheRepository
self.bookmarksRepository = bookmarksRepository
self.settingsRepository = settingsRepository
}
// MARK: - Public Methods
/// Syncs offline articles based on provided settings
/// - Fetches unread bookmarks from API
/// - Caches article HTML and optionally images
/// - Implements retry logic for temporary failures
/// - Updates last sync date in settings
@MainActor
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
Logger.sync.info("Offline sync skipped: disabled in settings")
return
}
_isSyncingSubject.send(true)
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
do {
// Fetch unread bookmarks from API
let page = try await bookmarksRepository.fetchBookmarks(
state: .unread,
limit: settings.maxUnreadArticlesInt,
offset: 0,
search: nil,
type: nil,
tag: nil
)
let bookmarks = page.bookmarks
Logger.sync.info("📚 Fetched \(bookmarks.count) unread bookmarks")
var successCount = 0
var skippedCount = 0
var errorCount = 0
var retryCount = 0
// Process each bookmark
for (index, bookmark) in bookmarks.enumerated() {
let progress = "\(index + 1)/\(bookmarks.count)"
// Check cache status
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgressSubject.send("⏭️ Article \(progress) already cached...")
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + images" : ""
_syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
// Retry logic for temporary server errors
var lastError: Error?
let maxRetries = 2
for attempt in 0...maxRetries {
do {
if attempt > 0 {
let delay = Double(attempt) * 2.0 // 2s, 4s backoff
Logger.sync.info("⏳ Retry \(attempt)/\(maxRetries) after \(delay)s delay...")
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
retryCount += 1
}
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'\(attempt > 0 ? " (after \(attempt) retries)" : "")")
lastError = nil
break // Success - exit retry loop
} catch {
lastError = error
// Check if error is retryable
let shouldRetry = isRetryableError(error)
if !shouldRetry || attempt == maxRetries {
// Log final error
logCacheError(error: error, bookmark: bookmark, attempt: attempt)
errorCount += 1
break // Give up
} else {
Logger.sync.warning("⚠️ Temporary error, will retry: \(error.localizedDescription)")
}
}
}
}
// Cleanup old articles (FIFO)
try await offlineCacheRepository.cleanupOldestCachedArticles(keepCount: settings.maxUnreadArticlesInt)
// Update last sync date in settings
var updatedSettings = settings
updatedSettings.lastSyncDate = Date()
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)\(retryCount > 0 ? ", Retries: \(retryCount)" : "")"
Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage)
// Clear progress message after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
_syncProgressSubject.send(nil)
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgressSubject.send("❌ Sync failed")
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
_syncProgressSubject.send(nil)
}
_isSyncingSubject.send(false)
}
func getCachedArticlesCount() -> Int {
offlineCacheRepository.getCachedArticlesCount()
}
func getCacheSize() -> String {
offlineCacheRepository.getCacheSize()
}
// MARK: - Private Helper Methods
/// Determines if an error is temporary and should be retried
/// - Retries on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
/// - Retries on: Network timeouts and connection losses
private func isRetryableError(_ error: Error) -> Bool {
// Retry on temporary server errors
if let apiError = error as? APIError {
switch apiError {
case .serverError(let statusCode):
// Retry on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
return statusCode == 502 || statusCode == 503 || statusCode == 504
case .invalidURL, .invalidResponse:
return false // Don't retry on permanent errors
}
}
// Retry on network timeouts
if let urlError = error as? URLError {
return urlError.code == .timedOut || urlError.code == .networkConnectionLost
}
return false
}
private func logCacheError(error: Error, bookmark: Bookmark, attempt: Int) {
let retryInfo = attempt > 0 ? " (after \(attempt) failed attempts)" : ""
if let urlError = error as? URLError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))")
} else if let decodingError = error as? DecodingError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Decoding error: \(decodingError)")
} else if let apiError = error as? APIError {
switch apiError {
case .invalidURL:
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid URL for bookmark ID '\(bookmark.id)'")
case .invalidResponse:
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid server response (nicht 200 OK)")
case .serverError(let statusCode):
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Server error HTTP \(statusCode)")
}
Logger.sync.error(" Bookmark ID: \(bookmark.id)")
Logger.sync.error(" URL: \(bookmark.url)")
} else {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Error: \(error.localizedDescription) (Type: \(type(of: error)))")
}
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PUpdateMaxCacheSizeUseCase {
func execute(sizeInBytes: UInt) async throws
}
class UpdateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute(sizeInBytes: UInt) async throws {
try await settingsRepository.updateMaxCacheSize(sizeInBytes)
}
}

View File

@ -82,6 +82,7 @@
"Favorite" = "Favorit";
"Finished reading?" = "Fertig gelesen?";
"Font" = "Schrift";
"Highlight" = "Markierung";
"Font family" = "Schriftart";
"Font Settings" = "Schrift";
"Font size" = "Schriftgröße";
@ -148,6 +149,8 @@
"Settings" = "Einstellungen";
"Show Performance Logs" = "Performance-Logs anzeigen";
"Show Timestamps" = "Zeitstempel anzeigen";
"Synchronization" = "Synchronisation";
"VPN connections are detected as active internet connections." = "VPN-Verbindungen werden als aktive Internetverbindungen erkannt.";
"Speed" = "Geschwindigkeit";
"Syncing with server..." = "Synchronisiere mit Server...";
"Theme" = "Design";
@ -163,3 +166,27 @@
"Your Password" = "Passwort";
"Your Username" = "Benutzername";
/* Offline Reading */
"Enable Offline Reading" = "Offline-Lesen aktivieren";
"Offline Reading" = "Offline-Lesen";
"Automatically download articles for offline use." = "Artikel automatisch für die Offline-Nutzung herunterladen.";
"Maximum Articles" = "Maximale Artikelanzahl";
"Max. Articles Offline" = "Max. Artikel Offline";
"Save Images" = "Bilder speichern";
"Also download images for offline use." = "Bilder auch für die Offline-Nutzung herunterladen.";
"Sync Now" = "Jetzt synchronisieren";
"Last synced: %@" = "Zuletzt synchronisiert: %@";
"Preview Cached Articles" = "Gecachte Artikel ansehen";
"%lld articles (%@)" = "%1$lld Artikel (%2$@)";
"Cached Articles" = "Gecachte Artikel";
"%lld articles cached" = "%lld Artikel gecacht";
"These articles are available offline. You can read them without an internet connection." = "Diese Artikel sind offline verfügbar. Sie können sie ohne Internetverbindung lesen.";
"Loading Cached Articles" = "Gecachte Artikel werden geladen";
"Please wait..." = "Bitte warten...";
"Unable to load cached articles" = "Gecachte Artikel können nicht geladen werden";
"No Cached Articles" = "Keine gecachten Artikel";
"Enable offline reading and sync to cache articles for offline access" = "Aktiviere Offline-Lesen und synchronisiere, um Artikel für den Offline-Zugriff zu cachen";
"Use 'Sync Now' to download articles" = "Verwende 'Jetzt synchronisieren', um Artikel herunterzuladen";
"Simulate Offline Mode" = "Offline-Modus simulieren";
"DEBUG: Toggle network status" = "DEBUG: Netzwerkstatus umschalten";

View File

@ -78,6 +78,7 @@
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Highlight" = "Highlight";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
@ -144,6 +145,8 @@
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Synchronization" = "Synchronization";
"VPN connections are detected as active internet connections." = "VPN connections are detected as active internet connections.";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";
@ -158,3 +161,27 @@
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
"Your Password" = "Your Password";
"Your Username" = "Your Username";
/* Offline Reading */
"Enable Offline Reading" = "Enable Offline Reading";
"Offline Reading" = "Offline Reading";
"Automatically download articles for offline use." = "Automatically download articles for offline use.";
"Maximum Articles" = "Maximum Articles";
"Max. Articles Offline" = "Max. Articles Offline";
"Save Images" = "Save Images";
"Also download images for offline use." = "Also download images for offline use.";
"Sync Now" = "Sync Now";
"Last synced: %@" = "Last synced: %@";
"Preview Cached Articles" = "Preview Cached Articles";
"%lld articles (%@)" = "%1$lld articles (%2$@)";
"Cached Articles" = "Cached Articles";
"%lld articles cached" = "%lld articles cached";
"These articles are available offline. You can read them without an internet connection." = "These articles are available offline. You can read them without an internet connection.";
"Loading Cached Articles" = "Loading Cached Articles";
"Please wait..." = "Please wait...";
"Unable to load cached articles" = "Unable to load cached articles";
"No Cached Articles" = "No Cached Articles";
"Enable offline reading and sync to cache articles for offline access" = "Enable offline reading and sync to cache articles for offline access";
"Use 'Sync Now' to download articles" = "Use 'Sync Now' to download articles";
"Simulate Offline Mode" = "Simulate Offline Mode";
"DEBUG: Toggle network status" = "DEBUG: Toggle network status";

View File

@ -7,6 +7,7 @@
import Foundation
import SwiftUI
import Combine
@MainActor
@Observable
@ -14,17 +15,22 @@ class AppViewModel {
private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory
private let syncTagsUseCase: PSyncTagsUseCase
let networkMonitorUseCase: PNetworkMonitorUseCase
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
var isNetworkConnected: Bool = true
private var lastAppStartTagSyncTime: Date?
private var cancellables = Set<AnyCancellable>()
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
setupNotificationObservers()
self.networkMonitorUseCase = factory.makeNetworkMonitorUseCase()
setupNotificationObservers()
setupNetworkMonitoring()
loadSetupStatus()
}
@ -63,6 +69,28 @@ class AppViewModel {
}
}
private func setupNetworkMonitoring() {
// Start monitoring network status
networkMonitorUseCase.startMonitoring()
// Bind network status to our published property
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.assign(to: \.isNetworkConnected, on: self)
.store(in: &cancellables)
}
func bindNetworkStatus(to appSettings: AppSettings) {
// Bind network status to AppSettings for global access
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.sink { isConnected in
Logger.viewModel.info("🌐 Network status changed: \(isConnected ? "Connected" : "Disconnected")")
appSettings.isNetworkConnected = isConnected
}
.store(in: &cancellables)
}
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
@ -70,6 +98,7 @@ class AppViewModel {
func onAppResume() async {
await checkServerReachability()
await syncTagsOnAppStart()
syncOfflineArticlesIfNeeded()
}
private func checkServerReachability() async {
@ -92,6 +121,28 @@ class AppViewModel {
lastAppStartTagSyncTime = now
}
private func syncOfflineArticlesIfNeeded() {
// Run offline sync in background without blocking app start
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let settings = try await self.settingsRepository.loadOfflineSettings()
guard settings.shouldSyncOnAppStart else {
Logger.sync.debug("Offline sync not needed (disabled or synced recently)")
return
}
Logger.sync.info("Auto-sync triggered on app start")
let offlineCacheSyncUseCase = self.factory.makeOfflineCacheSyncUseCase()
await offlineCacheSyncUseCase.syncOfflineArticles(settings: settings)
} catch {
Logger.sync.error("Failed to load offline settings for auto-sync: \(error.localizedDescription)")
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}

View File

@ -316,14 +316,20 @@ struct BookmarkDetailView2: View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
ZStack(alignment: .bottomTrailing) {
// Background blur for images that don't fill
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
CachedAsyncImage(
url: URL(string: viewModel.bookmarkDetail.imageUrl),
cacheKey: "bookmark-\(viewModel.bookmarkDetail.id)-hero"
)
.aspectRatio(contentMode: .fill)
.frame(width: width, height: headerHeight)
.blur(radius: 30)
.clipped()
// Main image with fit
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
CachedAsyncImage(
url: URL(string: viewModel.bookmarkDetail.imageUrl),
cacheKey: "bookmark-\(viewModel.bookmarkDetail.id)-hero"
)
.aspectRatio(contentMode: .fit)
.frame(width: width, height: headerHeight)

View File

@ -8,7 +8,8 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
private let getCachedArticleUseCase: PGetCachedArticleUseCase
private let createAnnotationUseCase: PCreateAnnotationUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -31,7 +32,8 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.getCachedArticleUseCase = factory.makeGetCachedArticleUseCase()
self.createAnnotationUseCase = factory.makeCreateAnnotationUseCase()
self.factory = factory
readProgressSubject
@ -72,16 +74,39 @@ class BookmarkDetailViewModel {
func loadArticleContent(id: String) async {
isLoadingArticle = true
// First, try to load from cache
if let cachedHTML = getCachedArticleUseCase.execute(id: id) {
articleContent = cachedHTML
processArticleContent()
isLoadingArticle = false
Logger.viewModel.info("📱 Loaded article \(id) from cache (\(cachedHTML.utf8.count) bytes)")
// Debug: Check for Base64 images
let base64Count = countOccurrences(in: cachedHTML, of: "data:image/")
let httpCount = countOccurrences(in: cachedHTML, of: "src=\"http")
Logger.viewModel.info(" Images in cached HTML: \(base64Count) Base64, \(httpCount) HTTP")
return
}
// If not cached, fetch from server
Logger.viewModel.info("📡 Fetching article \(id) from server (not in cache)")
do {
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent()
Logger.viewModel.info("✅ Fetched article from server (\(articleContent.utf8.count) bytes)")
} catch {
errorMessage = "Error loading article"
Logger.viewModel.error("❌ Failed to load article: \(error.localizedDescription)")
}
isLoadingArticle = false
}
private func countOccurrences(in text: String, of substring: String) -> Int {
return text.components(separatedBy: substring).count - 1
}
private func processArticleContent() {
let paragraphs = articleContent
.components(separatedBy: .newlines)
@ -148,7 +173,7 @@ class BookmarkDetailViewModel {
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
let annotation = try await createAnnotationUseCase.execute(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
@ -156,9 +181,9 @@ class BookmarkDetailViewModel {
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
Logger.viewModel.info("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
Logger.viewModel.error("❌ Failed to create annotation: \(error.localizedDescription)")
errorMessage = "Error creating annotation"
}
}

View File

@ -144,7 +144,10 @@ struct BookmarkCardView: View {
private var compactLayoutView: some View {
HStack(alignment: .top, spacing: 12) {
CachedAsyncImage(url: imageURL)
CachedAsyncImage(
url: imageURL,
cacheKey: "bookmark-\(bookmark.id)-hero"
)
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
@ -195,7 +198,10 @@ struct BookmarkCardView: View {
private var magazineLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: imageURL)
CachedAsyncImage(
url: imageURL,
cacheKey: "bookmark-\(bookmark.id)-hero"
)
.aspectRatio(contentMode: .fill)
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 8))
@ -275,7 +281,10 @@ struct BookmarkCardView: View {
private var naturalLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: imageURL)
CachedAsyncImage(
url: imageURL,
cacheKey: "bookmark-\(bookmark.id)-hero"
)
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width - 32)
.clipped()

View File

@ -6,7 +6,7 @@ struct BookmarksView: View {
// MARK: States
@State private var viewModel: BookmarksViewModel
@State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false
@State private var selectedBookmarkId: String?
@State private var showingAddBookmarkFromShare = false
@ -17,6 +17,7 @@ struct BookmarksView: View {
let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark?
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
let tag: String?
// MARK: Environments
@ -26,22 +27,29 @@ struct BookmarksView: View {
// MARK: Initializer
init(viewModel: BookmarksViewModel = .init(), state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
self.state = state
self.type = type
self._selectedBookmark = selectedBookmark
self.tag = tag
self.viewModel = viewModel
}
var body: some View {
ZStack {
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
VStack(spacing: 0) {
// Offline banner
if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
offlineBanner
}
// Main content
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
}
}
// FAB Button - only show for "Unread" and when not in error/loading state
@ -67,10 +75,17 @@ struct BookmarksView: View {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
)
.onAppear {
Task {
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
}
.task {
// Set appSettings reference
viewModel.appSettings = appSettings
// Wait briefly for initial network status to be set
// NetworkMonitor checks status synchronously in init, but the publisher
// might not have propagated to appSettings yet
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
Logger.ui.info("📲 BookmarksView.task - Loading bookmarks, isNetworkConnected: \(appSettings.isNetworkConnected)")
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
@ -83,6 +98,20 @@ struct BookmarksView: View {
}
}
}
.onChange(of: appSettings.isNetworkConnected) { oldValue, newValue in
// Network status changed
if !newValue && oldValue {
// Lost network connection - load cached bookmarks
Task {
await viewModel.loadCachedBookmarksFromUI()
}
} else if newValue && !oldValue {
// Regained network connection - refresh from server
Task {
await viewModel.refreshBookmarks()
}
}
}
}
// MARK: - Computed Properties
@ -90,7 +119,12 @@ struct BookmarksView: View {
private var shouldShowCenteredState: Bool {
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
let hasError = viewModel.errorMessage != nil
return (isEmpty && viewModel.isLoading) || hasError
let isOfflineNonUnread = !appSettings.isNetworkConnected && state != .unread
// Show centered state when:
// 1. Empty AND has error, OR
// 2. Offline mode in non-Unread tabs (Archive/Starred/All)
return (isEmpty && hasError) || isOfflineNonUnread
}
// MARK: - View Components
@ -100,7 +134,9 @@ struct BookmarksView: View {
VStack(spacing: 20) {
Spacer()
if viewModel.isLoading {
if !appSettings.isNetworkConnected && state != .unread {
offlineUnavailableView
} else if viewModel.isLoading {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
@ -133,6 +169,57 @@ struct BookmarksView: View {
.padding(.horizontal, 40)
}
@ViewBuilder
private var offlineUnavailableView: some View {
VStack(spacing: 20) {
// Icon stack
ZStack {
Image(systemName: "cloud.slash")
.font(.system(size: 48))
.foregroundColor(.secondary.opacity(0.3))
.offset(x: -8, y: 8)
Image(systemName: "wifi.slash")
.font(.system(size: 48))
.foregroundColor(.orange)
}
VStack(spacing: 8) {
Text("Offline Mode")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("\(state.displayName) Not Available")
.font(.headline)
.foregroundColor(.secondary)
Text("Only unread articles are cached for offline reading")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
// Hint to switch to Unread tab
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.left")
.font(.caption)
Text("Switch to Unread to view cached articles")
.font(.caption)
}
.foregroundColor(.accentColor)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.clipShape(Capsule())
}
.padding(.top, 8)
}
.padding(.horizontal, 40)
}
@ViewBuilder
private func errorView(message: String) -> some View {
VStack(spacing: 16) {
@ -276,6 +363,30 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var offlineBanner: some View {
HStack(spacing: 12) {
Image(systemName: "wifi.slash")
.font(.body)
.foregroundColor(.secondary)
Text("Offline Mode Showing cached articles")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGray6))
.overlay(
Rectangle()
.frame(height: 0.5)
.foregroundColor(Color(.separator)),
alignment: .bottom
)
}
@ViewBuilder
private var fabButton: some View {
VStack {
@ -301,12 +412,3 @@ struct BookmarksView: View {
}
}
}
#Preview {
BookmarksView(
viewModel: .init(MockUseCaseFactory()),
state: .archived,
type: [.article],
selectedBookmark: .constant(nil),
tag: nil)
}

View File

@ -8,6 +8,8 @@ class BookmarksViewModel {
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let getCachedBookmarksUseCase: PGetCachedBookmarksUseCase
weak var appSettings: AppSettings?
var bookmarks: BookmarksPage?
var isLoading = false
@ -29,7 +31,6 @@ class BookmarksViewModel {
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>()
private var limit = 50
private var offset = 0
@ -47,6 +48,7 @@ class BookmarksViewModel {
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
getCachedBookmarksUseCase = factory.makeGetCachedBookmarksUseCase()
setupNotificationObserver()
@ -107,7 +109,10 @@ class BookmarksViewModel {
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else { return }
guard !isUpdating else {
Logger.viewModel.debug("⏭️ Skipping loadBookmarks - already updating")
return
}
isUpdating = true
defer { isUpdating = false }
@ -120,6 +125,19 @@ class BookmarksViewModel {
offset = 0
hasMoreData = true
// Check if offline BEFORE making API call
Logger.viewModel.info("🔍 Checking network status - appSettings: \(appSettings != nil), isNetworkConnected: \(appSettings?.isNetworkConnected ?? false)")
if let appSettings, !appSettings.isNetworkConnected {
Logger.viewModel.info("📱 Device is offline - loading cached bookmarks")
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
isLoading = false
isInitialLoading = false
return
}
Logger.viewModel.info("🌐 Device appears online - making API request")
do {
let newBookmarks = try await getBooksmarksUseCase.execute(
state: state,
@ -139,6 +157,8 @@ class BookmarksViewModel {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
// Try to load cached bookmarks
await loadCachedBookmarks()
default:
isNetworkError = false
errorMessage = "Error loading bookmarks"
@ -154,6 +174,48 @@ class BookmarksViewModel {
isInitialLoading = false
}
@MainActor
private func loadCachedBookmarks() async {
Logger.viewModel.info("📱 loadCachedBookmarks called for state: \(currentState.displayName)")
// Only load cached bookmarks for "Unread" tab
// Other tabs (Archive, Starred, All) should show "offline unavailable" message
guard currentState == .unread else {
Logger.viewModel.info("📱 Skipping cache load for '\(currentState.displayName)' tab - only Unread is cached")
return
}
do {
Logger.viewModel.info("📱 Fetching cached bookmarks from use case...")
let cachedBookmarks = try await getCachedBookmarksUseCase.execute()
Logger.viewModel.info("📱 Retrieved \(cachedBookmarks.count) cached bookmarks")
if !cachedBookmarks.isEmpty {
// Create a BookmarksPage from cached bookmarks
bookmarks = BookmarksPage(
bookmarks: cachedBookmarks,
currentPage: 1,
totalCount: cachedBookmarks.count,
totalPages: 1,
links: nil
)
hasMoreData = false
Logger.viewModel.info("✅ Loaded \(cachedBookmarks.count) cached bookmarks for offline mode")
} else {
Logger.viewModel.warning("⚠️ No cached bookmarks found")
}
} catch {
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
}
}
@MainActor
func loadCachedBookmarksFromUI() async {
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
}
@MainActor
func loadMoreBookmarks() async {
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads

View File

@ -3,24 +3,177 @@ import Kingfisher
struct CachedAsyncImage: View {
let url: URL?
let cacheKey: String?
@EnvironmentObject private var appSettings: AppSettings
@State private var isImageCached = false
@State private var hasCheckedCache = false
@State private var cachedImage: UIImage?
init(url: URL?) {
init(url: URL?, cacheKey: String? = nil) {
self.url = url
self.cacheKey = cacheKey
}
var body: some View {
if let url {
KFImage(url)
.placeholder {
Color.gray.opacity(0.3)
imageView(for: url)
.task {
await checkCache(for: url)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
} else {
Image("placeholder")
.resizable()
.scaledToFill()
placeholderImage
}
}
@ViewBuilder
private func imageView(for url: URL) -> some View {
if appSettings.isNetworkConnected {
onlineImageView(url: url)
} else {
offlineImageView(url: url)
}
}
// MARK: - Online Mode
private func onlineImageView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.placeholder { Color.gray.opacity(0.3) }
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
// MARK: - Offline Mode
@ViewBuilder
private func offlineImageView(url: URL) -> some View {
if hasCheckedCache && !isImageCached {
placeholderWithWarning
} else if let cachedImage {
cachedImageView(image: cachedImage)
} else {
kingfisherCacheOnlyView(url: url)
}
}
private func cachedImageView(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.frame(maxWidth: .infinity)
}
private func kingfisherCacheOnlyView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.loadDiskFileSynchronously()
.onlyFromCache(true)
.placeholder { Color.gray.opacity(0.3) }
.onSuccess { _ in
Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)")
}
.onFailure { error in
Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)")
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
private var placeholderImage: some View {
Color.gray.opacity(0.3)
.frame(maxWidth: .infinity)
.overlay(
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
)
}
private var placeholderWithWarning: some View {
Color.gray.opacity(0.3)
.frame(maxWidth: .infinity)
.overlay(
VStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.gray)
.font(.title)
Text("Offline - Image not cached")
.font(.caption)
.foregroundColor(.secondary)
}
)
}
// MARK: - Cache Checking
private func checkCache(for url: URL) async {
// Try custom cache key first, then fallback to URL-based cache
if let cacheKey = cacheKey, await tryLoadFromCustomKey(cacheKey) {
return
}
await checkStandardCache(for: url)
}
private func tryLoadFromCustomKey(_ key: String) async -> Bool {
let image = await retrieveImageFromCache(key: key)
await MainActor.run {
if let image {
cachedImage = image
isImageCached = true
Logger.ui.debug("✅ Loaded image from cache using key: \(key)")
} else {
Logger.ui.debug("Image not found with cache key, trying URL-based cache")
}
hasCheckedCache = true
}
return image != nil
}
private func checkStandardCache(for url: URL) async {
let isCached = await isImageInCache(url: url)
await MainActor.run {
isImageCached = isCached
hasCheckedCache = true
if !appSettings.isNetworkConnected {
Logger.ui.debug(isCached
? "✅ Image is cached for offline use: \(url.absoluteString)"
: "❌ Image NOT cached for offline use: \(url.absoluteString)")
}
}
}
private func retrieveImageFromCache(key: String) async -> UIImage? {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure:
continuation.resume(returning: nil)
}
}
}
}
private func isImageInCache(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
}

View File

@ -402,6 +402,8 @@ struct NativeWebView: View {
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let highlightLabel = NSLocalizedString("Highlight", comment: "")
return """
// Create annotation color overlay
(function() {
@ -456,9 +458,9 @@ struct NativeWebView: View {
`;
overlay.appendChild(content);
// Add "Markierung" label
// Add localized label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.textContent = '\(highlightLabel)';
label.style.cssText = `
color: black;
font-size: 16px;

View File

@ -410,6 +410,7 @@ struct WebView: UIViewRepresentable {
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
let highlightLabel = NSLocalizedString("Highlight", comment: "")
return """
// Create annotation color overlay
@ -465,9 +466,9 @@ struct WebView: UIViewRepresentable {
`;
overlay.appendChild(content);
// Add "Markierung" label
// Add localized label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.textContent = '\(highlightLabel)';
label.style.cssText = `
color: black;
font-size: 16px;

View File

@ -0,0 +1,318 @@
//
// DebugMenuView.swift
// readeck
//
// Created by Ilyas Hallak on 21.11.25.
//
#if DEBUG
import SwiftUI
struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var appSettings: AppSettings
@StateObject private var viewModel = DebugMenuViewModel()
var body: some View {
NavigationView {
List {
// MARK: - Network Section
Section {
networkSimulationToggle
networkStatusInfo
} header: {
Text("Network Debugging")
} footer: {
Text("Simulate offline mode to test offline reading features")
}
// MARK: - Offline Debugging Section
Section {
Picker("Select Cached Bookmark", selection: $viewModel.selectedBookmarkId) {
Text("None").tag(nil as String?)
ForEach(viewModel.cachedBookmarks, id: \.id) { bookmark in
Text(bookmark.title.isEmpty ? bookmark.id : bookmark.title)
.lineLimit(1)
.tag(bookmark.id as String?)
}
}
NavigationLink {
OfflineImageDebugView(bookmarkId: viewModel.selectedBookmarkId ?? "")
} label: {
Label("Offline Image Diagnostics", systemImage: "photo.badge.checkmark")
}
.disabled(viewModel.selectedBookmarkId == nil)
} header: {
Text("Offline Reading")
} footer: {
Text("Select a cached bookmark to diagnose offline image issues")
}
// MARK: - Logging Section
Section {
NavigationLink {
DebugLogViewer()
} label: {
Label("View Logs", systemImage: "doc.text.magnifyingglass")
}
Button(role: .destructive) {
viewModel.clearLogs()
} label: {
Label("Clear All Logs", systemImage: "trash")
}
} header: {
Text("Logging")
} footer: {
Text("View and manage application logs")
}
// MARK: - Data Section
Section {
cacheInfo
Button(role: .destructive) {
viewModel.showResetCacheAlert = true
} label: {
Label("Clear Offline Cache", systemImage: "trash")
}
Button(role: .destructive) {
viewModel.showResetDatabaseAlert = true
} label: {
Label("Reset Core Data", systemImage: "exclamationmark.triangle.fill")
.foregroundColor(.red)
}
} header: {
Text("Data Management")
} footer: {
Text("⚠️ Reset Core Data will delete all local bookmarks and cache")
}
// MARK: - App Info Section
Section {
HStack {
Text("App Version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.secondary)
}
HStack {
Text("Build Number")
Spacer()
Text(viewModel.buildNumber)
.foregroundColor(.secondary)
}
HStack {
Text("Bundle ID")
Spacer()
Text(viewModel.bundleId)
.font(.caption)
.foregroundColor(.secondary)
}
} header: {
Text("App Information")
}
}
.navigationTitle("🛠️ Debug Menu")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.task {
await viewModel.loadCacheInfo()
}
.alert("Clear Offline Cache?", isPresented: $viewModel.showResetCacheAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
Task {
await viewModel.clearOfflineCache()
}
}
} message: {
Text("This will remove all cached articles. Your bookmarks will remain.")
}
.alert("Reset Core Data?", isPresented: $viewModel.showResetDatabaseAlert) {
Button("Cancel", role: .cancel) { }
Button("Reset", role: .destructive) {
viewModel.resetCoreData()
}
} message: {
Text("⚠️ WARNING: This will delete ALL local data including bookmarks, cache, and settings. This cannot be undone!")
}
}
}
// MARK: - Subviews
private var networkSimulationToggle: some View {
Toggle(isOn: Binding(
get: { !appSettings.isNetworkConnected },
set: { isOffline in
appSettings.isNetworkConnected = !isOffline
}
)) {
HStack {
Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash")
.foregroundColor(appSettings.isNetworkConnected ? .green : .orange)
VStack(alignment: .leading, spacing: 2) {
Text("Simulate Offline Mode")
Text(appSettings.isNetworkConnected ? "Network Connected" : "Network Disconnected")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
private var networkStatusInfo: some View {
HStack {
Text("Network Status")
Spacer()
Label(
appSettings.isNetworkConnected ? "Connected" : "Offline",
systemImage: appSettings.isNetworkConnected ? "checkmark.circle.fill" : "xmark.circle.fill"
)
.font(.caption)
.foregroundColor(appSettings.isNetworkConnected ? .green : .orange)
}
}
private var cacheInfo: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Cached Articles")
Spacer()
Text("\(viewModel.cachedArticlesCount)")
.foregroundColor(.secondary)
}
HStack {
Text("Cache Size")
Spacer()
Text(viewModel.cacheSize)
.foregroundColor(.secondary)
}
}
.task {
await viewModel.loadCacheInfo()
}
}
}
@MainActor
class DebugMenuViewModel: ObservableObject {
@Published var showResetCacheAlert = false
@Published var showResetDatabaseAlert = false
@Published var cachedArticlesCount = 0
@Published var cacheSize = "0 KB"
@Published var selectedBookmarkId: String?
@Published var cachedBookmarks: [Bookmark] = []
private let offlineCacheRepository = OfflineCacheRepository()
private let coreDataManager = CoreDataManager.shared
private let logger = Logger.general
var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
}
var bundleId: String {
Bundle.main.bundleIdentifier ?? "Unknown"
}
func loadCacheInfo() async {
cachedArticlesCount = offlineCacheRepository.getCachedArticlesCount()
cacheSize = offlineCacheRepository.getCacheSize()
// Load cached bookmarks for diagnostics
do {
cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
// Auto-select first bookmark if available
if selectedBookmarkId == nil, let firstBookmark = cachedBookmarks.first {
selectedBookmarkId = firstBookmark.id
}
} catch {
logger.error("Failed to load cached bookmarks: \(error.localizedDescription)")
}
}
func clearOfflineCache() async {
do {
try await offlineCacheRepository.clearCache()
await loadCacheInfo()
logger.info("Offline cache cleared via Debug Menu")
} catch {
logger.error("Failed to clear offline cache: \(error.localizedDescription)")
}
}
func clearLogs() {
// TODO: Implement log clearing when we add persistent logging
logger.info("Logs cleared via Debug Menu")
}
func resetCoreData() {
do {
try coreDataManager.resetDatabase()
logger.warning("Core Data reset via Debug Menu - App restart required")
// Show alert that restart is needed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
fatalError("Core Data has been reset. Please restart the app.")
}
} catch {
logger.error("Failed to reset Core Data: \(error.localizedDescription)")
}
}
}
// MARK: - Shake Gesture Detection
extension UIDevice {
static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification")
}
extension UIWindow {
open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil)
}
}
}
struct DeviceShakeViewModifier: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content
.onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in
action()
}
}
}
extension View {
func onShake(perform action: @escaping () -> Void) -> some View {
self.modifier(DeviceShakeViewModifier(action: action))
}
}
#Preview {
DebugMenuView()
.environmentObject(AppSettings())
}
#endif

View File

@ -0,0 +1,199 @@
//
// OfflineImageDebugView.swift
// readeck
//
// Debug view to diagnose offline image loading issues
//
import SwiftUI
import Kingfisher
struct OfflineImageDebugView: View {
let bookmarkId: String
@State private var debugInfo: DebugInfo = DebugInfo()
@EnvironmentObject var appSettings: AppSettings
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Offline Image Debug")
.font(.title)
.padding()
Group {
DebugSection("Network Status") {
InfoRow(label: "Connected", value: "\(appSettings.isNetworkConnected)")
}
DebugSection("Cached Article") {
InfoRow(label: "Has Cache", value: "\(debugInfo.hasCachedHTML)")
InfoRow(label: "HTML Size", value: debugInfo.htmlSize)
InfoRow(label: "Base64 Images", value: "\(debugInfo.base64ImageCount)")
InfoRow(label: "HTTP Images", value: "\(debugInfo.httpImageCount)")
}
DebugSection("Hero Image Cache") {
InfoRow(label: "URL", value: debugInfo.heroImageURL)
InfoRow(label: "In Cache", value: "\(debugInfo.heroImageInCache)")
InfoRow(label: "Cache Key", value: debugInfo.cacheKey)
}
if !debugInfo.sampleImages.isEmpty {
DebugSection("Sample HTML Images") {
ForEach(debugInfo.sampleImages.indices, id: \.self) { index in
VStack(alignment: .leading, spacing: 4) {
Text("Image \(index + 1)")
.font(.caption).bold()
Text(debugInfo.sampleImages[index])
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
}
}
.padding(.horizontal)
Button("Run Diagnostics") {
Task {
await runDiagnostics()
}
}
.buttonStyle(.borderedProminent)
.padding()
}
}
.task {
await runDiagnostics()
}
}
private func runDiagnostics() async {
let offlineCache = OfflineCacheRepository()
// Check cached HTML
if let cachedHTML = offlineCache.getCachedArticle(id: bookmarkId) {
debugInfo.hasCachedHTML = true
debugInfo.htmlSize = ByteCountFormatter.string(fromByteCount: Int64(cachedHTML.utf8.count), countStyle: .file)
// Count Base64 images
debugInfo.base64ImageCount = countMatches(in: cachedHTML, pattern: #"src="data:image/"#)
// Count HTTP images
debugInfo.httpImageCount = countMatches(in: cachedHTML, pattern: #"src="https?://"#)
// Extract sample image URLs
debugInfo.sampleImages = extractSampleImages(from: cachedHTML)
}
// Check hero image cache
do {
let bookmarkDetail = try await DefaultUseCaseFactory.shared.makeGetBookmarkUseCase().execute(id: bookmarkId)
if !bookmarkDetail.imageUrl.isEmpty, let url = URL(string: bookmarkDetail.imageUrl) {
debugInfo.heroImageURL = bookmarkDetail.imageUrl
debugInfo.cacheKey = url.cacheKey
// Check if image is in Kingfisher cache
let isCached = await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
debugInfo.heroImageInCache = isCached
}
} catch {
print("Error loading bookmark: \(error)")
}
}
private func countMatches(in text: String, pattern: String) -> Int {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return 0 }
let nsString = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))
return matches.count
}
private func extractSampleImages(from html: String) -> [String] {
let pattern = #"<img[^>]+src="([^"]+)""#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
let nsString = html as NSString
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
return matches.prefix(3).compactMap { match in
guard match.numberOfRanges >= 2 else { return nil }
let urlRange = match.range(at: 1)
let url = nsString.substring(with: urlRange)
// Truncate long Base64 strings
if url.hasPrefix("data:image/") {
return "data:image/... (Base64, \(url.count) chars)"
}
return url
}
}
struct DebugInfo {
var hasCachedHTML = false
var htmlSize = "0 KB"
var base64ImageCount = 0
var httpImageCount = 0
var heroImageURL = "N/A"
var heroImageInCache = false
var cacheKey = "N/A"
var sampleImages: [String] = []
}
}
struct InfoRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline.bold())
}
.padding(.vertical, 4)
}
}
struct DebugSection<Content: View>: View {
let title: String
let content: Content
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.padding(.top, 8)
content
Divider()
}
}
}
#Preview {
OfflineImageDebugView(bookmarkId: "123")
.environmentObject(AppSettings())
}

View File

@ -25,6 +25,16 @@ protocol UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
func makeSettingsRepository() -> PSettingsRepository
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase
func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase
func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase
func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase
func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase
func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase
func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase
func makeClearCacheUseCase() -> PClearCacheUseCase
}
@ -38,6 +48,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository()
private let networkMonitorRepository: PNetworkMonitorRepository = NetworkMonitorRepository()
static let shared = DefaultUseCaseFactory()
@ -144,4 +156,48 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
return DeleteAnnotationUseCase(repository: annotationsRepository)
}
func makeSettingsRepository() -> PSettingsRepository {
return settingsRepository
}
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
return OfflineCacheSyncUseCase(
offlineCacheRepository: offlineCacheRepository,
bookmarksRepository: bookmarksRepository,
settingsRepository: settingsRepository
)
}
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
return NetworkMonitorUseCase(repository: networkMonitorRepository)
}
func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase {
return GetCachedBookmarksUseCase(offlineCacheRepository: offlineCacheRepository)
}
func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase {
return GetCachedArticleUseCase(offlineCacheRepository: offlineCacheRepository)
}
func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase {
return CreateAnnotationUseCase(repository: annotationsRepository)
}
func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase {
return GetCacheSizeUseCase(settingsRepository: settingsRepository)
}
func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase {
return GetMaxCacheSizeUseCase(settingsRepository: settingsRepository)
}
func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase {
return UpdateMaxCacheSizeUseCase(settingsRepository: settingsRepository)
}
func makeClearCacheUseCase() -> PClearCacheUseCase {
return ClearCacheUseCase(settingsRepository: settingsRepository)
}
}

View File

@ -9,6 +9,18 @@ import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory {
func makeGetCachedBookmarksUseCase() -> any PGetCachedBookmarksUseCase {
MockGetCachedBookmarksUseCase()
}
func makeGetCachedArticleUseCase() -> any PGetCachedArticleUseCase {
MockGetCachedArticleUseCase()
}
func makeCreateAnnotationUseCase() -> any PCreateAnnotationUseCase {
MockCreateAnnotationUseCase()
}
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
MockCheckServerReachabilityUseCase()
}
@ -104,6 +116,34 @@ class MockUseCaseFactory: UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
MockDeleteAnnotationUseCase()
}
func makeSettingsRepository() -> PSettingsRepository {
MockSettingsRepository()
}
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
MockOfflineCacheSyncUseCase()
}
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
MockNetworkMonitorUseCase()
}
func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase {
MockGetCacheSizeUseCase()
}
func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase {
MockGetMaxCacheSizeUseCase()
}
func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase {
MockUpdateMaxCacheSizeUseCase()
}
func makeClearCacheUseCase() -> PClearCacheUseCase {
MockClearCacheUseCase()
}
}
@ -280,8 +320,139 @@ class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
}
}
class MockSettingsRepository: PSettingsRepository {
var hasFinishedSetup: Bool = true
func saveSettings(_ settings: Settings) async throws {}
func loadSettings() async throws -> Settings? {
return Settings(endpoint: "mock-endpoint", username: "mock-user", password: "mock-pw", token: "mock-token", fontFamily: .system, fontSize: .medium, hasFinishedSetup: true)
}
func clearSettings() async throws {}
func saveToken(_ token: String) async throws {}
func saveUsername(_ username: String) async throws {}
func savePassword(_ password: String) async throws {}
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {}
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {}
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {}
func loadCardLayoutStyle() async throws -> CardLayoutStyle { return .magazine }
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {}
func loadTagSortOrder() async throws -> TagSortOrder { return .byCount }
func loadOfflineSettings() async throws -> OfflineSettings {
return OfflineSettings()
}
func saveOfflineSettings(_ settings: OfflineSettings) async throws {}
func getCacheSize() async throws -> UInt { return 0 }
func getMaxCacheSize() async throws -> UInt { return 200 * 1024 * 1024 }
func updateMaxCacheSize(_ sizeInBytes: UInt) async throws {}
func clearCache() async throws {}
}
class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> {
Just(false).eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
Just(nil).eraseToAnyPublisher()
}
func syncOfflineArticles(settings: OfflineSettings) async {}
func getCachedArticlesCount() -> Int {
return 0
}
func getCacheSize() -> String {
return "0 KB"
}
}
class MockNetworkMonitorRepository: PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> {
Just(true).eraseToAnyPublisher()
}
func startMonitoring() {}
func stopMonitoring() {}
func reportConnectionFailure() {}
func reportConnectionSuccess() {}
}
class MockNetworkMonitorUseCase: PNetworkMonitorUseCase {
private let repository: PNetworkMonitorRepository
init(repository: PNetworkMonitorRepository = MockNetworkMonitorRepository()) {
self.repository = repository
}
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}
class MockGetCachedBookmarksUseCase: PGetCachedBookmarksUseCase {
func execute() async throws -> [Bookmark] {
return [Bookmark.mock]
}
}
class MockGetCachedArticleUseCase: PGetCachedArticleUseCase {
func execute(id: String) -> String? {
let path = Bundle.main.path(forResource: "article", ofType: "html")
return try? String(contentsOfFile: path!)
}
}
class MockCreateAnnotationUseCase: PCreateAnnotationUseCase {
func execute(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation {
return Annotation(id: "", text: "", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "")
}
func execute(bookmarkId: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws {
// Mock implementation - do nothing
}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
)
}
class MockGetCacheSizeUseCase: PGetCacheSizeUseCase {
func execute() async throws -> UInt {
return 0
}
}
class MockGetMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase {
func execute() async throws -> UInt {
return 200 * 1024 * 1024
}
}
class MockUpdateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase {
func execute(sizeInBytes: UInt) async throws {}
}
class MockClearCacheUseCase: PClearCacheUseCase {
func execute() async throws {}
}

View File

@ -206,9 +206,9 @@ struct PhoneTabView: View {
} else {
Section {
VStack {
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state, onSyncTapped: {
await offlineBookmarksViewModel.syncOfflineBookmarks()
}
})
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())

View File

@ -18,6 +18,7 @@ import Combine
class AppSettings: ObservableObject {
@Published var settings: Settings?
@Published var isNetworkConnected: Bool = true
var enableTTS: Bool {
settings?.enableTTS ?? false

View File

@ -4,6 +4,53 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
## Version 2.0.0
### Offline Reading
- **Read your articles without internet connection** - the feature you've been waiting for!
- Automatic background sync keeps your favorite articles cached
- Choose how many articles to cache (up to 200)
- Cache syncs automatically every 4 hours
- Manual sync button for instant updates
- Smart FIFO cleanup automatically removes old cached articles
- Article images are pre-downloaded for offline viewing
- Cached articles load instantly, even without network
### Smart Network Monitoring
- **Automatic offline detection** with reliable network monitoring
- Visual indicator shows when you're offline
- App automatically loads cached articles when offline
- Cache-first loading for instant article access
- Improved VPN handling without false-positives
- Network status checks interface availability for accuracy
### Offline Settings
- **New dedicated offline settings screen**
- Enable or disable offline mode
- Adjust number of cached articles with slider
- View last sync timestamp
- Manual sync button
- Toggle settings work instantly
### Performance & Architecture
- Clean architecture with dedicated cache repository layer
- Efficient CoreData integration for cached content
- Kingfisher image prefetching for smooth offline experience
- Background sync doesn't block app startup
- Reactive updates with Combine framework
### Developer Features (DEBUG)
- Offline mode simulation toggle for testing
- Detailed sync logging for troubleshooting
- Visual debug banner (green=online, red=offline)
---
## Version 1.2.0
### Annotations & Highlighting

View File

@ -1,24 +1,22 @@
import SwiftUI
import Kingfisher
struct CacheSettingsView: View {
@State private var cacheSize: String = "0 MB"
@State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false
@State private var viewModel = CacheSettingsViewModel()
var body: some View {
Section {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
Text("\(viewModel.cacheSize) / \(Int(viewModel.maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
Task {
await viewModel.updateCacheSize()
}
}
.font(.caption)
.foregroundColor(.blue)
@ -28,24 +26,26 @@ struct CacheSettingsView: View {
HStack {
Text("Max Cache Size")
Spacer()
Text("\(Int(maxCacheSize)) MB")
Text("\(Int(viewModel.maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Slider(value: $viewModel.maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
.onChange(of: viewModel.maxCacheSize) { _, newValue in
Task {
await viewModel.updateMaxCacheSize(newValue)
}
}
}
Button(action: {
showClearAlert = true
viewModel.showClearAlert = true
}) {
HStack {
if isClearing {
if viewModel.isClearing {
ProgressView()
.scaleEffect(0.8)
} else {
@ -55,7 +55,7 @@ struct CacheSettingsView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
.foregroundColor(viewModel.isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
@ -64,69 +64,24 @@ struct CacheSettingsView: View {
Spacer()
}
}
.disabled(isClearing)
.disabled(viewModel.isClearing)
} header: {
Text("Cache Settings")
}
.onAppear {
updateCacheSize()
loadMaxCacheSize()
.task {
await viewModel.loadCacheSettings()
}
.alert("Clear Cache", isPresented: $showClearAlert) {
.alert("Clear Cache", isPresented: $viewModel.showClearAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearCache()
Task {
await viewModel.clearCache()
}
}
} message: {
Text("This will remove all cached images. They will be downloaded again when needed.")
}
}
private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async {
switch result {
case .success(let size):
let mbSize = Double(size) / (1024 * 1024)
self.cacheSize = String(format: "%.1f MB", mbSize)
case .failure:
self.cacheSize = "Unknown"
}
}
}
}
private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize {
maxCacheSize = Double(savedSize) / (1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
} else {
maxCacheSize = 200
let defaultBytes = UInt(200 * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
}
}
private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
}
private func clearCache() {
isClearing = true
KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async {
self.isClearing = false
self.updateCacheSize()
}
}
KingfisherManager.shared.cache.clearMemoryCache()
}
}
#Preview {

View File

@ -0,0 +1,92 @@
//
// CacheSettingsViewModel.swift
// readeck
//
// Created by Claude on 01.12.25.
//
import Foundation
import Observation
@Observable
class CacheSettingsViewModel {
// MARK: - Dependencies
private let getCacheSizeUseCase: PGetCacheSizeUseCase
private let getMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase
private let updateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase
private let clearCacheUseCase: PClearCacheUseCase
// MARK: - Published State
var cacheSize: String = "0 MB"
var maxCacheSize: Double = 200 // in MB
var isClearing: Bool = false
var showClearAlert: Bool = false
// MARK: - Initialization
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getCacheSizeUseCase = factory.makeGetCacheSizeUseCase()
self.getMaxCacheSizeUseCase = factory.makeGetMaxCacheSizeUseCase()
self.updateMaxCacheSizeUseCase = factory.makeUpdateMaxCacheSizeUseCase()
self.clearCacheUseCase = factory.makeClearCacheUseCase()
}
// MARK: - Public Methods
@MainActor
func loadCacheSettings() async {
await updateCacheSize()
await loadMaxCacheSize()
}
@MainActor
func updateCacheSize() async {
do {
let sizeInBytes = try await getCacheSizeUseCase.execute()
let mbSize = Double(sizeInBytes) / (1024 * 1024)
cacheSize = String(format: "%.1f MB", mbSize)
Logger.viewModel.debug("Cache size: \(cacheSize)")
} catch {
cacheSize = "Unknown"
Logger.viewModel.error("Failed to get cache size: \(error.localizedDescription)")
}
}
@MainActor
func loadMaxCacheSize() async {
do {
let sizeInBytes = try await getMaxCacheSizeUseCase.execute()
maxCacheSize = Double(sizeInBytes) / (1024 * 1024)
Logger.viewModel.debug("Max cache size: \(maxCacheSize) MB")
} catch {
Logger.viewModel.error("Failed to load max cache size: \(error.localizedDescription)")
}
}
@MainActor
func updateMaxCacheSize(_ newSize: Double) async {
let bytes = UInt(newSize * 1024 * 1024)
do {
try await updateMaxCacheSizeUseCase.execute(sizeInBytes: bytes)
Logger.viewModel.info("Updated max cache size to \(newSize) MB")
} catch {
Logger.viewModel.error("Failed to update max cache size: \(error.localizedDescription)")
}
}
@MainActor
func clearCache() async {
isClearing = true
do {
try await clearCacheUseCase.execute()
await updateCacheSize()
Logger.viewModel.info("Cache cleared successfully")
} catch {
Logger.viewModel.error("Failed to clear cache: \(error.localizedDescription)")
}
isClearing = false
}
}

View File

@ -0,0 +1,200 @@
//
// CachedArticlesPreviewView.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import SwiftUI
struct CachedArticlesPreviewView: View {
// MARK: - State
@State private var viewModel = CachedArticlesPreviewViewModel()
@State private var selectedBookmarkId: String?
@EnvironmentObject var appSettings: AppSettings
// MARK: - Body
var body: some View {
ZStack {
if viewModel.isLoading && viewModel.cachedBookmarks.isEmpty {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.cachedBookmarks.isEmpty {
emptyStateView
} else {
cachedBookmarksList
}
}
.navigationTitle("Cached Articles".localized)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(
item: Binding<String?>(
get: { selectedBookmarkId },
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId)
.toolbar(.hidden, for: .tabBar)
}
.task {
await viewModel.loadCachedBookmarks()
}
}
// MARK: - View Components
@ViewBuilder
private var cachedBookmarksList: some View {
List {
Section {
ForEach(viewModel.cachedBookmarks, id: \.id) { bookmark in
Button(action: {
selectedBookmarkId = bookmark.id
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: .unread,
layout: .magazine,
onArchive: { _ in },
onDelete: { _ in },
onToggleFavorite: { _ in }
)
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(
top: 12,
leading: 16,
bottom: 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
}
} header: {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.caption)
Text(String(format: "%lld articles cached".localized, viewModel.cachedBookmarks.count))
.font(.caption)
.foregroundColor(.secondary)
}
.textCase(nil)
.padding(.bottom, 4)
} footer: {
Text("These articles are available offline. You can read them without an internet connection.".localized)
.font(.caption)
.foregroundColor(.secondary)
}
}
.listStyle(.insetGrouped)
.background(Color(R.color.bookmark_list_bg))
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.refreshList()
}
}
@ViewBuilder
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.3)
.tint(.accentColor)
VStack(spacing: 8) {
Text("Loading Cached Articles".localized)
.font(.headline)
.foregroundColor(.primary)
Text("Please wait...".localized)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
}
@ViewBuilder
private func errorView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.orange)
VStack(spacing: 8) {
Text("Unable to load cached articles".localized)
.font(.headline)
.foregroundColor(.primary)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Button("Try Again".localized) {
Task {
await viewModel.loadCachedBookmarks()
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(.horizontal, 40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
}
@ViewBuilder
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "tray")
.font(.system(size: 64))
.foregroundColor(.secondary.opacity(0.5))
VStack(spacing: 8) {
Text("No Cached Articles".localized)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Enable offline reading and sync to cache articles for offline access".localized)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Hint
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
.font(.caption)
Text("Use 'Sync Now' to download articles".localized)
.font(.caption)
}
.foregroundColor(.accentColor)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.clipShape(Capsule())
}
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
}
}
#Preview {
NavigationStack {
CachedArticlesPreviewView()
.environmentObject(AppSettings())
}
}

View File

@ -0,0 +1,53 @@
//
// CachedArticlesPreviewViewModel.swift
// readeck
//
// Created by Claude on 30.11.25.
//
import Foundation
import SwiftUI
@Observable
class CachedArticlesPreviewViewModel {
// MARK: - Dependencies
private let getCachedBookmarksUseCase: PGetCachedBookmarksUseCase
// MARK: - Published State
var cachedBookmarks: [Bookmark] = []
var isLoading = false
var errorMessage: String?
// MARK: - Initialization
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getCachedBookmarksUseCase = factory.makeGetCachedBookmarksUseCase()
}
// MARK: - Public Methods
@MainActor
func loadCachedBookmarks() async {
isLoading = true
errorMessage = nil
do {
Logger.viewModel.info("📱 CachedArticlesPreviewViewModel: Loading cached bookmarks...")
cachedBookmarks = try await getCachedBookmarksUseCase.execute()
Logger.viewModel.info("✅ Loaded \(cachedBookmarks.count) cached bookmarks for preview")
} catch {
Logger.viewModel.error("❌ Failed to load cached bookmarks: \(error.localizedDescription)")
errorMessage = "Failed to load cached articles"
}
isLoading = false
}
@MainActor
func refreshList() async {
await loadCachedBookmarks()
}
}

View File

@ -0,0 +1,180 @@
//
// OfflineReadingDetailView.swift
// readeck
//
// Created by Ilyas Hallak on 17.11.25.
//
import SwiftUI
struct OfflineReadingDetailView: View {
@State private var viewModel = OfflineSettingsViewModel()
@EnvironmentObject var appSettings: AppSettings
var body: some View {
List {
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Enable Offline Reading".localized, isOn: $viewModel.offlineSettings.enabled)
.onChange(of: viewModel.offlineSettings.enabled) {
Task {
await viewModel.saveSettings()
}
}
Text("Automatically download articles for offline use.".localized)
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
if viewModel.offlineSettings.enabled {
// Max articles slider
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Maximum Articles".localized)
Spacer()
Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(
value: $viewModel.offlineSettings.maxUnreadArticles,
in: 0...100,
step: 10
) {
Text("Max. Articles Offline".localized)
}
.onChange(of: viewModel.offlineSettings.maxUnreadArticles) {
Task {
await viewModel.saveSettings()
}
}
}
// Save images toggle
VStack(alignment: .leading, spacing: 4) {
Toggle("Save Images".localized, isOn: $viewModel.offlineSettings.saveImages)
.onChange(of: viewModel.offlineSettings.saveImages) {
Task {
await viewModel.saveSettings()
}
}
Text("Also download images for offline use.".localized)
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
}
} header: {
Text("Settings".localized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
} footer: {
Text("VPN connections are detected as active internet connections.".localized)
.font(.caption)
.foregroundColor(.secondary)
}
if viewModel.offlineSettings.enabled {
Section {
// Sync button
Button(action: {
Task {
await viewModel.syncNow()
}
}) {
HStack {
if viewModel.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Sync Now".localized)
.foregroundColor(viewModel.isSyncing ? .secondary : .blue)
if let progress = viewModel.syncProgress {
Text(progress)
.font(.caption)
.foregroundColor(.secondary)
} else if let lastSync = viewModel.offlineSettings.lastSyncDate {
Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
.disabled(viewModel.isSyncing)
// Cache stats with preview link
if viewModel.cachedArticlesCount > 0 {
SettingsRowNavigationLink(
icon: "doc.text.magnifyingglass",
iconColor: .green,
title: "Preview Cached Articles".localized,
subtitle: String(format: "%lld articles (%@)".localized, viewModel.cachedArticlesCount, viewModel.cacheSize)
) {
CachedArticlesPreviewView()
}
}
} header: {
Text("Synchronization".localized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
}
#if DEBUG
Section {
// Debug: Toggle offline mode simulation
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: Binding(
get: { !appSettings.isNetworkConnected },
set: { isOffline in
appSettings.isNetworkConnected = !isOffline
}
)) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Simulate Offline Mode".localized)
.foregroundColor(.orange)
Text("DEBUG: Toggle network status".localized)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
} header: {
Text("Debug".localized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
}
#endif
}
}
.listStyle(.insetGrouped)
.navigationTitle("Offline Reading".localized)
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadSettings()
}
}
}

View File

@ -0,0 +1,91 @@
//
// OfflineSettingsViewModel.swift
// readeck
//
// Created by Ilyas Hallak on 17.11.25.
//
import Foundation
import Observation
import Combine
@Observable
class OfflineSettingsViewModel {
// MARK: - Dependencies
private let settingsRepository: PSettingsRepository
private let offlineCacheSyncUseCase: POfflineCacheSyncUseCase
private var cancellables = Set<AnyCancellable>()
// MARK: - Published State
var offlineSettings: OfflineSettings = OfflineSettings()
var isSyncing: Bool = false
var syncProgress: String?
var cachedArticlesCount: Int = 0
var cacheSize: String = "0 KB"
// MARK: - Initialization
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.settingsRepository = factory.makeSettingsRepository()
self.offlineCacheSyncUseCase = factory.makeOfflineCacheSyncUseCase()
setupBindings()
}
// MARK: - Setup
private func setupBindings() {
// Bind isSyncing from UseCase
offlineCacheSyncUseCase.isSyncing
.receive(on: DispatchQueue.main)
.assign(to: \.isSyncing, on: self)
.store(in: &cancellables)
// Bind syncProgress from UseCase
offlineCacheSyncUseCase.syncProgress
.receive(on: DispatchQueue.main)
.assign(to: \.syncProgress, on: self)
.store(in: &cancellables)
}
// MARK: - Public Methods
@MainActor
func loadSettings() async {
do {
offlineSettings = try await settingsRepository.loadOfflineSettings()
updateCacheStats()
Logger.viewModel.debug("Loaded offline settings: enabled=\(offlineSettings.enabled)")
} catch {
Logger.viewModel.error("Failed to load offline settings: \(error.localizedDescription)")
}
}
@MainActor
func saveSettings() async {
do {
try await settingsRepository.saveOfflineSettings(offlineSettings)
Logger.viewModel.debug("Saved offline settings")
} catch {
Logger.viewModel.error("Failed to save offline settings: \(error.localizedDescription)")
}
}
@MainActor
func syncNow() async {
Logger.viewModel.info("Manual sync triggered")
await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings)
// Reload settings to get updated lastSyncDate
await loadSettings()
updateCacheStats()
}
@MainActor
func updateCacheStats() {
cachedArticlesCount = offlineCacheSyncUseCase.getCachedArticlesCount()
cacheSize = offlineCacheSyncUseCase.getCacheSize()
}
}

View File

@ -8,6 +8,7 @@
import SwiftUI
struct SettingsContainerView: View {
@State private var offlineViewModel = OfflineSettingsViewModel()
private var appVersion: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
@ -19,11 +20,67 @@ struct SettingsContainerView: View {
List {
AppearanceSettingsView()
ReadingSettingsView()
Section {
Toggle("Enable Offline Reading".localized, isOn: $offlineViewModel.offlineSettings.enabled)
.onChange(of: offlineViewModel.offlineSettings.enabled) {
Task {
await offlineViewModel.saveSettings()
}
}
if offlineViewModel.offlineSettings.enabled {
Button(action: {
Task {
await offlineViewModel.syncNow()
}
}) {
HStack {
if offlineViewModel.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Sync Now".localized)
.foregroundColor(offlineViewModel.isSyncing ? .secondary : .blue)
if let progress = offlineViewModel.syncProgress {
Text(progress)
.font(.caption)
.foregroundColor(.secondary)
} else if let lastSync = offlineViewModel.offlineSettings.lastSyncDate {
Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
.disabled(offlineViewModel.isSyncing)
SettingsRowNavigationLink(
icon: "arrow.down.circle.fill",
iconColor: .blue,
title: "Offline Reading".localized,
subtitle: offlineViewModel.cachedArticlesCount > 0 ? String(format: "%lld articles cached".localized, offlineViewModel.cachedArticlesCount) : nil
) {
OfflineReadingDetailView()
}
}
} header: {
Text("Offline Reading".localized)
} footer: {
Text("Automatically download articles for offline use.".localized + " " + "VPN connections are detected as active internet connections.".localized)
}
CacheSettingsView()
SyncSettingsView()
ReadingSettingsView()
SettingsServerView()
@ -42,11 +99,23 @@ struct SettingsContainerView: View {
.listStyle(.insetGrouped)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
.task {
await offlineViewModel.loadSettings()
}
}
@ViewBuilder
private var debugSettingsSection: some View {
Section {
SettingsRowNavigationLink(
icon: "wrench.and.screwdriver.fill",
iconColor: .orange,
title: "Debug Menu",
subtitle: "Network simulation, data management & more"
) {
DebugMenuView()
}
SettingsRowNavigationLink(
icon: "list.bullet.rectangle",
iconColor: .blue,

View File

@ -46,15 +46,6 @@ struct SettingsGeneralView: View {
}
#if DEBUG
Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
if viewModel.autoSyncEnabled {
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} header: {
Text("Sync Settings")
}
Section {
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)

View File

@ -14,16 +14,4 @@ extension String {
return attributedString?.string ?? self
}
var stripHTMLSimple: String {
// Einfache Regex-basierte HTML-Entfernung
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -14,6 +14,10 @@ struct readeckApp: App {
@StateObject private var appSettings = AppSettings()
@Environment(\.scenePhase) private var scenePhase
#if DEBUG
@State private var showDebugMenu = false
#endif
var body: some Scene {
WindowGroup {
Group {
@ -27,6 +31,15 @@ struct readeckApp: App {
.environmentObject(appSettings)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
.preferredColorScheme(appSettings.theme.colorScheme)
#if DEBUG
.onShake {
showDebugMenu = true
}
.sheet(isPresented: $showDebugMenu) {
DebugMenuView()
.environmentObject(appSettings)
}
#endif
.onAppear {
#if DEBUG
NFX.sharedInstance().start()
@ -34,6 +47,7 @@ struct readeckApp: App {
Task {
await loadAppSettings()
}
appViewModel.bindNetworkStatus(to: appSettings)
}
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
Task {
@ -58,58 +72,3 @@ struct readeckApp: App {
}
}
}
struct TestView: View {
var body: some View {
if #available(iOS 26.0, *) {
Text("hello")
.toolbar {
ToolbarSpacer(.flexible)
ToolbarItem {
Button {
} label: {
Label("Favorite", systemImage: "share")
.symbolVariant(.none)
}
}
ToolbarSpacer(.fixed)
ToolbarItemGroup {
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
}
.toolbar(removing: .title)
.ignoresSafeArea(edges: .top)
} else {
Text("hello1")
}
}
}

View File

@ -39,6 +39,7 @@ enum LogCategory: String, CaseIterable, Codable {
case general = "General"
case manual = "Manual"
case viewModel = "ViewModel"
case sync = "Sync"
}
class LogConfiguration: ObservableObject {
@ -260,6 +261,7 @@ extension Logger {
static let general = Logger(category: .general)
static let manual = Logger(category: .manual)
static let viewModel = Logger(category: .viewModel)
static let sync = Logger(category: .sync)
}
// MARK: - Performance Measurement Helper

View File

@ -8,16 +8,22 @@
</entity>
<entity name="BookmarkEntity" representedClassName="BookmarkEntity" syncable="YES" codeGenerationType="class">
<attribute name="authors" optional="YES" attributeType="String"/>
<attribute name="cachedDate" optional="YES" attributeType="Date" usesScalarValueType="NO" indexed="YES"/>
<attribute name="cacheSize" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="created" optional="YES" attributeType="String"/>
<attribute name="desc" optional="YES" attributeType="String"/>
<attribute name="documentType" optional="YES" attributeType="String"/>
<attribute name="hasArticle" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasDeleted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="heroImageURL" optional="YES" attributeType="String"/>
<attribute name="href" optional="YES" attributeType="String"/>
<attribute name="htmlContent" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="imageURLs" optional="YES" attributeType="String"/>
<attribute name="isArchived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isMarked" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lang" optional="YES" attributeType="String"/>
<attribute name="lastAccessDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="loaded" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="published" optional="YES" attributeType="String"/>
<attribute name="readingTime" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
@ -55,6 +61,10 @@
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="offlineEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="offlineLastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="offlineMaxUnreadArticles" optional="YES" attributeType="Double" defaultValueString="20" usesScalarValueType="YES"/>
<attribute name="offlineSaveImages" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="tagSortOrder" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/>

View File

@ -0,0 +1,347 @@
//
// OfflineCacheRepositoryTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 21.11.25.
//
import Testing
import Foundation
import CoreData
@testable import readeck
@Suite("OfflineCacheRepository Tests")
struct OfflineCacheRepositoryTests {
// MARK: - Test Setup
private func createInMemoryCoreDataStack() -> NSManagedObjectContext {
let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.main])!
let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
try! persistentStoreCoordinator.addPersistentStore(
ofType: NSInMemoryStoreType,
configurationName: nil,
at: nil,
options: nil
)
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.persistentStoreCoordinator = persistentStoreCoordinator
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}
private func createTestBookmark(id: String = "test-123", title: String = "Test Article") -> Bookmark {
return Bookmark(
id: id,
title: title,
url: "https://example.com/article",
href: "/api/bookmarks/\(id)",
description: "Test description",
authors: [],
created: ISO8601DateFormatter().string(from: Date()),
published: nil,
updated: ISO8601DateFormatter().string(from: Date()),
siteName: "Example Site",
site: "example.com",
readingTime: 5,
wordCount: 1000,
hasArticle: true,
isArchived: false,
isDeleted: false,
isMarked: false,
labels: [],
lang: "en",
loaded: true,
readProgress: 0,
documentType: "article",
state: 0,
textDirection: "ltr",
type: "bookmark",
resources: BookmarkResources(
article: Resource(src: "/api/bookmarks/\(id)/article"),
icon: nil,
image: nil,
log: nil,
props: nil,
thumbnail: nil
)
)
}
// MARK: - HTML Extraction Tests
@Test("Extract image URLs from HTML correctly")
func testExtractImageURLsFromHTML() {
let html = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png" />
<img src="/relative/image.jpg" alt="Relative">
<img src="https://example.com/image3.gif">
</body>
</html>
"""
// We need to test the private method indirectly via cacheBookmarkWithMetadata
// For now, we'll test the regex pattern separately
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
var imageURLs: [String] = []
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String?,
url.hasPrefix("http") {
imageURLs.append(url)
}
}
}
#expect(imageURLs.count == 3)
#expect(imageURLs.contains("https://example.com/image1.jpg"))
#expect(imageURLs.contains("https://example.com/image2.png"))
#expect(imageURLs.contains("https://example.com/image3.gif"))
#expect(!imageURLs.contains("/relative/image.jpg"))
}
@Test("Extract image URLs handles empty HTML")
func testExtractImageURLsFromEmptyHTML() {
let html = "<html><body><p>No images here</p></body></html>"
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))
#expect(results.count == 0)
}
@Test("Extract image URLs handles malformed HTML")
func testExtractImageURLsFromMalformedHTML() {
let html = """
<img src='single-quotes.jpg'>
<img src=no-quotes.jpg>
<img src="https://valid.com/image.jpg">
"""
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))
// Should only match double-quoted URLs
#expect(results.count == 1)
}
// MARK: - Cache Size Calculation Tests
@Test("Cache size calculation is accurate")
func testCacheSizeCalculation() {
let html = "Test HTML content"
let expectedSize = Int64(html.utf8.count)
#expect(expectedSize == 17)
}
@Test("Cache size handles empty content")
func testCacheSizeWithEmptyContent() {
let html = ""
let expectedSize = Int64(html.utf8.count)
#expect(expectedSize == 0)
}
@Test("Cache size handles UTF-8 characters correctly")
func testCacheSizeWithUTF8Characters() {
let html = "Hello 世界 🌍"
let expectedSize = Int64(html.utf8.count)
// UTF-8: "Hello " (6) + "" (6) + " " (1) + "🌍" (4) = 17 bytes
#expect(expectedSize > html.count) // More bytes than characters
}
// MARK: - Image URL Storage Tests
@Test("Image URLs are joined correctly with comma separator")
func testImageURLsJoining() {
let imageURLs = [
"https://example.com/image1.jpg",
"https://example.com/image2.png",
"https://example.com/image3.gif"
]
let joined = imageURLs.joined(separator: ",")
#expect(joined == "https://example.com/image1.jpg,https://example.com/image2.png,https://example.com/image3.gif")
// Test splitting
let split = joined.split(separator: ",").map(String.init)
#expect(split.count == 3)
#expect(split == imageURLs)
}
@Test("Image URLs splitting handles empty string")
func testImageURLsSplittingEmptyString() {
let imageURLsString = ""
let split = imageURLsString.split(separator: ",").map(String.init)
#expect(split.isEmpty)
}
@Test("Image URLs splitting handles single URL")
func testImageURLsSplittingSingleURL() {
let imageURLsString = "https://example.com/single.jpg"
let split = imageURLsString.split(separator: ",").map(String.init)
#expect(split.count == 1)
#expect(split.first == "https://example.com/single.jpg")
}
// MARK: - Bookmark Domain Model Tests
@Test("Bookmark creation has correct defaults")
func testBookmarkCreation() {
let bookmark = createTestBookmark()
#expect(bookmark.id == "test-123")
#expect(bookmark.title == "Test Article")
#expect(bookmark.url == "https://example.com/article")
#expect(bookmark.readProgress == 0)
#expect(bookmark.isMarked == false)
}
// MARK: - FIFO Cleanup Logic Tests
@Test("FIFO cleanup calculates correct number of items to delete")
func testFIFOCleanupCalculation() {
let totalCount = 30
let keepCount = 20
let expectedDeleteCount = totalCount - keepCount
#expect(expectedDeleteCount == 10)
}
@Test("FIFO cleanup does not delete when under limit")
func testFIFOCleanupUnderLimit() {
let totalCount = 15
let keepCount = 20
if totalCount > keepCount {
#expect(Bool(false), "Should not trigger cleanup")
} else {
#expect(Bool(true), "Cleanup should be skipped")
}
}
@Test("FIFO cleanup deletes all items when keepCount is zero")
func testFIFOCleanupKeepZero() {
let totalCount = 10
let keepCount = 0
let expectedDeleteCount = totalCount - keepCount
#expect(expectedDeleteCount == 10)
}
// MARK: - Date Handling Tests
@Test("Cache date and access date are set correctly")
func testCacheDates() {
let now = Date()
let cachedDate = now
let lastAccessDate = now
#expect(cachedDate.timeIntervalSince1970 == now.timeIntervalSince1970)
#expect(lastAccessDate.timeIntervalSince1970 == now.timeIntervalSince1970)
}
@Test("Last access date updates on read")
func testLastAccessDateUpdate() {
let initialDate = Date(timeIntervalSince1970: 1000)
let updatedDate = Date(timeIntervalSince1970: 2000)
#expect(updatedDate > initialDate)
#expect(updatedDate.timeIntervalSince1970 - initialDate.timeIntervalSince1970 == 1000)
}
// MARK: - ByteCountFormatter Tests
@Test("ByteCountFormatter formats small sizes correctly")
func testByteCountFormatterSmallSizes() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let bytes1KB = Int64(1024)
let formatted1KB = formatter.string(fromByteCount: bytes1KB)
#expect(formatted1KB.contains("KB") || formatted1KB.contains("kB"))
let bytes10KB = Int64(10 * 1024)
let formatted10KB = formatter.string(fromByteCount: bytes10KB)
#expect(formatted10KB.contains("KB") || formatted10KB.contains("kB"))
}
@Test("ByteCountFormatter formats large sizes correctly")
func testByteCountFormatterLargeSizes() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let bytes1MB = Int64(1024 * 1024)
let formatted1MB = formatter.string(fromByteCount: bytes1MB)
#expect(formatted1MB.contains("MB"))
let bytes100MB = Int64(100 * 1024 * 1024)
let formatted100MB = formatter.string(fromByteCount: bytes100MB)
#expect(formatted100MB.contains("MB"))
}
@Test("ByteCountFormatter handles zero bytes")
func testByteCountFormatterZero() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let formatted = formatter.string(fromByteCount: 0)
#expect(formatted.contains("0") || formatted.contains("Zero"))
}
// MARK: - NSPredicate Tests
@Test("Cache filter predicate syntax is correct")
func testCacheFilterPredicate() {
let predicate = NSPredicate(format: "htmlContent != nil")
// Test with mock data
let testData = ["htmlContent": "Some HTML"]
let result = predicate.evaluate(with: testData)
#expect(result == true)
}
@Test("ID filter predicate syntax is correct")
func testIDFilterPredicate() {
let testID = "test-123"
let predicate = NSPredicate(format: "id == %@", testID)
let testData = ["id": "test-123"]
let result = predicate.evaluate(with: testData)
#expect(result == true)
let wrongData = ["id": "wrong-id"]
let wrongResult = predicate.evaluate(with: wrongData)
#expect(wrongResult == false)
}
@Test("Combined cache and ID predicate is correct")
func testCombinedPredicate() {
let testID = "test-123"
let predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", testID)
let validData = ["id": "test-123", "htmlContent": "HTML"]
let validResult = predicate.evaluate(with: validData)
#expect(validResult == true)
let missingHTML = ["id": "test-123", "htmlContent": nil as String?]
let missingHTMLResult = predicate.evaluate(with: missingHTML)
#expect(missingHTMLResult == false)
}
}

View File

@ -0,0 +1,185 @@
//
// OfflineSettingsTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 21.11.25.
//
import Testing
import Foundation
@testable import readeck
@Suite("OfflineSettings Tests")
struct OfflineSettingsTests {
// MARK: - Initialization Tests
@Test("Default initialization has correct values")
func testDefaultInitialization() {
let settings = OfflineSettings()
#expect(settings.enabled == true)
#expect(settings.maxUnreadArticles == 20.0)
#expect(settings.saveImages == false)
#expect(settings.lastSyncDate == nil)
#expect(settings.maxUnreadArticlesInt == 20)
}
// MARK: - maxUnreadArticlesInt Tests
@Test("maxUnreadArticlesInt converts Double to Int correctly")
func testMaxUnreadArticlesIntConversion() {
var settings = OfflineSettings()
settings.maxUnreadArticles = 15.0
#expect(settings.maxUnreadArticlesInt == 15)
settings.maxUnreadArticles = 50.7
#expect(settings.maxUnreadArticlesInt == 50)
settings.maxUnreadArticles = 99.9
#expect(settings.maxUnreadArticlesInt == 99)
}
// MARK: - shouldSyncOnAppStart Tests
@Test("shouldSyncOnAppStart returns false when disabled")
func testShouldNotSyncWhenDisabled() {
var settings = OfflineSettings()
settings.enabled = false
settings.lastSyncDate = nil // Never synced
#expect(settings.shouldSyncOnAppStart == false)
}
@Test("shouldSyncOnAppStart returns true when never synced")
func testShouldSyncWhenNeverSynced() {
var settings = OfflineSettings()
settings.enabled = true
settings.lastSyncDate = nil
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart returns true when last sync was more than 4 hours ago")
func testShouldSyncWhenLastSyncOlderThan4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test with 5 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-5 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == true)
// Test with 4.5 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-4.5 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == true)
// Test with exactly 4 hours + 1 second ago
settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 1)
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart returns false when last sync was less than 4 hours ago")
func testShouldNotSyncWhenLastSyncWithin4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test with 3 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with 1 hour ago
settings.lastSyncDate = Date().addingTimeInterval(-1 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with 1 minute ago
settings.lastSyncDate = Date().addingTimeInterval(-60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with just now
settings.lastSyncDate = Date()
#expect(settings.shouldSyncOnAppStart == false)
}
@Test("shouldSyncOnAppStart boundary test near 4 hours")
func testShouldSyncBoundaryAt4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test slightly under 4 hours (3h 59m 30s) - should NOT sync
settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60 - 59 * 60 - 30)
#expect(settings.shouldSyncOnAppStart == false)
// Test slightly over 4 hours (4h 0m 30s) - should sync
settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 30)
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart with future date edge case")
func testShouldNotSyncWithFutureDate() {
var settings = OfflineSettings()
settings.enabled = true
// Edge case: lastSyncDate in the future (clock skew/bug)
settings.lastSyncDate = Date().addingTimeInterval(60 * 60) // 1 hour in future
#expect(settings.shouldSyncOnAppStart == false)
}
// MARK: - Codable Tests
@Test("OfflineSettings is encodable and decodable")
func testCodableRoundTrip() throws {
var original = OfflineSettings()
original.enabled = false
original.maxUnreadArticles = 35.0
original.saveImages = true
original.lastSyncDate = Date(timeIntervalSince1970: 1699999999)
let encoder = JSONEncoder()
let data = try encoder.encode(original)
let decoder = JSONDecoder()
let decoded = try decoder.decode(OfflineSettings.self, from: data)
#expect(decoded.enabled == original.enabled)
#expect(decoded.maxUnreadArticles == original.maxUnreadArticles)
#expect(decoded.saveImages == original.saveImages)
#expect(decoded.lastSyncDate?.timeIntervalSince1970 == original.lastSyncDate?.timeIntervalSince1970)
}
@Test("OfflineSettings decodes with missing optional fields")
func testDecodingWithMissingFields() throws {
let json = """
{
"enabled": true,
"maxUnreadArticles": 25.0,
"saveImages": false
}
"""
let data = json.data(using: .utf8)!
let decoder = JSONDecoder()
let settings = try decoder.decode(OfflineSettings.self, from: data)
#expect(settings.enabled == true)
#expect(settings.maxUnreadArticles == 25.0)
#expect(settings.saveImages == false)
#expect(settings.lastSyncDate == nil)
}
// MARK: - Edge Cases
@Test("maxUnreadArticles handles extreme values")
func testMaxUnreadArticlesExtremeValues() {
var settings = OfflineSettings()
settings.maxUnreadArticles = 0.0
#expect(settings.maxUnreadArticlesInt == 0)
settings.maxUnreadArticles = 1000.0
#expect(settings.maxUnreadArticlesInt == 1000)
settings.maxUnreadArticles = 0.1
#expect(settings.maxUnreadArticlesInt == 0)
}
}

View File

@ -59,73 +59,6 @@ final class StringExtensionsTests: XCTestCase {
XCTAssertEqual(onlyTags.stripHTML, expected)
}
// MARK: - stripHTMLSimple Tests
func testStripHTMLSimple_BasicTags() {
let html = "<p>Text mit <strong>fett</strong>.</p>"
let expected = "Text mit fett."
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_HTMLEntities() {
let html = "<p>Text mit &nbsp;Leerzeichen, &amp; Zeichen und &quot;Anführungszeichen&quot;.</p>"
let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_MoreEntities() {
let html = "<p>&lt;Tag&gt; und &#39;Apostroph&#39;</p>"
let expected = "<Tag> und 'Apostroph'"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_ComplexHTML() {
let html = "<div class=\"container\"><h1>Überschrift</h1><p>Absatz mit <em>kursiv</em> und <strong>fett</strong>.</p><ul><li>Liste 1</li><li>Liste 2</li></ul></div>"
let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_NoTags() {
let plainText = "Normaler Text ohne HTML."
XCTAssertEqual(plainText.stripHTMLSimple, plainText)
}
func testStripHTMLSimple_EmptyString() {
let emptyString = ""
XCTAssertEqual(emptyString.stripHTMLSimple, emptyString)
}
func testStripHTMLSimple_WhitespaceHandling() {
let html = " <p> Text mit Whitespace </p> "
let expected = "Text mit Whitespace"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
// MARK: - Performance Tests
func testStripHTML_Performance() {
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
measure {
_ = largeHTML.stripHTML
}
}
func testStripHTMLSimple_Performance() {
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
measure {
_ = largeHTML.stripHTMLSimple
}
}
// MARK: - Edge Cases
func testStripHTML_MalformedHTML() {

View File

@ -0,0 +1,306 @@
//
// HTMLImageEmbedderTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 30.11.25.
//
import Testing
import Foundation
import Kingfisher
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@testable import readeck
@Suite("HTMLImageEmbedder Tests")
struct HTMLImageEmbedderTests {
// MARK: - Test Data
private let htmlWithImages = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png">
</body>
</html>
"""
private let htmlWithoutImages = """
<html>
<body>
<p>Just text, no images here.</p>
</body>
</html>
"""
private let htmlWithDataURI = """
<html>
<body>
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w==">
<img src="https://example.com/new-image.jpg">
</body>
</html>
"""
// MARK: - Helper Methods
/// Creates a test image and caches it in Kingfisher for testing
private func cacheTestImage(url: URL) async {
// Create a simple 1x1 pixel red image for testing
#if os(iOS)
let size = CGSize(width: 1, height: 1)
UIGraphicsBeginImageContextWithOptions(size, false, 1.0)
UIColor.red.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
#elseif os(macOS)
let size = NSSize(width: 1, height: 1)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.setFill()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
#endif
if let image = image {
// Store both in memory and on disk for testing
let options = KingfisherParsedOptionsInfo([
.cacheOriginalImage,
.diskCacheExpiration(.never)
])
try? await ImageCache.default.store(image, forKey: url.cacheKey, options: options)
// Small delay to ensure cache write completes
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
}
/// Clears all cached images after tests
private func clearTestCache() async {
// Clear both memory and disk cache
await ImageCache.default.clearMemoryCache()
await ImageCache.default.clearDiskCache()
// Small delay to ensure cache clear completes
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
// MARK: - Basic Functionality Tests
@Test("Embed Base64 images converts URLs to data URIs")
func testEmbedBase64ImagesConvertsURLs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache test images first
let url1 = URL(string: "https://example.com/image1.jpg")!
let url2 = URL(string: "https://example.com/image2.png")!
await cacheTestImage(url: url1)
await cacheTestImage(url: url2)
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Verify images were embedded as Base64
#expect(result.contains("data:image/jpeg;base64,"))
#expect(!result.contains("https://example.com/image1.jpg"))
#expect(!result.contains("https://example.com/image2.png"))
await clearTestCache()
}
@Test("Embed Base64 images skips images not in cache")
func testEmbedBase64ImagesSkipsUncachedImages() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Don't cache any images - all should be skipped
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Original URLs should remain unchanged
#expect(result.contains("https://example.com/image1.jpg"))
#expect(result.contains("https://example.com/image2.png"))
#expect(!result.contains("data:image/jpeg;base64,"))
}
@Test("Embed Base64 images increases HTML size")
func testEmbedBase64ImagesIncreasesHTMLSize() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache one test image
let url1 = URL(string: "https://example.com/image1.jpg")!
await cacheTestImage(url: url1)
let originalSize = htmlWithImages.utf8.count
let result = await embedder.embedBase64Images(in: htmlWithImages)
let newSize = result.utf8.count
// Base64 encoded images should make HTML larger
#expect(newSize > originalSize)
await clearTestCache()
}
@Test("Embed Base64 images uses JPEG format with quality 0.85")
func testEmbedBase64ImagesUsesJPEGFormat() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let url = URL(string: "https://example.com/image1.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Verify data URI uses JPEG format
#expect(result.contains("data:image/jpeg;base64,"))
await clearTestCache()
}
// MARK: - Edge Case Tests
@Test("Embed Base64 images handles empty HTML")
func testEmbedBase64ImagesHandlesEmptyHTML() async {
let embedder = HTMLImageEmbedder()
let emptyHTML = ""
let result = await embedder.embedBase64Images(in: emptyHTML)
#expect(result.isEmpty)
#expect(result == emptyHTML)
}
@Test("Embed Base64 images handles HTML without images")
func testEmbedBase64ImagesHandlesHTMLWithoutImages() async {
let embedder = HTMLImageEmbedder()
let result = await embedder.embedBase64Images(in: htmlWithoutImages)
// Should return unchanged HTML
#expect(result == htmlWithoutImages)
#expect(!result.contains("data:image"))
}
@Test("Embed Base64 images skips already embedded data URIs")
func testEmbedBase64ImagesSkipsDataURIs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache the non-data URI image
let url = URL(string: "https://example.com/new-image.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlWithDataURI)
// Original data URI should remain
#expect(result.contains("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w=="))
// New image should be embedded
#expect(!result.contains("https://example.com/new-image.jpg"))
await clearTestCache()
}
@Test("Embed Base64 images processes multiple images correctly")
func testEmbedBase64ImagesProcessesMultipleImages() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlMultiple = """
<img src="https://example.com/img1.jpg">
<img src="https://example.com/img2.jpg">
<img src="https://example.com/img3.jpg">
"""
// Cache all three images
for i in 1...3 {
let url = URL(string: "https://example.com/img\(i).jpg")!
await cacheTestImage(url: url)
}
let result = await embedder.embedBase64Images(in: htmlMultiple)
// All three should be embedded
let dataURICount = result.components(separatedBy: "data:image/jpeg;base64,").count - 1
#expect(dataURICount == 3)
// None of the original URLs should remain
#expect(!result.contains("https://example.com/img1.jpg"))
#expect(!result.contains("https://example.com/img2.jpg"))
#expect(!result.contains("https://example.com/img3.jpg"))
await clearTestCache()
}
// MARK: - Statistics & Logging Tests
@Test("Embed Base64 images tracks success and failure counts")
func testEmbedBase64ImagesTracksStatistics() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlMixed = """
<img src="https://cached.com/image.jpg">
<img src="https://not-cached.com/image.jpg">
"""
// Cache only the first image
let cachedURL = URL(string: "https://cached.com/image.jpg")!
await cacheTestImage(url: cachedURL)
let result = await embedder.embedBase64Images(in: htmlMixed)
// First image should be embedded
#expect(result.contains("data:image/jpeg;base64,"))
#expect(!result.contains("https://cached.com/image.jpg"))
// Second image should remain as URL
#expect(result.contains("https://not-cached.com/image.jpg"))
await clearTestCache()
}
@Test("Embed Base64 images handles invalid URLs gracefully")
func testEmbedBase64ImagesHandlesInvalidURLs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlInvalid = """
<img src="not a valid url">
<img src="https://valid.com/image.jpg">
"""
let url = URL(string: "https://valid.com/image.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlInvalid)
// Invalid URL should remain unchanged
#expect(result.contains("not a valid url"))
// Valid URL should be embedded
#expect(!result.contains("https://valid.com/image.jpg"))
#expect(result.contains("data:image/jpeg;base64,"))
await clearTestCache()
}
}

View File

@ -0,0 +1,194 @@
//
// HTMLImageExtractorTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 30.11.25.
//
import Testing
import Foundation
@testable import readeck
@Suite("HTMLImageExtractor Tests")
struct HTMLImageExtractorTests {
// MARK: - Test Data
private let htmlWithImages = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png" />
<img src="https://example.com/image3.gif">
</body>
</html>
"""
private let htmlWithMixedURLs = """
<html>
<body>
<img src="https://absolute.com/img.jpg">
<img src="/relative/path.jpg">
<img src="data:image/jpeg;base64,abc123">
<img src="https://another.com/photo.png">
</body>
</html>
"""
private let htmlWithoutImages = """
<html>
<body>
<p>This is just text content with no images.</p>
<div>Some more content</div>
</body>
</html>
"""
private let htmlEmpty = ""
// MARK: - Basic Functionality Tests
@Test("Extract finds all absolute image URLs from HTML")
func testExtractFindsAllImageURLs() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithImages)
#expect(imageURLs.count == 3)
#expect(imageURLs.contains("https://example.com/image1.jpg"))
#expect(imageURLs.contains("https://example.com/image2.png"))
#expect(imageURLs.contains("https://example.com/image3.gif"))
}
@Test("Extract only includes absolute URLs with http or https")
func testExtractOnlyIncludesAbsoluteURLs() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithMixedURLs)
#expect(imageURLs.count == 2)
#expect(imageURLs.contains("https://absolute.com/img.jpg"))
#expect(imageURLs.contains("https://another.com/photo.png"))
// Verify relative and data URIs are NOT included
#expect(!imageURLs.contains("/relative/path.jpg"))
#expect(!imageURLs.contains(where: { $0.hasPrefix("data:") }))
}
@Test("Extract returns empty array when HTML has no images")
func testExtractReturnsEmptyArrayWhenNoImages() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithoutImages)
#expect(imageURLs.isEmpty)
}
// MARK: - Edge Case Tests
@Test("Extract ignores relative URLs without http prefix")
func testExtractIgnoresRelativeURLs() {
let htmlWithRelative = """
<img src="/images/logo.png">
<img src="./photos/pic.jpg">
<img src="../assets/icon.svg">
<img src="https://valid.com/image.jpg">
"""
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithRelative)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == "https://valid.com/image.jpg")
}
@Test("Extract handles empty HTML string")
func testExtractHandlesEmptyHTML() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlEmpty)
#expect(imageURLs.isEmpty)
}
@Test("Extract ignores data URI images")
func testExtractIgnoresDataURIs() {
let htmlWithDataURI = """
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w==">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">
<img src="https://example.com/real-image.jpg">
"""
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithDataURI)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == "https://example.com/real-image.jpg")
// Verify no data URIs are included
for url in imageURLs {
#expect(!url.hasPrefix("data:"))
}
}
// MARK: - Hero/Thumbnail Tests
@Test("Extract with hero image prepends it to array")
func testExtractWithHeroImagePrependsToArray() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: heroURL,
thumbnailURL: nil
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 hero
#expect(imageURLs.first == heroURL) // Hero should be at position 0
#expect(imageURLs.contains("https://example.com/image1.jpg"))
}
@Test("Extract with thumbnail prepends it when no hero image")
func testExtractWithThumbnailPrependsWhenNoHero() {
let extractor = HTMLImageExtractor()
let thumbnailURL = "https://example.com/thumbnail.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: nil,
thumbnailURL: thumbnailURL
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 thumbnail
#expect(imageURLs.first == thumbnailURL) // Thumbnail should be at position 0
}
@Test("Extract prefers hero image over thumbnail when both provided")
func testExtractPrefersHeroOverThumbnail() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let thumbnailURL = "https://example.com/thumbnail.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: heroURL,
thumbnailURL: thumbnailURL
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 hero (thumbnail ignored)
#expect(imageURLs.first == heroURL) // Hero takes precedence
#expect(!imageURLs.contains(thumbnailURL)) // Thumbnail should NOT be added
}
@Test("Extract with hero and thumbnail but no HTML images")
func testExtractWithHeroAndNoHTMLImages() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let imageURLs = extractor.extract(
from: htmlWithoutImages,
heroImageURL: heroURL,
thumbnailURL: nil
)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == heroURL)
}
}

View File

@ -0,0 +1,239 @@
//
// KingfisherImagePrefetcherTests.swift
// readeckTests
//
// Created by Ilyas Hallak on 30.11.25.
//
import Testing
import Foundation
import Kingfisher
@testable import readeck
import UIKit
@Suite("KingfisherImagePrefetcher Tests")
struct KingfisherImagePrefetcherTests {
// MARK: - Test Setup & Helpers
/// Mock server URL for test images
private let testImageURL1 = URL(string: "https://via.placeholder.com/150/FF0000/FFFFFF?text=Test1")!
private let testImageURL2 = URL(string: "https://via.placeholder.com/150/00FF00/FFFFFF?text=Test2")!
private let testImageURL3 = URL(string: "https://via.placeholder.com/150/0000FF/FFFFFF?text=Test3")!
/// Creates a simple test image for caching
private func createTestImage() -> KFCrossPlatformImage {
#if os(iOS)
let size = CGSize(width: 10, height: 10)
UIGraphicsBeginImageContextWithOptions(size, false, 1.0)
UIColor.blue.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return image
#elseif os(macOS)
let size = NSSize(width: 10, height: 10)
let image = NSImage(size: size)
image.lockFocus()
NSColor.blue.setFill()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
return image
#endif
}
/// Clears Kingfisher cache after tests
private func clearCache() async {
await ImageCache.default.clearCache()
}
/// Checks if an image is cached
private func isImageCached(forKey key: String) async -> Bool {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
// MARK: - Prefetch Tests
@Test("Prefetch images handles empty URL array")
func testPrefetchImagesHandlesEmptyArray() async {
let prefetcher = KingfisherImagePrefetcher()
let emptyURLs: [URL] = []
// Should complete without errors
await prefetcher.prefetchImages(urls: emptyURLs)
// No assertions needed - just verify it doesn't crash
#expect(emptyURLs.isEmpty)
}
@Test("Prefetch images uses never expiration for disk cache")
func testPrefetchImagesUsesNeverExpiration() async {
// This test verifies the configuration is set correctly
// The actual implementation uses .diskCacheExpiration(.never)
let prefetcher = KingfisherImagePrefetcher()
// Pre-cache a test image to verify it persists
let testURL = URL(string: "https://example.com/test.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(
testImage,
forKey: testURL.cacheKey,
options: KingfisherParsedOptionsInfo([.diskCacheExpiration(.never)])
)
let isCached = await isImageCached(forKey: testURL.cacheKey)
#expect(isCached == true)
await clearCache()
}
@Test("Verify prefetched images confirms cache status")
func testVerifyPrefetchedImagesConfirmsCacheStatus() async {
let prefetcher = KingfisherImagePrefetcher()
// Manually cache some test images
let url1 = URL(string: "https://example.com/cached1.jpg")!
let url2 = URL(string: "https://example.com/cached2.jpg")!
let url3 = URL(string: "https://example.com/not-cached.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: url1.cacheKey)
try? await ImageCache.default.store(testImage, forKey: url2.cacheKey)
// Verify the cached ones
await prefetcher.verifyPrefetchedImages([url1, url2, url3])
// Check that first two are cached
let isCached1 = await isImageCached(forKey: url1.cacheKey)
let isCached2 = await isImageCached(forKey: url2.cacheKey)
let isCached3 = await isImageCached(forKey: url3.cacheKey)
#expect(isCached1 == true)
#expect(isCached2 == true)
#expect(isCached3 == false)
await clearCache()
}
// MARK: - Custom Cache Key Tests
@Test("Cache image with custom key stores correctly")
func testCacheImageWithCustomKeyStoresCorrectly() async {
let prefetcher = KingfisherImagePrefetcher()
let customKey = "bookmark-123-hero"
// Pre-cache a test image with URL key so it can be "downloaded"
let sourceURL = URL(string: "https://example.com/hero.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: sourceURL.cacheKey)
// Now use the prefetcher to cache with custom key
await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey)
// Verify it's cached with custom key
let isCached = await isImageCached(forKey: customKey)
#expect(isCached == true)
await clearCache()
}
@Test("Cache image with custom key skips if already cached")
func testCacheImageWithCustomKeySkipsIfAlreadyCached() async {
let prefetcher = KingfisherImagePrefetcher()
let customKey = "bookmark-456-hero"
let sourceURL = URL(string: "https://example.com/hero2.jpg")!
// Pre-cache with custom key
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: customKey)
// Call again - should skip (verify by checking it doesn't fail)
await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey)
// Should still be cached
let isCached = await isImageCached(forKey: customKey)
#expect(isCached == true)
await clearCache()
}
// MARK: - Clear Cache Tests
@Test("Clear cached images removes all specified URLs")
func testClearCachedImagesRemovesAllURLs() async {
let prefetcher = KingfisherImagePrefetcher()
// Cache some test images
let url1 = URL(string: "https://example.com/clear1.jpg")!
let url2 = URL(string: "https://example.com/clear2.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: url1.cacheKey)
try? await ImageCache.default.store(testImage, forKey: url2.cacheKey)
// Verify they are cached
var isCached1 = await isImageCached(forKey: url1.cacheKey)
var isCached2 = await isImageCached(forKey: url2.cacheKey)
#expect(isCached1 == true)
#expect(isCached2 == true)
// Clear them
await prefetcher.clearCachedImages(urls: [url1, url2])
// Verify they are removed
isCached1 = await isImageCached(forKey: url1.cacheKey)
isCached2 = await isImageCached(forKey: url2.cacheKey)
#expect(isCached1 == false)
#expect(isCached2 == false)
}
@Test("Clear cached images handles empty array")
func testClearCachedImagesHandlesEmptyArray() async {
let prefetcher = KingfisherImagePrefetcher()
let emptyURLs: [URL] = []
// Should complete without errors
await prefetcher.clearCachedImages(urls: emptyURLs)
// No assertions needed - just verify it doesn't crash
#expect(emptyURLs.isEmpty)
}
// MARK: - Integration Tests
@Test("Prefetch and verify workflow")
func testPrefetchAndVerifyWorkflow() async {
let prefetcher = KingfisherImagePrefetcher()
// Pre-populate cache with test images
let urls = [
URL(string: "https://example.com/workflow1.jpg")!,
URL(string: "https://example.com/workflow2.jpg")!
]
let testImage = createTestImage()
for url in urls {
try? await ImageCache.default.store(testImage, forKey: url.cacheKey)
}
// Verify they were cached
await prefetcher.verifyPrefetchedImages(urls)
for url in urls {
let isCached = await isImageCached(forKey: url.cacheKey)
#expect(isCached == true)
}
await clearCache()
}
}

View File

@ -6,7 +6,6 @@
//
import XCTest
import SnapshotHelper
final class readeckUITests: XCTestCase {
@ -16,7 +15,7 @@ final class readeckUITests: XCTestCase {
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
@ -27,9 +26,7 @@ final class readeckUITests: XCTestCase {
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
setupSnapshot(app)
app.launch()
snapshot("01LaunchScreen")
// Use XCTAssert and related functions to verify your tests produce the correct results.
}