chore: clean unnecessary markdown files for CV sharing
This commit is contained in:
File diff suppressed because it is too large
Load Diff
381
CHANGES.md
381
CHANGES.md
@@ -1,381 +0,0 @@
|
|||||||
# VPS Integration - Code Changes Reference
|
|
||||||
|
|
||||||
## File: VPSAPIClient.swift (NEW FILE)
|
|
||||||
|
|
||||||
Created a complete API client with these sections:
|
|
||||||
|
|
||||||
1. **Configuration & Initialization**
|
|
||||||
- Singleton pattern
|
|
||||||
- URLSession setup with appropriate timeouts
|
|
||||||
- Base URL configuration
|
|
||||||
|
|
||||||
2. **Health Check**
|
|
||||||
```swift
|
|
||||||
func checkHealth() async throws -> Bool
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Download Operations**
|
|
||||||
```swift
|
|
||||||
func downloadChapter(...) async throws -> VPSDownloadResult
|
|
||||||
```
|
|
||||||
- Progress tracking via `@Published` properties
|
|
||||||
- Active download tracking
|
|
||||||
|
|
||||||
4. **Status Checking**
|
|
||||||
```swift
|
|
||||||
func getChapterManifest(...) async throws -> VPSChapterManifest?
|
|
||||||
func listDownloadedChapters(...) async throws -> [VPSChapterInfo]
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Image URLs**
|
|
||||||
```swift
|
|
||||||
func getImageURL(...) -> String
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Management**
|
|
||||||
```swift
|
|
||||||
func deleteChapter(...) async throws -> Bool
|
|
||||||
func getStorageStats() async throws -> VPSStorageStats
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File: MangaDetailView.swift
|
|
||||||
|
|
||||||
### Change 1: Import VPS Client
|
|
||||||
```swift
|
|
||||||
// Added to MangaDetailView struct
|
|
||||||
@StateObject private var vpsClient = VPSAPIClient.shared
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 2: Add VPS Download Button to Toolbar
|
|
||||||
```swift
|
|
||||||
// In toolbar
|
|
||||||
Button {
|
|
||||||
viewModel.showingVPSDownloadAll = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "icloud.and.arrow.down")
|
|
||||||
}
|
|
||||||
.disabled(viewModel.chapters.isEmpty)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 3: Add VPS Download Alert
|
|
||||||
```swift
|
|
||||||
.alert("Descargar a VPS", isPresented: $viewModel.showingVPSDownloadAll) {
|
|
||||||
Button("Cancelar", role: .cancel) { }
|
|
||||||
Button("Últimos 10 a VPS") {
|
|
||||||
Task {
|
|
||||||
await viewModel.downloadLastChaptersToVPS(count: 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Todos a VPS") {
|
|
||||||
Task {
|
|
||||||
await viewModel.downloadAllChaptersToVPS()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text("¿Cuántos capítulos quieres descargar al servidor VPS?")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 4: Update ChapterRowView
|
|
||||||
```swift
|
|
||||||
struct ChapterRowView: View {
|
|
||||||
// Added:
|
|
||||||
let onVPSDownloadToggle: () async -> Void
|
|
||||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
|
||||||
@State private var isVPSDownloaded = false
|
|
||||||
@State private var isVPSChecked = false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 5: Add VPS Status Indicator in ChapterRowView Body
|
|
||||||
```swift
|
|
||||||
// VPS Download Button / Status
|
|
||||||
if isVPSChecked {
|
|
||||||
if isVPSDownloaded {
|
|
||||||
Image(systemName: "icloud.fill")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await onVPSDownloadToggle()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "icloud.and.arrow.up")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 6: Add VPS Progress Display
|
|
||||||
```swift
|
|
||||||
// Mostrar progreso de descarga VPS
|
|
||||||
if vpsClient.activeDownloads.contains("\(mangaSlug)-\(chapter.number)"),
|
|
||||||
let progress = vpsClient.downloadProgress["\(mangaSlug)-\(chapter.number)"] {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "icloud.and.arrow.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
ProgressView(value: progress)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.frame(maxWidth: 100)
|
|
||||||
|
|
||||||
Text("VPS \(Int(progress * 100))%")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 7: Add VPS Status Check Function
|
|
||||||
```swift
|
|
||||||
private func checkVPSStatus() async {
|
|
||||||
do {
|
|
||||||
let manifest = try await vpsClient.getChapterManifest(
|
|
||||||
mangaSlug: mangaSlug,
|
|
||||||
chapterNumber: chapter.number
|
|
||||||
)
|
|
||||||
isVPSDownloaded = manifest != nil
|
|
||||||
isVPSChecked = true
|
|
||||||
} catch {
|
|
||||||
isVPSDownloaded = false
|
|
||||||
isVPSChecked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 8: Update chaptersList to Pass VPS Callback
|
|
||||||
```swift
|
|
||||||
ChapterRowView(
|
|
||||||
chapter: chapter,
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
onTap: {
|
|
||||||
viewModel.selectedChapter = chapter
|
|
||||||
},
|
|
||||||
onDownloadToggle: {
|
|
||||||
await viewModel.downloadChapter(chapter)
|
|
||||||
},
|
|
||||||
onVPSDownloadToggle: { // NEW
|
|
||||||
await viewModel.downloadChapterToVPS(chapter)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 9: ViewModel Additions
|
|
||||||
```swift
|
|
||||||
// New Published Property
|
|
||||||
@Published var showingVPSDownloadAll = false
|
|
||||||
|
|
||||||
// New Dependency
|
|
||||||
private let vpsClient = VPSAPIClient.shared
|
|
||||||
|
|
||||||
// New Methods
|
|
||||||
func downloadChapterToVPS(_ chapter: Chapter) async {
|
|
||||||
do {
|
|
||||||
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
|
|
||||||
let result = try await vpsClient.downloadChapter(
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
chapterNumber: chapter.number,
|
|
||||||
chapterSlug: chapter.slug,
|
|
||||||
imageUrls: imageUrls
|
|
||||||
)
|
|
||||||
// Handle result and show notification
|
|
||||||
} catch {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAllChaptersToVPS() async { /* ... */ }
|
|
||||||
func downloadLastChaptersToVPS(count: Int) async { /* ... */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File: ReaderView.swift
|
|
||||||
|
|
||||||
### Change 1: Add VPS Client
|
|
||||||
```swift
|
|
||||||
// Added to ReaderView struct
|
|
||||||
@ObservedObject private var vpsClient = VPSAPIClient.shared
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 2: Update PageView for VPS Support
|
|
||||||
```swift
|
|
||||||
struct PageView: View {
|
|
||||||
// Added:
|
|
||||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
|
||||||
@State private var useVPS = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// ...
|
|
||||||
if let localURL = StorageService.shared.getImageURL(...) {
|
|
||||||
// Load from local cache
|
|
||||||
} else if useVPS {
|
|
||||||
// Load from VPS
|
|
||||||
let vpsImageURL = vpsClient.getImageURL(
|
|
||||||
mangaSlug: mangaSlug,
|
|
||||||
chapterNumber: chapterNumber,
|
|
||||||
pageIndex: page.index + 1
|
|
||||||
)
|
|
||||||
AsyncImage(url: URL(string: vpsImageURL)) { /* ... */ }
|
|
||||||
} else {
|
|
||||||
// Load from original URL
|
|
||||||
fallbackImage
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
.task {
|
|
||||||
// Check if VPS has this chapter
|
|
||||||
if let manifest = try? await vpsClient.getChapterManifest(...) {
|
|
||||||
useVPS = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fallbackImage: some View { /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 3: Update ReaderViewModel
|
|
||||||
```swift
|
|
||||||
// New Published Property
|
|
||||||
@Published var isVPSDownloaded = false
|
|
||||||
|
|
||||||
// New Dependency
|
|
||||||
private let vpsClient = VPSAPIClient.shared
|
|
||||||
|
|
||||||
// Updated loadPages()
|
|
||||||
func loadPages() async {
|
|
||||||
// 1. Check VPS first
|
|
||||||
if let vpsManifest = try await vpsClient.getChapterManifest(...) {
|
|
||||||
// Load from VPS
|
|
||||||
isVPSDownloaded = true
|
|
||||||
}
|
|
||||||
// 2. Then local storage
|
|
||||||
else if let downloadedChapter = storage.getDownloadedChapter(...) {
|
|
||||||
// Load from local
|
|
||||||
isDownloaded = true
|
|
||||||
}
|
|
||||||
// 3. Finally scrape
|
|
||||||
else {
|
|
||||||
// Scrape from original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change 4: Update Reader Footer
|
|
||||||
```swift
|
|
||||||
// Page indicator section
|
|
||||||
if viewModel.isVPSDownloaded {
|
|
||||||
Label("VPS", systemImage: "icloud.fill")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isDownloaded {
|
|
||||||
Label("Local", systemImage: "checkmark.circle.fill")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Models (in VPSAPIClient.swift)
|
|
||||||
|
|
||||||
### VPSDownloadResult
|
|
||||||
```swift
|
|
||||||
struct VPSDownloadResult {
|
|
||||||
let success: Bool
|
|
||||||
let alreadyDownloaded: Bool
|
|
||||||
let manifest: VPSChapterManifest?
|
|
||||||
let downloaded: Int?
|
|
||||||
let failed: Int?
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### VPSChapterManifest
|
|
||||||
```swift
|
|
||||||
struct VPSChapterManifest: Codable {
|
|
||||||
let mangaSlug: String
|
|
||||||
let chapterNumber: Int
|
|
||||||
let totalPages: Int
|
|
||||||
let downloadedPages: Int
|
|
||||||
let failedPages: Int
|
|
||||||
let downloadDate: String
|
|
||||||
let totalSize: Int
|
|
||||||
let images: [VPSImageInfo]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### VPSChapterInfo
|
|
||||||
```swift
|
|
||||||
struct VPSChapterInfo: Codable {
|
|
||||||
let chapterNumber: Int
|
|
||||||
let downloadDate: String
|
|
||||||
let totalPages: Int
|
|
||||||
let downloadedPages: Int
|
|
||||||
let totalSize: Int
|
|
||||||
let totalSizeMB: String
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### VPSStorageStats
|
|
||||||
```swift
|
|
||||||
struct VPSStorageStats: Codable {
|
|
||||||
let totalMangas: Int
|
|
||||||
let totalChapters: Int
|
|
||||||
let totalSize: Int
|
|
||||||
let totalSizeMB: String
|
|
||||||
let totalSizeFormatted: String
|
|
||||||
let mangaDetails: [VPSMangaDetail]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority Order for Image Loading
|
|
||||||
|
|
||||||
1. **Local Device Storage** (fastest, offline)
|
|
||||||
2. **VPS Storage** (fast, online)
|
|
||||||
3. **Original URL** (slowest, may fail)
|
|
||||||
|
|
||||||
This ensures best performance and reliability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling Pattern
|
|
||||||
|
|
||||||
All VPS operations follow this pattern:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let result = try await vpsClient.someMethod(...)
|
|
||||||
// Handle success
|
|
||||||
} catch {
|
|
||||||
// Handle error - show user notification
|
|
||||||
notificationMessage = "Error: \(error.localizedDescription)"
|
|
||||||
showDownloadNotification = true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Progress Tracking Pattern
|
|
||||||
|
|
||||||
For downloads:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Track active downloads
|
|
||||||
vpsClient.activeDownloads.contains("downloadId")
|
|
||||||
|
|
||||||
// Get progress
|
|
||||||
vpsClient.downloadProgress["downloadId"]
|
|
||||||
|
|
||||||
// Display in UI
|
|
||||||
ProgressView(value: progress)
|
|
||||||
Text("\(Int(progress * 100))%")
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
# Quick Implementation Guide - MangaReader Optimizations
|
|
||||||
|
|
||||||
## 📂 Archivos Creados
|
|
||||||
|
|
||||||
```
|
|
||||||
ios-app/Sources/Services/
|
|
||||||
├── ManhwaWebScraperOptimized.swift # Scraper con cache inteligente
|
|
||||||
├── StorageServiceOptimized.swift # Storage con compresión y thumbnails
|
|
||||||
├── ImageCache.swift # Sistema de cache de imágenes
|
|
||||||
└── CacheManager.swift # Gerente central de cache
|
|
||||||
|
|
||||||
ios-app/Sources/Views/
|
|
||||||
└── ReaderViewOptimized.swift # Reader optimizado
|
|
||||||
|
|
||||||
OPTIMIZATION_SUMMARY.md # Documentación completa
|
|
||||||
IMPLEMENTATION_GUIDE.md # Este archivo
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Migración Rápida
|
|
||||||
|
|
||||||
### Opción 1: Reemplazo Directo (Recomendado para Testing)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. En tus ViewModels, cambia:
|
|
||||||
private let scraper = ManhwaWebScraper.shared
|
|
||||||
// Por:
|
|
||||||
private let scraper = ManhwaWebScraperOptimized.shared
|
|
||||||
|
|
||||||
// 2. En tus Views, cambia:
|
|
||||||
private let storage = StorageService.shared
|
|
||||||
// Por:
|
|
||||||
private let storage = StorageServiceOptimized.shared
|
|
||||||
|
|
||||||
// 3. En ReaderView:
|
|
||||||
// Reemplaza completamente por ReaderViewOptimized
|
|
||||||
```
|
|
||||||
|
|
||||||
### Opción 2: Migración Gradual (Más Segura)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Usa alias de tipo para migrar gradualmente
|
|
||||||
typealias Scraper = ManhwaWebScraperOptimized
|
|
||||||
typealias Storage = StorageServiceOptimized
|
|
||||||
|
|
||||||
// Código existente funciona sin cambios
|
|
||||||
private let scraper = Scraper.shared
|
|
||||||
private let storage = Storage.shared
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuración Inicial
|
|
||||||
|
|
||||||
### 1. Inicializar CacheManager en AppDelegate
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions ...
|
|
||||||
) -> Bool {
|
|
||||||
|
|
||||||
// Inicializar cache manager
|
|
||||||
let cacheManager = CacheManager.shared
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Imprimir reporte inicial en debug
|
|
||||||
cacheManager.printCacheReport()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitoring de background
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
|
||||||
// El CacheManager ya maneja esto automáticamente
|
|
||||||
// pero puedes agregar lógica adicional aquí
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitoring de memory warnings
|
|
||||||
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
|
|
||||||
// CacheManager ya responde automáticamente
|
|
||||||
print("⚠️ Memory warning received")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configurar Ajustes de Cache
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En algún lugar de configuración (ej: SettingsViewModel)
|
|
||||||
|
|
||||||
struct CacheSettings {
|
|
||||||
// Ajusta según tus necesidades
|
|
||||||
|
|
||||||
// Tamaño máximo de cache (en bytes)
|
|
||||||
static let maxImageCacheSize: Int64 = 500 * 1024 * 1024 // 500 MB
|
|
||||||
static let maxMetadataCacheSize: Int64 = 50 * 1024 * 1024 // 50 MB
|
|
||||||
|
|
||||||
// Tiempos de expiración (en segundos)
|
|
||||||
static let htmlCacheDuration: TimeInterval = 1800 // 30 min
|
|
||||||
static let imageCacheDuration: TimeInterval = 7 * 86400 // 7 días
|
|
||||||
static let thumbnailCacheDuration: TimeInterval = 30 * 86400 // 30 días
|
|
||||||
|
|
||||||
// Preloading
|
|
||||||
static let preloadAdjacentPages: Int = 2 // 2 páginas antes/después
|
|
||||||
static let enablePreloading: Bool = true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Monitoreo y Debugging
|
|
||||||
|
|
||||||
### Ver Estadísticas en Debug
|
|
||||||
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
// Agregar botón de debug en settings
|
|
||||||
struct DebugSettingsView: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Button("Print Cache Report") {
|
|
||||||
CacheManager.shared.printCacheReport()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Print Image Statistics") {
|
|
||||||
ImageCache.shared.printStatistics()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Clear All Cache") {
|
|
||||||
CacheManager.shared.clearAllCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Simulate Memory Warning") {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
### Instruments Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Para perfilado de memoria
|
|
||||||
1. Xcode → Product → Profile
|
|
||||||
2. Seleccionar "Allocations"
|
|
||||||
3. Monitorear:
|
|
||||||
- Overall memory usage
|
|
||||||
- Anonymous VM
|
|
||||||
- Image cache size
|
|
||||||
|
|
||||||
# Para perfilado de tiempo
|
|
||||||
1. Seleccionar "Time Profiler"
|
|
||||||
2. Buscar funciones lentas
|
|
||||||
3. Verificar que cache esté funcionando
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Checklist
|
|
||||||
|
|
||||||
### Tests Funcionales
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class CachePerformanceTests: XCTestCase {
|
|
||||||
|
|
||||||
func testScraperCache() async throws {
|
|
||||||
let scraper = ManhwaWebScraperOptimized.shared
|
|
||||||
|
|
||||||
// Primer scraping (debe ser lento)
|
|
||||||
let start1 = Date()
|
|
||||||
_ = try await scraper.scrapeChapters(mangaSlug: "test-manga")
|
|
||||||
let time1 = Date().timeIntervalSince(start1)
|
|
||||||
|
|
||||||
// Segundo scraping (debe ser rápido por cache)
|
|
||||||
let start2 = Date()
|
|
||||||
_ = try await scraper.scrapeChapters(mangaSlug: "test-manga")
|
|
||||||
let time2 = Date().timeIntervalSince(start2)
|
|
||||||
|
|
||||||
// El segundo debe ser >10x más rápido
|
|
||||||
XCTAssertLessThan(time2, time1 / 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testImageCompression() {
|
|
||||||
let storage = StorageServiceOptimized.shared
|
|
||||||
|
|
||||||
// Crear imagen de prueba grande (4 MB)
|
|
||||||
let largeImage = createTestImage(size: CGSize(width: 2000, height: 3000))
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Compress and save")
|
|
||||||
|
|
||||||
Task {
|
|
||||||
let url = try await storage.saveImage(
|
|
||||||
largeImage,
|
|
||||||
mangaSlug: "test",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verificar que archivo resultante es < 2 MB
|
|
||||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
let fileSize = attributes[.size] as! Int64
|
|
||||||
|
|
||||||
XCTAssertLessThan(fileSize, 2 * 1024 * 1024)
|
|
||||||
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 5.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreloading() {
|
|
||||||
let viewModel = ReaderViewModelOptimized(
|
|
||||||
manga: testManga,
|
|
||||||
chapter: testChapter
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cargar páginas
|
|
||||||
let expectation = XCTestExpectation(description: "Load pages")
|
|
||||||
Task {
|
|
||||||
await viewModel.loadPages()
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
wait(for: [expectation], timeout: 10.0)
|
|
||||||
|
|
||||||
// Simular navegación a página 5
|
|
||||||
viewModel.currentPage = 5
|
|
||||||
|
|
||||||
// Verificar que páginas 3, 4, 6, 7 están en cache
|
|
||||||
let imageCache = ImageCache.shared
|
|
||||||
XCTAssertNotNil(imageCache.image(for: viewModel.pages[3].url))
|
|
||||||
XCTAssertNotNil(imageCache.image(for: viewModel.pages[4].url))
|
|
||||||
XCTAssertNotNil(imageCache.image(for: viewModel.pages[6].url))
|
|
||||||
XCTAssertNotNil(imageCache.image(for: viewModel.pages[7].url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Memoria
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class MemoryTests: XCTestCase {
|
|
||||||
|
|
||||||
func testMemoryUnderLoad() {
|
|
||||||
let viewModel = ReaderViewModelOptimized(
|
|
||||||
manga: largeManga, // 100 páginas
|
|
||||||
chapter: largeChapter
|
|
||||||
)
|
|
||||||
|
|
||||||
let memoryBefore = getMemoryUsage()
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Load all pages")
|
|
||||||
Task {
|
|
||||||
await viewModel.loadPages()
|
|
||||||
|
|
||||||
// Navegar por todas las páginas
|
|
||||||
for i in 0..<viewModel.pages.count {
|
|
||||||
viewModel.currentPage = i
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
|
||||||
}
|
|
||||||
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 60.0)
|
|
||||||
|
|
||||||
let memoryAfter = getMemoryUsage()
|
|
||||||
let memoryIncrease = memoryAfter - memoryBefore
|
|
||||||
|
|
||||||
// No debe aumentar más de 150 MB
|
|
||||||
XCTAssertLessThan(memoryIncrease, 150 * 1024 * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMemoryWarningResponse() {
|
|
||||||
// Llenar cache
|
|
||||||
let cache = ImageCache.shared
|
|
||||||
for i in 0..<100 {
|
|
||||||
cache.setImage(createTestImage(), for: "test_\(i)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let itemsBefore = cache.getCacheStatistics().memoryCacheHits
|
|
||||||
|
|
||||||
// Simular memory warning
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Esperar un poco
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
|
||||||
|
|
||||||
// Cache debe estar vacío o casi vacío
|
|
||||||
let itemsAfter = cache.getCacheStatistics().memoryCacheHits
|
|
||||||
XCTAssertLessThan(itemsAfter, itemsBefore / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getMemoryUsage() -> UInt64 {
|
|
||||||
var info = mach_task_basic_info()
|
|
||||||
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size)/4
|
|
||||||
|
|
||||||
let result = withUnsafeMutablePointer(to: &info) {
|
|
||||||
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
|
|
||||||
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result == KERN_SUCCESS ? info.resident_size : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Optimizaciones por Categoría
|
|
||||||
|
|
||||||
### Scraper Optimizations
|
|
||||||
|
|
||||||
| Optimización | Archivo | Líneas Clave |
|
|
||||||
|--------------|---------|--------------|
|
|
||||||
| WKWebView reuse | `ManhwaWebScraperOptimized.swift` | 30-45 |
|
|
||||||
| HTML cache | `ManhwaWebScraperOptimized.swift` | 48-62 |
|
|
||||||
| Precompiled JS | `ManhwaWebScraperOptimized.swift` | 67-122 |
|
|
||||||
| Adaptive timeout | `ManhwaWebScraperOptimized.swift` | 127-165 |
|
|
||||||
| Concurrency control | `ManhwaWebScraperOptimized.swift` | 170-195 |
|
|
||||||
|
|
||||||
### Storage Optimizations
|
|
||||||
|
|
||||||
| Optimización | Archivo | Líneas Clave |
|
|
||||||
|--------------|---------|--------------|
|
|
||||||
| Adaptive compression | `StorageServiceOptimized.swift` | 28-52 |
|
|
||||||
| Thumbnail system | `StorageServiceOptimized.swift` | 57-85 |
|
|
||||||
| Lazy loading | `StorageServiceOptimized.swift` | 478-488 |
|
|
||||||
| Auto cleanup | `StorageServiceOptimized.swift` | 330-380 |
|
|
||||||
| Batch operations | `StorageServiceOptimized.swift` | 220-245 |
|
|
||||||
|
|
||||||
### Reader Optimizations
|
|
||||||
|
|
||||||
| Optimización | Archivo | Líneas Clave |
|
|
||||||
|--------------|---------|--------------|
|
|
||||||
| NSCache | `ImageCache.swift` | 22-45 |
|
|
||||||
| Preloading | `ReaderViewOptimized.swift` | 195-215 |
|
|
||||||
| Memory management | `ReaderViewOptimized.swift` | 420-445 |
|
|
||||||
| Debouncing | `ReaderViewOptimized.swift` | 505-525 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Problema: Cache no funciona
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Verificar que el singleton se esté usando correctamente
|
|
||||||
let scraper = ManhwaWebScraperOptimized.shared
|
|
||||||
// NO: let scraper = ManhwaWebScraperOptimized()
|
|
||||||
|
|
||||||
// Verificar que los métodos tengan @MainActor si es necesario
|
|
||||||
@MainActor
|
|
||||||
func loadChapters() async throws {
|
|
||||||
// código
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Memoria sigue alta
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. Verificar que se estén liberando referencias
|
|
||||||
deinit {
|
|
||||||
progressSaveTimer?.invalidate()
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Usizar weak en closures
|
|
||||||
Task { [weak self] in
|
|
||||||
await self?.loadPages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Verificar en Instruments
|
|
||||||
// - Revisar "Anonymous VM" (debe ser bajo)
|
|
||||||
// - Buscar "Malloc" leaks
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Preloading no funciona
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Verificar que esté habilitado
|
|
||||||
viewModel.enablePreloading = true // Debe ser true
|
|
||||||
|
|
||||||
// Verificar que onAppear se llame
|
|
||||||
PageView(page: page)
|
|
||||||
.onAppear {
|
|
||||||
print("Page \(page.index) appeared") // Debug
|
|
||||||
viewModel.preloadAdjacentPages(...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar priority queue
|
|
||||||
Task(priority: .utility) {
|
|
||||||
// Debe usar .utility, no .background
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Thumbnails no se generan
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Verificar directorio de thumbnails
|
|
||||||
let thumbDir = storage.getThumbnailDirectory(...)
|
|
||||||
print("Thumb dir: \(thumbDir.path)")
|
|
||||||
|
|
||||||
// Verificar que se cree el directorio
|
|
||||||
try? FileManager.default.createDirectory(
|
|
||||||
at: thumbDir,
|
|
||||||
withIntermediateDirectories: true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verificar que se guarde correctamente
|
|
||||||
try? thumbData.write(to: thumbnailURL)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Métricas de Éxito
|
|
||||||
|
|
||||||
### Objetivos de Rendimiento
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct PerformanceTargets {
|
|
||||||
// Tiempos de carga
|
|
||||||
static let maxChapterLoadTime: TimeInterval = 2.0 // segundos
|
|
||||||
static let maxPageLoadTime: TimeInterval = 0.5 // segundos (con cache)
|
|
||||||
static let maxAppLaunchTime: TimeInterval = 1.0 // segundos
|
|
||||||
|
|
||||||
// Memoria
|
|
||||||
static let maxReaderMemory: UInt64 = 120 * 1024 * 1024 // 120 MB
|
|
||||||
static let maxScraperMemory: UInt64 = 50 * 1024 * 1024 // 50 MB
|
|
||||||
|
|
||||||
// Cache
|
|
||||||
static let minCacheHitRate: Double = 0.80 // 80%
|
|
||||||
static let maxCacheSize: Int64 = 500 * 1024 * 1024 // 500 MB
|
|
||||||
}
|
|
||||||
|
|
||||||
// Función de verificación
|
|
||||||
func verifyPerformanceTargets() {
|
|
||||||
let cacheStats = ImageCache.shared.getCacheStatistics()
|
|
||||||
|
|
||||||
let hitRate = cacheStats.hitRate
|
|
||||||
XCTAssertGreaterThan(hitRate, PerformanceTargets.minCacheHitRate,
|
|
||||||
"Cache hit rate \(hitRate) below target")
|
|
||||||
|
|
||||||
// ... más verificaciones
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Próximos Pasos
|
|
||||||
|
|
||||||
### Implementación Inmediata
|
|
||||||
|
|
||||||
1. **Backup del código existente**
|
|
||||||
```bash
|
|
||||||
cp -r ios-app/Sources ios-app/Sources.backup
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Reemplazar archivos uno por uno**
|
|
||||||
- Empezar con ManhwaWebScraperOptimized
|
|
||||||
- Probar exhaustivamente
|
|
||||||
- Continuar con StorageServiceOptimized
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
3. **Testing completo**
|
|
||||||
- Unit tests
|
|
||||||
- Integration tests
|
|
||||||
- Manual testing
|
|
||||||
|
|
||||||
### Optimizaciones Futuras (Opcionales)
|
|
||||||
|
|
||||||
1. **Prefetching Predictivo**
|
|
||||||
```swift
|
|
||||||
// Usar ML para predecir próximo capítulo
|
|
||||||
func predictNextChapter(userHistory: [Chapter]) -> Chapter? {
|
|
||||||
// Implementación básica de ML
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Compresión HEIC**
|
|
||||||
```swift
|
|
||||||
// Usar HEIC en lugar de JPEG (50% más eficiente)
|
|
||||||
if #available(iOS 11.0, *) {
|
|
||||||
let heicData = image.heicData()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Progressive Image Loading**
|
|
||||||
```swift
|
|
||||||
// Cargar versión baja resolución primero
|
|
||||||
// Luego reemplazar con alta resolución
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Background Sync**
|
|
||||||
```swift
|
|
||||||
// Sincronizar en background usando BGTaskScheduler
|
|
||||||
func scheduleBackgroundSync() {
|
|
||||||
let request = BGAppRefreshTaskRequest(...)
|
|
||||||
try? BGTaskScheduler.shared.submit(request)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Tips y Best Practices
|
|
||||||
|
|
||||||
### DO ✅
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. Usar singletons para caches
|
|
||||||
static let shared = ImageCache()
|
|
||||||
|
|
||||||
// 2. Usar colas para I/O
|
|
||||||
private let ioQueue = DispatchQueue(label: "...", qos: .utility)
|
|
||||||
|
|
||||||
// 3. Weak references en closures
|
|
||||||
Task { [weak self] in
|
|
||||||
await self?.loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Responder a memory warnings
|
|
||||||
@objc func handleMemoryWarning() {
|
|
||||||
cache.removeAllObjects()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Debouncing de operaciones frecuentes
|
|
||||||
func saveDebounced() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = Timer.scheduledTimer(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DON'T ❌
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. NO crear múltiples instancias de cache
|
|
||||||
// let cache1 = ImageCache()
|
|
||||||
// let cache2 = ImageCache() // ❌
|
|
||||||
|
|
||||||
// 2. NO hacer I/O en main thread
|
|
||||||
// let data = try Data(contentsOf: url) // ❌ Bloquea
|
|
||||||
|
|
||||||
// 3. NO olvidar weak en closures
|
|
||||||
// Task {
|
|
||||||
// self.doSomething() // ❌ Memory leak
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 4. NO ignorar memory warnings
|
|
||||||
// @objc func handleMemoryWarning() {
|
|
||||||
// // ❌ No hacer nada
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 5. NO guardar en cada cambio
|
|
||||||
// func onChange() {
|
|
||||||
// saveToDisk() // ❌ Demasiado frecuente
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Soporte
|
|
||||||
|
|
||||||
Si encuentras problemas:
|
|
||||||
|
|
||||||
1. Revisa `OPTIMIZATION_SUMMARY.md` para detalles técnicos
|
|
||||||
2. Usa los tests de ejemplo como guía
|
|
||||||
3. Habilita logging debug en development
|
|
||||||
4. Usa Instruments para perfilado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Optimizing! 🚀**
|
|
||||||
@@ -1,865 +0,0 @@
|
|||||||
# MangaReader - Optimizaciones de Rendimiento y Memoria
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
Se han implementado optimizaciones comprehensivas en el proyecto MangaReader para mejorar el rendimiento, reducir el uso de memoria y optimizar el tamaño final de la aplicación.
|
|
||||||
|
|
||||||
## 📊 Métricas Esperadas de Mejora
|
|
||||||
|
|
||||||
| Métrica | BEFORE | AFTER | Mejora |
|
|
||||||
|---------|--------|-------|--------|
|
|
||||||
| Tiempo de carga de capítulos | 3-5 segundos | 0.5-2 segundos | **60-85%** |
|
|
||||||
| Uso de memoria en lectura | 150-300 MB | 50-100 MB | **50-65%** |
|
|
||||||
| Tamaño de cache de imágenes | Ilimitado | Max 500 MB | **Controlado** |
|
|
||||||
| Re-scraping de páginas | Siempre | Cache 30 min | **80% reducción** |
|
|
||||||
| Preloading de páginas | Ninguno | 2 páginas adelante/atrás | **Experiencia fluida** |
|
|
||||||
| Compresión de imágenes | JPEG 0.8 fijo | Adaptativa (0.6-0.9) | **30-40% espacio** |
|
|
||||||
| Thumbnails | No | Sí (150x200) | **Navegación rápida** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Optimización del Scraper (ManhwaWebScraperOptimized.swift)
|
|
||||||
|
|
||||||
### 🎯 Optimizaciones Implementadas
|
|
||||||
|
|
||||||
#### 1.1 WKWebView Reutilizable
|
|
||||||
```swift
|
|
||||||
// BEFORE: Creaba nueva instancia cada vez
|
|
||||||
private var webView: WKWebView?
|
|
||||||
|
|
||||||
// AFTER: Singleton con reutilización
|
|
||||||
static let shared = ManhwaWebScraperOptimized()
|
|
||||||
private var webView: WKWebView? // Una sola instancia
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Reducción de 70-80% en tiempo de inicialización
|
|
||||||
- Menor uso de memoria (sin múltiples WKWebView)
|
|
||||||
- Evita crashes por límite de WKWebViews simultáneos
|
|
||||||
|
|
||||||
#### 1.2 Cache Inteligente de HTML
|
|
||||||
```swift
|
|
||||||
// BEFORE: Siempre descargaba y parseaba HTML
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter] {
|
|
||||||
// Siempre scraping
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: NSCache + Disco con expiración
|
|
||||||
private var htmlCache: NSCache<NSString, NSString>
|
|
||||||
private let cacheValidDuration: TimeInterval = 1800 // 30 minutos
|
|
||||||
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter] {
|
|
||||||
if let cachedResult = getCachedResult(for: cacheKey) {
|
|
||||||
return parseChapters(from: cachedResult)
|
|
||||||
}
|
|
||||||
// Solo scraping si no hay cache válido
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 80-90% de requests sirven desde cache
|
|
||||||
- Reducción drástica de uso de red
|
|
||||||
- Tiempo de respuesta: 3-5s → 0.1-0.5s
|
|
||||||
|
|
||||||
#### 1.3 JavaScript Injection Optimizado
|
|
||||||
```swift
|
|
||||||
// BEFORE: Strings literales en cada llamada
|
|
||||||
chapters = try await webView.evaluateJavaScript("""
|
|
||||||
(function() {
|
|
||||||
// 50 líneas de JavaScript
|
|
||||||
})();
|
|
||||||
""") as! [[String: Any]]
|
|
||||||
|
|
||||||
// AFTER: Scripts precompilados (enum)
|
|
||||||
private enum JavaScriptScripts: String {
|
|
||||||
case extractChapters = """
|
|
||||||
(function() {
|
|
||||||
// Script optimizado
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
chapters = try await webView.evaluateJavaScript(
|
|
||||||
JavaScriptScripts.extractChapters.rawValue
|
|
||||||
) as! [[String: Any]]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Menor memoria (strings no se recrean)
|
|
||||||
- Ejecución 10-15% más rápida
|
|
||||||
- Código más mantenible
|
|
||||||
|
|
||||||
#### 1.4 Timeout Adaptativo
|
|
||||||
```swift
|
|
||||||
// BEFORE: Siempre 3-5 segundos fijos
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Basado en historial de rendimiento
|
|
||||||
private var averageLoadTime: TimeInterval = 3.0
|
|
||||||
|
|
||||||
private func getAdaptiveTimeout() -> TimeInterval {
|
|
||||||
return averageLoadTime + 1.0 // Margen de seguridad
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se ajusta automáticamente según condiciones de red
|
|
||||||
private func updateLoadTimeHistory(_ loadTime: TimeInterval) {
|
|
||||||
loadTimeHistory.append(loadTime)
|
|
||||||
averageLoadTime = calculateAverage()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 20-30% más rápido en conexiones buenas
|
|
||||||
- Más robusto en conexiones lentas
|
|
||||||
- Timeout óptimo: 2-8 segundos (adaptativo)
|
|
||||||
|
|
||||||
#### 1.5 Control de Concurrencia
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin límite de scraping simultáneo
|
|
||||||
// Podía crashear por demasiados WKWebViews
|
|
||||||
|
|
||||||
// AFTER: Semaphore para máximo 2 scrapings
|
|
||||||
private let scrapingSemaphore = DispatchSemaphore(value: 2)
|
|
||||||
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter] {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
scrapingSemaphore.wait()
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
defer { scrapingSemaphore.signal() }
|
|
||||||
// Scraping con límite de concurrencia
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Previene crashes por sobrecarga
|
|
||||||
Mejora estabilidad general
|
|
||||||
- Uso de memoria controlado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Optimización del StorageService (StorageServiceOptimized.swift)
|
|
||||||
|
|
||||||
### 🎯 Optimizaciones Implementadas
|
|
||||||
|
|
||||||
#### 2.1 Compresión Inteligente de Imágenes
|
|
||||||
```swift
|
|
||||||
// BEFORE: JPEG quality 0.8 fijo
|
|
||||||
let data = image.jpegData(compressionQuality: 0.8)
|
|
||||||
|
|
||||||
// AFTER: Calidad adaptativa basada en tamaño
|
|
||||||
private enum ImageCompression {
|
|
||||||
static func quality(for imageSize: Int) -> CGFloat {
|
|
||||||
let sizeMB = Double(imageSize) / (1024 * 1024)
|
|
||||||
|
|
||||||
if sizeMB > 3.0 {
|
|
||||||
return 0.6 // Imágenes muy grandes
|
|
||||||
} else if sizeMB > 1.5 {
|
|
||||||
return 0.75 // Imágenes medianas
|
|
||||||
} else {
|
|
||||||
return 0.9 // Imágenes pequeñas
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let quality = ImageCompression.quality(for: imageSize)
|
|
||||||
let data = image.jpegData(compressionQuality: quality)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 30-40% reducción en espacio de almacenamiento
|
|
||||||
- Calidad visual imperceptible
|
|
||||||
- Ahorro significativo en ancho de banda
|
|
||||||
|
|
||||||
#### 2.2 Sistema de Thumbnails
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin thumbnails - cargaba imágenes completas
|
|
||||||
struct MangaPage {
|
|
||||||
var thumbnailURL: String {
|
|
||||||
return url // No había thumbnails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Generación automática de thumbnails
|
|
||||||
private enum ThumbnailSize {
|
|
||||||
static let small = CGSize(width: 150, height: 200) // Para lista
|
|
||||||
static let medium = CGSize(width: 300, height: 400) // Para preview
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveImage(_ image: UIImage, ...) async throws -> URL {
|
|
||||||
// Guardar imagen completa
|
|
||||||
try data.write(to: fileURL)
|
|
||||||
|
|
||||||
// Crear thumbnail automáticamente en background
|
|
||||||
Task {
|
|
||||||
await createThumbnail(for: fileURL, ...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createThumbnail(for imageURL: URL, ...) async {
|
|
||||||
let thumbnail = await resizeImage(image, to: targetSize)
|
|
||||||
let thumbData = thumbnail.jpegData(compressionQuality: 0.5)
|
|
||||||
try? thumbData.write(to: thumbnailURL)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Navegación 10x más rápida en listas
|
|
||||||
- 90-95% menos memoria en previews
|
|
||||||
- Mejor experiencia de usuario
|
|
||||||
|
|
||||||
#### 2.3 Lazy Loading de Capítulos
|
|
||||||
```swift
|
|
||||||
// BEFORE: Cargaba todos los capítulos en memoria
|
|
||||||
func getDownloadedChapters() -> [DownloadedChapter] {
|
|
||||||
let all = try decodeJSON() // Todo en memoria
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Paginación y filtros eficientes
|
|
||||||
func getDownloadedChapters(offset: Int, limit: Int) -> [DownloadedChapter] {
|
|
||||||
let all = getAllDownloadedChapters()
|
|
||||||
let start = min(offset, all.count)
|
|
||||||
let end = min(offset + limit, all.count)
|
|
||||||
return Array(all[start..<end]) // Solo lo necesario
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDownloadedChapters(forManga mangaSlug: String) -> [DownloadedChapter] {
|
|
||||||
return getAllDownloadedChapters()
|
|
||||||
.filter { $0.mangaSlug == mangaSlug } // Solo del manga específico
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Inicio de app 50-70% más rápido
|
|
||||||
- Menor uso de memoria en startup
|
|
||||||
- UI más fluida
|
|
||||||
|
|
||||||
#### 2.4 Purga Automática de Cache
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin limpieza automática
|
|
||||||
// El cache crecía indefinidamente
|
|
||||||
|
|
||||||
// AFTER: Limpieza automática periódica
|
|
||||||
private let maxCacheAge: TimeInterval = 30 * 24 * 3600 // 30 días
|
|
||||||
private let maxCacheSize: Int64 = 2 * 1024 * 1024 * 1024 // 2 GB
|
|
||||||
|
|
||||||
private func setupAutomaticCleanup() {
|
|
||||||
Timer.scheduledTimer(withTimeInterval: 86400, repeats: true) { _ in
|
|
||||||
self.performCleanupIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performCleanupIfNeeded() {
|
|
||||||
if currentSize > maxCacheSize {
|
|
||||||
cleanupOldFiles()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupOldFiles() {
|
|
||||||
let now = Date()
|
|
||||||
// Eliminar archivos más viejos que maxCacheAge
|
|
||||||
for fileURL in oldFiles {
|
|
||||||
if modificationDate < now.addingTimeInterval(-maxCacheAge) {
|
|
||||||
try? fileManager.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Almacenamiento controlado automáticamente
|
|
||||||
- No requiere intervención del usuario
|
|
||||||
- Previene problemas de espacio
|
|
||||||
|
|
||||||
#### 2.5 Batch Operations
|
|
||||||
```swift
|
|
||||||
// BEFORE: Operaciones I/O individuales
|
|
||||||
func saveReadingProgress(_ progress: ReadingProgress) {
|
|
||||||
var allProgress = getAllReadingProgress() // Leer
|
|
||||||
allProgress.append(progress) // Modificar
|
|
||||||
saveProgressToDisk(allProgress) // Escribir
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Escritura diferida en background
|
|
||||||
func saveReadingProgress(_ progress: ReadingProgress) {
|
|
||||||
var allProgress = getAllReadingProgress()
|
|
||||||
allProgress.append(progress)
|
|
||||||
|
|
||||||
// Guardar en background, no bloquear
|
|
||||||
Task(priority: .utility) {
|
|
||||||
await saveProgressToDiskAsync(allProgress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- UI más fluida (no bloquea en I/O)
|
|
||||||
- Mejor experiencia de usuario
|
|
||||||
- Operaciones en paralelo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Optimización del ReaderView (ReaderViewOptimized.swift)
|
|
||||||
|
|
||||||
### 🎯 Optimizaciones Implementadas
|
|
||||||
|
|
||||||
#### 3.1 Image Caching con NSCache
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin cache en memoria
|
|
||||||
struct PageView: View {
|
|
||||||
var body: some View {
|
|
||||||
AsyncImage(url: URL(string: page.url)) { phase in
|
|
||||||
// Recargaba siempre
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: NSCache con prioridades
|
|
||||||
final class ImageCache {
|
|
||||||
static let shared = ImageCache()
|
|
||||||
private let cache: NSCache<NSString, UIImage>
|
|
||||||
|
|
||||||
func image(for url: String) -> UIImage? {
|
|
||||||
// Verificar memoria cache primero
|
|
||||||
if let cachedImage = getCachedImage(for: url) {
|
|
||||||
return cachedImage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar disco cache
|
|
||||||
if let diskImage = loadImageFromDisk(for: url) {
|
|
||||||
setImage(diskImage, for: url)
|
|
||||||
return diskImage
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil // No en cache, necesita descarga
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 80-90% de páginas cargan instantáneamente
|
|
||||||
- Hit rate de cache: 85-95%
|
|
||||||
- Navegación fluida sin recargas
|
|
||||||
|
|
||||||
#### 3.2 Preloading de Páginas Adyacentes
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin preloading
|
|
||||||
TabView(selection: $currentPage) {
|
|
||||||
ForEach(pages) { page in
|
|
||||||
PageView(page: page)
|
|
||||||
.onAppear {
|
|
||||||
// Solo carga la página actual
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Precarga 2 páginas adelante y atrás
|
|
||||||
func preloadAdjacentPages(currentIndex: Int, total: Int) {
|
|
||||||
let startIndex = max(0, currentIndex - 2)
|
|
||||||
let endIndex = min(total - 1, currentIndex + 2)
|
|
||||||
|
|
||||||
for index in startIndex...endIndex {
|
|
||||||
guard index != currentIndex else { continue }
|
|
||||||
|
|
||||||
Task(priority: .utility) {
|
|
||||||
// Precargar en background
|
|
||||||
await loadImage(pageIndex: index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TabView(selection: $currentPage) {
|
|
||||||
ForEach(pages) { page in
|
|
||||||
PageView(page: page)
|
|
||||||
.onAppear {
|
|
||||||
preloadAdjacentPages(
|
|
||||||
currentIndex: page.index,
|
|
||||||
total: pages.count
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Navegación instantánea en 80% de casos
|
|
||||||
- Mejor experiencia de lectura
|
|
||||||
- No afecta rendimiento significativamente
|
|
||||||
|
|
||||||
#### 3.3 Memory Management para Imágenes Grandes
|
|
||||||
```swift
|
|
||||||
// BEFORE: Imágenes a resolución completa
|
|
||||||
if let image = UIImage(data: data) {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Redimensiona automáticamente
|
|
||||||
private func optimizeImageSize(_ image: UIImage) -> UIImage {
|
|
||||||
let maxDimension: CGFloat = 2048
|
|
||||||
|
|
||||||
// Si ya es pequeña, no cambiar
|
|
||||||
if image.size.width <= maxDimension {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redimensionar manteniendo aspect ratio
|
|
||||||
let newSize = calculateNewSize()
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
|
||||||
return renderer.image { _ in
|
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response a memory warnings
|
|
||||||
@objc private func handleMemoryWarning() {
|
|
||||||
cache.removeAllObjects()
|
|
||||||
preloadQueue.removeAll()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 50-70% menos memoria en imágenes
|
|
||||||
- Sin crashes por memory pressure
|
|
||||||
- Rendering más rápido
|
|
||||||
|
|
||||||
#### 3.4 Optimización de TabView
|
|
||||||
```swift
|
|
||||||
// BEFORE: Cargaba todas las vistas
|
|
||||||
TabView(selection: $currentPage) {
|
|
||||||
ForEach(viewModel.pages) { page in
|
|
||||||
PageView(page: page)
|
|
||||||
.tag(page.index)
|
|
||||||
}
|
|
||||||
// Todas las páginas se creaban de una vez
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: View recycling + lazy loading
|
|
||||||
TabView(selection: $currentPage) {
|
|
||||||
ForEach(viewModel.pages) { page in
|
|
||||||
PageViewOptimized(
|
|
||||||
page: page,
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
chapterNumber: chapter.number,
|
|
||||||
viewModel: viewModel
|
|
||||||
)
|
|
||||||
.id(page.index) // View recycling
|
|
||||||
.tag(page.index)
|
|
||||||
.onAppear {
|
|
||||||
viewModel.preloadAdjacentPages(
|
|
||||||
currentIndex: page.index,
|
|
||||||
total: viewModel.pages.count
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Inicio 60-70% más rápido
|
|
||||||
- Menor uso de memoria
|
|
||||||
- Scroll más fluido
|
|
||||||
|
|
||||||
#### 3.5 Debouncing de Progreso
|
|
||||||
```swift
|
|
||||||
// BEFORE: Guardaba progreso en cada cambio de página
|
|
||||||
.onChange(of: currentPage) { _, newValue in
|
|
||||||
saveProgress() // I/O cada cambio
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Debouncing de 2 segundos
|
|
||||||
private var progressSaveTimer: Timer?
|
|
||||||
|
|
||||||
func currentPageChanged(from: Int, to: Int) {
|
|
||||||
saveProgressDebounced()
|
|
||||||
preloadAdjacentPages(currentIndex: to, total: pages.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveProgressDebounced() {
|
|
||||||
progressSaveTimer?.invalidate()
|
|
||||||
|
|
||||||
progressSaveTimer = Timer.scheduledTimer(
|
|
||||||
withTimeInterval: 2.0,
|
|
||||||
repeats: false
|
|
||||||
) { [weak self] _ in
|
|
||||||
self?.saveProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- 95% menos escrituras a disco
|
|
||||||
- Mejor rendimiento de navegación
|
|
||||||
- No se pierde progreso
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Cache Manager (CacheManager.swift)
|
|
||||||
|
|
||||||
### 🎯 Optimizaciones Implementadas
|
|
||||||
|
|
||||||
#### 4.1 Políticas LRU (Least Recently Used)
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin política clara de eliminación
|
|
||||||
// FIFO simple sin considerar uso
|
|
||||||
|
|
||||||
// AFTER: LRU completo con tracking
|
|
||||||
private struct CacheItem {
|
|
||||||
let key: String
|
|
||||||
let size: Int64
|
|
||||||
var lastAccess: Date
|
|
||||||
var accessCount: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackAccess(key: String, type: CacheType, size: Int64) {
|
|
||||||
cacheItems[key] = CacheItem(
|
|
||||||
key: key,
|
|
||||||
size: size,
|
|
||||||
lastAccess: Date(),
|
|
||||||
accessCount: existingItem.accessCount + 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpieza por prioridad + recencia
|
|
||||||
let sortedItems = cacheItems.sorted {
|
|
||||||
if $0.type.priority != $1.type.priority {
|
|
||||||
return $0.type.priority < $1.type.priority
|
|
||||||
}
|
|
||||||
return $0.lastAccess < $1.lastAccess
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Items más usados permanecen más tiempo
|
|
||||||
- Cache más eficiente
|
|
||||||
- Mejor hit rate
|
|
||||||
|
|
||||||
#### 4.2 Priorización por Tipo de Contenido
|
|
||||||
```swift
|
|
||||||
enum CacheType: String {
|
|
||||||
case images // Prioridad alta
|
|
||||||
case thumbnails // Prioridad media
|
|
||||||
case html // Prioridad baja
|
|
||||||
case metadata // Prioridad baja
|
|
||||||
|
|
||||||
var priority: CachePriority {
|
|
||||||
switch self {
|
|
||||||
case .images: return .high
|
|
||||||
case .thumbnails: return .medium
|
|
||||||
case .html, .metadata: return .low
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// En limpieza, eliminar baja prioridad primero
|
|
||||||
let lowPriorityItems = cacheItems.filter {
|
|
||||||
$0.type.priority == .low
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Preserva contenido importante
|
|
||||||
- Limpieza más inteligente
|
|
||||||
- Mejor experiencia de usuario
|
|
||||||
|
|
||||||
#### 4.3 Análisis de Patrones de Uso
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin análisis de uso
|
|
||||||
// AFTER: Tracking completo de patrones
|
|
||||||
struct CacheItem {
|
|
||||||
var lastAccess: Date
|
|
||||||
var accessCount: Int
|
|
||||||
let created: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCacheReport() -> CacheReport {
|
|
||||||
let averageAge = cacheItems.values
|
|
||||||
.map { now.timeIntervalSince($0.created) }
|
|
||||||
.reduce(0, +) / Double(cacheItems.count)
|
|
||||||
|
|
||||||
let averageAccessCount = cacheItems.values
|
|
||||||
.map { $0.accessCount }
|
|
||||||
.reduce(0, +) / Double(cacheItems.count)
|
|
||||||
|
|
||||||
return CacheReport(
|
|
||||||
averageAge: averageAge,
|
|
||||||
averageAccessCount: averageAccessCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Decisiones basadas en datos reales
|
|
||||||
- Optimización continua
|
|
||||||
- Mejora iterativa
|
|
||||||
|
|
||||||
#### 4.4 Emergency Cleanup
|
|
||||||
```swift
|
|
||||||
// BEFORE: Sin respuesta a baja memoria
|
|
||||||
// AFTER: Limpieza agresiva cuando es necesario
|
|
||||||
func performEmergencyCleanup() {
|
|
||||||
print("🚨 EMERGENCY CLEANUP")
|
|
||||||
|
|
||||||
// Eliminar todo de baja prioridad
|
|
||||||
let lowPriorityItems = cacheItems.filter {
|
|
||||||
$0.type.priority == .low
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, item) in lowPriorityItems {
|
|
||||||
removeCacheItem(key: key, type: item.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si aún es crítico, eliminar media prioridad vieja
|
|
||||||
if availableStorage < minFreeSpace {
|
|
||||||
let oldMediumItems = cacheItems.filter {
|
|
||||||
$0.type.priority == .medium &&
|
|
||||||
$0.lastAccess.addingTimeInterval(7 * 24 * 3600) < now
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, item) in oldMediumItems {
|
|
||||||
removeCacheItem(key: key, type: item.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- Previene crashes por falta de espacio
|
|
||||||
- Respuesta automática a situaciones críticas
|
|
||||||
- App más robusta
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Optimizaciones Generales
|
|
||||||
|
|
||||||
### 5.1 Reducción del Tamaño Final del App
|
|
||||||
|
|
||||||
**Estrategias implementadas:**
|
|
||||||
|
|
||||||
1. **Compresión de assets**
|
|
||||||
- Imágenes comprimidas con calidad adaptativa
|
|
||||||
- Eliminación de imágenes duplicadas
|
|
||||||
|
|
||||||
2. **Code optimization**
|
|
||||||
- Eliminación de código muerto
|
|
||||||
- Uso eficiente de librerías
|
|
||||||
|
|
||||||
3. **Stripping de símbolos**
|
|
||||||
- Configuración de release mode
|
|
||||||
- Optimización del compilador
|
|
||||||
|
|
||||||
**Estimación de reducción:**
|
|
||||||
- BEFORE: ~45 MB
|
|
||||||
- AFTER: ~30-35 MB
|
|
||||||
- **Reducción: 20-25%**
|
|
||||||
|
|
||||||
### 5.2 Optimización del Tiempo de Lanzamiento
|
|
||||||
|
|
||||||
**Estrategias:**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BEFORE: Todo sincrónico en init
|
|
||||||
init() {
|
|
||||||
loadFavorites() // Bloquea
|
|
||||||
loadReadingProgress() // Bloquea
|
|
||||||
loadDownloaded() // Bloquea
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Carga diferida y en background
|
|
||||||
init() {
|
|
||||||
// Solo carga esencial sincrónica
|
|
||||||
setupViews()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadContent() async {
|
|
||||||
// Todo lo demás en background
|
|
||||||
Task {
|
|
||||||
await loadFavorites()
|
|
||||||
await loadReadingProgress()
|
|
||||||
await loadDownloaded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mejoras:**
|
|
||||||
- BEFORE: 2-3 segundos hasta UI usable
|
|
||||||
- AFTER: 0.5-1 segundo hasta UI usable
|
|
||||||
- **Mejora: 60-70%**
|
|
||||||
|
|
||||||
### 5.3 Reducción de Uso de Memoria en Background
|
|
||||||
|
|
||||||
**Estrategias:**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BEFORE: No liberaba memoria en background
|
|
||||||
// AFTER: Limpieza agresiva al entrar en background
|
|
||||||
@objc private func handleBackgroundTransition() {
|
|
||||||
// Limpiar caches innecesarios
|
|
||||||
ImageCache.shared.clearAllCache()
|
|
||||||
|
|
||||||
// Guardar estado
|
|
||||||
saveCurrentState()
|
|
||||||
|
|
||||||
// Sugerir al sistema que puede liberar memoria
|
|
||||||
URLCache.shared.removeAllCachedResponses()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:**
|
|
||||||
- App menos probable de ser matada por el sistema
|
|
||||||
- Reanudación más rápida
|
|
||||||
- Mejor experiencia general
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Métricas de Rendimiento
|
|
||||||
|
|
||||||
### Métricas Antes/Después
|
|
||||||
|
|
||||||
| Operación | Antes | Después | Mejora |
|
|
||||||
|-----------|-------|---------|--------|
|
|
||||||
| **Scraper** |
|
|
||||||
| Primer scraping | 5-8s | 3-5s | 40% |
|
|
||||||
| Scraping con cache | 5-8s | 0.1-0.5s | 90% |
|
|
||||||
| Memoria del scraper | 50-80 MB | 20-40 MB | 50% |
|
|
||||||
| **Storage** |
|
|
||||||
| Guardar imagen | 0.5-1s | 0.3-0.6s | 40% |
|
|
||||||
| Cargar imagen local | 0.2-0.5s | 0.05-0.1s | 80% |
|
|
||||||
| Espacio por capítulo | 15-25 MB | 8-15 MB | 40% |
|
|
||||||
| **Reader** |
|
|
||||||
| Cargar página (sin cache) | 2-4s | 1-2s | 50% |
|
|
||||||
| Cargar página (con cache) | 2-4s | 0.05-0.1s | 95% |
|
|
||||||
| Memoria en lectura | 150-300 MB | 50-100 MB | 60% |
|
|
||||||
| **General** |
|
|
||||||
| Tiempo de launch | 2-3s | 0.5-1s | 70% |
|
|
||||||
| Tamaño del app | ~45 MB | ~30-35 MB | 25% |
|
|
||||||
| Crashes por memoria | 2-3/semana | 0-1/semana | 80% |
|
|
||||||
|
|
||||||
### Impacto en Experiencia de Usuario
|
|
||||||
|
|
||||||
| Aspecto | Mejora Percibida |
|
|
||||||
|---------|-----------------|
|
|
||||||
| Velocidad de carga | ⭐⭐⭐⭐⭐ (5/5) |
|
|
||||||
| Fluidez de navegación | ⭐⭐⭐⭐⭐ (5/5) |
|
|
||||||
| Estabilidad | ⭐⭐⭐⭐⭐ (5/5) |
|
|
||||||
| Uso de espacio | ⭐⭐⭐⭐ (4/5) |
|
|
||||||
| Consumo de datos | ⭐⭐⭐⭐⭐ (5/5) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Implementación y Testing
|
|
||||||
|
|
||||||
### Pasos para Implementación
|
|
||||||
|
|
||||||
1. **Reemplazar componentes originales:**
|
|
||||||
```bash
|
|
||||||
# Backup de archivos originales
|
|
||||||
mv ManhwaWebScraper.swift ManhwaWebScraper.swift.backup
|
|
||||||
mv StorageService.swift StorageService.swift.backup
|
|
||||||
mv ReaderView.swift ReaderView.swift.backup
|
|
||||||
|
|
||||||
# Usar versiones optimizadas
|
|
||||||
# (Ya sea reemplazando o usando imports condicionales)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configuración de Cache:**
|
|
||||||
```swift
|
|
||||||
// En AppDelegate o SceneDelegate
|
|
||||||
let cacheManager = CacheManager.shared
|
|
||||||
cacheManager.printCacheReport() // Debug inicial
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Testing:**
|
|
||||||
- Pruebas de carga con diferentes tamaños de capítulo
|
|
||||||
- Testing de memoria con Instruments
|
|
||||||
- Medición de tiempos de carga
|
|
||||||
- Verificación de funcionalidad de cache
|
|
||||||
|
|
||||||
### Tests Recomendados
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Test de cache efficiency
|
|
||||||
func testCacheHitRate() {
|
|
||||||
let report = CacheManager.shared.getCacheReport()
|
|
||||||
XCTAssertGreaterThan(report.itemsRemoved, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test de memory management
|
|
||||||
func testMemoryUsageUnderLoad() {
|
|
||||||
// Monitorear memoria durante carga de 100 páginas
|
|
||||||
// Debe mantenerse bajo límite crítico
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test de preloading
|
|
||||||
func testPreloadingEfficiency() {
|
|
||||||
// Verificar que páginas adyacentes se cargan
|
|
||||||
// antes de ser visibles
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Notas de Mantenimiento
|
|
||||||
|
|
||||||
### Monitoreo Continuo
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Imprimir reportes periódicamente
|
|
||||||
Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { _ in
|
|
||||||
CacheManager.shared.printCacheReport()
|
|
||||||
ImageCache.shared.printStatistics()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ajustes de Configuración
|
|
||||||
|
|
||||||
Según los patrones de uso reales, ajustar:
|
|
||||||
|
|
||||||
1. **Tamaños de cache:**
|
|
||||||
- `maxCacheSize` en CacheManager
|
|
||||||
- `memoryCacheLimit` en ImageCache
|
|
||||||
- `cacheValidDuration` en Scraper
|
|
||||||
|
|
||||||
2. **Thresholds de limpieza:**
|
|
||||||
- `maxCacheAge` según retención deseada
|
|
||||||
- `minFreeSpace` según dispositivo target
|
|
||||||
|
|
||||||
3. **Niveles de preloading:**
|
|
||||||
- Cantidad de páginas adyacentes
|
|
||||||
- Prioridades de descarga
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist de Verificación
|
|
||||||
|
|
||||||
- [ ] WKWebView reutiliza correctamente
|
|
||||||
- [ ] Cache de HTML funciona con expiración
|
|
||||||
- [ ] JavaScript precompilado ejecuta correctamente
|
|
||||||
- [ ] Timeout adaptativo responde a condiciones de red
|
|
||||||
- [ ] Compresión de imágenes mantiene calidad aceptable
|
|
||||||
- [ ] Thumbnails se generan correctamente
|
|
||||||
- [ ] Lazy loading funciona en listas grandes
|
|
||||||
- [ ] Purga automática no elimina contenido reciente
|
|
||||||
- [ ] NSCache responde a memory warnings
|
|
||||||
- [ ] Preloading no afecta performance
|
|
||||||
- [ ] Debouncing de progreso funciona
|
|
||||||
- [ ] LRU elimina items correctos
|
|
||||||
- [ ] Emergency cleanup funciona cuando es necesario
|
|
||||||
- [ ] Métricas de rendimiento se mantienen positivas
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Conclusión
|
|
||||||
|
|
||||||
Las optimizaciones implementadas mejoran significativamente el rendimiento y uso de memoria del MangaReader:
|
|
||||||
|
|
||||||
- **Rendimiento:** 50-90% de mejora en tiempos de carga
|
|
||||||
- **Memoria:** 50-65% de reducción en uso
|
|
||||||
- **Tamaño:** 20-25% de reducción en app final
|
|
||||||
- **Estabilidad:** 80% de reducción en crashes por memoria
|
|
||||||
- **Experiencia:** Calificación 4.5-5/5 en fluidez
|
|
||||||
|
|
||||||
Los archivos optimizados mantienen compatibilidad con el código existente mientras agregan capas de optimización inteligentes y automáticas.
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
# MangaReader - Archivos de Optimización
|
|
||||||
|
|
||||||
## 📦 Contenido
|
|
||||||
|
|
||||||
Este directorio contiene las versiones optimizadas de los componentes principales de MangaReader, junto con documentación completa.
|
|
||||||
|
|
||||||
### 🔧 Archivos de Código (Source Files)
|
|
||||||
|
|
||||||
#### Servicios Optimizados
|
|
||||||
|
|
||||||
1. **`ManhwaWebScraperOptimized.swift`**
|
|
||||||
- Reemplazo de `ManhwaWebScraper.swift`
|
|
||||||
- Cache inteligente de HTML (30 min)
|
|
||||||
- JavaScript precompilado
|
|
||||||
- Timeout adaptativo (2-8s según red)
|
|
||||||
- Control de concurrencia (máx 2 scrapings)
|
|
||||||
- **Mejora**: 80-90% más rápido con cache, 40% en primera carga
|
|
||||||
|
|
||||||
2. **`StorageServiceOptimized.swift`**
|
|
||||||
- Reemplazo de `StorageService.swift`
|
|
||||||
- Compresión adaptativa de imágenes (0.6-0.9 según tamaño)
|
|
||||||
- Sistema automático de thumbnails (150x200)
|
|
||||||
- Lazy loading con paginación
|
|
||||||
- Purga automática de cache viejo (>30 días)
|
|
||||||
- **Mejora**: 40% menos espacio, 70% más rápido en startup
|
|
||||||
|
|
||||||
3. **`ImageCache.swift`**
|
|
||||||
- **NUEVO**: Sistema completo de cache de imágenes
|
|
||||||
- NSCache en memoria + cache en disco
|
|
||||||
- Preloading de páginas adyacentes
|
|
||||||
- Prioridades de carga (current, adjacent, prefetch)
|
|
||||||
- Compresión automática de imágenes >2048px
|
|
||||||
- **Mejora**: 80-90% hit rate, navegación instantánea
|
|
||||||
|
|
||||||
4. **`CacheManager.swift`**
|
|
||||||
- **NUEVO**: Gerente centralizado de cache
|
|
||||||
- Políticas LRU (Least Recently Used)
|
|
||||||
- Priorización por tipo (images > thumbnails > html > metadata)
|
|
||||||
- Análisis de patrones de uso
|
|
||||||
- Emergency cleanup para situaciones críticas
|
|
||||||
- **Mejora**: Control total de cache, decisiones inteligentes
|
|
||||||
|
|
||||||
#### Vistas Optimizadas
|
|
||||||
|
|
||||||
5. **`ReaderViewOptimized.swift`**
|
|
||||||
- Reemplazo de `ReaderView.swift`
|
|
||||||
- Integración con ImageCache
|
|
||||||
- Preloading automático de 4 páginas adyacentes
|
|
||||||
- Debouncing de guardado de progreso (2s)
|
|
||||||
- Memory management para imágenes grandes
|
|
||||||
- **Mejora**: 60% menos memoria, 95% más rápido con cache
|
|
||||||
|
|
||||||
### 📚 Documentación (Documentation Files)
|
|
||||||
|
|
||||||
1. **`OPTIMIZATION_SUMMARY.md`**
|
|
||||||
- Documentación completa de todas las optimizaciones
|
|
||||||
- Explicación detallada de cada mejora
|
|
||||||
- Métricas antes/después
|
|
||||||
- Impacto en experiencia de usuario
|
|
||||||
- **Léelo primero para entender todo**
|
|
||||||
|
|
||||||
2. **`IMPLEMENTATION_GUIDE.md`**
|
|
||||||
- Guía paso a paso para implementación
|
|
||||||
- Configuración inicial
|
|
||||||
- Testing checklist
|
|
||||||
- Troubleshooting
|
|
||||||
- Tips y best practices
|
|
||||||
- **Usa esto como guía práctica**
|
|
||||||
|
|
||||||
3. **`BEFORE_AFTER_COMPARISON.md`**
|
|
||||||
- Comparaciones lado a lado de código
|
|
||||||
- ❌ BEFORE vs ✅ AFTER
|
|
||||||
- Explicación de problemas y soluciones
|
|
||||||
- Snippets de código comentados
|
|
||||||
- **Ideal para entender los cambios**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Comenzando (Quick Start)
|
|
||||||
|
|
||||||
### Paso 1: Backup
|
|
||||||
```bash
|
|
||||||
# Hacer backup de archivos originales
|
|
||||||
cd /home/ren/ios/MangaReader/ios-app/Sources
|
|
||||||
cp Services/ManhwaWebScraper.swift Services/ManhwaWebScraper.swift.backup
|
|
||||||
cp Services/StorageService.swift Services/StorageService.swift.backup
|
|
||||||
cp Views/ReaderView.swift Views/ReaderView.swift.backup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 2: Integración Gradual
|
|
||||||
|
|
||||||
#### Opción A: Usar alias de tipo (Recomendado para empezar)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Agrega esto en tu código:
|
|
||||||
typealias Scraper = ManhwaWebScraperOptimized
|
|
||||||
typealias Storage = StorageServiceOptimized
|
|
||||||
|
|
||||||
// Tu código existente funciona sin cambios:
|
|
||||||
private let scraper = Scraper.shared
|
|
||||||
private let storage = Storage.shared
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Opción B: Reemplazo directo (Para testing completo)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En tus ViewModels, cambiar:
|
|
||||||
private let scraper = ManhwaWebScraperOptimized.shared
|
|
||||||
private let storage = StorageServiceOptimized.shared
|
|
||||||
|
|
||||||
// En tu ReaderView, usar:
|
|
||||||
ReaderViewOptimized(manga: manga, chapter: chapter)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 3: Inicializar CacheManager
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En AppDelegate.swift
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions ...
|
|
||||||
) -> Bool {
|
|
||||||
// Inicializar cache manager
|
|
||||||
_ = CacheManager.shared
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Verificar configuración en debug
|
|
||||||
CacheManager.shared.printCacheReport()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Métricas de Mejora
|
|
||||||
|
|
||||||
### Rendimiento
|
|
||||||
|
|
||||||
| Operación | Antes | Después | Mejora |
|
|
||||||
|-----------|-------|---------|--------|
|
|
||||||
| Primer scraping | 5-8s | 3-5s | **40%** |
|
|
||||||
| Scraping con cache | 5-8s | 0.1-0.5s | **90%** |
|
|
||||||
| Cargar página (sin cache) | 2-4s | 1-2s | **50%** |
|
|
||||||
| Cargar página (con cache) | 2-4s | 0.05-0.1s | **95%** |
|
|
||||||
| Inicio de app | 2-3s | 0.5-1s | **70%** |
|
|
||||||
|
|
||||||
### Memoria y Espacio
|
|
||||||
|
|
||||||
| Recurso | Antes | Después | Mejora |
|
|
||||||
|---------|-------|---------|--------|
|
|
||||||
| Memoria en lectura | 150-300 MB | 50-100 MB | **60%** |
|
|
||||||
| Espacio por capítulo | 15-25 MB | 8-15 MB | **40%** |
|
|
||||||
| Tamaño del app | ~45 MB | ~30-35 MB | **25%** |
|
|
||||||
|
|
||||||
### Estabilidad
|
|
||||||
|
|
||||||
| Métrica | Antes | Después | Mejora |
|
|
||||||
|---------|-------|---------|--------|
|
|
||||||
| Crashes por memoria | 2-3/semana | 0-1/semana | **80%** |
|
|
||||||
| Hit rate de cache | N/A | 85-95% | **Nuevo** |
|
|
||||||
| Preloading de páginas | No | 4 páginas | **Nuevo** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Tests Funcionales
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar tests
|
|
||||||
xcodebuild test -scheme MangaReader -destination 'platform=iOS Simulator,name=iPhone 14'
|
|
||||||
|
|
||||||
# Con cobertura
|
|
||||||
xcodebuild test -scheme MangaReader -enableCodeCoverage YES
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Memoria
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Usar Instruments
|
|
||||||
1. Xcode → Product → Profile (⌘I)
|
|
||||||
2. Seleccionar "Allocations"
|
|
||||||
3. Monitorear:
|
|
||||||
- Overall memory usage
|
|
||||||
- Anonymous VM
|
|
||||||
- Image cache size
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests de Rendimiento
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Time Profiler
|
|
||||||
1. Product → Profile
|
|
||||||
2. Seleccionar "Time Profiler"
|
|
||||||
3. Buscar funciones lentas
|
|
||||||
4. Verificar que cache esté funcionando
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Estructura de Archivos
|
|
||||||
|
|
||||||
```
|
|
||||||
MangaReader/
|
|
||||||
├── ios-app/Sources/
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ ├── ManhwaWebScraper.swift [ORIGINAL]
|
|
||||||
│ │ ├── ManhwaWebScraperOptimized.swift ✨ [OPTIMIZADO]
|
|
||||||
│ │ ├── StorageService.swift [ORIGINAL]
|
|
||||||
│ │ ├── StorageServiceOptimized.swift ✨ [OPTIMIZADO]
|
|
||||||
│ │ ├── ImageCache.swift ✨ [NUEVO]
|
|
||||||
│ │ └── CacheManager.swift ✨ [NUEVO]
|
|
||||||
│ └── Views/
|
|
||||||
│ ├── ReaderView.swift [ORIGINAL]
|
|
||||||
│ └── ReaderViewOptimized.swift ✨ [OPTIMIZADO]
|
|
||||||
│
|
|
||||||
├── OPTIMIZATION_SUMMARY.md 📖 [DOCUMENTACIÓN]
|
|
||||||
├── IMPLEMENTATION_GUIDE.md 📖 [GUÍA PRÁCTICA]
|
|
||||||
├── BEFORE_AFTER_COMPARISON.md 📖 [COMPARACIONES]
|
|
||||||
└── README_OPTIMIZATIONS.md 📖 [ESTE ARCHIVO]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Características Principales
|
|
||||||
|
|
||||||
### 1. Scraper Optimizado
|
|
||||||
|
|
||||||
✅ **Cache Inteligente**
|
|
||||||
- HTML cacheado por 30 minutos
|
|
||||||
- Reducción de 80-90% en requests
|
|
||||||
|
|
||||||
✅ **JavaScript Precompilado**
|
|
||||||
- Scripts precompilados en enum
|
|
||||||
- 10-15% más rápido en ejecución
|
|
||||||
|
|
||||||
✅ **Timeout Adaptativo**
|
|
||||||
- Se ajusta a condiciones de red (2-8s)
|
|
||||||
- Aprende del historial de rendimiento
|
|
||||||
|
|
||||||
✅ **Control de Concurrencia**
|
|
||||||
- Máximo 2 scrapings simultáneos
|
|
||||||
- Previene crashes por sobrecarga
|
|
||||||
|
|
||||||
### 2. Storage Optimizado
|
|
||||||
|
|
||||||
✅ **Compresión Adaptativa**
|
|
||||||
- Calidad: 0.9 (pequeñas), 0.75 (medianas), 0.6 (grandes)
|
|
||||||
- 30-40% menos espacio
|
|
||||||
|
|
||||||
✅ **Sistema de Thumbnails**
|
|
||||||
- Generación automática (150x200)
|
|
||||||
- Navegación 10x más rápida
|
|
||||||
|
|
||||||
✅ **Lazy Loading**
|
|
||||||
- Paginación de capítulos
|
|
||||||
- Carga solo lo necesario
|
|
||||||
|
|
||||||
✅ **Purga Automática**
|
|
||||||
- Limpieza cada 24 horas
|
|
||||||
- Elimina archivos >30 días
|
|
||||||
|
|
||||||
### 3. Reader Optimizado
|
|
||||||
|
|
||||||
✅ **Image Caching**
|
|
||||||
- NSCache en memoria
|
|
||||||
- Cache en disco para persistencia
|
|
||||||
- 85-95% hit rate
|
|
||||||
|
|
||||||
✅ **Preloading Inteligente**
|
|
||||||
- Precarga 2 páginas antes y después
|
|
||||||
- Navegación instantánea en 80% de casos
|
|
||||||
|
|
||||||
✅ **Memory Management**
|
|
||||||
- Optimiza imágenes >2048px
|
|
||||||
- Respuesta a memory warnings
|
|
||||||
- 60% menos memoria
|
|
||||||
|
|
||||||
✅ **Debouncing**
|
|
||||||
- Guarda progreso cada 2s de inactividad
|
|
||||||
- 95% menos escrituras a disco
|
|
||||||
|
|
||||||
### 4. Cache Manager
|
|
||||||
|
|
||||||
✅ **Políticas LRU**
|
|
||||||
- Elimina menos usados primero
|
|
||||||
- Preserva contenido importante
|
|
||||||
|
|
||||||
✅ **Priorización**
|
|
||||||
- Images > Thumbnails > HTML > Metadata
|
|
||||||
- Limpieza graduada
|
|
||||||
|
|
||||||
✅ **Emergency Cleanup**
|
|
||||||
- Respuesta automática a baja memoria
|
|
||||||
- Previene crashes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Configuración
|
|
||||||
|
|
||||||
### Ajustes de Cache
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En CacheManager.swift
|
|
||||||
struct CacheLimits {
|
|
||||||
static let maxCacheSizePercentage: Double = 0.15 // 15% del almacenamiento
|
|
||||||
static let minFreeSpace: Int64 = 500 * 1024 * 1024 // 500 MB mínimo libre
|
|
||||||
static let maxAge: TimeInterval = 30 * 24 * 3600 // 30 días
|
|
||||||
static let maxItemCount: Int = 1000 // Máximo items
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ajustes de Imagen
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En ImageCache.swift
|
|
||||||
private let maxImageDimension: CGFloat = 2048 // Máximo 2048x2048
|
|
||||||
private let diskCacheLimit: Int64 = 500 * 1024 * 1024 // 500 MB cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ajustes de Preloading
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En ReaderViewOptimized.swift
|
|
||||||
@Published var enablePreloading = true // Habilitar preloading
|
|
||||||
private let preloadRange = 2 // 2 páginas antes/después
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Debugging
|
|
||||||
|
|
||||||
### Ver Estadísticas
|
|
||||||
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
// En tu vista de debug
|
|
||||||
Button("Print Cache Report") {
|
|
||||||
CacheManager.shared.printCacheReport()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Print Image Stats") {
|
|
||||||
ImageCache.shared.printStatistics()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Simulate Memory Warning") {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs Importantes
|
|
||||||
|
|
||||||
Busca estos logs en console:
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ Cache HIT → Cache funcionando
|
|
||||||
❌ Cache MISS - Scraping → Scrapeando (normal la primera vez)
|
|
||||||
🗑️ Removed old file → Limpieza automática
|
|
||||||
⚠️ Memory warning received → Memory warning (respuesta automática)
|
|
||||||
📥 Loaded image in 0.23s → Tiempo de carga de imagen
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Problema: Cache no funciona
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```swift
|
|
||||||
// Verificar que uses el singleton
|
|
||||||
let scraper = ManhwaWebScraperOptimized.shared // ✅ Correcto
|
|
||||||
let scraper = ManhwaWebScraperOptimized() // ❌ Incorrecto
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Memoria sigue alta
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```swift
|
|
||||||
// 1. Verificar weak references
|
|
||||||
Task { [weak self] in // ✅ Correcto
|
|
||||||
await self?.loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Verificar deinit
|
|
||||||
deinit {
|
|
||||||
progressSaveTimer?.invalidate()
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Preloading no funciona
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```swift
|
|
||||||
// Habilitar preloading
|
|
||||||
viewModel.enablePreloading = true // ✅
|
|
||||||
|
|
||||||
// Verificar onAppear
|
|
||||||
.onAppear {
|
|
||||||
viewModel.preloadAdjacentPages(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Monitoreo Continuo
|
|
||||||
|
|
||||||
### Métricas Clave
|
|
||||||
|
|
||||||
Monitorea regularmente:
|
|
||||||
|
|
||||||
1. **Cache Hit Rate**
|
|
||||||
```swift
|
|
||||||
let stats = ImageCache.shared.getCacheStatistics()
|
|
||||||
print("Hit rate: \(stats.hitRate * 100)%")
|
|
||||||
```
|
|
||||||
Objetivo: >80%
|
|
||||||
|
|
||||||
2. **Tamaño de Cache**
|
|
||||||
```swift
|
|
||||||
let size = CacheManager.shared.getCurrentCacheSize()
|
|
||||||
print("Cache size: \(formatBytes(size))")
|
|
||||||
```
|
|
||||||
Objetivo: <500 MB
|
|
||||||
|
|
||||||
3. **Uso de Memoria**
|
|
||||||
```bash
|
|
||||||
# Usar Instruments
|
|
||||||
# Objetivo: <100 MB en lectura
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Tiempo de Carga**
|
|
||||||
```swift
|
|
||||||
let start = Date()
|
|
||||||
await loadPages()
|
|
||||||
let time = Date().timeIntervalSince(start)
|
|
||||||
print("Load time: \(time)s")
|
|
||||||
```
|
|
||||||
Objetivo: <2s
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Recursos de Aprendizaje
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
|
|
||||||
1. **`OPTIMIZATION_SUMMARY.md`**
|
|
||||||
- Lee primero para overview completo
|
|
||||||
- Detalle técnico de cada optimización
|
|
||||||
|
|
||||||
2. **`BEFORE_AFTER_COMPARISON.md`**
|
|
||||||
- Compara código lado a lado
|
|
||||||
- Entiende qué cambió y por qué
|
|
||||||
|
|
||||||
3. **`IMPLEMENTATION_GUIDE.md`**
|
|
||||||
- Sigue esto para implementar
|
|
||||||
- Incluye tests y troubleshooting
|
|
||||||
|
|
||||||
### Apple Documentation
|
|
||||||
|
|
||||||
- [NSCache](https://developer.apple.com/documentation/foundation/nscache)
|
|
||||||
- [WKWebView](https://developer.apple.com/documentation/webkit/wkwebview)
|
|
||||||
- [Memory Management](https://developer.apple.com/documentation/swift/memory_safety)
|
|
||||||
- [Instruments](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist de Verificación
|
|
||||||
|
|
||||||
Antes de considerar la implementación completa:
|
|
||||||
|
|
||||||
### Funcionalidad
|
|
||||||
- [ ] WKWebView se reutiliza correctamente
|
|
||||||
- [ ] Cache de HTML funciona con expiración
|
|
||||||
- [ ] JavaScript precompilado ejecuta correctamente
|
|
||||||
- [ ] Timeout adaptativo responde a condiciones de red
|
|
||||||
- [ ] Compresión de imágenes mantiene calidad aceptable
|
|
||||||
- [ ] Thumbnails se generan correctamente
|
|
||||||
- [ ] Lazy loading funciona en listas grandes
|
|
||||||
- [ ] Purga automática no elimina contenido reciente
|
|
||||||
- [ ] NSCache responde a memory warnings
|
|
||||||
- [ ] Preloading no afecta performance
|
|
||||||
- [ ] Debouncing de progreso funciona
|
|
||||||
- [ ] LRU elimina items correctos
|
|
||||||
|
|
||||||
### Rendimiento
|
|
||||||
- [ ] Tiempo de carga < 2s para capítulos
|
|
||||||
- [ ] Hit rate de cache > 80%
|
|
||||||
- [ ] Memoria en lectura < 100 MB
|
|
||||||
- [ ] App launch < 1s
|
|
||||||
- [ ] Tamaño de cache < 500 MB
|
|
||||||
|
|
||||||
### Estabilidad
|
|
||||||
- [ ] Sin crashes por memoria
|
|
||||||
- [ ] Sin memory leaks
|
|
||||||
- [ ] Respuesta correcta a warnings
|
|
||||||
- [ ] Cleanup automático funciona
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Próximos Pasos
|
|
||||||
|
|
||||||
### Implementación Inmediata
|
|
||||||
|
|
||||||
1. ✅ Hacer backup del código existente
|
|
||||||
2. ✅ Reemplazar archivos uno por uno
|
|
||||||
3. ✅ Probar exhaustivamente
|
|
||||||
4. ✅ Monitorear métricas
|
|
||||||
|
|
||||||
### Optimizaciones Futuras (Opcionales)
|
|
||||||
|
|
||||||
- [ ] Prefetching predictivo con ML
|
|
||||||
- [ ] Compresión HEIC (50% más eficiente)
|
|
||||||
- [ ] Progressive image loading
|
|
||||||
- [ ] Background sync con BGTaskScheduler
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Tips y Best Practices
|
|
||||||
|
|
||||||
### DO ✅
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. Usar singletons para caches
|
|
||||||
static let shared = ImageCache()
|
|
||||||
|
|
||||||
// 2. Usar colas para I/O
|
|
||||||
private let ioQueue = DispatchQueue(label: "...", qos: .utility)
|
|
||||||
|
|
||||||
// 3. Weak references en closures
|
|
||||||
Task { [weak self] in
|
|
||||||
await self?.loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Responder a memory warnings
|
|
||||||
@objc func handleMemoryWarning() {
|
|
||||||
cache.removeAllObjects()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Debouncing de operaciones frecuentes
|
|
||||||
func saveDebounced() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = Timer.scheduledTimer(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DON'T ❌
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 1. NO crear múltiples instancias
|
|
||||||
// let cache1 = ImageCache()
|
|
||||||
// let cache2 = ImageCache() // ❌
|
|
||||||
|
|
||||||
// 2. NO hacer I/O en main thread
|
|
||||||
// let data = try Data(contentsOf: url) // ❌ Bloquea
|
|
||||||
|
|
||||||
// 3. NO olvidar weak en closures
|
|
||||||
// Task {
|
|
||||||
// self.doSomething() // ❌ Memory leak
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 4. NO ignorar memory warnings
|
|
||||||
// @objc func handleMemoryWarning() {
|
|
||||||
// // ❌ No hacer nada
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 5. NO guardar en cada cambio
|
|
||||||
// func onChange() {
|
|
||||||
// saveToDisk() // ❌ Demasiado frecuente
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Soporte
|
|
||||||
|
|
||||||
Si encuentras problemas:
|
|
||||||
|
|
||||||
1. Revisa la documentación:
|
|
||||||
- `OPTIMIZATION_SUMMARY.md` para detalles técnicos
|
|
||||||
- `IMPLEMENTATION_GUIDE.md` para guía práctica
|
|
||||||
- `BEFORE_AFTER_COMPARISON.md` para comparaciones
|
|
||||||
|
|
||||||
2. Usa los tests de ejemplo
|
|
||||||
|
|
||||||
3. Habilita logging debug en development
|
|
||||||
|
|
||||||
4. Usa Instruments para perfilado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Conclusión
|
|
||||||
|
|
||||||
Estas optimizaciones mejoran significativamente MangaReader:
|
|
||||||
|
|
||||||
- **Rendimiento**: 50-90% de mejora en tiempos de carga
|
|
||||||
- **Memoria**: 50-65% de reducción en uso
|
|
||||||
- **Tamaño**: 20-25% de reducción en app final
|
|
||||||
- **Estabilidad**: 80% de reducción en crashes por memoria
|
|
||||||
- **Experiencia**: Calificación 4.5-5/5 en fluidez
|
|
||||||
|
|
||||||
Los archivos optimizados mantienen compatibilidad con el código existente mientras agregan capas de optimización inteligentes y automáticas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**¡Happy Optimizing! 🚀**
|
|
||||||
|
|
||||||
Para cualquier pregunta o sugerencia, consulta los archivos de documentación incluidos.
|
|
||||||
230
SERVER_SETUP.md
230
SERVER_SETUP.md
@@ -1,230 +0,0 @@
|
|||||||
# MangaReader - Configuración del Servidor VPS
|
|
||||||
|
|
||||||
## 📋 Información del Servidor
|
|
||||||
|
|
||||||
**URL:** https://manga.cbcren.online
|
|
||||||
**Subdominio:** manga.cbcren.online (dedicado, sin interferencias)
|
|
||||||
**Protocolo:** HTTPS (SSL automático por Let's Encrypt)
|
|
||||||
**Puerto backend:** 3001 (interno, vía Docker proxy)
|
|
||||||
**Estado:** ✅ Activo y corriendo
|
|
||||||
|
|
||||||
## 🚀 Servicios Configurados
|
|
||||||
|
|
||||||
### 1. Backend API (Node.js/Express)
|
|
||||||
- **Puerto:** 3001
|
|
||||||
- **Servicio systemd:** `mangareader-backend.service`
|
|
||||||
- **Directorio:** `/home/ren/ios/MangaReader/backend`
|
|
||||||
- **Auto-reinicio:** ✅ Sí (systemd)
|
|
||||||
- **Acceso externo:** ✅ Sí (firewall ufw)
|
|
||||||
|
|
||||||
### 2. Proxy Reverse (Caddy en Docker)
|
|
||||||
- **Contenedor:** gitea-proxy
|
|
||||||
- **Imagen:** caddy:2
|
|
||||||
- **Rol:** Proxy HTTPS con certificado SSL automático
|
|
||||||
- **Backend interno:** 172.17.0.1:3001 (Docker bridge gateway)
|
|
||||||
- **SSL:** Let's Encrypt automático
|
|
||||||
- **No interfiere con:** Gitea, Nextcloud, DNS, Finanzas
|
|
||||||
|
|
||||||
### 3. DNS
|
|
||||||
- **Registro A:** manga.cbcren.online → 194.163.191.200
|
|
||||||
- **Propietario:** Usuario
|
|
||||||
- **Propósito:** Subdominio dedicado exclusivo para MangaReader
|
|
||||||
|
|
||||||
### 4. Storage
|
|
||||||
- **Directorio:** `/home/ren/ios/MangaReader/storage/`
|
|
||||||
- **Estructura:**
|
|
||||||
```
|
|
||||||
storage/
|
|
||||||
└── manga/
|
|
||||||
└── {mangaSlug}/
|
|
||||||
└── chapter_{chapterNumber}/
|
|
||||||
├── page_001.jpg
|
|
||||||
├── page_002.jpg
|
|
||||||
└── manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🌐 Endpoints API
|
|
||||||
|
|
||||||
### Manga Endpoints
|
|
||||||
- `GET /api/health` - Health check
|
|
||||||
- `GET /api/manga/:slug` - Info de manga
|
|
||||||
- `GET /api/manga/:slug/chapters` - Lista de capítulos
|
|
||||||
- `GET /api/chapter/:slug/images` - Imágenes de capítulo
|
|
||||||
- `GET /api/manga/:slug/full` - Info completa
|
|
||||||
|
|
||||||
### Storage Endpoints
|
|
||||||
- `POST /api/download` - Descargar capítulo a VPS
|
|
||||||
- `GET /api/storage/chapters/:mangaSlug` - Listar capítulos descargados
|
|
||||||
- `GET /api/storage/chapter/:mangaSlug/:chapterNumber` - Verificar capítulo
|
|
||||||
- `GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex` - Obtener imagen
|
|
||||||
- `DELETE /api/storage/chapter/:mangaSlug/:chapterNumber` - Eliminar capítulo
|
|
||||||
- `GET /api/storage/stats` - Estadísticas de almacenamiento
|
|
||||||
|
|
||||||
## 🔧 Gestión del Servicio
|
|
||||||
|
|
||||||
### Verificar estado
|
|
||||||
```bash
|
|
||||||
sudo systemctl status mangareader-backend.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Iniciar servicio
|
|
||||||
```bash
|
|
||||||
sudo systemctl start mangareader-backend.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Detener servicio
|
|
||||||
```bash
|
|
||||||
sudo systemctl stop mangareader-backend.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reiniciar servicio
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart mangareader-backend.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver logs
|
|
||||||
```bash
|
|
||||||
sudo journalctl -u mangareader-backend.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver logs recientes
|
|
||||||
```bash
|
|
||||||
sudo journalctl -u mangareader-backend.service -n 50
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 Configuración iOS App
|
|
||||||
|
|
||||||
### APIConfig.swift
|
|
||||||
```swift
|
|
||||||
static let serverURL = "https://manga.cbcren.online"
|
|
||||||
static let port: Int? = nil // Usa puerto estándar HTTPS (443)
|
|
||||||
```
|
|
||||||
|
|
||||||
### URL Base Completa
|
|
||||||
```
|
|
||||||
https://manga.cbcren.online
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Tests
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
```bash
|
|
||||||
curl https://manga.cbcren.online/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### Storage Stats
|
|
||||||
```bash
|
|
||||||
curl https://manga.cbcren.online/api/storage/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Local (directo al backend)
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔒 Seguridad
|
|
||||||
|
|
||||||
### SSL/TLS
|
|
||||||
- **Certificado:** Let's Encrypt (automático via Caddy)
|
|
||||||
- **Renovación:** Automática
|
|
||||||
- **Protocolo:** HTTPS/TLS 1.2+
|
|
||||||
- **Proxy:** Caddy maneja SSL termination
|
|
||||||
|
|
||||||
### Firewall
|
|
||||||
- Puerto 3001 abierto solo para acceso local (Docker)
|
|
||||||
- No requiere puerto abierto al público (Caddy maneja el proxy)
|
|
||||||
|
|
||||||
### Recomendaciones Futuras
|
|
||||||
1. ✅ SSL/HTTPS (implementado con Let's Encrypt)
|
|
||||||
2. Implementar autenticación JWT
|
|
||||||
3. Rate limiting
|
|
||||||
4. Validación de input
|
|
||||||
5. Sanitización de rutas de archivos
|
|
||||||
|
|
||||||
## 📊 Monitoreo
|
|
||||||
|
|
||||||
### Revisar uso de disco
|
|
||||||
```bash
|
|
||||||
du -sh /home/ren/ios/MangaReader/storage/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Listar capítulos descargados
|
|
||||||
```bash
|
|
||||||
find /home/ren/ios/MangaReader/storage/ -name "manifest.json"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver tamaño por manga
|
|
||||||
```bash
|
|
||||||
du -sh /home/ren/ios/MangaReader/storage/manga/*/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚨 Troubleshooting
|
|
||||||
|
|
||||||
### El servicio no inicia
|
|
||||||
```bash
|
|
||||||
# Ver logs de error
|
|
||||||
sudo journalctl -u mangareader-backend.service -n 100 --no-pager
|
|
||||||
|
|
||||||
# Verificar que node esté instalado
|
|
||||||
which node
|
|
||||||
node --version
|
|
||||||
|
|
||||||
# Verificar puerto disponible
|
|
||||||
sudo ss -tlnp | grep 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
### No se puede acceder desde el exterior
|
|
||||||
```bash
|
|
||||||
# Verificar firewall
|
|
||||||
sudo ufw status
|
|
||||||
|
|
||||||
# Verificar que el servicio esté corriendo
|
|
||||||
sudo systemctl status mangareader-backend.service
|
|
||||||
|
|
||||||
# Verificar puerto desde adentro
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### Puerto ya en uso
|
|
||||||
```bash
|
|
||||||
# Ver qué está usando el puerto
|
|
||||||
sudo ss -tlnp | grep 3001
|
|
||||||
|
|
||||||
# Matar proceso si es necesario
|
|
||||||
sudo kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Notas Importantes
|
|
||||||
|
|
||||||
1. **Auto-inicio:** El servicio se inicia automáticamente al reiniciar el servidor
|
|
||||||
2. **Auto-reinicio:** Si el servicio falla, se reinicia automáticamente después de 10 segundos
|
|
||||||
3. **Subdominio dedicado:** manga.cbcren.online no interfiere con Gitea ni otros servicios
|
|
||||||
4. **HTTPS automático:** Caddy obtiene y renueva certificados SSL automáticamente
|
|
||||||
5. **Sin puertos públicos:** El puerto 3001 es interno, solo se expone vía proxy HTTPS
|
|
||||||
6. **Almacenamiento:** Usa /home/ren/ios/MangaReader/storage/ con espacio disponible (200GB)
|
|
||||||
|
|
||||||
## 🎯 Próximos Pasos
|
|
||||||
|
|
||||||
1. ✅ Backend configurado y corriendo
|
|
||||||
2. ✅ Subdominio dedicado configurado
|
|
||||||
3. ✅ SSL/HTTPS automático
|
|
||||||
4. ✅ Servicio systemd activo
|
|
||||||
5. ✅ Proxy Caddy configurado
|
|
||||||
6. ⏭️ Clonar repo en Mac
|
|
||||||
7. ⏭️ Compilar app iOS
|
|
||||||
8. ⏭️ Instalar en iPad/iPhone via Sideloadly/3uTools
|
|
||||||
9. ⏭️ Probar descarga de capítulos
|
|
||||||
10. ⏭️ Verificar lectura offline
|
|
||||||
|
|
||||||
## 📞 Soporte
|
|
||||||
|
|
||||||
Si tienes problemas:
|
|
||||||
1. Verificar los logs: `sudo journalctl -u mangareader-backend.service -f`
|
|
||||||
2. Verificar el estado: `sudo systemctl status mangareader-backend.service`
|
|
||||||
3. Verificar firewall: `sudo ufw status`
|
|
||||||
4. Verificar conectividad: `curl http://localhost:3001/api/health`
|
|
||||||
|
|
||||||
---
|
|
||||||
**Última actualización:** 2026-02-04 16:42 CET
|
|
||||||
**Versión:** 2.0.0
|
|
||||||
**Estado:** ✅ Producción (manga.cbcren.online con HTTPS)
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# VPS Backend Integration - iOS App Updates
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully integrated VPS backend storage into the iOS MangaReader app, allowing users to download chapters to a remote VPS server and read them from there.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. VPSAPIClient.swift
|
|
||||||
**Location:** `/home/ren/ios/MangaReader/ios-app/Sources/Services/VPSAPIClient.swift`
|
|
||||||
|
|
||||||
**Purpose:** Complete API client for communicating with the VPS backend server.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Singleton pattern for shared instance
|
|
||||||
- Health check endpoint
|
|
||||||
- Download chapters to VPS storage
|
|
||||||
- Check chapter download status (manifest)
|
|
||||||
- List downloaded chapters
|
|
||||||
- Get image URLs from VPS
|
|
||||||
- Delete chapters from VPS
|
|
||||||
- Get storage statistics
|
|
||||||
- Progress tracking for downloads
|
|
||||||
- Comprehensive error handling
|
|
||||||
|
|
||||||
**Main Methods:**
|
|
||||||
```swift
|
|
||||||
// Download chapter to VPS
|
|
||||||
func downloadChapter(mangaSlug:chapterNumber:chapterSlug:imageUrls:) async throws -> VPSDownloadResult
|
|
||||||
|
|
||||||
// Check if chapter exists on VPS
|
|
||||||
func getChapterManifest(mangaSlug:chapterNumber:) async throws -> VPSChapterManifest?
|
|
||||||
|
|
||||||
// List all downloaded chapters for a manga
|
|
||||||
func listDownloadedChapters(mangaSlug:) async throws -> [VPSChapterInfo]
|
|
||||||
|
|
||||||
// Get URL for specific page image
|
|
||||||
func getImageURL(mangaSlug:chapterNumber:pageIndex:) -> String
|
|
||||||
|
|
||||||
// Delete chapter from VPS
|
|
||||||
func deleteChapter(mangaSlug:chapterNumber:) async throws -> Bool
|
|
||||||
|
|
||||||
// Get storage statistics
|
|
||||||
func getStorageStats() async throws -> VPSStorageStats
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### 2. MangaDetailView.swift
|
|
||||||
**Location:** `/home/ren/ios/MangaReader/ios-app/Sources/Views/MangaDetailView.swift`
|
|
||||||
|
|
||||||
**Changes Made:**
|
|
||||||
|
|
||||||
#### View Level Updates:
|
|
||||||
- Added `@StateObject private var vpsClient = VPSAPIClient.shared`
|
|
||||||
- Added VPS download button to toolbar (icloud.and.arrow.down icon)
|
|
||||||
- Added alert for VPS bulk download options
|
|
||||||
- Updated chapter list to pass VPS download callback
|
|
||||||
|
|
||||||
#### ChapterRowView Updates:
|
|
||||||
- Added VPS download button/status indicator (icloud.fill when downloaded, icloud.and.arrow.up to download)
|
|
||||||
- Added VPS download progress display
|
|
||||||
- Added `checkVPSStatus()` async function to check if chapter is on VPS
|
|
||||||
- Shows cloud icon when chapter is available on VPS
|
|
||||||
- Shows VPS download progress with percentage
|
|
||||||
|
|
||||||
#### ViewModel Updates:
|
|
||||||
**New Published Properties:**
|
|
||||||
```swift
|
|
||||||
@Published var showingVPSDownloadAll = false
|
|
||||||
```
|
|
||||||
|
|
||||||
**New Methods:**
|
|
||||||
```swift
|
|
||||||
// Download single chapter to VPS
|
|
||||||
func downloadChapterToVPS(_ chapter: Chapter) async
|
|
||||||
|
|
||||||
// Download all chapters to VPS
|
|
||||||
func downloadAllChaptersToVPS() async
|
|
||||||
|
|
||||||
// Download last N chapters to VPS
|
|
||||||
func downloadLastChaptersToVPS(count: Int) async
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Scrapes image URLs from original source
|
|
||||||
- Sends download request to VPS
|
|
||||||
- Shows success/failure notifications
|
|
||||||
- Tracks download progress
|
|
||||||
- Handles errors gracefully
|
|
||||||
|
|
||||||
### 3. ReaderView.swift
|
|
||||||
**Location:** `/home/ren/ios/MangaReader/ios-app/Sources/Views/ReaderView.swift`
|
|
||||||
|
|
||||||
**Changes Made:**
|
|
||||||
|
|
||||||
#### View Level Updates:
|
|
||||||
- Added `@ObservedObject private var vpsClient = VPSAPIClient.shared`
|
|
||||||
|
|
||||||
#### PageView Updates:
|
|
||||||
- Added VPS image loading capability
|
|
||||||
- Checks if chapter is available on VPS on load
|
|
||||||
- Loads images from VPS when available (priority order: local → VPS → original URL)
|
|
||||||
- Falls back to original URL if VPS fails
|
|
||||||
- Added `useVPS` state variable
|
|
||||||
|
|
||||||
#### ViewModel Updates:
|
|
||||||
**New Published Properties:**
|
|
||||||
```swift
|
|
||||||
@Published var isVPSDownloaded = false
|
|
||||||
```
|
|
||||||
|
|
||||||
**New Dependencies:**
|
|
||||||
```swift
|
|
||||||
private let vpsClient = VPSAPIClient.shared
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated loadPages() Method:**
|
|
||||||
Now checks sources in this priority order:
|
|
||||||
1. VPS storage (if available)
|
|
||||||
2. Local device storage
|
|
||||||
3. Scrape from original website
|
|
||||||
|
|
||||||
**Footer Updates:**
|
|
||||||
- Shows "VPS" label with cloud icon when reading from VPS
|
|
||||||
- Shows "Local" label with checkmark when reading from local storage
|
|
||||||
|
|
||||||
## User Experience Flow
|
|
||||||
|
|
||||||
### Downloading to VPS:
|
|
||||||
|
|
||||||
1. **Single Chapter:**
|
|
||||||
- User taps cloud upload icon (icloud.and.arrow.up) next to chapter
|
|
||||||
- App scrapes image URLs
|
|
||||||
- Sends download request to VPS
|
|
||||||
- Shows progress indicator (VPS XX%)
|
|
||||||
- Shows success notification
|
|
||||||
- Cloud icon changes to filled (icloud.fill)
|
|
||||||
|
|
||||||
2. **Multiple Chapters:**
|
|
||||||
- User taps cloud download button in toolbar
|
|
||||||
- Chooses "Últimos 10 a VPS" or "Todos a VPS"
|
|
||||||
- Downloads sequentially with progress tracking
|
|
||||||
- Shows summary notification
|
|
||||||
|
|
||||||
### Reading from VPS:
|
|
||||||
|
|
||||||
1. User opens chapter
|
|
||||||
2. App checks if chapter is on VPS
|
|
||||||
3. If available, loads images from VPS URLs
|
|
||||||
4. Shows "VPS" indicator in footer
|
|
||||||
5. Falls back to local or original if VPS fails
|
|
||||||
|
|
||||||
### Visual Indicators:
|
|
||||||
|
|
||||||
**Chapter List:**
|
|
||||||
- ✓ Green checkmark: Downloaded locally
|
|
||||||
- ☁️ Blue cloud: Available on VPS
|
|
||||||
- ☁️↑ Cloud upload: Download to VPS button
|
|
||||||
- Progress bar: Shows VPS download progress
|
|
||||||
|
|
||||||
**Reader View:**
|
|
||||||
- "VPS" label with cloud icon: Reading from VPS
|
|
||||||
- "Local" label with checkmark: Reading from local cache
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All VPS operations include comprehensive error handling:
|
|
||||||
- Network errors caught and displayed
|
|
||||||
- Timeout handling (5 min request, 10 min resource)
|
|
||||||
- Graceful fallback to alternative sources
|
|
||||||
- User-friendly error messages in Spanish
|
|
||||||
- Silent failures for non-critical operations
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
**Default VPS URL:** `http://localhost:3000/api`
|
|
||||||
|
|
||||||
To change the VPS URL, modify the `baseURL` in VPSAPIClient initialization or add a configuration method.
|
|
||||||
|
|
||||||
## API Endpoints Used
|
|
||||||
|
|
||||||
From the backend server (`/home/ren/ios/MangaReader/backend/server.js`):
|
|
||||||
|
|
||||||
- `POST /api/download` - Request chapter download
|
|
||||||
- `GET /api/storage/chapter/:mangaSlug/:chapterNumber` - Check chapter status
|
|
||||||
- `GET /api/storage/chapters/:mangaSlug` - List downloaded chapters
|
|
||||||
- `GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex` - Get image
|
|
||||||
- `DELETE /api/storage/chapter/:mangaSlug/:chapterNumber` - Delete chapter
|
|
||||||
- `GET /api/storage/stats` - Get statistics
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
To complete the integration:
|
|
||||||
|
|
||||||
1. **Update VPS URL:** Change `baseURL` in VPSAPIClient to your actual VPS address
|
|
||||||
2. **Test:** Run the app and test download/read functionality
|
|
||||||
3. **Optional Enhancements:**
|
|
||||||
- Add settings screen to configure VPS URL
|
|
||||||
- Add authentication token support
|
|
||||||
- Implement retry logic for failed downloads
|
|
||||||
- Add download queue management
|
|
||||||
- Show VPS storage usage in UI
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
✅ Saves local device storage
|
|
||||||
✅ Faster downloads from VPS vs original source
|
|
||||||
✅ Access chapters from multiple devices
|
|
||||||
✅ Offline reading capability (when cached from VPS)
|
|
||||||
✅ Centralized manga library management
|
|
||||||
✅ Progressive enhancement (works without VPS)
|
|
||||||
|
|
||||||
## Technical Highlights
|
|
||||||
|
|
||||||
- Async/await for all network operations
|
|
||||||
- Combine for reactive state management
|
|
||||||
- Priority-based image loading (local → VPS → original)
|
|
||||||
- Progress tracking for better UX
|
|
||||||
- Comprehensive error handling
|
|
||||||
- Clean separation of concerns
|
|
||||||
- Follows existing code patterns and conventions
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# Quick Start Guide: Integration Tests
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies (if not already installed)
|
|
||||||
cd /home/ren/ios/MangaReader/backend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Method 1: Using npm scripts (Recommended)
|
|
||||||
|
|
||||||
### Run individual tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1: Start server
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# Terminal 2: Run VPS flow test
|
|
||||||
npm run test:vps
|
|
||||||
|
|
||||||
# Terminal 3: Run concurrent downloads test
|
|
||||||
npm run test:concurrent
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean up test data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test:clean
|
|
||||||
```
|
|
||||||
|
|
||||||
## Method 2: Using the test runner script
|
|
||||||
|
|
||||||
### Basic commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start server in background
|
|
||||||
./run-tests.sh start
|
|
||||||
|
|
||||||
# Check server status
|
|
||||||
./run-tests.sh status
|
|
||||||
|
|
||||||
# View server logs
|
|
||||||
./run-tests.sh logs
|
|
||||||
|
|
||||||
# Run VPS flow test
|
|
||||||
./run-tests.sh vps-flow
|
|
||||||
|
|
||||||
# Run concurrent downloads test
|
|
||||||
./run-tests.sh concurrent
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
./run-tests.sh all
|
|
||||||
|
|
||||||
# Clean up test data
|
|
||||||
./run-tests.sh cleanup
|
|
||||||
|
|
||||||
# Stop server
|
|
||||||
./run-tests.sh stop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Complete workflow (one command):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./run-tests.sh start && ./run-tests.sh all && ./run-tests.sh cleanup && ./run-tests.sh stop
|
|
||||||
```
|
|
||||||
|
|
||||||
## Method 3: Manual execution
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1: Start server
|
|
||||||
node server.js
|
|
||||||
|
|
||||||
# Terminal 2: Run VPS flow test
|
|
||||||
node test-vps-flow.js
|
|
||||||
|
|
||||||
# Terminal 3: Run concurrent downloads test
|
|
||||||
node test-concurrent-downloads.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Gets Tested
|
|
||||||
|
|
||||||
### VPS Flow Test (`test-vps-flow.js`)
|
|
||||||
- ✓ Server health check
|
|
||||||
- ✓ Chapter image scraping
|
|
||||||
- ✓ Download to VPS storage
|
|
||||||
- ✓ File verification
|
|
||||||
- ✓ Storage statistics
|
|
||||||
- ✓ Chapter deletion
|
|
||||||
- ✓ Complete cleanup
|
|
||||||
|
|
||||||
### Concurrent Downloads Test (`test-concurrent-downloads.js`)
|
|
||||||
- ✓ 5 chapters downloaded concurrently
|
|
||||||
- ✓ No race conditions
|
|
||||||
- ✓ No file corruption
|
|
||||||
- ✓ Independent manifests
|
|
||||||
- ✓ Concurrent deletion
|
|
||||||
- ✓ Thread-safe operations
|
|
||||||
|
|
||||||
## Expected Output
|
|
||||||
|
|
||||||
### Success:
|
|
||||||
```
|
|
||||||
✓ ALL TESTS PASSED
|
|
||||||
✓ No race conditions detected
|
|
||||||
✓ No file corruption found
|
|
||||||
✓ Storage handles concurrent access properly
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Results:
|
|
||||||
```
|
|
||||||
Total Tests: 11
|
|
||||||
Passed: 11
|
|
||||||
Failed: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Port already in use:
|
|
||||||
```bash
|
|
||||||
lsof -ti:3000 | xargs kill -9
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server not responding:
|
|
||||||
```bash
|
|
||||||
# Check if server is running
|
|
||||||
./run-tests.sh status
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
./run-tests.sh logs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean everything and start fresh:
|
|
||||||
```bash
|
|
||||||
# Stop server
|
|
||||||
./run-tests.sh stop
|
|
||||||
|
|
||||||
# Clean test data
|
|
||||||
./run-tests.sh cleanup
|
|
||||||
|
|
||||||
# Remove logs
|
|
||||||
rm -rf logs/
|
|
||||||
|
|
||||||
# Start fresh
|
|
||||||
./run-tests.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Duration
|
|
||||||
|
|
||||||
- **VPS Flow Test**: ~2-3 minutes
|
|
||||||
- **Concurrent Test**: ~3-5 minutes
|
|
||||||
|
|
||||||
Total time: ~5-8 minutes for both tests
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `test-vps-flow.js` | End-to-end VPS flow tests |
|
|
||||||
| `test-concurrent-downloads.js` | Concurrent download tests |
|
|
||||||
| `run-tests.sh` | Test automation script |
|
|
||||||
| `TEST_README.md` | Detailed documentation |
|
|
||||||
| `TEST_QUICK_START.md` | This quick reference |
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show test runner help
|
|
||||||
./run-tests.sh help
|
|
||||||
|
|
||||||
# View detailed documentation
|
|
||||||
cat TEST_README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
After tests pass:
|
|
||||||
1. ✓ Verify storage directory structure
|
|
||||||
2. ✓ Check image quality in downloaded chapters
|
|
||||||
3. ✓ Monitor storage stats in production
|
|
||||||
4. ✓ Set up CI/CD integration (see TEST_README.md)
|
|
||||||
|
|
||||||
## Storage Location
|
|
||||||
|
|
||||||
Downloaded test chapters are stored in:
|
|
||||||
```
|
|
||||||
/home/ren/ios/MangaReader/storage/manga/one-piece_1695365223767/
|
|
||||||
├── chapter_787/
|
|
||||||
├── chapter_788/
|
|
||||||
├── chapter_789/
|
|
||||||
├── chapter_790/
|
|
||||||
└── chapter_791/
|
|
||||||
```
|
|
||||||
|
|
||||||
Each chapter contains:
|
|
||||||
- `page_001.jpg`, `page_002.jpg`, etc. - Downloaded images
|
|
||||||
- `manifest.json` - Chapter metadata and image list
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# Integration Tests for MangaReader VPS Backend
|
|
||||||
|
|
||||||
This directory contains comprehensive integration tests for the complete VPS flow: iOS app → VPS backend → Storage → Images served back.
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
### 1. `test-vps-flow.js`
|
|
||||||
Tests the complete end-to-end flow of downloading and serving manga chapters.
|
|
||||||
|
|
||||||
**Test Coverage:**
|
|
||||||
- Server health check
|
|
||||||
- Chapter image scraping from source
|
|
||||||
- Download to VPS storage
|
|
||||||
- Storage verification
|
|
||||||
- Image file validation
|
|
||||||
- Image path retrieval
|
|
||||||
- Chapter listing
|
|
||||||
- Storage statistics
|
|
||||||
- Chapter deletion
|
|
||||||
- Post-deletion verification
|
|
||||||
- Storage stats update verification
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
# Make sure the server is running first
|
|
||||||
node server.js &
|
|
||||||
|
|
||||||
# In another terminal, run the test
|
|
||||||
node test-vps-flow.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Output:**
|
|
||||||
- Color-coded test progress
|
|
||||||
- Detailed assertions with success/failure indicators
|
|
||||||
- Storage statistics
|
|
||||||
- Final summary with pass/fail counts
|
|
||||||
|
|
||||||
### 2. `test-concurrent-downloads.js`
|
|
||||||
Tests concurrent download operations to verify thread safety and data integrity.
|
|
||||||
|
|
||||||
**Test Coverage:**
|
|
||||||
- Pre-download cleanup
|
|
||||||
- Concurrent chapter downloads (5 chapters, max 3 concurrent)
|
|
||||||
- Post-download verification
|
|
||||||
- File integrity checks (no corruption, no missing files)
|
|
||||||
- Manifest independence verification
|
|
||||||
- Storage statistics accuracy
|
|
||||||
- Chapter listing functionality
|
|
||||||
- Concurrent deletion
|
|
||||||
- Complete cleanup verification
|
|
||||||
- Race condition detection
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
# Make sure the server is running first
|
|
||||||
node server.js &
|
|
||||||
|
|
||||||
# In another terminal, run the test
|
|
||||||
node test-concurrent-downloads.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Output:**
|
|
||||||
- Progress tracking for each operation
|
|
||||||
- Batch processing information
|
|
||||||
- Detailed integrity reports per chapter
|
|
||||||
- Summary of valid/missing/corrupted images
|
|
||||||
- Concurrent delete tracking
|
|
||||||
- Final summary with race condition analysis
|
|
||||||
|
|
||||||
## Test Configuration
|
|
||||||
|
|
||||||
Both tests use the following configuration:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
mangaSlug: 'one-piece_1695365223767',
|
|
||||||
chapters: [787, 788, 789, 790, 791], // For concurrent test
|
|
||||||
baseUrl: 'http://localhost:3000',
|
|
||||||
timeout: 120000-180000 // 2-3 minutes
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can modify these values in the test files if needed.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **Dependencies installed:**
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Server running on port 3000:**
|
|
||||||
```bash
|
|
||||||
node server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Storage directory structure:**
|
|
||||||
The tests will automatically create the required storage structure:
|
|
||||||
```
|
|
||||||
/storage
|
|
||||||
/manga
|
|
||||||
/one-piece_1695365223767
|
|
||||||
/chapter_789
|
|
||||||
page_001.jpg
|
|
||||||
page_002.jpg
|
|
||||||
...
|
|
||||||
manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running All Tests
|
|
||||||
|
|
||||||
Run both test suites:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1: Start server
|
|
||||||
cd /home/ren/ios/MangaReader/backend
|
|
||||||
node server.js
|
|
||||||
|
|
||||||
# Terminal 2: Run VPS flow test
|
|
||||||
node test-vps-flow.js
|
|
||||||
|
|
||||||
# Terminal 3: Run concurrent downloads test
|
|
||||||
node test-concurrent-downloads.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Success Indicators
|
|
||||||
- ✓ Green checkmarks for passing assertions
|
|
||||||
- 🎉 "ALL TESTS PASSED!" message
|
|
||||||
- Exit code 0
|
|
||||||
|
|
||||||
### Failure Indicators
|
|
||||||
- ✗ Red X marks for failing assertions
|
|
||||||
- ❌ "SOME TESTS FAILED" message
|
|
||||||
- Detailed error messages
|
|
||||||
- Exit code 1
|
|
||||||
|
|
||||||
## Color Codes
|
|
||||||
|
|
||||||
The tests use color-coded output for easy reading:
|
|
||||||
- **Green**: Success/passing assertions
|
|
||||||
- **Red**: Errors/failing assertions
|
|
||||||
- **Blue**: Information messages
|
|
||||||
- **Cyan**: Test titles
|
|
||||||
- **Yellow**: Warnings
|
|
||||||
- **Magenta**: Operation tracking (concurrent tests)
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
Tests automatically clean up after themselves:
|
|
||||||
- Delete test chapters from storage
|
|
||||||
- Remove temporary files
|
|
||||||
- Reset storage statistics
|
|
||||||
|
|
||||||
However, you can manually clean up:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Remove all test data
|
|
||||||
rm -rf /home/ren/ios/MangaReader/storage/manga/one-piece_1695365223767
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Server Not Responding
|
|
||||||
```
|
|
||||||
Error: Failed to fetch
|
|
||||||
```
|
|
||||||
**Solution:** Make sure the server is running on port 3000:
|
|
||||||
```bash
|
|
||||||
node server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chapter Already Exists
|
|
||||||
Tests will automatically clean up existing chapters. If you see warnings, that's normal behavior.
|
|
||||||
|
|
||||||
### Timeout Errors
|
|
||||||
If tests timeout, the scraper might be taking too long. You can:
|
|
||||||
1. Increase the timeout value in TEST_CONFIG
|
|
||||||
2. Check your internet connection
|
|
||||||
3. Verify the source website is accessible
|
|
||||||
|
|
||||||
### Port Already in Use
|
|
||||||
```
|
|
||||||
Error: listen EADDRINUSE: address already in use :::3000
|
|
||||||
```
|
|
||||||
**Solution:** Kill the existing process:
|
|
||||||
```bash
|
|
||||||
lsof -ti:3000 | xargs kill -9
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Coverage Summary
|
|
||||||
|
|
||||||
| Feature | VPS Flow Test | Concurrent Test |
|
|
||||||
|---------|---------------|-----------------|
|
|
||||||
| Server Health | ✓ | - |
|
|
||||||
| Image Scraping | ✓ | ✓ |
|
|
||||||
| Download to Storage | ✓ | ✓ (5 chapters) |
|
|
||||||
| File Verification | ✓ | ✓ |
|
|
||||||
| Manifest Validation | ✓ | ✓ |
|
|
||||||
| Storage Stats | ✓ | ✓ |
|
|
||||||
| Chapter Listing | ✓ | ✓ |
|
|
||||||
| Deletion | ✓ | ✓ (concurrent) |
|
|
||||||
| Race Conditions | - | ✓ |
|
|
||||||
| Corruption Detection | - | ✓ |
|
|
||||||
|
|
||||||
## Integration with CI/CD
|
|
||||||
|
|
||||||
These tests can be integrated into a CI/CD pipeline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Example GitHub Actions workflow
|
|
||||||
- name: Start Server
|
|
||||||
run: node server.js &
|
|
||||||
|
|
||||||
- name: Wait for Server
|
|
||||||
run: sleep 5
|
|
||||||
|
|
||||||
- name: Run VPS Flow Tests
|
|
||||||
run: node test-vps-flow.js
|
|
||||||
|
|
||||||
- name: Run Concurrent Tests
|
|
||||||
run: node test-concurrent-downloads.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
- **VPS Flow Test**: ~2-3 minutes (downloads 5 images from 1 chapter)
|
|
||||||
- **Concurrent Test**: ~3-5 minutes (downloads 5 images from 5 chapters with max 3 concurrent)
|
|
||||||
|
|
||||||
Times vary based on:
|
|
||||||
- Network speed to source website
|
|
||||||
- VPS performance
|
|
||||||
- Current load on source website
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
When adding new features:
|
|
||||||
1. Add corresponding tests in `test-vps-flow.js`
|
|
||||||
2. If feature involves concurrent operations, add tests in `test-concurrent-downloads.js`
|
|
||||||
3. Update this README with new test coverage
|
|
||||||
4. Ensure all tests pass before submitting
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Same as the main MangaReader project.
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
# Integration Tests: Creation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
I have created comprehensive integration tests for the complete VPS flow: iOS app → VPS backend → Storage → Images served back.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. `/home/ren/ios/MangaReader/backend/test-vps-flow.js`
|
|
||||||
**Purpose**: End-to-end integration test for the complete VPS download and serving flow
|
|
||||||
|
|
||||||
**Test Cases (11 tests)**:
|
|
||||||
- Server health check
|
|
||||||
- Get chapter images from scraper
|
|
||||||
- Download chapter to storage
|
|
||||||
- Verify chapter exists in storage
|
|
||||||
- Verify image files exist on disk
|
|
||||||
- Get image path from storage service
|
|
||||||
- List downloaded chapters
|
|
||||||
- Get storage statistics
|
|
||||||
- Delete chapter from storage
|
|
||||||
- Verify chapter was removed
|
|
||||||
- Verify storage stats updated after deletion
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Color-coded output for easy reading
|
|
||||||
- Detailed assertions with success/failure indicators
|
|
||||||
- Comprehensive error reporting
|
|
||||||
- Automatic cleanup
|
|
||||||
- Progress tracking
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
npm run test:vps
|
|
||||||
# or
|
|
||||||
node test-vps-flow.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `/home/ren/ios/MangaReader/backend/test-concurrent-downloads.js`
|
|
||||||
**Purpose**: Test concurrent download operations to verify thread safety and data integrity
|
|
||||||
|
|
||||||
**Test Cases (10 tests)**:
|
|
||||||
- Pre-download verification and cleanup
|
|
||||||
- Concurrent downloads (5 chapters, max 3 concurrent)
|
|
||||||
- Post-download verification
|
|
||||||
- Integrity check (no corruption, no missing files)
|
|
||||||
- Manifest independence verification
|
|
||||||
- Storage statistics accuracy
|
|
||||||
- Chapter listing functionality
|
|
||||||
- Concurrent deletion of all chapters
|
|
||||||
- Complete cleanup verification
|
|
||||||
- Race condition detection
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Progress tracker with operation IDs
|
|
||||||
- Batch processing (max 3 concurrent)
|
|
||||||
- Detailed integrity reports per chapter
|
|
||||||
- Corruption detection
|
|
||||||
- Missing file detection
|
|
||||||
- Concurrent operation tracking
|
|
||||||
- Race condition analysis
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
npm run test:concurrent
|
|
||||||
# or
|
|
||||||
node test-concurrent-downloads.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `/home/ren/ios/MangaReader/backend/run-tests.sh`
|
|
||||||
**Purpose**: Automation script for easy test execution and server management
|
|
||||||
|
|
||||||
**Commands**:
|
|
||||||
- `start` - Start server in background
|
|
||||||
- `stop` - Stop server
|
|
||||||
- `restart` - Restart server
|
|
||||||
- `logs` - Show server logs (tail -f)
|
|
||||||
- `status` - Check server status
|
|
||||||
- `vps-flow` - Run VPS flow test
|
|
||||||
- `concurrent` - Run concurrent downloads test
|
|
||||||
- `all` - Run all tests
|
|
||||||
- `cleanup` - Clean up test data
|
|
||||||
- `help` - Show help message
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Automatic server management
|
|
||||||
- PID tracking
|
|
||||||
- Log management
|
|
||||||
- Color-coded output
|
|
||||||
- Error handling
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
./run-tests.sh start && ./run-tests.sh all && ./run-tests.sh cleanup && ./run-tests.sh stop
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. `/home/ren/ios/MangaReader/backend/TEST_README.md`
|
|
||||||
**Purpose**: Comprehensive documentation for integration tests
|
|
||||||
|
|
||||||
**Contents**:
|
|
||||||
- Detailed test descriptions
|
|
||||||
- Configuration options
|
|
||||||
- Prerequisites
|
|
||||||
- Usage examples
|
|
||||||
- Troubleshooting guide
|
|
||||||
- Test coverage table
|
|
||||||
- CI/CD integration examples
|
|
||||||
- Performance notes
|
|
||||||
|
|
||||||
### 5. `/home/ren/ios/MangaReader/backend/TEST_QUICK_START.md`
|
|
||||||
**Purpose**: Quick reference guide for running tests
|
|
||||||
|
|
||||||
**Contents**:
|
|
||||||
- Quick start instructions
|
|
||||||
- Multiple execution methods
|
|
||||||
- What gets tested
|
|
||||||
- Expected output
|
|
||||||
- Troubleshooting
|
|
||||||
- Test duration estimates
|
|
||||||
- Storage location info
|
|
||||||
|
|
||||||
### 6. Updated `/home/ren/ios/MangaReader/backend/package.json`
|
|
||||||
**Added npm scripts**:
|
|
||||||
- `test` - Run default tests
|
|
||||||
- `test:vps` - Run VPS flow test
|
|
||||||
- `test:concurrent` - Run concurrent downloads test
|
|
||||||
- `test:all` - Run all tests
|
|
||||||
- `test:clean` - Clean up test data
|
|
||||||
|
|
||||||
## Test Coverage Summary
|
|
||||||
|
|
||||||
| Feature | VPS Flow Test | Concurrent Test | Total Tests |
|
|
||||||
|---------|---------------|-----------------|-------------|
|
|
||||||
| Server Health | ✓ | - | 1 |
|
|
||||||
| Image Scraping | ✓ | ✓ | 2 |
|
|
||||||
| Download to Storage | ✓ | ✓ | 2 |
|
|
||||||
| File Verification | ✓ | ✓ | 2 |
|
|
||||||
| Manifest Validation | ✓ | ✓ | 2 |
|
|
||||||
| Storage Stats | ✓ | ✓ | 2 |
|
|
||||||
| Chapter Listing | ✓ | ✓ | 2 |
|
|
||||||
| Deletion | ✓ | ✓ | 2 |
|
|
||||||
| Cleanup | ✓ | ✓ | 2 |
|
|
||||||
| Race Conditions | - | ✓ | 1 |
|
|
||||||
| Corruption Detection | - | ✓ | 1 |
|
|
||||||
| **TOTAL** | **11** | **10** | **21** |
|
|
||||||
|
|
||||||
## Key Features Implemented
|
|
||||||
|
|
||||||
### 1. Comprehensive Logging
|
|
||||||
- Color-coded output (green for success, red for errors, blue for info)
|
|
||||||
- Detailed progress tracking
|
|
||||||
- Error messages with stack traces
|
|
||||||
- Operation tracking with IDs (for concurrent tests)
|
|
||||||
|
|
||||||
### 2. Robust Assertions
|
|
||||||
- Custom assertion functions with detailed messages
|
|
||||||
- Immediate feedback on failures
|
|
||||||
- Clear error context
|
|
||||||
|
|
||||||
### 3. Automatic Cleanup
|
|
||||||
- Tests clean up after themselves
|
|
||||||
- No residual test data
|
|
||||||
- Storage state restored
|
|
||||||
|
|
||||||
### 4. Progress Tracking
|
|
||||||
- Real-time operation status
|
|
||||||
- Duration tracking
|
|
||||||
- Batch processing information
|
|
||||||
- Summary statistics
|
|
||||||
|
|
||||||
### 5. Integrity Verification
|
|
||||||
- File existence checks
|
|
||||||
- Size validation
|
|
||||||
- Manifest validation
|
|
||||||
- Corruption detection
|
|
||||||
- Race condition detection
|
|
||||||
|
|
||||||
## Test Configuration
|
|
||||||
|
|
||||||
Both tests use these defaults (configurable in files):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
mangaSlug: 'one-piece_1695365223767',
|
|
||||||
chapters: [787, 788, 789, 790, 791], // Concurrent test only
|
|
||||||
baseUrl: 'http://localhost:3000',
|
|
||||||
timeout: 120000-180000 // 2-3 minutes
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Tests
|
|
||||||
|
|
||||||
### Quick Start:
|
|
||||||
```bash
|
|
||||||
cd /home/ren/ios/MangaReader/backend
|
|
||||||
|
|
||||||
# Method 1: Using npm scripts
|
|
||||||
npm start # Terminal 1: Start server
|
|
||||||
npm run test:vps # Terminal 2: Run VPS flow test
|
|
||||||
npm run test:concurrent # Terminal 3: Run concurrent test
|
|
||||||
|
|
||||||
# Method 2: Using automation script
|
|
||||||
./run-tests.sh start
|
|
||||||
./run-tests.sh all
|
|
||||||
./run-tests.sh cleanup
|
|
||||||
./run-tests.sh stop
|
|
||||||
|
|
||||||
# Method 3: All in one
|
|
||||||
./run-tests.sh start && ./run-tests.sh all && ./run-tests.sh cleanup && ./run-tests.sh stop
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected Results
|
|
||||||
|
|
||||||
### Success Output:
|
|
||||||
```
|
|
||||||
============================================================
|
|
||||||
TEST RESULTS SUMMARY
|
|
||||||
============================================================
|
|
||||||
|
|
||||||
Total Tests: 11
|
|
||||||
Passed: 11
|
|
||||||
Failed: 0
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
🎉 ALL TESTS PASSED!
|
|
||||||
======================================================================
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Files Created During Execution:
|
|
||||||
```
|
|
||||||
/home/ren/ios/MangaReader/storage/manga/one-piece_1695365223767/
|
|
||||||
├── chapter_789/
|
|
||||||
│ ├── page_001.jpg
|
|
||||||
│ ├── page_002.jpg
|
|
||||||
│ ├── ...
|
|
||||||
│ └── manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Assertions Included
|
|
||||||
|
|
||||||
Each test includes multiple assertions:
|
|
||||||
- **Equality checks** - Verify expected values match actual values
|
|
||||||
- **Truthy checks** - Verify conditions are met
|
|
||||||
- **File system checks** - Verify files and directories exist
|
|
||||||
- **Data validation** - Verify data integrity
|
|
||||||
- **Operation checks** - Verify operations complete successfully
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- Try-catch blocks around all operations
|
|
||||||
- Detailed error messages
|
|
||||||
- Stack traces for debugging
|
|
||||||
- Graceful failure handling
|
|
||||||
- Cleanup even on failure
|
|
||||||
|
|
||||||
## Performance Characteristics
|
|
||||||
|
|
||||||
- **VPS Flow Test**: Downloads 5 images (1 chapter) in ~2-3 minutes
|
|
||||||
- **Concurrent Test**: Downloads 25 images (5 chapters × 5 images) in ~3-5 minutes
|
|
||||||
- **Memory Usage**: Efficient concurrent processing with max 3 parallel downloads
|
|
||||||
- **Disk I/O**: Optimized for SSD/NVMe storage
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Run the tests**:
|
|
||||||
```bash
|
|
||||||
cd /home/ren/ios/MangaReader/backend
|
|
||||||
./run-tests.sh all
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify results**: Check for green checkmarks and "ALL TESTS PASSED" message
|
|
||||||
|
|
||||||
3. **Review logs**: Check `logs/server.log` for any issues
|
|
||||||
|
|
||||||
4. **Inspect storage**: Verify downloaded images in storage directory
|
|
||||||
|
|
||||||
5. **Integrate into CI/CD**: Add to your CI/CD pipeline (see TEST_README.md)
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Adding New Tests:
|
|
||||||
1. Create test function in appropriate test file
|
|
||||||
2. Add assertions using provided helper functions
|
|
||||||
3. Record test results
|
|
||||||
4. Update documentation
|
|
||||||
|
|
||||||
### Modifying Configuration:
|
|
||||||
- Edit `TEST_CONFIG` object in test files
|
|
||||||
- Update documentation if defaults change
|
|
||||||
|
|
||||||
### Extending Coverage:
|
|
||||||
- Add new test cases to existing suites
|
|
||||||
- Create new test files for new features
|
|
||||||
- Update TEST_README.md with coverage table
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
- Check TEST_README.md for detailed documentation
|
|
||||||
- Check TEST_QUICK_START.md for quick reference
|
|
||||||
- Review test output for specific error messages
|
|
||||||
- Check logs/server.log for server-side issues
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ Created 2 comprehensive test files with 21 total tests
|
|
||||||
✅ Created automation script for easy test execution
|
|
||||||
✅ Created detailed documentation (3 markdown files)
|
|
||||||
✅ Added npm scripts to package.json
|
|
||||||
✅ Implemented color-coded output and progress tracking
|
|
||||||
✅ Added comprehensive error handling and cleanup
|
|
||||||
✅ Verified thread safety and race condition detection
|
|
||||||
✅ Implemented integrity checks for file corruption
|
|
||||||
✅ Ready for CI/CD integration
|
|
||||||
|
|
||||||
All tests are production-ready and can be run immediately!
|
|
||||||
992
docs/API.md
992
docs/API.md
@@ -1,992 +0,0 @@
|
|||||||
# API Documentation - MangaReader
|
|
||||||
|
|
||||||
Este documento proporciona la documentación completa de la API de MangaReader, incluyendo modelos de datos, servicios, ViewModels y sus responsabilidades.
|
|
||||||
|
|
||||||
## Tabla de Contenidos
|
|
||||||
|
|
||||||
- [Modelos de Datos](#modelos-de-datos)
|
|
||||||
- [Servicios](#servicios)
|
|
||||||
- [ViewModels](#viewmodels)
|
|
||||||
- [Views](#views)
|
|
||||||
- [Errores y Manejo de Excepciones](#errores-y-manejo-de-excepciones)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modelos de Datos
|
|
||||||
|
|
||||||
Los modelos de datos se encuentran en `ios-app/Sources/Models/Manga.swift`. Son estructuras inmutables que conforman a protocolos Swift estándar para serialización e identificación.
|
|
||||||
|
|
||||||
### Manga
|
|
||||||
|
|
||||||
Representa la información completa de un manga.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct Manga: Codable, Identifiable, Hashable {
|
|
||||||
let id: String { slug }
|
|
||||||
let slug: String
|
|
||||||
let title: String
|
|
||||||
let description: String
|
|
||||||
let genres: [String]
|
|
||||||
let status: String
|
|
||||||
let url: String
|
|
||||||
let coverImage: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case slug, title, description, genres, status, url, coverImage
|
|
||||||
}
|
|
||||||
|
|
||||||
var displayStatus: String { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
|
||||||
|-----------|------|-------------|---------|
|
|
||||||
| `id` | `String` | Identificador único (computed, igual a `slug`) | `"one-piece_1695365223767"` |
|
|
||||||
| `slug` | `String` | Slug único del manga usado en URLs | `"one-piece_1695365223767"` |
|
|
||||||
| `title` | `String` | Título del manga | `"One Piece"` |
|
|
||||||
| `description` | `String` | Descripción o sinopsis | `"La historia de piratas..."` |
|
|
||||||
| `genres` | `[String]` | Array de géneros literarios | `["Acción", "Aventura"]` |
|
|
||||||
| `status` | `String` | Estado de publicación (crudo) | `"PUBLICANDOSE"` |
|
|
||||||
| `url` | `String` | URL completa del manga | `"https://manhwaweb.com/manga/..."` |
|
|
||||||
| `coverImage` | `String?` | URL de imagen de portada (opcional) | `"https://..."` |
|
|
||||||
|
|
||||||
#### Métodos Computados
|
|
||||||
|
|
||||||
**`displayStatus: String`**
|
|
||||||
- Retorna el estado traducido y formateado para mostrar en UI
|
|
||||||
- Mapeos:
|
|
||||||
- `"PUBLICANDOSE"` → `"En publicación"`
|
|
||||||
- `"FINALIZADO"` → `"Finalizado"`
|
|
||||||
- `"EN_PAUSA"`, `"EN_ESPERA"` → `"En pausa"`
|
|
||||||
- Otro → retorna valor original
|
|
||||||
|
|
||||||
#### Ejemplo de Uso
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let manga = Manga(
|
|
||||||
slug: "one-piece_1695365223767",
|
|
||||||
title: "One Piece",
|
|
||||||
description: "La historia de Monkey D. Luffy...",
|
|
||||||
genres: ["Acción", "Aventura", "Comedia"],
|
|
||||||
status: "PUBLICANDOSE",
|
|
||||||
url: "https://manhwaweb.com/manga/one-piece_1695365223767",
|
|
||||||
coverImage: "https://example.com/cover.jpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(manga.displayStatus) // "En publicación"
|
|
||||||
print(manga.id) // "one-piece_1695365223767"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Chapter
|
|
||||||
|
|
||||||
Representa un capítulo individual de un manga.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct Chapter: Codable, Identifiable, Hashable {
|
|
||||||
let id: Int { number }
|
|
||||||
let number: Int
|
|
||||||
let title: String
|
|
||||||
let url: String
|
|
||||||
let slug: String
|
|
||||||
var isRead: Bool = false
|
|
||||||
var isDownloaded: Bool = false
|
|
||||||
var lastReadPage: Int = 0
|
|
||||||
|
|
||||||
var displayNumber: String { ... }
|
|
||||||
var progress: Double { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
|
||||||
|-----------|------|-------------|---------|
|
|
||||||
| `id` | `Int` | Identificador único (computed, igual a `number`) | `1` |
|
|
||||||
| `number` | `Int` | Número del capítulo | `1` |
|
|
||||||
| `title` | `String` | Título del capítulo | `"El inicio de la aventura"` |
|
|
||||||
| `url` | `String` | URL completa del capítulo | `"https://manhwaweb.com/leer/..."` |
|
|
||||||
| `slug` | `String` | Slug para identificar el capítulo | `"one-piece/capitulo-1"` |
|
|
||||||
| `isRead` | `Bool` | Estado de lectura (mutable) | `false` |
|
|
||||||
| `isDownloaded` | `Bool` | Estado de descarga (mutable) | `false` |
|
|
||||||
| `lastReadPage` | `Int` | Última página leída (mutable) | `5` |
|
|
||||||
|
|
||||||
#### Métodos Computados
|
|
||||||
|
|
||||||
**`displayNumber: String`**
|
|
||||||
- Retorna string formateado para mostrar
|
|
||||||
- Formato: `"Capítulo {number}"`
|
|
||||||
|
|
||||||
**`progress: Double`**
|
|
||||||
- Retorna progreso como `Double` para ProgressViews
|
|
||||||
- Valor: `Double(lastReadPage)`
|
|
||||||
|
|
||||||
#### Ejemplo de Uso
|
|
||||||
|
|
||||||
```swift
|
|
||||||
var chapter = Chapter(
|
|
||||||
number: 1,
|
|
||||||
title: "El inicio",
|
|
||||||
url: "https://manhwaweb.com/leer/one-piece/1",
|
|
||||||
slug: "one-piece/1"
|
|
||||||
)
|
|
||||||
|
|
||||||
chapter.isRead = true
|
|
||||||
chapter.lastReadPage = 15
|
|
||||||
|
|
||||||
print(chapter.displayNumber) // "Capítulo 1"
|
|
||||||
print(chapter.progress) // 15.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MangaPage
|
|
||||||
|
|
||||||
Representa una página individual (imagen) de un capítulo.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct MangaPage: Codable, Identifiable, Hashable {
|
|
||||||
let id: String { url }
|
|
||||||
let url: String
|
|
||||||
let index: Int
|
|
||||||
var isCached: Bool = false
|
|
||||||
|
|
||||||
var thumbnailURL: String { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
|
||||||
|-----------|------|-------------|---------|
|
|
||||||
| `id` | `String` | Identificador único (computed, igual a `url`) | `"https://..."` |
|
|
||||||
| `url` | `String` | URL completa de la imagen | `"https://example.com/page1.jpg"` |
|
|
||||||
| `index` | `Int` | Índice de la página en el capítulo | `0` |
|
|
||||||
| `isCached` | `Bool` | Estado de cache local (mutable) | `false` |
|
|
||||||
|
|
||||||
#### Métodos Computados
|
|
||||||
|
|
||||||
**`thumbnailURL: String`**
|
|
||||||
- Actualmente retorna la misma URL
|
|
||||||
- Futuro: implementar versión thumbnail optimizada
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ReadingProgress
|
|
||||||
|
|
||||||
Almacena el progreso de lectura de un usuario.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct ReadingProgress: Codable {
|
|
||||||
let mangaSlug: String
|
|
||||||
let chapterNumber: Int
|
|
||||||
let pageNumber: Int
|
|
||||||
let timestamp: Date
|
|
||||||
|
|
||||||
var isCompleted: Bool { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `mangaSlug` | `String` | Slug del manga |
|
|
||||||
| `chapterNumber` | `Int` | Número del capítulo |
|
|
||||||
| `pageNumber` | `Int` | Página actual |
|
|
||||||
| `timestamp` | `Date` | Fecha/hora de lectura |
|
|
||||||
|
|
||||||
#### Métodos Computados
|
|
||||||
|
|
||||||
**`isCompleted: Bool`**
|
|
||||||
- Retorna `true` si el usuario leyó más de 5 páginas
|
|
||||||
- Lógica: `return pageNumber > 5`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DownloadedChapter
|
|
||||||
|
|
||||||
Representa un capítulo descargado localmente.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct DownloadedChapter: Codable, Identifiable {
|
|
||||||
let id: String { "\(mangaSlug)-chapter\(chapterNumber)" }
|
|
||||||
let mangaSlug: String
|
|
||||||
let mangaTitle: String
|
|
||||||
let chapterNumber: Int
|
|
||||||
let pages: [MangaPage]
|
|
||||||
let downloadedAt: Date
|
|
||||||
var totalSize: Int64 = 0
|
|
||||||
|
|
||||||
var displayTitle: String { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | `String` | ID compuesto único |
|
|
||||||
| `mangaSlug` | `String` | Slug del manga |
|
|
||||||
| `mangaTitle` | `String` | Título del manga |
|
|
||||||
| `chapterNumber` | `Int` | Número del capítulo |
|
|
||||||
| `pages` | `[MangaPage]` | Array de páginas |
|
|
||||||
| `downloadedAt` | `Date` | Fecha de descarga |
|
|
||||||
| `totalSize` | `Int64` | Tamaño total en bytes |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Servicios
|
|
||||||
|
|
||||||
Los servicios encapsulan la lógica de negocio y se encuentran en `ios-app/Sources/Services/`.
|
|
||||||
|
|
||||||
### ManhwaWebScraper
|
|
||||||
|
|
||||||
Servicio responsable del scraping de contenido web desde manhwaweb.com.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class ManhwaWebScraper: NSObject, ObservableObject
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Acceso | Descripción |
|
|
||||||
|-----------|------|--------|-------------|
|
|
||||||
| `shared` | `ManhwaWebScraper` | `static` | Instancia singleton compartida |
|
|
||||||
| `webView` | `WKWebView?` | `private` | WebView para ejecutar JavaScript |
|
|
||||||
|
|
||||||
#### Métodos Públicos
|
|
||||||
|
|
||||||
##### `scrapeMangaInfo(mangaSlug:)`
|
|
||||||
|
|
||||||
Obtiene la información completa de un manga.
|
|
||||||
|
|
||||||
**Firma:**
|
|
||||||
```swift
|
|
||||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parámetros:**
|
|
||||||
- `mangaSlug`: `String` - Slug único del manga
|
|
||||||
|
|
||||||
**Retorna:**
|
|
||||||
- `Manga` - Objeto con información completa del manga
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
- `ScrapingError.webViewNotInitialized`
|
|
||||||
- `ScrapingError.pageLoadFailed`
|
|
||||||
- `ScrapingError.noContentFound`
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let manga = try await ManhwaWebScraper.shared.scrapeMangaInfo(
|
|
||||||
mangaSlug: "one-piece_1695365223767"
|
|
||||||
)
|
|
||||||
print("Manga: \(manga.title)")
|
|
||||||
} catch {
|
|
||||||
print("Error: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Proceso Interno:**
|
|
||||||
1. Construye URL: `https://manhwaweb.com/manga/{slug}`
|
|
||||||
2. Carga URL en WKWebView
|
|
||||||
3. Espera 3 segundos a que JavaScript renderice
|
|
||||||
4. Ejecuta JavaScript para extraer:
|
|
||||||
- Título (de `<h1>` o `<title>`)
|
|
||||||
- Descripción (de `<p>` con >100 caracteres)
|
|
||||||
- Géneros (de links `/genero/*`)
|
|
||||||
- Estado (via regex)
|
|
||||||
- Cover image (de `.cover img`)
|
|
||||||
5. Parsea resultado a `Manga`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### `scrapeChapters(mangaSlug:)`
|
|
||||||
|
|
||||||
Obtiene la lista de capítulos de un manga.
|
|
||||||
|
|
||||||
**Firma:**
|
|
||||||
```swift
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parámetros:**
|
|
||||||
- `mangaSlug`: `String` - Slug del manga
|
|
||||||
|
|
||||||
**Retorna:**
|
|
||||||
- `[Chapter]` - Array de capítulos ordenados descendente
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
- `ScrapingError.webViewNotInitialized`
|
|
||||||
- `ScrapingError.pageLoadFailed`
|
|
||||||
- `ScrapingError.parsingError`
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let chapters = try await ManhwaWebScraper.shared.scrapeChapters(
|
|
||||||
mangaSlug: "one-piece_1695365223767"
|
|
||||||
)
|
|
||||||
print("Found \(chapters.count) chapters")
|
|
||||||
} catch {
|
|
||||||
print("Error: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Proceso Interno:**
|
|
||||||
1. Carga página del manga
|
|
||||||
2. Ejecuta JavaScript que:
|
|
||||||
- Busca todos los links `/leer/*`
|
|
||||||
- Extrae número de capítulo via regex
|
|
||||||
- Filtra duplicados
|
|
||||||
- Ordena descendente
|
|
||||||
3. Parsea a array de `Chapter`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### `scrapeChapterImages(chapterSlug:)`
|
|
||||||
|
|
||||||
Obtiene las URLs de las imágenes de un capítulo.
|
|
||||||
|
|
||||||
**Firma:**
|
|
||||||
```swift
|
|
||||||
func scrapeChapterImages(chapterSlug: String) async throws -> [String]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parámetros:**
|
|
||||||
- `chapterSlug`: `String` - Slug del capítulo
|
|
||||||
|
|
||||||
**Retorna:**
|
|
||||||
- `[String]` - Array de URLs de imágenes en orden
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
- `ScrapingError.webViewNotInitialized`
|
|
||||||
- `ScrapingError.pageLoadFailed`
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let images = try await ManhwaWebScraper.shared.scrapeChapterImages(
|
|
||||||
chapterSlug: "one-piece/1"
|
|
||||||
)
|
|
||||||
print("Found \(images.count) pages")
|
|
||||||
for (index, imageUrl) in images.enumerated() {
|
|
||||||
print("Page \(index + 1): \(imageUrl)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Error: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Proceso Interno:**
|
|
||||||
1. Carga URL del capítulo
|
|
||||||
2. Espera 5 segundos (para imágenes)
|
|
||||||
3. Ejecuta JavaScript que:
|
|
||||||
- Selecciona todas las etiquetas `<img>`
|
|
||||||
- Filtra elementos de UI (avatars, icons, logos)
|
|
||||||
- Elimina duplicados
|
|
||||||
4. Retorna array de URLs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Métodos Privados
|
|
||||||
|
|
||||||
##### `setupWebView()`
|
|
||||||
|
|
||||||
Configura el WKWebView con preferencias optimizadas.
|
|
||||||
|
|
||||||
##### `loadURLAndWait(_:waitForImages:)`
|
|
||||||
|
|
||||||
Carga una URL y espera a que JavaScript termine de renderizar.
|
|
||||||
|
|
||||||
**Parámetros:**
|
|
||||||
- `url`: `URL` - URL a cargar
|
|
||||||
- `waitForImages`: `Bool` - Si `true`, espera 5 segundos; si `false`, 3 segundos
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### StorageService
|
|
||||||
|
|
||||||
Servicio responsable del almacenamiento local de datos.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class StorageService
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Acceso | Descripción |
|
|
||||||
|-----------|------|--------|-------------|
|
|
||||||
| `shared` | `StorageService` | `static` | Instancia singleton compartida |
|
|
||||||
| `documentsDirectory` | `URL` | `private` | Directorio de documentos |
|
|
||||||
| `chaptersDirectory` | `URL` | `private` | Directorio de capítulos |
|
|
||||||
|
|
||||||
#### Gestión de Favoritos
|
|
||||||
|
|
||||||
##### `getFavorites()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getFavorites() -> [String]
|
|
||||||
```
|
|
||||||
Retorna array de slugs de mangas favoritos.
|
|
||||||
|
|
||||||
##### `saveFavorite(mangaSlug:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func saveFavorite(mangaSlug: String)
|
|
||||||
```
|
|
||||||
Guarda un manga como favorito (no duplica si ya existe).
|
|
||||||
|
|
||||||
##### `removeFavorite(mangaSlug:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func removeFavorite(mangaSlug: String)
|
|
||||||
```
|
|
||||||
Elimina un manga de favoritos.
|
|
||||||
|
|
||||||
##### `isFavorite(mangaSlug:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func isFavorite(mangaSlug: String) -> Bool
|
|
||||||
```
|
|
||||||
Verifica si un manga es favorito.
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
// Guardar favorito
|
|
||||||
storage.saveFavorite(mangaSlug: "one-piece_1695365223767")
|
|
||||||
|
|
||||||
// Verificar
|
|
||||||
if storage.isFavorite(mangaSlug: "one-piece_1695365223767") {
|
|
||||||
print("Es favorito")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listar todos
|
|
||||||
let favorites = storage.getFavorites()
|
|
||||||
print("Tienes \(favorites.count) favoritos")
|
|
||||||
|
|
||||||
// Eliminar
|
|
||||||
storage.removeFavorite(mangaSlug: "one-piece_1695365223767")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Gestión de Progreso de Lectura
|
|
||||||
|
|
||||||
##### `saveReadingProgress(_:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func saveReadingProgress(_ progress: ReadingProgress)
|
|
||||||
```
|
|
||||||
Guarda o actualiza el progreso de lectura.
|
|
||||||
|
|
||||||
##### `getReadingProgress(mangaSlug:chapterNumber:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress?
|
|
||||||
```
|
|
||||||
Retorna el progreso de un capítulo específico.
|
|
||||||
|
|
||||||
##### `getAllReadingProgress()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getAllReadingProgress() -> [ReadingProgress]
|
|
||||||
```
|
|
||||||
Retorna todo el progreso guardado.
|
|
||||||
|
|
||||||
##### `getLastReadChapter(mangaSlug:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getLastReadChapter(mangaSlug: String) -> ReadingProgress?
|
|
||||||
```
|
|
||||||
Retorna el capítulo más reciente leído de un manga.
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
// Guardar progreso
|
|
||||||
let progress = ReadingProgress(
|
|
||||||
mangaSlug: "one-piece_1695365223767",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageNumber: 15,
|
|
||||||
timestamp: Date()
|
|
||||||
)
|
|
||||||
storage.saveReadingProgress(progress)
|
|
||||||
|
|
||||||
// Recuperar progreso
|
|
||||||
if let savedProgress = storage.getReadingProgress(
|
|
||||||
mangaSlug: "one-piece_1695365223767",
|
|
||||||
chapterNumber: 1
|
|
||||||
) {
|
|
||||||
print("Última página: \(savedProgress.pageNumber)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Último capítulo leído
|
|
||||||
if let lastChapter = storage.getLastReadChapter(mangaSlug: "one-piece_1695365223767") {
|
|
||||||
print("Último capítulo: \(lastChapter.chapterNumber)")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Gestión de Capítulos Descargados
|
|
||||||
|
|
||||||
##### `saveDownloadedChapter(_:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func saveDownloadedChapter(_ chapter: DownloadedChapter)
|
|
||||||
```
|
|
||||||
Guarda metadata de un capítulo descargado.
|
|
||||||
|
|
||||||
##### `getDownloadedChapters()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getDownloadedChapters() -> [DownloadedChapter]
|
|
||||||
```
|
|
||||||
Retorna todos los capítulos descargados.
|
|
||||||
|
|
||||||
##### `isChapterDownloaded(mangaSlug:chapterNumber:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool
|
|
||||||
```
|
|
||||||
Verifica si un capítulo está descargado.
|
|
||||||
|
|
||||||
##### `deleteDownloadedChapter(mangaSlug:chapterNumber:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int)
|
|
||||||
```
|
|
||||||
Elimina un capítulo descargado (archivos + metadata).
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
// Verificar si está descargado
|
|
||||||
if storage.isChapterDownloaded(
|
|
||||||
mangaSlug: "one-piece_1695365223767",
|
|
||||||
chapterNumber: 1
|
|
||||||
) {
|
|
||||||
print("Capítulo ya descargado")
|
|
||||||
} else {
|
|
||||||
print("Capítulo no descargado")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eliminar capítulo
|
|
||||||
storage.deleteDownloadedChapter(
|
|
||||||
mangaSlug: "one-piece_1695365223767",
|
|
||||||
chapterNumber: 1
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Gestión de Imágenes
|
|
||||||
|
|
||||||
##### `saveImage(_:mangaSlug:chapterNumber:pageIndex:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL
|
|
||||||
```
|
|
||||||
Guarda una imagen en disco local.
|
|
||||||
|
|
||||||
**Parámetros:**
|
|
||||||
- `image`: `UIImage` - Imagen a guardar
|
|
||||||
- `mangaSlug`: `String` - Slug del manga
|
|
||||||
- `chapterNumber`: `Int` - Número del capítulo
|
|
||||||
- `pageIndex`: `Int` - Índice de la página
|
|
||||||
|
|
||||||
**Retorna:**
|
|
||||||
- `URL` - Ruta del archivo guardado
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
- Error si no se puede convertir o guardar
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
do {
|
|
||||||
let imageURL = try await storage.saveImage(
|
|
||||||
image: myUIImage,
|
|
||||||
mangaSlug: "one-piece_1695365223767",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
print("Imagen guardada en: \(imageURL.path)")
|
|
||||||
} catch {
|
|
||||||
print("Error guardando imagen: \(error)")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### `loadImage(mangaSlug:chapterNumber:pageIndex:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage?
|
|
||||||
```
|
|
||||||
Carga una imagen desde disco.
|
|
||||||
|
|
||||||
##### `getImageURL(mangaSlug:chapterNumber:pageIndex:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL?
|
|
||||||
```
|
|
||||||
Retorna la URL local de una imagen si existe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Gestión de Almacenamiento
|
|
||||||
|
|
||||||
##### `getStorageSize()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func getStorageSize() -> Int64
|
|
||||||
```
|
|
||||||
Retorna el tamaño total usado en bytes.
|
|
||||||
|
|
||||||
##### `clearAllDownloads()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func clearAllDownloads()
|
|
||||||
```
|
|
||||||
Elimina todos los capítulos descargados.
|
|
||||||
|
|
||||||
##### `formatFileSize(_:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func formatFileSize(_ bytes: Int64) -> String
|
|
||||||
```
|
|
||||||
Formatea bytes a string legible (KB, MB, GB).
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
// Obtener tamaño usado
|
|
||||||
let size = storage.getStorageSize()
|
|
||||||
let formatted = storage.formatFileSize(size)
|
|
||||||
print("Almacenamiento usado: \(formatted)")
|
|
||||||
|
|
||||||
// Limpiar todo
|
|
||||||
storage.clearAllDownloads()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ViewModels
|
|
||||||
|
|
||||||
Los ViewModels coordinan entre Services y Views.
|
|
||||||
|
|
||||||
### MangaListViewModel
|
|
||||||
|
|
||||||
ViewModel para la lista principal de mangas.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class MangaListViewModel: ObservableObject
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades Publicadas
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `mangas` | `[Manga]` | Lista de mangas cargados |
|
|
||||||
| `isLoading` | `Bool` | Indicador de carga |
|
|
||||||
| `searchText` | `String` | Texto de búsqueda |
|
|
||||||
| `filter` | `MangaFilter` | Filtro actual (all/favorites/downloaded) |
|
|
||||||
| `newMangaSlug` | `String` | Slug del nuevo manga a agregar |
|
|
||||||
|
|
||||||
#### Métodos Públicos
|
|
||||||
|
|
||||||
##### `loadMangas()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func loadMangas() async
|
|
||||||
```
|
|
||||||
Carga los mangas guardados en favoritos.
|
|
||||||
|
|
||||||
##### `addManga(_:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func addManga(_ slug: String) async
|
|
||||||
```
|
|
||||||
Agrega un nuevo manga mediante scraping.
|
|
||||||
|
|
||||||
#### Propiedades Computadas
|
|
||||||
|
|
||||||
##### `filteredMangas`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
var filteredMangas: [Manga]
|
|
||||||
```
|
|
||||||
Retorna mangas filtrados por búsqueda y categoría.
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
@StateObject private var viewModel = MangaListViewModel()
|
|
||||||
|
|
||||||
// Cargar mangas
|
|
||||||
await viewModel.loadMangas()
|
|
||||||
|
|
||||||
// Agregar manga
|
|
||||||
await viewModel.addManga("one-piece_1695365223767")
|
|
||||||
|
|
||||||
// Filtrar
|
|
||||||
viewModel.filter = .favorites
|
|
||||||
viewModel.searchText = "one"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MangaDetailViewModel
|
|
||||||
|
|
||||||
ViewModel para el detalle de un manga.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class MangaDetailViewModel: ObservableObject
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades Publicadas
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `chapters` | `[Chapter]` | Lista de capítulos |
|
|
||||||
| `isLoadingChapters` | `Bool` | Indicador de carga |
|
|
||||||
| `isFavorite` | `Bool` | Estado de favorito |
|
|
||||||
| `selectedChapter` | `Chapter?` | Capítulo seleccionado |
|
|
||||||
| `showingDownloadAll` | `Bool` | Mostrar diálogo de descarga |
|
|
||||||
|
|
||||||
#### Métodos Públicos
|
|
||||||
|
|
||||||
##### `loadChapters()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func loadChapters() async
|
|
||||||
```
|
|
||||||
Carga los capítulos del manga.
|
|
||||||
|
|
||||||
##### `toggleFavorite()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func toggleFavorite()
|
|
||||||
```
|
|
||||||
Alterna el estado de favorito.
|
|
||||||
|
|
||||||
##### `downloadAllChapters()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func downloadAllChapters()
|
|
||||||
```
|
|
||||||
Inicia descarga de todos los capítulos.
|
|
||||||
|
|
||||||
##### `downloadLastChapters(count:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func downloadLastChapters(count: Int)
|
|
||||||
```
|
|
||||||
Descarga los últimos N capítulos.
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
@StateObject private var viewModel = MangaDetailViewModel(manga: manga)
|
|
||||||
|
|
||||||
// Cargar capítulos
|
|
||||||
await viewModel.loadChapters()
|
|
||||||
|
|
||||||
// Marcar favorito
|
|
||||||
viewModel.toggleFavorite()
|
|
||||||
|
|
||||||
// Descargar últimos 10
|
|
||||||
viewModel.downloadLastChapters(count: 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ReaderViewModel
|
|
||||||
|
|
||||||
ViewModel para el lector de capítulos.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class ReaderViewModel: ObservableObject
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Propiedades Publicadas
|
|
||||||
|
|
||||||
| Propiedad | Tipo | Descripción |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `pages` | `[MangaPage]` | Lista de páginas |
|
|
||||||
| `currentPage` | `Int` | Página actual |
|
|
||||||
| `isLoading` | `Bool` | Indicador de carga |
|
|
||||||
| `showError` | `Bool` | Mostrar error |
|
|
||||||
| `showControls` | `Bool` | Mostrar controles UI |
|
|
||||||
| `isFavorite` | `Bool` | Estado de favorito |
|
|
||||||
| `isDownloaded` | `Bool` | Capítulo descargado |
|
|
||||||
| `backgroundColor` | `Color` | Color de fondo |
|
|
||||||
| `readingMode` | `ReadingMode` | Modo de lectura |
|
|
||||||
|
|
||||||
#### Métodos Públicos
|
|
||||||
|
|
||||||
##### `loadPages()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func loadPages() async
|
|
||||||
```
|
|
||||||
Carga las páginas del capítulo (desde local o web).
|
|
||||||
|
|
||||||
##### `cachePage(_:image:)`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func cachePage(_ page: MangaPage, image: Image) async
|
|
||||||
```
|
|
||||||
Cachea una página localmente (TODO: implementar).
|
|
||||||
|
|
||||||
##### `toggleFavorite()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func toggleFavorite()
|
|
||||||
```
|
|
||||||
Alterna favorito del manga actual.
|
|
||||||
|
|
||||||
##### `cycleBackgroundColor()`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func cycleBackgroundColor()
|
|
||||||
```
|
|
||||||
Cicla entre colores de fondo (blanco/negro/sepia).
|
|
||||||
|
|
||||||
#### Propiedades Computadas
|
|
||||||
|
|
||||||
- `currentPageIndex`: `Int` - Índice de página actual
|
|
||||||
- `totalPages`: `Int` - Total de páginas
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
@StateObject private var viewModel = ReaderViewModel(manga: manga, chapter: chapter)
|
|
||||||
|
|
||||||
// Cargar páginas
|
|
||||||
await viewModel.loadPages()
|
|
||||||
|
|
||||||
// Ir a página específica
|
|
||||||
viewModel.currentPage = 10
|
|
||||||
|
|
||||||
// Cambiar fondo
|
|
||||||
viewModel.cycleBackgroundColor()
|
|
||||||
|
|
||||||
// Cambiar modo lectura
|
|
||||||
viewModel.readingMode = .horizontal
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Views
|
|
||||||
|
|
||||||
Las vistas son componentes SwiftUI que presentan la UI.
|
|
||||||
|
|
||||||
### ContentView
|
|
||||||
|
|
||||||
Vista principal que muestra la lista de mangas.
|
|
||||||
|
|
||||||
**Componentes:**
|
|
||||||
- `MangaRowView`: Fila individual de manga
|
|
||||||
- `MangaListViewModel`: ViewModel asociado
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
- Búsqueda de mangas
|
|
||||||
- Filtros (todos/favoritos/descargados)
|
|
||||||
- Agregar manga manualmente
|
|
||||||
- Pull-to-refresh
|
|
||||||
|
|
||||||
### MangaDetailView
|
|
||||||
|
|
||||||
Vista de detalle de un manga específico.
|
|
||||||
|
|
||||||
**Componentes:**
|
|
||||||
- `ChapterRowView`: Fila de capítulo
|
|
||||||
- `FlowLayout`: Layout de géneros
|
|
||||||
- `MangaDetailViewModel`: ViewModel asociado
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
- Mostrar información del manga
|
|
||||||
- Listar capítulos
|
|
||||||
- Marcar favorito
|
|
||||||
- Descargar capítulos
|
|
||||||
|
|
||||||
### ReaderView
|
|
||||||
|
|
||||||
Vista de lectura de capítulos.
|
|
||||||
|
|
||||||
**Componentes:**
|
|
||||||
- `PageView`: Vista de página individual
|
|
||||||
- `ReaderViewModel`: ViewModel asociado
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
- Mostrar páginas con zoom/pan
|
|
||||||
- Navegación entre páginas
|
|
||||||
- Configurar fondo
|
|
||||||
- Cambiar modo de lectura
|
|
||||||
- Slider de navegación
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Errores y Manejo de Excepciones
|
|
||||||
|
|
||||||
### ScrapingError
|
|
||||||
|
|
||||||
Errores específicos del scraper.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
enum ScrapingError: LocalizedError {
|
|
||||||
case webViewNotInitialized
|
|
||||||
case pageLoadFailed
|
|
||||||
case noContentFound
|
|
||||||
case parsingError
|
|
||||||
|
|
||||||
var errorDescription: String? { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Casos
|
|
||||||
|
|
||||||
| Error | Descripción | Mensaje (Español) |
|
|
||||||
|-------|-------------|-------------------|
|
|
||||||
| `webViewNotInitialized` | WKWebView no configurado | "WebView no está inicializado" |
|
|
||||||
| `pageLoadFailed` | Error cargando página | "Error al cargar la página" |
|
|
||||||
| `noContentFound` | No se encontró contenido | "No se encontró contenido" |
|
|
||||||
| `parsingError` | Error procesando datos | "Error al procesar el contenido" |
|
|
||||||
|
|
||||||
### Estrategias de Manejo de Errores
|
|
||||||
|
|
||||||
1. **ViewModels**: Capturan errores del scraper
|
|
||||||
2. **Views**: Muestran alertas al usuario
|
|
||||||
3. **Servicios**: Propagan errores con `throws`
|
|
||||||
|
|
||||||
**Ejemplo:**
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let manga = try await scraper.scrapeMangaInfo(mangaSlug: slug)
|
|
||||||
// Manejar éxito
|
|
||||||
} catch ScrapingError.webViewNotInitialized {
|
|
||||||
// Mostrar alerta específica
|
|
||||||
errorMessage = "Error de configuración"
|
|
||||||
} catch {
|
|
||||||
// Error genérico
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Última actualización**: Febrero 2026
|
|
||||||
**Versión**: 1.0.0
|
|
||||||
@@ -1,642 +0,0 @@
|
|||||||
# Arquitectura de MangaReader
|
|
||||||
|
|
||||||
Este documento describe la arquitectura general del proyecto MangaReader, explicando cómo funcionan los componentes Backend y iOS App, y cómo fluyen los datos desde el scraping hasta el display en pantalla.
|
|
||||||
|
|
||||||
## Tabla de Contenidos
|
|
||||||
|
|
||||||
- [Visión General](#visión-general)
|
|
||||||
- [Arquitectura del Sistema](#arquitectura-del-sistema)
|
|
||||||
- [Arquitectura de la App iOS](#arquitectura-de-la-app-ios)
|
|
||||||
- [Flujo de Datos](#flujo-de-datos)
|
|
||||||
- [Patrones de Diseño](#patrones-de-diseño)
|
|
||||||
- [Diagramas de Secuencia](#diagramas-de-secuencia)
|
|
||||||
|
|
||||||
## Visión General
|
|
||||||
|
|
||||||
MangaReader es una aplicación nativa de iOS para leer manga sin publicidad. El proyecto consta de dos componentes opcionales:
|
|
||||||
|
|
||||||
1. **Backend (Opcional)**: Servidor Node.js con Express que realiza scraping usando Puppeteer
|
|
||||||
2. **iOS App**: Aplicación nativa SwiftUI que puede hacer scraping localmente usando WKWebView
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MangaReader │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ Backend │ │ iOS App │ │
|
|
||||||
│ │ (Opcional) │ │ (Principal) │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ • Node.js │ │ • SwiftUI │ │
|
|
||||||
│ │ • Express │ │ • WKWebView │ │
|
|
||||||
│ │ • Puppeteer │ │ • Core Data │ │
|
|
||||||
│ └─────────────────┘ └─────────────────┘ │
|
|
||||||
│ ▲ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ Scraping Independiente │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Arquitectura del Sistema
|
|
||||||
|
|
||||||
### Componentes Principales
|
|
||||||
|
|
||||||
#### 1. Backend (Opcional)
|
|
||||||
|
|
||||||
El backend es una API REST opcional que puede actuar como intermediario:
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── scraper.js # Scraper con Puppeteer
|
|
||||||
├── server.js # API REST con Express
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responsabilidades:**
|
|
||||||
- Realizar scraping de manhwaweb.com
|
|
||||||
- Servir datos vía API REST
|
|
||||||
- Cachear respuestas para mejorar rendimiento
|
|
||||||
|
|
||||||
**API Endpoints:**
|
|
||||||
- `GET /api/health` - Health check
|
|
||||||
- `GET /api/manga/:slug` - Información de un manga
|
|
||||||
- `GET /api/manga/:slug/chapters` - Lista de capítulos
|
|
||||||
- `GET /api/chapter/:slug/images` - Imágenes de un capítulo
|
|
||||||
|
|
||||||
**Nota Importante**: El backend es completamente opcional. La app iOS está diseñada para funcionar de manera autónoma sin necesidad del backend.
|
|
||||||
|
|
||||||
#### 2. iOS App (Principal)
|
|
||||||
|
|
||||||
La aplicación iOS es el componente principal y puede operar independientemente:
|
|
||||||
|
|
||||||
```
|
|
||||||
ios-app/
|
|
||||||
├── MangaReaderApp.swift # Entry point
|
|
||||||
├── Info.plist
|
|
||||||
└── Sources/
|
|
||||||
├── Models/ # Modelos de datos
|
|
||||||
│ └── Manga.swift
|
|
||||||
├── Services/ # Lógica de negocio
|
|
||||||
│ ├── ManhwaWebScraper.swift
|
|
||||||
│ └── StorageService.swift
|
|
||||||
└── Views/ # UI SwiftUI
|
|
||||||
├── ContentView.swift
|
|
||||||
├── MangaDetailView.swift
|
|
||||||
└── ReaderView.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
## Arquitectura de la App iOS
|
|
||||||
|
|
||||||
### MVVM Pattern (Model-View-ViewModel)
|
|
||||||
|
|
||||||
La app iOS sigue el patrón MVVM para separar la UI de la lógica de negocio:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MVVM Architecture │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────┐ ┌─────────────────┐ ┌─────────┐ │
|
|
||||||
│ │ View │◄────────│ ViewModel │◄────────│ Model │ │
|
|
||||||
│ │(SwiftUI)│ │ (Observable) │ │(Struct) │ │
|
|
||||||
│ └─────────┘ └─────────────────┘ └─────────┘ │
|
|
||||||
│ ▲ │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ └───────────────────────┴───────────────────────────┘ │
|
|
||||||
│ Data Binding & Commands │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Estructura de Componentes
|
|
||||||
|
|
||||||
#### 1. Models (Datos)
|
|
||||||
|
|
||||||
**Ubicación**: `ios-app/Sources/Models/Manga.swift`
|
|
||||||
|
|
||||||
Los modelos son estructuras inmutables que representan los datos:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
- Manga: Información del manga (título, descripción, géneros, estado)
|
|
||||||
- Chapter: Capítulo individual (número, título, URL, estado de lectura)
|
|
||||||
- MangaPage: Página individual del capítulo (URL de imagen, índice)
|
|
||||||
- ReadingProgress: Progreso de lectura del usuario
|
|
||||||
- DownloadedChapter: Capítulo descargado localmente
|
|
||||||
```
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
- Inmutables (`struct`)
|
|
||||||
- Conformes a `Codable` para serialización
|
|
||||||
- Conformes a `Identifiable` para SwiftUI
|
|
||||||
- Conformes a `Hashable` para comparaciones
|
|
||||||
|
|
||||||
#### 2. Services (Lógica de Negocio)
|
|
||||||
|
|
||||||
**Ubicación**: `ios-app/Sources/Services/`
|
|
||||||
|
|
||||||
##### ManhwaWebScraper.swift
|
|
||||||
|
|
||||||
Responsable del scraping de contenido web:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class ManhwaWebScraper: NSObject, ObservableObject {
|
|
||||||
// Singleton instance
|
|
||||||
static let shared = ManhwaWebScraper()
|
|
||||||
|
|
||||||
// Funciones principales:
|
|
||||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
|
||||||
func scrapeChapterImages(chapterSlug: String) async throws -> [String]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
- Usa `WKWebView` para ejecutar JavaScript
|
|
||||||
- Implementa `async/await` para operaciones asíncronas
|
|
||||||
- Patrón Singleton para compartir instancia
|
|
||||||
- Manejo de errores con `ScrapingError`
|
|
||||||
|
|
||||||
##### StorageService.swift
|
|
||||||
|
|
||||||
Responsable del almacenamiento local:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class StorageService {
|
|
||||||
// Singleton instance
|
|
||||||
static let shared = StorageService()
|
|
||||||
|
|
||||||
// Gestión de favoritos:
|
|
||||||
func getFavorites() -> [String]
|
|
||||||
func saveFavorite(mangaSlug: String)
|
|
||||||
func removeFavorite(mangaSlug: String)
|
|
||||||
|
|
||||||
// Gestión de progreso:
|
|
||||||
func saveReadingProgress(_ progress: ReadingProgress)
|
|
||||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress?
|
|
||||||
|
|
||||||
// Gestión de descargas:
|
|
||||||
func saveDownloadedChapter(_ chapter: DownloadedChapter)
|
|
||||||
func getDownloadedChapters() -> [DownloadedChapter]
|
|
||||||
|
|
||||||
// Gestión de imágenes:
|
|
||||||
func saveImage(_ image: UIImage, ...) async throws -> URL
|
|
||||||
func loadImage(...) -> UIImage?
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
- Almacena favoritos en `UserDefaults`
|
|
||||||
- Almacena progreso en `UserDefaults`
|
|
||||||
- Guarda imágenes en el sistema de archivos
|
|
||||||
- Usa `FileManager` para gestión de archivos
|
|
||||||
|
|
||||||
#### 3. ViewModels (Presentación)
|
|
||||||
|
|
||||||
**Ubicación**: Integrados en los archivos de Views
|
|
||||||
|
|
||||||
Los ViewModels coordinan entre Services y Views:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class MangaListViewModel: ObservableObject {
|
|
||||||
@Published var mangas: [Manga] = []
|
|
||||||
@Published var isLoading = false
|
|
||||||
|
|
||||||
private let scraper = ManhwaWebScraper.shared
|
|
||||||
private let storage = StorageService.shared
|
|
||||||
|
|
||||||
func loadMangas() async
|
|
||||||
func addManga(_ slug: String) async
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responsabilidades:**
|
|
||||||
- Mantener estado de la UI
|
|
||||||
- Transformar datos para presentación
|
|
||||||
- Manejar lógica de navegación
|
|
||||||
- Coordinar llamadas a servicios
|
|
||||||
|
|
||||||
#### 4. Views (UI)
|
|
||||||
|
|
||||||
**Ubicación**: `ios-app/Sources/Views/`
|
|
||||||
|
|
||||||
##### ContentView.swift
|
|
||||||
- Vista principal de la app
|
|
||||||
- Lista de mangas con filtros
|
|
||||||
- Búsqueda y añadir manga
|
|
||||||
|
|
||||||
##### MangaDetailView.swift
|
|
||||||
- Detalle de un manga específico
|
|
||||||
- Lista de capítulos
|
|
||||||
- Descarga de capítulos
|
|
||||||
|
|
||||||
##### ReaderView.swift
|
|
||||||
- Lector de imágenes
|
|
||||||
- Gestos de zoom y pan
|
|
||||||
- Configuración de lectura
|
|
||||||
|
|
||||||
## Flujo de Datos
|
|
||||||
|
|
||||||
### 1. Flujo de Scraping de Manga
|
|
||||||
|
|
||||||
```
|
|
||||||
Usuario ingresa slug
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ContentView -> MangaListViewModel.addManga(slug) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ManhwaWebScraper.scrapeMangaInfo(mangaSlug) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ WKWebView carga URL de manhwaweb.com │
|
|
||||||
│ https://manhwaweb.com/manga/{slug} │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ JavaScript ejecutado en WKWebView: │
|
|
||||||
│ - Extrae título, descripción, géneros │
|
|
||||||
│ - Extrae estado, imagen de portada │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ Datos parseados a struct Manga │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ViewModel actualiza @Published var mangas │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ SwiftUI detecta cambio y re-renderiza UI │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Flujo de Lectura de Capítulo
|
|
||||||
|
|
||||||
```
|
|
||||||
Usuario selecciona capítulo
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ MangaDetailView -> ReaderView(manga, chapter) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ReaderViewModel.loadPages() │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
├──► ¿Capítulo descargado?
|
|
||||||
│ │
|
|
||||||
│ SÍ │ NO
|
|
||||||
│ ▼
|
|
||||||
│ ┌─────────────────────────────────────────────────┐
|
|
||||||
│ │ StorageService.getDownloadedChapter() │
|
|
||||||
│ │ Cargar páginas locales │
|
|
||||||
│ └─────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
│ └──────────────────┐
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────┼──────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ManhwaWebScraper.scrapeChapterImages(chapterSlug) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ WKWebView carga URL del capítulo │
|
|
||||||
│ https://manhwaweb.com/leer/{slug} │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ JavaScript extrae URLs de imágenes: │
|
|
||||||
│ - Selecciona todas las etiquetas <img> │
|
|
||||||
│ - Filtra elementos de UI (avatars, icons) │
|
|
||||||
│ - Elimina duplicados │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ Array de strings con URLs de imágenes │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ Convertir a [MangaPage] y mostrar en ReaderView │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ReaderView muestra imágenes con AsyncImage │
|
|
||||||
│ Cache automático de imágenes en visualización │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Flujo de Guardado de Progreso
|
|
||||||
|
|
||||||
```
|
|
||||||
Usuario navega a página X
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ReaderViewModel.currentPage cambia a X │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ ReaderViewModel.saveProgress() │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ Crear ReadingProgress(mangaSlug, chapterNumber, pageNumber) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ StorageService.saveReadingProgress(progress) │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ JSONEncoder codifica a Data │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ UserDefaults.set(data, forKey: "readingProgress") │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Patrones de Diseño
|
|
||||||
|
|
||||||
### 1. Singleton Pattern
|
|
||||||
|
|
||||||
**Uso**: Services compartidos
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class StorageService {
|
|
||||||
static let shared = StorageService()
|
|
||||||
private init() { ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
class ManhwaWebScraper {
|
|
||||||
static let shared = ManhwaWebScraper()
|
|
||||||
private init() { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- Unica instancia compartida en toda la app
|
|
||||||
- Reduce consumo de memoria
|
|
||||||
- Facilita acceso desde cualquier View/ViewModel
|
|
||||||
|
|
||||||
### 2. MVVM (Model-View-ViewModel)
|
|
||||||
|
|
||||||
**Uso**: Arquitectura general de la app
|
|
||||||
|
|
||||||
**Separación de responsabilidades:**
|
|
||||||
- **Model**: Datos puras (`struct`, `Codable`)
|
|
||||||
- **View**: UI pura (`SwiftUI`, reactive)
|
|
||||||
- **ViewModel**: Lógica de presentación (`ObservableObject`)
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- Testabilidad de ViewModels sin UI
|
|
||||||
- Reutilización de ViewModels
|
|
||||||
- Separación clara de concerns
|
|
||||||
|
|
||||||
### 3. Repository Pattern
|
|
||||||
|
|
||||||
**Uso**: Abstracción de fuentes de datos
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class StorageService {
|
|
||||||
// Abstrae UserDefaults, FileManager, etc.
|
|
||||||
func getFavorites() -> [String]
|
|
||||||
func saveFavorite(mangaSlug: String)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- Interfaz unificada para diferentes storage
|
|
||||||
- Fácil cambiar implementación
|
|
||||||
- Centraliza lógica de persistencia
|
|
||||||
|
|
||||||
### 4. Async/Await Pattern
|
|
||||||
|
|
||||||
**Uso**: Operaciones de scraping
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga {
|
|
||||||
// Operación asíncrona
|
|
||||||
try await loadURLAndWait(url)
|
|
||||||
let info = try await webView.evaluateJavaScript(...)
|
|
||||||
return Manga(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- Código asíncrono legible
|
|
||||||
- Manejo de errores claro
|
|
||||||
- No bloquea el hilo principal
|
|
||||||
|
|
||||||
### 5. Observable Object Pattern
|
|
||||||
|
|
||||||
**Uso**: Reactividad en SwiftUI
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
class MangaListViewModel: ObservableObject {
|
|
||||||
@Published var mangas: [Manga] = []
|
|
||||||
|
|
||||||
func loadMangas() async {
|
|
||||||
mangas = ... // SwiftUI detecta cambio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- UI se actualiza automáticamente
|
|
||||||
- Código declarativo
|
|
||||||
- Menos código boilerplate
|
|
||||||
|
|
||||||
### 6. Factory Pattern (Implícito)
|
|
||||||
|
|
||||||
**Uso**: Creación de modelos
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Funciones estáticas que crean instancias
|
|
||||||
Chapter(number: 1, title: "...", url: "...", slug: "...")
|
|
||||||
MangaPage(url: "...", index: 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beneficios:**
|
|
||||||
- Creación consistente de objetos
|
|
||||||
- Validación en inicialización
|
|
||||||
- Fácil de mantener
|
|
||||||
|
|
||||||
## Diagramas de Secuencia
|
|
||||||
|
|
||||||
### Secuencia 1: Agregar Manga
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌────────────┐
|
|
||||||
│ Usuario │ │ ViewModel │ │ Scraper │ │ WKWebView │
|
|
||||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘ └─────┬──────┘
|
|
||||||
│ │ │ │
|
|
||||||
│ ingresa slug │ │ │
|
|
||||||
│───────────────>│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ scrapeMangaInfo() │ │
|
|
||||||
│ │────────────────────>│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ load(URL) │
|
|
||||||
│ │ │───────────────────>│
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ wait 3 seconds │
|
|
||||||
│ │ │<───────────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ evaluateJavaScript │
|
|
||||||
│ │ │───────────────────>│
|
|
||||||
│ │ │ (extrae datos) │
|
|
||||||
│ │ │<───────────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Manga │ │
|
|
||||||
│ │<────────────────────│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ actualiza │ │ │
|
|
||||||
│ UI │ │ │
|
|
||||||
│<───────────────│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
┌────┴────┐ ┌──────┴───────┘ └────────┴────────┘ └─────┴──────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secuencia 2: Leer Capítulo
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────┐
|
|
||||||
│ Usuario │ │ ReaderView │ │ ViewModel │ │ Storage │
|
|
||||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘ └────┬─────┘
|
|
||||||
│ │ │ │
|
|
||||||
│ tap capítulo │ │ │
|
|
||||||
│───────────────>│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ loadPages() │ │
|
|
||||||
│ │────────────────────>│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ isDownloaded()? │
|
|
||||||
│ │ │──────────────────>│
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ NO │
|
|
||||||
│ │ │<──────────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ scrapeChapterImages│
|
|
||||||
│ │ │ (via Scraper) │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [MangaPage] │ │
|
|
||||||
│ │<────────────────────│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ muestra páginas│ │ │
|
|
||||||
│<───────────────│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
┌────┴────┐ ┌──────┴───────┘ └────────┴────────┘ └────┴─────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secuencia 3: Guardar Favorito
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
||||||
│ Usuario │ │ View │ │ StorageService │
|
|
||||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘
|
|
||||||
│ │ │
|
|
||||||
│ tap corazón │ │
|
|
||||||
│───────────────>│ │
|
|
||||||
│ │ │
|
|
||||||
│ │ toggleFavorite() │
|
|
||||||
│ │────────────────────>│
|
|
||||||
│ │ │
|
|
||||||
│ │ │ getFavorites()
|
|
||||||
│ │ │ (UserDefaults)
|
|
||||||
│ │ │
|
|
||||||
│ │ │ saveFavorite()
|
|
||||||
│ │ │ (UserDefaults)
|
|
||||||
│ │ │
|
|
||||||
│ │ actualiza UI │
|
|
||||||
│ │<────────────────────│
|
|
||||||
│ │ │
|
|
||||||
┌────┴────┘ ┌──────┴───────┘ └────────┴────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Decisiones de Arquitectura
|
|
||||||
|
|
||||||
### ¿Por qué WKWebView para scraping?
|
|
||||||
|
|
||||||
1. **JavaScript Rendering**: manhwaweb.com usa JavaScript para cargar contenido
|
|
||||||
2. **Sin dependencias externas**: No requiere librerías de terceros
|
|
||||||
3. **Aislamiento**: El scraping ocurre en contexto separado
|
|
||||||
4. **Control**: Full control sobre timeouts, cookies, headers
|
|
||||||
|
|
||||||
### ¿Por qué UserDefaults para favoritos/progreso?
|
|
||||||
|
|
||||||
1. **Simplicidad**: Datos pequeños y simples
|
|
||||||
2. **Sincronización**: iCloud sync automático disponible
|
|
||||||
3. **Rendimiento**: Lectura/escritura rápida
|
|
||||||
4. **Persistencia**: Survive app reinstalls (si iCloud)
|
|
||||||
|
|
||||||
### ¿Por qué FileManager para imágenes?
|
|
||||||
|
|
||||||
1. **Tamaño**: Imágenes pueden ser grandes (MBs)
|
|
||||||
2. **Performance**: Acceso directo a archivos
|
|
||||||
3. **Cache control**: Control manual de qué guardar
|
|
||||||
4. **Escalabilidad**: No limitado por UserDefaults
|
|
||||||
|
|
||||||
### ¿Por qué MVVM?
|
|
||||||
|
|
||||||
1. **SwiftUI nativo**: SwiftUI está diseñado para MVVM
|
|
||||||
2. **Testabilidad**: ViewModels testeables sin UI
|
|
||||||
3. **Reactibilidad**: `@Published` y `ObservableObject`
|
|
||||||
4. **Separación**: UI separada de lógica de negocio
|
|
||||||
|
|
||||||
## Consideraciones de Escalabilidad
|
|
||||||
|
|
||||||
### Futuras Mejoras
|
|
||||||
|
|
||||||
1. **Database**: Migrar de UserDefaults a Core Data o SQLite
|
|
||||||
2. **Background Tasks**: Descargas en background
|
|
||||||
3. **Caching Strategy**: LRU cache para imágenes
|
|
||||||
4. **Pagination**: Cargar capítulos bajo demanda
|
|
||||||
5. **Sync Service**: Sincronización entre dispositivos
|
|
||||||
|
|
||||||
### Rendimiento
|
|
||||||
|
|
||||||
- **Lazy Loading**: Cargar imágenes bajo demanda
|
|
||||||
- **Image Compression**: JPEG 80% calidad
|
|
||||||
- **Request Batching**: Descargar páginas en paralelo
|
|
||||||
- **Memory Management**: Liberar imágenes no visibles
|
|
||||||
|
|
||||||
## Seguridad
|
|
||||||
|
|
||||||
### Consideraciones
|
|
||||||
|
|
||||||
1. **No se almacenan credenciales**: La app no requiere login
|
|
||||||
2. **SSL Pinning**: Considerar para producción
|
|
||||||
3. **Input Validation**: Validar slugs antes de scraping
|
|
||||||
4. **Rate Limiting**: No sobrecargar el servidor objetivo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Última actualización**: Febrero 2026
|
|
||||||
**Versión**: 1.0.0
|
|
||||||
@@ -1,735 +0,0 @@
|
|||||||
# Contributing to MangaReader
|
|
||||||
|
|
||||||
Gracias por tu interés en contribuir a MangaReader. Este documento proporciona una guía completa para contribuyentes, incluyendo cómo agregar nuevas fuentes de manga, modificar el scraper, estándares de código y testing.
|
|
||||||
|
|
||||||
## Tabla de Contenidos
|
|
||||||
|
|
||||||
- [Configuración del Entorno de Desarrollo](#configuración-del-entorno-de-desarrollo)
|
|
||||||
- [Cómo Agregar Nuevas Fuentes de Manga](#cómo-agregar-nuevas-fuentes-de-manga)
|
|
||||||
- [Cómo Modificar el Scraper](#cómo-modificar-el-scraper)
|
|
||||||
- [Estándares de Código](#estándares-de-código)
|
|
||||||
- [Testing](#testing)
|
|
||||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuración del Entorno de Desarrollo
|
|
||||||
|
|
||||||
### Requisitos Previos
|
|
||||||
|
|
||||||
1. **MacOS** con Xcode 15+
|
|
||||||
2. **iOS 15+** device o simulator
|
|
||||||
3. **Git** para control de versiones
|
|
||||||
4. **Cuenta de Developer** de Apple (opcional, para firmar)
|
|
||||||
|
|
||||||
### Pasos Iniciales
|
|
||||||
|
|
||||||
1. **Fork el repositorio** (si es proyecto open source)
|
|
||||||
2. **Clona tu fork**:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/tu-usuario/MangaReader.git
|
|
||||||
cd MangaReader/ios-app
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Abre el proyecto en Xcode**:
|
|
||||||
```bash
|
|
||||||
open MangaReader.xcodeproj
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configura el signing**:
|
|
||||||
- Selecciona el proyecto en el sidebar
|
|
||||||
- En "Signing & Capabilities", elige tu Team
|
|
||||||
- Asegúrate que "Automatically manage signing" esté activado
|
|
||||||
|
|
||||||
5. **Ejecuta el proyecto**:
|
|
||||||
- Selecciona un dispositivo o simulador
|
|
||||||
- Presiona `Cmd + R`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cómo Agregar Nuevas Fuentes de Manga
|
|
||||||
|
|
||||||
MangaReader está diseñado para soportar múltiples fuentes de manga. Actualmente soporta manhwaweb.com, pero puedes agregar más fuentes siguiendo estos pasos.
|
|
||||||
|
|
||||||
### Arquitectura de Fuentes
|
|
||||||
|
|
||||||
Las fuentes se implementan usando un protocolo común:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
protocol MangaSource {
|
|
||||||
var name: String { get }
|
|
||||||
var baseURL: String { get }
|
|
||||||
|
|
||||||
func fetchMangaInfo(slug: String) async throws -> Manga
|
|
||||||
func fetchChapters(mangaSlug: String) async throws -> [Chapter]
|
|
||||||
func fetchChapterImages(chapterSlug: String) async throws -> [String]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 1: Crear el Scraper de la Nueva Fuente
|
|
||||||
|
|
||||||
Crea un nuevo archivo en `ios-app/Sources/Services/`:
|
|
||||||
|
|
||||||
**Ejemplo: `MangaTownScraper.swift`**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import Foundation
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
/// Scraper para mangatown.com
|
|
||||||
/// Implementa el protocolo MangaSource para agregar soporte de esta fuente
|
|
||||||
@MainActor
|
|
||||||
class MangaTownScraper: NSObject, ObservableObject, MangaSource {
|
|
||||||
|
|
||||||
// MARK: - MangaSource Protocol
|
|
||||||
|
|
||||||
let name = "MangaTown"
|
|
||||||
let baseURL = "https://www.mangatown.com"
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
static let shared = MangaTownScraper()
|
|
||||||
private var webView: WKWebView?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
setupWebView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setupWebView() {
|
|
||||||
let configuration = WKWebViewConfiguration()
|
|
||||||
configuration.applicationNameForUserAgent = "Mozilla/5.0"
|
|
||||||
|
|
||||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
||||||
webView?.navigationDelegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MangaSource Implementation
|
|
||||||
|
|
||||||
/// Obtiene la información de un manga desde MangaTown
|
|
||||||
/// - Parameter slug: Identificador único del manga
|
|
||||||
/// - Returns: Objeto Manga con información completa
|
|
||||||
/// - Throws: ScrapingError si falla el scraping
|
|
||||||
func fetchMangaInfo(slug: String) async throws -> Manga {
|
|
||||||
guard let webView = webView else {
|
|
||||||
throw ScrapingError.webViewNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = URL(string: "\(baseURL)/manga/\(slug)")!
|
|
||||||
try await loadURLAndWait(url, webView: webView)
|
|
||||||
|
|
||||||
// Extraer información usando JavaScript
|
|
||||||
let info: [String: Any] = try await webView.evaluateJavaScript("""
|
|
||||||
(function() {
|
|
||||||
// Implementación específica para MangaTown
|
|
||||||
return {
|
|
||||||
title: document.querySelector('.manga-title')?.textContent || '',
|
|
||||||
description: document.querySelector('.manga-summary')?.textContent || '',
|
|
||||||
// ... más extracciones
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
""") as! [String: Any]
|
|
||||||
|
|
||||||
return Manga(
|
|
||||||
slug: slug,
|
|
||||||
title: info["title"] as? String ?? "",
|
|
||||||
description: info["description"] as? String ?? "",
|
|
||||||
genres: [],
|
|
||||||
status: "",
|
|
||||||
url: url.absoluteString,
|
|
||||||
coverImage: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtiene los capítulos de un manga
|
|
||||||
/// - Parameter mangaSlug: Slug del manga
|
|
||||||
/// - Returns: Array de capítulos ordenados
|
|
||||||
/// - Throws: ScrapingError si falla
|
|
||||||
func fetchChapters(mangaSlug: String) async throws -> [Chapter] {
|
|
||||||
// Implementación similar a scrapeChapters
|
|
||||||
// ...
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtiene las imágenes de un capítulo
|
|
||||||
/// - Parameter chapterSlug: Slug del capítulo
|
|
||||||
/// - Returns: Array de URLs de imágenes
|
|
||||||
/// - Throws: ScrapingError si falla
|
|
||||||
func fetchChapterImages(chapterSlug: String) async throws -> [String] {
|
|
||||||
// Implementación similar a scrapeChapterImages
|
|
||||||
// ...
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func loadURLAndWait(_ url: URL, webView: WKWebView, waitTime: Double = 3.0) async throws {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
webView.load(URLRequest(url: url))
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + waitTime) {
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - WKNavigationDelegate
|
|
||||||
|
|
||||||
extension MangaTownScraper: WKNavigationDelegate {
|
|
||||||
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
||||||
// Navegación completada
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
||||||
print("Navigation failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 2: Registrar la Nueva Fuente
|
|
||||||
|
|
||||||
Agrega la fuente al registro de fuentes disponibles. Crea un archivo `MangaSourceRegistry.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
/// Registro de fuentes de manga disponibles
|
|
||||||
enum MangaSourceRegistry {
|
|
||||||
static let allSources: [MangaSource] = [
|
|
||||||
ManhwaWebScraper.shared,
|
|
||||||
MangaTownScraper.shared,
|
|
||||||
]
|
|
||||||
|
|
||||||
static func source(named name: String) -> MangaSource? {
|
|
||||||
allSources.first { $0.name == name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 3: Actualizar la UI para Seleccionar Fuente
|
|
||||||
|
|
||||||
Modifica `ContentView.swift` para permitir selección de fuente:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En MangaListViewModel
|
|
||||||
@Published var selectedSource: MangaSource = ManhwaWebScraper.shared
|
|
||||||
|
|
||||||
// En ContentView
|
|
||||||
Picker("Fuente", selection: $viewModel.selectedSource) {
|
|
||||||
ForEach(MangaSourceRegistry.allSources, id: \.name) { source in
|
|
||||||
Text(source.name).tag(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 4: Testing de la Nueva Fuente
|
|
||||||
|
|
||||||
Crea tests para verificar que el scraper funciona correctamente:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import XCTest
|
|
||||||
@testable import MangaReader
|
|
||||||
|
|
||||||
class MangaTownScraperTests: XCTestCase {
|
|
||||||
var scraper: MangaTownScraper!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
scraper = MangaTownScraper.shared
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFetchMangaInfo() async throws {
|
|
||||||
let manga = try await scraper.fetchMangaInfo(slug: "one-piece")
|
|
||||||
|
|
||||||
XCTAssertFalse(manga.title.isEmpty)
|
|
||||||
XCTAssertFalse(manga.description.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFetchChapters() async throws {
|
|
||||||
let chapters = try await scraper.fetchChapters(mangaSlug: "one-piece")
|
|
||||||
|
|
||||||
XCTAssertFalse(chapters.isEmpty)
|
|
||||||
XCTAssertTrue(chapters.first?.number == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cómo Modificar el Scraper
|
|
||||||
|
|
||||||
Si la estructura de manhwaweb.com cambia y el scraper deja de funcionar, sigue estos pasos para actualizarlo.
|
|
||||||
|
|
||||||
### Paso 1: Investigar la Nueva Estructura
|
|
||||||
|
|
||||||
1. Abre manhwaweb.com en Safari/Chrome
|
|
||||||
2. Abre Web Inspector (F12 o Cmd+Option+I)
|
|
||||||
3. Navega a un manga/capítulo
|
|
||||||
4. Inspecciona el HTML para encontrar los nuevos selectores
|
|
||||||
|
|
||||||
### Paso 2: Identificar Selectores Clave
|
|
||||||
|
|
||||||
Busca los siguientes elementos:
|
|
||||||
|
|
||||||
**Para Manga Info:**
|
|
||||||
- Selector del título (ej: `h1`, `.title`, `[class*="title"]`)
|
|
||||||
- Selector de la descripción (ej: `p`, `.description`)
|
|
||||||
- Selector de géneros (ej: `a[href*="/genero/"]`, `.genres a`)
|
|
||||||
- Selector de estado (ej: regex en body o `.status`)
|
|
||||||
- Selector de cover (ej: `.cover img`, `[class*="cover"] img`)
|
|
||||||
|
|
||||||
**Para Capítulos:**
|
|
||||||
- Selector de links (ej: `a[href*="/leer/"]`, `.chapter-link`)
|
|
||||||
- Cómo extraer el número de capítulo (regex o atributo)
|
|
||||||
|
|
||||||
**Para Imágenes:**
|
|
||||||
- Selector de imágenes (ej: `img`, `.page-image img`)
|
|
||||||
- Cómo distinguir imágenes de contenido de UI
|
|
||||||
|
|
||||||
### Paso 3: Actualizar el JavaScript
|
|
||||||
|
|
||||||
En `ManhwaWebScraper.swift`, actualiza el JavaScript en los métodos correspondientes:
|
|
||||||
|
|
||||||
**Ejemplo: Actualizar `scrapeMangaInfo`**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let mangaInfo: [String: Any] = try await webView.evaluateJavaScript("""
|
|
||||||
(function() {
|
|
||||||
// ACTUALIZAR: Nuevo selector de título
|
|
||||||
let title = '';
|
|
||||||
const titleEl = document.querySelector('.nuevo-selector-titulo');
|
|
||||||
if (titleEl) {
|
|
||||||
title = titleEl.textContent?.trim() || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACTUALIZAR: Nueva lógica de descripción
|
|
||||||
let description = '';
|
|
||||||
const descEl = document.querySelector('.nuevo-selector-desc');
|
|
||||||
if (descEl) {
|
|
||||||
description = descEl.textContent?.trim() || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resto de extracciones...
|
|
||||||
return {
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
""") as! [String: Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 4: Probar los Cambios
|
|
||||||
|
|
||||||
1. Compila y ejecuta la app
|
|
||||||
2. Intenta agregar un manga existente
|
|
||||||
3. Verifica que la información se muestre correctamente
|
|
||||||
4. Intenta leer un capítulo
|
|
||||||
5. Verifica que las imágenes carguen
|
|
||||||
|
|
||||||
### Paso 5: Manejo de Errores
|
|
||||||
|
|
||||||
Agrega manejo de errores robusto:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Agregar fallbacks
|
|
||||||
let titleEl = document.querySelector('.nuevo-selector') ||
|
|
||||||
document.querySelector('.selector-respalgo') ||
|
|
||||||
document.querySelector('h1'); // Último recurso
|
|
||||||
|
|
||||||
if (titleEl) {
|
|
||||||
title = titleEl.textContent?.trim() || '';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Consejos para Troubleshooting
|
|
||||||
|
|
||||||
1. **Incrementa el tiempo de espera** si la página carga lento:
|
|
||||||
```swift
|
|
||||||
try await loadURLAndWait(url, waitForImages: true)
|
|
||||||
// Aumenta el tiempo en loadURLAndWait
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verifica que JavaScript esté habilitado** en WKWebView
|
|
||||||
|
|
||||||
3. **Revisa la consola** del WebView agregando logging:
|
|
||||||
```swift
|
|
||||||
webView.evaluateJavaScript("console.log('Debug info')")
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Usa `try?`** en vez de `try!` temporalmente para evitar crashes:
|
|
||||||
```swift
|
|
||||||
let info = try? webView.evaluateJavaScript(...) as? [String: Any]
|
|
||||||
print("Info: \(info ?? [:])")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estándares de Código
|
|
||||||
|
|
||||||
### Swift Style Guide
|
|
||||||
|
|
||||||
Sigue las convenciones de Swift para código limpio y mantenible.
|
|
||||||
|
|
||||||
#### 1. Nomenclatura
|
|
||||||
|
|
||||||
**Clases y Structs**: PascalCase
|
|
||||||
```swift
|
|
||||||
class ManhwaWebScraper { }
|
|
||||||
struct Manga { }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Propiedades y Métodos**: camelCase
|
|
||||||
```swift
|
|
||||||
var mangaTitle: String
|
|
||||||
func scrapeMangaInfo() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Constantes Privadas**: camelCase con prefijo si es necesario
|
|
||||||
```swift
|
|
||||||
private let favoritesKey = "favoriteMangas"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Organización de Código
|
|
||||||
|
|
||||||
Usa MARK comments para organizar:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
class ManhwaWebScraper {
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
private var webView: WKWebView?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
init() { }
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
|
|
||||||
func scrapeMangaInfo() { }
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func loadURLAndWait() { }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Documentación
|
|
||||||
|
|
||||||
Documenta todas las funciones públicas:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
/// Obtiene la lista de capítulos de un manga
|
|
||||||
///
|
|
||||||
/// Este método carga la página del manga en un WKWebView,
|
|
||||||
/// ejecuta JavaScript para extraer los capítulos, y retorna
|
|
||||||
/// un array ordenado de manera descendente.
|
|
||||||
///
|
|
||||||
/// - Parameter mangaSlug: El slug único del manga
|
|
||||||
/// - Returns: Array de capítulos ordenados por número (descendente)
|
|
||||||
/// - Throws: `ScrapingError` si el WebView no está inicializado
|
|
||||||
/// o si falla la extracción de contenido
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```swift
|
|
||||||
/// do {
|
|
||||||
/// let chapters = try await scraper.scrapeChapters(mangaSlug: "one-piece")
|
|
||||||
/// print("Found \(chapters.count) chapters")
|
|
||||||
/// } catch {
|
|
||||||
/// print("Error: \(error)")
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter] {
|
|
||||||
// Implementación
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Async/Await
|
|
||||||
|
|
||||||
Usa async/await para código asíncrono:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BUENO
|
|
||||||
func loadManga() async throws -> Manga {
|
|
||||||
let manga = try await scraper.scrapeMangaInfo(slug: slug)
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
// MAL (evitar completion handlers si es posible)
|
|
||||||
func loadManga(completion: @escaping (Result<Manga, Error>) -> Void) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Error Handling
|
|
||||||
|
|
||||||
Usa errores tipados en vez de genéricos:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BUENO
|
|
||||||
enum ScrapingError: LocalizedError {
|
|
||||||
case webViewNotInitialized
|
|
||||||
case pageLoadFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// MAL
|
|
||||||
func scrape() throws {
|
|
||||||
throw NSError(domain: "Error", code: 1, userInfo: nil)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6. Opcionalidad
|
|
||||||
|
|
||||||
Usa opcionales con cuidado:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BUENO - unwrap seguro
|
|
||||||
if let coverImage = manga.coverImage {
|
|
||||||
// Usar coverImage
|
|
||||||
}
|
|
||||||
|
|
||||||
// BUENO - nil coalescing
|
|
||||||
let title = manga.title ?? "Unknown"
|
|
||||||
|
|
||||||
// MAL - force unwrap (evitar a menos que estés 100% seguro)
|
|
||||||
let image = UIImage(contentsOfFile: path)!
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7. Closures
|
|
||||||
|
|
||||||
Usa trailing closure syntax cuando sea el último parámetro:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// BUENO
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
print("Async code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACEPTABLE
|
|
||||||
DispatchQueue.main.async(execute: {
|
|
||||||
print("Async code")
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Escribir Tests
|
|
||||||
|
|
||||||
Crea tests para nuevas funcionalidades:
|
|
||||||
|
|
||||||
**Ejemplo de Unit Test:**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import XCTest
|
|
||||||
@testable import MangaReader
|
|
||||||
|
|
||||||
class StorageServiceTests: XCTestCase {
|
|
||||||
var storage: StorageService!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
storage = StorageService.shared
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Limpiar después de cada test
|
|
||||||
storage.clearAllDownloads()
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSaveAndRetrieveFavorite() {
|
|
||||||
// Given
|
|
||||||
let slug = "test-manga"
|
|
||||||
|
|
||||||
// When
|
|
||||||
storage.saveFavorite(mangaSlug: slug)
|
|
||||||
let isFavorite = storage.isFavorite(mangaSlug: slug)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
XCTAssertTrue(isFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRemoveFavorite() {
|
|
||||||
// Given
|
|
||||||
let slug = "test-manga"
|
|
||||||
storage.saveFavorite(mangaSlug: slug)
|
|
||||||
|
|
||||||
// When
|
|
||||||
storage.removeFavorite(mangaSlug: slug)
|
|
||||||
let isFavorite = storage.isFavorite(mangaSlug: slug)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
XCTAssertFalse(isFavorite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ejecutar Tests
|
|
||||||
|
|
||||||
1. En Xcode, presiona `Cmd + U` para ejecutar todos los tests
|
|
||||||
2. Para ejecutar un test específico, click en el diamante junto al nombre del test
|
|
||||||
3. Los tests deben ejecutarse en el simulator o en un dispositivo real
|
|
||||||
|
|
||||||
### Cobertura de Código
|
|
||||||
|
|
||||||
Apunta a tener al menos 70% de cobertura de código:
|
|
||||||
|
|
||||||
- Servicios: 80%+
|
|
||||||
- ViewModels: 70%+
|
|
||||||
- Models: 90%+ (son datos simples)
|
|
||||||
- Views: 50%+ (UI testing es más difícil)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pull Request Guidelines
|
|
||||||
|
|
||||||
### Antes de Abrir un PR
|
|
||||||
|
|
||||||
1. **Actualiza tu rama**:
|
|
||||||
```bash
|
|
||||||
git checkout main
|
|
||||||
git pull upstream main
|
|
||||||
git checkout tu-rama
|
|
||||||
git rebase main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Resuelve conflicts** si los hay
|
|
||||||
|
|
||||||
3. **Ejecuta los tests**:
|
|
||||||
```bash
|
|
||||||
xcodebuild test -scheme MangaReader -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Limpia el código**:
|
|
||||||
- Remueve `print()` statements de debugging
|
|
||||||
- Formatea el código (Xcode puede hacerlo automáticamente: Ctrl + I)
|
|
||||||
- Remueve comentarios TODO/FIXME implementados
|
|
||||||
|
|
||||||
### Estructura del PR
|
|
||||||
|
|
||||||
Usa esta plantilla para tu PR:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Descripción
|
|
||||||
Breve descripción de los cambios.
|
|
||||||
|
|
||||||
## Tipo de Cambio
|
|
||||||
- [ ] Bug fix (non-breaking change que arregla un issue)
|
|
||||||
- [ ] New feature (non-breaking change que agrega funcionalidad)
|
|
||||||
- [ ] Breaking change (fix or feature que causaría breaking changes)
|
|
||||||
- [ ] Documentation update
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
Describe los tests que ejecutaste:
|
|
||||||
- [ ] Unit tests pasan
|
|
||||||
- [ ] Manual testing en device/simulator
|
|
||||||
- [ ] Probado en iOS 15, iOS 16, iOS 17
|
|
||||||
|
|
||||||
## Screenshots (si aplica)
|
|
||||||
Before/After screenshots para cambios de UI.
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Mi código sigue los style guidelines
|
|
||||||
- [ ] He realizado self-review de mi código
|
|
||||||
- [ ] He comentado código complejo
|
|
||||||
- [ ] He actualizado la documentación
|
|
||||||
- [ ] No hay nuevos warnings
|
|
||||||
- [ ] He agregado tests que prueban mis cambios
|
|
||||||
- [ ] Todos los tests pasan
|
|
||||||
```
|
|
||||||
|
|
||||||
### Review Process
|
|
||||||
|
|
||||||
1. **Automated Checks**: CI ejecutará tests automáticamente
|
|
||||||
2. **Code Review**: Al menos 1 revisor debe aprobar
|
|
||||||
3. **Testing**: El revisor probará los cambios en un dispositivo
|
|
||||||
4. **Merge**: El maintainer mergeará si todo está bien
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issues Comunes
|
|
||||||
|
|
||||||
#### 1. WebView no carga contenido
|
|
||||||
|
|
||||||
**Síntoma**: `scrapeMangaInfo` retorna datos vacíos
|
|
||||||
|
|
||||||
**Solución**:
|
|
||||||
- Aumenta el tiempo de espera en `loadURLAndWait`
|
|
||||||
- Verifica que la URL sea correcta
|
|
||||||
- Agrega logging para ver qué JavaScript retorna
|
|
||||||
|
|
||||||
#### 2. Tests fallan en CI pero pasan localmente
|
|
||||||
|
|
||||||
**Síntoma**: Tests pasan en tu máquina pero fallan en GitHub Actions
|
|
||||||
|
|
||||||
**Solución**:
|
|
||||||
- Asegúrate de que los tests no dependen de datos locales
|
|
||||||
- Usa mocks en vez de scrapers reales en tests
|
|
||||||
- Verifica que la configuración de iOS sea la misma
|
|
||||||
|
|
||||||
#### 3. Imágenes no cargan
|
|
||||||
|
|
||||||
**Síntoma**: ReaderView muestra placeholders en vez de imágenes
|
|
||||||
|
|
||||||
**Solución**:
|
|
||||||
- Verifica que las URLs sean válidas
|
|
||||||
- Agrega logging en `scrapeChapterImages`
|
|
||||||
- Prueba las URLs en un navegador
|
|
||||||
- Verifica que no haya bloqueo de red
|
|
||||||
|
|
||||||
#### 4. El proyecto no compila
|
|
||||||
|
|
||||||
**Síntoma**: Errores de "Cannot find type" o "No such module"
|
|
||||||
|
|
||||||
**Solución**:
|
|
||||||
1. Limpia el proyecto: `Cmd + Shift + K`
|
|
||||||
2. Cierra Xcode
|
|
||||||
3. Borra `DerivedData`: `rm -rf ~/Library/Developer/Xcode/DerivedData`
|
|
||||||
4. Abre Xcode y rebuild
|
|
||||||
|
|
||||||
### Pedir Ayuda
|
|
||||||
|
|
||||||
Si estás atascado:
|
|
||||||
|
|
||||||
1. **Busca en Issues existentes**: Puede que alguien ya tuvo el mismo problema
|
|
||||||
2. **Crea un Issue** con:
|
|
||||||
- Descripción detallada del problema
|
|
||||||
- Pasos para reproducir
|
|
||||||
- Logs relevantes
|
|
||||||
- Tu entorno (Xcode version, iOS version, macOS version)
|
|
||||||
3. **Únete a Discord/Slack** (si existe) para ayuda en tiempo real
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recursos Adicionales
|
|
||||||
|
|
||||||
### Documentación de Referencia
|
|
||||||
|
|
||||||
- [Swift Language Guide](https://docs.swift.org/swift-book/)
|
|
||||||
- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui)
|
|
||||||
- [WKWebView Reference](https://developer.apple.com/documentation/webkit/wkwebview)
|
|
||||||
- [Swift Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
|
|
||||||
|
|
||||||
### Herramientas Útiles
|
|
||||||
|
|
||||||
- **SwiftLint**: Linter para Swift
|
|
||||||
- **SwiftFormat**: Formateador de código
|
|
||||||
- **SwiftGen**: Generador de código para recursos
|
|
||||||
|
|
||||||
### Comunidad
|
|
||||||
|
|
||||||
- [Swift Forums](https://forums.swift.org/)
|
|
||||||
- [Stack Overflow - swift tag](https://stackoverflow.com/questions/tagged/swift)
|
|
||||||
- [r/swift subreddit](https://www.reddit.com/r/swift/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Última actualización**: Febrero 2026
|
|
||||||
**Versión**: 1.0.0
|
|
||||||
277
docs/README.md
277
docs/README.md
@@ -1,277 +0,0 @@
|
|||||||
# MangaReader - Documentación Técnica
|
|
||||||
|
|
||||||
Bienvenido a la documentación técnica de MangaReader. Este directorio contiene toda la información necesaria para entender, desarrollar y contribuir al proyecto.
|
|
||||||
|
|
||||||
## Documentos Disponibles
|
|
||||||
|
|
||||||
### [ARCHITECTURE.md](./ARCHITECTURE.md)
|
|
||||||
Documentación completa de la arquitectura del sistema.
|
|
||||||
|
|
||||||
**Contenido:**
|
|
||||||
- Visión general del sistema (Backend + iOS App)
|
|
||||||
- Arquitectura MVVM de la app iOS
|
|
||||||
- Diagramas de flujo de datos (scraping, lectura, guardado)
|
|
||||||
- Patrones de diseño implementados (Singleton, MVVM, Repository, etc.)
|
|
||||||
- Diagramas de secuencia en ASCII
|
|
||||||
- Decisiones arquitectónicas y su justificación
|
|
||||||
|
|
||||||
**Para quién es:**
|
|
||||||
- Desarrolladores que necesitan entender la estructura general
|
|
||||||
- Arquitectos que evalúan el diseño del sistema
|
|
||||||
- Nuevos contribuyentes que necesitan orientación
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [API.md](./API.md)
|
|
||||||
Documentación de la API y modelos de datos.
|
|
||||||
|
|
||||||
**Contenido:**
|
|
||||||
- Modelos de datos (Manga, Chapter, MangaPage, ReadingProgress, etc.)
|
|
||||||
- Documentación completa de servicios (ManhwaWebScraper, StorageService)
|
|
||||||
- Descripción de ViewModels y sus responsabilidades
|
|
||||||
- Errores y manejo de excepciones
|
|
||||||
- Ejemplos de uso para cada método público
|
|
||||||
|
|
||||||
**Para quién es:**
|
|
||||||
- Desarrolladores que integran funcionalidades
|
|
||||||
- Equipo QA que necesita entender comportamientos esperados
|
|
||||||
- Contribuyentes que agregan nuevas features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [CONTRIBUTING.md](./CONTRIBUTING.md)
|
|
||||||
Guía para contribuir al proyecto.
|
|
||||||
|
|
||||||
**Contenido:**
|
|
||||||
- Configuración del entorno de desarrollo
|
|
||||||
- Cómo agregar nuevas fuentes de manga
|
|
||||||
- Cómo modificar el scraper existente
|
|
||||||
- Estándares de código (Swift style guide)
|
|
||||||
- Testing (unit tests, integración)
|
|
||||||
- Pull request guidelines
|
|
||||||
- Troubleshooting común
|
|
||||||
|
|
||||||
**Para quién es:**
|
|
||||||
- Nuevos contribuyentes
|
|
||||||
- Desarrolladores que agregan features
|
|
||||||
- Maintainers que revisan PRs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estructura del Proyecto
|
|
||||||
|
|
||||||
```
|
|
||||||
MangaReader/
|
|
||||||
├── docs/ # Documentación técnica
|
|
||||||
│ ├── README.md # Este archivo (índice)
|
|
||||||
│ ├── ARCHITECTURE.md # Arquitectura y diagramas
|
|
||||||
│ ├── API.md # API y modelos de datos
|
|
||||||
│ └── CONTRIBUTING.md # Guía para contribuyentes
|
|
||||||
│
|
|
||||||
├── ios-app/ # Aplicación iOS
|
|
||||||
│ ├── MangaReaderApp.swift # Entry point de la app
|
|
||||||
│ ├── Sources/
|
|
||||||
│ │ ├── Models/ # Modelos de datos
|
|
||||||
│ │ │ └── Manga.swift # Models documentados
|
|
||||||
│ │ ├── Services/ # Lógica de negocio
|
|
||||||
│ │ │ ├── ManhwaWebScraper.swift # Scraper documentado
|
|
||||||
│ │ │ └── StorageService.swift # Storage documentado
|
|
||||||
│ │ └── Views/ # UI SwiftUI
|
|
||||||
│ │ ├── ContentView.swift
|
|
||||||
│ │ ├── MangaDetailView.swift
|
|
||||||
│ │ └── ReaderView.swift
|
|
||||||
│ └── MangaReader.xcodeproj
|
|
||||||
│
|
|
||||||
└── backend/ # Backend opcional (Node.js)
|
|
||||||
├── scraper.js
|
|
||||||
├── server.js
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumen Rápido de Componentes
|
|
||||||
|
|
||||||
### Modelos de Datos
|
|
||||||
- **Manga**: Información completa de un manga
|
|
||||||
- **Chapter**: Capítulo individual con estado de lectura
|
|
||||||
- **MangaPage**: Página individual (imagen)
|
|
||||||
- **ReadingProgress**: Progreso de lectura del usuario
|
|
||||||
- **DownloadedChapter**: Capítulo descargado localmente
|
|
||||||
|
|
||||||
### Servicios
|
|
||||||
- **ManhwaWebScraper**: Scraper usando WKWebView para manhwaweb.com
|
|
||||||
- **StorageService**: Gestión de almacenamiento local (UserDefaults + FileManager)
|
|
||||||
|
|
||||||
### ViewModels
|
|
||||||
- **MangaListViewModel**: Lista principal de mangas
|
|
||||||
- **MangaDetailViewModel**: Detalle de un manga
|
|
||||||
- **ReaderViewModel**: Lector de capítulos
|
|
||||||
|
|
||||||
### Views
|
|
||||||
- **ContentView**: Vista principal con lista de mangas
|
|
||||||
- **MangaDetailView**: Detalle y capítulos de un manga
|
|
||||||
- **ReaderView**: Lector de imágenes con zoom/pan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comenzar Rápidamente
|
|
||||||
|
|
||||||
### Para Entender la Arquitectura
|
|
||||||
1. Lee [ARCHITECTURE.md](./ARCHITECTURE.md) - Sección "Visión General"
|
|
||||||
2. Revisa los diagramas de flujo de datos
|
|
||||||
3. Estudia los patrones de diseño usados
|
|
||||||
|
|
||||||
### Para Usar la API
|
|
||||||
1. Consulta [API.md](./API.md) - Sección "Modelos de Datos"
|
|
||||||
2. Revisa los servicios disponibles
|
|
||||||
3. Mira los ejemplos de uso
|
|
||||||
|
|
||||||
### Para Contribuir
|
|
||||||
1. Lee [CONTRIBUTING.md](./CONTRIBUTING.md) - "Configuración del Entorno"
|
|
||||||
2. Configura tu entorno de desarrollo
|
|
||||||
3. Revisa los estándares de código
|
|
||||||
4. Sigue el workflow de Pull Requests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tecnologías Utilizadas
|
|
||||||
|
|
||||||
### iOS App
|
|
||||||
- **SwiftUI**: Framework de UI declarativo
|
|
||||||
- **Combine**: Programación reactiva
|
|
||||||
- **WKWebView**: Rendering de JavaScript
|
|
||||||
- **UserDefaults**: Almacenamiento de preferencias
|
|
||||||
- **FileManager**: Almacenamiento de archivos
|
|
||||||
|
|
||||||
### Backend (Opcional)
|
|
||||||
- **Node.js**: Runtime de JavaScript
|
|
||||||
- **Express**: Framework web
|
|
||||||
- **Puppeteer**: Headless Chrome automation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Patrones de Diseño Principales
|
|
||||||
|
|
||||||
| Patrón | Implementación | Propósito |
|
|
||||||
|--------|---------------|-----------|
|
|
||||||
| **MVVM** | ViewModels separados de Views | Separar UI de lógica |
|
|
||||||
| **Singleton** | `StorageService.shared`, `ManhwaWebScraper.shared` | Instancia única compartida |
|
|
||||||
| **Repository** | `StorageService` abstrae UserDefaults/FileManager | Interfaz unificada de datos |
|
|
||||||
| **Async/Await** | Métodos `async throws` en scraper | Código asíncrono legible |
|
|
||||||
| **Observable** | `@Published`, `ObservableObject` | Reactividad en SwiftUI |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flujos Principales
|
|
||||||
|
|
||||||
### 1. Agregar Manga
|
|
||||||
```
|
|
||||||
Usuario → ContentView → MangaListViewModel
|
|
||||||
↓
|
|
||||||
ManhwaWebScraper.scrapeMangaInfo()
|
|
||||||
↓
|
|
||||||
WKWebView + JavaScript
|
|
||||||
↓
|
|
||||||
Manga actualizado en UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Leer Capítulo
|
|
||||||
```
|
|
||||||
Usuario → MangaDetailView → ReaderView
|
|
||||||
↓
|
|
||||||
ReaderViewModel.loadPages()
|
|
||||||
↓
|
|
||||||
¿Descargado? ─NO→ ManhwaWebScraper.scrapeChapterImages()
|
|
||||||
│S ↓
|
|
||||||
└─→ StorageService.getDownloadedChapter()
|
|
||||||
↓
|
|
||||||
Mostrar páginas en ReaderView
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Guardar Progreso
|
|
||||||
```
|
|
||||||
Usuario navega a página X
|
|
||||||
↓
|
|
||||||
ReaderViewModel.currentPage = X
|
|
||||||
↓
|
|
||||||
ReaderViewModel.saveProgress()
|
|
||||||
↓
|
|
||||||
StorageService.saveReadingProgress()
|
|
||||||
↓
|
|
||||||
UserDefaults (JSON codificado)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preguntas Frecuentes
|
|
||||||
|
|
||||||
**¿Puedo agregar nuevas fuentes de manga?**
|
|
||||||
Sí. Lee [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Cómo Agregar Nuevas Fuentes de Manga".
|
|
||||||
|
|
||||||
**¿El backend es obligatorio?**
|
|
||||||
No. La app iOS funciona completamente de manera autónoma. El backend es opcional y puede servir como cache/API intermedia.
|
|
||||||
|
|
||||||
**¿Cómo cambio el scraper si manhwaweb.com cambia su estructura?**
|
|
||||||
Consulta [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Cómo Modificar el Scraper".
|
|
||||||
|
|
||||||
**¿Cómo ejecuto los tests?**
|
|
||||||
Ve a [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Testing".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Convenciones de Documentación
|
|
||||||
|
|
||||||
### En Código Swift
|
|
||||||
- **///**: Comentarios de documentación públicos (soportan Markdown)
|
|
||||||
- **//:**: Comentarios de sección (MARK)
|
|
||||||
- **//**: Comentarios de implementación
|
|
||||||
|
|
||||||
### Ejemplo de Documentación de Método
|
|
||||||
```swift
|
|
||||||
/// Obtiene la lista de capítulos de un manga.
|
|
||||||
///
|
|
||||||
/// Este método carga la página del manga, espera a que JavaScript renderice
|
|
||||||
/// el contenido, y extrae todos los links de capítulos disponibles.
|
|
||||||
///
|
|
||||||
/// - Parameter mangaSlug: Slug único del manga
|
|
||||||
/// - Returns: Array de `Chapter` ordenados por número (descendente)
|
|
||||||
/// - Throws: `ScrapingError` si el WebView no está inicializado
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```swift
|
|
||||||
/// let chapters = try await scraper.scrapeChapters(mangaSlug: "one-piece")
|
|
||||||
/// ```
|
|
||||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recursos Adicionales
|
|
||||||
|
|
||||||
### Documentación Oficial
|
|
||||||
- [Swift Language Guide](https://docs.swift.org/swift-book/)
|
|
||||||
- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui)
|
|
||||||
- [WKWebView Reference](https://developer.apple.com/documentation/webkit/wkwebview)
|
|
||||||
|
|
||||||
### Herramientas
|
|
||||||
- **SwiftLint**: Linter para código Swift
|
|
||||||
- **SwiftFormat**: Formateador automático
|
|
||||||
- **Jazzy**: Generador de documentación (Objective-C/Swift)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Soporte y Contribución
|
|
||||||
|
|
||||||
¿Encontraste un error en la documentación? ¿Falta algo?
|
|
||||||
|
|
||||||
1. Abre un issue en el repositorio
|
|
||||||
2. O envía un Pull Request con las mejoras
|
|
||||||
|
|
||||||
Para más detalles sobre cómo contribuir, revisa [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Última actualización**: Febrero 2026
|
|
||||||
**Versión**: 1.0.0
|
|
||||||
**Mantenedor**: MangaReader Team
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# Checklist de Implementación - Sistema de Descarga
|
|
||||||
|
|
||||||
## ✅ Componentes Core
|
|
||||||
|
|
||||||
### DownloadManager
|
|
||||||
- [x] Crear clase `DownloadManager` con patrón Singleton
|
|
||||||
- [x] Implementar `downloadChapter()` con async/await
|
|
||||||
- [x] Implementar `downloadChapters()` para múltiples capítulos
|
|
||||||
- [x] Implementar `downloadImages()` con concurrencia limitada
|
|
||||||
- [x] Implementar `cancelDownload(taskId:)` para cancelación individual
|
|
||||||
- [x] Implementar `cancelAllDownloads()` para cancelación masiva
|
|
||||||
- [x] Crear `DownloadTask` con propiedades @Published
|
|
||||||
- [x] Crear enum `DownloadState` con todos los estados
|
|
||||||
- [x] Crear enum `DownloadError` con tipos de error
|
|
||||||
- [x] Crear `CancellationChecker` para cancelación asíncrona
|
|
||||||
- [x] Integrar con `StorageService` para guardar imágenes
|
|
||||||
- [x] Integrar con `ManhwaWebScraper` para obtener URLs
|
|
||||||
- [x] Implementar manejo robusto de errores
|
|
||||||
- [x] Implementar actualización de progreso en tiempo real
|
|
||||||
- [x] Mantener historial de descargas (completadas y fallidas)
|
|
||||||
- [x] Verificar duplicados antes de descargar
|
|
||||||
|
|
||||||
### MangaDetailView
|
|
||||||
- [x] Añadir botón de descarga en toolbar
|
|
||||||
- [x] Crear alert para seleccionar cantidad de capítulos
|
|
||||||
- [x] Actualizar `ChapterRowView` con botón de descarga
|
|
||||||
- [x] Mostrar progreso de descarga en cada fila
|
|
||||||
- [x] Añadir indicador visual de capítulo descargado
|
|
||||||
- [x] Actualizar `MangaDetailViewModel`:
|
|
||||||
- [x] Integrar `DownloadManager`
|
|
||||||
- [x] Implementar `downloadChapter()` async
|
|
||||||
- [x] Implementar `downloadAllChapters()`
|
|
||||||
- [x] Implementar `downloadLastChapters(count:)`
|
|
||||||
- [x] Implementar `getDownloadProgress(for:)`
|
|
||||||
- [x] Implementar `isDownloadingChapter(_:)`
|
|
||||||
- [x] Implementar notificaciones de estado
|
|
||||||
- [x] Crear overlay de notificaciones
|
|
||||||
- [x] Manejar estados de error y éxito
|
|
||||||
|
|
||||||
### DownloadsView
|
|
||||||
- [x] Crear `DownloadsView` con 3 tabs
|
|
||||||
- [x] Tab "Activas"
|
|
||||||
- [x] Tab "Completadas"
|
|
||||||
- [x] Tab "Fallidas"
|
|
||||||
- [x] Crear `ActiveDownloadCard` con progreso
|
|
||||||
- [x] Crear `CompletedDownloadCard`
|
|
||||||
- [x] Crear `FailedDownloadCard` con reintentar
|
|
||||||
- [x] Implementar `DownloadsViewModel`
|
|
||||||
- [x] Añadir botón "Cancelar todas"
|
|
||||||
- [x] Añadir botones "Limpiar historial"
|
|
||||||
- [x] Mostrar tamaño de almacenamiento
|
|
||||||
- [x] Añadir botón "Limpiar todo" con alert
|
|
||||||
- [x] Crear estados vacíos descriptivos
|
|
||||||
- [x] Implementar picker segmentado para tabs
|
|
||||||
|
|
||||||
## ✅ Extensiones y Utilidades
|
|
||||||
|
|
||||||
### DownloadExtensions
|
|
||||||
- [x] Extensión de `DownloadTask`
|
|
||||||
- [x] `formattedSize` - tamaño estimado
|
|
||||||
- [x] `estimatedTimeRemaining` - tiempo restante
|
|
||||||
- [x] Extensión de `DownloadManager`
|
|
||||||
- [x] `downloadStats` - estadísticas
|
|
||||||
- [x] `hasActiveDownloads` - check de activas
|
|
||||||
- [x] `totalDownloads` - contador total
|
|
||||||
- [x] Extensión de `UIImage`
|
|
||||||
- [x] `compressedData(quality:)` - compresión JPEG
|
|
||||||
- [x] `resized(maxWidth:maxHeight:)` - redimensionado
|
|
||||||
- [x] `optimizedForStorage()` - optimización completa
|
|
||||||
- [x] Crear `DownloadStats` modelo
|
|
||||||
- [x] Definir nombres de notificaciones
|
|
||||||
- [x] Crear `URLSession.downloadSession()`
|
|
||||||
|
|
||||||
## ✅ Integración
|
|
||||||
|
|
||||||
### StorageService
|
|
||||||
- [x] Verificar que `saveImage()` existe y funciona
|
|
||||||
- [x] Verificar que `getImageURL()` existe y funciona
|
|
||||||
- [x] Verificar que `isChapterDownloaded()` existe y funciona
|
|
||||||
- [x] Verificar que `getChapterDirectory()` existe y funciona
|
|
||||||
- [x] Verificar que `deleteDownloadedChapter()` existe y funciona
|
|
||||||
- [x] Verificar que `getStorageSize()` existe y funciona
|
|
||||||
- [x] Verificar que `formatFileSize()` existe y funciona
|
|
||||||
|
|
||||||
### Models
|
|
||||||
- [x] Verificar que `DownloadedChapter` modelo existe
|
|
||||||
- [x] Verificar que `MangaPage` modelo existe
|
|
||||||
- [x] Verificar que `Chapter` modelo tiene propiedades necesarias
|
|
||||||
|
|
||||||
## ✅ UI/UX
|
|
||||||
|
|
||||||
### Notificaciones
|
|
||||||
- [x] Toast notification al completar descarga
|
|
||||||
- [x] Icono verde para éxito
|
|
||||||
- [x] Icono rojo para error
|
|
||||||
- [x] Auto-ocultado después de 3 segundos
|
|
||||||
- [x] Animación desde abajo
|
|
||||||
- [x] Overlay con blur shadow
|
|
||||||
|
|
||||||
### Progreso Visual
|
|
||||||
- [x] ProgressView lineal
|
|
||||||
- [x] Porcentaje numérico
|
|
||||||
- [x] Páginas descargadas/total
|
|
||||||
- [x] Barra animada
|
|
||||||
- [x] Colores significativos (azul descargando, verde completado)
|
|
||||||
|
|
||||||
### Estados de Descarga
|
|
||||||
- [x] Icono para pending (gris)
|
|
||||||
- [x] Icono para downloading (azul animado)
|
|
||||||
- [x] Icono para completed (checkmark verde)
|
|
||||||
- [x] Icono para failed (X rojo)
|
|
||||||
- [x] Icono para cancelled (gris)
|
|
||||||
|
|
||||||
### Estados Vacíos
|
|
||||||
- [x] Icono grande y descriptivo
|
|
||||||
- [x] Mensaje claro
|
|
||||||
- [x] Llamada a la acción si aplica
|
|
||||||
|
|
||||||
## ✅ Manejo de Errores
|
|
||||||
|
|
||||||
### Tipos de Error
|
|
||||||
- [x] `alreadyDownloaded` - Capítulo ya descargado
|
|
||||||
- [x] `noImagesFound` - Scraper no encontró imágenes
|
|
||||||
- [x] `invalidURL` - URL malformada
|
|
||||||
- [x] `invalidResponse` - Respuesta HTTP inválida
|
|
||||||
- [x] `httpError(statusCode)` - Error HTTP específico
|
|
||||||
- [x] `invalidImageData` - Datos no son imagen válida
|
|
||||||
- [x] `cancelled` - Usuario canceló
|
|
||||||
- [x] `storageError(String)` - Error de almacenamiento
|
|
||||||
|
|
||||||
### Recuperación
|
|
||||||
- [x] Limpieza de archivos parciales al cancelar
|
|
||||||
- [x] Mensajes descriptivos al usuario
|
|
||||||
- [x] Logging de errores para debugging
|
|
||||||
- [x] Estado `failed` en FailedDownloadCard
|
|
||||||
- [x] Opción de reintentar (preparado)
|
|
||||||
|
|
||||||
## ✅ Concurrencia y Performance
|
|
||||||
|
|
||||||
### Estrategia de Concurrencia
|
|
||||||
- [x] Usar Swift Concurrency (async/await)
|
|
||||||
- [x] Usar `@MainActor` para UI
|
|
||||||
- [x] Usar `TaskGroup` para descargas en paralelo
|
|
||||||
- [x] Limitar a 3 capítulos simultáneos
|
|
||||||
- [x] Limitar a 5 imágenes simultáneas por capítulo
|
|
||||||
- [x] Usar `CancellationChecker` para cancelación segura
|
|
||||||
|
|
||||||
### Optimizaciones
|
|
||||||
- [x] Comprimir imágenes al 75-80% JPEG
|
|
||||||
- [x] Redimensionar si > 2048px
|
|
||||||
- [x] Concurrencia limitada para evitar picos
|
|
||||||
- [x] Limpieza automática de historiales (50 completadas, 20 fallidas)
|
|
||||||
|
|
||||||
## ✅ Configuración
|
|
||||||
|
|
||||||
### Parámetros
|
|
||||||
- [x] `maxConcurrentDownloads = 3`
|
|
||||||
- [x] `maxConcurrentImagesPerChapter = 5`
|
|
||||||
- [x] JPEG compression quality 0.8
|
|
||||||
- [x] Optimized storage quality 0.75
|
|
||||||
- [x] Max dimension 2048px
|
|
||||||
|
|
||||||
### Timeouts
|
|
||||||
- [x] URLSession request: 30 segundos
|
|
||||||
- [x] URLSession resource: 5 minutos
|
|
||||||
- [x] Scraper page load: 3-5 segundos
|
|
||||||
|
|
||||||
## ✅ Documentación
|
|
||||||
|
|
||||||
### Archivos de Documentación
|
|
||||||
- [x] `DOWNLOAD_SYSTEM_README.md` - Guía completa
|
|
||||||
- [x] `IMPLEMENTATION_SUMMARY.md` - Resumen ejecutivo
|
|
||||||
- [x] `DIAGRAMS.md` - Diagramas de flujo
|
|
||||||
- [x] `IntegrationExample.swift` - Ejemplos de código
|
|
||||||
|
|
||||||
### Código
|
|
||||||
- [x] Comentarios en código complejo
|
|
||||||
- [x] Documentación de métodos públicos
|
|
||||||
- [x] Ejemplos de uso en README
|
|
||||||
|
|
||||||
## ✅ Testing
|
|
||||||
|
|
||||||
### Testing Manual
|
|
||||||
- [x] Verificar descarga de un capítulo
|
|
||||||
- [x] Verificar descarga de múltiples capítulos
|
|
||||||
- [x] Verificar cancelación de descarga
|
|
||||||
- [x] Verificar manejo de errores
|
|
||||||
- [x] Verificar progreso visual
|
|
||||||
- [x] Verificar notificaciones
|
|
||||||
- [x] Verificar limpieza de almacenamiento
|
|
||||||
|
|
||||||
### Casos de Prueba
|
|
||||||
- [ ] Descargar capítulo sin internet
|
|
||||||
- [ ] Descargar capítulo ya descargado
|
|
||||||
- [ ] Cancelar descarga a mitad
|
|
||||||
- [ ] Descargar capítulo con 0 imágenes
|
|
||||||
- [ ] Llenar almacenamiento del dispositivo
|
|
||||||
- [ ] Probar con diferentes tamaños de capítulo
|
|
||||||
- [ ] Probar concurrentemente múltiples descargas
|
|
||||||
|
|
||||||
## 📋 Próximos Pasos (Opcionales)
|
|
||||||
|
|
||||||
### Mejoras Futuras
|
|
||||||
- [ ] Background downloads con URLSession
|
|
||||||
- [ ] Reanudar descargas pausadas
|
|
||||||
- [ ] Priorización de descargas
|
|
||||||
- [ ] Descarga automática de nuevos capítulos
|
|
||||||
- [ ] Soporte para formato WebP
|
|
||||||
- [ ] Batch operations en StorageService
|
|
||||||
- [ ] Metrics y analytics
|
|
||||||
|
|
||||||
### Testing Automatizado
|
|
||||||
- [ ] Unit tests para DownloadManager
|
|
||||||
- [ ] Integration tests
|
|
||||||
- [ ] UI tests para DownloadsView
|
|
||||||
- [ ] Performance tests
|
|
||||||
- [ ] Memory leak tests con XCTest
|
|
||||||
|
|
||||||
### UI Adicional
|
|
||||||
- [ ] SettingsView con preferencias de descarga
|
|
||||||
- [ ] ActiveDownloadsWidget para home
|
|
||||||
- [ ] ActiveDownloadsBanner modifier
|
|
||||||
- [ ] Badge en TabView
|
|
||||||
- [ ] Sheet para descargas desde cualquier vista
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Estadísticas de Implementación
|
|
||||||
|
|
||||||
**Fecha**: 2026-02-04
|
|
||||||
**Versión**: 1.0
|
|
||||||
**Estado**: ✅ COMPLETO
|
|
||||||
|
|
||||||
### Archivos
|
|
||||||
- **Nuevos**: 5 archivos principales
|
|
||||||
- **Modificados**: 2 archivos existentes
|
|
||||||
- **Total de líneas**: ~1,500 líneas
|
|
||||||
|
|
||||||
### Tiempos
|
|
||||||
- **Desarrollo**: 4-6 horas
|
|
||||||
- **Testing**: 1-2 horas
|
|
||||||
- **Documentación**: 2-3 horas
|
|
||||||
- **Total**: 7-11 horas
|
|
||||||
|
|
||||||
### Cobertura
|
|
||||||
- **DownloadManager**: 100% completo
|
|
||||||
- **MangaDetailView**: 100% completo
|
|
||||||
- **DownloadsView**: 100% completo
|
|
||||||
- **Extensiones**: 100% completo
|
|
||||||
- **Integración**: 100% completo
|
|
||||||
- **Documentación**: 100% completo
|
|
||||||
|
|
||||||
## 🎉 Checklist Final
|
|
||||||
|
|
||||||
- [x] Todos los componentes core implementados
|
|
||||||
- [x] UI/UX pulida y funcional
|
|
||||||
- [x] Manejo de errores robusto
|
|
||||||
- [x] Concurrencia optimizada
|
|
||||||
- [x] Integración completa con servicios existentes
|
|
||||||
- [x] Documentación exhaustiva
|
|
||||||
- [x] Ejemplos de integración
|
|
||||||
- [x] Diagramas de flujo
|
|
||||||
- [x] Testing manual completado
|
|
||||||
- [x] Código limpio y mantenible
|
|
||||||
|
|
||||||
**ESTADO FINAL**: ✅ LISTO PARA PRODUCCIÓN
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
# API Configuration for MangaReader iOS App
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This directory contains the API configuration for connecting the iOS app to the VPS backend. The configuration is centralized in `APIConfig.swift` and includes all necessary settings for API communication.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- **APIConfig.swift**: Main configuration file with all API settings, endpoints, and helper methods
|
|
||||||
- **APIConfigExample.swift**: Comprehensive usage examples and demonstrations
|
|
||||||
- **README.md** (this file): Documentation and usage guide
|
|
||||||
|
|
||||||
## Current Configuration
|
|
||||||
|
|
||||||
### Server Connection
|
|
||||||
- **Server URL**: `https://gitea.cbcren.online`
|
|
||||||
- **Port**: `3001`
|
|
||||||
- **Full Base URL**: `https://gitea.cbcren.online:3001`
|
|
||||||
- **API Version**: `v1`
|
|
||||||
- **API Base Path**: `https://gitea.cbcren.online:3001/api/v1`
|
|
||||||
|
|
||||||
### Timeouts
|
|
||||||
- **Default Request Timeout**: `30.0` seconds (for regular API calls)
|
|
||||||
- **Resource Download Timeout**: `300.0` seconds (5 minutes, for large downloads)
|
|
||||||
|
|
||||||
### Retry Policy
|
|
||||||
- **Max Retries**: `3` attempts
|
|
||||||
- **Base Retry Delay**: `1.0` second (with exponential backoff)
|
|
||||||
|
|
||||||
### Cache Configuration
|
|
||||||
- **Max Memory Usage**: `100` cached responses
|
|
||||||
- **Cache Expiry**: `300.0` seconds (5 minutes)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic URL Construction
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Method 1: Use the helper function
|
|
||||||
let url = APIConfig.url(for: "manga/popular")
|
|
||||||
// Result: "https://gitea.cbcren.online:3001/manga/popular"
|
|
||||||
|
|
||||||
// Method 2: Get a URL object
|
|
||||||
if let urlObj = APIConfig.urlObject(for: "manga/popular") {
|
|
||||||
var request = URLRequest(url: urlObj)
|
|
||||||
// Make request...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Use predefined endpoints
|
|
||||||
let endpoint = APIConfig.Endpoints.download(mangaSlug: "one-piece", chapterNumber: 1089)
|
|
||||||
// Result: "https://gitea.cbcren.online:3001/api/v1/download/one-piece/1089"
|
|
||||||
```
|
|
||||||
|
|
||||||
### URLSession Configuration
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
configuration.timeoutIntervalForRequest = APIConfig.defaultTimeout
|
|
||||||
configuration.timeoutIntervalForResource = APIConfig.downloadTimeout
|
|
||||||
let session = URLSession(configuration: configuration)
|
|
||||||
```
|
|
||||||
|
|
||||||
### URLRequest with Headers
|
|
||||||
|
|
||||||
```swift
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.timeoutInterval = APIConfig.defaultTimeout
|
|
||||||
|
|
||||||
// Add common headers
|
|
||||||
for (key, value) in APIConfig.commonHeaders {
|
|
||||||
request.setValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authentication if needed
|
|
||||||
if let token = authToken {
|
|
||||||
let authHeaders = APIConfig.authHeader(token: token)
|
|
||||||
for (key, value) in authHeaders {
|
|
||||||
request.setValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Endpoints
|
|
||||||
|
|
||||||
### Download Endpoints
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Request chapter download
|
|
||||||
APIConfig.Endpoints.download(mangaSlug: "one-piece", chapterNumber: 1089)
|
|
||||||
|
|
||||||
// Check if chapter is downloaded
|
|
||||||
APIConfig.Endpoints.checkDownloaded(mangaSlug: "one-piece", chapterNumber: 1089)
|
|
||||||
|
|
||||||
// List all downloaded chapters for a manga
|
|
||||||
APIConfig.Endpoints.listChapters(mangaSlug: "one-piece")
|
|
||||||
|
|
||||||
// Get specific image from chapter
|
|
||||||
APIConfig.Endpoints.getImage(mangaSlug: "one-piece", chapterNumber: 1089, pageIndex: 0)
|
|
||||||
|
|
||||||
// Delete a chapter
|
|
||||||
APIConfig.Endpoints.deleteChapter(mangaSlug: "one-piece", chapterNumber: 1089)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Endpoints
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Get storage statistics
|
|
||||||
APIConfig.Endpoints.storageStats()
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
APIConfig.Endpoints.health()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
The configuration includes presets for different environments:
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```swift
|
|
||||||
APIConfig.development
|
|
||||||
// - serverURL: "http://192.168.1.100"
|
|
||||||
// - port: 3001
|
|
||||||
// - timeout: 60.0s
|
|
||||||
// - logging: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Staging
|
|
||||||
```swift
|
|
||||||
APIConfig.staging
|
|
||||||
// - serverURL: "https://staging.cbcren.online"
|
|
||||||
// - port: 3001
|
|
||||||
// - timeout: 30.0s
|
|
||||||
// - logging: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (Current)
|
|
||||||
```swift
|
|
||||||
APIConfig.production
|
|
||||||
// - serverURL: "https://gitea.cbcren.online"
|
|
||||||
// - port: 3001
|
|
||||||
// - timeout: 30.0s
|
|
||||||
// - logging: false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing (Debug Only)
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
APIConfig.testing
|
|
||||||
// - serverURL: "http://localhost:3001"
|
|
||||||
// - port: 3001
|
|
||||||
// - timeout: 5.0s
|
|
||||||
// - logging: true
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
## Changing the Server URL
|
|
||||||
|
|
||||||
To change the API server URL, modify the `serverURL` property in `APIConfig.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// In APIConfig.swift, line 37
|
|
||||||
static let serverURL = "https://gitea.cbcren.online" // Change this
|
|
||||||
```
|
|
||||||
|
|
||||||
For environment-specific URLs, use compile-time conditionals:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
static let serverURL = "http://192.168.1.100" // Local development
|
|
||||||
#else
|
|
||||||
static let serverURL = "https://gitea.cbcren.online" // Production
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Codes
|
|
||||||
|
|
||||||
The API defines specific error codes for different scenarios:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
APIConfig.ErrorCodes.chapterNotFound // 40401
|
|
||||||
APIConfig.ErrorCodes.chapterAlreadyDownloaded // 40901
|
|
||||||
APIConfig.ErrorCodes.storageLimitExceeded // 50701
|
|
||||||
APIConfig.ErrorCodes.invalidImageFormat // 42201
|
|
||||||
APIConfig.ErrorCodes.downloadFailed // 50001
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
The configuration includes a validation method:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
if APIConfig.isValid {
|
|
||||||
print("Configuration is valid")
|
|
||||||
} else {
|
|
||||||
print("Configuration is invalid")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This checks:
|
|
||||||
- Server URL is not empty
|
|
||||||
- Port is in valid range (1-65535)
|
|
||||||
- Timeout values are positive
|
|
||||||
- Retry count is non-negative
|
|
||||||
|
|
||||||
## Debug Support
|
|
||||||
|
|
||||||
In debug builds, you can print the current configuration:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
APIConfig.printConfiguration()
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
This outputs:
|
|
||||||
```
|
|
||||||
=== API Configuration ===
|
|
||||||
Server URL: https://gitea.cbcren.online
|
|
||||||
Port: 3001
|
|
||||||
Base URL: https://gitea.cbcren.online:3001
|
|
||||||
API Version: v1
|
|
||||||
Default Timeout: 30.0s
|
|
||||||
Download Timeout: 300.0s
|
|
||||||
Max Retries: 3
|
|
||||||
Logging Enabled: false
|
|
||||||
Cache Enabled: true
|
|
||||||
=========================
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always use predefined endpoints** when available instead of manually constructing URLs
|
|
||||||
2. **Use appropriate timeouts** - `defaultTimeout` for regular calls, `downloadTimeout` for large downloads
|
|
||||||
3. **Validate configuration** on app startup
|
|
||||||
4. **Use the helper methods** (`url()`, `urlObject()`) for URL construction
|
|
||||||
5. **Include common headers** in all requests
|
|
||||||
6. **Handle specific error codes** defined in `APIConfig.ErrorCodes`
|
|
||||||
7. **Enable logging only in debug builds** for security
|
|
||||||
|
|
||||||
## Example: Making an API Call
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func fetchPopularManga() async throws -> [Manga] {
|
|
||||||
// Construct URL
|
|
||||||
guard let url = APIConfig.urlObject(for: "manga/popular") else {
|
|
||||||
throw APIError.invalidURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.timeoutInterval = APIConfig.defaultTimeout
|
|
||||||
|
|
||||||
// Add headers
|
|
||||||
for (key, value) in APIConfig.commonHeaders {
|
|
||||||
request.setValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make request
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
// Validate response
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
|
||||||
httpResponse.statusCode == 200 else {
|
|
||||||
throw APIError.requestFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode response
|
|
||||||
let mangas = try JSONDecoder().decode([Manga].self, from: data)
|
|
||||||
return mangas
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example: Downloading with Retry
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func downloadChapterWithRetry(
|
|
||||||
mangaSlug: String,
|
|
||||||
chapterNumber: Int
|
|
||||||
) async throws -> Data {
|
|
||||||
let endpoint = APIConfig.Endpoints.download(
|
|
||||||
mangaSlug: mangaSlug,
|
|
||||||
chapterNumber: chapterNumber
|
|
||||||
)
|
|
||||||
|
|
||||||
return try await fetchWithRetry(endpoint: endpoint, retryCount: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchWithRetry(endpoint: String, retryCount: Int) async throws -> Data {
|
|
||||||
guard let url = URL(string: endpoint),
|
|
||||||
retryCount < APIConfig.maxRetries else {
|
|
||||||
throw APIError.retryLimitExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.timeoutInterval = APIConfig.downloadTimeout
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse,
|
|
||||||
httpResponse.statusCode == 200 {
|
|
||||||
return data
|
|
||||||
} else {
|
|
||||||
throw APIError.requestFailed
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Calculate exponential backoff delay
|
|
||||||
let delay = APIConfig.baseRetryDelay * pow(2.0, Double(retryCount))
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
||||||
|
|
||||||
return try await fetchWithRetry(endpoint: endpoint, retryCount: retryCount + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Connection Issues
|
|
||||||
|
|
||||||
1. **Verify server URL**: Check that `serverURL` is correct and accessible
|
|
||||||
2. **Check port**: Ensure `port` matches the backend server configuration
|
|
||||||
3. **Test connectivity**: Use the health endpoint: `APIConfig.Endpoints.health()`
|
|
||||||
4. **Enable logging**: Set `loggingEnabled = true` to see request details
|
|
||||||
|
|
||||||
### Timeout Issues
|
|
||||||
|
|
||||||
1. **For regular API calls**: Use `APIConfig.defaultTimeout` (30 seconds)
|
|
||||||
2. **For large downloads**: Use `APIConfig.downloadTimeout` (300 seconds)
|
|
||||||
3. **Slow networks**: Increase timeout values if needed
|
|
||||||
|
|
||||||
### SSL Certificate Issues
|
|
||||||
|
|
||||||
If using HTTPS with a self-signed certificate:
|
|
||||||
1. Add the certificate to the app's bundle
|
|
||||||
2. Configure URLSession to trust the certificate
|
|
||||||
3. Or use HTTP for development (not recommended for production)
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
When migrating from the old configuration:
|
|
||||||
|
|
||||||
1. Replace hardcoded URLs with `APIConfig.url(for:)` or predefined endpoints
|
|
||||||
2. Use `APIConfig.commonHeaders` instead of manually setting headers
|
|
||||||
3. Replace hardcoded timeouts with `APIConfig.defaultTimeout` or `APIConfig.downloadTimeout`
|
|
||||||
4. Add validation on app startup with `APIConfig.isValid`
|
|
||||||
5. Use specific error codes from `APIConfig.ErrorCodes`
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- See `APIConfigExample.swift` for more comprehensive examples
|
|
||||||
- Check the backend API documentation for available endpoints
|
|
||||||
- Review the iOS app's Services directory for integration examples
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
# Diagramas del Sistema de Descarga
|
|
||||||
|
|
||||||
## 1. Arquitectura General
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ UI Layer │
|
|
||||||
├──────────────────────┬──────────────────┬───────────────────┤
|
|
||||||
│ MangaDetailView │ DownloadsView │ ReaderView │
|
|
||||||
│ - Download buttons │ - Active tab │ - Read offline │
|
|
||||||
│ - Progress bars │ - Completed tab │ - Use local URLs │
|
|
||||||
│ - Notifications │ - Failed tab │ │
|
|
||||||
└──────────┬───────────┴──────────┬───────┴─────────┬─────────┘
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Presentation Layer │
|
|
||||||
├──────────────────────┬──────────────────┬───────────────────┤
|
|
||||||
│ MangaDetailViewModel│ DownloadsViewModel│ │
|
|
||||||
│ - downloadChapter() │ - clearAllStorage│ │
|
|
||||||
│ - downloadChapters()│ - showClearAlert│ │
|
|
||||||
└──────────┬──────────────────────────────┴───────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Business Layer │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ DownloadManager │
|
|
||||||
│ - downloadChapter() │
|
|
||||||
│ - downloadChapters() │
|
|
||||||
│ - cancelDownload() │
|
|
||||||
│ - cancelAllDownloads() │
|
|
||||||
│ - downloadImages() │
|
|
||||||
└──────────┬──────────────────────┬───────────────────────────┘
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌────────────────────────┐ ┌────────────────────────────────┐
|
|
||||||
│ Scraper Layer │ │ Storage Layer │
|
|
||||||
├────────────────────────┤ ├────────────────────────────────┤
|
|
||||||
│ ManhwaWebScraper │ │ StorageService │
|
|
||||||
│ - scrapeChapters() │ │ - saveImage() │
|
|
||||||
│ - scrapeChapterImages()│ │ - getImageURL() │
|
|
||||||
│ - scrapeMangaInfo() │ │ - isChapterDownloaded() │
|
|
||||||
│ │ │ - getChapterDirectory() │
|
|
||||||
│ │ │ - deleteDownloadedChapter() │
|
|
||||||
└────────────────────────┘ └────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Network Layer │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ URLSession │
|
|
||||||
│ - downloadImage(from: URL) │
|
|
||||||
│ - data(from: URL) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Flujo de Descarga Detallado
|
|
||||||
|
|
||||||
```
|
|
||||||
USUARIO TOCA "DESCARGAR CAPÍTULO"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ MangaDetailView │
|
|
||||||
│ Button tapped → downloadChapter() │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ MangaDetailViewModel │
|
|
||||||
│ 1. Verificar si ya está descargado │
|
|
||||||
│ 2. Llamar downloadManager.downloadChapter()│
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ DownloadManager │
|
|
||||||
│ 1. Verificar duplicados │
|
|
||||||
│ 2. Crear DownloadTask (state: .pending) │
|
|
||||||
│ 3. Agregar a activeDownloads[] │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ ManhwaWebScraper │
|
|
||||||
│ scrapeChapterImages(chapterSlug) │
|
|
||||||
│ → Retorna [String] URLs de imágenes │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ DownloadManager │
|
|
||||||
│ 4. Actualizar task.imageURLs │
|
|
||||||
│ 5. Iniciar downloadImages() │
|
|
||||||
│ Task 1: state = .downloading(0.0) │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ downloadImages() - CONCURRENCIA │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ TaskGroup (max 5 concurrent) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
|
|
||||||
│ │ │Img 0│ │Img 1│ │Img 2│ │Img 3│... │ │
|
|
||||||
│ │ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ ▼ ▼ ▼ ▼ │ │
|
|
||||||
│ │ ┌───────────────────────────────┐ │ │
|
|
||||||
│ │ │ downloadImage(from: URL) │ │ │
|
|
||||||
│ │ │ 1. URLSession.data(from:) │ │ │
|
|
||||||
│ │ │ 2. Validar HTTP 200 │ │ │
|
|
||||||
│ │ │ 3. UIImage(data:) │ │ │
|
|
||||||
│ │ │ 4. optimizedForStorage() │ │ │
|
|
||||||
│ │ └───────────────┬───────────────┘ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ▼ │ │
|
|
||||||
│ │ ┌───────────────────────────────┐ │ │
|
|
||||||
│ │ │ StorageService.saveImage() │ │ │
|
|
||||||
│ │ │ → Documents/Chapters/... │ │ │
|
|
||||||
│ │ └───────────────┬───────────────┘ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ▼ │ │
|
|
||||||
│ │ ┌───────────────────────────────┐ │ │
|
|
||||||
│ │ │ task.updateProgress() │ │ │
|
|
||||||
│ │ │ downloadedPages += 1 │ │ │
|
|
||||||
│ │ │ progress = new value │ │ │
|
|
||||||
│ │ │ @Published → UI updates │ │ │
|
|
||||||
│ │ └───────────────────────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Repetir para todas las imágenes... │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ DownloadManager │
|
|
||||||
│ 6. Todas las imágenes descargadas │
|
|
||||||
│ 7. Crear DownloadedChapter metadata │
|
|
||||||
│ 8. storage.saveDownloadedChapter() │
|
|
||||||
│ 9. task.complete() → state = .completed │
|
|
||||||
│ 10. Mover de activeDownloads[] a │
|
|
||||||
│ completedDownloads[] │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ MangaDetailViewModel │
|
|
||||||
│ 11. showDownloadCompletionNotification() │
|
|
||||||
│ 12. "1 capítulo(s) descargado(s)" │
|
|
||||||
│ 13. loadChapters() para actualizar UI │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ UI ACTUALIZADA │
|
|
||||||
│ - ChapterRow muestra checkmark verde │
|
|
||||||
│ - Toast notification aparece │
|
|
||||||
│ - DownloadsView actualiza │
|
|
||||||
└───────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Estados de una Descarga
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ESTADOS DE DESCARGA │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
PENDING
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ state: .pending │
|
|
||||||
│ downloadedPages: 0 │
|
|
||||||
│ progress: 0.0 │
|
|
||||||
│ UI: Icono gris │
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│ Usuario inicia descarga
|
|
||||||
▼
|
|
||||||
DOWNLOADING
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ state: .downloading │
|
|
||||||
│ downloadedPages: N │ ← Incrementando
|
|
||||||
│ progress: N/Total │ ← 0.0 a 1.0
|
|
||||||
│ UI: Barra azul │ ← Animando
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│
|
|
||||||
├──────────────────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
COMPLETADO CANCELADO/ERROR
|
|
||||||
┌──────────────────┐ ┌──────────────────────┐
|
|
||||||
│ state: .completed│ │ state: .cancelled │
|
|
||||||
│ downloadedPages: │ │ state: .failed(error)│
|
|
||||||
│ Total │ │ downloadedPages: N │
|
|
||||||
│ progress: 1.0 │ │ progress: N/Total │
|
|
||||||
│ UI: Checkmark │ │ UI: X rojo / Icono │
|
|
||||||
└──────────────────┘ └──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Cancelación de Descarga
|
|
||||||
|
|
||||||
```
|
|
||||||
USUARIO TOCA "CANCELAR"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ DownloadManager.cancelDownload(taskId) │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ 1. Encontrar task en activeDownloads[] │
|
|
||||||
│ 2. task.cancel() │
|
|
||||||
│ → cancellationToken.isCancelled = true│
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ TaskGroup detecta cancelación │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ if task.isCancelled { │ │
|
|
||||||
│ │ throw DownloadError.cancelled │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ downloadImages() lanza error │
|
|
||||||
│ → Catch block ejecuta cleanup │
|
|
||||||
└───────────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ LIMPIEZA │
|
|
||||||
│ 1. Remover de activeDownloads[] │
|
|
||||||
│ 2. storage.deleteDownloadedChapter() │
|
|
||||||
│ → Eliminar imágenes parciales │
|
|
||||||
│ 3. NO agregar a completed[] │
|
|
||||||
└───────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ UI ACTUALIZADA │
|
|
||||||
│ - Progress bar desaparece │
|
|
||||||
│ - Icono de descarga restaurado │
|
|
||||||
└───────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Concurrencia de Descargas
|
|
||||||
|
|
||||||
```
|
|
||||||
NIVEL 1: Descarga de Capítulos (max 3 simultáneos)
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ downloadManager.downloadChapters([ch1, ch2, ch3, ch4...]) │
|
|
||||||
└───────────────┬─────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ TaskGroup (limitado a 3 tasks) │
|
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ Chapter 1 │ │ Chapter 2 │ │ Chapter 3 │ ← Active │
|
|
||||||
│ │ downloading│ │ downloading│ │ downloading│ │
|
|
||||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
|
|
||||||
│ │ Chapter 4 │ │ Chapter 5 │ │ Chapter 6 │ ← Waiting │
|
|
||||||
│ │ waiting │ │ waiting │ │ waiting │ │
|
|
||||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Cuando Chapter 1 completa → Chapter 4 inicia │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
NIVEL 2: Descarga de Imágenes (max 5 simultáneas por capítulo)
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Chapter 1: downloadImages([img0, img1, ... img50]) │
|
|
||||||
└───────────────┬─────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ TaskGroup (limitado a 5 tasks) │
|
|
||||||
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
|
||||||
│ │img0│ │img1│ │img2│ │img3│ │img4│ ← Descargando │
|
|
||||||
│ └─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘ │
|
|
||||||
│ │ │ │ │ │ │
|
|
||||||
│ ┌─▼──────▼──────▼──────▼──────▼──┐ │
|
|
||||||
│ │ img5, img6, img7, img8, img9...│ ← Waiting │
|
|
||||||
│ └─────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Cuando img0-4 completan → img5-9 inician │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
RESULTADO: Máximo 15 imágenes descargando simultáneamente
|
|
||||||
(3 capítulos × 5 imágenes)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Gestión de Errores
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ TIPOS DE ERROR │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
NETWORK ERRORS
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ - Timeout (30s) │ → Reintentar automáticamente
|
|
||||||
│ - No internet │ → Error al usuario
|
|
||||||
│ - HTTP 4xx, 5xx │ → Error específico
|
|
||||||
└──────────────────────┘
|
|
||||||
|
|
||||||
SCRAPER ERRORS
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ - No images found │ → Error: "No se encontraron imágenes"
|
|
||||||
│ - Page load failed │ → Error: "Error al cargar página"
|
|
||||||
│ - Parsing error │ → Error: "Error al procesar"
|
|
||||||
└──────────────────────┘
|
|
||||||
|
|
||||||
STORAGE ERRORS
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ - No space left │ → Error: "Espacio insuficiente"
|
|
||||||
│ - Permission denied │ → Error: "Sin permisos"
|
|
||||||
│ - Disk write error │ → Error: "Error de escritura"
|
|
||||||
└──────────────────────┘
|
|
||||||
|
|
||||||
VALIDATION ERRORS
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ - Already downloaded │ → Skip o sobrescribir
|
|
||||||
│ - Invalid URL │ → Error: "URL inválida"
|
|
||||||
│ - Invalid image data │ → Error: "Imagen inválida"
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Sincronización de UI
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ @Published PROPERTIES │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
DownloadManager
|
|
||||||
┌───────────────────────────────────┐
|
|
||||||
│ @Published var activeDownloads │ → Vista observa
|
|
||||||
│ @Published var completedDownloads │ → Vista observa
|
|
||||||
│ @Published var failedDownloads │ → Vista observa
|
|
||||||
│ @Published var totalProgress │ → Vista observa
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ @Published cambia
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────┐
|
|
||||||
│ SwiftUI View se re-renderiza │
|
|
||||||
│ automáticamente │
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
|
|
||||||
DownloadTask
|
|
||||||
┌───────────────────────────────────┐
|
|
||||||
│ @Published var state │ → Card observa
|
|
||||||
│ @Published var downloadedPages │ → ProgressView observa
|
|
||||||
│ @Published var progress │ → ProgressView observa
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ @Published cambia
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────┐
|
|
||||||
│ ActiveDownloadCard se actualiza │
|
|
||||||
│ automáticamente │
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Estructura de Archivos
|
|
||||||
|
|
||||||
```
|
|
||||||
Documents/
|
|
||||||
└── Chapters/
|
|
||||||
└── {mangaSlug}/
|
|
||||||
└── Chapter{chapterNumber}/
|
|
||||||
├── page_0.jpg
|
|
||||||
├── page_1.jpg
|
|
||||||
├── page_2.jpg
|
|
||||||
├── ...
|
|
||||||
└── page_N.jpg
|
|
||||||
|
|
||||||
Ejemplo:
|
|
||||||
Documents/
|
|
||||||
└── Chapters/
|
|
||||||
└── one-piece_1695365223767/
|
|
||||||
└── Chapter1/
|
|
||||||
├── page_0.jpg (150 KB)
|
|
||||||
├── page_1.jpg (180 KB)
|
|
||||||
├── page_2.jpg (165 KB)
|
|
||||||
└── ...
|
|
||||||
└── Chapter2/
|
|
||||||
├── page_0.jpg
|
|
||||||
├── page_1.jpg
|
|
||||||
└── ...
|
|
||||||
|
|
||||||
metadata.json
|
|
||||||
{
|
|
||||||
"downloadedChapters": [
|
|
||||||
{
|
|
||||||
"id": "one-piece_1695365223767-chapter1",
|
|
||||||
"mangaSlug": "one-piece_1695365223767",
|
|
||||||
"mangaTitle": "One Piece",
|
|
||||||
"chapterNumber": 1,
|
|
||||||
"pages": [...],
|
|
||||||
"downloadedAt": "2026-02-04T10:30:00Z",
|
|
||||||
"totalSize": 5242880
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
# Sistema de Descarga de Capítulos - Resumen de Implementación
|
|
||||||
|
|
||||||
## Archivos Creados/Modificados
|
|
||||||
|
|
||||||
### Archivos Nuevos Creados
|
|
||||||
|
|
||||||
1. **`/Sources/Services/DownloadManager.swift`** (470 líneas)
|
|
||||||
- Clase principal `DownloadManager` con patrón Singleton
|
|
||||||
- `DownloadTask`: Representa una tarea de descarga individual
|
|
||||||
- `DownloadState`: Enum con estados de descarga
|
|
||||||
- `DownloadProgress`: Modelo de progreso
|
|
||||||
- `CancellationChecker`: Sistema de cancelación asíncrona
|
|
||||||
- `DownloadError`: Tipos de errores específicos
|
|
||||||
|
|
||||||
2. **`/Sources/Views/DownloadsView.swift`** (350 líneas)
|
|
||||||
- Vista principal de gestión de descargas
|
|
||||||
- 3 tabs: Activas, Completadas, Fallidas
|
|
||||||
- `ActiveDownloadCard`: Card con progreso en tiempo real
|
|
||||||
- `CompletedDownloadCard`: Card de descargas exitosas
|
|
||||||
- `FailedDownloadCard`: Card con opción de reintentar
|
|
||||||
- `DownloadsViewModel`: ViewModel para la vista
|
|
||||||
|
|
||||||
3. **`/Sources/Extensions/DownloadExtensions.swift`** (180 líneas)
|
|
||||||
- Extensiones de `DownloadTask` para formateo
|
|
||||||
- Extensiones de `DownloadManager` para estadísticas
|
|
||||||
- Extensiones de `UIImage` para compresión y optimización
|
|
||||||
- Constantes de notificaciones
|
|
||||||
- `DownloadStats` modelo
|
|
||||||
|
|
||||||
4. **`/Sources/Examples/IntegrationExample.swift`** (250 líneas)
|
|
||||||
- Ejemplos de integración con TabView
|
|
||||||
- Ejemplo de navegación desde MangaDetailView
|
|
||||||
- Badge en TabView para descargas activas
|
|
||||||
- Sheet para descargas
|
|
||||||
- Vista de configuración
|
|
||||||
- Widget de descargas activas
|
|
||||||
- Modificador de banner
|
|
||||||
|
|
||||||
5. **`/Sources/Services/DOWNLOAD_SYSTEM_README.md`** (400 líneas)
|
|
||||||
- Documentación completa del sistema
|
|
||||||
- Guía de uso de todos los componentes
|
|
||||||
- Ejemplos de código
|
|
||||||
- Configuración y parámetros
|
|
||||||
- Best practices
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
### Archivos Modificados
|
|
||||||
|
|
||||||
1. **`/Sources/Views/MangaDetailView.swift`**
|
|
||||||
- Actualizado `ChapterRowView` para mostrar progreso de descarga
|
|
||||||
- Añadido botón de descarga individual por capítulo
|
|
||||||
- Actualizado `MangaDetailViewModel`:
|
|
||||||
- Integración con `DownloadManager`
|
|
||||||
- Métodos para descargar capítulos
|
|
||||||
- Notificaciones de completado/error
|
|
||||||
- Seguimiento de progreso
|
|
||||||
- Añadido overlay de notificaciones
|
|
||||||
|
|
||||||
2. **`/Sources/Models/Manga.swift`** (sin cambios)
|
|
||||||
- Ya contiene los modelos necesarios:
|
|
||||||
- `DownloadedChapter`
|
|
||||||
- `ReadingProgress`
|
|
||||||
- `MangaPage`
|
|
||||||
|
|
||||||
3. **`/Sources/Services/StorageService.swift`** (sin cambios)
|
|
||||||
- Ya contiene métodos necesarios:
|
|
||||||
- `saveImage()`
|
|
||||||
- `getImageURL()`
|
|
||||||
- `isChapterDownloaded()`
|
|
||||||
- `getChapterDirectory()`
|
|
||||||
- `getStorageSize()`
|
|
||||||
|
|
||||||
## Características Implementadas
|
|
||||||
|
|
||||||
### 1. DownloadManager (Gerente de Descargas)
|
|
||||||
- ✅ Descarga asíncrona de imágenes con async/await
|
|
||||||
- ✅ Concurrencia controlada (3 capítulos, 5 imágenes simultáneas)
|
|
||||||
- ✅ Cancelación de descargas (individual o masiva)
|
|
||||||
- ✅ Progreso en tiempo real
|
|
||||||
- ✅ Manejo robusto de errores
|
|
||||||
- ✅ Historial de descargas (completadas y fallidas)
|
|
||||||
- ✅ Integración con StorageService
|
|
||||||
- ✅ Verificación de duplicados
|
|
||||||
|
|
||||||
### 2. MangaDetailView Actualizado
|
|
||||||
- ✅ Botón de descarga en toolbar
|
|
||||||
- ✅ Descarga individual por capítulo
|
|
||||||
- ✅ Progreso visible en cada fila
|
|
||||||
- ✅ Notificaciones de estado
|
|
||||||
- ✅ Alert para descargar múltiples capítulos
|
|
||||||
- ✅ Indicador visual de capítulos descargados
|
|
||||||
|
|
||||||
### 3. DownloadsView (Vista de Descargas)
|
|
||||||
- ✅ Tabs: Activas, Completadas, Fallidas
|
|
||||||
- ✅ Cards con información detallada
|
|
||||||
- ✅ Cancelación de descargas
|
|
||||||
- ✅ Limpieza de historiales
|
|
||||||
- ✅ Información de almacenamiento usado
|
|
||||||
- ✅ Alert para limpiar todo
|
|
||||||
- ✅ Estados vacíos descriptivos
|
|
||||||
|
|
||||||
### 4. Extensiones y Utilidades
|
|
||||||
- ✅ Formateo de tamaños de archivo
|
|
||||||
- ✅ Estimación de tiempo restante
|
|
||||||
- ✅ Optimización de imágenes
|
|
||||||
- ✅ Compresión JPEG configurable
|
|
||||||
- ✅ Notificaciones del sistema
|
|
||||||
- ✅ URLSession configurada
|
|
||||||
|
|
||||||
## Flujo de Descarga Completo
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Usuario toca botón de descarga
|
|
||||||
↓
|
|
||||||
2. DownloadManager.downloadChapter()
|
|
||||||
↓
|
|
||||||
3. ManhwaWebScraper.scrapeChapterImages()
|
|
||||||
↓
|
|
||||||
4. Se crea DownloadTask con estado .pending
|
|
||||||
↓
|
|
||||||
5. downloadImages() inicia con TaskGroup
|
|
||||||
↓
|
|
||||||
6. Por cada imagen:
|
|
||||||
- downloadImage() desde URL
|
|
||||||
- UIImage.optimizedForStorage()
|
|
||||||
- StorageService.saveImage()
|
|
||||||
- Actualizar progreso
|
|
||||||
↓
|
|
||||||
7. Al completar todas:
|
|
||||||
- StorageService.saveDownloadedChapter()
|
|
||||||
- Mover tarea a completadas
|
|
||||||
- Notificar usuario
|
|
||||||
↓
|
|
||||||
8. Capítulo marcado como descargado
|
|
||||||
```
|
|
||||||
|
|
||||||
## Concurrencia y Performance
|
|
||||||
|
|
||||||
### Estrategia de Concurrencia
|
|
||||||
```swift
|
|
||||||
// Nivel 1: Descarga de capítulos (máximo 3 en paralelo)
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
for chapter in chapters {
|
|
||||||
group.addTask {
|
|
||||||
try await downloadChapter(chapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nivel 2: Descarga de imágenes por capítulo (máximo 5 en paralelo)
|
|
||||||
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
|
|
||||||
for (index, imageURL) in imageURLs.enumerated() {
|
|
||||||
group.addTask {
|
|
||||||
return (index, try await downloadImage(from: imageURL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optimizaciones de Memoria
|
|
||||||
- Imágenes comprimidas al 75-80% JPEG
|
|
||||||
- Redimensionado si > 2048px
|
|
||||||
- Concurrencia limitada para evitar picos
|
|
||||||
- Limpieza automática de historiales
|
|
||||||
|
|
||||||
## Manejo de Errores
|
|
||||||
|
|
||||||
### Tipos de Errores
|
|
||||||
```swift
|
|
||||||
enum DownloadError {
|
|
||||||
case alreadyDownloaded // Ya existe
|
|
||||||
case noImagesFound // Scraper falló
|
|
||||||
case invalidURL // URL malformada
|
|
||||||
case invalidResponse // Error HTTP
|
|
||||||
case httpError(statusCode) // 4xx, 5xx
|
|
||||||
case invalidImageData // No es imagen
|
|
||||||
case cancelled // Usuario canceló
|
|
||||||
case storageError(String) // Error disco
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recuperación
|
|
||||||
- Reintentos automáticos en errores de red
|
|
||||||
- Limpieza de archivos parciales
|
|
||||||
- Logging de errores para debugging
|
|
||||||
- Mensajes descriptivos al usuario
|
|
||||||
|
|
||||||
## Integración con StorageService
|
|
||||||
|
|
||||||
### Guardado de Imágenes
|
|
||||||
```swift
|
|
||||||
try await storage.saveImage(
|
|
||||||
image, // UIImage optimizada
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
// Guarda en: Documents/Chapters/manga-slug/Chapter1/page_0.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificación de Descarga
|
|
||||||
```swift
|
|
||||||
if storage.isChapterDownloaded(
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1
|
|
||||||
) {
|
|
||||||
// Ya está descargado
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lectura de Imágenes
|
|
||||||
```swift
|
|
||||||
if let imageURL = storage.getImageURL(
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
) {
|
|
||||||
// Usar URL local
|
|
||||||
AsyncImage(url: imageURL) { image in
|
|
||||||
image.resizable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI/UX Implementada
|
|
||||||
|
|
||||||
### Notificaciones
|
|
||||||
- Toast notification al completar
|
|
||||||
- Icono verde (éxito) o rojo (error)
|
|
||||||
- Auto-ocultado después de 3 segundos
|
|
||||||
- Animación desde abajo
|
|
||||||
|
|
||||||
### Progreso Visual
|
|
||||||
- Barra de progreso lineal
|
|
||||||
- Porcentaje numérico
|
|
||||||
- Páginas descargadas/total
|
|
||||||
- Tiempo estimado restante
|
|
||||||
|
|
||||||
### Estados Vacíos
|
|
||||||
- Iconos grandes y descriptivos
|
|
||||||
- Mensajes claros
|
|
||||||
- Llamadas a la acción
|
|
||||||
|
|
||||||
### Estados de Descarga
|
|
||||||
- ⏳ Pending: Gris
|
|
||||||
- 🔄 Downloading: Azul con progreso
|
|
||||||
- ✅ Completed: Verde
|
|
||||||
- ❌ Failed: Rojo con mensaje
|
|
||||||
- ❌ Cancelled: Gris
|
|
||||||
|
|
||||||
## Testing y Debugging
|
|
||||||
|
|
||||||
### Logs Implementados
|
|
||||||
```swift
|
|
||||||
print("Downloading chapter \(chapter.number)")
|
|
||||||
print("Error downloading chapter: \(error.localizedDescription)")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Puntos de Verificación
|
|
||||||
- ¿El capítulo ya está descargado?
|
|
||||||
- ¿Se encontraron imágenes?
|
|
||||||
- ¿Las URLs son válidas?
|
|
||||||
- ¿Las imágenes son válidas?
|
|
||||||
- ¿Hay espacio disponible?
|
|
||||||
|
|
||||||
### Métricas Disponibles
|
|
||||||
- Número de descargas activas
|
|
||||||
- Progreso general
|
|
||||||
- Tiempo restante estimado
|
|
||||||
- Tamaño de almacenamiento
|
|
||||||
- Tasa de éxito
|
|
||||||
|
|
||||||
## Configuración
|
|
||||||
|
|
||||||
### Parámetros Ajustables
|
|
||||||
```swift
|
|
||||||
// En DownloadManager
|
|
||||||
private let maxConcurrentDownloads = 3
|
|
||||||
private let maxConcurrentImagesPerChapter = 5
|
|
||||||
|
|
||||||
// En StorageService.saveImage()
|
|
||||||
image.jpegData(compressionQuality: 0.8)
|
|
||||||
|
|
||||||
// En DownloadExtensions
|
|
||||||
let maxDimension: CGFloat = 2048
|
|
||||||
return resized.compressedData(quality: 0.75)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Timeouts
|
|
||||||
- URLSession request: 30 segundos
|
|
||||||
- URLSession resource: 5 minutos
|
|
||||||
- Espera carga de página scraper: 3-5 segundos
|
|
||||||
|
|
||||||
## Uso Recomendado
|
|
||||||
|
|
||||||
### En Tu App Principal
|
|
||||||
1. Agregar `DownloadsView` a tu TabView principal
|
|
||||||
2. Opcional: Añadir badge con count de descargas activas
|
|
||||||
3. Usar `ActiveDownloadsWidget` en home
|
|
||||||
4. Implementar navegación desde `MangaDetailView`
|
|
||||||
|
|
||||||
### En ReaderView
|
|
||||||
1. Verificar si capítulo está descargado
|
|
||||||
2. Usar `storage.getImageURL()` para imágenes locales
|
|
||||||
3. Fallback a URLs remotas si no existe
|
|
||||||
|
|
||||||
### En SettingsView
|
|
||||||
1. Mostrar tamaño de almacenamiento usado
|
|
||||||
2. Botón para limpiar descargas
|
|
||||||
3. Estadísticas de descargas
|
|
||||||
4. Preferencias (solo Wi-Fi, etc.)
|
|
||||||
|
|
||||||
## Archivos de Configuración No Necesarios
|
|
||||||
|
|
||||||
El sistema no requiere:
|
|
||||||
- ❌ Info.plist modifications (permisos estándar)
|
|
||||||
- ❌ Entitlements especiales
|
|
||||||
- ❌ Background modes (opcional para futuro)
|
|
||||||
- ❌ Network configurations (usa URLSession por defecto)
|
|
||||||
|
|
||||||
## Next Steps Opcionales
|
|
||||||
|
|
||||||
### Mejoras Futuras
|
|
||||||
- [ ] Background downloads con URLSession
|
|
||||||
- [ ] Reanudar descargas pausadas
|
|
||||||
- [ ] Priorización de descargas
|
|
||||||
- [ ] Descarga automática de nuevos capítulos
|
|
||||||
- [ ] Compresión adicional (WebP)
|
|
||||||
- [ ] Batch operations
|
|
||||||
- [ ] Metrics y analytics
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Unit tests para DownloadManager
|
|
||||||
- [ ] Integration tests
|
|
||||||
- [ ] UI tests para DownloadsView
|
|
||||||
- [ ] Performance tests
|
|
||||||
- [ ] Memory leak tests
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
- [ ] Vídeo demostrativo
|
|
||||||
- [ ] Screenshots en README
|
|
||||||
- [ ] Diagramas de secuencia
|
|
||||||
- [ ] API documentation
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
**Tiempo de Desarrollo**: ~4-6 horas
|
|
||||||
**Líneas de Código**: ~1,500 líneas
|
|
||||||
**Archivos Creados**: 5 nuevos
|
|
||||||
**Archivos Modificados**: 2 existentes
|
|
||||||
**Complejidad**: Media-Alta
|
|
||||||
**Robustez**: Alta
|
|
||||||
**UX**: Excelente
|
|
||||||
|
|
||||||
**Estado**: ✅ COMPLETO Y FUNCIONAL
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
# Quick Start - Sistema de Descarga
|
|
||||||
|
|
||||||
## Integración Rápida (5 minutos)
|
|
||||||
|
|
||||||
### Paso 1: Verificar Archivos
|
|
||||||
|
|
||||||
Los siguientes archivos ya están creados en tu proyecto:
|
|
||||||
|
|
||||||
```
|
|
||||||
ios-app/Sources/
|
|
||||||
├── Services/
|
|
||||||
│ ├── DownloadManager.swift ✅ 13KB
|
|
||||||
│ └── DOWNLOAD_SYSTEM_README.md ✅ Documentación completa
|
|
||||||
├── Views/
|
|
||||||
│ ├── DownloadsView.swift ✅ 13KB
|
|
||||||
│ └── MangaDetailView.swift ✅ Actualizado
|
|
||||||
├── Extensions/
|
|
||||||
│ └── DownloadExtensions.swift ✅ 4.7KB
|
|
||||||
├── Examples/
|
|
||||||
│ └── IntegrationExample.swift ✅ Ejemplos de integración
|
|
||||||
└── Tests/
|
|
||||||
└── DownloadManagerTests.swift ✅ Tests unitarios
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 2: Agregar DownloadsView a Tu App
|
|
||||||
|
|
||||||
Si tienes un TabView, simplemente agrega:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// En tu ContentView o App principal
|
|
||||||
TabView {
|
|
||||||
ContentView() // Tu vista actual
|
|
||||||
.tabItem {
|
|
||||||
Label("Biblioteca", systemImage: "books.vertical")
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadsView() // ✅ NUEVA VISTA
|
|
||||||
.tabItem {
|
|
||||||
Label("Descargas", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
.badge(downloadManager.activeDownloads.count) // Opcional: badge
|
|
||||||
|
|
||||||
SettingsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Ajustes", systemImage: "gear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 3: Probar la Descarga
|
|
||||||
|
|
||||||
1. Abre `MangaDetailView` (ya está actualizado)
|
|
||||||
2. Toca el botón de descarga (icono de flecha hacia abajo) en la toolbar
|
|
||||||
3. Selecciona "Descargar últimos 10" o "Descargar todos"
|
|
||||||
4. Observa el progreso en cada fila de capítulo
|
|
||||||
5. Ve a la tab "Descargas" para ver el progreso detallado
|
|
||||||
|
|
||||||
¡Eso es todo! El sistema está completamente integrado.
|
|
||||||
|
|
||||||
## Características Incluidas
|
|
||||||
|
|
||||||
### ✅ Ya Funciona
|
|
||||||
- Descarga de capítulos individuales
|
|
||||||
- Descarga masiva (todos o últimos N)
|
|
||||||
- Progreso en tiempo real
|
|
||||||
- Cancelación de descargas
|
|
||||||
- Historial de descargas
|
|
||||||
- Notificaciones de estado
|
|
||||||
- Gestión de almacenamiento
|
|
||||||
- Manejo de errores
|
|
||||||
|
|
||||||
### 📱 UI Components
|
|
||||||
- `DownloadsView` - Vista completa con tabs
|
|
||||||
- `ActiveDownloadCard` - Card con progreso
|
|
||||||
- `CompletedDownloadCard` - Card de completados
|
|
||||||
- `FailedDownloadCard` - Card con reintentar
|
|
||||||
- Toast notifications
|
|
||||||
- Progress bars
|
|
||||||
|
|
||||||
### 🔧 Services
|
|
||||||
- `DownloadManager` - Singleton gerente de descargas
|
|
||||||
- `DownloadTask` - Modelo de tarea individual
|
|
||||||
- `DownloadState` - Estados de descarga
|
|
||||||
- `DownloadError` - Tipos de error
|
|
||||||
|
|
||||||
## Uso Básico
|
|
||||||
|
|
||||||
### Desde MangaDetailView
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Ya está implementado en MangaDetailView
|
|
||||||
// El usuario solo necesita tocar el botón de descarga
|
|
||||||
```
|
|
||||||
|
|
||||||
### Programáticamente
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let downloadManager = DownloadManager.shared
|
|
||||||
|
|
||||||
// Descargar un capítulo
|
|
||||||
try await downloadManager.downloadChapter(
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
mangaTitle: manga.title,
|
|
||||||
chapter: chapter
|
|
||||||
)
|
|
||||||
|
|
||||||
// Descargar múltiples
|
|
||||||
await downloadManager.downloadChapters(
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
mangaTitle: manga.title,
|
|
||||||
chapters: chapters
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cancelar descarga
|
|
||||||
downloadManager.cancelDownload(taskId: taskId)
|
|
||||||
|
|
||||||
// Cancelar todas
|
|
||||||
downloadManager.cancelAllDownloads()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificar Descargas
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let storage = StorageService.shared
|
|
||||||
|
|
||||||
// ¿Está descargado?
|
|
||||||
if storage.isChapterDownloaded(
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
chapterNumber: 1
|
|
||||||
) {
|
|
||||||
// Usar imagen local
|
|
||||||
let imageURL = storage.getImageURL(
|
|
||||||
mangaSlug: manga.slug,
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Personalización Opcional
|
|
||||||
|
|
||||||
### Ajustar Concurrencia
|
|
||||||
|
|
||||||
En `DownloadManager.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
private let maxConcurrentDownloads = 3 // Capítulos simultáneos
|
|
||||||
private let maxConcurrentImagesPerChapter = 5 // Imágenes simultáneas
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ajustar Calidad de Imagen
|
|
||||||
|
|
||||||
En `StorageService.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
image.jpegData(compressionQuality: 0.8) // 80% de calidad
|
|
||||||
```
|
|
||||||
|
|
||||||
En `DownloadExtensions.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let maxDimension: CGFloat = 2048 // Redimensionar si es mayor
|
|
||||||
return resized.compressedData(quality: 0.75) // 75% de calidad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Solución de Problemas
|
|
||||||
|
|
||||||
### Las descargas no inician
|
|
||||||
1. Verificar conexión a internet
|
|
||||||
2. Verificar que ManhwaWebScraper funciona
|
|
||||||
3. Verificar logs en consola
|
|
||||||
|
|
||||||
### El progreso no se actualiza
|
|
||||||
1. Asegurar que estás en @MainActor
|
|
||||||
2. Verificar que las propiedades son @Published
|
|
||||||
3. Verificar que observas DownloadManager
|
|
||||||
|
|
||||||
### Error "Already downloaded"
|
|
||||||
1. Es normal - el capítulo ya existe
|
|
||||||
2. Usa `storage.deleteDownloadedChapter()` para eliminar
|
|
||||||
3. O permite sobrescribir
|
|
||||||
|
|
||||||
### Las imágenes no se guardan
|
|
||||||
1. Verificar permisos de la app
|
|
||||||
2. Verificar espacio disponible
|
|
||||||
3. Verificar que directorios existen
|
|
||||||
|
|
||||||
## Próximos Pasos
|
|
||||||
|
|
||||||
### Opcional: Badge en TabView
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct MainTabView: View {
|
|
||||||
@StateObject private var downloadManager = DownloadManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
TabView {
|
|
||||||
// ...
|
|
||||||
DownloadsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Descargas", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
.badge(downloadManager.activeDownloads.count) // ✅ Badge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Opcional: Widget en Home
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct ContentView: View {
|
|
||||||
@ObservedObject var downloadManager = DownloadManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
// Tu contenido actual
|
|
||||||
|
|
||||||
if downloadManager.hasActiveDownloads {
|
|
||||||
ActiveDownloadsWidget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Opcional: Banner de Descargas
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct ContentView: View {
|
|
||||||
var body: some View {
|
|
||||||
MangaDetailView(manga: manga)
|
|
||||||
.activeDownloadsBanner() // ✅ Modificador personalizado
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Manual
|
|
||||||
1. Descargar un capítulo
|
|
||||||
2. Cancelar una descarga
|
|
||||||
3. Descargar múltiples capítulos
|
|
||||||
4. Probar sin internet
|
|
||||||
5. Limpiar almacenamiento
|
|
||||||
|
|
||||||
### Automatizado
|
|
||||||
Los tests están en `/Sources/Tests/DownloadManagerTests.swift`
|
|
||||||
|
|
||||||
Para ejecutar en Xcode:
|
|
||||||
1. Cmd + U
|
|
||||||
2. O Product → Test
|
|
||||||
|
|
||||||
## Archivos de Referencia
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
- `DOWNLOAD_SYSTEM_README.md` - Guía completa (400 líneas)
|
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Resumen ejecutivo
|
|
||||||
- `DIAGRAMS.md` - Diagramas de flujo
|
|
||||||
- `CHECKLIST.md` - Checklist de implementación
|
|
||||||
|
|
||||||
### Código
|
|
||||||
- `DownloadManager.swift` - Core del sistema
|
|
||||||
- `DownloadsView.swift` - Vista principal
|
|
||||||
- `DownloadExtensions.swift` - Extensiones útiles
|
|
||||||
- `IntegrationExample.swift` - Ejemplos de integración
|
|
||||||
|
|
||||||
## Soporte
|
|
||||||
|
|
||||||
### Problemas Comunes
|
|
||||||
|
|
||||||
**"No se compila"**
|
|
||||||
- Asegúrate de tener iOS 15+
|
|
||||||
- Verificar que todos los archivos están en el target
|
|
||||||
- Limpiar carpeta de builds (Cmd + Shift + K)
|
|
||||||
|
|
||||||
**"Las descargas fallan"**
|
|
||||||
- Verificar que ManhwaWebScraper funciona correctamente
|
|
||||||
- Probar con diferentes capítulos
|
|
||||||
- Verificar logs en consola
|
|
||||||
|
|
||||||
**"No se guardan las imágenes"**
|
|
||||||
- Verificar permisos en Info.plist
|
|
||||||
- Probar en dispositivo real (no simulador)
|
|
||||||
- Verificar espacio disponible
|
|
||||||
|
|
||||||
### Contacto
|
|
||||||
|
|
||||||
Para más ayuda, consulta:
|
|
||||||
1. `DOWNLOAD_SYSTEM_README.md` - Documentación completa
|
|
||||||
2. `DIAGRAMS.md` - Diagramas de flujo
|
|
||||||
3. `IntegrationExample.swift` - Ejemplos de código
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Tiempo de integración**: 5 minutos
|
|
||||||
**Dificultad**: Fácil
|
|
||||||
**Estado**: ✅ COMPLETO
|
|
||||||
|
|
||||||
¡Happy coding! 🚀
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
# Sistema de Descarga de Capítulos - MangaReader iOS
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
El sistema de descarga de capítulos permite a los usuarios descargar capítulos completos de manga para lectura offline. El sistema está diseñado con arquitectura asíncrona moderna usando Swift async/await.
|
|
||||||
|
|
||||||
## Componentes Principales
|
|
||||||
|
|
||||||
### 1. DownloadManager (`/Sources/Services/DownloadManager.swift`)
|
|
||||||
|
|
||||||
Gerente centralizado que maneja todas las operaciones de descarga.
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
- Descarga asíncrona de imágenes con concurrencia controlada
|
|
||||||
- Máximo 3 descargas simultáneas de capítulos
|
|
||||||
- Máximo 5 imágenes simultáneas por capítulo
|
|
||||||
- Cancelación de descargas individuales o masivas
|
|
||||||
- Seguimiento de progreso en tiempo real
|
|
||||||
- Manejo robusto de errores
|
|
||||||
- Historial de descargas completadas y fallidas
|
|
||||||
|
|
||||||
**Uso básico:**
|
|
||||||
```swift
|
|
||||||
let downloadManager = DownloadManager.shared
|
|
||||||
|
|
||||||
// Descargar un capítulo
|
|
||||||
try await downloadManager.downloadChapter(
|
|
||||||
mangaSlug: "one-piece",
|
|
||||||
mangaTitle: "One Piece",
|
|
||||||
chapter: chapter
|
|
||||||
)
|
|
||||||
|
|
||||||
// Descargar múltiples capítulos
|
|
||||||
await downloadManager.downloadChapters(
|
|
||||||
mangaSlug: "one-piece",
|
|
||||||
mangaTitle: "One Piece",
|
|
||||||
chapters: chapters
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cancelar descarga
|
|
||||||
downloadManager.cancelDownload(taskId: "taskId")
|
|
||||||
|
|
||||||
// Cancelar todas
|
|
||||||
downloadManager.cancelAllDownloads()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. MangaDetailView (`/Sources/Views/MangaDetailView.swift`)
|
|
||||||
|
|
||||||
Vista de detalles del manga con funcionalidad de descarga integrada.
|
|
||||||
|
|
||||||
**Características añadidas:**
|
|
||||||
- Botón de descarga en la toolbar
|
|
||||||
- Descarga individual por capítulo
|
|
||||||
- Progreso de descarga visible en cada fila de capítulo
|
|
||||||
- Notificaciones de completado/error
|
|
||||||
- Alert para descargar últimos 10 o todos los capítulos
|
|
||||||
|
|
||||||
**Flujo de descarga:**
|
|
||||||
1. Usuario toca botón de descarga en toolbar → muestra alert
|
|
||||||
2. Selecciona cantidad de capítulos a descargar
|
|
||||||
3. Cada capítulo muestra progreso de descarga en tiempo real
|
|
||||||
4. Notificación aparece al completar todas las descargas
|
|
||||||
5. Capítulos descargados muestran checkmark verde
|
|
||||||
|
|
||||||
### 3. DownloadsView (`/Sources/Views/DownloadsView.swift`)
|
|
||||||
|
|
||||||
Vista dedicada para gestionar todas las descargas.
|
|
||||||
|
|
||||||
**Tabs:**
|
|
||||||
- **Activas**: Descargas en progreso con barra de progreso
|
|
||||||
- **Completadas**: Historial de descargas exitosas
|
|
||||||
- **Fallidas**: Descargas con errores, permite reintentar
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
- Cancelar descargas individuales
|
|
||||||
- Cancelar todas las descargas activas
|
|
||||||
- Limpiar historiales (completadas/fallidas)
|
|
||||||
- Ver tamaño de almacenamiento usado
|
|
||||||
- Limpiar todo el almacenamiento descargado
|
|
||||||
|
|
||||||
### 4. StorageService (`/Sources/Services/StorageService.swift`)
|
|
||||||
|
|
||||||
Servicio de almacenamiento ya existente, ahora con soporte para descargas.
|
|
||||||
|
|
||||||
**Métodos utilizados:**
|
|
||||||
```swift
|
|
||||||
// Guardar imagen descargada
|
|
||||||
try await storage.saveImage(
|
|
||||||
image,
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verificar si capítulo está descargado
|
|
||||||
storage.isChapterDownloaded(mangaSlug: "manga-slug", chapterNumber: 1)
|
|
||||||
|
|
||||||
// Obtener directorio del capítulo
|
|
||||||
let chapterDir = storage.getChapterDirectory(
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// Obtener URL de imagen local
|
|
||||||
if let imageURL = storage.getImageURL(
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
) {
|
|
||||||
// Usar imagen local
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eliminar capítulo descargado
|
|
||||||
storage.deleteDownloadedChapter(
|
|
||||||
mangaSlug: "manga-slug",
|
|
||||||
chapterNumber: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// Obtener tamaño de almacenamiento
|
|
||||||
let size = storage.getStorageSize()
|
|
||||||
let formatted = storage.formatFileSize(size)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Modelos de Datos
|
|
||||||
|
|
||||||
### DownloadTask
|
|
||||||
Representa una tarea de descarga individual:
|
|
||||||
```swift
|
|
||||||
class DownloadTask: ObservableObject {
|
|
||||||
let id: String
|
|
||||||
let mangaSlug: String
|
|
||||||
let mangaTitle: String
|
|
||||||
let chapterNumber: Int
|
|
||||||
let imageURLs: [String]
|
|
||||||
|
|
||||||
@Published var state: DownloadState
|
|
||||||
@Published var downloadedPages: Int
|
|
||||||
@Published var progress: Double
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DownloadState
|
|
||||||
Estados posibles de una descarga:
|
|
||||||
```swift
|
|
||||||
enum DownloadState {
|
|
||||||
case pending
|
|
||||||
case downloading(progress: Double)
|
|
||||||
case completed
|
|
||||||
case failed(error: String)
|
|
||||||
case cancelled
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DownloadError
|
|
||||||
Tipos de errores de descarga:
|
|
||||||
```swift
|
|
||||||
enum DownloadError: LocalizedError {
|
|
||||||
case alreadyDownloaded
|
|
||||||
case noImagesFound
|
|
||||||
case invalidURL
|
|
||||||
case invalidResponse
|
|
||||||
case httpError(statusCode: Int)
|
|
||||||
case invalidImageData
|
|
||||||
case cancelled
|
|
||||||
case storageError(String)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuración
|
|
||||||
|
|
||||||
### Parámetros de Descarga
|
|
||||||
En `DownloadManager`:
|
|
||||||
```swift
|
|
||||||
private let maxConcurrentDownloads = 3 // Máximo de capítulos simultáneos
|
|
||||||
private let maxConcurrentImagesPerChapter = 5 // Máximo de imágenes simultáneas por capítulo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Calidad de Imagen
|
|
||||||
En `StorageService.saveImage()`:
|
|
||||||
```swift
|
|
||||||
image.jpegData(compressionQuality: 0.8) // 80% de calidad JPEG
|
|
||||||
```
|
|
||||||
|
|
||||||
En `DownloadExtensions`:
|
|
||||||
```swift
|
|
||||||
func optimizedForStorage() -> Data? {
|
|
||||||
// Redimensiona si > 2048px
|
|
||||||
// Comprime a 75% de calidad
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integración con ReaderView
|
|
||||||
|
|
||||||
Para leer capítulos descargados:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct ReaderView: View {
|
|
||||||
let chapter: Chapter
|
|
||||||
let mangaSlug: String
|
|
||||||
|
|
||||||
@StateObject private var storage = StorageService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack {
|
|
||||||
ForEach(pageIndices, id: \.self) { index in
|
|
||||||
if let imageURL = storage.getImageURL(
|
|
||||||
mangaSlug: mangaSlug,
|
|
||||||
chapterNumber: chapter.number,
|
|
||||||
pageIndex: index
|
|
||||||
) {
|
|
||||||
// Usar imagen local
|
|
||||||
AsyncImage(url: imageURL) { image in
|
|
||||||
image.resizable()
|
|
||||||
} placeholder: {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback a URL remota
|
|
||||||
RemoteChapterPage(url: remoteURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notificaciones
|
|
||||||
|
|
||||||
El sistema emite notificaciones para seguimiento:
|
|
||||||
```swift
|
|
||||||
extension Notification.Name {
|
|
||||||
static let downloadDidStart = Notification.Name("downloadDidStart")
|
|
||||||
static let downloadDidUpdate = Notification.Name("downloadDidUpdate")
|
|
||||||
static let downloadDidComplete = Notification.Name("downloadDidComplete")
|
|
||||||
static let downloadDidFail = Notification.Name("downloadDidFail")
|
|
||||||
static let downloadDidCancel = Notification.Name("downloadDidCancel")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manejo de Errores
|
|
||||||
|
|
||||||
### Errores de Red
|
|
||||||
- Timeout: 30 segundos por imagen
|
|
||||||
- Reintentos: Manejados por URLSession
|
|
||||||
- HTTP errors: Capturados y reportados en UI
|
|
||||||
|
|
||||||
### Errores de Almacenamiento
|
|
||||||
- Espacio insuficiente: Error con mensaje descriptivo
|
|
||||||
- Permisos: Manejados por FileManager
|
|
||||||
- Corrupción de archivos: Archivos eliminados y descarga reiniciada
|
|
||||||
|
|
||||||
### Errores de Scraping
|
|
||||||
- No se encontraron imágenes: Error `noImagesFound`
|
|
||||||
- Página no carga: Error del scraper propagado
|
|
||||||
- Cambios en la web: Requieren actualización del scraper
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Concurrencia
|
|
||||||
El sistema usa Swift Concurrency:
|
|
||||||
- `async/await` para operaciones asíncronas
|
|
||||||
- `Task` para crear contextos de concurrencia
|
|
||||||
- `@MainActor` para actualizaciones de UI
|
|
||||||
- `TaskGroup` para descargas en paralelo
|
|
||||||
|
|
||||||
### 2. Memoria
|
|
||||||
- Imágenes comprimidas antes de guardar
|
|
||||||
- Descarga limitada a 5 imágenes simultáneas
|
|
||||||
- Limpieza automática de historiales (50 completadas, 20 fallidas)
|
|
||||||
|
|
||||||
### 3. UX
|
|
||||||
- Progreso visible en tiempo real
|
|
||||||
- Cancelación en cualquier punto
|
|
||||||
- Notificaciones de estado
|
|
||||||
- Estados vacíos descriptivos
|
|
||||||
- Feedback inmediato de acciones
|
|
||||||
|
|
||||||
### 4. Robustez
|
|
||||||
- Validación de estados antes de descargar
|
|
||||||
- Limpieza de archivos parciales al cancelar
|
|
||||||
- Verificación de archivos existentes
|
|
||||||
- Manejo exhaustivo de errores
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Pruebas Unitarias
|
|
||||||
```swift
|
|
||||||
func testDownloadManager() async throws {
|
|
||||||
let manager = DownloadManager.shared
|
|
||||||
|
|
||||||
// Probar descarga individual
|
|
||||||
try await manager.downloadChapter(
|
|
||||||
mangaSlug: "test",
|
|
||||||
mangaTitle: "Test Manga",
|
|
||||||
chapter: testChapter
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertTrue(manager.activeDownloads.isEmpty)
|
|
||||||
XCTAssertEqual(manager.completedDownloads.count, 1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pruebas de Integración
|
|
||||||
- Descargar capítulo completo
|
|
||||||
- Cancelar descarga a mitad
|
|
||||||
- Descargar múltiples capítulos
|
|
||||||
- Probar con y sin conexión
|
|
||||||
- Verificar persistencia de archivos
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Descargas no inician
|
|
||||||
- Verificar conexión a internet
|
|
||||||
- Verificar que el scraper puede acceder a la web
|
|
||||||
- Revisar logs del scraper
|
|
||||||
|
|
||||||
### Progreso no actualiza
|
|
||||||
- Asegurar que las vistas están en @MainActor
|
|
||||||
- Verificar que DownloadTask es @ObservedObject
|
|
||||||
- Chequear que las propiedades son @Published
|
|
||||||
|
|
||||||
### Archivos no se guardan
|
|
||||||
- Verificar permisos de la app
|
|
||||||
- Chequear espacio disponible
|
|
||||||
- Revisar que directorios existen
|
|
||||||
|
|
||||||
### Imágenes corruptas
|
|
||||||
- Verificar calidad de compresión
|
|
||||||
- Chequear que URLs sean válidas
|
|
||||||
- Probar redimensionado de imágenes
|
|
||||||
|
|
||||||
## Futuras Mejoras
|
|
||||||
|
|
||||||
- [ ] Soporte para reanudar descargas pausadas
|
|
||||||
- [ ] Priorización de descargas
|
|
||||||
- [ ] Descarga automática de nuevos capítulos
|
|
||||||
- [ ] Compresión adicional de imágenes
|
|
||||||
- [ ] Soporte para formatos WebP
|
|
||||||
- [ ] Batch operations en StorageService
|
|
||||||
- [ ] Background downloads con URLSession
|
|
||||||
- [ ] Metrics y analytics de descargas
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# MangaReader - Suite de Tests Completa
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
He creado una suite completa de tests para el proyecto MangaReader que incluye **~120 tests** distribuidos en **4,900+ líneas de código**.
|
|
||||||
|
|
||||||
## Archivos Creados (11 archivos)
|
|
||||||
|
|
||||||
### Tests Principales (4 archivos, ~1,850 líneas)
|
|
||||||
1. **ModelTests.swift** (350 líneas) - Tests para modelos de datos
|
|
||||||
2. **StorageServiceTests.swift** (500 líneas) - Tests para servicio de almacenamiento
|
|
||||||
3. **ManhwaWebScraperTests.swift** (450 líneas) - Tests para web scraper
|
|
||||||
4. **IntegrationTests.swift** (550 líneas) - Tests de integración completa
|
|
||||||
|
|
||||||
### Helpers y Utilidades (4 archivos, ~1,300 líneas)
|
|
||||||
5. **TestHelpers.swift** (400 líneas) - Factories y helpers para tests
|
|
||||||
6. **XCTestSuiteExtensions.swift** (250 líneas) - Extensiones de XCTest
|
|
||||||
7. **XCTestManifests.swift** (200 líneas) - Manifests de test suites
|
|
||||||
8. **TestExamples.swift** (450 líneas) - Ejemplos y plantillas
|
|
||||||
|
|
||||||
### Documentación (3 archivos, ~1,800 líneas)
|
|
||||||
9. **README.md** (400 líneas) - Documentación completa de tests
|
|
||||||
10. **TEST_SUMMARY.md** (500 líneas) - Resumen detallado de la suite
|
|
||||||
11. **run_tests.sh** (200 líneas) - Script para ejecutar tests
|
|
||||||
|
|
||||||
## Cobertura de Tests
|
|
||||||
|
|
||||||
### Por Componente
|
|
||||||
|
|
||||||
| Componente | Tests | Cobertura | Estado |
|
|
||||||
|------------|-------|-----------|--------|
|
|
||||||
| **Modelos** | 35 | 95%+ | ✅ Completo |
|
|
||||||
| **StorageService** | 40 | 90%+ | ✅ Completo |
|
|
||||||
| **ManhwaWebScraper** | 25 | 85%+ | ✅ Completo |
|
|
||||||
| **Integración** | 20 | 80%+ | ✅ Completo |
|
|
||||||
|
|
||||||
### Por Tipo de Test
|
|
||||||
|
|
||||||
- **Tests Unitarios**: 100 tests (83%)
|
|
||||||
- **Tests de Integración**: 20 tests (17%)
|
|
||||||
- **Tests de Performance**: 7 tests
|
|
||||||
- **Tests de Concurrencia**: 6 tests
|
|
||||||
- **Tests de Edge Cases**: 20+ tests
|
|
||||||
|
|
||||||
## Características Implementadas
|
|
||||||
|
|
||||||
### 1. Tests de Modelos (ModelTests.swift)
|
|
||||||
- ✅ Codable serialization/deserialization
|
|
||||||
- ✅ Validación de datos
|
|
||||||
- ✅ Hashable compliance
|
|
||||||
- ✅ Cálculo de propiedades derivadas
|
|
||||||
- ✅ Edge cases (valores vacíos, nil, negativos)
|
|
||||||
- ✅ Performance tests
|
|
||||||
|
|
||||||
### 2. Tests de Storage (StorageServiceTests.swift)
|
|
||||||
- ✅ Gestión de favoritos (CRUD completo)
|
|
||||||
- ✅ Reading progress tracking
|
|
||||||
- ✅ Downloaded chapters management
|
|
||||||
- ✅ Image caching
|
|
||||||
- ✅ Storage management (size, cleanup)
|
|
||||||
- ✅ Operaciones concurrentes
|
|
||||||
- ✅ Tests de gran escala (1000+ operaciones)
|
|
||||||
|
|
||||||
### 3. Tests de Scraper (ManhwaWebScraperTests.swift)
|
|
||||||
- ✅ Mock de WKWebView responses
|
|
||||||
- ✅ Parsing de JavaScript results
|
|
||||||
- ✅ Chapter parsing y deduplication
|
|
||||||
- ✅ Image filtering
|
|
||||||
- ✅ Manga info extraction
|
|
||||||
- ✅ URL construction
|
|
||||||
- ✅ Error handling
|
|
||||||
- ✅ Performance tests (1000+ items)
|
|
||||||
|
|
||||||
### 4. Tests de Integración (IntegrationTests.swift)
|
|
||||||
- ✅ Flujo completo scraper -> storage
|
|
||||||
- ✅ Descarga de capítulos con imágenes
|
|
||||||
- ✅ Reading progress tracking
|
|
||||||
- ✅ Favorite management
|
|
||||||
- ✅ Multi-manga scenarios
|
|
||||||
- ✅ Concurrent operations
|
|
||||||
- ✅ Data persistence
|
|
||||||
- ✅ Large scale operations
|
|
||||||
|
|
||||||
### 5. Helpers y Utilities
|
|
||||||
- ✅ TestDataFactory (crear objetos de prueba)
|
|
||||||
- ✅ ImageTestHelpers (crear imágenes)
|
|
||||||
- ✅ FileSystemTestHelpers (operaciones de archivos)
|
|
||||||
- ✅ StorageTestHelpers (limpieza y seed data)
|
|
||||||
- ✅ AsyncTestHelpers (operaciones asíncronas)
|
|
||||||
- ✅ ScraperTestHelpers (mocks de HTML/JS)
|
|
||||||
- ✅ AssertionHelpers (asserts personalizados)
|
|
||||||
- ✅ PerformanceTestHelpers (medición de rendimiento)
|
|
||||||
|
|
||||||
### 6. Extensiones de XCTest
|
|
||||||
- ✅ Async helpers (wait, waitForOperation)
|
|
||||||
- ✅ Error assertions (assertThrowsError, assertNoThrow)
|
|
||||||
- ✅ Custom assertions (assertDatesEqual, assertEmpty, etc.)
|
|
||||||
- ✅ Memory leak detection
|
|
||||||
- ✅ Test logging
|
|
||||||
- ✅ Metrics tracking
|
|
||||||
|
|
||||||
## Cómo Usar
|
|
||||||
|
|
||||||
### Ejecutar Todos los Tests
|
|
||||||
```bash
|
|
||||||
# Desde Xcode
|
|
||||||
Cmd + U
|
|
||||||
|
|
||||||
# Desde terminal
|
|
||||||
./run_tests.sh --all
|
|
||||||
|
|
||||||
# Con cobertura
|
|
||||||
./run_tests.sh --all --coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ejecutar Tests Específicos
|
|
||||||
```bash
|
|
||||||
# Solo unitarios
|
|
||||||
./run_tests.sh --unit
|
|
||||||
|
|
||||||
# Solo integración
|
|
||||||
./run_tests.sh --integration
|
|
||||||
|
|
||||||
# Con output detallado
|
|
||||||
./run_tests.sh --all --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### En Xcode
|
|
||||||
- **Cmd + U**: Ejecutar todos los tests
|
|
||||||
- **Cmd + 6**: Abrir Test Navigator
|
|
||||||
- **Click derecho en test**: Run individual test
|
|
||||||
|
|
||||||
## Archivos de Tests
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/ren/ios/MangaReader/ios-app/Tests/
|
|
||||||
├── ModelTests.swift # Tests de modelos (35 tests)
|
|
||||||
├── StorageServiceTests.swift # Tests de storage (40 tests)
|
|
||||||
├── ManhwaWebScraperTests.swift # Tests de scraper (25 tests)
|
|
||||||
├── IntegrationTests.swift # Tests de integración (20 tests)
|
|
||||||
├── TestHelpers.swift # Helpers y factories
|
|
||||||
├── XCTestSuiteExtensions.swift # Extensiones de XCTest
|
|
||||||
├── XCTestManifests.swift # Manifests de test suites
|
|
||||||
├── TestExamples.swift # Ejemplos y plantillas
|
|
||||||
├── README.md # Documentación completa
|
|
||||||
├── TEST_SUMMARY.md # Resumen detallado
|
|
||||||
└── run_tests.sh # Script de ejecución
|
|
||||||
```
|
|
||||||
|
|
||||||
## Estadísticas Finales
|
|
||||||
|
|
||||||
- **Total Tests**: ~120
|
|
||||||
- **Total Líneas de Código**: ~4,900
|
|
||||||
- **Cobertura Promedio**: 87%+
|
|
||||||
- **Tests Unitarios**: 100 (83%)
|
|
||||||
- **Tests de Integración**: 20 (17%)
|
|
||||||
- **Tests de Performance**: 7
|
|
||||||
- **Tests de Concurrencia**: 6
|
|
||||||
- **Tests de Edge Cases**: 20+
|
|
||||||
|
|
||||||
## Próximos Pasos
|
|
||||||
|
|
||||||
1. **Agregar tests al target de Xcode**
|
|
||||||
- Abrir el proyecto en Xcode
|
|
||||||
- Agregar los archivos de tests
|
|
||||||
- Configurar el test target
|
|
||||||
|
|
||||||
2. **Ejecutar los tests**
|
|
||||||
- Cmd + U para ejecutar todos
|
|
||||||
- Verificar que pasan
|
|
||||||
- Ajustar si es necesario
|
|
||||||
|
|
||||||
3. **Configurar CI/CD**
|
|
||||||
- Agregar ejecución de tests en GitHub Actions
|
|
||||||
- Reportes de cobertura
|
|
||||||
- Tests en cada PR
|
|
||||||
|
|
||||||
4. **Mantener los tests**
|
|
||||||
- Actualizar cuando se agregan features
|
|
||||||
- Mantener cobertura > 85%
|
|
||||||
- Agregar tests para bugs encontrados
|
|
||||||
|
|
||||||
## Beneficios
|
|
||||||
|
|
||||||
### Calidad del Código
|
|
||||||
- ✅ Bugs detectados temprano
|
|
||||||
- ✅ Refactorización segura
|
|
||||||
- ✅ Documentación viva del código
|
|
||||||
|
|
||||||
### Confianza
|
|
||||||
- ✅ Tests independientes y ejecutables en cualquier orden
|
|
||||||
- ✅ Setup/teardown apropiado
|
|
||||||
- ✅ Mocks de dependencias externas
|
|
||||||
|
|
||||||
### Mantenibilidad
|
|
||||||
- ✅ Helpers reutilizables
|
|
||||||
- ✅ Ejemplos y plantillas
|
|
||||||
- ✅ Documentación completa
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Tests de performance incluidos
|
|
||||||
- ✅ Tests de gran escala
|
|
||||||
- ✅ Métricas y benchmarks
|
|
||||||
|
|
||||||
## Recursos
|
|
||||||
|
|
||||||
- **README.md**: Guía completa de uso
|
|
||||||
- **TEST_SUMMARY.md**: Descripción detallada de cada test
|
|
||||||
- **TestExamples.swift**: Ejemplos y plantillas para nuevos tests
|
|
||||||
- **run_tests.sh --help**: Ayuda del script
|
|
||||||
|
|
||||||
## Contacto
|
|
||||||
|
|
||||||
Para preguntas o sugerencias sobre los tests, consultar:
|
|
||||||
- README.md para documentación general
|
|
||||||
- TestExamples.swift para ejemplos de código
|
|
||||||
- TEST_SUMMARY.md para detalles de cada test
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Creado**: 2026-02-04
|
|
||||||
**Versión**: 1.0
|
|
||||||
**Framework**: XCTest
|
|
||||||
**Plataforma**: iOS 15+
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
# MangaReader Test Suite
|
|
||||||
|
|
||||||
Suite completa de tests para el proyecto MangaReader usando XCTest.
|
|
||||||
|
|
||||||
## Tabla de Contenidos
|
|
||||||
|
|
||||||
- [Descripción General](#descripción-general)
|
|
||||||
- [Estructura de Tests](#estructura-de-tests)
|
|
||||||
- [Ejecutar Tests](#ejecutar-tests)
|
|
||||||
- [Guía de Tests](#guía-de-tests)
|
|
||||||
- [Mejores Prácticas](#mejores-prácticas)
|
|
||||||
|
|
||||||
## Descripción General
|
|
||||||
|
|
||||||
Esta suite de tests cubre todos los componentes principales del proyecto MangaReader:
|
|
||||||
|
|
||||||
1. **Modelos de Datos** - Validación de Codable, edge cases, y lógica de negocio
|
|
||||||
2. **StorageService** - Almacenamiento local, favoritos, progreso de lectura
|
|
||||||
3. **ManhwaWebScraper** - Web scraping y parsing de HTML/JavaScript
|
|
||||||
4. **Integración** - Flujos completos que conectan múltiples componentes
|
|
||||||
|
|
||||||
## Estructura de Tests
|
|
||||||
|
|
||||||
```
|
|
||||||
Tests/
|
|
||||||
├── ModelTests.swift # Tests para modelos de datos
|
|
||||||
├── StorageServiceTests.swift # Tests para servicio de almacenamiento
|
|
||||||
├── ManhwaWebScraperTests.swift # Tests para web scraper
|
|
||||||
├── IntegrationTests.swift # Tests de integración
|
|
||||||
├── TestHelpers.swift # Helpers y factories para tests
|
|
||||||
└── XCTestSuiteExtensions.swift # Extensiones de XCTest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ejecutar Tests
|
|
||||||
|
|
||||||
### Desde Xcode
|
|
||||||
|
|
||||||
1. Abrir el proyecto en Xcode
|
|
||||||
2. Cmd + U para ejecutar todos los tests
|
|
||||||
3. Cmd + 6 para abrir el Test Navigator
|
|
||||||
4. Click derecho en un test específico para ejecutarlo
|
|
||||||
|
|
||||||
### Desde Línea de Comandos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ejecutar todos los tests
|
|
||||||
xcodebuild test -scheme MangaReader -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
||||||
|
|
||||||
# Ejecutar tests específicos
|
|
||||||
xcodebuild test -scheme MangaReader -only-testing:MangaReaderTests/ModelTests
|
|
||||||
|
|
||||||
# Ejecutar con cobertura
|
|
||||||
xcodebuild test -scheme MangaReader -enableCodeCoverage YES
|
|
||||||
```
|
|
||||||
|
|
||||||
## Guía de Tests
|
|
||||||
|
|
||||||
### ModelTests.swift
|
|
||||||
|
|
||||||
Prueba todos los modelos de datos del proyecto.
|
|
||||||
|
|
||||||
#### Tests Incluidos:
|
|
||||||
|
|
||||||
**Manga Model:**
|
|
||||||
- `testMangaInitialization` - Verifica inicialización correcta
|
|
||||||
- `testMangaCodableSerialization` - Prueba encoding/decoding JSON
|
|
||||||
- `testMangaDisplayStatus` - Verifica traducción de estados
|
|
||||||
- `testMangaHashable` - Prueba conformidad con Hashable
|
|
||||||
|
|
||||||
**Chapter Model:**
|
|
||||||
- `testChapterInitialization` - Inicialización con valores por defecto
|
|
||||||
- `testChapterDisplayNumber` - Formato de número de capítulo
|
|
||||||
- `testChapterProgress` - Cálculo de progreso de lectura
|
|
||||||
- `testChapterCodableSerialization` - Serialización JSON
|
|
||||||
|
|
||||||
**MangaPage Model:**
|
|
||||||
- `testMangaPageInitialization` - Creación de páginas
|
|
||||||
- `testMangaPageThumbnailURL` - URLs de thumbnails
|
|
||||||
- `testMangaPageCodableSerialization` - Serialización
|
|
||||||
|
|
||||||
**ReadingProgress Model:**
|
|
||||||
- `testReadingProgressInitialization` - Creación de progreso
|
|
||||||
- `testReadingProgressIsCompleted` - Lógica de completación
|
|
||||||
- `testReadingProgressCodableSerialization` - Persistencia
|
|
||||||
|
|
||||||
**DownloadedChapter Model:**
|
|
||||||
- `testDownloadedChapterInitialization` - Creación de capítulos descargados
|
|
||||||
- `testDownloadedChapterDisplayTitle` - Formato de títulos
|
|
||||||
- `testDownloadedChapterCodableSerialization` - Serialización completa
|
|
||||||
|
|
||||||
**Edge Cases:**
|
|
||||||
- `testMangaWithEmptyGenres` - Manejo de arrays vacíos
|
|
||||||
- `testMangaWithNilCoverImage` - Imagen de portada opcional
|
|
||||||
- `testChapterWithZeroNumber` - Capítulo cero
|
|
||||||
- `testMangaPageWithNegativeIndex` - Índices negativos
|
|
||||||
|
|
||||||
### StorageServiceTests.swift
|
|
||||||
|
|
||||||
Prueba el servicio de almacenamiento local.
|
|
||||||
|
|
||||||
#### Tests Incluidos:
|
|
||||||
|
|
||||||
**Favorites:**
|
|
||||||
- `testSaveFavorite` - Guardar un favorito
|
|
||||||
- `testSaveMultipleFavorites` - Guardar varios favoritos
|
|
||||||
- `testSaveDuplicateFavorite` - Evitar duplicados
|
|
||||||
- `testRemoveFavorite` - Eliminar favorito
|
|
||||||
- `testIsFavorite` - Verificar si es favorito
|
|
||||||
|
|
||||||
**Reading Progress:**
|
|
||||||
- `testSaveReadingProgress` - Guardar progreso
|
|
||||||
- `testSaveMultipleReadingProgress` - Múltiples progresos
|
|
||||||
- `testUpdateExistingReadingProgress` - Actualizar progreso
|
|
||||||
- `testGetLastReadChapter` - Obtener último capítulo leído
|
|
||||||
- `testGetReadingProgressWhenNotExists` - Progreso inexistente
|
|
||||||
|
|
||||||
**Downloaded Chapters:**
|
|
||||||
- `testSaveDownloadedChapter` - Guardar metadatos de capítulo
|
|
||||||
- `testIsChapterDownloaded` - Verificar descarga
|
|
||||||
- `testGetDownloadedChapters` - Listar capítulos
|
|
||||||
- `testDeleteDownloadedChapter` - Eliminar capítulo
|
|
||||||
|
|
||||||
**Image Caching:**
|
|
||||||
- `testSaveAndLoadImage` - Guardar y cargar imagen
|
|
||||||
- `testLoadNonExistentImage` - Imagen inexistente
|
|
||||||
- `testGetImageURL` - Obtener URL de imagen
|
|
||||||
|
|
||||||
**Storage Management:**
|
|
||||||
- `testGetStorageSize` - Calcular tamaño usado
|
|
||||||
- `testClearAllDownloads` - Limpiar todo el almacenamiento
|
|
||||||
- `testFormatFileSize` - Formatear tamaño a legible
|
|
||||||
|
|
||||||
**Concurrent Operations:**
|
|
||||||
- `testConcurrentImageSave` - Guardar imágenes concurrentemente
|
|
||||||
|
|
||||||
### ManhwaWebScraperTests.swift
|
|
||||||
|
|
||||||
Prueba el web scraper con mocks de WKWebView.
|
|
||||||
|
|
||||||
#### Tests Incluidos:
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- `testScrapingErrorDescriptions` - Descripciones de errores
|
|
||||||
- `testScrapingErrorLocalizedError` - Conformidad con LocalizedError
|
|
||||||
|
|
||||||
**Chapter Parsing:**
|
|
||||||
- `testChapterParsingFromJavaScriptResult` - Parsear respuesta JS
|
|
||||||
- `testChapterParsingWithInvalidData` - Manejar datos inválidos
|
|
||||||
- `testChapterDeduplication` - Eliminar capítulos duplicados
|
|
||||||
- `testChapterSorting` - Ordenar capítulos
|
|
||||||
|
|
||||||
**Image Parsing:**
|
|
||||||
- `testImageParsingFromJavaScriptResult` - Parsear URLs de imágenes
|
|
||||||
- `testImageParsingWithEmptyArray` - Array vacío de imágenes
|
|
||||||
- `testImageParsingWithInvalidURLs` - Filtrar URLs inválidas
|
|
||||||
|
|
||||||
**Manga Info Parsing:**
|
|
||||||
- `testMangaInfoParsingFromJavaScriptResult` - Extraer info de manga
|
|
||||||
- `testMangaInfoParsingWithEmptyFields` - Campos vacíos
|
|
||||||
- `testMangaStatusParsing` - Normalizar estados
|
|
||||||
|
|
||||||
**URL Construction:**
|
|
||||||
- `testMangaURLConstruction` - Construir URLs de manga
|
|
||||||
- `testChapterURLConstruction` - Construir URLs de capítulo
|
|
||||||
- `testURLConstructionWithSpecialCharacters` - Caracteres especiales
|
|
||||||
|
|
||||||
**Edge Cases:**
|
|
||||||
- `testChapterNumberExtraction` - Extraer números de capítulo
|
|
||||||
- `testChapterSlugExtraction` - Extraer slugs
|
|
||||||
- `testDuplicateRemovalPreservingOrder` - Eliminar duplicados manteniendo orden
|
|
||||||
|
|
||||||
### IntegrationTests.swift
|
|
||||||
|
|
||||||
Prueba flujos completos que integran múltiples componentes.
|
|
||||||
|
|
||||||
#### Tests Incluidos:
|
|
||||||
|
|
||||||
**Complete Flow:**
|
|
||||||
- `testCompleteScrapingAndStorageFlow` - Scraper -> Storage
|
|
||||||
- `testChapterDownloadFlow` - Descarga completa de capítulo
|
|
||||||
- `testReadingProgressTrackingFlow` - Seguimiento de lectura
|
|
||||||
|
|
||||||
**Multi-Manga Scenarios:**
|
|
||||||
- `testMultipleMangasProgressTracking` - Varios mangas
|
|
||||||
- `testMultipleChapterDownloads` - Descargas de múltiples capítulos
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- `testDownloadFlowWithMissingImages` - Imágenes faltantes
|
|
||||||
- `testStorageCleanupFlow` - Limpieza de almacenamiento
|
|
||||||
|
|
||||||
**Data Persistence:**
|
|
||||||
- `testDataPersistenceAcrossOperations` - Persistencia de datos
|
|
||||||
|
|
||||||
**Concurrent Operations:**
|
|
||||||
- `testConcurrentFavoriteOperations` - Operaciones concurrentes favoritos
|
|
||||||
- `testConcurrentProgressOperations` - Operaciones concurrentes progreso
|
|
||||||
- `testConcurrentImageOperations` - Guardado concurrente de imágenes
|
|
||||||
|
|
||||||
**Large Scale:**
|
|
||||||
- `testLargeScaleFavoriteOperations` - 1000 favoritos
|
|
||||||
- `testLargeScaleProgressOperations` - 500 progresos
|
|
||||||
|
|
||||||
## TestHelpers.swift
|
|
||||||
|
|
||||||
Proporciona helpers y factories para crear datos de prueba:
|
|
||||||
|
|
||||||
### TestDataFactory
|
|
||||||
|
|
||||||
Crea objetos de prueba:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let manga = TestDataFactory.createManga(
|
|
||||||
slug: "test-manga",
|
|
||||||
title: "Test Manga"
|
|
||||||
)
|
|
||||||
|
|
||||||
let chapter = TestDataFactory.createChapter(number: 1)
|
|
||||||
|
|
||||||
let chapters = TestDataFactory.createChapters(count: 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ImageTestHelpers
|
|
||||||
|
|
||||||
Crea imágenes de prueba:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let image = ImageTestHelpers.createTestImage(
|
|
||||||
color: .blue,
|
|
||||||
size: CGSize(width: 800, height: 1200)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### FileSystemTestHelpers
|
|
||||||
|
|
||||||
Operaciones de sistema de archivos:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let tempDir = try FileSystemTestHelpers.createTemporaryDirectory()
|
|
||||||
|
|
||||||
try FileSystemTestHelpers.createTestChapterStructure(
|
|
||||||
mangaSlug: "test",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageCount: 10,
|
|
||||||
in: tempDir
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### StorageTestHelpers
|
|
||||||
|
|
||||||
Limpieza y preparación de almacenamiento:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
StorageTestHelpers.clearAllStorage()
|
|
||||||
|
|
||||||
StorageTestHelpers.seedTestData(
|
|
||||||
favoriteCount: 5,
|
|
||||||
progressCount: 10
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mejores Prácticas
|
|
||||||
|
|
||||||
### 1. Independencia de Tests
|
|
||||||
|
|
||||||
Cada test debe ser independiente y poder ejecutarse solo:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
// Limpiar estado antes del test
|
|
||||||
UserDefaults.standard.removeObject(forKey: "favoritesKey")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Limpiar estado después del test
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Nombres Descriptivos
|
|
||||||
|
|
||||||
Usa nombres que describan qué se está probando:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// ✅ Bueno
|
|
||||||
func testSaveDuplicateFavoriteDoesNotAddDuplicate()
|
|
||||||
|
|
||||||
// ❌ Malo
|
|
||||||
func testFavorite()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Un Assert por Test
|
|
||||||
|
|
||||||
Cuando sea posible, usa un assert por test:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// ✅ Bueno
|
|
||||||
func testFavoriteIsSaved() {
|
|
||||||
storageService.saveFavorite(mangaSlug: "test")
|
|
||||||
XCTAssertTrue(storageService.isFavorite(mangaSlug: "test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFavoriteIsRemoved() {
|
|
||||||
storageService.saveFavorite(mangaSlug: "test")
|
|
||||||
storageService.removeFavorite(mangaSlug: "test")
|
|
||||||
XCTAssertFalse(storageService.isFavorite(mangaSlug: "test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ Evitar
|
|
||||||
func testFavoriteOperations() {
|
|
||||||
storageService.saveFavorite(mangaSlug: "test")
|
|
||||||
XCTAssertTrue(storageService.isFavorite(mangaSlug: "test"))
|
|
||||||
|
|
||||||
storageService.removeFavorite(mangaSlug: "test")
|
|
||||||
XCTAssertFalse(storageService.isFavorite(mangaSlug: "test"))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. AAA Pattern
|
|
||||||
|
|
||||||
Usa el patrón Arrange-Act-Assert:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func testChapterProgressCalculation() {
|
|
||||||
// Arrange - Preparar el test
|
|
||||||
var chapter = Chapter(number: 1, title: "Test", url: "", slug: "")
|
|
||||||
let expectedPage = 5
|
|
||||||
|
|
||||||
// Act - Ejecutar la acción
|
|
||||||
chapter.lastReadPage = expectedPage
|
|
||||||
|
|
||||||
// Assert - Verificar el resultado
|
|
||||||
XCTAssertEqual(chapter.progress, Double(expectedPage))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Mock de Dependencias
|
|
||||||
|
|
||||||
No hagas llamadas de red reales en tests unitarios:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// ✅ Bueno - Mock
|
|
||||||
let mockJSResult = [["number": 10, "title": "Chapter 10"]]
|
|
||||||
let chapters = parseChaptersFromJS(mockJSResult)
|
|
||||||
|
|
||||||
// ❌ Malo - Llamada real
|
|
||||||
let chapters = await scraper.scrapeChapters(mangaSlug: "test")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Tests Asíncronos
|
|
||||||
|
|
||||||
Usa `async/await` apropiadamente:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func testAsyncImageSave() async throws {
|
|
||||||
let image = createTestImage()
|
|
||||||
|
|
||||||
let url = try await storageService.saveImage(
|
|
||||||
image,
|
|
||||||
mangaSlug: "test",
|
|
||||||
chapterNumber: 1,
|
|
||||||
pageIndex: 0
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cobertura de Código
|
|
||||||
|
|
||||||
Objetivos de cobertura:
|
|
||||||
|
|
||||||
- **Modelos**: 95%+ (lógica crítica de datos)
|
|
||||||
- **StorageService**: 90%+ (manejo de archivos y persistencia)
|
|
||||||
- **Scraper**: 85%+ (con mocks de WKWebView)
|
|
||||||
- **Integración**: 80%+ (flujos críticos de usuario)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Tests Fallan Intermittentemente
|
|
||||||
|
|
||||||
Si un test falla solo algunas veces:
|
|
||||||
|
|
||||||
1. Verifica que hay cleanup adecuado en `tearDown()`
|
|
||||||
2. Asegura que los tests son independientes
|
|
||||||
3. Usa `waitFor` apropiadamente para operaciones asíncronas
|
|
||||||
|
|
||||||
### Tests de Performance Fallan
|
|
||||||
|
|
||||||
Si los tests de rendimiento fallan en diferentes máquinas:
|
|
||||||
|
|
||||||
1. Ajusta las métricas según el hardware
|
|
||||||
2. Usa medidas relativas en lugar de absolutas
|
|
||||||
3. Considera deshabilitar tests de performance en CI
|
|
||||||
|
|
||||||
### Memory Leaks en Tests
|
|
||||||
|
|
||||||
Para detectar memory leaks:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func testNoMemoryLeak() {
|
|
||||||
let instance = MyClass()
|
|
||||||
assertNoMemoryLeak(instance)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recursos Adicionales
|
|
||||||
|
|
||||||
- [XCTest Documentation](https://developer.apple.com/documentation/xctest)
|
|
||||||
- [Testing with Xcode](https://developer.apple.com/documentation/xcode/testing)
|
|
||||||
- [Unit Testing Best Practices](https://www.objc.io/books/unit-testing/)
|
|
||||||
|
|
||||||
## Contribuir
|
|
||||||
|
|
||||||
Para agregar nuevos tests:
|
|
||||||
|
|
||||||
1. Decide si es unit test, integration test, o performance test
|
|
||||||
2. Agrega el test al archivo apropiado
|
|
||||||
3. Usa los helpers en `TestHelpers.swift` cuando sea posible
|
|
||||||
4. Asegura que el test es independiente
|
|
||||||
5. Agrega documentación si el test es complejo
|
|
||||||
6. Ejecuta todos los tests para asegurar que nada se rompe
|
|
||||||
|
|
||||||
## Licencia
|
|
||||||
|
|
||||||
Mismo que el proyecto principal.
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
# Resumen de Tests Creados - MangaReader
|
|
||||||
|
|
||||||
## Archivos Creados
|
|
||||||
|
|
||||||
### 1. ModelTests.swift (~17 KB, 350+ líneas)
|
|
||||||
**Tests para modelos de datos:**
|
|
||||||
|
|
||||||
- **Manga Model Tests** (6 tests)
|
|
||||||
- Inicialización y validación de datos
|
|
||||||
- Codable serialization/deserialization
|
|
||||||
- displayStatus (traducción de estados)
|
|
||||||
- Hashable compliance
|
|
||||||
- Arrays vacíos y coverImage nil
|
|
||||||
|
|
||||||
- **Chapter Model Tests** (5 tests)
|
|
||||||
- Inicialización con valores por defecto
|
|
||||||
- displayNumber formatting
|
|
||||||
- Cálculo de progreso
|
|
||||||
- Codable y Hashable
|
|
||||||
|
|
||||||
- **MangaPage Model Tests** (4 tests)
|
|
||||||
- Creación de páginas
|
|
||||||
- thumbnailURL
|
|
||||||
- Codable y Hashable
|
|
||||||
|
|
||||||
- **ReadingProgress Model Tests** (3 tests)
|
|
||||||
- Inicialización
|
|
||||||
- Lógica isCompleted (páginas > 5)
|
|
||||||
- Codable con timestamp
|
|
||||||
|
|
||||||
- **DownloadedChapter Model Tests** (3 tests)
|
|
||||||
- Inicialización
|
|
||||||
- displayTitle formatting
|
|
||||||
- Codable
|
|
||||||
|
|
||||||
- **Edge Cases** (7 tests)
|
|
||||||
- Empty genres
|
|
||||||
- Nil coverImage
|
|
||||||
- Zero chapter numbers
|
|
||||||
- Large page numbers
|
|
||||||
- Negative indices
|
|
||||||
- Zero progress
|
|
||||||
|
|
||||||
- **Performance Tests** (2 tests)
|
|
||||||
- Manga encoding (1000 iteraciones)
|
|
||||||
- Chapter array equality lookup
|
|
||||||
|
|
||||||
### 2. StorageServiceTests.swift (~20 KB, 500+ líneas)
|
|
||||||
**Tests para servicio de almacenamiento:**
|
|
||||||
|
|
||||||
- **Favorites Tests** (7 tests)
|
|
||||||
- Guardar favorito único
|
|
||||||
- Guardar múltiples favoritos
|
|
||||||
- Evitar duplicados
|
|
||||||
- Remover favorito
|
|
||||||
- Verificar isFavorite
|
|
||||||
- Manejo de favoritos inexistentes
|
|
||||||
|
|
||||||
- **Reading Progress Tests** (7 tests)
|
|
||||||
- Guardar progreso individual
|
|
||||||
- Guardar múltiples progresos
|
|
||||||
- Actualizar progreso existente
|
|
||||||
- Obtener último capítulo leído
|
|
||||||
- Manejo de progreso inexistente
|
|
||||||
|
|
||||||
- **Downloaded Chapters Tests** (5 tests)
|
|
||||||
- Guardar metadatos de capítulo
|
|
||||||
- Verificar isChapterDownloaded
|
|
||||||
- Listar capítulos descargados
|
|
||||||
- Eliminar capítulos
|
|
||||||
- Manejo de capítulos inexistentes
|
|
||||||
|
|
||||||
- **Image Caching Tests** (5 tests)
|
|
||||||
- Guardar y cargar imágenes
|
|
||||||
- Cargar imágenes inexistentes
|
|
||||||
- Obtener URL de imagen
|
|
||||||
- Verificar existencia de archivos
|
|
||||||
|
|
||||||
- **Storage Management Tests** (4 tests)
|
|
||||||
- Calcular tamaño de almacenamiento
|
|
||||||
- Limpiar todos los downloads
|
|
||||||
- Formatear tamaño de archivo
|
|
||||||
- Verificar varios tamaños
|
|
||||||
|
|
||||||
- **Directory Management Tests** (2 tests)
|
|
||||||
- Obtener directorio de capítulo
|
|
||||||
- Creación automática de directorios
|
|
||||||
|
|
||||||
- **Edge Cases** (5 tests)
|
|
||||||
- Slug vacío
|
|
||||||
- Caracteres especiales
|
|
||||||
- Progreso con cero páginas
|
|
||||||
- Capítulo número cero
|
|
||||||
- Guardado concurrente de imágenes
|
|
||||||
|
|
||||||
- **Performance Tests** (2 tests)
|
|
||||||
- Guardar 1000 favoritos
|
|
||||||
- Guardar 100 progresos
|
|
||||||
|
|
||||||
### 3. ManhwaWebScraperTests.swift (~18 KB, 450+ líneas)
|
|
||||||
**Tests para web scraper:**
|
|
||||||
|
|
||||||
- **Error Handling Tests** (2 tests)
|
|
||||||
- Descripciones de errores
|
|
||||||
- LocalizedError compliance
|
|
||||||
|
|
||||||
- **Chapter Parsing Tests** (4 tests)
|
|
||||||
- Parsear respuesta de JavaScript
|
|
||||||
- Manejar datos inválidos
|
|
||||||
- Eliminar duplicados
|
|
||||||
- Ordenar capítulos
|
|
||||||
|
|
||||||
- **Image Parsing Tests** (3 tests)
|
|
||||||
- Parsear URLs de imágenes
|
|
||||||
- Filtrar UI elements
|
|
||||||
- Manejar arrays vacíos
|
|
||||||
|
|
||||||
- **Manga Info Parsing Tests** (3 tests)
|
|
||||||
- Extraer información completa
|
|
||||||
- Manejar campos vacíos
|
|
||||||
- Parsear estados
|
|
||||||
|
|
||||||
- **URL Construction Tests** (3 tests)
|
|
||||||
- Construir URLs de manga
|
|
||||||
- Construir URLs de capítulo
|
|
||||||
- Manejar caracteres especiales
|
|
||||||
|
|
||||||
- **Edge Cases** (3 tests)
|
|
||||||
- Extraer número de capítulo con regex
|
|
||||||
- Extraer slug
|
|
||||||
- Eliminar duplicados preservando orden
|
|
||||||
|
|
||||||
- **Performance Tests** (3 tests)
|
|
||||||
- Parsear 1000 capítulos
|
|
||||||
- Filtrar 10,000 imágenes
|
|
||||||
- Ordenar 1000 capítulos
|
|
||||||
|
|
||||||
- **Integration Simulation** (1 test)
|
|
||||||
- Flujo completo simulado
|
|
||||||
|
|
||||||
### 4. IntegrationTests.swift (~20 KB, 550+ líneas)
|
|
||||||
**Tests de integración completa:**
|
|
||||||
|
|
||||||
- **Complete Flow Tests** (4 tests)
|
|
||||||
- Scraper -> Storage completo
|
|
||||||
- Descarga de capítulo con imágenes
|
|
||||||
- Tracking de progreso de lectura
|
|
||||||
- Gestión de favoritos
|
|
||||||
|
|
||||||
- **Multi-Manga Scenarios** (2 tests)
|
|
||||||
- Tracking de múltiples mangas
|
|
||||||
- Descargas de múltiples capítulos
|
|
||||||
|
|
||||||
- **Error Handling Scenarios** (2 tests)
|
|
||||||
- Descarga con imágenes faltantes
|
|
||||||
- Limpieza de almacenamiento
|
|
||||||
|
|
||||||
- **Data Persistence Tests** (1 test)
|
|
||||||
- Persistencia a través de operaciones
|
|
||||||
|
|
||||||
- **Concurrent Operations** (3 tests)
|
|
||||||
- Operaciones concurrentes en favoritos
|
|
||||||
- Operaciones concurrentes en progreso
|
|
||||||
- Guardado concurrente de imágenes (20 imágenes)
|
|
||||||
|
|
||||||
- **Large Scale Tests** (2 tests)
|
|
||||||
- 1000 operaciones de favoritos
|
|
||||||
- 500 operaciones de progreso
|
|
||||||
|
|
||||||
### 5. TestHelpers.swift (~17 KB, 400+ líneas)
|
|
||||||
**Helpers y utilities:**
|
|
||||||
|
|
||||||
- **TestDataFactory**
|
|
||||||
- createManga, createChapter, createMangaPage
|
|
||||||
- createReadingProgress, createDownloadedChapter
|
|
||||||
- createChapters(count:), createPages(count:)
|
|
||||||
|
|
||||||
- **ImageTestHelpers**
|
|
||||||
- createTestImage(color:size:)
|
|
||||||
- createTestImageWithText(size:)
|
|
||||||
- compareImages, isImageNotEmpty
|
|
||||||
|
|
||||||
- **FileSystemTestHelpers**
|
|
||||||
- createTemporaryDirectory, removeTemporaryDirectory
|
|
||||||
- createTestFile, fileExists, fileSize
|
|
||||||
- createTestChapterStructure
|
|
||||||
|
|
||||||
- **StorageTestHelpers**
|
|
||||||
- clearAllStorage
|
|
||||||
- seedTestData
|
|
||||||
- assertStorageIsEmpty
|
|
||||||
|
|
||||||
- **AsyncTestHelpers**
|
|
||||||
- executeWithTimeout
|
|
||||||
|
|
||||||
- **ScraperTestHelpers**
|
|
||||||
- mockChapterListHTML, mockChapterImagesHTML
|
|
||||||
- mockMangaInfoHTML
|
|
||||||
- mockChapterJSResult, mockImagesJSResult
|
|
||||||
- mockMangaInfoJSResult
|
|
||||||
|
|
||||||
- **AssertionHelpers**
|
|
||||||
- assertArraysEqual, assertArrayContains
|
|
||||||
- assertValidURL, assertValidManga, assertValidChapter
|
|
||||||
|
|
||||||
- **PerformanceTestHelpers**
|
|
||||||
- measureTime, measureAsyncTime, averageTime
|
|
||||||
|
|
||||||
### 6. XCTestSuiteExtensions.swift (~10 KB, 250+ líneas)
|
|
||||||
**Extensiones de XCTest:**
|
|
||||||
|
|
||||||
- **Async Extensions**
|
|
||||||
- wait(for duration:)
|
|
||||||
|
|
||||||
- **Operation Helpers**
|
|
||||||
- waitForOperation(timeout:operation:)
|
|
||||||
|
|
||||||
- **Error Assertions**
|
|
||||||
- assertThrowsError
|
|
||||||
- assertNoThrow
|
|
||||||
|
|
||||||
- **Custom Assertions**
|
|
||||||
- assertDatesEqual, assertCount, assertEmpty, assertNotEmpty
|
|
||||||
|
|
||||||
- **Memory Leak Detection**
|
|
||||||
- assertNoMemoryLeak
|
|
||||||
|
|
||||||
- **Test Logging**
|
|
||||||
- logTest(_:level:)
|
|
||||||
|
|
||||||
- **Cleanup Helpers**
|
|
||||||
- clearAllUserDefaults, clearTemporaryDirectory
|
|
||||||
|
|
||||||
- **Test Metrics**
|
|
||||||
- recordMetric, assertMetricImproved
|
|
||||||
|
|
||||||
- **Documentation**
|
|
||||||
- Guía de ejecución
|
|
||||||
- Estructura de tests
|
|
||||||
- Mejores prácticas
|
|
||||||
|
|
||||||
### 7. README.md (~12 KB, 400+ líneas)
|
|
||||||
**Documentación completa:**
|
|
||||||
|
|
||||||
- Descripción general de la suite
|
|
||||||
- Estructura de tests
|
|
||||||
- Cómo ejecutar tests (Xcode y CLI)
|
|
||||||
- Guía detallada de cada test
|
|
||||||
- Mejores prácticas de testing
|
|
||||||
- Troubleshooting
|
|
||||||
- Recursos adicionales
|
|
||||||
|
|
||||||
### 8. run_tests.sh (~6 KB, 200 líneas)
|
|
||||||
**Script para ejecutar tests:**
|
|
||||||
|
|
||||||
- Opciones de ejecución (--all, --unit, --integration)
|
|
||||||
- Soporte para cobertura de código
|
|
||||||
- Output con colores
|
|
||||||
- Limpieza de build
|
|
||||||
- Ayuda integrada
|
|
||||||
|
|
||||||
## Estadísticas Totales
|
|
||||||
|
|
||||||
**Cantidad de Tests:**
|
|
||||||
- ModelTests: ~35 tests
|
|
||||||
- StorageServiceTests: ~40 tests
|
|
||||||
- ManhwaWebScraperTests: ~25 tests
|
|
||||||
- IntegrationTests: ~20 tests
|
|
||||||
- **Total: ~120 tests**
|
|
||||||
|
|
||||||
**Líneas de Código:**
|
|
||||||
- Código de tests: ~1,850 líneas
|
|
||||||
- Helpers y utilities: ~650 líneas
|
|
||||||
- Documentación: ~400 líneas
|
|
||||||
- **Total: ~2,900 líneas**
|
|
||||||
|
|
||||||
**Cobertura:**
|
|
||||||
- Modelos: 95%+
|
|
||||||
- StorageService: 90%+
|
|
||||||
- ManhwaWebScraper: 85%+ (con mocks)
|
|
||||||
- Integración: 80%+
|
|
||||||
|
|
||||||
## Características Principales
|
|
||||||
|
|
||||||
### 1. Tests Independientes
|
|
||||||
- Cada test tiene su propio setup/teardown
|
|
||||||
- Los tests pueden ejecutarse en cualquier orden
|
|
||||||
- Limpieza automática de estado
|
|
||||||
|
|
||||||
### 2. Setup y Teardown
|
|
||||||
- `setUp()` ejecuta antes de cada test
|
|
||||||
- `tearDown()` limpia después de cada test
|
|
||||||
- Limpieza de UserDefaults, archivos, etc.
|
|
||||||
|
|
||||||
### 3. Mocks Apropiados
|
|
||||||
- Mock de WKWebView responses
|
|
||||||
- Mock de HTML/JavaScript
|
|
||||||
- TestDataFactory para objetos de prueba
|
|
||||||
|
|
||||||
### 4. Tests Asíncronos
|
|
||||||
- Uso de async/await
|
|
||||||
- Tests de concurrencia
|
|
||||||
- Timeouts apropiados
|
|
||||||
|
|
||||||
### 5. Performance Tests
|
|
||||||
- Medición de rendimiento
|
|
||||||
- Tests de gran escala
|
|
||||||
- Comparativas de métricas
|
|
||||||
|
|
||||||
### 6. Edge Cases
|
|
||||||
- Datos inválidos
|
|
||||||
- Arrays vacíos
|
|
||||||
- Valores nulos
|
|
||||||
- Caracteres especiales
|
|
||||||
- Operaciones concurrentes
|
|
||||||
|
|
||||||
### 7. Documentación Completa
|
|
||||||
- README detallado
|
|
||||||
- Comentarios en cada test
|
|
||||||
- Ejemplos de uso
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
## Cómo Ejecutar
|
|
||||||
|
|
||||||
### En Xcode:
|
|
||||||
```bash
|
|
||||||
# Todos los tests
|
|
||||||
Cmd + U
|
|
||||||
|
|
||||||
# Test específico
|
|
||||||
Click derecho > Run
|
|
||||||
|
|
||||||
# Con cobertura
|
|
||||||
Product > Test > Gather coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
### Con script:
|
|
||||||
```bash
|
|
||||||
# Todos los tests
|
|
||||||
./run_tests.sh --all
|
|
||||||
|
|
||||||
# Con cobertura
|
|
||||||
./run_tests.sh --all --coverage
|
|
||||||
|
|
||||||
# Solo unitarios
|
|
||||||
./run_tests.sh --unit
|
|
||||||
|
|
||||||
# Solo integración
|
|
||||||
./run_tests.sh --integration --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Con xcodebuild:
|
|
||||||
```bash
|
|
||||||
xcodebuild test -scheme MangaReader \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 15'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Próximos Pasos
|
|
||||||
|
|
||||||
1. **Ejecutar los tests** para verificar que funcionan
|
|
||||||
2. **Agregar al proyecto Xcode** como target de tests
|
|
||||||
3. **Configurar CI/CD** para ejecutar tests automáticamente
|
|
||||||
4. **Ajustar cobertura** según necesidades
|
|
||||||
5. **Agregar tests adicionales** para nuevas features
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
- Todos los tests usan XCTest framework
|
|
||||||
- Compatible con iOS 15+
|
|
||||||
- Requiere Xcode 14+
|
|
||||||
- Tests marcados con @MainActor donde es necesario
|
|
||||||
- Soporte completo para async/await
|
|
||||||
Reference in New Issue
Block a user