From a5668911d45da0decbad3ee726f19d51d2a18d16 Mon Sep 17 00:00:00 2001 From: Jesus rodolfo gil Farias Date: Sun, 19 Jul 2026 12:17:47 -0400 Subject: [PATCH 1/3] feat: implement Google Drive BYOS sync architecture including authentication, storage container, and UI sync panel --- .env.example | 27 ++- Docs/05-stack-sugerido.md | 29 ++- Docs/06-plan-de-trabajo.md | 23 +-- src/application/ports.ts | 8 + src/infrastructure/auth/google-auth.ts | 39 ++++ src/infrastructure/container.ts | 10 +- src/infrastructure/sync/google-drive-sync.ts | 150 ++++++++++++++ src/infrastructure/sync/supabase-sync.ts | 47 ----- src/ui/App.tsx | 16 +- src/ui/components/SyncPanel.tsx | 198 +++++++++++++++++++ src/ui/pages/CatalogoPage.tsx | 56 ++---- 11 files changed, 487 insertions(+), 116 deletions(-) create mode 100644 src/infrastructure/auth/google-auth.ts create mode 100644 src/infrastructure/sync/google-drive-sync.ts delete mode 100644 src/infrastructure/sync/supabase-sync.ts create mode 100644 src/ui/components/SyncPanel.tsx diff --git a/.env.example b/.env.example index d2c1540..b724bd4 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,14 @@ # CapMark — configuración. La app funciona 100% en local SIN estas variables. -# Rellénalas solo si quieres sincronización multi-dispositivo con tu backend auto-instanciado. -# Genera este archivo como .env con: ./scripts/bootstrap.sh +# Para sincronización BYOS con Google Drive, rellena VITE_GOOGLE_CLIENT_ID. -# --- Backend (Supabase self-hosted; ver infra/docker-compose.yml) --- -VITE_SUPABASE_URL=http://localhost:8000 -VITE_SUPABASE_ANON_KEY= +# --- Google Drive BYOS Sync --- +# Crea un proyecto en https://console.cloud.google.com, habilita la Drive API +# y genera un OAuth 2.0 Client ID (tipo "Web application"). +# Autoriza como origen: http://localhost:5173 (desarrollo) y tu dominio en producción. +VITE_GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com -# --- Edge Functions --- -# Verificación de links del lado servidor (RF-017, evita CORS) -VITE_VERIFY_URL=http://localhost:8000/functions/v1/verify-link -# Proxy para el scraper opt-in (descarga el HTML sin CORS) -VITE_SCRAPER_PROXY=http://localhost:8000/functions/v1/fetch-html - -# --- Secretos del backend (usados por docker-compose, NO por la app cliente) --- -POSTGRES_PASSWORD=change-me-please -JWT_SECRET=change-me-a-32-char-min-secret-string -ANON_KEY= -SERVICE_ROLE_KEY= +# --- Proxy opcional para verificación de links (RF-017) --- +# Sin backend, la verificación usa no-cors como fallback (best-effort). +# Para verificación confiable, apunta a un worker (ej. Cloudflare Workers): +# VITE_VERIFY_URL=https://tu-worker.workers.dev/verify-link +# VITE_SCRAPER_PROXY=https://tu-worker.workers.dev/fetch-html diff --git a/Docs/05-stack-sugerido.md b/Docs/05-stack-sugerido.md index 55127fa..13bc35d 100644 --- a/Docs/05-stack-sugerido.md +++ b/Docs/05-stack-sugerido.md @@ -31,8 +31,31 @@ | Backend / sync | **Firebase (Firestore + Auth)** | Sync offline muy pulido, pero NoSQL: el modelo relacional Obra/Fuente/Progreso queda menos natural y hay más *lock-in*. | | Sync avanzado (opcional) | **PowerSync / ElectricSQL** sobre Postgres | Sync local-first con Postgres si la calidad offline es crítica; más piezas que mantener para un proyecto personal. | +## Opción C — BYOS Google Drive (**implementada**) + +| Capa | Elección | Justificación | +|------|----------|-| +| App / UI | **Ionic + Capacitor** con TypeScript (React) | Sin cambios frente a Opción A. | +| Persistencia local + offline | **Dexie / IndexedDB** | Ídem. Los datos locales siguen siendo la fuente de verdad. | +| Backend, auth y sync | **Google Drive `appDataFolder`** (BYOS) | Costo $0; el usuario autentifica con su propia cuenta Google via OAuth 2.0 (`drive.appdata`). La carpeta es invisible para el usuario y aislada por `client_id`. | +| Auth | **`@react-oauth/google`** (popup nativo) | Sin servidor propio de auth; Google gestiona tokens y renovación. | +| Verificación de links | Fallback `no-cors` desde cliente (best-effort) | Sin Edge Function; RF-017 es `Should`. Opcional: Cloudflare Worker gratuito como proxy. | +| Estrategia de conflictos | Última escritura gana por `exportadoEn` | Igual que la Opción A pero sin Realtime; sync manual o al abrir la app. | + +### Ventajas frente a Opción A + +- **Costo cero de infraestructura**: no hay Docker, Postgres ni servidor que mantener. +- **Cero fricción de instanciación**: `VITE_GOOGLE_CLIENT_ID` en `.env` es el único requisito. +- **Privacidad**: los datos viven en la cuenta personal del usuario, no en un servidor tuyo. + +### Limitaciones frente a Opción A + +- Sync manual (no Realtime en tiempo real); suficiente para uso personal. +- RF-017 (verificación de links) en modo best-effort sin proxy externo. +- Requiere conexión para sincronizar (la app sigue funcionando offline en local). + ## Recomendación -Para un MVP personal con multi-dispositivo, offline y modelo relacional, **Opción A** -(Ionic + Capacitor + Supabase) ofrece el mejor equilibrio entre esfuerzo, portabilidad y -reutilización de lo que ya dominas, y deja el camino abierto al bot de Telegram en fase 2. +Para un MVP personal local-first de costo cero, **Opción C** (BYOS Google Drive) es la +elección óptima. Elimina toda la infraestructura self-hosted manteniendo la arquitectura +en capas que permite volver a la Opción A en cualquier momento cambiando solo `container.ts`. diff --git a/Docs/06-plan-de-trabajo.md b/Docs/06-plan-de-trabajo.md index 1aab458..9f7ae85 100644 --- a/Docs/06-plan-de-trabajo.md +++ b/Docs/06-plan-de-trabajo.md @@ -6,14 +6,13 @@ --- -## 0. Principio rector: infraestructura auto-instanciable +## 0. Principio rector: local-first + BYOS -Requisito explícito del proyecto: **no montar Supabase (ni backend alguno) a mano**. Toda la -infraestructura debe poder levantarse **con un comando**, versionada como código, de modo que -cualquier persona (o cualquier dispositivo/entorno) instancie su propio backend sin pasos -manuales en paneles web. +Requisito explícito del proyecto: **costo cero de infraestructura**. La sincronización +usa la cuenta personal de Google del usuario (`appDataFolder`, invisible en Drive UI). +La app es completamente funcional sin conexión; sync es una acción manual. -Esto se traduce en reglas que atraviesan **todas** las fases: +Reglas que atraviesan **todas** las fases: - **Backend self-hosted por Docker Compose.** Nada de crear proyectos a mano en un panel. `docker compose up` levanta Postgres + Auth + Realtime + Edge Functions. @@ -88,11 +87,13 @@ scripts/ - **Entregable:** CRUD local funcional sin red; los cambios quedan encolados. - **Cubre:** RNF-004 (offline), RNF-007 (no pérdida de progreso). -### Fase 3 — Autenticación y sincronización (F-05) -- Supabase Auth self-hosted; sesión persistente entre aperturas (RF-015). -- Motor de sync: sube el outbox al reconectar, recibe cambios por Realtime, resuelve por LWW. -- **Entregable:** dos dispositivos/usuarios sincronizando altas, cambios y borrados. -- **Cubre:** RF-015, RF-016; RNF-005 (< 10 s), RNF-006 (HTTPS/TLS en transporte). +### Fase 3 — Autenticación y sincronización BYOS (F-05) — **implementada** +- Google OAuth 2.0 con scope `drive.appdata`; token gestionado por `GoogleAuthStore` en memoria. +- Motor de sync: `GoogleDriveSync` sube el catálogo completo como `capmark-db.json` a `appDataFolder` + (invisible para el usuario). Pull descarga y fusiona por LWW (`exportadoEn`). +- UI: `SyncPanel` (modal desde toolbar) con botones subir / bajar / sincronizar. +- **Entregable:** dos dispositivos del mismo usuario sincronizan el catálogo vía Google Drive. +- **Cubre:** RF-015 (auth via Google), RF-016 (sync offline-first); RNF-006 (HTTPS/TLS nativo en Drive API). ### Fase 4 — UI de obras y fuentes (F-01, F-02) - Catálogo (lista) + alta/edición de obra: título, tipo, alias, tags, estado, prioridad, notas. diff --git a/src/application/ports.ts b/src/application/ports.ts index e2ff7c7..4af0fa9 100644 --- a/src/application/ports.ts +++ b/src/application/ports.ts @@ -63,4 +63,12 @@ export interface SyncPort { probarConexion(): Promise; } +/** Autenticación opcional con un proveedor externo (Google). La app funciona sin él. */ +export interface AuthPort { + getToken(): string | null; + setToken(token: string, userEmail?: string): void; + getUserEmail(): string | null; + logout(): void; +} + export type { NuevaObra, NuevaFuente, NuevoProgreso }; diff --git a/src/infrastructure/auth/google-auth.ts b/src/infrastructure/auth/google-auth.ts new file mode 100644 index 0000000..69f08ae --- /dev/null +++ b/src/infrastructure/auth/google-auth.ts @@ -0,0 +1,39 @@ +import { AuthPort } from '@application/ports'; + +/** + * Store en memoria para el token OAuth de Google Drive. + * No persiste en localStorage: los access tokens expiran en ~1 hora y + * @react-oauth/google los renueva silenciosamente. Solo el email se guarda + * en sessionStorage para mostrarlo en la UI entre recargas dentro de la misma sesión. + */ +export class GoogleAuthStore implements AuthPort { + private token: string | null = null; + private email: string | null = null; + + constructor() { + // Restaurar email de sesión (cosmético, el token siempre se reobtiene via OAuth) + this.email = sessionStorage.getItem('capmark_google_email'); + } + + getToken(): string | null { + return this.token; + } + + setToken(token: string, userEmail?: string): void { + this.token = token; + if (userEmail) { + this.email = userEmail; + sessionStorage.setItem('capmark_google_email', userEmail); + } + } + + getUserEmail(): string | null { + return this.email; + } + + logout(): void { + this.token = null; + this.email = null; + sessionStorage.removeItem('capmark_google_email'); + } +} diff --git a/src/infrastructure/container.ts b/src/infrastructure/container.ts index 1c7ec15..395c040 100644 --- a/src/infrastructure/container.ts +++ b/src/infrastructure/container.ts @@ -6,23 +6,27 @@ import { CatalogoService } from '@application/catalogo-service'; import { FuenteService } from '@application/fuente-service'; import { ProgresoService } from '@application/progreso-service'; import { ScraperService } from '@application/scraper-service'; +import { GoogleAuthStore } from './auth/google-auth'; import { db } from './persistence/db'; import { DexieRepositorio } from './persistence/dexie-repositorio'; import { GenericScraper } from './scraper/generic-scraper'; -import { SupabaseSync } from './sync/supabase-sync'; +import { GoogleDriveSync } from './sync/google-drive-sync'; import { idGen, reloj } from './system'; import { HttpLinkVerifier } from './verify/link-verifier'; const repo = new DexieRepositorio(db); const verifier = new HttpLinkVerifier(); +const authStore = new GoogleAuthStore(); +const backupService = new BackupService(repo); export const container = { catalogo: new CatalogoService(repo, idGen, reloj), fuentes: new FuenteService(repo, idGen, verifier), progreso: new ProgresoService(repo, idGen, reloj), scraper: new ScraperService([new GenericScraper()]), - backup: new BackupService(repo), - sync: new SupabaseSync(), + backup: backupService, + auth: authStore, + sync: new GoogleDriveSync(authStore, backupService), }; export type Container = typeof container; diff --git a/src/infrastructure/sync/google-drive-sync.ts b/src/infrastructure/sync/google-drive-sync.ts new file mode 100644 index 0000000..da7bdaf --- /dev/null +++ b/src/infrastructure/sync/google-drive-sync.ts @@ -0,0 +1,150 @@ +import { BackupService } from '@application/backup-service'; +import { AuthPort, SyncPort } from '@application/ports'; + +const DRIVE_API = 'https://www.googleapis.com/drive/v3'; +const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; +const FILE_NAME = 'capmark-db.json'; + +/** + * Adaptador de sincronización BYOS sobre la appDataFolder de Google Drive del usuario. + * + * Estrategia: el backup completo se serializa como un único JSON (`capmark-db.json`) + * guardado en la carpeta oculta `appDataFolder` (invisible para el usuario en Drive UI, + * aislada por el Client ID de la app). Conflictos: última escritura gana por `exportadoEn`. + * + * Ciclo de sync: + * push() → exportar() localmente → subir a Drive (PUT si existe, POST si no). + * pull() → descargar de Drive → importar() con merge LWW. + * + * La app es local-first: sin token OAuth, `disponible()` es false y todo sigue en local. + * Implementa SyncPort (RNF-009); el dominio y la UI no conocen esta clase. + */ +export class GoogleDriveSync implements SyncPort { + constructor( + private auth: AuthPort, + private backup: BackupService, + ) {} + + disponible(): boolean { + return this.auth.getToken() !== null; + } + + async probarConexion(): Promise { + if (!this.disponible()) return false; + try { + const res = await fetch(`${DRIVE_API}/about?fields=user`, { + headers: this.headers(), + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } + } + + /** + * Exporta el catálogo completo y lo sube a appDataFolder/capmark-db.json. + * Si el archivo ya existe en Drive, lo sobreescribe (PUT multipart). + * Si no existe, lo crea (POST multipart). + */ + async push(): Promise { + if (!this.disponible()) return; + const data = await this.backup.exportar(); + const body = JSON.stringify(data); + const existingId = await this.findFileId(); + + if (existingId) { + await this.updateFile(existingId, body); + } else { + await this.createFile(body); + } + } + + /** + * Descarga capmark-db.json de appDataFolder y lo fusiona con el estado local. + * Estrategia LWW: si `exportadoEn` remoto > local, se importa; si no, se ignora. + */ + async pull(): Promise { + if (!this.disponible()) return; + const fileId = await this.findFileId(); + if (!fileId) return; // primera vez: Drive está vacío, no hay nada que bajar + + const res = await fetch(`${DRIVE_API}/files/${fileId}?alt=media`, { + headers: this.headers(), + }); + if (!res.ok) return; + + const remote = await res.json(); + + // LWW: solo importamos si el remoto es más reciente que nuestro último export + const localBackup = await this.backup.exportar(); + const remoteTs = new Date(remote.exportadoEn ?? 0).getTime(); + const localTs = new Date(localBackup.exportadoEn ?? 0).getTime(); + if (remoteTs > localTs) { + await this.backup.importar(remote, /* reemplazar= */ true); + } + } + + // ── helpers privados ──────────────────────────────────────────────────────── + + private headers(): Record { + return { Authorization: `Bearer ${this.auth.getToken()}` }; + } + + /** Busca el archivo por nombre en appDataFolder. Devuelve su id o null. */ + private async findFileId(): Promise { + const q = encodeURIComponent( + `name='${FILE_NAME}' and 'appDataFolder' in parents and trashed=false`, + ); + const res = await fetch(`${DRIVE_API}/files?spaces=appDataFolder&q=${q}&fields=files(id)`, { + headers: this.headers(), + }); + if (!res.ok) return null; + const data = (await res.json()) as { files: { id: string }[] }; + return data.files[0]?.id ?? null; + } + + /** Crea el archivo en appDataFolder (primera vez). */ + private async createFile(body: string): Promise { + const metadata = { name: FILE_NAME, parents: ['appDataFolder'] }; + const form = this.buildMultipart(metadata, body); + await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': form.contentType }, + body: form.body, + }); + } + + /** Sobreescribe el archivo existente (actualizaciones sucesivas). */ + private async updateFile(fileId: string, body: string): Promise { + const form = this.buildMultipart({}, body); + await fetch(`${DRIVE_UPLOAD}/files/${fileId}?uploadType=multipart`, { + method: 'PATCH', + headers: { ...this.headers(), 'Content-Type': form.contentType }, + body: form.body, + }); + } + + /** + * Construye un cuerpo multipart/related para la Drive API. + * Parte 1: metadatos JSON. Parte 2: contenido JSON de la base de datos. + */ + private buildMultipart( + metadata: object, + content: string, + ): { contentType: string; body: string } { + const boundary = 'capmark_boundary'; + const body = [ + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + JSON.stringify(metadata), + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + content, + `--${boundary}--`, + ].join('\r\n'); + return { contentType: `multipart/related; boundary=${boundary}`, body }; + } +} diff --git a/src/infrastructure/sync/supabase-sync.ts b/src/infrastructure/sync/supabase-sync.ts deleted file mode 100644 index a95b9e5..0000000 --- a/src/infrastructure/sync/supabase-sync.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { SyncPort } from '@application/ports'; - -/** - * Adaptador de sincronización opcional contra el backend auto-instanciable (Supabase - * self-hosted). La app es local-first: si no hay backend configurado, `disponible()` es - * false y todo sigue funcionando en local. Estrategia de conflictos: última escritura gana. - * - * Stub deliberado: el motor de sync completo (outbox + Realtime) es la Fase 3 del plan. - */ -export class SupabaseSync implements SyncPort { - private url = import.meta.env.VITE_SUPABASE_URL as string | undefined; - private key = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; - - disponible(): boolean { - return Boolean(this.url && this.key); - } - - /** - * Sondea si el backend responde, con timeout corto. Usa `no-cors` para que un servidor - * vivo cuente como alcanzable aunque no mande cabeceras CORS (solo nos importa "responde - * o no"). A prueba de fallos: cualquier error de red o timeout devuelve `false`; nunca - * lanza, así la app nunca se rompe por estar sin conexión (RNF-004). - */ - async probarConexion(): Promise { - if (!this.disponible()) return false; - const ctrl = new AbortController(); - const timeout = setTimeout(() => ctrl.abort(), 3000); - try { - await fetch(this.url as string, { mode: 'no-cors', signal: ctrl.signal }); - return true; - } catch { - return false; - } finally { - clearTimeout(timeout); - } - } - - async push(): Promise { - if (!this.disponible()) return; - // TODO(Fase 3): subir outbox de cambios locales; LWW por `actualizadaEn`. - } - - async pull(): Promise { - if (!this.disponible()) return; - // TODO(Fase 3): traer cambios remotos vía Realtime/REST y fusionar por LWW. - } -} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ce1366e..fbc8b93 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react'; import { IonApp, IonRouterOutlet, IonSpinner } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; +import { GoogleOAuthProvider } from '@react-oauth/google'; import { Redirect, Route } from 'react-router-dom'; // Rutas con carga diferida: cada página es su propio chunk (bundle inicial más liviano). @@ -11,8 +12,12 @@ const Cargando = () => (
); +// El clientId viene de VITE_GOOGLE_CLIENT_ID en .env (ver .env.example). +// Sin él, la app sigue funcionando en modo solo-local; el botón de sync no aparece. +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined; + export default function App() { - return ( + const app = ( }> @@ -27,4 +32,13 @@ export default function App() { ); + + // Si no hay Client ID configurado, la app funciona igual en modo solo-local. + if (!GOOGLE_CLIENT_ID) return app; + + return ( + + {app} + + ); } diff --git a/src/ui/components/SyncPanel.tsx b/src/ui/components/SyncPanel.tsx new file mode 100644 index 0000000..76abc8d --- /dev/null +++ b/src/ui/components/SyncPanel.tsx @@ -0,0 +1,198 @@ +import { useState, useCallback } from 'react'; +import { + IonButton, IonButtons, IonContent, IonHeader, IonIcon, + IonItem, IonLabel, IonModal, IonNote, IonSpinner, IonTitle, IonToolbar, +} from '@ionic/react'; +import { + logoGoogle, cloudDoneOutline, cloudOfflineOutline, + syncOutline, logOutOutline, cloudUploadOutline, cloudDownloadOutline, +} from 'ionicons/icons'; +import { useGoogleLogin } from '@react-oauth/google'; +import { container } from '@infrastructure/container'; + +type EstadoSync = 'desconectado' | 'conectando' | 'conectado' | 'sincronizando' | 'error'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onSyncCompleto?: () => void; // para que CatalogoPage recargue tras un pull +} + +/** + * Panel de sincronización con Google Drive (BYOS). + * Maneja el ciclo completo: OAuth login → push/pull → logout. + * La app sigue funcionando si el usuario no conecta: local-first (RNF-004). + */ +export default function SyncPanel({ isOpen, onClose, onSyncCompleto }: Props) { + const [estado, setEstado] = useState( + container.sync.disponible() ? 'conectado' : 'desconectado', + ); + const [mensaje, setMensaje] = useState(''); + const email = container.auth.getUserEmail(); + + const login = useGoogleLogin({ + scope: 'https://www.googleapis.com/auth/drive.appdata', + onSuccess: (tokenResponse) => { + container.auth.setToken(tokenResponse.access_token); + setEstado('conectado'); + setMensaje('Conectado. Sincroniza para subir o bajar tus datos.'); + }, + onError: () => { + setEstado('error'); + setMensaje('No se pudo conectar con Google. Inténtalo de nuevo.'); + }, + }); + + const handleLogin = useCallback(() => { + setEstado('conectando'); + setMensaje(''); + login(); + }, [login]); + + const sincronizar = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.push(); + await container.sync.pull(); + setEstado('conectado'); + setMensaje(`Sincronizado · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + onSyncCompleto?.(); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al sincronizar.'); + } + }, [onSyncCompleto]); + + const subir = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.push(); + setEstado('conectado'); + setMensaje(`Datos subidos a Drive · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al subir.'); + } + }, []); + + const bajar = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.pull(); + setEstado('conectado'); + setMensaje(`Datos bajados de Drive · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + onSyncCompleto?.(); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al bajar.'); + } + }, [onSyncCompleto]); + + const logout = useCallback(() => { + container.auth.logout(); + setEstado('desconectado'); + setMensaje(''); + }, []); + + const cargando = estado === 'conectando' || estado === 'sincronizando'; + + return ( + + + + Google Drive Sync + + Cerrar + + + + + + + {/* ── Estado de conexión ── */} + + + +

