diff --git a/README.md b/README.md index b708b52..fdea8b6 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ The production server serves both frontend and backend from port **8200** - Link to view all rolls using each batch - Duplicate and retire batch actions +### Development Chart (B&W Timing) +- **Lookup table** for B&W film development times +- Database of film stock + developer + ISO + dilution + temperature → dev time +- Pre-seeded with **34 common combinations** (Ilford HP5+, FP4+, Delta; Kodak Tri-X, T-Max; Fomapan) +- Supports **push/pull processing** with different ISO ratings +- **Easy data entry** via CSV import or API +- **Autocomplete** for film stocks and developers +- **Filtering** by film stock, developer, or ISO rating +- **Lookup API** for quick dev time queries + ### User Experience - **Touch-friendly** mobile-responsive design - **Drag-and-drop** with visual feedback @@ -166,6 +176,16 @@ The production server serves both frontend and backend from port **8200** - `PUT /api/chemistry/{id}` - Update batch - `DELETE /api/chemistry/{id}` - Delete batch +### Development Chart (B&W Timing Lookup) +- `GET /api/dev-chart` - List all chart entries (filter: `?film_stock=HP5&developer=Ilfosol`) +- `POST /api/dev-chart` - Create new chart entry +- `GET /api/dev-chart/{id}` - Get chart entry +- `PUT /api/dev-chart/{id}` - Update chart entry +- `DELETE /api/dev-chart/{id}` - Delete chart entry +- `POST /api/dev-chart/lookup` - Lookup dev time (body: `{film_stock, developer, iso_rating, dilution_ratio?, temperature_celsius?}`) +- `GET /api/dev-chart/autocomplete/films` - Autocomplete film stock names +- `GET /api/dev-chart/autocomplete/developers` - Autocomplete developer names + ## 🗄️ Database **Location:** `backend/data/emulsion.db` (SQLite) @@ -173,6 +193,7 @@ The production server serves both frontend and backend from port **8200** **Tables:** - `film_rolls` - Film roll tracking with computed status - `chemistry_batches` - Chemistry batch tracking with C41 dev time calculation +- `development_chart` - B&W development timing lookup table **Changing Database Location:** @@ -194,12 +215,32 @@ python import_chemistry.py --db-path ../../backend/data/emulsion.db # Import film rolls python import_rolls.py --db-path ../../backend/data/emulsion.db +# Import development chart data (B&W timing) +python import_dev_chart.py ../data/dev_chart_seed.csv --db-path ../../backend/data/emulsion.db + # Validate imported data python validate.py --db-path ../../backend/data/emulsion.db ``` See `migration/README.md` for CSV format requirements. +### Development Chart CSV Format + +The development chart CSV should have these columns: +``` +film_stock,developer,iso_rating,dilution_ratio,temperature_celsius,development_time_seconds,agitation_notes,notes +``` + +Example: +```csv +Ilford HP5 Plus 400,Ilfosol 3,400,1+4,20.0,6:30,First 30s continuous then 10s every minute,From Ilford datasheet +Kodak Tri-X 400,D-76,800,1+1,20.0,14:00,Agitate 5s every 30s,Push +1 stop +``` + +**Note:** `development_time_seconds` can be in seconds (390) or MM:SS format (6:30). + +A seed file with 34 common film/developer combinations is provided at `migration/data/dev_chart_seed.csv`. + ## 🏗️ Architecture ### How It Works diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index d9af765..7233797 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -6,9 +6,10 @@ api_router = APIRouter(prefix="/api") # Import and include route modules -from app.api import rolls, chemistry +from app.api import rolls, chemistry, dev_chart api_router.include_router(rolls.router, prefix="/rolls", tags=["Film Rolls"]) api_router.include_router(chemistry.router, prefix="/chemistry", tags=["Chemistry"]) +api_router.include_router(dev_chart.router, prefix="/dev-chart", tags=["Development Chart"]) __all__ = ["api_router"] diff --git a/backend/app/api/dev_chart.py b/backend/app/api/dev_chart.py new file mode 100644 index 0000000..be8eef7 --- /dev/null +++ b/backend/app/api/dev_chart.py @@ -0,0 +1,256 @@ +"""Development chart API endpoints.""" + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from app.core.database import get_db +from app.models import DevelopmentChart +from app.api.schemas.development_chart import ( + DevelopmentChartCreate, + DevelopmentChartUpdate, + DevelopmentChartResponse, + DevelopmentChartList, + DevelopmentChartLookupQuery, + DevelopmentChartLookupResponse, +) + +router = APIRouter() + + +@router.get("", response_model=DevelopmentChartList) +def list_dev_chart_entries( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"), + film_stock: Optional[str] = Query(None, description="Filter by film stock (case-insensitive partial match)"), + developer: Optional[str] = Query(None, description="Filter by developer (case-insensitive partial match)"), + iso_rating: Optional[int] = Query(None, description="Filter by ISO rating (exact match)"), + db: Session = Depends(get_db), +): + """ + Get list of all development chart entries with optional filtering. + + Supports filtering by film stock, developer, and ISO rating. + Film stock and developer filters are case-insensitive partial matches. + """ + query = db.query(DevelopmentChart) + + # Apply filters + if film_stock: + query = query.filter(DevelopmentChart.film_stock.ilike(f"%{film_stock}%")) + + if developer: + query = query.filter(DevelopmentChart.developer.ilike(f"%{developer}%")) + + if iso_rating is not None: + query = query.filter(DevelopmentChart.iso_rating == iso_rating) + + # Order by film stock, then developer, then ISO rating + query = query.order_by( + DevelopmentChart.film_stock, + DevelopmentChart.developer, + DevelopmentChart.iso_rating + ) + + total = query.count() + entries = query.offset(skip).limit(limit).all() + + return DevelopmentChartList(entries=entries, total=total) + + +@router.post("", response_model=DevelopmentChartResponse, status_code=201) +def create_dev_chart_entry( + entry_data: DevelopmentChartCreate, + db: Session = Depends(get_db), +): + """ + Create a new development chart entry. + + Adds a new timing datapoint for a specific combination of film stock, + developer, ISO rating, dilution ratio, and temperature. + + Note: Database-level unique constraint prevents duplicate entries. + """ + from sqlalchemy.exc import IntegrityError + + # Create new entry + new_entry = DevelopmentChart(**entry_data.model_dump()) + db.add(new_entry) + + try: + db.commit() + db.refresh(new_entry) + return new_entry + except IntegrityError: + db.rollback() + # Entry already exists - provide helpful error message + existing = db.query(DevelopmentChart).filter( + and_( + DevelopmentChart.film_stock == entry_data.film_stock, + DevelopmentChart.developer == entry_data.developer, + DevelopmentChart.iso_rating == entry_data.iso_rating, + DevelopmentChart.dilution_ratio == entry_data.dilution_ratio, + DevelopmentChart.temperature_celsius == entry_data.temperature_celsius, + ) + ).first() + + entry_id = existing.id if existing else "unknown" + raise HTTPException( + status_code=409, + detail=( + f"Entry already exists for {entry_data.film_stock} + {entry_data.developer} " + f"at ISO {entry_data.iso_rating}, {entry_data.dilution_ratio}, {entry_data.temperature_celsius}°C. " + f"Use PUT to update existing entry (ID: {entry_id})" + ) + ) + + +@router.get("/{entry_id}", response_model=DevelopmentChartResponse) +def get_dev_chart_entry( + entry_id: str, + db: Session = Depends(get_db), +): + """Get a specific development chart entry by ID.""" + entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first() + + if not entry: + raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found") + + return entry + + +@router.put("/{entry_id}", response_model=DevelopmentChartResponse) +def update_dev_chart_entry( + entry_id: str, + entry_data: DevelopmentChartUpdate, + db: Session = Depends(get_db), +): + """ + Update an existing development chart entry. + + Only provided fields will be updated. Omitted fields remain unchanged. + """ + entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first() + + if not entry: + raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found") + + # Update only provided fields + update_data = entry_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(entry, field, value) + + db.commit() + db.refresh(entry) + + return entry + + +@router.delete("/{entry_id}", status_code=204) +def delete_dev_chart_entry( + entry_id: str, + db: Session = Depends(get_db), +): + """Delete a development chart entry.""" + entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first() + + if not entry: + raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found") + + db.delete(entry) + db.commit() + + return None + + +@router.post("/lookup", response_model=DevelopmentChartLookupResponse) +def lookup_dev_time( + query: DevelopmentChartLookupQuery, + db: Session = Depends(get_db), +): + """ + Lookup development time for specific film/developer combination. + + Searches for an exact match based on film stock, developer, ISO rating, + and optionally dilution ratio and temperature. If no exact match is found, + returns similar entries as suggestions. + """ + # Build query for exact match + filters = [ + DevelopmentChart.film_stock == query.film_stock, + DevelopmentChart.developer == query.developer, + DevelopmentChart.iso_rating == query.iso_rating, + ] + + if query.dilution_ratio: + filters.append(DevelopmentChart.dilution_ratio == query.dilution_ratio) + + if query.temperature_celsius: + filters.append(DevelopmentChart.temperature_celsius == query.temperature_celsius) + + # Try exact match + entry = db.query(DevelopmentChart).filter(and_(*filters)).first() + + if entry: + return DevelopmentChartLookupResponse( + found=True, + entry=entry, + suggestions=None + ) + + # No exact match - find suggestions (same film + developer, any ISO/dilution/temp) + suggestions_query = db.query(DevelopmentChart).filter( + and_( + DevelopmentChart.film_stock == query.film_stock, + DevelopmentChart.developer == query.developer, + ) + ).order_by( + DevelopmentChart.iso_rating, + DevelopmentChart.dilution_ratio, + DevelopmentChart.temperature_celsius + ).limit(5) + + suggestions = suggestions_query.all() + + return DevelopmentChartLookupResponse( + found=False, + entry=None, + suggestions=suggestions if suggestions else None + ) + + +@router.get("/autocomplete/films", response_model=list[str]) +def autocomplete_film_stocks( + q: str = Query(..., min_length=1, description="Search query"), + limit: int = Query(10, ge=1, le=50, description="Maximum number of results"), + db: Session = Depends(get_db), +): + """ + Autocomplete film stock names. + + Returns distinct film stock names that match the query. + """ + results = db.query(DevelopmentChart.film_stock).filter( + DevelopmentChart.film_stock.ilike(f"%{q}%") + ).distinct().order_by(DevelopmentChart.film_stock).limit(limit).all() + + return [r[0] for r in results] + + +@router.get("/autocomplete/developers", response_model=list[str]) +def autocomplete_developers( + q: str = Query(..., min_length=1, description="Search query"), + limit: int = Query(10, ge=1, le=50, description="Maximum number of results"), + db: Session = Depends(get_db), +): + """ + Autocomplete developer names. + + Returns distinct developer names that match the query. + """ + results = db.query(DevelopmentChart.developer).filter( + DevelopmentChart.developer.ilike(f"%{q}%") + ).distinct().order_by(DevelopmentChart.developer).limit(limit).all() + + return [r[0] for r in results] diff --git a/backend/app/api/schemas/__init__.py b/backend/app/api/schemas/__init__.py index 6ece074..898bbba 100644 --- a/backend/app/api/schemas/__init__.py +++ b/backend/app/api/schemas/__init__.py @@ -20,6 +20,15 @@ AssignChemistryRequest, RateRollRequest, ) +from app.api.schemas.development_chart import ( + DevelopmentChartBase, + DevelopmentChartCreate, + DevelopmentChartUpdate, + DevelopmentChartResponse, + DevelopmentChartList, + DevelopmentChartLookupQuery, + DevelopmentChartLookupResponse, +) __all__ = [ "FilmRollBase", @@ -36,4 +45,11 @@ "UnloadRollRequest", "AssignChemistryRequest", "RateRollRequest", + "DevelopmentChartBase", + "DevelopmentChartCreate", + "DevelopmentChartUpdate", + "DevelopmentChartResponse", + "DevelopmentChartList", + "DevelopmentChartLookupQuery", + "DevelopmentChartLookupResponse", ] diff --git a/backend/app/api/schemas/development_chart.py b/backend/app/api/schemas/development_chart.py new file mode 100644 index 0000000..e158d43 --- /dev/null +++ b/backend/app/api/schemas/development_chart.py @@ -0,0 +1,125 @@ +"""Pydantic schemas for development chart API.""" + +from datetime import datetime +from decimal import Decimal +from typing import Optional, List + +from pydantic import BaseModel, Field, field_validator + + +class DevelopmentChartBase(BaseModel): + """Base schema for development chart entries.""" + + film_stock: str = Field( + ..., + min_length=1, + max_length=200, + description="Film stock name (e.g., 'Ilford HP5 Plus 400')", + examples=["Ilford HP5 Plus 400", "Kodak Tri-X 400", "Kodak T-Max 400"] + ) + developer: str = Field( + ..., + min_length=1, + max_length=200, + description="Developer name (e.g., 'Ilfosol 3', 'D-76')", + examples=["Ilfosol 3", "D-76", "HC-110", "Rodinal"] + ) + iso_rating: int = Field( + ..., + gt=0, + le=25600, + description="ISO rating (use pushed/pulled ISO for push/pull processing)", + examples=[400, 800, 1600] + ) + dilution_ratio: str = Field( + ..., + min_length=1, + max_length=50, + description="Dilution ratio (e.g., '1+4', '1+9', 'stock')", + examples=["1+4", "1+9", "1+14", "1+31", "stock"] + ) + temperature_celsius: Decimal = Field( + ..., + gt=0, + le=100, + description="Development temperature in Celsius (typically 15-30°C, up to 100°C for some processes)", + examples=[20.0, 24.0, 38.0] + ) + development_time_seconds: int = Field( + ..., + gt=0, + description="Development time in seconds", + examples=[390, 660, 480] + ) + agitation_notes: Optional[str] = Field( + None, + max_length=500, + description="Agitation pattern notes", + examples=["First 30s continuous, then 10s every minute"] + ) + notes: Optional[str] = Field( + None, + description="Additional notes or source references", + examples=["From Ilford datasheet", "Personal experiment results"] + ) + + +class DevelopmentChartCreate(DevelopmentChartBase): + """Schema for creating a new development chart entry.""" + pass + + +class DevelopmentChartUpdate(BaseModel): + """Schema for updating an existing development chart entry.""" + + film_stock: Optional[str] = Field(None, min_length=1, max_length=200) + developer: Optional[str] = Field(None, min_length=1, max_length=200) + iso_rating: Optional[int] = Field(None, gt=0, le=25600) + dilution_ratio: Optional[str] = Field(None, min_length=1, max_length=50) + temperature_celsius: Optional[Decimal] = Field(None, gt=0, le=100) + development_time_seconds: Optional[int] = Field(None, gt=0) + agitation_notes: Optional[str] = Field(None, max_length=500) + notes: Optional[str] = None + + +class DevelopmentChartResponse(DevelopmentChartBase): + """Schema for development chart entry response.""" + + id: str + development_time_formatted: str = Field( + ..., + description="Formatted development time as MM:SS" + ) + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class DevelopmentChartList(BaseModel): + """Schema for list of development chart entries.""" + + entries: List[DevelopmentChartResponse] + total: int = Field(..., description="Total number of entries matching filters") + + +class DevelopmentChartLookupQuery(BaseModel): + """Schema for development chart lookup query.""" + + film_stock: str = Field(..., description="Film stock name (exact match)") + developer: str = Field(..., description="Developer name (exact match)") + iso_rating: int = Field(..., gt=0, description="ISO rating") + dilution_ratio: Optional[str] = Field(None, description="Dilution ratio (optional)") + temperature_celsius: Optional[Decimal] = Field(None, gt=0, description="Temperature in Celsius (optional)") + + +class DevelopmentChartLookupResponse(BaseModel): + """Schema for development chart lookup response.""" + + found: bool = Field(..., description="Whether a matching entry was found") + entry: Optional[DevelopmentChartResponse] = Field(None, description="Matching entry if found") + suggestions: Optional[List[DevelopmentChartResponse]] = Field( + None, + description="Similar entries if exact match not found" + ) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index a85c5bc..dc532a1 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -67,7 +67,7 @@ def init_db() -> None: Note: This only creates tables that don't exist yet. It won't modify existing tables. """ # Import models to ensure they're registered with Base - from app.models import FilmRoll, ChemistryBatch + from app.models import FilmRoll, ChemistryBatch, DevelopmentChart # Ensure database directory exists settings.get_database_path() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0403bc2..247313e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,5 +3,6 @@ from app.models.base import Base from app.models.film_roll import FilmRoll from app.models.chemistry_batch import ChemistryBatch +from app.models.development_chart import DevelopmentChart -__all__ = ["Base", "FilmRoll", "ChemistryBatch"] +__all__ = ["Base", "FilmRoll", "ChemistryBatch", "DevelopmentChart"] diff --git a/backend/app/models/development_chart.py b/backend/app/models/development_chart.py new file mode 100644 index 0000000..df3b8b5 --- /dev/null +++ b/backend/app/models/development_chart.py @@ -0,0 +1,109 @@ +"""Development chart database model for B&W film development timing lookup.""" + +from decimal import Decimal +from typing import Optional + +import sqlalchemy +from sqlalchemy import Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, generate_uuid + + +class DevelopmentChart(Base, TimestampMixin): + """ + Development chart lookup table for B&W film development. + + Stores timing, dilution, and temperature data for specific combinations of: + - Film stock (e.g., "Ilford HP5 Plus 400", "Kodak Tri-X 400") + - Developer (e.g., "Ilfosol 3", "D-76", "HC-110") + - ISO rating (e.g., 400, 800 for push processing) + - Dilution ratio (e.g., "1+4", "1+9", "1+14") + - Temperature in Celsius (e.g., 20, 24) + + Development time is stored in seconds for consistency with C41 calculations. + + Example entries: + - Ilford HP5+, Ilfosol 3, ISO 400, 1+4, 20°C → 390 seconds (6:30) + - Ilford HP5+, Ilfosol 3, ISO 800, 1+4, 20°C → 660 seconds (11:00) + - Ilford HP5+, Ilfosol 3, ISO 400, 1+9, 20°C → 480 seconds (8:00) + """ + + __tablename__ = "development_chart" + __table_args__ = ( + # Unique constraint to prevent duplicate entries + # This ensures data integrity and prevents race conditions + sqlalchemy.UniqueConstraint( + 'film_stock', 'developer', 'iso_rating', 'dilution_ratio', 'temperature_celsius', + name='uix_dev_chart_combination' + ), + ) + + # Primary key + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=generate_uuid + ) + + # Film and developer identification + film_stock: Mapped[str] = mapped_column( + String(200), nullable=False, index=True, + comment="Film stock name (e.g., 'Ilford HP5 Plus 400')" + ) + developer: Mapped[str] = mapped_column( + String(200), nullable=False, index=True, + comment="Developer name (e.g., 'Ilfosol 3', 'D-76', 'HC-110')" + ) + + # Development parameters + iso_rating: Mapped[int] = mapped_column( + Integer, nullable=False, index=True, + comment="ISO rating (use pushed/pulled ISO for push/pull processing)" + ) + dilution_ratio: Mapped[str] = mapped_column( + String(50), nullable=False, + comment="Dilution ratio (e.g., '1+4', '1+9', 'stock', '1+31')" + ) + temperature_celsius: Mapped[Decimal] = mapped_column( + Numeric(4, 1), nullable=False, + comment="Development temperature in Celsius (e.g., 20.0, 24.0)" + ) + + # Development timing + development_time_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, + comment="Development time in seconds (e.g., 390 for 6:30)" + ) + + # Additional information + agitation_notes: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, + comment="Agitation pattern notes (e.g., 'First 30s continuous, then 10s every minute')" + ) + notes: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="Additional notes, source references, or experimental observations" + ) + + @property + def development_time_formatted(self) -> str: + """ + Format development time as MM:SS. + + Returns: + Formatted time string (e.g., "6:30", "11:00") + """ + minutes = self.development_time_seconds // 60 + seconds = self.development_time_seconds % 60 + return f"{minutes}:{seconds:02d}" + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/tests/test_dev_chart.py b/backend/tests/test_dev_chart.py new file mode 100644 index 0000000..8b17251 --- /dev/null +++ b/backend/tests/test_dev_chart.py @@ -0,0 +1,397 @@ +"""Tests for development chart API endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from decimal import Decimal + +from app.main import app +from app.core.database import get_db +from app.models.base import Base +from app.models import DevelopmentChart + + +# Create test database +SQLALCHEMY_DATABASE_URL = "sqlite:///./test_dev_chart.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +# Override dependency +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db +client = TestClient(app) + + +@pytest.fixture(scope="function", autouse=True) +def setup_database(): + """Create tables before each test and drop after.""" + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def sample_entry(): + """Create a sample development chart entry for testing.""" + return { + "film_stock": "Ilford HP5 Plus 400", + "developer": "Ilfosol 3", + "iso_rating": 400, + "dilution_ratio": "1+4", + "temperature_celsius": 20.0, + "development_time_seconds": 390, # 6:30 + "agitation_notes": "First 30s continuous, then 10s every minute", + "notes": "From Ilford datasheet" + } + + +def test_create_dev_chart_entry(sample_entry): + """Test creating a new development chart entry.""" + response = client.post("/api/dev-chart", json=sample_entry) + assert response.status_code == 201 + + data = response.json() + assert data["film_stock"] == sample_entry["film_stock"] + assert data["developer"] == sample_entry["developer"] + assert data["iso_rating"] == sample_entry["iso_rating"] + assert data["dilution_ratio"] == sample_entry["dilution_ratio"] + assert float(data["temperature_celsius"]) == sample_entry["temperature_celsius"] + assert data["development_time_seconds"] == sample_entry["development_time_seconds"] + assert data["development_time_formatted"] == "6:30" + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + + +def test_create_duplicate_entry(sample_entry): + """Test that creating a duplicate entry fails.""" + # Create first entry + response = client.post("/api/dev-chart", json=sample_entry) + assert response.status_code == 201 + + # Try to create duplicate + response = client.post("/api/dev-chart", json=sample_entry) + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + +def test_list_dev_chart_entries(sample_entry): + """Test listing development chart entries.""" + # Create multiple entries + client.post("/api/dev-chart", json=sample_entry) + + # Create another with different ISO (push) + push_entry = sample_entry.copy() + push_entry["iso_rating"] = 800 + push_entry["development_time_seconds"] = 660 # 11:00 + client.post("/api/dev-chart", json=push_entry) + + # List all entries + response = client.get("/api/dev-chart") + assert response.status_code == 200 + + data = response.json() + assert "entries" in data + assert "total" in data + assert data["total"] == 2 + assert len(data["entries"]) == 2 + + +def test_filter_by_film_stock(sample_entry): + """Test filtering entries by film stock.""" + # Create entries for different films + client.post("/api/dev-chart", json=sample_entry) + + kodak_entry = sample_entry.copy() + kodak_entry["film_stock"] = "Kodak Tri-X 400" + client.post("/api/dev-chart", json=kodak_entry) + + # Filter for Ilford + response = client.get("/api/dev-chart", params={"film_stock": "Ilford"}) + assert response.status_code == 200 + + data = response.json() + assert data["total"] == 1 + assert "Ilford" in data["entries"][0]["film_stock"] + + +def test_filter_by_developer(sample_entry): + """Test filtering entries by developer.""" + # Create entries with different developers + client.post("/api/dev-chart", json=sample_entry) + + d76_entry = sample_entry.copy() + d76_entry["developer"] = "D-76" + d76_entry["dilution_ratio"] = "1+1" + d76_entry["development_time_seconds"] = 480 + client.post("/api/dev-chart", json=d76_entry) + + # Filter for D-76 + response = client.get("/api/dev-chart", params={"developer": "D-76"}) + assert response.status_code == 200 + + data = response.json() + assert data["total"] == 1 + assert data["entries"][0]["developer"] == "D-76" + + +def test_filter_by_iso_rating(sample_entry): + """Test filtering entries by ISO rating.""" + # Create entries with different ISOs + client.post("/api/dev-chart", json=sample_entry) + + push_entry = sample_entry.copy() + push_entry["iso_rating"] = 800 + push_entry["development_time_seconds"] = 660 + client.post("/api/dev-chart", json=push_entry) + + # Filter for ISO 800 + response = client.get("/api/dev-chart", params={"iso_rating": 800}) + assert response.status_code == 200 + + data = response.json() + assert data["total"] == 1 + assert data["entries"][0]["iso_rating"] == 800 + + +def test_get_single_entry(sample_entry): + """Test getting a single entry by ID.""" + # Create entry + create_response = client.post("/api/dev-chart", json=sample_entry) + entry_id = create_response.json()["id"] + + # Get entry + response = client.get(f"/api/dev-chart/{entry_id}") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == entry_id + assert data["film_stock"] == sample_entry["film_stock"] + + +def test_get_nonexistent_entry(): + """Test getting a nonexistent entry returns 404.""" + response = client.get("/api/dev-chart/nonexistent-id") + assert response.status_code == 404 + + +def test_update_entry(sample_entry): + """Test updating an existing entry.""" + # Create entry + create_response = client.post("/api/dev-chart", json=sample_entry) + entry_id = create_response.json()["id"] + + # Update development time + update_data = { + "development_time_seconds": 420, # 7:00 + "notes": "Updated timing based on experiment" + } + response = client.put(f"/api/dev-chart/{entry_id}", json=update_data) + assert response.status_code == 200 + + data = response.json() + assert data["development_time_seconds"] == 420 + assert data["development_time_formatted"] == "7:00" + assert data["notes"] == "Updated timing based on experiment" + # Other fields should remain unchanged + assert data["film_stock"] == sample_entry["film_stock"] + assert data["developer"] == sample_entry["developer"] + + +def test_delete_entry(sample_entry): + """Test deleting an entry.""" + # Create entry + create_response = client.post("/api/dev-chart", json=sample_entry) + entry_id = create_response.json()["id"] + + # Delete entry + response = client.delete(f"/api/dev-chart/{entry_id}") + assert response.status_code == 204 + + # Verify it's gone + get_response = client.get(f"/api/dev-chart/{entry_id}") + assert get_response.status_code == 404 + + +def test_lookup_exact_match(sample_entry): + """Test lookup with exact match.""" + # Create entry + client.post("/api/dev-chart", json=sample_entry) + + # Lookup with all parameters + lookup_query = { + "film_stock": "Ilford HP5 Plus 400", + "developer": "Ilfosol 3", + "iso_rating": 400, + "dilution_ratio": "1+4", + "temperature_celsius": 20.0 + } + response = client.post("/api/dev-chart/lookup", json=lookup_query) + assert response.status_code == 200 + + data = response.json() + assert data["found"] is True + assert data["entry"] is not None + assert data["entry"]["development_time_seconds"] == 390 + assert data["entry"]["development_time_formatted"] == "6:30" + + +def test_lookup_without_dilution_temp(sample_entry): + """Test lookup without specifying dilution and temperature.""" + # Create entry + client.post("/api/dev-chart", json=sample_entry) + + # Lookup with only required fields + lookup_query = { + "film_stock": "Ilford HP5 Plus 400", + "developer": "Ilfosol 3", + "iso_rating": 400 + } + response = client.post("/api/dev-chart/lookup", json=lookup_query) + assert response.status_code == 200 + + data = response.json() + assert data["found"] is True + assert data["entry"] is not None + + +def test_lookup_no_match_with_suggestions(sample_entry): + """Test lookup with no exact match returns suggestions.""" + # Create entry for HP5 at ISO 400 + client.post("/api/dev-chart", json=sample_entry) + + # Lookup for same film/dev but different ISO + lookup_query = { + "film_stock": "Ilford HP5 Plus 400", + "developer": "Ilfosol 3", + "iso_rating": 800 # Different ISO + } + response = client.post("/api/dev-chart/lookup", json=lookup_query) + assert response.status_code == 200 + + data = response.json() + assert data["found"] is False + assert data["entry"] is None + assert data["suggestions"] is not None + assert len(data["suggestions"]) > 0 + # Suggestions should be for same film/dev + for suggestion in data["suggestions"]: + assert suggestion["film_stock"] == "Ilford HP5 Plus 400" + assert suggestion["developer"] == "Ilfosol 3" + + +def test_lookup_no_match_no_suggestions(): + """Test lookup with no matches at all.""" + lookup_query = { + "film_stock": "Nonexistent Film", + "developer": "Nonexistent Developer", + "iso_rating": 400 + } + response = client.post("/api/dev-chart/lookup", json=lookup_query) + assert response.status_code == 200 + + data = response.json() + assert data["found"] is False + assert data["entry"] is None + assert data["suggestions"] is None + + +def test_autocomplete_films(sample_entry): + """Test film stock autocomplete.""" + # Create entries + client.post("/api/dev-chart", json=sample_entry) + + kodak_entry = sample_entry.copy() + kodak_entry["film_stock"] = "Kodak Tri-X 400" + client.post("/api/dev-chart", json=kodak_entry) + + # Autocomplete for "Ilford" + response = client.get("/api/dev-chart/autocomplete/films", params={"q": "Ilford"}) + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + assert "Ilford" in data[0] + + +def test_autocomplete_developers(sample_entry): + """Test developer autocomplete.""" + # Create entries with different developers + client.post("/api/dev-chart", json=sample_entry) + + d76_entry = sample_entry.copy() + d76_entry["developer"] = "D-76" + d76_entry["dilution_ratio"] = "1+1" + client.post("/api/dev-chart", json=d76_entry) + + # Autocomplete for "D" + response = client.get("/api/dev-chart/autocomplete/developers", params={"q": "D"}) + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert "D-76" in data + + +def test_development_time_formatting(): + """Test that development times are formatted correctly.""" + test_cases = [ + (390, "6:30"), # 6 minutes 30 seconds + (660, "11:00"), # 11 minutes + (480, "8:00"), # 8 minutes + (45, "0:45"), # 45 seconds + (3600, "60:00"), # 1 hour + ] + + for seconds, expected_format in test_cases: + entry = { + "film_stock": "Test Film", + "developer": "Test Developer", + "iso_rating": 400, + "dilution_ratio": "1+4", + "temperature_celsius": 20.0, + "development_time_seconds": seconds + } + response = client.post("/api/dev-chart", json=entry) + assert response.status_code == 201 + + data = response.json() + assert data["development_time_formatted"] == expected_format + + # Clean up + client.delete(f"/api/dev-chart/{data['id']}") + + +def test_pagination(sample_entry): + """Test pagination of dev chart entries.""" + # Create 15 entries + for i in range(15): + entry = sample_entry.copy() + entry["film_stock"] = f"Film {i}" + client.post("/api/dev-chart", json=entry) + + # Get first page (10 items) + response = client.get("/api/dev-chart", params={"skip": 0, "limit": 10}) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 15 + assert len(data["entries"]) == 10 + + # Get second page (5 items) + response = client.get("/api/dev-chart", params={"skip": 10, "limit": 10}) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 15 + assert len(data["entries"]) == 5 diff --git a/examples/dev_chart_demo.py b/examples/dev_chart_demo.py new file mode 100755 index 0000000..6539f91 --- /dev/null +++ b/examples/dev_chart_demo.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Development Chart API Demo + +Demonstrates how to use the development chart API to: +1. Query development times for specific film/developer combinations +2. Add new entries to the chart +3. Search and filter entries +""" + +import requests +import json + +BASE_URL = "http://localhost:8200/api" + + +def lookup_dev_time(film_stock, developer, iso_rating, dilution_ratio=None, temperature=None): + """ + Lookup development time for a specific combination. + + Args: + film_stock: Film stock name (e.g., "Ilford HP5 Plus 400") + developer: Developer name (e.g., "Ilfosol 3") + iso_rating: ISO rating (e.g., 400, 800 for push) + dilution_ratio: Optional dilution (e.g., "1+4", "1+9") + temperature: Optional temperature in Celsius (e.g., 20.0) + + Returns: + dict: Lookup result with entry or suggestions + """ + payload = { + "film_stock": film_stock, + "developer": developer, + "iso_rating": iso_rating, + } + + if dilution_ratio: + payload["dilution_ratio"] = dilution_ratio + + if temperature: + payload["temperature_celsius"] = temperature + + response = requests.post(f"{BASE_URL}/dev-chart/lookup", json=payload) + response.raise_for_status() + return response.json() + + +def add_dev_chart_entry(film_stock, developer, iso_rating, dilution_ratio, + temperature, dev_time_seconds, agitation_notes=None, notes=None): + """ + Add a new development chart entry. + + Args: + film_stock: Film stock name + developer: Developer name + iso_rating: ISO rating + dilution_ratio: Dilution ratio (e.g., "1+4") + temperature: Temperature in Celsius + dev_time_seconds: Development time in seconds (or use helper to convert from MM:SS) + agitation_notes: Optional agitation pattern notes + notes: Optional additional notes + + Returns: + dict: Created entry + """ + payload = { + "film_stock": film_stock, + "developer": developer, + "iso_rating": iso_rating, + "dilution_ratio": dilution_ratio, + "temperature_celsius": temperature, + "development_time_seconds": dev_time_seconds, + } + + if agitation_notes: + payload["agitation_notes"] = agitation_notes + + if notes: + payload["notes"] = notes + + response = requests.post(f"{BASE_URL}/dev-chart", json=payload) + response.raise_for_status() + return response.json() + + +def search_by_film_stock(film_stock, limit=10): + """ + Search for all entries for a specific film stock. + + Args: + film_stock: Film stock name (partial match) + limit: Maximum number of results + + Returns: + dict: List of entries and total count + """ + response = requests.get( + f"{BASE_URL}/dev-chart", + params={"film_stock": film_stock, "limit": limit} + ) + response.raise_for_status() + return response.json() + + +def time_to_seconds(time_str): + """ + Convert MM:SS format to seconds. + + Args: + time_str: Time in MM:SS format (e.g., "6:30", "11:00") + + Returns: + int: Time in seconds + """ + parts = time_str.split(":") + minutes = int(parts[0]) + seconds = int(parts[1]) if len(parts) > 1 else 0 + return minutes * 60 + seconds + + +def main(): + print("=" * 60) + print("Development Chart API Demo") + print("=" * 60) + print() + + # Example 1: Lookup development time for HP5+ at box speed + print("Example 1: Lookup HP5+ with Ilfosol 3 at ISO 400") + print("-" * 60) + result = lookup_dev_time("Ilford HP5 Plus 400", "Ilfosol 3", 400, "1+4", 20.0) + if result["found"]: + entry = result["entry"] + print(f"✓ Found entry!") + print(f" Film: {entry['film_stock']}") + print(f" Developer: {entry['developer']}") + print(f" ISO: {entry['iso_rating']}") + print(f" Dilution: {entry['dilution_ratio']}") + print(f" Temperature: {entry['temperature_celsius']}°C") + print(f" Development time: {entry['development_time_formatted']}") + print(f" Agitation: {entry['agitation_notes']}") + else: + print("✗ No exact match found") + print() + + # Example 2: Lookup push processing + print("Example 2: Lookup HP5+ push +1 (ISO 800)") + print("-" * 60) + result = lookup_dev_time("Ilford HP5 Plus 400", "Ilfosol 3", 800) + if result["found"]: + entry = result["entry"] + print(f"✓ Found entry!") + print(f" Development time: {entry['development_time_formatted']}") + print(f" Notes: {entry['notes']}") + else: + print("✗ No exact match found") + if result["suggestions"]: + print(f" Found {len(result['suggestions'])} similar entries") + print() + + # Example 3: Search all Kodak Tri-X entries + print("Example 3: Search all Kodak Tri-X entries") + print("-" * 60) + results = search_by_film_stock("Kodak Tri-X") + print(f"Found {results['total']} entries:") + for entry in results["entries"][:5]: # Show first 5 + print(f" - {entry['developer']}, ISO {entry['iso_rating']}, {entry['dilution_ratio']}: {entry['development_time_formatted']}") + print() + + # Example 4: Add a new entry (commented out to avoid duplicates) + print("Example 4: Adding a new entry") + print("-" * 60) + print("# To add a new entry, uncomment the following code:") + print(""" + new_entry = add_dev_chart_entry( + film_stock="Ilford XP2 Super 400", + developer="C41", + iso_rating=400, + dilution_ratio="standard", + temperature=38.0, + dev_time_seconds=time_to_seconds("3:15"), + agitation_notes="10s every 30s", + notes="C41 process at standard time" + ) + print(f"✓ Added entry: {new_entry['id']}") + """) + print() + + print("=" * 60) + print("Demo complete! Use the API to manage your dev chart.") + print("=" * 60) + + +if __name__ == "__main__": + try: + main() + except requests.exceptions.ConnectionError: + print("Error: Cannot connect to API server") + print("Make sure the server is running: uvicorn app.main:app --port 8200") + except Exception as e: + print(f"Error: {e}") diff --git a/frontend/src/assets/film-stocks/fuji-quicksnap-400.png b/frontend/src/assets/film-stocks/fuji-quicksnap-400.png new file mode 100644 index 0000000..615b5ac Binary files /dev/null and b/frontend/src/assets/film-stocks/fuji-quicksnap-400.png differ diff --git a/frontend/src/assets/film-stocks/kodak-funsaver-800.png b/frontend/src/assets/film-stocks/kodak-funsaver-800.png new file mode 100644 index 0000000..277a172 Binary files /dev/null and b/frontend/src/assets/film-stocks/kodak-funsaver-800.png differ diff --git a/frontend/src/utils/filmStockImages.js b/frontend/src/utils/filmStockImages.js index eef59be..063cd1a 100644 --- a/frontend/src/utils/filmStockImages.js +++ b/frontend/src/utils/filmStockImages.js @@ -23,6 +23,10 @@ import reflx800 from '../assets/film-stocks/reflx-800.png'; import reflx400 from '../assets/film-stocks/reflx-400.png'; import unknownRollIcon from '../assets/film-stocks/unknown.png'; +// These are for 35mm but the images are actually of disposable cameras +import fujiQuickSnapIcon from '../assets/film-stocks/fuji-quicksnap-400.png'; +import kodakFunSaver800Icon from '../assets/film-stocks/kodak-funsaver-800.png'; + // 120 film import kodakEktar100_120 from '../assets/film-stocks/kodak-ektar-100-120.png'; @@ -69,6 +73,10 @@ const filmStockImageMap = { 'Cinestill 800T': cinestill800T, 'Cinestill 50D': cinestill50D, 'Cinestill 400D': cinestill400D, + + // Disposable cameras (35mm) + 'Fujifilm QuickSnap 400': fujiQuickSnapIcon, + 'Kodak FunSaver 800': kodakFunSaver800Icon, }; const filmStockImageMap120 = { diff --git a/migration/data/dev_chart_seed.csv b/migration/data/dev_chart_seed.csv new file mode 100644 index 0000000..25b3fe4 --- /dev/null +++ b/migration/data/dev_chart_seed.csv @@ -0,0 +1,35 @@ +film_stock,developer,iso_rating,dilution_ratio,temperature_celsius,development_time_seconds,agitation_notes,notes +Ilford HP5 Plus 400,Ilfosol 3,400,1+4,20.0,6:30,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford HP5 Plus 400,Ilfosol 3,800,1+4,20.0,11:00,First 30s continuous then 10s every minute,Push +1 stop from Ilford datasheet +Ilford HP5 Plus 400,Ilfosol 3,1600,1+4,20.0,16:00,First 30s continuous then 10s every minute,Push +2 stops from Ilford datasheet +Ilford HP5 Plus 400,Ilfosol 3,400,1+9,20.0,8:00,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford HP5 Plus 400,Ilfosol 3,800,1+9,20.0,14:00,First 30s continuous then 10s every minute,Push +1 stop from Ilford datasheet +Ilford HP5 Plus 400,D-76,400,1+1,20.0,9:30,Agitate 5s every 30s,From Ilford datasheet +Ilford HP5 Plus 400,D-76,800,1+1,20.0,13:00,Agitate 5s every 30s,Push +1 stop from Ilford datasheet +Ilford HP5 Plus 400,D-76,400,stock,20.0,7:00,Agitate 5s every 30s,From Ilford datasheet +Ilford FP4 Plus 125,Ilfosol 3,125,1+4,20.0,5:30,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford FP4 Plus 125,Ilfosol 3,250,1+4,20.0,9:00,First 30s continuous then 10s every minute,Push +1 stop from Ilford datasheet +Ilford FP4 Plus 125,Ilfosol 3,125,1+9,20.0,7:00,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford FP4 Plus 125,D-76,125,1+1,20.0,8:00,Agitate 5s every 30s,From Ilford datasheet +Ilford FP4 Plus 125,D-76,125,stock,20.0,6:00,Agitate 5s every 30s,From Ilford datasheet +Kodak Tri-X 400,D-76,400,1+1,20.0,10:00,Agitate 5s every 30s,From Kodak datasheet +Kodak Tri-X 400,D-76,400,stock,20.0,8:00,Agitate 5s every 30s,From Kodak datasheet +Kodak Tri-X 400,D-76,800,1+1,20.0,14:00,Agitate 5s every 30s,Push +1 stop from Kodak datasheet +Kodak Tri-X 400,D-76,1600,1+1,20.0,18:00,Agitate 5s every 30s,Push +2 stops from Kodak datasheet +Kodak Tri-X 400,HC-110,400,1+31,20.0,6:00,Agitate 5s every 30s,Dilution B from Kodak datasheet +Kodak Tri-X 400,HC-110,800,1+31,20.0,8:00,Agitate 5s every 30s,Push +1 stop Dilution B +Kodak Tri-X 400,Rodinal,400,1+50,20.0,13:00,Agitate 10s every minute,Standard Rodinal development +Kodak T-Max 400,T-Max Developer,400,1+4,24.0,7:00,Agitate 5s every 30s,From Kodak datasheet at 24C +Kodak T-Max 400,T-Max Developer,800,1+4,24.0,9:30,Agitate 5s every 30s,Push +1 stop at 24C +Kodak T-Max 400,D-76,400,1+1,20.0,9:00,Agitate 5s every 30s,From Kodak datasheet +Kodak T-Max 400,HC-110,400,1+31,20.0,5:30,Agitate 5s every 30s,Dilution B from Kodak datasheet +Fomapan 400,D-76,400,1+1,20.0,11:00,Agitate 5s every 30s,From Foma datasheet +Fomapan 400,Rodinal,400,1+50,20.0,12:00,Agitate 10s every minute,From Foma datasheet +Ilford Delta 400,Ilfosol 3,400,1+9,20.0,7:00,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford Delta 400,Ilfosol 3,800,1+9,20.0,11:00,First 30s continuous then 10s every minute,Push +1 stop from Ilford datasheet +Ilford Delta 400,D-76,400,1+1,20.0,9:00,Agitate 5s every 30s,From Ilford datasheet +Ilford Delta 400,D-76,800,1+1,20.0,12:00,Agitate 5s every 30s,Push +1 stop from Ilford datasheet +Ilford Delta 100,Ilfosol 3,100,1+9,20.0,6:00,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford Delta 100,D-76,100,1+1,20.0,7:30,Agitate 5s every 30s,From Ilford datasheet +Ilford Delta 3200,Ilfosol 3,3200,1+4,20.0,12:00,First 30s continuous then 10s every minute,From Ilford datasheet +Ilford Delta 3200,D-76,3200,stock,20.0,11:00,Agitate 5s every 30s,From Ilford datasheet diff --git a/migration/scripts/import_dev_chart.py b/migration/scripts/import_dev_chart.py new file mode 100755 index 0000000..d96665a --- /dev/null +++ b/migration/scripts/import_dev_chart.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Import development chart data from CSV file. + +CSV format: +film_stock,developer,iso_rating,dilution_ratio,temperature_celsius,development_time_seconds,agitation_notes,notes + +Example: +Ilford HP5 Plus 400,Ilfosol 3,400,1+4,20.0,390,First 30s continuous then 10s every minute,From Ilford datasheet +""" + +import sys +import csv +import argparse +from pathlib import Path +from decimal import Decimal + +# Add backend to path +backend_path = Path(__file__).parent.parent.parent / "backend" +sys.path.insert(0, str(backend_path)) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.models.base import Base +from app.models.development_chart import DevelopmentChart + + +def parse_time_to_seconds(time_str: str) -> int: + """ + Parse time string to seconds. + + Supports formats: + - "6:30" -> 390 seconds + - "390" -> 390 seconds + - "11:00" -> 660 seconds + - "390.5" -> 390 seconds (truncates decimals) + + Args: + time_str: Time string to parse + + Returns: + Time in seconds + """ + time_str = time_str.strip() + + # If it's in MM:SS format + if ":" in time_str: + parts = time_str.split(":") + minutes = int(parts[0]) + seconds = int(parts[1]) if len(parts) > 1 else 0 + return minutes * 60 + seconds + + # Otherwise try to parse as a number (handles integers, floats, etc.) + try: + # Convert to float first to handle decimals, then to int (truncates) + return int(float(time_str)) + except ValueError: + raise ValueError(f"Invalid time format: {time_str}. Expected MM:SS or number of seconds.") + + +def import_from_csv(csv_path: Path, db_path: Path, skip_duplicates: bool = True): + """ + Import development chart entries from CSV file. + + Args: + csv_path: Path to CSV file + db_path: Path to SQLite database + skip_duplicates: If True, skip entries that already exist + """ + # Create database engine + engine = create_engine(f"sqlite:///{db_path}") + + # Create tables if they don't exist + Base.metadata.create_all(bind=engine) + + # Create session + Session = sessionmaker(bind=engine) + session = Session() + + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + + added = 0 + skipped = 0 + errors = 0 + + for row_num, row in enumerate(reader, start=2): # Start at 2 (1 is header) + try: + # Parse development time using the helper function + dev_time_seconds = parse_time_to_seconds(row['development_time_seconds']) + + # Check if entry already exists + existing = session.query(DevelopmentChart).filter( + DevelopmentChart.film_stock == row['film_stock'].strip(), + DevelopmentChart.developer == row['developer'].strip(), + DevelopmentChart.iso_rating == int(row['iso_rating']), + DevelopmentChart.dilution_ratio == row['dilution_ratio'].strip(), + DevelopmentChart.temperature_celsius == Decimal(row['temperature_celsius']), + ).first() + + if existing: + if skip_duplicates: + print(f"Row {row_num}: Skipping duplicate entry for {row['film_stock']} + {row['developer']} (ISO {row['iso_rating']})") + skipped += 1 + continue + else: + print(f"Row {row_num}: Updating existing entry for {row['film_stock']} + {row['developer']} (ISO {row['iso_rating']})") + existing.development_time_seconds = dev_time_seconds + existing.agitation_notes = row.get('agitation_notes', '').strip() or None + existing.notes = row.get('notes', '').strip() or None + added += 1 + continue + + # Create new entry + entry = DevelopmentChart( + film_stock=row['film_stock'].strip(), + developer=row['developer'].strip(), + iso_rating=int(row['iso_rating']), + dilution_ratio=row['dilution_ratio'].strip(), + temperature_celsius=Decimal(row['temperature_celsius']), + development_time_seconds=dev_time_seconds, + agitation_notes=row.get('agitation_notes', '').strip() or None, + notes=row.get('notes', '').strip() or None, + ) + + session.add(entry) + print(f"Row {row_num}: Added {row['film_stock']} + {row['developer']} (ISO {row['iso_rating']}, {row['dilution_ratio']}, {row['temperature_celsius']}°C) -> {entry.development_time_formatted}") + added += 1 + + except Exception as e: + print(f"Row {row_num}: Error - {e}") + errors += 1 + + # Commit all changes + session.commit() + + print("\n" + "=" * 60) + print(f"Import complete!") + print(f" Added: {added}") + print(f" Skipped: {skipped}") + print(f" Errors: {errors}") + print(f" Total in database: {session.query(DevelopmentChart).count()}") + + except Exception as e: + print(f"Error reading CSV file: {e}") + session.rollback() + return False + finally: + session.close() + + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Import development chart data from CSV file", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +CSV Format: + film_stock,developer,iso_rating,dilution_ratio,temperature_celsius,development_time_seconds,agitation_notes,notes + +Example: + Ilford HP5 Plus 400,Ilfosol 3,400,1+4,20.0,6:30,First 30s continuous then 10s every minute,From Ilford datasheet + +Note: development_time_seconds can be in seconds (390) or MM:SS format (6:30) + """ + ) + + parser.add_argument( + "csv_file", + type=Path, + help="Path to CSV file with development chart data" + ) + + parser.add_argument( + "--db-path", + type=Path, + default=Path(__file__).parent.parent.parent / "backend" / "data" / "emulsion.db", + help="Path to SQLite database (default: backend/data/emulsion.db)" + ) + + parser.add_argument( + "--update-duplicates", + action="store_true", + help="Update existing entries instead of skipping them" + ) + + args = parser.parse_args() + + # Validate paths + if not args.csv_file.exists(): + print(f"Error: CSV file not found: {args.csv_file}") + return 1 + + # Import data + success = import_from_csv( + args.csv_file, + args.db_path, + skip_duplicates=not args.update_duplicates + ) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main())