diff --git a/README.md b/README.md index afcdb01..525f083 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,43 @@ -# GitPlant EDMS MVP +# Gitplant Pass 1 (Desktop-first) -GitPlant is a document-control MVP for engineering teams. +This repository now contains the Pass 1 desktop-first vertical slice defined in `docs/PDF_REVIEW_ARCHITECTURE_PACKAGE.md`. -## Workflow mapping +## Workspace structure -- **Code (Plant)** = `main` branch (source of truth documents) -- **Pull Request (Project)** = project workflow against Plant +- `apps/desktop` – Tauri desktop shell + React/TypeScript UI +- `apps/desktop/src-tauri` – Rust native commands, SQLite schema, managed file storage +- `packages/shared-types` – shared contracts +- `packages/viewer-core` – renderer abstraction interfaces +- `packages/viewer-pdfjs` – PDF.js renderer adapter implementation +- `packages/persistence-core` – persistence gateway interfaces used by UI -## Quickstart +## Prerequisites -From repository root, run one command: +- Node.js 20+ +- Rust stable toolchain +- Tauri system dependencies: https://v2.tauri.app/start/prerequisites/ -```bash -npm run dev -``` +## Commands -This starts both: -- Backend API on `http://127.0.0.1:8000` -- Frontend UI on `http://127.0.0.1:5173` +From repository root: -## Single-server mode (API + UI on one port) +- Dev (one command): `npm run desktop:dev` +- Desktop build: `npm run desktop:build` +- Tests: `npm test` +- Typecheck: `npm run typecheck` -```bash -npm run start -``` +## Pass 1 manual verification checklist -This builds frontend and serves it via FastAPI at `http://127.0.0.1:8000`. +1. Run `npm run desktop:dev` and confirm desktop window opens. +2. Click **Import PDF**, pick a local `.pdf` through native picker. +3. Confirm document appears in viewer and page renders. +4. Use **Prev/Next** and **Zoom In/Zoom Out/Fit Width**. +5. Confirm imported document appears in **Recent Documents**. +6. Restart app and confirm recent item reopens. -## Demo account +## Deferred to Pass 2+ -- `user@edms.local / user123` - -## Demo controls - -Dev endpoints are enabled automatically when `APP_ENV=dev` (or explicitly with `ENABLE_DEMO_ENDPOINTS=true`). - -- `POST /dev/seed` → wipe + reseed docs/projects/audit events + placeholder PDFs -- `POST /dev/reset` → wipe demo data + storage -- `GET /dev/status` → enabled flag + reason + entity counts - -## Testing (single command) - -```bash -npm test -``` - -This runs: -- backend pytest suite -- scripted frontend/backend happy-path flow - -## Persistence - -- SQLite DB path defaults to `backend/.data/edms.db` -- Plant storage defaults to `backend/storage/plant` -- Document upload storage defaults to `backend/storage/documents` - - -## Vercel deployment notes - -- Set `VITE_API_URL` in the frontend environment to your deployed backend URL (for example `https://.vercel.app`). -- Backend CORS allows: - - `https://gitplant-oggy.vercel.app` - - any `https://*.vercel.app` origin (for Vercel preview deployments) -- CORS preflight `OPTIONS` requests are handled by FastAPI `CORSMiddleware`. +- Markup tools/redlines/comments/workflow +- Collaboration/sync/export pipeline +- Native renderer adapter +- Advanced performance optimization and tile rendering diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..863b10d --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,5 @@ + + + Gitplant Desktop +
+ diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..92591cd --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,34 @@ +{ + "name": "@gitplant/desktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tauri dev", + "build": "tauri build", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "dev:web": "vite --port 1420 --strictPort", + "build:web": "vite build" + }, + "dependencies": { + "@gitplant/persistence-core": "0.1.0", + "@gitplant/shared-types": "0.1.0", + "@gitplant/viewer-pdfjs": "0.1.0", + "@tauri-apps/api": "^2.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "typescript": "^5.7.3", + "vite": "^6.0.5", + "vitest": "^2.1.8" + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..4cfeadf --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "gitplant-desktop" +version = "0.1.0" +edition = "2021" + +[lib] +name = "gitplant_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +tauri = { version = "2", features = [] } +uuid = { version = "1", features = ["v4", "serde"] } +rfd = "0.15" + +[build-dependencies] +tauri-build = { version = "2", features = [] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..9838198 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,156 @@ +use std::{fs, path::{Path, PathBuf}}; +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::Serialize; +use tauri::Manager; +use uuid::Uuid; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedDocumentPayload { + document_id: String, + title: String, + managed_file_path: String, + file_size_bytes: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RecentDocumentView { + document_id: String, + title: String, + managed_file_path: String, + opened_at: String, + page_count: Option, +} + +fn db_path(app: &tauri::AppHandle) -> anyhow::Result { + let dir = app.path().app_data_dir()?; + fs::create_dir_all(&dir)?; + Ok(dir.join("gitplant.db")) +} + +fn storage_dir(app: &tauri::AppHandle) -> anyhow::Result { + let dir = app.path().app_data_dir()?.join("documents"); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn init_schema(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS document_revisions ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + revision_number INTEGER NOT NULL, + managed_file_path TEXT NOT NULL, + original_file_name TEXT NOT NULL, + source_path TEXT, + page_count INTEGER, + file_size_bytes INTEGER NOT NULL, + imported_at TEXT NOT NULL, + FOREIGN KEY(document_id) REFERENCES documents(id) + ); + CREATE TABLE IF NOT EXISTS recent_documents ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + opened_at TEXT NOT NULL, + FOREIGN KEY(document_id) REFERENCES documents(id) + ); + " + )?; + Ok(()) +} + +fn open_conn(app: &tauri::AppHandle) -> anyhow::Result { + let conn = Connection::open(db_path(app)?)?; + init_schema(&conn)?; + Ok(conn) +} + +#[tauri::command] +fn import_pdf_from_picker(app: tauri::AppHandle) -> Result { + let selected = rfd::FileDialog::new().add_filter("PDF", &["pdf"]).pick_file().ok_or("No file selected")?; + if selected.extension().and_then(|e| e.to_str()).map(|s| s.to_lowercase()) != Some("pdf".into()) { + return Err("Invalid file type. Please select a PDF.".into()); + } + let doc_id = Uuid::new_v4().to_string(); + let rev_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let name = selected.file_stem().and_then(|s| s.to_str()).unwrap_or("Untitled").to_string(); + let file_name = selected.file_name().and_then(|s| s.to_str()).unwrap_or("document.pdf").to_string(); + let managed_path = storage_dir(&app).map_err(|e| e.to_string())?.join(format!("{}-{}", doc_id, file_name)); + fs::copy(&selected, &managed_path).map_err(|e| format!("Import failure: {e}"))?; + let file_size = fs::metadata(&managed_path).map_err(|e| e.to_string())?.len(); + + let conn = open_conn(&app).map_err(|e| format!("DB initialization failure: {e}"))?; + conn.execute("INSERT INTO documents (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", params![doc_id, name, now]).map_err(|e| e.to_string())?; + conn.execute("INSERT INTO document_revisions (id, document_id, revision_number, managed_file_path, original_file_name, source_path, page_count, file_size_bytes, imported_at) VALUES (?1, ?2, 1, ?3, ?4, ?5, NULL, ?6, ?7)", + params![rev_id, doc_id, managed_path.to_string_lossy().to_string(), file_name, selected.to_string_lossy().to_string(), file_size as i64, now]).map_err(|e| e.to_string())?; + conn.execute("INSERT INTO recent_documents (id, document_id, opened_at) VALUES (?1, ?2, ?3)", params![Uuid::new_v4().to_string(), doc_id, now]).map_err(|e| e.to_string())?; + + Ok(ImportedDocumentPayload { document_id: doc_id, title: name, managed_file_path: managed_path.to_string_lossy().to_string(), file_size_bytes: file_size }) +} + +#[tauri::command] +fn list_recent_documents(app: tauri::AppHandle) -> Result, String> { + let conn = open_conn(&app).map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT d.id,d.title,dr.managed_file_path,r.opened_at,dr.page_count FROM recent_documents r JOIN documents d ON d.id=r.document_id JOIN document_revisions dr ON dr.document_id=d.id ORDER BY r.opened_at DESC LIMIT 20").map_err(|e| e.to_string())?; + let rows = stmt.query_map([], |row| Ok(RecentDocumentView { + document_id: row.get(0)?, title: row.get(1)?, managed_file_path: row.get(2)?, opened_at: row.get(3)?, page_count: row.get(4)? + })).map_err(|e| e.to_string())?; + Ok(rows.filter_map(Result::ok).collect()) +} + +fn latest_path(conn: &Connection, document_id: &str) -> Result<(String, String), String> { + conn.query_row("SELECT d.title,dr.managed_file_path FROM documents d JOIN document_revisions dr ON dr.document_id=d.id WHERE d.id=?1 ORDER BY dr.revision_number DESC LIMIT 1", params![document_id], |r| Ok((r.get(0)?, r.get(1)?))).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn open_document(app: tauri::AppHandle, document_id: String) -> Result { + let conn = open_conn(&app).map_err(|e| e.to_string())?; + let (title, managed_file_path) = latest_path(&conn, &document_id)?; + conn.execute("INSERT INTO recent_documents (id, document_id, opened_at) VALUES (?1, ?2, ?3)", params![Uuid::new_v4().to_string(), document_id, Utc::now().to_rfc3339()]).map_err(|e| e.to_string())?; + let file_size_bytes = fs::metadata(&managed_file_path).map_err(|e| e.to_string())?.len(); + Ok(ImportedDocumentPayload { document_id, title, managed_file_path, file_size_bytes }) +} + +#[tauri::command] +fn read_document_bytes(app: tauri::AppHandle, document_id: String) -> Result, String> { + let conn = open_conn(&app).map_err(|e| e.to_string())?; + let (_, managed_file_path) = latest_path(&conn, &document_id)?; + fs::read(Path::new(&managed_file_path)).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn update_page_count(app: tauri::AppHandle, document_id: String, page_count: i64) -> Result<(), String> { + let conn = open_conn(&app).map_err(|e| e.to_string())?; + conn.execute("UPDATE document_revisions SET page_count=?1 WHERE document_id=?2 AND revision_number=1", params![page_count, document_id]).map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![import_pdf_from_picker, list_recent_documents, open_document, read_document_bytes, update_page_count]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlite_schema_supports_document_and_recent_persistence() { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + conn.execute("INSERT INTO documents (id,title,created_at,updated_at) VALUES ('d1','Doc','t','t')", []).unwrap(); + conn.execute("INSERT INTO document_revisions (id,document_id,revision_number,managed_file_path,original_file_name,source_path,page_count,file_size_bytes,imported_at) VALUES ('r1','d1',1,'/tmp/a.pdf','a.pdf','/src/a.pdf',3,99,'t')", []).unwrap(); + conn.execute("INSERT INTO recent_documents (id,document_id,opened_at) VALUES ('x','d1','t')", []).unwrap(); + let mut stmt = conn.prepare("SELECT COUNT(*) FROM recent_documents").unwrap(); + let count: i64 = stmt.query_row([], |r| r.get(0)).unwrap(); + assert_eq!(count, 1); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..d00431a --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + gitplant_desktop_lib::run(); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..660a3da --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Gitplant Desktop", + "version": "0.1.0", + "identifier": "com.gitplant.desktop", + "build": { + "beforeDevCommand": "npm run dev:web -w @gitplant/desktop", + "beforeBuildCommand": "npm run build:web -w @gitplant/desktop", + "devUrl": "http://localhost:1420", + "frontendDist": "../dist" + }, + "app": {"windows": [{"title": "Gitplant Desktop", "width": 1400, "height": 900}]}, + "bundle": {"active": true, "targets": "all"} +} diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx new file mode 100644 index 0000000..a3f7f83 --- /dev/null +++ b/apps/desktop/src/App.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { App } from './App'; +import { vi } from 'vitest'; + +vi.mock('./lib/tauriGateway', () => ({ + tauriGateway: { + listRecentDocuments: vi.fn().mockResolvedValue([]), + importPdfFromPicker: vi.fn(), + openDocument: vi.fn(), + readDocumentBytes: vi.fn(), + updatePageCount: vi.fn() + } +})); + +describe('App smoke', () => { + it('shows boot state and empty recent list', async () => { + render(); + expect(screen.getByText('Gitplant Desktop')).toBeInTheDocument(); + expect(await screen.findByText('No recent documents.')).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 0000000..c0b9a52 --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import { tauriGateway } from './lib/tauriGateway'; +import { PdfViewer } from './components/PdfViewer'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import type { RecentDocumentView } from '@gitplant/shared-types'; + +export function App() { + const [recent, setRecent] = useState([]); + const [currentId, setCurrentId] = useState(null); + const [bytes, setBytes] = useState(null); + const [title, setTitle] = useState(''); + const [error, setError] = useState(''); + + const refreshRecent = async () => setRecent(await tauriGateway.listRecentDocuments()); + useEffect(() => { void refreshRecent(); }, []); + + const openDocument = async (documentId: string) => { + const data = await tauriGateway.openDocument(documentId); + const raw = await tauriGateway.readDocumentBytes(documentId); + setCurrentId(documentId); + setTitle(data.title); + setBytes(new Uint8Array(raw)); + await refreshRecent(); + }; + + const importPdf = async () => { + setError(''); + try { + const imported = await tauriGateway.importPdfFromPicker(); + await openDocument(imported.documentId); + } catch (e) { + setError((e as Error).message || 'Import failure'); + } + }; + + return
+

Gitplant Desktop

+ {error &&
{error}
} + +
{ if (currentId) void tauriGateway.updatePageCount(currentId, n); }} />
+
; +} diff --git a/apps/desktop/src/components/ErrorBoundary.tsx b/apps/desktop/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..564969e --- /dev/null +++ b/apps/desktop/src/components/ErrorBoundary.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +type Props = React.PropsWithChildren; +type State = { hasError: boolean }; + +export class ErrorBoundary extends React.Component { + state: State = { hasError: false }; + static getDerivedStateFromError(): State { return { hasError: true }; } + render() { + if (this.state.hasError) return
Application error occurred.
; + return this.props.children; + } +} diff --git a/apps/desktop/src/components/PdfViewer.test.tsx b/apps/desktop/src/components/PdfViewer.test.tsx new file mode 100644 index 0000000..8ab73e9 --- /dev/null +++ b/apps/desktop/src/components/PdfViewer.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@testing-library/react'; +import { PdfViewer } from './PdfViewer'; + +describe('PdfViewer states', () => { + it('shows empty state', () => { + render(); + expect(screen.getByText('Select a PDF to view.')).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/components/PdfViewer.tsx b/apps/desktop/src/components/PdfViewer.tsx new file mode 100644 index 0000000..01dff12 --- /dev/null +++ b/apps/desktop/src/components/PdfViewer.tsx @@ -0,0 +1,54 @@ +import { useEffect, useMemo, useState } from 'react'; +import { PdfjsRendererAdapter } from '@gitplant/viewer-pdfjs'; + +type Props = { bytes: Uint8Array | null; title: string; onPageCount?: (n: number) => void }; + +export function PdfViewer({ bytes, title, onPageCount }: Props) { + const renderer = useMemo(() => new PdfjsRendererAdapter(), []); + const [page, setPage] = useState(1); + const [pageCount, setPageCount] = useState(0); + const [zoom, setZoom] = useState(1); + const [image, setImage] = useState(''); + const [state, setState] = useState<'idle'|'loading'|'error'>('idle'); + + useEffect(() => { void (async () => { + if (!bytes) return; + setState('loading'); + try { + await renderer.openDocument(bytes); + const count = await renderer.getPageCount(); + setPage(1); setPageCount(count); onPageCount?.(count); + const render = await renderer.renderPage(1, { scale: zoom }); + setImage(render.imageDataUrl); + setState('idle'); + } catch { + setState('error'); + } + })(); return () => { void renderer.closeDocument(); }; }, [bytes]); + + useEffect(() => { void (async () => { + if (!bytes || state === 'error' || pageCount === 0) return; + setState('loading'); + try { + const render = await renderer.renderPage(page, { scale: zoom }); + setImage(render.imageDataUrl); + setState('idle'); + } catch { setState('error'); } + })(); }, [page, zoom]); + + if (!bytes) return
Select a PDF to view.
; + if (state === 'error') return
Failed to render document.
; + return
+

{title}

+
+ + {page}/{pageCount} + + + + +
+ {state === 'loading' ?
Loading...
: pdf page} +
+
; +} diff --git a/apps/desktop/src/lib/tauriGateway.ts b/apps/desktop/src/lib/tauriGateway.ts new file mode 100644 index 0000000..13c6e7d --- /dev/null +++ b/apps/desktop/src/lib/tauriGateway.ts @@ -0,0 +1,11 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { PersistenceGateway, ImportedDocumentPayload } from '@gitplant/persistence-core'; +import type { RecentDocumentView } from '@gitplant/shared-types'; + +export const tauriGateway: PersistenceGateway = { + importPdfFromPicker: () => invoke('import_pdf_from_picker'), + listRecentDocuments: () => invoke('list_recent_documents'), + openDocument: (documentId: string) => invoke('open_document', { documentId }), + readDocumentBytes: (documentId: string) => invoke('read_document_bytes', { documentId }), + updatePageCount: (documentId: string, pageCount: number) => invoke('update_page_count', { documentId, pageCount }) +}; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..6c14464 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..e1c9c78 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "baseUrl": ".", + "paths": { + "@gitplant/*": ["../../packages/*/src"] + }, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "vite.config.ts", "vitest.setup.ts"] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 0000000..ecc16e2 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: './vitest.setup.ts' } }); diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/apps/desktop/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/package.json b/package.json index dad1da8..1665f28 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "gitplant", "private": true, - "version": "0.1.0", + "version": "0.2.0", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "python run_dev.py", - "dev:backend": "cd backend && APP_ENV=dev ENABLE_DEMO_ENDPOINTS=true uvicorn app.main:app --reload --host 0.0.0.0 --port 8000", - "dev:frontend": "cd frontend && npm run dev", - "build": "cd frontend && npm run build", - "start": "npm run build && cd backend && APP_ENV=prod ENABLE_DEMO_ENDPOINTS=false uvicorn app.main:app --host 0.0.0.0 --port 8000", - "test": "npm run test:backend && python scripts/e2e_happy_path.py", - "test:backend": "cd backend && pytest -q" + "desktop:dev": "npm run dev -w @gitplant/desktop", + "desktop:build": "npm run build -w @gitplant/desktop", + "test": "npm run test -ws --if-present", + "typecheck": "npm run typecheck -ws --if-present", + "build": "npm run build -ws --if-present" } } diff --git a/packages/persistence-core/package.json b/packages/persistence-core/package.json new file mode 100644 index 0000000..b6e432f --- /dev/null +++ b/packages/persistence-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@gitplant/persistence-core", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@gitplant/shared-types": "0.1.0" + } +} diff --git a/packages/persistence-core/src/index.ts b/packages/persistence-core/src/index.ts new file mode 100644 index 0000000..58bab92 --- /dev/null +++ b/packages/persistence-core/src/index.ts @@ -0,0 +1,16 @@ +import type { RecentDocumentView } from '@gitplant/shared-types'; + +export interface ImportedDocumentPayload { + documentId: string; + title: string; + managedFilePath: string; + fileSizeBytes: number; +} + +export interface PersistenceGateway { + importPdfFromPicker(): Promise; + listRecentDocuments(): Promise; + openDocument(documentId: string): Promise; + readDocumentBytes(documentId: string): Promise; + updatePageCount(documentId: string, pageCount: number): Promise; +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..3aae07f --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@gitplant/shared-types", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts" +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..deaf56f --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -0,0 +1,32 @@ +export interface DocumentRecord { + id: string; + title: string; + createdAt: string; + updatedAt: string; +} + +export interface DocumentRevisionRecord { + id: string; + documentId: string; + revisionNumber: number; + managedFilePath: string; + originalFileName: string; + sourcePath?: string | null; + pageCount?: number | null; + fileSizeBytes: number; + importedAt: string; +} + +export interface RecentDocumentRecord { + id: string; + documentId: string; + openedAt: string; +} + +export interface RecentDocumentView { + documentId: string; + title: string; + managedFilePath: string; + openedAt: string; + pageCount?: number | null; +} diff --git a/packages/viewer-core/package.json b/packages/viewer-core/package.json new file mode 100644 index 0000000..27494c1 --- /dev/null +++ b/packages/viewer-core/package.json @@ -0,0 +1,6 @@ +{ + "name": "@gitplant/viewer-core", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts" +} diff --git a/packages/viewer-core/src/index.ts b/packages/viewer-core/src/index.ts new file mode 100644 index 0000000..6f6dcda --- /dev/null +++ b/packages/viewer-core/src/index.ts @@ -0,0 +1,25 @@ +export interface RenderSpec { + scale: number; +} + +export interface DocumentMetadata { + title?: string; + pageCount: number; +} + +export interface RenderResult { + width: number; + height: number; + imageDataUrl: string; +} + +export interface ViewerRenderer { + openDocument(source: Uint8Array): Promise; + closeDocument(): Promise; + getDocumentMetadata(): Promise; + getPageCount(): Promise; + renderPage(pageNumber: number, spec: RenderSpec): Promise; + getTextContent(pageNumber: number): Promise; + screenToPage(x: number, y: number): { x: number; y: number }; + pageToScreen(x: number, y: number): { x: number; y: number }; +} diff --git a/packages/viewer-pdfjs/package.json b/packages/viewer-pdfjs/package.json new file mode 100644 index 0000000..98410b7 --- /dev/null +++ b/packages/viewer-pdfjs/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gitplant/viewer-pdfjs", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@gitplant/viewer-core": "0.1.0", + "pdfjs-dist": "^4.10.38" + } +} diff --git a/packages/viewer-pdfjs/src/index.ts b/packages/viewer-pdfjs/src/index.ts new file mode 100644 index 0000000..2a41174 --- /dev/null +++ b/packages/viewer-pdfjs/src/index.ts @@ -0,0 +1,62 @@ +import { GlobalWorkerOptions, getDocument, type PDFDocumentProxy } from 'pdfjs-dist'; +import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import type { DocumentMetadata, RenderResult, RenderSpec, ViewerRenderer } from '@gitplant/viewer-core'; + +GlobalWorkerOptions.workerSrc = pdfWorker; + +export class PdfjsRendererAdapter implements ViewerRenderer { + private document: PDFDocumentProxy | null = null; + + async openDocument(source: Uint8Array): Promise { + this.document = await getDocument({ data: source }).promise; + } + + async closeDocument(): Promise { + if (this.document) { + await this.document.destroy(); + this.document = null; + } + } + + async getDocumentMetadata(): Promise { + const pageCount = await this.getPageCount(); + return { pageCount }; + } + + async getPageCount(): Promise { + if (!this.document) throw new Error('No document opened'); + return this.document.numPages; + } + + async renderPage(pageNumber: number, spec: RenderSpec): Promise { + if (!this.document) throw new Error('No document opened'); + const page = await this.document.getPage(pageNumber); + const viewport = page.getViewport({ scale: spec.scale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) throw new Error('Failed to initialize canvas context'); + canvas.width = viewport.width; + canvas.height = viewport.height; + await page.render({ canvasContext: context, viewport }).promise; + return { + width: viewport.width, + height: viewport.height, + imageDataUrl: canvas.toDataURL('image/png') + }; + } + + async getTextContent(pageNumber: number): Promise { + if (!this.document) return ''; + const page = await this.document.getPage(pageNumber); + const text = await page.getTextContent(); + return text.items.map((i: any) => i.str ?? '').join(' '); + } + + screenToPage(x: number, y: number): { x: number; y: number } { + return { x, y }; + } + + pageToScreen(x: number, y: number): { x: number; y: number } { + return { x, y }; + } +}