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
84 changes: 31 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
@@ -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://<your-backend>.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
5 changes: 5 additions & 0 deletions apps/desktop/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!doctype html>
<html>
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Gitplant Desktop</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
34 changes: 34 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 20 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = [] }
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
156 changes: 156 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<i64>,
}

fn db_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> anyhow::Result<PathBuf> {
let dir = app.path().app_data_dir()?;
fs::create_dir_all(&dir)?;
Ok(dir.join("gitplant.db"))
}

fn storage_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> anyhow::Result<PathBuf> {
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<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> anyhow::Result<Connection> {
let conn = Connection::open(db_path(app)?)?;
init_schema(&conn)?;
Ok(conn)
}

#[tauri::command]
fn import_pdf_from_picker<R: tauri::Runtime>(app: tauri::AppHandle<R>) -> Result<ImportedDocumentPayload, String> {
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<R: tauri::Runtime>(app: tauri::AppHandle<R>) -> Result<Vec<RecentDocumentView>, 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<R: tauri::Runtime>(app: tauri::AppHandle<R>, document_id: String) -> Result<ImportedDocumentPayload, String> {
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<R: tauri::Runtime>(app: tauri::AppHandle<R>, document_id: String) -> Result<Vec<u8>, 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<R: tauri::Runtime>(app: tauri::AppHandle<R>, 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);
}
}
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
gitplant_desktop_lib::run();
}
14 changes: 14 additions & 0 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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"}
}
21 changes: 21 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />);
expect(screen.getByText('Gitplant Desktop')).toBeInTheDocument();
expect(await screen.findByText('No recent documents.')).toBeInTheDocument();
});
});
Loading