Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
29 changes: 26 additions & 3 deletions Docs/05-stack-sugerido.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
23 changes: 12 additions & 11 deletions Docs/06-plan-de-trabajo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/application/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,12 @@ export interface SyncPort {
probarConexion(): Promise<boolean>;
}

/** 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 };
5 changes: 5 additions & 0 deletions src/domain/obra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@ export interface NuevaObra {
tags?: string[];
estado?: EstadoObra;
prioridad?: Prioridad;
url?: string; // URL de lectura actual
notas?: string;
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -82,6 +86,7 @@ export function editarObra(obra: Obra, cambios: Partial<NuevaObra>, 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,
Expand Down
4 changes: 2 additions & 2 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down
39 changes: 39 additions & 0 deletions src/infrastructure/auth/google-auth.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
10 changes: 7 additions & 3 deletions src/infrastructure/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions src/infrastructure/persistence/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}
}

Expand Down
Loading
Loading