Skip to content

Commit b6cebec

Browse files
mpgithubcodeclaude
andcommitted
Extensions, and the first one: Blueprint takeoff
- extensions: packages in backend/app/extensions/<id> (API at /api/x/<id>/, own migrations recorded as "<id>:NNNN") with screens in frontend/src/extensions/<id> (#/x/<id>, loaded when opened); the admin switches them on and off in Settings → Extensions; the sidebar lists the ones that are on - Blueprint takeoff: projects from a PDF drawing set (upload or a files table); sheets read from title blocks and the drawing index (number, title, discipline) with their scales; include sheets by discipline, check them, calibrate a scale from a known dimension - legends and schedules read as tables (or boxed by hand) become items with their label, symbol and schedule columns; items found on sheets by label (standing alone, not in sentences) or by symbol (OpenCV template matching, four rotations, sizes ±10%), as suggestions to accept or reject; left-out areas - counting and measuring runs and areas by hand with the sheet's scale; text search across sheets - the takeoff: quantities by sheet, waste, unit cost; CSV, marked-up PDF, a Probyte table; schedules → tables - tests: a made-up drawing set (tests/takeoff/drawing_set.py), API and browser tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 41483a7 commit b6cebec

28 files changed

Lines changed: 3429 additions & 23 deletions

‎README.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ Connect an [ONLYOFFICE Docs](https://www.onlyoffice.com/docs) Document Server (t
214214
215215
Under **Network settings**, give *Probyte's address for the Document Server* (`http://probyte:8000` in the same Compose file, or `http://<this machine's IP>:<Probyte's port>` when the Document Server runs in another stack) when it can't use Probyte's public address. (In *Directly* mode, a Probyte served over HTTPS needs the Document Server on HTTPS too: browsers don't load an HTTP editor into an HTTPS page.)
216216

217+
## Extensions
218+
219+
Optional parts of Probyte, switched on by the server's admin in **Settings → Extensions** (off by default). An extension that's on shows in everyone's sidebar under **Extensions**; switched off, it's hidden and its data is kept. Each one is a package in `backend/app/extensions/<id>/` (its API at `/api/x/<id>/`, its own database migrations) with its screens in `frontend/src/extensions/<id>/`, loaded only when opened.
220+
221+
### Blueprint takeoff
222+
223+
Count, measure and list what's on a PDF drawing set, for estimates and takeoff sheets. Automatic results are always **suggestions**: you check them, correct them, accept or reject them, and only what's accepted is in the takeoff.
224+
225+
1. **Upload the drawing set** (or pick a PDF from a files table). Every page is read: its **sheet number** and **title** (from the title block and the drawing index on the cover), its **discipline** (E… electrical, A… architectural, M… mechanical, FA/FP/LS…) and the **scales** written on it (`1/4" = 1'-0"`, `1" = 20'`, `1:100`), the plan's first.
226+
2. **Choose the sheets** (Sheets tab): include a whole discipline at once (e.g. all electrical sheets), correct a number, title or scale, and tick each sheet once it's right. A sheet whose scale is missing or wrong (a half-size print) is **calibrated** by clicking both ends of a known dimension.
227+
3. **Items from the legends** (Items tab): **Read legends on this sheet** finds legends and schedules (light fixture schedules, symbol legends) and lists their entries with their symbol, their **label** (the fixture type written on the plan: A, B, EM-2, C48) and the schedule's other columns (manufacturer, catalog number, wattage…). Correct and choose them, and they become items. A legend that isn't a ruled table is boxed by hand (**Box a legend**). Items can also be added by hand: things to count, runs (length) and areas.
228+
4. **Find them on the sheets**: by **label** (the label standing alone on the plan; the same word in a sentence of the notes doesn't count) or by **symbol** (its picture from the legend, found turned any of four ways and a little larger or smaller, with a likeness setting). Finds appear dashed on the sheets: click one to accept (A) or reject (R) it, accept all on a sheet, or every find at least 90% alike; **Next to review** steps through them. Legends and areas you box (notes, details) are left out of finds.
229+
5. **Count and measure by hand**: click to count; click along a run or around an area (double-click to finish, Shift for straight lines). Lengths and areas use the sheet's scale; runs can add a length to every run (drops, makeup).
230+
6. **Search** the sheets' text (GFCI, WP, EM-2…), go to each hit, and count the hits as an item.
231+
7. **The takeoff** (Takeoff tab): each item's quantity by sheet and in all, waste % (counts rounded up), unit cost and extended cost. Export it as **CSV**, as the **drawing set marked up** (each mark in its item's color, with a key of quantities on every sheet), or into a **Probyte table** (to price, share and track it like any other table). Schedules on a sheet (panel, fixture, door schedules) can be copied into Probyte tables too.
232+
233+
Works best with drawing sets exported from CAD (they have real text and lines). Scanned drawings have no text: their labels and scales can't be read (count, calibrate and measure by hand; symbols can still be found). The drawing sets are kept in the data volume (`attachments/takeoff/`).
234+
217235
## Linked tables (from any Probyte server)
218236

219237
**A linked table behaves like the table it links to.** A linked files table opens files in the full-screen viewer with their details, uploads files (with **Upload** instead of New), and replaces and edits them; a linked contacts table imports and downloads vCards; a linked tasks & events table knows which field is a task's status or an event's start (the record panel, calendars and task rules work as in the source), and fields keep their formats (currency, dates…), descriptions and rules. Everything goes through the link, so it can only do what the link's owner allowed (view, add, edit, delete). In the sidebar, a small link mark shows which tables are linked.
@@ -463,7 +481,7 @@ KEEP_STACK=1 scripts/test.sh ... # leave the test stack running afterwards to
463481

464482
Tests run against a separate copy of the stack (Compose project `probyte-test`, ports 18080/15232, its own database and Radicale, plus throwaway S3 and WebDAV servers from `tests/docker-compose.storage.yml`), never your real data. Only Docker is needed. Failed browser tests leave a screenshot in `tests/.artifacts/`.
465483

466-
What's covered: every field type, formulas, links, lookups and rollups, type changes; records, views, filters, imports, bulk edits; users kept apart; the REST API, every MCP tool, OAuth and webhooks (filters, retries); public links and linked tables; two-way calendar and contacts sync (phones' items, conflicts, deletions, repeats, time zones); files tables on S3 and WebDAV (uploads, previews, renames, outside changes, the trash); ONLYOFFICE editing (against a stand-in Document Server; `ONLYOFFICE_TEST_URL` runs `tests/office` against a real one); live updates and the computed-value cache; and the app itself in a browser on a desktop and a phone (building tables, editing, filtering, board, calendar, import/export, settings).
484+
What's covered: every field type, formulas, links, lookups and rollups, type changes; records, views, filters, imports, bulk edits; users kept apart; the REST API, every MCP tool, OAuth and webhooks (filters, retries); public links and linked tables; two-way calendar and contacts sync (phones' items, conflicts, deletions, repeats, time zones); files tables on S3 and WebDAV (uploads, previews, renames, outside changes, the trash); Blueprint takeoff (a made-up drawing set: sheets, scales, legends, finds by label and symbol, review, measuring, exports); ONLYOFFICE editing (against a stand-in Document Server; `ONLYOFFICE_TEST_URL` runs `tests/office` against a real one); live updates and the computed-value cache; and the app itself in a browser on a desktop and a phone (building tables, editing, filtering, board, calendar, import/export, settings).
467485

