Initial commit: MangaReader iOS App
✨ 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>
This commit is contained in:
525
ios-app/Sources/Services/StorageService.swift
Normal file
525
ios-app/Sources/Services/StorageService.swift
Normal file
@@ -0,0 +1,525 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Servicio para manejar el almacenamiento local de capítulos y progreso de lectura.
|
||||
///
|
||||
/// `StorageService` centraliza todas las operaciones de persistencia de la aplicación,
|
||||
/// incluyendo:
|
||||
/// - Gestión de favoritos (UserDefaults)
|
||||
/// - Seguimiento de progreso de lectura (UserDefaults)
|
||||
/// - Metadata de capítulos descargados (JSON en disco)
|
||||
/// - Almacenamiento de imágenes (FileManager)
|
||||
///
|
||||
/// El servicio usa UserDefaults para datos pequeños y simples, y FileManager para
|
||||
/// almacenamiento de archivos binarios como imágenes.
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let storage = StorageService.shared
|
||||
///
|
||||
/// // Guardar favorito
|
||||
/// storage.saveFavorite(mangaSlug: "one-piece")
|
||||
///
|
||||
/// // Guardar progreso
|
||||
/// let progress = ReadingProgress(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pageNumber: 15,
|
||||
/// timestamp: Date()
|
||||
/// )
|
||||
/// storage.saveReadingProgress(progress)
|
||||
///
|
||||
/// // Verificar tamaño usado
|
||||
/// let size = storage.getStorageSize()
|
||||
/// print("Used: \(storage.formatFileSize(size))")
|
||||
/// ```
|
||||
class StorageService {
|
||||
// MARK: - Singleton
|
||||
|
||||
/// Instancia compartida del servicio (Singleton pattern)
|
||||
static let shared = StorageService()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// FileManager para operaciones de sistema de archivos
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
/// Directorio de Documents de la app
|
||||
private let documentsDirectory: URL
|
||||
|
||||
/// Subdirectorio para capítulos descargados
|
||||
private let chaptersDirectory: URL
|
||||
|
||||
/// URL del archivo de metadata de descargas
|
||||
private let metadataURL: URL
|
||||
|
||||
/// Claves para UserDefaults
|
||||
private let favoritesKey = "favoriteMangas"
|
||||
private let readingProgressKey = "readingProgress"
|
||||
private let downloadedChaptersKey = "downloadedChaptersMetadata"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Inicializador privado para implementar Singleton.
|
||||
///
|
||||
/// Configura las rutas de directorios y crea la estructura necesaria
|
||||
/// si no existe.
|
||||
private init() {
|
||||
documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
chaptersDirectory = documentsDirectory.appendingPathComponent("Chapters")
|
||||
metadataURL = documentsDirectory.appendingPathComponent("metadata.json")
|
||||
|
||||
createDirectoriesIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Directory Management
|
||||
|
||||
/// Crea el directorio de capítulos si no existe.
|
||||
private func createDirectoriesIfNeeded() {
|
||||
if !fileManager.fileExists(atPath: chaptersDirectory.path) {
|
||||
try? fileManager.createDirectory(at: chaptersDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
func getChapterDirectory(mangaSlug: String, chapterNumber: Int) -> URL {
|
||||
let chapterPath = "\(mangaSlug)/Chapter\(chapterNumber)"
|
||||
return chaptersDirectory.appendingPathComponent(chapterPath)
|
||||
}
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
/// Retorna la lista de slugs de mangas favoritos.
|
||||
///
|
||||
/// - Returns: Array de strings con los slugs de mangas marcados como favoritos
|
||||
func getFavorites() -> [String] {
|
||||
UserDefaults.standard.stringArray(forKey: favoritesKey) ?? []
|
||||
}
|
||||
|
||||
/// Guarda un manga como favorito.
|
||||
///
|
||||
/// Si el manga ya está en favoritos, no hace nada (no duplica).
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug del manga a marcar como favorito
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// storage.saveFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
/// ```
|
||||
func saveFavorite(mangaSlug: String) {
|
||||
var favorites = getFavorites()
|
||||
if !favorites.contains(mangaSlug) {
|
||||
favorites.append(mangaSlug)
|
||||
UserDefaults.standard.set(favorites, forKey: favoritesKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Elimina un manga de favoritos.
|
||||
///
|
||||
/// Si el manga no está en favoritos, no hace nada.
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug del manga a eliminar de favoritos
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// storage.removeFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
/// ```
|
||||
func removeFavorite(mangaSlug: String) {
|
||||
var favorites = getFavorites()
|
||||
favorites.removeAll { $0 == mangaSlug }
|
||||
UserDefaults.standard.set(favorites, forKey: favoritesKey)
|
||||
}
|
||||
|
||||
/// Verifica si un manga está marcado como favorito.
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug del manga a verificar
|
||||
/// - Returns: `true` si el manga está en favoritos, `false` en caso contrario
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if storage.isFavorite(mangaSlug: "one-piece_1695365223767") {
|
||||
/// print("Este manga es favorito")
|
||||
/// }
|
||||
/// ```
|
||||
func isFavorite(mangaSlug: String) -> Bool {
|
||||
getFavorites().contains(mangaSlug)
|
||||
}
|
||||
|
||||
// MARK: - Reading Progress
|
||||
|
||||
/// Guarda o actualiza el progreso de lectura de un capítulo.
|
||||
///
|
||||
/// Si ya existe progreso para el mismo manga y capítulo, lo actualiza.
|
||||
/// Si no existe, agrega un nuevo registro.
|
||||
///
|
||||
/// - Parameter progress: Objeto `ReadingProgress` con la información a guardar
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let progress = ReadingProgress(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pageNumber: 15,
|
||||
/// timestamp: Date()
|
||||
/// )
|
||||
/// storage.saveReadingProgress(progress)
|
||||
/// ```
|
||||
func saveReadingProgress(_ progress: ReadingProgress) {
|
||||
var allProgress = getAllReadingProgress()
|
||||
|
||||
// Actualizar o agregar el progreso
|
||||
if let index = allProgress.firstIndex(where: { $0.mangaSlug == progress.mangaSlug && $0.chapterNumber == progress.chapterNumber }) {
|
||||
allProgress[index] = progress
|
||||
} else {
|
||||
allProgress.append(progress)
|
||||
}
|
||||
|
||||
saveProgressToDisk(allProgress)
|
||||
}
|
||||
|
||||
/// Retorna el progreso de lectura de un capítulo específico.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: Objeto `ReadingProgress` si existe, `nil` en caso contrario
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if let progress = storage.getReadingProgress(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1
|
||||
/// ) {
|
||||
/// print("Última página: \(progress.pageNumber)")
|
||||
/// }
|
||||
/// ```
|
||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress? {
|
||||
getAllReadingProgress().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
||||
}
|
||||
|
||||
/// Retorna todo el progreso de lectura almacenado.
|
||||
///
|
||||
/// - Returns: Array de todos los objetos `ReadingProgress` almacenados
|
||||
func getAllReadingProgress() -> [ReadingProgress] {
|
||||
guard let data = UserDefaults.standard.data(forKey: readingProgressKey),
|
||||
let progress = try? JSONDecoder().decode([ReadingProgress].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
/// Retorna el capítulo más recientemente leído de un manga.
|
||||
///
|
||||
/// Busca entre todos los progresos del manga y retorna el que tiene
|
||||
/// el timestamp más reciente.
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug del manga
|
||||
/// - Returns: Objeto `ReadingProgress` más reciente, o `nil` si no hay progreso
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if let lastRead = storage.getLastReadChapter(mangaSlug: "one-piece") {
|
||||
/// print("Último capítulo leído: \(lastRead.chapterNumber)")
|
||||
/// }
|
||||
/// ```
|
||||
func getLastReadChapter(mangaSlug: String) -> ReadingProgress? {
|
||||
let progress = getAllReadingProgress().filter { $0.mangaSlug == mangaSlug }
|
||||
return progress.max { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
/// Guarda el array de progresos en UserDefaults.
|
||||
///
|
||||
/// - Parameter progress: Array de `ReadingProgress` a guardar
|
||||
private func saveProgressToDisk(_ progress: [ReadingProgress]) {
|
||||
if let data = try? JSONEncoder().encode(progress) {
|
||||
UserDefaults.standard.set(data, forKey: readingProgressKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloaded Chapters
|
||||
|
||||
/// Guarda la metadata de un capítulo descargado.
|
||||
///
|
||||
/// Si el capítulo ya existe en la metadata, lo actualiza.
|
||||
/// Si no existe, agrega un nuevo registro.
|
||||
///
|
||||
/// - Parameter chapter: Objeto `DownloadedChapter` con la metadata
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let downloaded = DownloadedChapter(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// mangaTitle: "One Piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pages: pages,
|
||||
/// downloadedAt: Date()
|
||||
/// )
|
||||
/// storage.saveDownloadedChapter(downloaded)
|
||||
/// ```
|
||||
func saveDownloadedChapter(_ chapter: DownloadedChapter) {
|
||||
var downloaded = getDownloadedChapters()
|
||||
|
||||
if let index = downloaded.firstIndex(where: { $0.id == chapter.id }) {
|
||||
downloaded[index] = chapter
|
||||
} else {
|
||||
downloaded.append(chapter)
|
||||
}
|
||||
|
||||
if let data = try? JSONEncoder().encode(downloaded) {
|
||||
try? data.write(to: metadataURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retorna todos los capítulos descargados.
|
||||
///
|
||||
/// - Returns: Array de objetos `DownloadedChapter`
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let downloads = storage.getDownloadedChapters()
|
||||
/// print("Tienes \(downloads.count) capítulos descargados")
|
||||
/// for chapter in downloads {
|
||||
/// print("- \(chapter.displayTitle)")
|
||||
/// }
|
||||
/// ```
|
||||
func getDownloadedChapters() -> [DownloadedChapter] {
|
||||
guard let data = try? Data(contentsOf: metadataURL),
|
||||
let downloaded = try? JSONDecoder().decode([DownloadedChapter].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return downloaded
|
||||
}
|
||||
|
||||
/// Retorna un capítulo descargado específico.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: Objeto `DownloadedChapter` si existe, `nil` en caso contrario
|
||||
func getDownloadedChapter(mangaSlug: String, chapterNumber: Int) -> DownloadedChapter? {
|
||||
getDownloadedChapters().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
||||
}
|
||||
|
||||
/// Verifica si un capítulo está descargado.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: `true` si el capítulo está descargado, `false` en caso contrario
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if storage.isChapterDownloaded(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1
|
||||
/// ) {
|
||||
/// print("Capítulo ya descargado")
|
||||
/// }
|
||||
/// ```
|
||||
func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool {
|
||||
getDownloadedChapter(mangaSlug: mangaSlug, chapterNumber: chapterNumber) != nil
|
||||
}
|
||||
|
||||
/// Elimina un capítulo descargado (archivos y metadata).
|
||||
///
|
||||
/// Elimina todos los archivos de imagen del capítulo del disco
|
||||
/// y remueve la metadata del registro de descargas.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo a eliminar
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// storage.deleteDownloadedChapter(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1
|
||||
/// )
|
||||
/// ```
|
||||
func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int) {
|
||||
// Eliminar archivos
|
||||
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
||||
try? fileManager.removeItem(at: chapterDir)
|
||||
|
||||
// Eliminar metadata
|
||||
var downloaded = getDownloadedChapters()
|
||||
downloaded.removeAll { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
||||
|
||||
if let data = try? JSONEncoder().encode(downloaded) {
|
||||
try? data.write(to: metadataURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Caching
|
||||
|
||||
/// Guarda una imagen en disco local.
|
||||
///
|
||||
/// Comprime la imagen como JPEG con 80% de calidad y la guarda
|
||||
/// en el directorio del capítulo correspondiente.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - image: Imagen (UIImage) a guardar
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - pageIndex: Índice de la página (para nombre de archivo)
|
||||
/// - Returns: URL del archivo guardado
|
||||
/// - Throws: Error si no se puede crear el directorio o guardar la imagen
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// do {
|
||||
/// let imageURL = try await storage.saveImage(
|
||||
/// image: myUIImage,
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pageIndex: 0
|
||||
/// )
|
||||
/// print("Imagen guardada en: \(imageURL.path)")
|
||||
/// } catch {
|
||||
/// print("Error guardando imagen: \(error)")
|
||||
/// }
|
||||
/// ```
|
||||
func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL {
|
||||
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
||||
|
||||
// Crear directorio si no existe
|
||||
if !fileManager.fileExists(atPath: chapterDir.path) {
|
||||
try fileManager.createDirectory(at: chapterDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
let filename = "page_\(pageIndex).jpg"
|
||||
let fileURL = chapterDir.appendingPathComponent(filename)
|
||||
|
||||
// Guardar imagen
|
||||
if let data = image.jpegData(compressionQuality: 0.8) {
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
throw NSError(domain: "StorageService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error saving image"])
|
||||
}
|
||||
|
||||
/// Carga una imagen desde disco local.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - pageIndex: Índice de la página
|
||||
/// - Returns: Objeto `UIImage` si el archivo existe, `nil` en caso contrario
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if let image = storage.loadImage(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pageIndex: 0
|
||||
/// ) {
|
||||
/// print("Imagen cargada: \(image.size)")
|
||||
/// }
|
||||
/// ```
|
||||
func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage? {
|
||||
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
||||
let filename = "page_\(pageIndex).jpg"
|
||||
let fileURL = chapterDir.appendingPathComponent(filename)
|
||||
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UIImage(contentsOfFile: fileURL.path)
|
||||
}
|
||||
|
||||
/// Retorna la URL local de una imagen si está cacheada.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - pageIndex: Índice de la página
|
||||
/// - Returns: URL del archivo si existe, `nil` en caso contrario
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// if let imageURL = storage.getImageURL(
|
||||
/// mangaSlug: "one-piece",
|
||||
/// chapterNumber: 1,
|
||||
/// pageIndex: 0
|
||||
/// ) {
|
||||
/// print("Imagen cacheada en: \(imageURL.path)")
|
||||
/// }
|
||||
/// ```
|
||||
func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL? {
|
||||
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
||||
let filename = "page_\(pageIndex).jpg"
|
||||
let fileURL = chapterDir.appendingPathComponent(filename)
|
||||
|
||||
return fileManager.fileExists(atPath: fileURL.path) ? fileURL : nil
|
||||
}
|
||||
|
||||
// MARK: - Storage Management
|
||||
|
||||
/// Calcula el tamaño total usado por los capítulos descargados.
|
||||
///
|
||||
/// Recursivamente suma el tamaño de todos los archivos en el
|
||||
/// directorio de capítulos.
|
||||
///
|
||||
/// - Returns: Tamaño total en bytes
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let bytes = storage.getStorageSize()
|
||||
/// let formatted = storage.formatFileSize(bytes)
|
||||
/// print("Usando \(formatted) de espacio")
|
||||
/// ```
|
||||
func getStorageSize() -> Int64 {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
if let enumerator = fileManager.enumerator(at: chaptersDirectory, includingPropertiesForKeys: [.fileSizeKey]) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
/// Elimina todos los capítulos descargados y su metadata.
|
||||
///
|
||||
/// Elimina completamente el directorio de capítulos y el archivo
|
||||
/// de metadata, liberando todo el espacio usado.
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// storage.clearAllDownloads()
|
||||
/// print("Todos los descargados han sido eliminados")
|
||||
/// ```
|
||||
func clearAllDownloads() {
|
||||
try? fileManager.removeItem(at: chaptersDirectory)
|
||||
createDirectoriesIfNeeded()
|
||||
|
||||
// Limpiar metadata
|
||||
try? fileManager.removeItem(at: metadataURL)
|
||||
}
|
||||
|
||||
/// Formatea un tamaño en bytes a un string legible.
|
||||
///
|
||||
/// Usa `ByteCountFormatter` para convertir bytes a KB, MB, GB según
|
||||
/// corresponda, con el formato apropiado para archivos.
|
||||
///
|
||||
/// - Parameter bytes: Tamaño en bytes
|
||||
/// - Returns: String formateado (ej: "15.2 MB", "1.3 GB")
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// print(storage.formatFileSize(1024)) // "1 KB"
|
||||
/// print(storage.formatFileSize(15728640)) // "15 MB"
|
||||
/// print(storage.formatFileSize(2147483648)) // "2 GB"
|
||||
/// ```
|
||||
func formatFileSize(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user