+ {{ + desconectado: 'Sin conectar', + conectando: 'Conectando…', + conectado: email ? `Conectado como ${email}` : 'Conectado', + sincronizando: 'Sincronizando…', + error: 'Error', + }[estado]} +

+

+ Tus datos se guardan en tu propio Google Drive, de forma privada y cifrada por Google. +

+
+ {cargando && } +
+ + {/* ── Mensaje de estado ── */} + {mensaje && ( + + {mensaje} + + )} + + {/* ── Panel desconectado ── */} + {estado === 'desconectado' && ( +
+ + + Conectar con Google Drive + + + Solo se solicita acceso a la carpeta privada de CapMark. + Ninguna otra app podrá leer tus datos. + +
+ )} + + {/* ── Panel conectado ── */} + {(estado === 'conectado' || estado === 'sincronizando' || estado === 'error') && container.sync.disponible() && ( +
+ void sincronizar()} disabled={cargando}> + + Sincronizar (subir y bajar) + + void subir()} disabled={cargando}> + + Solo subir + + void bajar()} disabled={cargando}> + + Solo bajar + + + + Desconectar cuenta + +
+ )} + + {/* ── Nota informativa ── */} + + Los datos se guardan en una carpeta oculta de tu Drive, invisible en la interfaz normal + de Google Drive. Solo CapMark puede leerla. Puedes seguir usando la app sin conexión: + los cambios se sincronizarán la próxima vez que pulses "Sincronizar". + + +
+
+ ); +} diff --git a/src/ui/pages/CatalogoPage.tsx b/src/ui/pages/CatalogoPage.tsx index 3201538..7eaa3a6 100644 --- a/src/ui/pages/CatalogoPage.tsx +++ b/src/ui/pages/CatalogoPage.tsx @@ -1,11 +1,10 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { IonBadge, IonButton, IonButtons, IonChip, IonContent, IonFab, IonFabButton, IonHeader, IonIcon, IonItem, IonLabel, IonList, IonNote, IonPage, IonSearchbar, IonSelect, IonSelectOption, IonTitle, IonToolbar, useIonViewWillEnter, } from '@ionic/react'; -import { add, cloudOfflineOutline, cloudDoneOutline, downloadOutline, folderOpenOutline } from 'ionicons/icons'; -import { useRef } from 'react'; +import { add, cloudOutline, downloadOutline, folderOpenOutline } from 'ionicons/icons'; import { useHistory } from 'react-router-dom'; import { Filtro } from '@application/catalogo-service'; import { NuevaObra } from '@application/ports'; @@ -13,15 +12,12 @@ import { Obra } from '@domain/obra'; import { ESTADOS_OBRA, PRIORIDADES } from '@domain/types'; import { container } from '@infrastructure/container'; import ObraFormModal from '@ui/components/ObraFormModal'; +import SyncPanel from '@ui/components/SyncPanel'; import { capFmt, colorEstado, colorPrioridad, fechaCorta } from '@ui/format'; import { sembrarDemo } from '@ui/seed'; interface Fila { obra: Obra; capitulo?: number; ultima?: string; } -// Estado de sincronización de cara al usuario. La app siempre funciona en local (RNF-004); -// esto solo informa si además hay un backend y si responde. -type EstadoSync = 'local' | 'comprobando' | 'conectado' | 'sin-conexion'; - export default function CatalogoPage() { const history = useHistory(); const [filtro, setFiltro] = useState({}); @@ -29,16 +25,7 @@ export default function CatalogoPage() { const [tags, setTags] = useState([]); const [total, setTotal] = useState(0); const [modal, setModal] = useState(false); - const [sync, setSync] = useState( - container.sync.disponible() ? 'comprobando' : 'local', - ); - - // Comprueba si el backend responde ahora mismo. Sin backend configurado ⇒ 'local'. - const comprobarSync = useCallback(async () => { - if (!container.sync.disponible()) { setSync('local'); return; } - setSync('comprobando'); - setSync((await container.sync.probarConexion()) ? 'conectado' : 'sin-conexion'); - }, []); + const [syncPanel, setSyncPanel] = useState(false); const recargar = useCallback(async (f: Filtro) => { // Universo de tags disponibles (a partir del catálogo completo) para el filtro RF-014. @@ -56,7 +43,7 @@ export default function CatalogoPage() { setFilas(filas); }, []); - useIonViewWillEnter(() => { void recargar(filtro); void comprobarSync(); }); + useIonViewWillEnter(() => { void recargar(filtro); }); const aplicar = (patch: Partial) => { const f = { ...filtro, ...patch }; @@ -105,18 +92,16 @@ export default function CatalogoPage() { fileRef.current?.click()} title="Importar backup"> - void comprobarSync()} - style={{ marginRight: 12, marginLeft: 4, cursor: 'pointer' }} - /> + setSyncPanel(true)} + title={container.sync.disponible() ? 'Google Drive conectado' : 'Conectar Google Drive'} + > + + @@ -145,11 +130,7 @@ export default function CatalogoPage() { - {sync === 'sin-conexion' && ( - - No se puede sincronizar. Trabajando en modo local. - - )} + {filas.length} obra{filas.length === 1 ? '' : 's'} {/* RF-014: contador de coincidencias */} @@ -206,6 +187,11 @@ export default function CatalogoPage() { }} /> setModal(false)} onSave={crear} /> + setSyncPanel(false)} + onSyncCompleto={() => void recargar(filtro)} + /> ); } From fabf56c84d433eb6ddf62318f74edfc0e5aaebe1 Mon Sep 17 00:00:00 2001 From: Jesus rodolfo gil Farias Date: Sun, 19 Jul 2026 12:37:18 -0400 Subject: [PATCH 2/3] feat: add @react-oauth/google dependency to package.json --- package-lock.json | 11 +++++++++++ package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index fae316d..544449d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ionic/react": "^8.4.0", "@ionic/react-router": "^8.4.0", + "@react-oauth/google": "^0.13.5", "dexie": "^4.0.10", "ionicons": "^7.4.0", "react": "^18.3.1", @@ -824,6 +825,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.5.tgz", + "integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", diff --git a/package.json b/package.json index ff503ad..ff08aaf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@ionic/react": "^8.4.0", "@ionic/react-router": "^8.4.0", + "@react-oauth/google": "^0.13.5", "dexie": "^4.0.10", "ionicons": "^7.4.0", "react": "^18.3.1", From 9c4e8ca4c3249c5a4ca8286667be7106625521d5 Mon Sep 17 00:00:00 2001 From: Jesus rodolfo gil Farias Date: Sun, 19 Jul 2026 12:47:59 -0400 Subject: [PATCH 3/3] feat: implement domain models, IndexedDB persistence, and work-in-progress UI for obra management --- src/domain/obra.ts | 5 + src/domain/types.ts | 4 +- src/infrastructure/persistence/db.ts | 6 + src/ui/components/ObraFormModal.tsx | 241 ++++++++++++++++++++++----- src/ui/pages/ObraDetallePage.tsx | 11 ++ src/ui/seed.ts | 7 +- 6 files changed, 230 insertions(+), 44 deletions(-) diff --git a/src/domain/obra.ts b/src/domain/obra.ts index db1c169..d6e8156 100644 --- a/src/domain/obra.ts +++ b/src/domain/obra.ts @@ -16,6 +16,7 @@ export interface Obra { tags: string[]; estado: EstadoObra; prioridad: Prioridad; + url?: string; // URL de lectura actual (cambiable) notas?: string; creadaEn: string; // ISO 8601 actualizadaEn: string; // ISO 8601 @@ -28,6 +29,7 @@ export interface NuevaObra { tags?: string[]; estado?: EstadoObra; prioridad?: Prioridad; + url?: string; // URL de lectura actual notas?: string; } @@ -58,6 +60,7 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr if (!PRIORIDADES.includes(prioridad)) throw new DomainError(`Prioridad inválida: ${prioridad} (RF-005).`); const iso = ahora.toISOString(); + const urlRaw = input.url?.trim(); return { id, titulo, @@ -66,6 +69,7 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr tags: dedup(input.tags ?? []), // RF-003: sin duplicados dentro de la obra estado, prioridad, + url: urlRaw || undefined, notas: input.notas?.trim() || undefined, creadaEn: iso, actualizadaEn: iso, @@ -82,6 +86,7 @@ export function editarObra(obra: Obra, cambios: Partial, ahora = new tags: cambios.tags ?? obra.tags, estado: cambios.estado ?? obra.estado, prioridad: cambios.prioridad ?? obra.prioridad, + url: cambios.url !== undefined ? cambios.url : obra.url, notas: cambios.notas ?? obra.notas, }, obra.id, diff --git a/src/domain/types.ts b/src/domain/types.ts index 92cbfb2..8a8adca 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -1,7 +1,7 @@ // Capa de dominio: vocabulario del negocio. Sin dependencias de infraestructura ni UI. -export type TipoObra = 'manga' | 'manhua' | 'novela'; -export const TIPOS_OBRA: TipoObra[] = ['manga', 'manhua', 'novela']; +export type TipoObra = 'manga' | 'manhua' | 'manwha' | 'manhwa' | 'anime' | 'webtoon' | 'novela' | 'novela-visual'; +export const TIPOS_OBRA: TipoObra[] = ['manga', 'manhua', 'manwha', 'manhwa', 'anime', 'webtoon', 'novela', 'novela-visual']; export type EstadoObra = 'pendiente' | 'leyendo' | 'pausado' | 'abandonado' | 'completado'; export const ESTADOS_OBRA: EstadoObra[] = ['pendiente', 'leyendo', 'pausado', 'abandonado', 'completado']; diff --git a/src/infrastructure/persistence/db.ts b/src/infrastructure/persistence/db.ts index afc975a..dc7d2fc 100644 --- a/src/infrastructure/persistence/db.ts +++ b/src/infrastructure/persistence/db.ts @@ -19,6 +19,12 @@ export class CapMarkDB extends Dexie { fuentes: 'id, obraId, esPrincipal', progreso: 'id, obraId, registradoEn', }); + // v2: agrega campo url? en obras (campo opcional, sin índice; Dexie lo persiste automáticamente) + this.version(2).stores({ + obras: 'id, titulo, tipo, estado, prioridad, actualizadaEn', + fuentes: 'id, obraId, esPrincipal', + progreso: 'id, obraId, registradoEn', + }); } } diff --git a/src/ui/components/ObraFormModal.tsx b/src/ui/components/ObraFormModal.tsx index 2837e68..ad3f541 100644 --- a/src/ui/components/ObraFormModal.tsx +++ b/src/ui/components/ObraFormModal.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { - IonButton, IonButtons, IonContent, IonHeader, IonInput, IonItem, IonLabel, IonModal, - IonSelect, IonSelectOption, IonTextarea, IonTitle, IonToolbar, + IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonInput, IonItem, IonLabel, IonModal, + IonSegment, IonSegmentButton, IonSelect, IonSelectOption, IonTextarea, IonTitle, IonToolbar, } from '@ionic/react'; +import { codeSlash, createOutline } from 'ionicons/icons'; import { NuevaObra } from '@application/ports'; import { Obra } from '@domain/obra'; import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA } from '@domain/types'; @@ -16,28 +17,114 @@ interface Props { const vacia: NuevaObra = { titulo: '', tipo: 'manga', estado: 'pendiente', prioridad: 'media' }; +type Modo = 'form' | 'json'; + export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) { + const [modo, setModo] = useState('form'); const [form, setForm] = useState(vacia); const [alias, setAlias] = useState(''); const [tags, setTags] = useState(''); + const [url, setUrl] = useState(''); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (obra) { - setForm({ titulo: obra.titulo, tipo: obra.tipo, estado: obra.estado, prioridad: obra.prioridad, notas: obra.notas }); + const base: NuevaObra = { + titulo: obra.titulo, + tipo: obra.tipo, + estado: obra.estado, + prioridad: obra.prioridad, + url: obra.url ?? '', + notas: obra.notas, + }; + setForm(base); setAlias(obra.nombresAlternativos.join(', ')); setTags(obra.tags.join(', ')); + setUrl(obra.url ?? ''); + setJsonText(JSON.stringify( + { ...base, nombresAlternativos: obra.nombresAlternativos, tags: obra.tags }, + null, + 2, + )); } else { - setForm(vacia); setAlias(''); setTags(''); + setForm(vacia); + setAlias(''); + setTags(''); + setUrl(''); + setJsonText(''); } setError(null); + setJsonError(null); + setModo('form'); }, [obra, isOpen]); const split = (s: string) => s.split(',').map((x) => x.trim()).filter(Boolean); + /** Sync: cuando el usuario edita el form, actualiza el JSON en tiempo real */ + const sincronizarJson = (f: NuevaObra, a: string, t: string, u: string) => { + const preview: NuevaObra = { + ...f, + nombresAlternativos: split(a), + tags: split(t), + url: u.trim() || undefined, + }; + setJsonText(JSON.stringify(preview, null, 2)); + }; + + const setField = (patch: Partial) => { + const next = { ...form, ...patch }; + setForm(next); + sincronizarJson(next, alias, tags, url); + }; + + const setAliasSync = (v: string) => { + setAlias(v); + sincronizarJson(form, v, tags, url); + }; + + const setTagsSync = (v: string) => { + setTags(v); + sincronizarJson(form, alias, v, url); + }; + + const setUrlSync = (v: string) => { + setUrl(v); + sincronizarJson(form, alias, tags, v); + }; + + /** Cuando el usuario edita el JSON crudo, parsea y sincroniza el form */ + const onJsonChange = (raw: string) => { + setJsonText(raw); + setJsonError(null); + try { + const parsed = JSON.parse(raw) as Partial & { nombresAlternativos?: string[]; tags?: string[] }; + setForm({ + titulo: parsed.titulo ?? '', + tipo: parsed.tipo ?? 'manga', + estado: parsed.estado ?? 'pendiente', + prioridad: parsed.prioridad ?? 'media', + url: parsed.url ?? '', + notas: parsed.notas, + }); + setAlias((parsed.nombresAlternativos ?? []).join(', ')); + setTags((parsed.tags ?? []).join(', ')); + setUrl(parsed.url ?? ''); + } catch { + setJsonError('JSON inválido — corrígelo antes de guardar.'); + } + }; + const guardar = async () => { + if (modo === 'json' && jsonError) return; try { - await onSave({ ...form, nombresAlternativos: split(alias), tags: split(tags) }); + await onSave({ + ...form, + nombresAlternativos: split(alias), + tags: split(tags), + url: url.trim() || undefined, + }); onClose(); } catch (e) { setError(e instanceof Error ? e.message : 'Error al guardar'); @@ -53,45 +140,119 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) Cancelar + + setModo(e.detail.value as Modo)}> + + + Formulario + + + + JSON + + + + - - Título principal * - setForm({ ...form, titulo: e.detail.value ?? '' })} placeholder="Ej. Solo Leveling" /> - - - Tipo * - setForm({ ...form, tipo: e.detail.value })}> - {TIPOS_OBRA.map((t) => {t})} - - - - Estado - setForm({ ...form, estado: e.detail.value })}> - {ESTADOS_OBRA.map((s) => {s})} - - - - Prioridad - setForm({ ...form, prioridad: e.detail.value })}> - {PRIORIDADES.map((p) => {p})} - - - - Nombres alternativos (coma) - setAlias(e.detail.value ?? '')} placeholder="나 혼자만 레벨업, 我独自升级" /> - - - Tags (coma) - setTags(e.detail.value ?? '')} placeholder="acción, fantasía" /> - - - Notas - setForm({ ...form, notas: e.detail.value ?? '' })} autoGrow /> - + {modo === 'form' ? ( + <> + + Título principal * + setField({ titulo: e.detail.value ?? '' })} + placeholder="Ej. Solo Leveling" + /> + + + + Tipo * + setField({ tipo: e.detail.value })}> + {TIPOS_OBRA.map((t) => {t})} + + + + + Estado + setField({ estado: e.detail.value })}> + {ESTADOS_OBRA.map((s) => {s})} + + + + + Prioridad + setField({ prioridad: e.detail.value })}> + {PRIORIDADES.map((p) => {p})} + + + + + 🔗 URL de lectura actual + setUrlSync(e.detail.value ?? '')} + placeholder="https://… (donde estás leyendo ahora)" + /> + + + + Nombres alternativos (coma) + setAliasSync(e.detail.value ?? '')} + placeholder="나 혼자만 레벨업, Only I Level Up" + /> + + + + Tags (coma) + setTagsSync(e.detail.value ?? '')} + placeholder="acción, fantasía" + /> + + + + Notas + setField({ notas: e.detail.value ?? '' })} + autoGrow + /> + + + ) : ( + <> +