468486
### Backups
469487

@@ -547,6 +565,9 @@ backend/app/
547565
ratelimit.py slowing down password guessing
548566
audit.py record history (who changed what)
549567
admin.py command line: reset-password, create-user, backup, backups, restore
568+
office.py ONLYOFFICE editing: sessions, the Document Server's callbacks, the relay at /office-ds/
569+
extensions/ optional parts (each a package with its router at /api/x/<id>/ and its own migrations/);
570+
takeoff/ is Blueprint takeoff: drawings.py (sheets, scales, legends, words), symbols.py (finding symbols), api.py
550571
deploy/ the Docker Hub setup: docker-compose.yml (no source needed), install.sh (one-line installer), .env.example
551572
docker/ the image's entrypoint (one-container mode: app + worker + Radicale) and that Radicale's config
552573
.github/workflows/docker.yml builds and pushes the images to Docker Hub when a release is tagged
@@ -557,6 +578,7 @@ frontend/src/
557578
data.ts in-memory dataset cache, incremental refresh (/changes), live event stream
558579
components/DatasetPage.tsx one database / combined database: views, toolbar, record ops
559580
components/*View.tsx grid, kanban, calendar, timeline, list, gallery, chart (each loaded on first use)
581+
extensions/ the extensions' screens (#/x/<id>), each loaded when opened; takeoff/ is Blueprint takeoff
560582
tests/ api/ (pytest), ui/ (Playwright), bench/ (timings), screens/ (README pictures),
561583
filter_cases.json (filter/sort cases the browser and the server must agree on); run with scripts/test.sh
562584
```

‎backend/app/extensions/__init__.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Extensions: optional parts of Probyte with their own API, database tables and screens.
2+
3+
An extension is a package in this folder that defines EXTENSION (an Extension): its id, name, an APIRouter (served
4+
under /api/x/<id>/) and a migrations folder (NNNN_name.sql / .py, applied with Probyte's own, as "<id>:NNNN_name").
5+
Its screens are in frontend/src/extensions/<id>/ (loaded only when opened).
6+
7+
The server's admin switches extensions on and off (Settings → Extensions). Off, an extension's API answers 404 and
8+
its screens aren't shown; its data stays.
9+
"""
10+
import importlib
11+
import logging
12+
import pkgutil
13+
from dataclasses import dataclass, field
14+
from pathlib import Path
15+
16+
from fastapi import APIRouter, Depends, HTTPException
17+
from pydantic import BaseModel
18+
19+
from ..db import connection
20+
from ..routes.common import User
21+
22+
log = logging.getLogger("probyte.extensions")
23+
24+
25+
@dataclass
26+
class Extension:
27+
id: str
28+
name: str
29+
description: str
30+
router: APIRouter
31+
icon: str = "layers"
32+
migrations: Path | None = None
33+
default_on: bool = False
34+
extra: dict = field(default_factory=dict)
35+
36+
37+
def discover() -> list[Extension]:
38+
out = []
39+
for m in pkgutil.iter_modules(__path__):
40+
if not m.ispkg:
41+
continue
42+
try:
43+
mod = importlib.import_module(f"{__name__}.{m.name}")
44+
except Exception: # a broken extension doesn't stop Probyte
45+
log.exception("extension %s didn't load", m.name)
46+
continue
47+
ext = getattr(mod, "EXTENSION", None)
48+
if isinstance(ext, Extension):
49+
out.append(ext)
50+
return sorted(out, key=lambda e: e.name.lower())
51+
52+
53+
EXTENSIONS = discover()
54+
BY_ID = {e.id: e for e in EXTENSIONS}
55+
56+
57+
def is_on(conn, ext_id: str) -> bool:
58+
row = conn.execute("SELECT value FROM app_settings WHERE key = %s", (f"ext.{ext_id}",)).fetchone()
59+
return (row["value"] == "1") if row else BY_ID[ext_id].default_on
60+
61+
62+
def _require(ext_id: str):
63+
def check():
64+
with connection() as conn:
65+
if not is_on(conn, ext_id):
66+
raise HTTPException(404, "This extension is switched off (Settings → Extensions)")
67+
return check
68+
69+
70+
def mount(app):
71+
for e in EXTENSIONS:
72+
app.include_router(e.router, prefix=f"/api/x/{e.id}", dependencies=[Depends(_require(e.id))])
73+
app.include_router(router)
74+
75+
76+
def migration_dirs() -> list[tuple[str, Path]]:
77+
return [(e.id, e.migrations) for e in EXTENSIONS if e.migrations and e.migrations.is_dir()]
78+
79+
80+
# ---------------------------------------------------------------- the list, and switching them (admin)
81+
82+
router = APIRouter()
83+
84+
85+
@router.get("/api/extensions")
86+
def list_extensions(user=User):
87+
with connection() as conn:
88+
return [{"id": e.id, "name": e.name, "description": e.description, "icon": e.icon, "enabled": is_on(conn, e.id)}
89+
for e in EXTENSIONS]
90+
91+
92+
class Switch(BaseModel):
93+
enabled: bool
94+
95+
96+
@router.put("/api/extensions/{ext_id}")
97+
def switch(ext_id: str, body: Switch, user=User):
98+
if not user.get("is_admin") or user.get("api_key"):
99+
raise HTTPException(403, "Only the server's admin account can do this")
100+
if ext_id not in BY_ID:
101+
raise HTTPException(404, "No such extension")
102+
with connection() as conn:
103+
conn.execute("INSERT INTO app_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
104+
(f"ext.{ext_id}", "1" if body.enabled else ""))
105+
return list_extensions(user)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Blueprint takeoff: count, measure and list what's on a drawing set (PDF), for estimates and takeoff sheets.
2+
3+
The sheets are read (sheet numbers, titles, scales, legends, schedules) and items found on them (their symbols or
4+
labels), always as suggestions that a person reviews; only what's confirmed is in the takeoff. Exports: CSV, the
5+
drawing set marked up, or a Probyte table.
6+
"""
7+
from pathlib import Path
8+
9+
from .. import Extension
10+
from .api import router
11+
12+
EXTENSION = Extension(
13+
id="takeoff",
14+
name="Blueprint takeoff",
15+
description="Count symbols and measure runs and areas on PDF drawing sets, using their legends and scales, "
16+
"and turn them into a takeoff list (CSV, marked-up PDF or a Probyte table).",
17+
icon="ruler",
18+
router=router,
19+
migrations=Path(__file__).parent / "migrations",
20+
)

0 commit comments

Comments
 (0)