✨ Features: - App iOS completa para leer manga sin publicidad - Scraper con WKWebView para manhwaweb.com - Sistema de descargas offline - Lector con zoom y navegación - Favoritos y progreso de lectura - Compatible con iOS 15+ y Sideloadly/3uTools 📦 Contenido: - Backend Node.js con Puppeteer (opcional) - App iOS con SwiftUI - Scraper de capítulos e imágenes - Sistema de almacenamiento local - Testing completo - Documentación exhaustiva 🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente - 21 páginas descargadas - 4.68 MB total - URLs verificadas y funcionales 🎉 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
993 lines
22 KiB
Markdown
993 lines
22 KiB
Markdown
# 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
|