+ Pega o edita el JSON de la obra. Los cambios se sincronizan con el formulario. +

+ onJsonChange(e.detail.value ?? '')} + autoGrow + rows={20} + style={{ fontFamily: 'monospace', fontSize: 13 }} + placeholder={'{\n "titulo": "Mi Obra",\n "tipo": "manga",\n "url": "https://..."\n}'} + /> + {jsonError && ( +

{jsonError}

+ )} + + )} {error &&

{error}

} - Guardar + + + Guardar +
); diff --git a/src/ui/pages/ObraDetallePage.tsx b/src/ui/pages/ObraDetallePage.tsx index 30c9e32..e01cf4c 100644 --- a/src/ui/pages/ObraDetallePage.tsx +++ b/src/ui/pages/ObraDetallePage.tsx @@ -168,6 +168,17 @@ export default function ObraDetallePage() { {obra.prioridad}{' '} {obra.tipo} + {obra.url && ( +
+ + 🔗 {obra.url} + + window.open(obra.url, '_blank', 'noopener')}> + + Abrir + +
+ )} {obra.nombresAlternativos.length > 0 && (

También: {obra.nombresAlternativos.join(' · ')}

)} diff --git a/src/ui/seed.ts b/src/ui/seed.ts index cf6d9ac..3687574 100644 --- a/src/ui/seed.ts +++ b/src/ui/seed.ts @@ -4,11 +4,12 @@ import { container } from '@infrastructure/container'; export async function sembrarDemo(): Promise { const solo = await container.catalogo.crear({ titulo: 'Solo Leveling', - tipo: 'manhua', + tipo: 'manwha', nombresAlternativos: ['나 혼자만 레벨업', 'Only I Level Up'], tags: ['acción', 'fantasía'], estado: 'leyendo', prioridad: 'alta', + url: 'https://asuracomic.net/series/solo-leveling', }); await container.fuentes.agregar(solo.id, { nombreEnSitio: 'Asura Scans', url: 'https://asuracomic.net/', esPrincipal: true }); await container.fuentes.agregar(solo.id, { nombreEnSitio: 'MangaDex', url: 'https://mangadex.org/' }); @@ -17,11 +18,12 @@ export async function sembrarDemo(): Promise { const omni = await container.catalogo.crear({ titulo: 'Omniscient Reader', - tipo: 'manhua', + tipo: 'manwha', nombresAlternativos: ['전지적 독자 시점', 'ORV'], tags: ['aventura', 'apocalipsis'], estado: 'pausado', prioridad: 'media', + url: 'https://www.webtoons.com/en/action/omniscient-reader', }); await container.fuentes.agregar(omni.id, { nombreEnSitio: 'Webtoon', url: 'https://www.webtoons.com/', esPrincipal: true }); await container.progreso.registrar(omni.id, { capitulo: 92 }); @@ -32,6 +34,7 @@ export async function sembrarDemo(): Promise { tags: ['isekai'], estado: 'pendiente', prioridad: 'baja', + url: 'https://novelbin.com/b/mushoku-tensei', }); await container.fuentes.agregar(mushoku.id, { nombreEnSitio: 'NovelBin', url: 'https://novelbin.com/' }); }