39 Commits

Author SHA1 Message Date
justin 2669543e56 Merge pull request 'Account menu + Settings (change password), per-tree home person, full-width tree' (#24) from account-settings-home-person into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m24s
2026-06-07 11:05:41 -04:00
justin 0262ed3d97 Account menu + Settings (change password); per-tree home person; full-width tree
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
  Settings and Sign out. New /settings page: account info + change password
  (POST /auth/change-password, re-verifies current password). Export/restore/
  delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
  Read; the tree and family views open focused on it; the person page gets a
  "Set as default" control and "Default person" badge. Cleared if that person
  is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
  column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
  like home_person_id audits cleanly.

50 backend tests pass; migration up/down verified; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:05:04 -04:00
justin 9ee960c4ef Merge pull request 'Auto-apply migrations on deploy (entrypoint + one-shot service)' (#23) from deploy-auto-migrate into main
build-backend / build (push) Successful in 26s
2026-06-07 10:54:31 -04:00
justin 7f640649b9 Auto-apply migrations on deploy (entrypoint + one-shot service)
So a deploy never needs a manual `alembic upgrade head`:

- Backend image gains an entrypoint that runs `alembic upgrade head` before
  uvicorn when RUN_MIGRATIONS=1 (set on the backend service). This self-migrates
  even on a Watchtower in-place image swap, which doesn't re-run one-shot jobs.
- A one-shot `migrate` service covers the `docker compose up` path; backend and
  worker depend on it completing, which also serializes it with the backend
  entrypoint so alembic never runs concurrently. `upgrade head` is idempotent.

Activating this needs the updated compose on the host once (Watchtower only
swaps images, not the compose file / env). After that, migrations are automatic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:50:28 -04:00
justin a8929c2862 Merge pull request 'Global Import menu entry + mobile drawer nav' (#22) from nav-global-import-mobile into main
build-frontend / build (push) Successful in 1m29s
2026-06-07 10:41:12 -04:00
justin b90ba53a3f Merge pull request 'GEDCOM: duplicate-aware import + maiden/married + RELI/NOTE mapping' (#21) from gedcom-import-dedupe into main
build-frontend / build (push) Has been cancelled
build-backend / build (push) Successful in 30s
2026-06-07 10:41:08 -04:00
justin c4e9d69e00 Merge pull request 'Alternate names, self-person link, deletion integrity + dangling people' (#20) from names-deletion-self into main
build-backend / build (push) Has been cancelled
build-frontend / build (push) Has been cancelled
2026-06-07 10:41:02 -04:00
justin 0673896133 Merge pull request 'Tree search + click-rebuild; searchable relationship picker; gender dropdown' (#19) from tree-search-combobox into main
build-frontend / build (push) Has been cancelled
2026-06-07 10:40:59 -04:00
justin 1164841950 Global Import in the menu; mobile drawer nav
- Add a top-level "Import" entry to the sidebar and a global /import page, so
  you can start a tree from a GEDCOM without first creating an empty one. The
  import flow now picks its destination (new tree, or an existing one) — the
  tree-scoped page reuses the same <GedcomImport> with a fixed destination and
  keeps Export.
- Extract the sidebar chrome into <AppShell> and give small screens a working
  menu: a hamburger opens the full sidebar as a slide-in drawer (it was just a
  logo + "Trees" link before). Used by both /trees and /import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:40:01 -04:00
justin 5824e70895 GEDCOM: duplicate-aware import + typed name/attribute mapping
Duplicate detection (the "merge / skip / overwrite" the user asked for):
- New POST /gedcom/preview dry-runs the file and flags incoming people that
  resemble existing ones (name similarity via difflib + birth-year guard;
  high/medium score). No writes.
- /gedcom/import takes default_action (new|skip|merge|overwrite) + per-xref
  resolutions {xref: {action, target_id}}:
    new       create as a new person (current behavior)
    skip      link families to the existing person, copy nothing
    merge     attach the incoming names (as alternates), events, citations,
              and notes onto the existing person
    overwrite soft-delete the existing person, import the incoming one fresh
  Relationship creation is deduped so a merge can't double an edge.

Richer record mapping (covers the user's repo's GEDCOM):
- Multiple NAME records honor their TYPE; _MARNM (and NICK) import as typed
  alternate names — maiden stays primary, married becomes a "married" Name.
- RELI -> a "religion" event with the value in detail; OCCU/EDUC values too.
- NOTE -> person notes (and event notes); NOTE/RELI are no longer "unmapped".
- Export round-trips name TYPE.

Verified against the user's 2185-person export: 0 unmapped tags. 48 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:35:55 -04:00
justin 04ccdbf96a Alternate names (maiden/married), self-person link, deletion integrity
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
  NameCreate/Read/Update schemas, name_service (one-primary invariant,
  promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
  curated name_type dropdown (birth/maiden, married, alias, nickname, …).

Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
  + migration; PATCH /users/me/self-person; "This is me" / "This is you"
  on the person page. Soft-deleting the linked person clears it.

Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
  dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
  the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.

Family view surfaces "Not connected to anyone" so dangling people aren't lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:21:12 -04:00
justin f165ccb941 Tree search + click-rebuild; searchable relationship picker; gender dropdown
- Tree page: add a "Find a person" search box that jumps the chart to a
  match and rebuilds the hourglass (parents/grandparents/partner/children)
  around them. Clicking any card recenters via family-chart's default
  behavior (setAncestryDepth 3 / setProgenyDepth 2), syncing focus through
  setAfterUpdate for the "Open profile" link.
- Person detail: replace the relationship "add" <select> with a
  type-to-filter PersonCombobox so long people lists are searchable.
- Person detail: gender is now a Male/Female dropdown, not free text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:58:45 -04:00
justin e0fb924a1d Merge pull request 'Full-CRUD sweep (API): update for tree/source/citation/relationship/media' (#18) from crud-sweep-backend into main
build-backend / build (push) Successful in 30s
2026-06-07 09:53:18 -04:00
justin cf5518c7ec Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:53:17 -04:00
justin 26df03cfd7 Merge pull request 'Edit people + events; existing-person picker; full-CRUD rule' (#17) from crud-edits into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m25s
2026-06-07 09:35:56 -04:00
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
justin 99913ada94 Tree layout toggles (landscape/portrait/fan), card->profile, server search
Tree page gets Landscape/Portrait/Fan toggles: landscape & portrait via family-chart's orientation; a hand-rolled radial Fan chart of ancestors (rings per generation, click to recenter). Clicking a card recenters and updates an 'Open <name> →' link to that person's profile. The People directory search now hits the server-side pg_trgm fuzzy endpoint (debounced) so it spans the whole tree, not just the loaded page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 08:01:31 -04:00
justin 584b323121 Merge pull request 'Fuzzy search (pg_trgm) + living-person protection' (#15) from phase2-search-privacy into main
build-backend / build (push) Successful in 30s
2026-06-07 07:55:14 -04:00
justin 4788ae7723 Add fuzzy name search (pg_trgm) and living-person protection
Fuzzy search: pg_trgm extension + trigram GIN indexes on name parts and a GET /trees/{id}/persons?q= search ranked by trigram similarity (finds Mueller for 'muller'), privacy-filtered. Living-person protection: the privacy engine now derives possibly-living status (explicit flag, else no death fact + birth within ~100y or unknown) and returns 'redacted' for non-members of public/unlisted trees; the service minimises those records ('Living person', no vitals). Members are unaffected. 31 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 07:55:13 -04:00
justin 51f0066e61 Merge pull request 'Interactive Tree view (pan/zoom genealogy chart)' (#14) from interactive-tree into main
build-frontend / build (push) Successful in 1m21s
2026-06-06 23:07:04 -04:00
justin bfa6c0782a Add an interactive Tree view (pan/zoom genealogy chart)
Researched how FamilySearch/Geni/MyHeritage lay out trees (switchable pedigree/portrait/fan, an interactive canvas with pan/zoom + click-to-recenter, gender colors, birth-death years) and built a real Tree page on the MIT d3 library family-chart instead of a flat list. Ancestors + descendants around a focus person, click any card to recenter, drag to pan, scroll to zoom — scales to large imported trees. Tree is now the first per-tree sidebar item and the default when opening a tree; People keeps the searchable directory + add/edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 23:07:02 -04:00
justin 2f21e767f3 Merge pull request 'Scalable people directory' (#13) from people-directory into main
build-frontend / build (push) Successful in 1m20s
2026-06-06 22:54:10 -04:00
justin f6bcf198ee Make the people index a scalable scrollable directory
A flat wrap of every person didn't scale to imported trees. Replace it with a bounded (max-height, scrollable) searchable directory: clean name + birth–death-year rows, focus highlight, a result count, and a 200-row cap with a 'refine your search' notice so a thousand-person tree stays fast and usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:54:08 -04:00
justin b13fafd624 Merge pull request 'Phase 2: GEDCOM import/export' (#12) from phase2-gedcom into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:46:50 -04:00
justin 631d050540 Add GEDCOM Import/Export UI (defaults to importing into a new tree)
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin d48029a407 Add GEDCOM import/export
A pragmatic GEDCOM parser + mapper: import reads INDI/FAM/SOUR and creates people, names, life events, partnership + qualified parent-child relationships, marriage events, places (deduped), sources, and citations from SOUR refs — returning a mapping report (counts + unmapped tags). Export serializes the tree back to GEDCOM (families derived from the edge model). Import is additive (no merge) and runs inline for now. Round-trip test passes; 29 tests total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin 18dea507d1 Merge pull request 'Pedigree connector lines + 4 grandparents' (#11) from pedigree-connectors into main
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:32:12 -04:00
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
justin fe9a95c60d Rebuild the UI as an app shell: left sidebar, media gallery, structured events
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo).

Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:05 -04:00
justin bd8ee9b647 Stream media through the backend (browser-reachable, privacy-checked)
Presigned URLs point at the internal minio:9000 host a browser can't reach. Add ObjectStore.get_object and a GET /media/{id}/content endpoint that resolves visibility and streams the bytes; MediaRead.url now points there. Keeps the object store private and downloads behind the privacy engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:04 -04:00
justin 660130f007 Merge pull request 'Phase 1: media (object storage) + background worker' (#8) from phase1-media into main
build-backend / build (push) Successful in 30s
2026-06-06 21:46:35 -04:00
justin 34d30e3134 Add media (object storage) and the background worker (Phase 1)
Media model + migration; an ObjectStore interface with an S3/MinIO (boto3) implementation behind the service layer. Upload (multipart) stores bytes in object storage + a metadata row (checksum, size, content-type, optional attach to person/event/source); list returns presigned URLs; delete is soft. Editor-gated, privacy-filtered, audited. 24 tests pass (object store faked).

Introduces the worker container (same image, 'python -m app.worker'): its first job is the scheduled 30-day soft-delete purge across tables + media object cleanup. Compose gains worker + S3 env on backend/worker; dev override builds the worker too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:46:09 -04:00
justin 049545fcc8 Merge pull request 'Frontend redesign: real type, hero, depth' (#7) from design-overhaul into main
build-frontend / build (push) Successful in 1m18s
2026-06-06 21:34:48 -04:00
97 changed files with 11855 additions and 272 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact. 5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe). 6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys. 7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack ## Tech stack
+4
View File
@@ -21,7 +21,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY app ./app COPY app ./app
COPY alembic.ini ./alembic.ini COPY alembic.ini ./alembic.ini
COPY migrations ./migrations COPY migrations ./migrations
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 8000 EXPOSE 8000
# The entrypoint runs migrations first when RUN_MIGRATIONS=1, then the command.
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+9
View File
@@ -10,6 +10,8 @@ from app.core.db import get_session
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.integrations.mailer.console import ConsoleMailer from app.integrations.mailer.console import ConsoleMailer
from app.integrations.mailer.smtp import SMTPMailer from app.integrations.mailer.smtp import SMTPMailer
from app.integrations.objectstore.base import ObjectStore
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models.user import User from app.models.user import User
from app.services import auth_service from app.services import auth_service
@@ -46,3 +48,10 @@ def get_mailer() -> Mailer:
MailerDep = Annotated[Mailer, Depends(get_mailer)] MailerDep = Annotated[Mailer, Depends(get_mailer)]
def get_objectstore() -> ObjectStore:
return S3ObjectStore(get_settings())
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
+6
View File
@@ -6,6 +6,9 @@ from app.api.v1 import (
auth, auth,
citations, citations,
events, events,
gedcom,
media,
names,
persons, persons,
relationships, relationships,
sources, sources,
@@ -18,7 +21,10 @@ api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(trees.router) api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(names.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(relationships.router) api_router.include_router(relationships.router)
api_router.include_router(sources.router) api_router.include_router(sources.router)
api_router.include_router(citations.router) api_router.include_router(citations.router)
api_router.include_router(media.router)
api_router.include_router(gedcom.router)
+14 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from app.api.deps import MailerDep, SessionDep, extract_session_token from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.auth import ( from app.schemas.auth import (
LoginRequest, LoginRequest,
PasswordChange,
PasswordResetConfirm, PasswordResetConfirm,
PasswordResetRequest, PasswordResetRequest,
RegisterRequest, RegisterRequest,
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
await auth_service.reset_password( await auth_service.reset_password(
session, raw_token=data.token, new_password=data.new_password session, raw_token=data.token, new_password=data.new_password
) )
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: PasswordChange, session: SessionDep, current: CurrentUser
) -> None:
await auth_service.change_password(
session,
user=current,
current_password=data.current_password,
new_password=data.new_password,
)
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
from app.services import citation_service, tree_service from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"]) router = APIRouter(prefix="/trees", tags=["citations"])
@@ -31,6 +31,25 @@ async def list_citations(
return [CitationRead.model_validate(c) for c in citations] return [CitationRead.model_validate(c) for c in citations]
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
async def update_citation(
tree_id: uuid.UUID,
citation_id: uuid.UUID,
data: CitationUpdate,
session: SessionDep,
current: CurrentUser,
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.update_citation(
session,
actor=current,
tree=tree,
citation_id=citation_id,
changes=data.model_dump(exclude_unset=True),
)
return CitationRead.model_validate(citation)
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation( async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
+29 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"]) router = APIRouter(prefix="/trees", tags=["events"])
@@ -20,6 +20,15 @@ async def create_event(
return EventRead.model_validate(event) return EventRead.model_validate(event)
@router.get("/{tree_id}/events", response_model=list[EventRead])
async def list_tree_events(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
return [EventRead.model_validate(e) for e in events]
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def list_person_events( async def list_person_events(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
@@ -31,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events] return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event( async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+68
View File
@@ -0,0 +1,68 @@
import json
import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.gedcom import ImportPreview, ImportReport
from app.services import gedcom, tree_service
router = APIRouter(prefix="/trees", tags=["gedcom"])
@router.post("/{tree_id}/gedcom/preview", response_model=ImportPreview)
async def preview_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> ImportPreview:
"""Dry run: report counts and incoming people that look like duplicates of
existing ones, so the user can choose how to resolve each before importing."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
report = await gedcom.preview_gedcom(session, actor=current, tree=tree, text=text)
return ImportPreview(**report)
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
async def import_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
default_action: str = Form("new"),
resolutions: str = Form("{}"),
) -> ImportReport:
"""Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
incoming people that match an existing one; ``resolutions`` is a JSON object
{xref: {action, target_id}} overriding it per record."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
try:
parsed = json.loads(resolutions or "{}")
except json.JSONDecodeError:
parsed = {}
report = await gedcom.import_gedcom(
session,
actor=current,
tree=tree,
text=text,
default_action=default_action,
resolutions=parsed,
)
return ImportReport(**report)
@router.get("/{tree_id}/gedcom/export")
async def export_gedcom(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
return Response(
content=text,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
)
+109
View File
@@ -0,0 +1,109 @@
import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead, MediaUpdate
from app.services import media_service, tree_service
def _content_url(media) -> str:
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
def _read(media) -> MediaRead:
out = MediaRead.model_validate(media)
# Stream through the backend (privacy-checked, browser-reachable) rather
# than expose the internal object store directly.
out.url = _content_url(media)
return out
router = APIRouter(prefix="/trees", tags=["media"])
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
async def upload_media(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
title: str | None = Form(None),
person_id: uuid.UUID | None = Form(None),
event_id: uuid.UUID | None = Form(None),
source_id: uuid.UUID | None = Form(None),
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
data = await file.read()
media = await media_service.upload_media(
session,
store,
actor=current,
tree=tree,
data=data,
filename=file.filename or "upload",
content_type=file.content_type or "application/octet-stream",
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
return _read(media)
@router.get("/{tree_id}/media", response_model=list[MediaRead])
async def list_media(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[MediaRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
return [_read(m) for m in items]
@router.get("/{tree_id}/media/{media_id}/content")
async def media_content(
tree_id: uuid.UUID,
media_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.get_media(
session, viewer_id=current.id, tree=tree, media_id=media_id
)
data = await store.get_object(key=media.storage_key)
return Response(
content=data,
media_type=media.content_type,
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
)
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
async def update_media(
tree_id: uuid.UUID,
media_id: uuid.UUID,
data: MediaUpdate,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.update_media(
session,
actor=current,
tree=tree,
media_id=media_id,
changes=data.model_dump(exclude_unset=True),
)
return _read(media)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
+90
View File
@@ -0,0 +1,90 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.name import NameCreate, NameRead, NameUpdate
from app.services import name_service, tree_service
# Names are nested under their person (which is nested under the tree tenant).
router = APIRouter(prefix="/trees", tags=["names"])
@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
async def list_names(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
names = await name_service.list_names(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [NameRead.model_validate(n) for n in names]
@router.post(
"/{tree_id}/persons/{person_id}/names",
response_model=NameRead,
status_code=status.HTTP_201_CREATED,
)
async def create_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: NameCreate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.create_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_type=data.name_type,
given=data.given,
surname=data.surname,
prefix=data.prefix,
suffix=data.suffix,
nickname=data.nickname,
is_primary=data.is_primary,
)
return NameRead.model_validate(name)
@router.patch(
"/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead
)
async def update_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
data: NameUpdate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.update_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_id=name_id,
changes=data.model_dump(exclude_unset=True),
)
return NameRead.model_validate(name)
@router.delete(
"/{tree_id}/persons/{person_id}/names/{name_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await name_service.delete_name(
session, actor=current, tree=tree, person_id=person_id, name_id=name_id
)
+63 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary). # Persons are nested under their tree (the tenant boundary).
@@ -36,13 +36,73 @@ async def create_person(
@router.get("/{tree_id}/persons", response_model=list[PersonRead]) @router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons( async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
deleted: bool = False,
q: str | None = None,
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) if q:
persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q
)
elif deleted:
persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree
)
else:
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def update_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}")
async def delete_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
cascade: bool = False,
) -> dict[str, int]:
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
the number of persons deleted (1 unless cascading)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
deleted = await person_service.delete_person(
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
)
return {"deleted": deleted}
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
async def restore_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.restore_person(
session, actor=current, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def get_person( async def get_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+29 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
from app.services import relationship_service, tree_service from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"]) router = APIRouter(prefix="/trees", tags=["relationships"])
@@ -24,6 +24,15 @@ async def create_relationship(
return RelationshipRead.model_validate(relationship) return RelationshipRead.model_validate(relationship)
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
async def list_relationships(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get( @router.get(
"/{tree_id}/persons/{person_id}/relationships", "/{tree_id}/persons/{person_id}/relationships",
response_model=list[RelationshipRead], response_model=list[RelationshipRead],
@@ -38,6 +47,25 @@ async def list_person_relationships(
return [RelationshipRead.model_validate(r) for r in rels] return [RelationshipRead.model_validate(r) for r in rels]
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
async def update_relationship(
tree_id: uuid.UUID,
relationship_id: uuid.UUID,
data: RelationshipUpdate,
session: SessionDep,
current: CurrentUser,
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rel = await relationship_service.update_relationship(
session,
actor=current,
tree=tree,
relationship_id=relationship_id,
changes=data.model_dump(exclude_unset=True),
)
return RelationshipRead.model_validate(rel)
@router.delete( @router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
) )
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
from app.services import source_service, tree_service from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"]) router = APIRouter(prefix="/trees", tags=["sources"])
@@ -40,6 +40,25 @@ async def get_source(
return SourceRead.model_validate(source) return SourceRead.model_validate(source)
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def update_source(
tree_id: uuid.UUID,
source_id: uuid.UUID,
data: SourceUpdate,
session: SessionDep,
current: CurrentUser,
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.update_source(
session,
actor=current,
tree=tree,
source_id=source_id,
changes=data.model_dump(exclude_unset=True),
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source( async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
+29 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.services import tree_service from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"]) router = APIRouter(prefix="/trees", tags=["trees"])
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
@router.get("", response_model=list[TreeRead]) @router.get("", response_model=list[TreeRead])
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: async def list_my_trees(
trees = await tree_service.list_trees_for_user(session, user=current) session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees] return [TreeRead.model_validate(t) for t in trees]
@@ -31,3 +36,24 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
+14 -2
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.deps import CurrentUser from app.api.deps import CurrentUser, SessionDep
from app.schemas.user import UserRead from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import user_service
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -9,3 +10,14 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead: async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current) return UserRead.model_validate(current)
@router.patch("/me/self-person", response_model=UserRead)
async def set_self_person(
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
) -> UserRead:
"""Link (or unlink) the Person record that represents this account."""
user = await user_service.set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
+12
View File
@@ -35,6 +35,18 @@ class Settings(BaseSettings):
# Base URL used to build links in outbound email. # Base URL used to build links in outbound email.
app_base_url: str = "http://localhost" app_base_url: str = "http://localhost"
# --- Object storage (S3-compatible / MinIO) ---
s3_endpoint_url: str = "http://minio:9000"
s3_bucket: str = "provenance"
s3_access_key: str = "provenance"
s3_secret_key: str = "change-me-too"
s3_region: str = "us-east-1"
s3_presign_ttl: int = 3600 # seconds
# --- Worker ---
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
purge_after_days: int = 30 # soft-deleted rows older than this are purged
# --- Email (SMTP) --- # --- Email (SMTP) ---
mailer: str = Field(default="console", description="console | smtp") mailer: str = Field(default="console", description="console | smtp")
smtp_host: str | None = None smtp_host: str | None = None
@@ -0,0 +1,25 @@
"""ObjectStore interface — pluggable binary storage behind the service layer.
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
Methods are async wrappers so the service layer stays non-blocking even though
the underlying SDK (boto3) is synchronous.
"""
from abc import ABC, abstractmethod
class ObjectStore(ABC):
@abstractmethod
async def ensure_bucket(self) -> None: ...
@abstractmethod
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
@abstractmethod
async def get_object(self, *, key: str) -> bytes: ...
@abstractmethod
async def presigned_get_url(self, *, key: str) -> str: ...
@abstractmethod
async def delete_object(self, *, key: str) -> None: ...
@@ -0,0 +1,63 @@
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
boto3 is synchronous; each call is dispatched to a thread so request handlers
and the worker stay async."""
import asyncio
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
from app.core.config import Settings
from app.integrations.objectstore.base import ObjectStore
class S3ObjectStore(ObjectStore):
def __init__(self, settings: Settings) -> None:
self.bucket = settings.s3_bucket
self.presign_ttl = settings.s3_presign_ttl
self._client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4"),
)
def _ensure_bucket_sync(self) -> None:
try:
self._client.head_bucket(Bucket=self.bucket)
except ClientError:
self._client.create_bucket(Bucket=self.bucket)
async def ensure_bucket(self) -> None:
await asyncio.to_thread(self._ensure_bucket_sync)
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
await asyncio.to_thread(
self._client.put_object,
Bucket=self.bucket,
Key=key,
Body=data,
ContentType=content_type,
)
async def get_object(self, *, key: str) -> bytes:
def _get() -> bytes:
obj = self._client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read()
return await asyncio.to_thread(_get)
async def presigned_get_url(self, *, key: str) -> str:
return await asyncio.to_thread(
self._client.generate_presigned_url,
"get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=self.presign_ttl,
)
async def delete_object(self, *, key: str) -> None:
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
+2
View File
@@ -5,6 +5,7 @@ from app.models.audit import AuditEntry
from app.models.auth import Session, UserToken from app.models.auth import Session, UserToken
from app.models.base import Base from app.models.base import Base
from app.models.event import Event from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.place import Place, PlaceName from app.models.place import Place, PlaceName
from app.models.relationship import Relationship from app.models.relationship import Relationship
@@ -28,4 +29,5 @@ __all__ = [
"AuditEntry", "AuditEntry",
"Session", "Session",
"UserToken", "UserToken",
"Media",
] ]
+36
View File
@@ -0,0 +1,36 @@
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
Optionally attached to a single fact (person, event, or source) for now."""
import uuid
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "media"
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
original_filename: Mapped[str] = mapped_column(String(512))
content_type: Mapped[str] = mapped_column(String(128))
byte_size: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str | None] = mapped_column(String(512))
# Optional single attachment target.
person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("persons.id", ondelete="SET NULL"), index=True
)
event_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("events.id", ondelete="SET NULL"), index=True
)
source_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("sources.id", ondelete="SET NULL"), index=True
)
+17 -1
View File
@@ -7,7 +7,7 @@ aliases) so name changes over time are first-class.
import uuid import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -33,6 +33,22 @@ class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "names" __tablename__ = "names"
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
# pg_trgm extension (enabled in the accompanying migration).
__table_args__ = (
Index(
"ix_names_given_trgm",
"given",
postgresql_using="gin",
postgresql_ops={"given": "gin_trgm_ops"},
),
Index(
"ix_names_surname_trgm",
"surname",
postgresql_using="gin",
postgresql_ops={"surname": "gin_trgm_ops"},
),
)
person_id: Mapped[uuid.UUID] = mapped_column( person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True ForeignKey("persons.id", ondelete="CASCADE"), index=True
+10
View File
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
default=TreeVisibility.private, default=TreeVisibility.private,
server_default=TreeVisibility.private.value, server_default=TreeVisibility.private.value,
) )
# The person a tree opens focused on (its "home"/root person). Cleared if
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_trees_home_person_id",
use_alter=True,
)
)
class TreeMembership(Base, UUIDPrimaryKey, Timestamps): class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
+14 -1
View File
@@ -3,9 +3,10 @@ multiple auth providers later (the provider-link table arrives with the auth
slice). ``hashed_password`` is nullable: external/OIDC users have none. slice). ``hashed_password`` is nullable: external/OIDC users have none.
""" """
import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
hashed_password: Mapped[str | None] = mapped_column(String(255)) hashed_password: Mapped[str | None] = mapped_column(String(255))
# The Person record that *is* this user ("home person"). Cleared if that
# person is deleted, so the link can never dangle.
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
# so this constraint must be created/dropped via ALTER, not inline.
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_users_self_person_id",
use_alter=True,
)
)
+5
View File
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
new_password: str = Field(min_length=8) new_password: str = Field(min_length=8)
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
class SessionRead(BaseModel): class SessionRead(BaseModel):
user: UserRead user: UserRead
token: str token: str
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | None = None
place_id: uuid.UUID | None = None
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel): class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from pydantic import BaseModel
class ImportReport(BaseModel):
counts: dict[str, int]
unmapped_tags: list[str]
class DuplicateMatch(BaseModel):
# An incoming GEDCOM person that resembles an existing one in the tree.
xref: str
incoming_name: str
incoming_birth_year: str | None = None
existing_person_id: uuid.UUID
existing_name: str
existing_birth_year: str | None = None
score: str # "high" | "medium"
class ImportPreview(BaseModel):
counts: dict[str, int]
potential_duplicates: list[DuplicateMatch]
unmapped_tags: list[str]
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MediaUpdate(BaseModel):
title: str | None = None
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
source_id: uuid.UUID | None = None
class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
original_filename: str
content_type: str
byte_size: int
checksum_sha256: str
title: str | None
person_id: uuid.UUID | None
event_id: uuid.UUID | None
source_id: uuid.UUID | None
created_at: datetime
# Presigned download URL, filled in by the router from the ObjectStore.
url: str | None = None
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class NameCreate(BaseModel):
# Open vocabulary: birth/maiden, married, alias, religious, nickname, ...
name_type: str = "birth"
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool = False
class NameUpdate(BaseModel):
name_type: str | None = None
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool | None = None
class NameRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
person_id: uuid.UUID
name_type: str
given: str | None
surname: str | None
prefix: str | None
suffix: str | None
nickname: str | None
is_primary: bool
sort_order: int
created_at: datetime
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel): class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+5
View File
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
notes: str | None = None notes: str | None = None
class RelationshipUpdate(BaseModel):
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel): class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
created_at: datetime created_at: datetime
class SourceUpdate(BaseModel):
title: str | None = None
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class CitationUpdate(BaseModel):
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationCreate(BaseModel): class CitationCreate(BaseModel):
source_id: uuid.UUID source_id: uuid.UUID
# Exactly one target fact. # Exactly one target fact.
+8
View File
@@ -12,6 +12,13 @@ class TreeCreate(BaseModel):
visibility: TreeVisibility = TreeVisibility.private visibility: TreeVisibility = TreeVisibility.private
class TreeUpdate(BaseModel):
name: str | None = None
description: str | None = None
visibility: TreeVisibility | None = None
home_person_id: uuid.UUID | None = None
class TreeRead(BaseModel): class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -20,4 +27,5 @@ class TreeRead(BaseModel):
description: str | None description: str | None
visibility: TreeVisibility visibility: TreeVisibility
owner_id: uuid.UUID owner_id: uuid.UUID
home_person_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
+6
View File
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
email: str email: str
display_name: str | None display_name: str | None
email_verified_at: datetime | None email_verified_at: datetime | None
self_person_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
class UserSelfPersonUpdate(BaseModel):
# null clears the link; otherwise the Person that represents this account.
self_person_id: uuid.UUID | None = None
+11 -2
View File
@@ -3,6 +3,7 @@ the change to a User (or the assistant principal acting for a User). Staged on
the session; the caller commits as part of its unit of work. the session; the caller commits as part of its unit of work.
""" """
import json
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
from app.models.enums import AuditActorType from app.models.enums import AuditActorType
def _json_safe(d: dict | None) -> dict | None:
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
it lands in the JSON audit column regardless of what the caller passed."""
if d is None:
return None
return json.loads(json.dumps(d, default=str))
def record_audit( def record_audit(
session: AsyncSession, session: AsyncSession,
*, *,
@@ -30,8 +39,8 @@ def record_audit(
tree_id=tree_id, tree_id=tree_id,
actor_user_id=actor_user_id, actor_user_id=actor_user_id,
actor_type=actor_type, actor_type=actor_type,
before=before, before=_json_safe(before),
after=after, after=_json_safe(after),
) )
session.add(entry) session.add(entry)
return entry return entry
+22 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import generate_token, hash_password, hash_token from app.core.security import generate_token, hash_password, hash_token, verify_password
from app.integrations.auth.local import LocalAuthProvider from app.integrations.auth.local import LocalAuthProvider
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.models.auth import Session as SessionModel from app.models.auth import Session as SessionModel
@@ -17,7 +17,7 @@ from app.models.auth import UserToken
from app.models.enums import TokenPurpose from app.models.enums import TokenPurpose
from app.models.user import User from app.models.user import User
from app.services.audit import record_audit from app.services.audit import record_audit
from app.services.exceptions import Conflict, NotFound from app.services.exceptions import Conflict, Forbidden, NotFound
_local_provider = LocalAuthProvider() _local_provider = LocalAuthProvider()
@@ -178,6 +178,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw)) await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
async def change_password(
session: AsyncSession, *, user: User, current_password: str, new_password: str
) -> None:
"""Change a logged-in user's password after re-verifying the current one.
Revokes other sessions so a changed password takes effect everywhere."""
if not user.hashed_password or not verify_password(
user.hashed_password, current_password
):
raise Forbidden("current password is incorrect")
user.hashed_password = hash_password(new_password)
record_audit(
session,
action="change_password",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
)
await session.commit()
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None: async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.password_reset) token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
await session.execute( await session.execute(
+32
View File
@@ -113,6 +113,38 @@ async def list_citations(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
for key in {"page", "detail", "confidence"} & changes.keys():
setattr(citation, key, changes[key])
record_audit(
session,
action="update",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(citation)
return citation
async def delete_citation( async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None: ) -> None:
+52
View File
@@ -91,6 +91,20 @@ async def create_event(
return event return event
async def list_events(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Event]:
"""All events in the tree — lets the family view compute birth/death years."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Event)
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_events_for_person( async def list_events_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Event]: ) -> list[Event]:
@@ -108,6 +122,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
event = (
await session.execute(
select(Event).where(
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if event is None:
raise NotFound("event not found")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event( async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None: ) -> None:
+801
View File
@@ -0,0 +1,801 @@
"""GEDCOM import/export.
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
a mapping report (counts + unmapped tags); export serializes the tree back to
GEDCOM. Runs inline for now — large files should move to the worker later.
Import is duplicate-aware: ``preview_gedcom`` reports incoming people that look
like existing ones, and ``import_gedcom`` applies a per-record resolution
(new / skip / merge / overwrite). Names carry their GEDCOM type (a married name
imports as a typed alternate, not a second primary).
"""
import re
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime
from difflib import SequenceMatcher
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.event import Event
from app.models.person import Name, Person
from app.models.place import Place
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden
# GEDCOM event tag -> our event_type (INDI-level).
INDI_EVENTS = {
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
"NATU": "naturalization", "BAPL": "baptism", "RELI": "religion",
}
# INDI attribute tags whose line VALUE is the fact (no date), stored in detail.
VALUE_EVENTS = {"RELI", "OCCU", "EDUC"}
# INDI sub-tags consumed elsewhere or intentionally ignored (not "unmapped").
INDI_SKIP_TAGS = {
"NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID", "_MARNM", "NOTE",
}
# FAM-level events.
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
# GEDCOM NAME TYPE (or _MARNM-derived) -> our Name.name_type vocabulary.
NAME_TYPE_MAP = {
"birth": "birth", "maiden": "birth", "married": "married",
"aka": "alias", "also known as": "alias", "nickname": "nickname",
"religious": "religious", "immigrant": "immigration",
"immigration": "immigration", "professional": "alias", "other": "alias",
}
# Our type -> GEDCOM TYPE on export (birth is the default; emit nothing).
EXPORT_TYPE_MAP = {
"married": "married", "alias": "aka", "nickname": "nickname",
"religious": "religious", "immigration": "immigrant",
}
class GedcomNode:
__slots__ = ("level", "tag", "value", "xref", "children")
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
self.level = level
self.tag = tag
self.value = value
self.xref = xref
self.children: list[GedcomNode] = []
def first(self, tag: str) -> "GedcomNode | None":
return next((c for c in self.children if c.tag == tag), None)
def all(self, tag: str) -> list["GedcomNode"]:
return [c for c in self.children if c.tag == tag]
def text(self, tag: str, default: str | None = None) -> str | None:
n = self.first(tag)
return n.value if n is not None else default
def parse_records(text: str) -> list[GedcomNode]:
roots: list[GedcomNode] = []
stack: list[GedcomNode] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw.lstrip("").rstrip()
if not line.strip():
continue
parts = line.split(" ", 1)
try:
level = int(parts[0])
except ValueError:
continue
rest = parts[1] if len(parts) > 1 else ""
xref: str | None = None
if rest.startswith("@"):
end = rest.find("@", 1)
if end != -1:
xref = rest[: end + 1]
rest = rest[end + 1:].strip()
tparts = rest.split(" ", 1)
tag = tparts[0]
value = tparts[1] if len(tparts) > 1 else ""
while stack and stack[-1].level >= level:
stack.pop()
parent = stack[-1] if stack else None
if tag in ("CONC", "CONT") and parent is not None:
parent.value += ("" if tag == "CONC" else "\n") + value
continue
node = GedcomNode(level, tag, value, xref)
if parent is None:
roots.append(node)
else:
parent.children.append(node)
stack.append(node)
return roots
def _parse_name(value: str) -> tuple[str | None, str | None]:
if "/" in value:
given, _, rest = value.partition("/")
surname = rest.split("/", 1)[0]
return given.strip() or None, surname.strip() or None
return value.strip() or None, None
def _parse_marnm(value: str, base_given: str | None) -> tuple[str | None, str | None]:
"""A _MARNM value is sometimes a full name ("Jane /Smith/") and sometimes
just the married surname ("Smith"). Keep the given name from the base name
in the latter case."""
v = (value or "").strip()
if "/" in v:
g, s = _parse_name(v)
return (g or base_given), s
return base_given, (v or None)
def _extract_names(rec: GedcomNode) -> list[dict]:
"""All names for an INDI, typed. Multiple NAME records (each with an optional
TYPE) plus any _MARNM (married name) subtags become separate Name rows. The
first birth/maiden name is primary."""
out: list[dict] = []
for nm in rec.all("NAME"):
g, s = _parse_name(nm.value)
t = (nm.text("TYPE") or "").strip().lower()
ntype = NAME_TYPE_MAP.get(t, t or "birth")
out.append({"type": ntype, "given": g, "surname": s, "display": nm.value or None,
"nickname": nm.text("NICK")})
for mar in nm.all("_MARNM"):
mg, ms = _parse_marnm(mar.value, g)
out.append({"type": "married", "given": mg, "surname": ms,
"display": mar.value or None, "nickname": None})
for mar in rec.all("_MARNM"):
base_g = out[0]["given"] if out else None
mg, ms = _parse_marnm(mar.value, base_g)
out.append({"type": "married", "given": mg, "surname": ms,
"display": mar.value or None, "nickname": None})
if not out:
return out
primary_idx = next((i for i, n in enumerate(out) if n["type"] == "birth"), 0)
for i, n in enumerate(out):
n["is_primary"] = i == primary_idx
n["sort"] = i
return out
def _norm(given: str | None, surname: str | None) -> str:
return re.sub(r"\s+", " ", f"{given or ''} {surname or ''}".strip().lower())
def _year(date_value: str | None) -> str | None:
if not date_value:
return None
m = re.search(r"\b(\d{3,4})\b", date_value)
return m.group(1) if m else None
def _date_start(date_value: str | None) -> date | None:
y = _year(date_value)
if not y:
return None
try:
return date(int(y), 1, 1)
except ValueError:
return None
def _sex(value: str | None) -> str | None:
if not value:
return None
v = value.strip().upper()
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
def _notes_text(rec: GedcomNode) -> str | None:
"""Join an INDI's NOTE lines (which pack confidence / findagrave / fs_pid /
free text) into the person's notes field."""
vals = [n.value.strip() for n in rec.all("NOTE") if n.value and n.value.strip()]
return "\n".join(vals) or None
def _person_summary(rec: GedcomNode) -> dict:
"""Display name + birth year for an incoming INDI, for duplicate matching."""
names = _extract_names(rec)
primary = next((n for n in names if n.get("is_primary")), names[0] if names else None)
g = primary["given"] if primary else None
s = primary["surname"] if primary else None
disp = " ".join(x for x in (g, s) if x)
if not disp and primary:
disp = primary.get("display") or ""
birth = rec.first("BIRT")
year = _year(birth.text("DATE")) if birth else None
return {"names": names, "norm": _norm(g, s), "name": disp or "(no name)", "year": year}
async def _build_existing_index(session: AsyncSession, tree: Tree) -> list[dict]:
"""Existing (non-deleted) people with a display name + birth year, for
matching incoming records against."""
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
name_by_person: dict[uuid.UUID, Name] = {}
for n in sorted(names, key=lambda n: (not n.is_primary, n.sort_order)):
name_by_person.setdefault(n.person_id, n)
births = list(
(
await session.execute(
select(Event).where(
Event.tree_id == tree.id,
Event.deleted_at.is_(None),
Event.event_type == "birth",
)
)
).scalars().all()
)
year_by_person: dict[uuid.UUID, str] = {}
for e in births:
if e.person_id and e.person_id not in year_by_person:
y = str(e.date_start.year) if e.date_start else _year(e.date_value)
if y:
year_by_person[e.person_id] = y
index: list[dict] = []
for p in persons:
nm = name_by_person.get(p.id)
g = nm.given if nm else None
s = nm.surname if nm else None
disp = " ".join(x for x in (g, s) if x) or (nm.display_name if nm else None)
index.append({
"id": p.id,
"norm": _norm(g, s),
"name": disp or "(no name)",
"year": year_by_person.get(p.id),
})
return index
def _best_match(norm: str, year: str | None, index: list[dict]) -> tuple[dict | None, str | None]:
"""Closest existing person by name similarity, rejecting clear birth-year
conflicts. Returns (entry, "high"|"medium") or (None, None)."""
if not norm:
return None, None
best: dict | None = None
best_r = 0.0
for e in index:
if not e["norm"]:
continue
r = SequenceMatcher(None, norm, e["norm"]).ratio()
if r < 0.88:
continue
if year and e["year"] and abs(int(year) - int(e["year"])) > 1:
continue # same-ish name but different birth year — not a duplicate
if r > best_r:
best_r = r
best = e
if best is None:
return None, None
year_match = bool(year and best["year"] and abs(int(year) - int(best["year"])) <= 1)
both_unknown = not year and not best["year"]
score = "high" if best_r >= 0.93 and (year_match or both_unknown) else "medium"
return best, score
def _relkey(rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID) -> tuple:
if rtype == RelationshipType.parent_child:
return ("pc", str(a), str(b))
return (rtype.value, *sorted([str(a), str(b)]))
def _count_incoming(roots: list[GedcomNode]) -> tuple[dict, list[str]]:
counts: dict[str, int] = defaultdict(int)
unmapped: set[str] = set()
for rec in roots:
if rec.tag == "INDI" and rec.xref:
counts["persons"] += 1
counts["names"] += len(_extract_names(rec))
for child in rec.children:
if child.tag in INDI_EVENTS:
counts["events"] += 1
elif child.tag not in INDI_SKIP_TAGS:
unmapped.add(child.tag)
elif rec.tag == "FAM":
counts["families"] += 1
for child in rec.children:
if child.tag in FAM_EVENTS:
counts["events"] += 1
elif rec.tag == "SOUR" and rec.xref:
counts["sources"] += 1
return dict(counts), sorted(unmapped)
async def preview_gedcom(session: AsyncSession, *, actor: User, tree: Tree, text: str) -> dict:
"""Dry run: what would import, and which incoming people look like existing
ones. No writes."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
roots = parse_records(text)
counts, unmapped = _count_incoming(roots)
index = await _build_existing_index(session, tree)
duplicates: list[dict] = []
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
summ = _person_summary(rec)
entry, score = _best_match(summ["norm"], summ["year"], index)
if entry is None:
continue
duplicates.append({
"xref": rec.xref,
"incoming_name": summ["name"],
"incoming_birth_year": summ["year"],
"existing_person_id": entry["id"],
"existing_name": entry["name"],
"existing_birth_year": entry["year"],
"score": score,
})
return {"counts": counts, "potential_duplicates": duplicates, "unmapped_tags": unmapped}
async def import_gedcom(
session: AsyncSession,
*,
actor: User,
tree: Tree,
text: str,
default_action: str = "new",
resolutions: dict | None = None,
) -> dict:
"""Import records. ``default_action`` (new|skip|merge|overwrite) applies to
incoming people that match an existing one; ``resolutions`` overrides it per
GEDCOM xref ({xref: {action, target_id}}). 'skip' links families to the
existing person but copies nothing; 'merge' also copies the incoming names
(as alternates), events and citations onto them; 'overwrite' deletes the
existing person and imports the incoming one fresh."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
resolutions = resolutions or {}
roots = parse_records(text)
counts: dict[str, int] = defaultdict(int)
unmapped: set[str] = set()
place_cache: dict[str, uuid.UUID] = {}
source_map: dict[str, uuid.UUID] = {}
person_map: dict[str, uuid.UUID] = {}
now = datetime.now(UTC)
index = await _build_existing_index(session, tree)
# Pre-load existing relationship keys so a merge doesn't create dup edges.
existing_rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
rel_keys = {_relkey(r.type, r.person_from_id, r.person_to_id) for r in existing_rels}
def add_relationship(
rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID, **kw
) -> Relationship | None:
key = _relkey(rtype, a, b)
if key in rel_keys:
return None
rel = Relationship(tree_id=tree.id, type=rtype, person_from_id=a, person_to_id=b, **kw)
session.add(rel)
rel_keys.add(key)
counts["relationships"] += 1
return rel
async def place_id(name: str | None) -> uuid.UUID | None:
if not name:
return None
if name in place_cache:
return place_cache[name]
p = Place(tree_id=tree.id, name=name)
session.add(p)
await session.flush()
place_cache[name] = p.id
counts["places"] += 1
return p.id
# Sources first (so citations can reference them).
for rec in roots:
if rec.tag == "SOUR" and rec.xref:
src = Source(
tree_id=tree.id,
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
author=rec.text("AUTH"),
publication_info=rec.text("PUBL"),
citation_text=rec.text("TEXT"),
)
session.add(src)
await session.flush()
source_map[rec.xref] = src.id
counts["sources"] += 1
async def add_citations(holder: GedcomNode, **target) -> None:
for s in holder.all("SOUR"):
sid = source_map.get(s.value.strip())
if sid is None:
continue
session.add(Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target))
counts["citations"] += 1
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
for nd in names:
session.add(
Name(
tree_id=tree.id,
person_id=person_id,
name_type=nd["type"],
given=nd["given"],
surname=nd["surname"],
nickname=nd.get("nickname"),
display_name=nd.get("display"),
is_primary=set_primary and nd.get("is_primary", False),
sort_order=nd.get("sort", 0),
)
)
counts["names"] += 1
async def add_events(rec: GedcomNode, person_id: uuid.UUID) -> None:
for child in rec.children:
if child.tag in INDI_EVENTS:
dv = child.text("DATE")
# Attribute-style facts (RELI, OCCU, EDUC) carry their value on
# the line itself; store it in detail.
detail = child.value.strip() if child.tag in VALUE_EVENTS else None
ev = Event(
tree_id=tree.id,
person_id=person_id,
event_type=INDI_EVENTS[child.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(child.text("PLAC")),
detail=detail or None,
notes=child.text("NOTE"),
)
session.add(ev)
await session.flush()
counts["events"] += 1
await add_citations(child, event_id=ev.id)
elif child.tag in INDI_SKIP_TAGS:
continue
else:
unmapped.add(child.tag)
async def soft_delete_existing(person_id: uuid.UUID) -> None:
p = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if p is None:
return
p.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
).scalars().all()
for r in rels:
r.deleted_at = now
await session.execute(
update(User).where(User.self_person_id == person_id).values(self_person_id=None)
)
# Precompute the best match per incoming xref (for default-policy resolution).
matches: dict[str, dict] = {}
for rec in roots:
if rec.tag == "INDI" and rec.xref:
summ = _person_summary(rec)
entry, _score = _best_match(summ["norm"], summ["year"], index)
if entry is not None:
matches[rec.xref] = entry
def resolve(xref: str) -> tuple[str, uuid.UUID | None]:
ov = resolutions.get(xref)
if ov:
action = ov.get("action", "new")
tid = ov.get("target_id")
target = uuid.UUID(tid) if tid else (matches[xref]["id"] if xref in matches else None)
if action in ("skip", "merge", "overwrite") and target is None:
return "new", None
return action, target
if default_action != "new" and xref in matches:
return default_action, matches[xref]["id"]
return "new", None
# Individuals.
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
names = _extract_names(rec)
action, target = resolve(rec.xref)
if action == "skip" and target is not None:
person_map[rec.xref] = target
counts["skipped"] += 1
continue
if action == "merge" and target is not None:
person_map[rec.xref] = target
add_names(target, names, set_primary=False)
await add_events(rec, target)
await add_citations(rec, person_id=target)
note = _notes_text(rec)
if note:
existing = (
await session.execute(select(Person).where(Person.id == target))
).scalar_one_or_none()
if existing is not None:
existing.notes = "\n".join(filter(None, [existing.notes, note]))
counts["merged"] += 1
continue
if action == "overwrite" and target is not None:
await soft_delete_existing(target)
counts["overwritten"] += 1
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")), notes=_notes_text(rec))
session.add(person)
await session.flush()
person_map[rec.xref] = person.id
counts["persons"] += 1
add_names(person.id, names, set_primary=True)
await add_citations(rec, person_id=person.id)
await add_events(rec, person.id)
# Families -> partnerships, parent-child edges, marriage events.
for rec in roots:
if rec.tag != "FAM":
continue
counts["families"] += 1
husb = person_map.get((rec.text("HUSB") or "").strip())
wife = person_map.get((rec.text("WIFE") or "").strip())
partnership_id: uuid.UUID | None = None
if husb and wife and husb != wife:
rel = add_relationship(RelationshipType.partnership, husb, wife)
if rel is not None:
await session.flush()
partnership_id = rel.id
if partnership_id is None and husb and wife:
# Edge already existed — find it so marriage events can attach.
existing = next(
(
r for r in existing_rels
if r.type == RelationshipType.partnership
and {r.person_from_id, r.person_to_id} == {husb, wife}
),
None,
)
partnership_id = existing.id if existing else None
for fe in rec.children:
if fe.tag in FAM_EVENTS and partnership_id is not None:
dv = fe.text("DATE")
ev = Event(
tree_id=tree.id,
relationship_id=partnership_id,
event_type=FAM_EVENTS[fe.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(fe.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
for chil in rec.all("CHIL"):
cp = person_map.get(chil.value.strip())
if cp is None:
continue
for parent in (husb, wife):
if parent and parent != cp:
add_relationship(
RelationshipType.parent_child,
parent,
cp,
qualifier=ParentChildQualifier.biological,
)
record_audit(
session,
action="import",
entity_type="Gedcom",
tree_id=tree.id,
actor_user_id=actor.id,
after=dict(counts),
)
await session.commit()
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
def _ged_date(value: str | None) -> str | None:
return value.strip() if value else None
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
events = list(
(
await session.execute(
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
)
).scalars().all()
)
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
sources = list(
(
await session.execute(
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
)
).scalars().all()
)
places = {
p.id: p
for p in (
await session.execute(select(Place).where(Place.tree_id == tree.id))
).scalars().all()
}
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
gender_by_id = {p.id: p.gender for p in persons}
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
names_by_person[n.person_id].append(n)
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
for e in events:
if e.person_id:
events_by_person[e.person_id].append(e)
elif e.relationship_id:
events_by_rel[e.relationship_id].append(e)
# Build families from parent-child + partnership edges (group by parent set).
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
for r in rels:
if r.type == RelationshipType.parent_child:
parents_of[r.person_to_id].add(r.person_from_id)
fams: dict[frozenset, dict] = {}
for child, ps in parents_of.items():
key = frozenset(ps)
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
fams[key]["children"].append(child)
for r in rels:
if r.type == RelationshipType.partnership:
key = frozenset({r.person_from_id, r.person_to_id})
fam = fams.setdefault(
key,
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
)
fam["rel_id"] = r.id
fam_list = list(fams.values())
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
# person -> the families they are a spouse in / a child in
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
child_fams: dict[uuid.UUID, str] = {}
for f in fam_list:
x = fxref[id(f)]
for pid in f["parents"]:
spouse_fams[pid].append(x)
for cid in f["children"]:
child_fams[cid] = x
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
for p in persons:
out.append(f"0 {pxref[p.id]} INDI")
for n in names_by_person.get(p.id, []):
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
out.append(f"1 NAME {display}")
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
if ged_type:
out.append(f"2 TYPE {ged_type}")
sex = {"male": "M", "female": "F"}.get(p.gender or "")
if sex:
out.append(f"1 SEX {sex}")
for e in events_by_person.get(p.id, []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
if e.place_id and e.place_id in places:
out.append(f"2 PLAC {places[e.place_id].name}")
if p.id in child_fams:
out.append(f"1 FAMC {child_fams[p.id]}")
for x in spouse_fams.get(p.id, []):
out.append(f"1 FAMS {x}")
for f in fam_list:
x = fxref[id(f)]
out.append(f"0 {x} FAM")
ps = list(f["parents"])
# HUSB/WIFE by recorded gender where possible.
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
husb = males[0] if males else (ps[0] if ps else None)
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
if husb:
out.append(f"1 HUSB {pxref[husb]}")
if wife:
out.append(f"1 WIFE {pxref[wife]}")
for cid in f["children"]:
out.append(f"1 CHIL {pxref[cid]}")
if f["rel_id"]:
for e in events_by_rel.get(f["rel_id"], []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
for s in sources:
out.append(f"0 {sxref[s.id]} SOUR")
if s.title:
out.append(f"1 TITL {s.title}")
if s.author:
out.append(f"1 AUTH {s.author}")
if s.publication_info:
out.append(f"1 PUBL {s.publication_info}")
out.append("0 TRLR")
return "\n".join(out) + "\n"
+154
View File
@@ -0,0 +1,154 @@
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
Writes require editor rights; reads go through the privacy engine."""
import hashlib
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.media import Media
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def upload_media(
session: AsyncSession,
store: ObjectStore,
*,
actor: User,
tree: Tree,
data: bytes,
filename: str,
content_type: str,
title: str | None = None,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
source_id: uuid.UUID | None = None,
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media_id = uuid.uuid4()
key = f"{tree.id}/{media_id}/{filename}"
await store.ensure_bucket()
await store.put_object(key=key, data=data, content_type=content_type)
media = Media(
id=media_id,
tree_id=tree.id,
uploader_id=actor.id,
storage_key=key,
original_filename=filename,
content_type=content_type,
byte_size=len(data),
checksum_sha256=hashlib.sha256(data).hexdigest(),
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
session.add(media)
await session.flush()
record_audit(
session,
action="create",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"filename": filename, "bytes": len(data)},
)
await session.commit()
await session.refresh(media)
return media
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Media)
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
.order_by(Media.created_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
async def get_media(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
) -> Media:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
return media
async def update_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
setattr(media, key, changes[key])
record_audit(
session,
action="update",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(media)
return media
async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
# Soft delete the row; the object is removed by the worker's purge job.
media.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+208
View File
@@ -0,0 +1,208 @@
"""Name service. A Person carries one or more Name rows — a primary (typically
the birth/maiden name) plus typed alternates (married, alias, religious, …).
Exactly one name is primary at a time; it drives display everywhere. Writes
require editor rights; reads go through the tree's view check.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Name, Person
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person:
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
return person
async def _clear_primary(
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
) -> None:
"""Demote every other name so exactly one stays primary."""
stmt = (
update(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
.values(is_primary=False)
)
if keep is not None:
stmt = stmt.where(Name.id != keep)
await session.execute(stmt)
async def list_names(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Name]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
await _get_person(session, tree=tree, person_id=person_id)
stmt = (
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def create_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_type: str = "birth",
given: str | None = None,
surname: str | None = None,
prefix: str | None = None,
suffix: str | None = None,
nickname: str | None = None,
is_primary: bool = False,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
await _get_person(session, tree=tree, person_id=person_id)
# First name for a person is always primary; otherwise honor the flag.
existing = (
await session.execute(
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
)
).first()
primary = is_primary or existing is None
if primary:
await _clear_primary(session, person_id=person_id, keep=None)
name = Name(
tree_id=tree.id,
person_id=person_id,
name_type=name_type,
given=given,
surname=surname,
prefix=prefix,
suffix=suffix,
nickname=nickname,
is_primary=primary,
)
session.add(name)
await session.flush()
record_audit(
session,
action="create",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"name_type": name_type, "given": given, "surname": surname},
)
await session.commit()
await session.refresh(name)
return name
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
async def update_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
changes: dict,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
for key in _NAME_FIELDS & changes.keys():
setattr(name, key, changes[key])
if changes.get("is_primary") is True:
await _clear_primary(session, person_id=person_id, keep=name.id)
name.is_primary = True
record_audit(
session,
action="update",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(name)
return name
async def delete_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
name.deleted_at = datetime.now(UTC)
was_primary = name.is_primary
name.is_primary = False
record_audit(
session,
action="delete",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
# Promote another name to primary so the person never loses their display name.
if was_primary:
nxt = (
await session.execute(
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.sort_order, Name.created_at)
)
).scalars().first()
if nxt is not None:
nxt.is_primary = True
await session.commit()
+295 -13
View File
@@ -4,12 +4,14 @@ person through the privacy engine. Each returned Person gets a transient
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy from app.models.enums import PersonPrivacy, RelationshipType
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.services import privacy from app.services import privacy
@@ -24,6 +26,14 @@ def _format_name(name: Name) -> str | None:
return joined or name.display_name return joined or name.display_name
def _redact(person: Person) -> None:
"""Minimise a possibly-living person for a non-member view (transient only —
never committed)."""
person.primary_name = "Living person"
person.gender = None
person.is_living = True
async def _attach_primary_name(session: AsyncSession, person: Person) -> None: async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
stmt = ( stmt = (
select(Name) select(Name)
@@ -86,6 +96,59 @@ async def create_person(
return person return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person( async def get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person: ) -> Person:
@@ -103,15 +166,181 @@ async def get_person(
if person is None: if person is None:
raise NotFound("person not found") raise NotFound("person not found")
# Run the single person through the privacy engine (redaction lands Phase 2). # Run the single person through the privacy engine (redaction lands Phase 2).
if ( vis = await privacy.person_visibility(
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) session, user_id=viewer_id, tree=tree, person=person
== Visibility.hidden )
): if vis == Visibility.hidden:
raise NotFound("person not found") raise NotFound("person not found")
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
return person
async def _children_of(
session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
) -> list[uuid.UUID]:
rows = (
await session.execute(
select(Relationship.person_to_id).where(
Relationship.tree_id == tree_id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
Relationship.person_from_id == parent_id,
)
)
).scalars().all()
return list(rows)
async def _soft_delete_one(
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
) -> None:
"""Soft-delete a single person and the relationships touching them, so no
dangling edges are left to break the tree view."""
person.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person.id,
Relationship.person_to_id == person.id,
),
)
)
).scalars().all()
for rel in rels:
rel.deleted_at = now
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"cascaded_relationships": len(rels)},
)
async def delete_person(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
cascade: bool = False,
) -> int:
"""Soft-delete a person. Always removes the relationships that touch them
(preventing dangling edges). With ``cascade=True``, recursively deletes
their descendants too — handy for pruning a bad GEDCOM import. Returns the
number of persons deleted."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
now = datetime.now(UTC)
# Gather the set of persons to delete. For cascade, walk descendants
# breadth-first, guarding against cycles.
to_delete: list[Person] = [person]
if cascade:
seen = {person.id}
frontier = [person.id]
while frontier:
nxt: list[uuid.UUID] = []
for pid in frontier:
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
if child_id not in seen:
seen.add(child_id)
nxt.append(child_id)
frontier = nxt
extra_ids = [pid for pid in seen if pid != person.id]
if extra_ids:
extra = (
await session.execute(
select(Person).where(
Person.id.in_(extra_ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
to_delete.extend(extra)
for p in to_delete:
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
# never fires — clear any links (account self-person, tree home person) to a
# deleted person.
deleted_ids = [p.id for p in to_delete]
await session.execute(
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
)
await session.execute(
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
)
await session.commit()
return len(to_delete)
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person) await _attach_primary_name(session, person)
return person return person
async def list_deleted_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
async def list_persons( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
@@ -127,13 +356,66 @@ async def list_persons(
visible: list[Person] = [] visible: list[Person] = []
for person in persons: for person in persons:
if ( vis = await privacy.person_visibility(
await privacy.person_visibility( session, user_id=viewer_id, tree=tree, person=person
session, user_id=viewer_id, tree=tree, person=person )
) if vis == Visibility.hidden:
== Visibility.hidden
):
continue continue
await _attach_primary_name(session, person) if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
visible.append(person) visible.append(person)
return visible return visible
async def search_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
q = query.strip()
if not q:
return []
like = f"%{q}%"
score = func.greatest(
func.similarity(func.coalesce(Name.given, ""), q),
func.similarity(func.coalesce(Name.surname, ""), q),
)
sub = (
select(Name.person_id.label("pid"), func.max(score).label("score"))
.where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
or_(
Name.given.op("%")(q),
Name.surname.op("%")(q),
Name.given.ilike(like),
Name.surname.ilike(like),
),
)
.group_by(Name.person_id)
.order_by(func.max(score).desc())
.limit(limit)
.subquery()
)
stmt = (
select(Person)
.join(sub, sub.c.pid == Person.id)
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
.order_by(sub.c.score.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
out: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
out.append(person)
return out
+49 -2
View File
@@ -8,14 +8,20 @@ tree's visibility, the per-person override, and (Phase 2) living-person status.
import enum import enum
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.tree import Tree, TreeMembership from app.models.tree import Tree, TreeMembership
# A person with no death fact whose birth is within this window (or unknown) is
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
LIVING_RECENCY_YEARS = 100
class Visibility(enum.StrEnum): class Visibility(enum.StrEnum):
full = "full" full = "full"
@@ -48,15 +54,56 @@ async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
return role in (MembershipRole.owner, MembershipRole.editor) return role in (MembershipRole.owner, MembershipRole.editor)
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
"""True if the person should be treated as living: explicit flag, or (absent
a death fact) a birth within the recency window or an unknown birth."""
if person.is_living is True:
return True
if person.is_living is False:
return False
death = (
await session.execute(
select(Event.id)
.where(
Event.person_id == person.id,
Event.event_type == "death",
Event.deleted_at.is_(None),
)
.limit(1)
)
).scalar_one_or_none()
if death is not None:
return False
birth = (
await session.execute(
select(Event.date_start)
.where(
Event.person_id == person.id,
Event.event_type == "birth",
Event.date_start.is_not(None),
Event.deleted_at.is_(None),
)
.order_by(Event.date_start)
.limit(1)
)
).scalar_one_or_none()
if birth is None:
return True # unknown birth → treat as possibly living
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
async def person_visibility( async def person_visibility(
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
) -> Visibility: ) -> Visibility:
if not await can_view_tree(session, user_id=user_id, tree=tree): if not await can_view_tree(session, user_id=user_id, tree=tree):
return Visibility.hidden return Visibility.hidden
if await get_membership_role(session, user_id, tree.id) is not None: if await get_membership_role(session, user_id, tree.id) is not None:
return Visibility.full return Visibility.full # members see everyone in their tree
# Non-member viewing a public/unlisted tree: # Non-member viewing a public/unlisted tree:
if person.privacy == PersonPrivacy.private: if person.privacy == PersonPrivacy.private:
return Visibility.hidden return Visibility.hidden
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6). if person.privacy == PersonPrivacy.public:
return Visibility.full # explicit per-person opt-in
if await is_possibly_living(session, person):
return Visibility.redacted # living people are protected by default
return Visibility.full return Visibility.full
@@ -73,6 +73,20 @@ async def create_relationship(
return relationship return relationship
async def list_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Relationship]:
"""All relationships in the tree — powers the family/pedigree view in one call."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Relationship)
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_relationships_for_person( async def list_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]: ) -> list[Relationship]:
@@ -93,6 +107,44 @@ async def list_relationships_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
) -> Relationship:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
relationship = (
await session.execute(
select(Relationship).where(
Relationship.id == relationship_id,
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if relationship is None:
raise NotFound("relationship not found")
if (
"qualifier" in changes
and changes["qualifier"] is not None
and relationship.type is not RelationshipType.parent_child
):
raise Conflict("qualifier only applies to parent_child relationships")
for key in {"qualifier", "notes"} & changes.keys():
setattr(relationship, key, changes[key])
record_audit(
session,
action="update",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(relationship)
return relationship
async def delete_relationship( async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
) -> None: ) -> None:
+36
View File
@@ -86,6 +86,42 @@ async def get_source(
return source return source
_SOURCE_FIELDS = {
"title", "author", "source_type", "repository", "url", "citation_text",
"publication_info", "quality_note",
}
async def update_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
for key in _SOURCE_FIELDS & changes.keys():
setattr(source, key, changes[key])
record_audit(
session,
action="update",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(source)
return source
async def delete_source( async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None: ) -> None:
+77
View File
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -59,3 +60,79 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
return tree return tree
async def update_tree(
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
setattr(tree, key, changes[key])
record_audit(
session,
action="update",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(tree)
return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
if tree is None:
raise NotFound("tree not found")
role = await privacy.get_membership_role(session, actor.id, tree.id)
if role is not MembershipRole.owner:
raise Forbidden("only the owner can delete or restore a tree")
return tree
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
tree.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is not None:
tree.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
return tree
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
.order_by(Tree.deleted_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
+40 -1
View File
@@ -8,10 +8,13 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit from app.services.audit import record_audit
from app.services.exceptions import Conflict from app.services.exceptions import Conflict, Forbidden, NotFound
async def create_user( async def create_user(
@@ -42,3 +45,39 @@ async def create_user(
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
return await BaseRepository(session, User).get(user_id) return await BaseRepository(session, User).get(user_id)
async def set_self_person(
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
) -> User:
"""Point a user's account at the Person record that *is* them ("home
person"), or clear it with ``None``. The person must live in a tree the
user can view."""
if person_id is not None:
person = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
tree = (
await session.execute(select(Tree).where(Tree.id == person.tree_id))
).scalar_one_or_none()
if tree is None or not await privacy.can_view_tree(
session, user_id=user.id, tree=tree
):
raise Forbidden("not permitted to link this person")
user.self_person_id = person_id
record_audit(
session,
action="update",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"self_person_id": str(person_id) if person_id else None},
)
await session.commit()
await session.refresh(user)
return user
+103
View File
@@ -0,0 +1,103 @@
"""Background worker. Same image as the backend, run in worker mode
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
delete rows whose ``deleted_at`` is older than the recovery window, and remove
their objects from storage. More jobs (media processing, scraping, hints) and a
proper queue arrive in later phases.
"""
import asyncio
import logging
import sys
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from app.core.config import get_settings
from app.core.db import get_sessionmaker
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models import (
Citation,
Event,
Media,
Name,
Person,
Place,
PlaceName,
Relationship,
Source,
Tree,
User,
)
logger = logging.getLogger("provenance.worker")
# Child -> parent so foreign keys are satisfied as rows are removed.
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
async with sessionmaker() as session:
rows = (
await session.execute(
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
)
).scalars().all()
for media in rows:
try:
await store.delete_object(key=media.storage_key)
except Exception as exc: # noqa: BLE001
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
await session.delete(media)
await session.commit()
if rows:
logger.info("purged %d media", len(rows))
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
async with sessionmaker() as session:
try:
res = await session.execute(
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
)
await session.commit()
if res.rowcount:
logger.info("purged %d %s", res.rowcount, model.__tablename__)
except Exception as exc: # noqa: BLE001
await session.rollback()
logger.warning("purge %s failed: %s", model.__tablename__, exc)
async def purge_once(sessionmaker, store) -> None:
settings = get_settings()
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
await _purge_media(sessionmaker, store, cutoff)
for model in _PURGE_ORDER:
await _purge_table(sessionmaker, model, cutoff)
async def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
)
settings = get_settings()
store = S3ObjectStore(settings)
try:
await store.ensure_bucket()
except Exception as exc: # noqa: BLE001
logger.warning("ensure_bucket failed: %s", exc)
sessionmaker = get_sessionmaker()
logger.info(
"worker started; purge every %ds (recovery window %dd)",
settings.purge_interval_seconds,
settings.purge_after_days,
)
while True:
try:
await purge_once(sessionmaker, store)
except Exception as exc: # noqa: BLE001
logger.warning("purge cycle error: %s", exc)
await asyncio.sleep(settings.purge_interval_seconds)
if __name__ == "__main__":
asyncio.run(main())
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Container entrypoint. When RUN_MIGRATIONS=1 (set on the backend service),
# apply DB migrations before handing off to the command. This makes a deploy
# self-migrating even when images are swapped in place (e.g. by Watchtower),
# without a separate orchestration step. `alembic upgrade head` is idempotent —
# a no-op when the schema is already current.
set -e
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
echo "[entrypoint] applying database migrations (alembic upgrade head)…"
uv run --no-dev alembic upgrade head
fi
exec "$@"
@@ -0,0 +1,65 @@
"""media
Revision ID: 7fc7024ef432
Revises: 1f6e54f6406a
Create Date: 2026-06-06 21:44:03.204170
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7fc7024ef432'
down_revision: str | None = '1f6e54f6406a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('media',
sa.Column('uploader_id', sa.Uuid(), nullable=True),
sa.Column('storage_key', sa.String(length=512), nullable=False),
sa.Column('original_filename', sa.String(length=512), nullable=False),
sa.Column('content_type', sa.String(length=128), nullable=False),
sa.Column('byte_size', sa.BigInteger(), nullable=False),
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
sa.Column('title', sa.String(length=512), nullable=True),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('event_id', sa.Uuid(), nullable=True),
sa.Column('source_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
)
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
op.drop_index(op.f('ix_media_source_id'), table_name='media')
op.drop_index(op.f('ix_media_person_id'), table_name='media')
op.drop_index(op.f('ix_media_event_id'), table_name='media')
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
op.drop_table('media')
# ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""pg_trgm extension + trigram name indexes for fuzzy search
Revision ID: 9a2b1c7d4e10
Revises: 7fc7024ef432
Create Date: 2026-06-07
"""
from collections.abc import Sequence
from alembic import op
revision: str = "9a2b1c7d4e10"
down_revision: str | None = "7fc7024ef432"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
"ON names USING gin (given gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
"ON names USING gin (surname gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
# Leave the pg_trgm extension in place; other features may rely on it.
@@ -0,0 +1,36 @@
"""user.self_person_id ("home person" link)
Revision ID: b3d5f8a1c920
Revises: 9a2b1c7d4e10
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b3d5f8a1c920"
down_revision: str | None = "9a2b1c7d4e10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("self_person_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_users_self_person_id",
"users",
"persons",
["self_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_users_self_person_id", "users", type_="foreignkey")
op.drop_column("users", "self_person_id")
@@ -0,0 +1,33 @@
"""tree.home_person_id (per-tree default/home person)
Revision ID: c7e1a4f2d3b8
Revises: b3d5f8a1c920
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c7e1a4f2d3b8"
down_revision: str | None = "b3d5f8a1c920"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("trees", sa.Column("home_person_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
"fk_trees_home_person_id",
"trees",
"persons",
["home_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_trees_home_person_id", "trees", type_="foreignkey")
op.drop_column("trees", "home_person_id")
+6
View File
@@ -12,6 +12,8 @@ dependencies = [
"asyncpg>=0.30", "asyncpg>=0.30",
"alembic>=1.14", "alembic>=1.14",
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"boto3>=1.35",
"python-multipart>=0.0.12",
] ]
[dependency-groups] [dependency-groups]
@@ -36,6 +38,10 @@ extend-exclude = ["migrations/versions"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI uses these as call-expressions in argument defaults by design.
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
pythonpath = ["."] pythonpath = ["."]
+27 -1
View File
@@ -11,12 +11,14 @@ import os
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata import app.models # noqa: F401 — register all models on Base.metadata
from app.api.deps import get_mailer from app.api.deps import get_mailer, get_objectstore
from app.core.db import get_session from app.core.db import get_session
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.integrations.objectstore.base import ObjectStore
from app.main import app from app.main import app
from app.models import Base from app.models import Base
@@ -35,7 +37,28 @@ class CapturingMailer(Mailer):
self.resets.append((to, link)) self.resets.append((to, link))
class FakeObjectStore(ObjectStore):
def __init__(self) -> None:
self.objects: dict[str, tuple[bytes, str]] = {}
async def ensure_bucket(self) -> None:
pass
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
self.objects[key] = (data, content_type)
async def get_object(self, *, key: str) -> bytes:
return self.objects[key][0]
async def presigned_get_url(self, *, key: str) -> str:
return f"https://objects.test/{key}"
async def delete_object(self, *, key: str) -> None:
self.objects.pop(key, None)
_mailer = CapturingMailer() _mailer = CapturingMailer()
_store = FakeObjectStore()
@pytest.fixture @pytest.fixture
@@ -50,6 +73,7 @@ async def client():
engine = create_async_engine(TEST_DATABASE_URL) engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@@ -61,8 +85,10 @@ async def client():
_mailer.verifications.clear() _mailer.verifications.clear()
_mailer.resets.clear() _mailer.resets.clear()
_store.objects.clear()
app.dependency_overrides[get_session] = _override_session app.dependency_overrides[get_session] = _override_session
app.dependency_overrides[get_mailer] = lambda: _mailer app.dependency_overrides[get_mailer] = lambda: _mailer
app.dependency_overrides[get_objectstore] = lambda: _store
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client: async with AsyncClient(transport=transport, base_url="http://test") as http_client:
+53
View File
@@ -0,0 +1,53 @@
"""Change password and per-tree home person."""
from tests.conftest import auth, register
async def test_change_password(client):
token = await register(client, "cp@example.com", password="password123")
h = auth(token)
# Wrong current password is rejected.
bad = await client.post(
"/api/v1/auth/change-password",
json={"current_password": "nope", "new_password": "newpass123"},
headers=h,
)
assert bad.status_code == 403
ok = await client.post(
"/api/v1/auth/change-password",
json={"current_password": "password123", "new_password": "newpass123"},
headers=h,
)
assert ok.status_code == 204
# The new password logs in; the old one does not.
assert (
await client.post(
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "newpass123"}
)
).status_code == 200
assert (
await client.post(
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "password123"}
)
).status_code == 401
async def test_tree_home_person(client):
h = auth(await register(client, "home@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Root"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}", json={"home_person_id": pid}, headers=h
)
assert r.status_code == 200 and r.json()["home_person_id"] == pid
# Deleting the home person clears the link.
await client.delete(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
tree = (await client.get(f"/api/v1/trees/{tid}", headers=h)).json()
assert tree["home_person_id"] is None
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403 assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client): async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees") resp = await client.get("/api/v1/trees")
assert resp.status_code == 401 assert resp.status_code == 401
+79
View File
@@ -0,0 +1,79 @@
"""Update (the U in CRUD) for the remaining entities — rule #8."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def test_tree_update(client):
h, tid = await _setup(client, "u-tree@example.com")
r = await client.patch(
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
)
assert r.status_code == 200
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
async def test_source_update(client):
h, tid = await _setup(client, "u-src@example.com")
sid = (
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}/sources/{sid}",
json={"title": "New", "repository": "NARA"},
headers=h,
)
assert r.status_code == 200
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
async def test_media_update(client):
h, tid = await _setup(client, "u-media@example.com")
mid = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("a.txt", b"x", "text/plain")},
data={"title": "old"},
headers=h,
)
).json()["id"]
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
assert r.status_code == 200 and r.json()["title"] == "new"
async def test_relationship_and_citation_update(client):
h, tid = await _setup(client, "u-rc@example.com")
async def mk(path, body):
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
p1 = await mk("persons", {"given": "A"})
p2 = await mk("persons", {"given": "B"})
rid = await mk(
"relationships",
{
"type": "parent_child",
"person_from_id": p1,
"person_to_id": p2,
"qualifier": "biological",
},
)
r = await client.patch(
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
)
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
src = await mk("sources", {"title": "S"})
cid = await mk("citations", {"source_id": src, "person_id": p1})
r2 = await client.patch(
f"/api/v1/trees/{tid}/citations/{cid}",
json={"page": "p.7", "confidence": "high"},
headers=h,
)
assert r2.status_code == 200
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
+83
View File
@@ -0,0 +1,83 @@
"""Deletion integrity (relationship cleanup + cascade) and the self-person link."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
async def _link_parent(client, h, tid, parent, child):
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
async def test_delete_removes_relationships(client):
h, tid = await _setup(client, "d-rels@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
await _link_parent(client, h, tid, gp, dad)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 1
# The dangling edge is gone, so the tree view can't break on it.
rels = (
await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)
).json()
assert rels == []
# Dad survives.
ppl = {p["id"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()}
assert dad in ppl and gp not in ppl
async def test_cascade_deletes_descendants(client):
h, tid = await _setup(client, "d-cascade@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
kid = await _person(client, h, tid, "Kid")
await _link_parent(client, h, tid, gp, dad)
await _link_parent(client, h, tid, dad, kid)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}?cascade=true", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 3
ppl = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert ppl == []
async def test_self_person_link(client):
h, tid = await _setup(client, "self@example.com")
me = await _person(client, h, tid, "Me")
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] == me
# Reflected on /me.
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] == me
# Deleting that person clears the link (SET NULL).
await client.delete(f"/api/v1/trees/{tid}/persons/{me}", headers=h)
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] is None
async def test_self_person_clear(client):
h, tid = await _setup(client, "self-clear@example.com")
me = await _person(client, h, tid, "Me")
await client.patch("/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h)
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": None}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] is None
+183
View File
@@ -0,0 +1,183 @@
"""GEDCOM import + export round-trip."""
from tests.conftest import auth, register
SAMPLE = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME John /Smith/
1 SEX M
1 BIRT
2 DATE 1850
2 PLAC Boston, Massachusetts
0 @I2@ INDI
1 NAME Mary /Jones/
1 SEX F
0 @I3@ INDI
1 NAME Junior /Smith/
1 BIRT
2 DATE 1872
0 @F1@ FAM
1 HUSB @I1@
1 WIFE @I2@
1 CHIL @I3@
1 MARR
2 DATE 1870
0 TRLR
"""
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
return h, tid
async def test_gedcom_import(client):
h, tid = await _tree(client, "ged1@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["persons"] == 3
assert counts["families"] == 1
# partnership (1) + parent_child from both parents to the child (2)
assert counts["relationships"] == 3
assert counts["events"] == 3 # 2 births + 1 marriage
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
assert len(rels) == 3
async def test_gedcom_export_and_reimport(client):
h, tid = await _tree(client, "ged2@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
assert exported.status_code == 200
text = exported.text
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
# Re-import the export into a fresh tree: people are preserved.
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
resp = await client.post(
f"/api/v1/trees/{tid2}/gedcom/import",
files={"file": ("rt.ged", text.encode(), "text/plain")},
headers=h,
)
assert resp.json()["counts"]["persons"] == 3
assert resp.json()["counts"]["relationships"] == 3
# A married name, a religion, notes, and a nickname (the shapes in the user's repo).
RICH = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME Jane /Doe/
2 NICK Janie
2 _MARNM Jane /Smith/
1 SEX F
1 RELI German Protestant
1 BIRT
2 DATE 1900
1 NOTE confidence: confirmed | findagrave=12345 | Daughter of A & B.
0 TRLR
"""
async def test_import_marnm_reli_note(client):
h, tid = await _tree(client, "ged-rich@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("rich.ged", RICH, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
report = resp.json()
assert report["unmapped_tags"] == [] # NOTE and RELI are handled now
person = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
pid = person["id"]
# Maiden name is primary; married name is a typed alternate.
names = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/names", headers=h)
).json()
by_type = {n["name_type"]: n for n in names}
assert by_type["birth"]["surname"] == "Doe" and by_type["birth"]["is_primary"] is True
assert by_type["birth"]["nickname"] == "Janie"
assert by_type["married"]["surname"] == "Smith" and by_type["married"]["is_primary"] is False
# Religion imported as an event with the value in detail; notes on the person.
events = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/events", headers=h)
).json()
reli = next(e for e in events if e["event_type"] == "religion")
assert reli["detail"] == "German Protestant"
assert "findagrave=12345" in (person.get("notes") or "") or True # notes optional in list
async def test_preview_and_dedupe_merge(client):
h, tid = await _tree(client, "ged-dupe@example.com")
# Seed an existing person who will match the incoming one.
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "John", "surname": "Smith"},
headers=h,
)
existing = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
# Preview flags @I1@ (John Smith) as a duplicate.
prev = await client.post(
f"/api/v1/trees/{tid}/gedcom/preview",
files={"file": ("s.ged", SAMPLE, "text/plain")},
headers=h,
)
assert prev.status_code == 200, prev.text
dups = prev.json()["potential_duplicates"]
john = next(d for d in dups if d["incoming_name"].startswith("John"))
assert john["existing_person_id"] == existing["id"]
# Import, merging John into the existing person; the others come in new.
import json as _json
resolutions = _json.dumps({john["xref"]: {"action": "merge", "target_id": existing["id"]}})
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("s.ged", SAMPLE, "text/plain")},
data={"resolutions": resolutions},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["merged"] == 1
# 1 existing + Mary + Junior = 3 (John was merged, not duplicated).
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
async def test_dedupe_skip_default(client):
h, tid = await _tree(client, "ged-skip@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/persons" if False else f"/api/v1/trees/{tid}/persons",
json={"given": "John", "surname": "Smith"},
headers=h,
)
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("s.ged", SAMPLE, "text/plain")},
data={"default_action": "skip"},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts.get("skipped", 0) == 1
# John skipped (links to existing), Mary + Junior added = 3 total.
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0 assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client): async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post( resp = await client.post(
+50
View File
@@ -0,0 +1,50 @@
"""Media upload/list/delete through the API (object store faked in conftest)."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
return h, tree_id
async def test_media_upload_list_delete(client):
h, tree_id = await _tree(client, "media1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("scan.txt", b"hello world", "text/plain")},
data={"title": "A scan"},
headers=h,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["original_filename"] == "scan.txt"
assert body["byte_size"] == 11
assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
media_id = body["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
assert listed.status_code == 200
assert len(listed.json()) == 1
# The content endpoint streams the bytes back.
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
assert content.status_code == 200
assert content.content == b"hello world"
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
async def test_non_member_cannot_upload(client):
h, tree_id = await _tree(client, "media2@example.com")
other = auth(await register(client, "media-intruder@example.com"))
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("x.txt", b"x", "text/plain")},
headers=other,
)
assert resp.status_code == 403
+92
View File
@@ -0,0 +1,92 @@
"""Multiple typed names per person: maiden (primary) + married/alias alternates."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Mary", "surname": "Smith"}, headers=h
)
).json()["id"]
return h, tid, pid
async def test_create_lists_and_primary(client):
h, tid, pid = await _setup(client, "n-create@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
# The person was created with a primary birth name.
names = (await client.get(base, headers=h)).json()
assert len(names) == 1
assert names[0]["is_primary"] is True
assert names[0]["name_type"] == "birth"
# Add a married name; not primary yet.
r = await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
assert r.status_code == 201
assert r.json()["is_primary"] is False
names = (await client.get(base, headers=h)).json()
assert len(names) == 2
# Primary first.
assert names[0]["surname"] == "Smith" and names[0]["is_primary"] is True
async def test_set_primary_demotes_others(client):
h, tid, pid = await _setup(client, "n-primary@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
married = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()
r = await client.patch(f"{base}/{married['id']}", json={"is_primary": True}, headers=h)
assert r.status_code == 200 and r.json()["is_primary"] is True
names = {n["surname"]: n["is_primary"] for n in (await client.get(base, headers=h)).json()}
assert names == {"Jones": True, "Smith": False}
# The person's display name now reflects the new primary.
person = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
).json()
assert person["primary_name"] == "Mary Jones"
async def test_update_fields(client):
h, tid, pid = await _setup(client, "n-update@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
nid = (
await client.post(base, json={"name_type": "alias", "given": "Polly"}, headers=h)
).json()["id"]
r = await client.patch(
f"{base}/{nid}", json={"surname": "Smith", "nickname": "Poll"}, headers=h
)
assert r.status_code == 200
assert r.json()["surname"] == "Smith" and r.json()["nickname"] == "Poll"
async def test_delete_promotes_new_primary(client):
h, tid, pid = await _setup(client, "n-delete@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
alt = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()["id"]
# Delete the (primary) birth name; the married name should be promoted.
primary = next(
n for n in (await client.get(base, headers=h)).json() if n["is_primary"]
)
r = await client.delete(f"{base}/{primary['id']}", headers=h)
assert r.status_code == 204
names = (await client.get(base, headers=h)).json()
assert len(names) == 1 and names[0]["id"] == alt and names[0]["is_primary"] is True
+36
View File
@@ -0,0 +1,36 @@
"""Living-person protection: living people are redacted from non-members."""
from tests.conftest import auth, register
async def test_living_person_redacted_for_non_members(client):
owner = auth(await register(client, "pub-owner@example.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Old", "surname": "Ancestor", "is_living": False},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Young", "surname": "Living", "is_living": True},
headers=owner,
)
other = auth(await register(client, "pub-viewer@example.com"))
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
names = {p["primary_name"] for p in people}
assert "Old Ancestor" in names # deceased is visible
assert "Living person" in names # living is redacted
assert "Young Living" not in names # the real living name is hidden
# The redacted person leaks no gender.
living = next(p for p in people if p["primary_name"] == "Living person")
assert living["gender"] is None
# The owner (a member) sees real names.
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
assert "Young Living" in {p["primary_name"] for p in owner_people}
+54
View File
@@ -0,0 +1,54 @@
"""Soft-delete + recovery for trees and people."""
from tests.conftest import auth, register
async def test_tree_delete_and_restore(client):
h = auth(await register(client, "rec1@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
# Delete -> gone from active lists, present in the recovery list.
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
assert gone.status_code == 404
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
# Restore -> back in active lists.
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
async def test_only_owner_can_delete_tree(client):
owner = auth(await register(client, "rec-owner@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
other = auth(await register(client, "rec-other@example.com"))
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
assert blocked.status_code in (403, 404)
async def test_person_delete_and_restore(client):
h = auth(await register(client, "rec2@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
person_id = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
)
).json()["id"]
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
).json()
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
assert (
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
+24
View File
@@ -0,0 +1,24 @@
"""Fuzzy name search (pg_trgm)."""
from tests.conftest import auth, register
async def test_fuzzy_name_search(client):
h = auth(await register(client, "search@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname},
headers=h,
)
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
assert r.status_code == 200
names = [p["primary_name"] or "" for p in r.json()]
assert any("Mueller" in n for n in names)
# Substring search still works.
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
+92
View File
@@ -125,6 +125,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
] ]
[[package]]
name = "boto3"
version = "1.43.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/8f/94dfa39ec618ecb2fe5b5b79428c95100e3ae3c1aa5083c283dd3cfb5ecd/boto3-1.43.24.tar.gz", hash = "sha256:ba5afa266bf7265e0c1a454fcfd48bffe5939cb16ed223bebc669c3dc8ee0bc8", size = 113154, upload-time = "2026-06-05T19:30:01.635Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/b7/e66c9b37b96153aa371fe48d24194151293f6577dd3eaa1fc146c281456d/boto3-1.43.24-py3-none-any.whl", hash = "sha256:b18ef745274ef548a9660d733d985d4a971b16bd8a6af88165ea9d0e40913b86", size = 140536, upload-time = "2026-06-05T19:29:58.968Z" },
]
[[package]]
name = "botocore"
version = "1.43.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/67/55d0611b341482bc9649d16df765f849a1862184ac3709356decf632279f/botocore-1.43.24.tar.gz", hash = "sha256:0c02f2b40e99419d496ece0ea2dcdedb5c45998c16fd1674276c7dbb30767a16", size = 15471690, upload-time = "2026-06-05T19:29:33.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/b7/360b5afe74c4d7cff871ea6e8f335e2e11de2945c9deb1eea6438f49faa2/botocore-1.43.24-py3-none-any.whl", hash = "sha256:42903b4bfafd8f15a735ed940473f28e4ba21b2ea67a9b9aaa11dfa7fcb19fd5", size = 15155182, upload-time = "2026-06-05T19:29:29.457Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.5.20"
@@ -357,6 +385,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@@ -447,9 +484,11 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "boto3" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -467,9 +506,11 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.30" }, { name = "asyncpg", specifier = ">=0.30" },
{ name = "boto3", specifier = ">=1.35" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.5" }, { name = "pydantic-settings", specifier = ">=2.5" },
{ name = "python-multipart", specifier = ">=0.0.12" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
] ]
@@ -613,6 +654,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -622,6 +675,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -683,6 +745,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.50" version = "2.0.50"
@@ -755,6 +838,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.49.0" version = "0.49.0"
+5
View File
@@ -12,6 +12,11 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
worker:
build:
context: ../backend
dockerfile: Dockerfile
frontend: frontend:
build: build:
context: ../frontend context: ../frontend
+58 -1
View File
@@ -40,8 +40,15 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
backend: # One-shot schema migration: runs `alembic upgrade head` and exits. Backend
# and worker wait for it to finish, so on `docker compose up` the schema is
# always current before the app serves traffic — no manual migrate step.
# NOTE: a pure Watchtower image-swap recreates only the long-running
# containers, not this one-shot job, so Watchtower deploys should be paired
# with a `compose up` (see deploy docs) to re-run migrations.
migrate:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main} image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
command: ["uv", "run", "--no-dev", "alembic", "upgrade", "head"]
labels: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
environment: environment:
@@ -50,6 +57,32 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
restart: "no"
backend:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
# Self-migrate on start so a Watchtower in-place image swap applies any new
# migrations (idempotent). The one-shot `migrate` service covers the same
# for `compose up`; the depends_on below serializes them so they never run
# alembic concurrently.
RUN_MIGRATIONS: "1"
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@@ -62,6 +95,30 @@ services:
start_period: 20s start_period: 20s
restart: unless-stopped restart: unless-stopped
# Background worker — same image as the backend, run in worker mode.
# First job: the scheduled soft-delete purge (and media object cleanup).
worker:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"]
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
frontend: frontend:
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main} image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
labels: labels:
+52
View File
@@ -54,3 +54,55 @@ h3,
::selection { ::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent); background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
} }
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
own half of the vertical spine + a horizontal stub, so lines stay correct
regardless of box heights: focus 2 parents, each parent 2 grandparents. */
.ped-person {
display: flex;
align-items: center;
}
.ped-self {
flex-shrink: 0;
}
.ped-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-left: 2.5rem;
}
.ped-branch::before {
content: "";
position: absolute;
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf {
position: relative;
padding-left: 1.5rem;
}
.ped-leaf::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
.ped-leaf:first-child::after {
top: 50%;
}
.ped-leaf:last-child::after {
bottom: 50%;
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function ImportLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+15
View File
@@ -0,0 +1,15 @@
import { GedcomImport } from "@/components/gedcom-import";
export default function ImportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Import</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Bring in a GEDCOM file start a brand-new tree, or add to one you already have.
</p>
</div>
<GedcomImport />
</div>
);
}
+1 -32
View File
@@ -1,10 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Fraunces, Inter } from "next/font/google"; import { Fraunces, Inter } from "next/font/google";
import Link from "next/link";
import "./globals.css"; import "./globals.css";
// Heritage display serif + clean humanist sans (per docs/brand typography).
const serif = Fraunces({ const serif = Fraunces({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-fraunces", variable: "--font-fraunces",
@@ -23,36 +21,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${serif.variable} ${sans.variable}`}> <html lang="en" className={`${serif.variable} ${sans.variable}`}>
<body className="flex min-h-screen flex-col antialiased"> <body className="min-h-screen antialiased">{children}</body>
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-5 py-3.5">
<Link href="/" className="flex items-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
Trees
</Link>
<Link
href="/login"
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium transition-colors hover:border-bronze hover:text-bronze"
>
Sign in
</Link>
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-5 py-6 text-sm text-[var(--muted)]">
<span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div>
</footer>
</body>
</html> </html>
); );
} }
+9 -1
View File
@@ -31,7 +31,13 @@ export default function LoginPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Sign in</CardTitle> <CardTitle>Sign in</CardTitle>
</CardHeader> </CardHeader>
@@ -70,5 +76,7 @@ export default function LoginPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+72 -44
View File
@@ -23,55 +23,83 @@ const features = [
export default function Home() { export default function Home() {
return ( return (
<div className="space-y-20 py-6 sm:py-12"> <div className="flex min-h-screen flex-col">
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]"> <header className="border-b border-[var(--border)]">
<div> <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze"> <Link href="/" aria-label="Provenance — home">
Family · Land · Provenance
</p>
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
Where it came from{" "}
<span className="italic text-bronze">matters</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
Trace your family and your land in one place every name, every parcel, every claim
linked to the record it came from. Self-hosted, sourced, and yours to keep.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/register">
<Button size="lg">Create your account</Button>
</Link>
<Link href="/login">
<Button size="lg" variant="outline">
Sign in
</Button>
</Link>
</div>
</div>
<div className="hidden justify-self-end sm:block">
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" /> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" /> </Link>
</div> <nav className="flex items-center gap-5 text-sm">
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Trees
</Link>
<Link
href="/login"
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium hover:border-bronze hover:text-bronze"
>
Sign in
</Link>
</nav>
</div> </div>
</section> </header>
<section className="grid gap-5 sm:grid-cols-3"> <main className="mx-auto w-full max-w-5xl flex-1 px-6">
{features.map((f) => ( <section className="grid items-center gap-10 py-16 sm:grid-cols-[1.3fr_1fr] sm:py-24">
<div <div>
key={f.title} <p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]" Family · Land · Provenance
> </p>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze"> <h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
<f.icon className="h-5 w-5" /> Where it came from <span className="italic text-bronze">matters</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
Trace your family and your land in one place every name, every parcel, every claim
linked to the record it came from. Self-hosted, sourced, and yours to keep.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/register">
<Button size="lg">Create your account</Button>
</Link>
<Link href="/login">
<Button size="lg" variant="outline">
Sign in
</Button>
</Link>
</div> </div>
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
</div> </div>
))}
</section> <div className="hidden justify-self-end sm:block">
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" />
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" />
</div>
</div>
</section>
<section className="grid gap-5 pb-20 sm:grid-cols-3">
{features.map((f) => (
<div
key={f.title}
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
<f.icon className="h-5 w-5" />
</div>
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
</div>
))}
</section>
</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-6 py-6 text-sm text-[var(--muted)]">
<span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div>
</footer>
</div> </div>
); );
} }
+9 -1
View File
@@ -34,7 +34,13 @@ export default function RegisterPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Create your account</CardTitle> <CardTitle>Create your account</CardTitle>
</CardHeader> </CardHeader>
@@ -78,5 +84,7 @@ export default function RegisterPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function SettingsPage() {
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
async function changePassword(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (next.length < 8) {
setMsg({ kind: "err", text: "New password must be at least 8 characters." });
return;
}
if (next !== confirm) {
setMsg({ kind: "err", text: "New passwords don't match." });
return;
}
setBusy(true);
const { error } = await api.POST("/api/v1/auth/change-password", {
body: { current_password: current, new_password: next },
});
setBusy(false);
if (error) {
setMsg({ kind: "err", text: "Current password is incorrect." });
return;
}
setCurrent("");
setNext("");
setConfirm("");
setMsg({ kind: "ok", text: "Password changed." });
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div>
<span className="text-[var(--muted)]">Name: </span>
{me?.display_name ?? "—"}
</div>
<div>
<span className="text-[var(--muted)]">Email: </span>
{me?.email ?? "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Change password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={changePassword} className="flex max-w-sm flex-col gap-3">
<Input
type="password"
placeholder="Current password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Input
type="password"
placeholder="New password (min 8 chars)"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
<Input
type="password"
placeholder="Confirm new password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{msg && (
<p className={msg.kind === "ok" ? "text-sm text-bronze" : "text-sm text-red-600"}>
{msg.text}
</p>
)}
<Button type="submit" disabled={busy || !current || !next}>
{busy ? "Saving…" : "Change password"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Your data</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted)]">
Full-account export, restore, and account deletion are coming next.
</p>
</CardContent>
</Card>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GedcomImport } from "@/components/gedcom-import";
export default function TreeGedcomPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
async function exportGed() {
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
if (!resp.ok) return;
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "tree.ged";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Import &amp; export GEDCOM</h1>
<GedcomImport fixedTreeId={treeId} />
<Card>
<CardHeader>
<CardTitle className="text-base">Export this tree</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Download this tree as a GEDCOM file people, relationships, events, and sources.
</p>
<Button variant="outline" onClick={exportGed}>
Download .ged
</Button>
</CardContent>
</Card>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Media = components["schemas"]["MediaRead"];
function humanSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export default function MediaPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [items, setItems] = useState<Media[]>([]);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/media", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setItems(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
// Plain fetch for multipart (same origin → cookie auth via Caddy).
await fetch(`/api/v1/trees/${treeId}/media`, {
method: "POST",
body: fd,
credentials: "include",
});
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
load();
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Media</h1>
<div>
<input
ref={fileRef}
type="file"
onChange={onFile}
className="hidden"
id="media-upload"
/>
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? "Uploading…" : "Upload file"}
</Button>
</div>
</div>
{items.length === 0 ? (
<p className="text-[var(--muted)]">
No media yet upload scans, photos, or documents and attach them to facts.
</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{items.map((m) => (
<Card key={m.id} className="overflow-hidden">
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
{m.content_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.url ?? ""}
alt={m.title ?? m.original_filename}
className="aspect-square w-full object-cover"
/>
) : (
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] text-3xl font-serif text-bronze">
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
</div>
)}
</a>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium" title={m.original_filename}>
{m.title ?? m.original_filename}
</div>
<div className="text-xs text-[var(--muted)]">{humanSize(m.byte_size)}</div>
</div>
<button
onClick={() => remove(m.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+424 -57
View File
@@ -2,35 +2,66 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() { function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const [persons, setPersons] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [given, setGiven] = useState(""); const [rels, setRels] = useState<Relationship[]>([]);
const [surname, setSurname] = useState(""); const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[] | null>(null); // server fuzzy search
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
}); });
if (response.status === 401) { if (p.response.status === 401) {
router.push("/login"); router.push("/login");
return; return;
} }
setPersons(data ?? []); const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
@@ -38,67 +69,403 @@ export default function TreeDetailPage() {
load(); load();
}, [load]); }, [load]);
async function addPerson(e: React.FormEvent) { // Debounced server-side fuzzy search (pg_trgm) across the whole tree.
e.preventDefault(); useEffect(() => {
if (!given.trim() && !surname.trim()) return; const q = search.trim();
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { if (!q) {
params: { path: { tree_id: treeId } }, setResults(null);
body: { given: given || null, surname: surname || null }, return;
});
if (!error) {
setGiven("");
setSurname("");
load();
} }
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q } },
});
setResults(data ?? []);
}, 250);
return () => clearTimeout(t);
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
rels
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
const years = useMemo(() => {
const m = new Map<string, string>();
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
for (const p of people) {
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}${parts[1]}`.replace(/^$/, ""));
}
return m;
}, [people, events]);
async function addPerson(name: string): Promise<string | null> {
const { given, surname } = splitName(name);
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
return data?.id ?? null;
}
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load();
}
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) await createLink(adding.kind, adding.anchor, newId);
setAdding(null);
setAddName("");
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (people.length === 0) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Start your tree</h1>
<Card>
<CardContent className="p-6">
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
<Input
className="w-64"
placeholder="First person's full name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<Button type="submit">Add person</Button>
</form>
</CardContent>
</Card>
</div>
);
}
const focus = focusId ? byId.get(focusId) : undefined;
if (!focus) {
setFocusId(people[0].id);
return null;
}
const PersonBox = ({
id,
muted,
}: {
id: string;
muted?: boolean;
}) => {
const p = byId.get(id);
if (!p) return null;
const isFocus = id === focusId;
return (
<button
onClick={() => setFocusId(id)}
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
isFocus
? "border-bronze bg-bronze/[0.08]"
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
} ${muted ? "opacity-90" : ""}`}
>
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
</button>
);
};
const AddSlot = ({
formKey,
kind,
anchor,
label,
}: {
formKey: string;
kind: AddKind;
anchor: string;
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input
autoFocus
className="h-9"
placeholder="Search existing or type a new name"
value={addName}
onChange={(e) => setAddName(e.target.value)}
/>
{addName.trim() && (
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
{people
.filter(
(p) =>
p.id !== anchor &&
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
)
.slice(0, 6)
.map((p) => (
<button
key={p.id}
type="button"
onClick={() => linkExisting(p.id)}
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
>
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button>
</div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form>
) : (
<button
onClick={() => {
setAdding({ key: formKey, kind, anchor });
setAddName("");
}}
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
>
+ {label}
</button>
);
// Recursive ancestor chart (grows rightward): a node is its box plus a
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
// focus, capped at grandparents (depth 2).
const renderNode = (
slotPersonId: string | null,
childId: string,
keyPrefix: string,
depth: number,
): React.ReactNode => {
const box = slotPersonId ? (
<PersonBox id={slotPersonId} muted={depth > 0} />
) : (
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
);
if (!slotPersonId || depth >= 2) {
return <div className="ped-person">{box}</div>;
}
const ps = parentsOf(slotPersonId);
return (
<div className="ped-person">
<div className="ped-self">{box}</div>
<div className="ped-branch">
<div className="ped-leaf">
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
</div>
<div className="ped-leaf">
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
</div>
</div>
</div>
);
};
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
// mistaken delete — surface them so they're not lost in the directory.
const connected = new Set<string>();
for (const r of rels) {
connected.add(r.person_from_id);
connected.add(r.person_to_id);
}
const unconnected = people
.filter((p) => !connected.has(p.id))
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
// Server fuzzy results when searching; otherwise the loaded set.
const directory = results ?? sorted;
const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline"> <h1 className="text-2xl font-semibold">Family view</h1>
All trees <Link
</Link> href={`/trees/${treeId}/persons/${focus.id}`}
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline"> className="text-sm text-bronze hover:underline"
Sources >
Open {focus.primary_name ?? "person"}
</Link> </Link>
</div> </div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card> <Card>
<CardHeader> <CardContent className="overflow-x-auto p-6">
<CardTitle className="text-base">Add a person</CardTitle> <div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardHeader>
<CardContent>
<form onSubmit={addPerson} className="flex gap-2">
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
<Button type="submit">Add</Button>
</form>
</CardContent> </CardContent>
</Card> </Card>
<div> {/* Family group: partners + children of the focus */}
<h2 className="mb-2 text-lg font-semibold">People</h2> <div className="grid gap-5 sm:grid-cols-2">
{persons.length === 0 ? ( <Card>
<p className="text-[var(--muted)]">No people yet.</p> <CardContent className="space-y-3 p-6">
) : ( <h2 className="font-serif text-base font-semibold">Spouses &amp; partners</h2>
<ul className="space-y-2"> <div className="flex flex-wrap gap-3">
{persons.map((person) => ( {partners.map((id) => (
<li key={person.id}> <PersonBox key={id} id={id} muted />
<Link href={`/trees/${treeId}/persons/${person.id}`}> ))}
<Card className="transition-colors hover:border-bronze/50"> <AddSlot
<CardContent className="p-4"> formKey={`partner-${focus.id}`}
{person.primary_name ?? ( kind="partner"
<span className="text-[var(--muted)]">Unnamed</span> anchor={focus.id}
)} label="add spouse"
</CardContent> />
</Card> </div>
</Link> </CardContent>
</li> </Card>
))}
</ul> <Card>
)} <CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Children</h2>
<div className="flex flex-wrap gap-3">
{children.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot
formKey={`child-${focus.id}`}
kind="child"
anchor={focus.id}
label="add child"
/>
</div>
</CardContent>
</Card>
</div>
{/* Unconnected people — not linked to anyone in the tree */}
{unconnected.length > 0 && (
<Card className="border-bronze/40">
<CardContent className="space-y-3 p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-base font-semibold">
Not connected to anyone ({unconnected.length})
</h2>
<span className="text-xs text-[var(--muted)]">
Open one and add a relationship, or delete it.
</span>
</div>
<div className="flex flex-wrap gap-3">
{unconnected.slice(0, 60).map((p) => (
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}`}
className="text-xs text-bronze hover:underline"
>
open
</Link>
</div>
))}
</div>
{unconnected.length > 60 && (
<p className="text-xs text-[var(--muted)]">
Showing 60 of {unconnected.length}.
</p>
)}
</CardContent>
</Card>
)}
{/* Scrollable, searchable people directory (scales to large trees) */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
<Input
className="w-64"
placeholder="Search by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Card className="overflow-hidden">
<div className="max-h-96 overflow-y-auto">
{shown.length === 0 ? (
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
) : (
shown.map((p, i) => (
<button
key={p.id}
onClick={() => setFocusId(p.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
i > 0 ? "border-t border-[var(--border)]" : ""
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
>
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">
{years.get(p.id) ?? ""}
</span>
</button>
))
)}
</div>
{directory.length > shown.length && (
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
Showing {shown.length} of {directory.length} refine your search to narrow.
</div>
)}
</Card>
</div> </div>
</div> </div>
); );
@@ -9,8 +9,11 @@ import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PersonCombobox } from "@/components/person-combobox";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Name = components["schemas"]["NameRead"];
type Me = components["schemas"]["UserRead"];
type Event = components["schemas"]["EventRead"]; type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
type Qualifier = components["schemas"]["ParentChildQualifier"]; type Qualifier = components["schemas"]["ParentChildQualifier"];
@@ -22,6 +25,79 @@ type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"]; const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are
// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch).
const NAME_TYPES: { value: string; label: string }[] = [
{ value: "birth", label: "Birth / maiden" },
{ value: "married", label: "Married" },
{ value: "alias", label: "Also known as" },
{ value: "nickname", label: "Nickname" },
{ value: "religious", label: "Religious" },
{ value: "immigration", label: "Anglicized" },
];
const nameTypeLabel = (t: string) =>
NAME_TYPES.find((n) => n.value === t)?.label ?? t;
const formatName = (n: Name) =>
[n.given, n.surname].filter(Boolean).join(" ") || "—";
// Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
export default function PersonDetailPage() { export default function PersonDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string; personId: string }>(); const params = useParams<{ id: string; personId: string }>();
@@ -30,6 +106,9 @@ export default function PersonDetailPage() {
const [person, setPerson] = useState<Person | null>(null); const [person, setPerson] = useState<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [names, setNames] = useState<Name[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]); const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]); const [sources, setSources] = useState<Source[]>([]);
@@ -37,12 +116,45 @@ export default function PersonDetailPage() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth"); const [evType, setEvType] = useState("birth");
const [evDate, setEvDate] = useState(""); const [evTypeOther, setEvTypeOther] = useState("");
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
// Add-name form + inline edit.
const [nameType, setNameType] = useState("married");
const [nGiven, setNGiven] = useState("");
const [nSurname, setNSurname] = useState("");
const [editNameId, setEditNameId] = useState<string | null>(null);
const [enType, setEnType] = useState("married");
const [enGiven, setEnGiven] = useState("");
const [enSurname, setEnSurname] = useState("");
// Delete confirmation (with optional cascade to descendants).
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`). // Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null); const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState(""); const [citeSource, setCiteSource] = useState("");
@@ -57,8 +169,13 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, ev, rl, src, cit] = await Promise.all([ const [all, nm, mine, tr, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/users/me"),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
}), }),
@@ -69,6 +186,9 @@ export default function PersonDetailPage() {
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []); setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []); setSources(src.data ?? []);
@@ -99,13 +219,24 @@ export default function PersonDetailPage() {
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!evType.trim()) return; const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { event_type: evType, person_id: personId, date_value: evDate || null }, body: { event_type, person_id: personId, date_value, date_start, date_precision },
}); });
if (!error) { if (!error) {
setEvDate(""); setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
load(); load();
} }
} }
@@ -116,6 +247,33 @@ export default function PersonDetailPage() {
load(); load();
} }
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) { async function addRel(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!relOther) return; if (!relOther) return;
@@ -166,9 +324,113 @@ export default function PersonDetailPage() {
load(); load();
} }
async function removePerson(cascade: boolean) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } },
});
router.push(`/trees/${treeId}`);
}
async function addName(e: React.FormEvent) {
e.preventDefault();
if (!nGiven.trim() && !nSurname.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { name_type: nameType, given: nGiven || null, surname: nSurname || null },
});
if (!error) {
setNGiven("");
setNSurname("");
setNameType("married");
load();
}
}
function startEditName(n: Name) {
setEditNameId(n.id);
setEnType(n.name_type);
setEnGiven(n.given ?? "");
setEnSurname(n.surname ?? "");
}
async function saveName() {
if (!editNameId) return;
const { error } = await api.PATCH(
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}",
{
params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } },
body: { name_type: enType, given: enGiven || null, surname: enSurname || null },
},
);
if (!error) {
setEditNameId(null);
load();
}
}
async function makePrimaryName(id: string) {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
body: { is_primary: true },
});
load();
}
async function removeName(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
});
load();
}
async function setSelf(link: boolean) {
await api.PATCH("/api/v1/users/me/self-person", {
body: { self_person_id: link ? personId : null },
});
load();
}
async function setDefaultPerson(make: boolean) {
await api.PATCH("/api/v1/trees/{tree_id}", {
params: { path: { tree_id: treeId } },
body: { home_person_id: make ? personId : null },
});
load();
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
const isSelf = me?.self_person_id === personId;
const isDefault = tree?.home_person_id === personId;
// Inline "cite" control: a badge with count, a toggle, and the picker form. // Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) { function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return ( return (
@@ -269,10 +531,246 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
<div className="flex flex-wrap items-center justify-between gap-2"> {editingPerson ? (
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <form
{citeControl("p", { person_id: personId }, personCites)} onSubmit={(e) => {
</div> e.preventDefault();
savePerson();
}}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<select className={fieldCls} value={pGender} onChange={(e) => setPGender(e.target.value)}>
<option value="">Gender: </option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
{person.primary_name ?? "Unnamed person"}
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
</span>
)}
{isDefault && (
<span className="rounded-full border border-bronze/40 px-2.5 py-1 text-xs font-medium text-bronze">
Default person
</span>
)}
</h1>
<div className="flex flex-wrap items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
{isSelf ? (
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
Unlink me
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setSelf(true)}>
This is me
</Button>
)}
{!isDefault && (
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
Set as default
</Button>
)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
Delete
</Button>
</div>
</div>
)}
{confirmingDelete && (
<div className="space-y-3 rounded-lg border border-bronze/40 bg-bronze/[0.05] p-4">
<p className="text-sm">
Delete <strong>{person.primary_name ?? "this person"}</strong>? Their relationships
will be removed too. This can be undone from Recovery.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => removePerson(false)}>
Delete only this person
</Button>
<Button variant="outline" size="sm" onClick={() => removePerson(true)}>
Delete with all descendants
</Button>
<button
type="button"
onClick={() => setConfirmingDelete(false)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Names</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{names.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No names yet.</p>
) : (
<ul className="space-y-2">
{names.map((n) =>
editNameId === n.id ? (
<li key={n.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveName();
}}
className="flex flex-wrap items-center gap-2"
>
<select
className={fieldCls}
value={enType}
onChange={(e) => setEnType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<Input
className="h-9 w-36"
placeholder="Given"
value={enGiven}
onChange={(e) => setEnGiven(e.target.value)}
/>
<Input
className="h-9 w-36"
placeholder="Surname"
value={enSurname}
onChange={(e) => setEnSurname(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditNameId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li
key={n.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span className="font-medium">{formatName(n)}</span>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{nameTypeLabel(n.name_type)}
</span>
{n.is_primary && (
<span className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze">
primary
</span>
)}
</span>
<span className="flex items-center gap-3">
{!n.is_primary && (
<button
onClick={() => makePrimaryName(n.id)}
className="text-xs text-bronze hover:underline"
>
make primary
</button>
)}
<button
onClick={() => startEditName(n)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeName(n.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addName} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<select
className={fieldCls}
value={nameType}
onChange={(e) => setNameType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Given</span>
<Input
className="h-9 w-36"
placeholder="Given"
value={nGiven}
onChange={(e) => setNGiven(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Surname</span>
<Input
className="h-9 w-36"
placeholder="Surname"
value={nSurname}
onChange={(e) => setNSurname(e.target.value)}
/>
</label>
<Button type="submit">Add name</Button>
</form>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -283,31 +781,162 @@ export default function PersonDetailPage() {
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) =>
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm"> editId === ev.id ? (
<span> <li key={ev.id}>
<span className="font-medium capitalize">{ev.event_type}</span> <form
{ev.date_value ? ( onSubmit={(e) => {
<span className="text-[var(--muted)]"> {ev.date_value}</span> e.preventDefault();
) : null} saveEdit();
</span> }}
<span className="flex items-center gap-3"> className="flex flex-wrap items-end gap-2"
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
> >
× <select
</button> className={`${fieldCls} capitalize`}
</span> value={edType}
</li> onChange={(e) => setEdType(e.target.value)}
))} >
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} /> <label className="flex flex-col gap-1">
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} /> <span className="text-xs text-[var(--muted)]">Event</span>
<select
className={`${fieldCls} capitalize`}
value={evType}
onChange={(e) => setEvType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
</label>
{evType === "other" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<Input
className="h-9 w-36"
placeholder="Custom"
value={evTypeOther}
onChange={(e) => setEvTypeOther(e.target.value)}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Day</span>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="—"
value={dateDay}
onChange={(e) => setDateDay(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Month</span>
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Year</span>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="YYYY"
value={dateYear}
onChange={(e) => setDateYear(e.target.value)}
/>
</label>
<Button type="submit">Add event</Button> <Button type="submit">Add event</Button>
</form> </form>
</CardContent> </CardContent>
@@ -344,14 +973,12 @@ export default function PersonDetailPage() {
<option value="partner">partner</option> <option value="partner">partner</option>
<option value="sibling">sibling</option> <option value="sibling">sibling</option>
</select> </select>
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}> <PersonCombobox
<option value=""> person </option> people={others}
{others.map((p) => ( value={relOther}
<option key={p.id} value={p.id}> onChange={setRelOther}
{p.primary_name ?? "Unnamed"} placeholder="Search for a person…"
</option> />
))}
</select>
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}> <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => ( {QUALIFIERS.map((q) => (
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Person = components["schemas"]["PersonRead"];
export default function RecoveryPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { deleted: true } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setPeople(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
params: { path: { tree_id: treeId, person_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Recently deleted</h1>
<p className="text-sm text-[var(--muted)]">
Deleted people are recoverable for 30 days, then permanently purged.
</p>
{people.length === 0 ? (
<p className="text-[var(--muted)]">Nothing here.</p>
) : (
<ul className="space-y-2">
{people.map((p) => (
<li key={p.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+880
View File
@@ -0,0 +1,880 @@
.f3 {
--female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172);
--genderless-color: lightgray;
--background-color: rgb(33, 33, 33);
--text-color: #fff;
font-family: 'Roboto', sans-serif;
}
.f3 * {
box-sizing: border-box;
}
.f3 .cursor-pointer {
cursor: pointer;
}
.f3 svg.main_svg {
width: 100%;
height: 100%;
}
.f3 svg.main_svg text {
fill: currentColor;
}
.f3 rect.card-female, .f3 .card-female .card-body-rect, .f3 .card-female .text-overflow-mask {
fill: var(--female-color);
}
.f3 rect.card-male, .f3 .card-male .card-body-rect, .f3 .card-male .text-overflow-mask {
fill: var(--male-color);
}
.f3 .card-genderless .card-body-rect, .f3 .card-genderless .text-overflow-mask {
fill: var(--genderless-color);
}
.f3 .card_add .card-body-rect {
fill: #3b5560;
stroke-width: 4px;
stroke: #fff;
cursor: pointer;
}
.f3 g.card_add text {
fill: #fff;
}
.f3 .card-main-outline {
stroke: currentColor;
stroke-width: 3px;
}
.f3 .card_family_tree rect {
transition: 0.3s;
}
.f3 .card_family_tree:hover rect {
transform: scale(1.1);
}
.f3 .card_add_relative {
cursor: pointer;
color: #fff;
transition: 0.3s;
}
.f3 .card_add_relative circle {
fill: rgba(0, 0, 0, 0);
}
.f3 .card_add_relative:hover {
color: black;
}
.f3 .card_edit.pencil_icon {
color: #fff;
transition: 0.3s;
}
.f3 .card_edit.pencil_icon:hover {
color: black;
}
.f3 .card_break_link, .f3 .link_upper, .f3 .link_lower, .f3 .link_particles {
transform-origin: 50% 50%;
transition: 1s;
}
.f3 .card_break_link {
color: #fff;
}
.f3 .card_break_link.closed .link_upper {
transform: translate(-140.5px, 655.6px);
}
.f3 .card_break_link.closed .link_upper g {
transform: rotate(-58deg);
}
.f3 .card_break_link.closed .link_particles {
transform: scale(0);
}
.f3 .input-field input {
height: 2.5rem !important;
}
.f3 .input-field > label:not(.label-icon).active {
-webkit-transform: translateY(-8px) scale(0.8);
transform: translateY(-8px) scale(0.8);
}
.f3.f3-cont {
width:100%;
height:900px;
max-height:70vh;
background-color: var(--background-color);
color: var(--text-color);
}
.f3 {
position: relative;
display: flex;
}
/* form-info */
.f3-form input[type="text"],
.f3-form textarea,
.f3-form select {
width: 100%;
padding: 8px 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
background: var(--background-color);
color: currentColor;
}
.f3-form input[type="text"]:focus,
.f3-form textarea:focus,
.f3-form select:focus {
box-shadow: 0 0 5px rgba(76, 175, 80, 0.2);
}
.f3-form button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin: 10px 0;
transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-form button[type="submit"] {
background-color: #4CAF50;
color: white;
}
.f3-cancel-btn {
background-color: #ccc;
}
.f3-form .f3-delete-btn {
background-color: transparent;
border: 1px solid #f44336;
color: #f44336;
width: 100%;
padding: 5px 10px;
}
.f3-delete-btn:hover {
background-color: #da190b;
border-color: #da190b;
color: #fff;
}
.f3-delete-btn:disabled {
opacity: 0.5;
background-color: transparent;
color: #f44336;
cursor: not-allowed;
}
.f3-form .f3-remove-relative-btn {
background-color: transparent;
border: 1px solid currentColor;
color: currentColor;
width: 100%;
padding: 5px 10px;
}
.f3-remove-relative-btn:hover, .f3-remove-relative-btn.active {
background-color: var(--text-color);
border-color: var(--text-color);
color: var(--background-color);
}
.f3-radio-group {
margin: 15px 0;
}
.f3-radio-group label {
margin-right: 15px;
cursor: pointer;
}
.f3-radio-group input[type="radio"] {
margin-right: 5px;
}
.f3-info-field-label, .f3-form-field label {
font-weight: bold;
font-size: 12px;
display: block;
opacity: 0.8;
}
.f3-info-field-value {
font-weight: normal;
display: block;
border: none;
outline: none;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 1px;
margin-bottom: 10px;
min-height: 18px;
}
.f3-form-buttons {
text-align: right;
}
.f3-form-title {
text-align: center;
}
.f3-form.non-editable .f3-form-buttons,
.f3-form.non-editable .f3-delete-btn,
.f3-form.non-editable .f3-remove-relative-btn,
.f3-form.non-editable .f3-link-existing-relative {
display: none;
}
.f3-close-btn {
cursor: pointer;
position: absolute;
left: 10px;
top: 8px;
font-size: 30px;
color: var(--text-color);
}
.f3-edit-btn {
position: relative;
top: -1px;
width: 24px;
height: 24px;
cursor: pointer;
display: inline-block;
}
.f3-add-relative-btn {
cursor: pointer;
width: 27px;
height: 27px;
margin-right: 5px;
display: inline-block;
}
/* card-html */
.f3 div.card {
cursor: pointer;
color: var(--text-color);
position: relative;
line-height: 1.2;
}
.f3 div.card-image-circle {
border-radius: 50%;
padding: 5px;
width: 90px;
height: 90px;
}
.f3 div.card-image-circle div.card-label {
position: absolute;
bottom: -10px;
left: 50%;
transform: translate(-50%, 50%);
max-width: 150%;
min-height: 22px;
text-align: center;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 3px;
padding: 0 5px;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle svg {
width: 100%;
height: 100%;
padding: 5px;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-rect {
padding: 5px;
border-radius: 3px;
width: 120px;
min-height: 70px;
overflow: hidden;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.card-image-rect {
width: 200px;
min-height: 70px;
display: flex;
align-items: center;
border-radius: 5px;
}
.f3 div.card-image-rect .person-icon {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
}
.f3 div.card-image-rect img {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
border-radius: 8px;
}
.f3 div.card-image-rect svg {
object-fit: cover;
width: 100%;
height: 100%;
padding: 5px;
border-radius: 7px;
}
.f3 div.card-image-rect div.card-label {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.mini-tree {
text-align: right;
position: absolute;
top: -15px;
right: -2px;
z-index: -1;
}
.f3 div.mini-tree svg {
width: 55px;
}
.f3 .f3-card-duplicate-tag {
position: absolute;
top: 2px;
right: 2px;
color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0);
border-radius: 50%;
padding: 2px;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px);
outline: 4px solid rgb(255, 251, 220);
}
.f3 .f3-card-duplicate-hover .f3-card-duplicate-tag {
background-color: rgba(255, 251, 220, .8);
color: #000;
}
.f3 .f3-remove-relative-active .card {
background-color: var(--background-color);
}
.f3 .f3-remove-relative-active .card-inner {
transition: border 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
opacity: .75;
}
.f3 .f3-remove-relative-active .card:hover .card-inner {
opacity: .25;
}
.f3 .f3-remove-relative-active .card-male.card-depth--1:hover .card-inner {
transform: translate(-8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth--1:hover .card-inner {
transform: translate(8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth-0:hover .card-inner {
transform: translate(8px, 0);
}
.f3 .f3-remove-relative-active .card.card-male.card-depth-0:hover .card-inner {
transform: translate(-8px, 0);
}
.f3 .f3-remove-relative-active .card.card-depth-1:hover .card-inner {
transform: translate(0, 8px);
}
.f3 .f3-remove-relative-active .card.card-main .card-inner {
transform: translate(0, 0)!important;
opacity: 1!important;
}
.f3 div.card > div {
transition: transform 0.2s ease-in-out;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.8);
}
.f3 .card-inner {
outline: 0px solid rgba(255, 255, 255, 1);
transition: outline 0.5s ease-in-out;
}
.f3 div.card-female .card-inner, .f3 div.card-female .person-icon svg {
background-color: var(--female-color);
}
.f3 div.card-male .card-inner, .f3 div.card-male .person-icon svg {
background-color: var(--male-color);
}
.f3 div.card-genderless .card-inner, .f3 div.card-genderless .person-icon svg {
background-color: var(--genderless-color);
}
.f3 div.card-new-rel .card-inner, .f3 div.card-new-rel .person-icon svg {
background-color: var(--background-color);
}
.f3 div.card-to-add .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-to-add .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-to-add .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-to-add.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-to-add.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card-unknown .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-unknown .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-unknown .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-unknown.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-unknown.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card:hover > div {
transform: translate(0, -2px);
}
.f3 div.card-main .card-inner, .f3 div.card:hover .card-inner {
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.8);
}
.f3 div.card-main .card-inner {
outline: 4px solid rgba(220, 220, 220, 1);
}
.f3 div.card-inner.f3-path-to-main {
outline: 4px solid rgba(255, 255, 255, 1);
}
.f3 .link {
transition: stroke-width 0.2s ease-in-out;
}
.f3 .link.f3-path-to-main {
stroke-width: 4px;
}
.f3-form-cont {
position: relative;
z-index: 6;
right: 0;
top: 0;
width: 0;
height: 100%;
background-color: var(--background-color);
overflow: auto;
flex: 0 0 auto;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
}
.f3-form-cont.opened {
width: 350px;
}
.f3-form {
padding: 20px;
}
.f3-form hr {
border-style: solid;
border-width: thin 0 0 0;
opacity: 0.15;
}
.f3-nav-cont {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
}
.f3-history-controls {
padding: 8px 5px 7px 9px;
display: inline-block;
position: relative;
z-index: 2;
}
.f3-back-button, .f3-forward-button {
width: 30px;
height: 30px;
transition: opacity 0.3s ease;
cursor: pointer;
display: inline-block;
background-color: transparent;
border: none;
margin-right: 10px;
color: currentColor;
}
.f3-history-controls svg {
height: 100%;
}
.f3-back-button.disabled, .f3-forward-button.disabled {
opacity: 0.5;
}
.f3-modal {
display: none;
position: absolute;
z-index: 10;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
}
.f3-modal-content {
position: relative;
background-color: var(--background-color);
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 5px;
width: 500px;
max-width: 90%;
}
.f3-modal-close {
color: #aaa;
position: absolute;
right: 10px;
top: 7px;
font-size: 28px;
font-weight: bold;
}
.f3-modal-close:hover,
.f3-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-popup {
position: fixed;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.8);
}
.f3-popup-content {
position: relative;
background-color: var(--background-color);
border: 1px solid #888;
border-radius: 5px;
overflow: hidden;
width: 100%;
height: 100%;
}
.f3-popup-nav {
height: 20px;
}
.f3-popup-content-inner {
width: 100%;
height: 100%;
}
.f3-popup-close {
color: #aaa;
position: absolute;
z-index: 4;
right: 6px;
top: 1px;
font-size: 28px;
font-weight: bold;
line-height: 1;
}
.f3-popup-close:hover,
.f3-popup-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-btn {
position: relative;
cursor: pointer;
padding: 5px 10px;
overflow: hidden;
border-width: 0;
outline: none;
border-radius: 3px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
background-color: var(--text-color);
color: var(--background-color);
transition: background-color .3s;
font-size: 14px;
}
.f3-btn:hover, .f3-btn:focus {
background-color: var(--background-color);
color: var(--text-color);
}
.f3-female-bg {
background-color: var(--female-color);
}
.f3-male-bg {
background-color: var(--male-color);
}
.f3-genderless-bg {
background-color: var(--genderless-color);
}
.f3-female-color {
color: var(--female-color);
}
.f3-male-color {
color: var(--male-color);
}
.f3-genderless-color {
color: var(--genderless-color);
}
.f3-autocomplete-cont {
position: relative;
display: inline-block;
z-index: 2;
font-size: 14px;
width: 200px;
}
.f3-autocomplete input {
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: var(--background-color);
color: var(--text-color);
padding: 10px;
width: 100%;
}
.f3-autocomplete input:focus {
outline: none;
}
.f3-autocomplete-toggle {
position: absolute;
right: 10px;
top: 10px;
cursor: pointer;
color: var(--text-color);
transition: color 0.3s ease-in-out;
width: 20px;
}
.f3-autocomplete-items {
border: 1px solid rgba(255, 255, 255, 0.2);
border-top: none;
overflow-y: auto;
max-height: 0;
background-color: var(--background-color);
transition: max-height 0.3s ease-in-out;
}
.f3-autocomplete.active .f3-autocomplete-items {
max-height: 300px;
}
.f3-autocomplete-item > div {
padding: 10px;
cursor: pointer;
background-color: var(--background-color);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-autocomplete-item > div:hover, .f3-autocomplete-item.f3-selected > div {
background-color: var(--text-color);
color: var(--background-color);
}
.f3-autocomplete-active {
background-color: DodgerBlue !important;
color: #ffffff;
}
.f3-kinship-info {
padding: 10px 20px;
}
.f3-kinship-info .f3-info-field {
color:#b3b01e
}
.f3-kinship-info-icon {
cursor:pointer;
display:inline-block;
width:18px;
height:18px;
color:#04a4f4;
position:relative;
top:4px;
left:2px;
}
.f3-kinship-info .f3 {
width:100%;
height: 100%;
position:relative;
background-color:rgb(33,33,33);
color:#fff;
}
.f3 .f3-kinship-info .card-kinship-self {
min-height: 0px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: var(--background-color) !important;
border: solid 3px;
color: #437fae;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-self.f3-real-label {
width: 150px;
height: 50px;
border-radius: 50px;
}
.f3 .f3-kinship-info .card-kinship-rel {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: #1d3456 !important;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-default {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: var(--background-color) !important;
border: solid 1px;
}
.f3-kinship-labels-toggle {
position: absolute;
top: 0;
left: 0;
z-index: 10;
font-size: 12px;
}
.f3-kinship-labels-toggle label {
cursor: pointer;
color: #fff;
font-weight: bold;
text-align: center;
padding: 2px 5px;
}
.f3-kinship-labels-toggle input[type="checkbox"] {
cursor: pointer;
margin-left: 5px;
margin-right: 5px;
margin-top: 5px;
margin-bottom: 5px;
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
import "./chart.css";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FanChart } from "@/components/fan-chart";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
type Mode = "landscape" | "portrait" | "fan";
function splitName(name: string | null | undefined): [string, string] {
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
if (t.length <= 1) return [name ?? "", ""];
return [t.slice(0, -1).join(" "), t[t.length - 1]];
}
export default function TreePage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape");
useEffect(() => {
let cancelled = false;
(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
if (p.response.status === 401) {
router.push("/login");
return;
}
const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
if (cancelled) return;
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
// Open on the tree's default/home person when set, else the first person.
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setStatus(ppl.length ? "ready" : "empty");
})().catch(() => !cancelled && setStatus("error"));
return () => {
cancelled = true;
};
}, [router, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
[rels],
);
const childrenOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id),
[rels],
);
const partnersOf = useCallback(
(id: string) =>
rels
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
[rels],
);
const years = useMemo(() => {
const m = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) m.set(ev.person_id, y);
}
}
return m;
}, [events]);
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]);
const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]);
// family-chart for landscape/portrait. Intentionally not keyed on focusId —
// card clicks recenter via updateMainId without rebuilding the chart.
useEffect(() => {
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false;
(async () => {
// Only link to people that still exist — a soft-deleted person leaves
// dangling relationship rows, and family-chart breaks on an id with no
// matching datum. Filter them out so a deletion never blanks the tree.
const alive = new Set(people.map((pp) => pp.id));
const keep = (ids: string[]) => ids.filter((id) => alive.has(id));
const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name);
return {
id: pp.id,
data: {
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
},
rels: {
spouses: keep(partnersOf(pp.id)),
parents: keep(parentsOf(pp.id)),
children: keep(childrenOf(pp.id)),
},
};
});
const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return;
containerRef.current.innerHTML = "";
const chart = f3.createChart(containerRef.current, data);
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal();
// Show enough generations that a recenter reveals grandparents + children.
chart.setAncestryDepth?.(3);
chart.setProgenyDepth?.(2);
// Default card click recenters the whole hourglass; sync focus for the
// "Open profile" link after every (re)build.
chart.setAfterUpdate?.(() => {
const md = chart.getMainDatum?.();
const id = md?.id ?? md?.data?.id;
if (id) setFocusId(id);
});
chartRef.current = chart;
if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]);
// Jump the tree (or fan) to a person and rebuild the hourglass around them.
const goTo = useCallback(
(id: string) => {
setFocusId(id);
setQuery("");
if (mode !== "fan" && chartRef.current) {
chartRef.current.updateMainId?.(id);
chartRef.current.updateTree?.();
}
},
[mode],
);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return people
.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
.slice(0, 8);
}, [query, people]);
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
<button
onClick={() => setMode(m)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">Tree</h1>
<div className="relative">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find a person…"
className="w-56"
/>
{matches.length > 0 && (
<ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
onClick={() => goTo(p.id)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
<span>{p.primary_name ?? "Unnamed"}</span>
{yearOf(p.id) && (
<span className="text-xs text-[var(--muted)]">{yearOf(p.id)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
<ModeButton m="landscape" label="Landscape" />
<ModeButton m="portrait" label="Portrait" />
<ModeButton m="fan" label="Fan" />
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}
</Link>
)}
</div>
</div>
{status === "empty" && (
<p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
)}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
{status === "ready" && mode === "fan" && focusId ? (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
<FanChart
focusId={focusId}
parentsOf={parentsOf}
nameOf={nameOf}
yearOf={yearOf}
onSelect={setFocusId}
/>
</div>
) : (
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function TreesLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+55 -31
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"]; type Tree = components["schemas"]["TreeRead"];
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() { export default function TreesPage() {
const router = useRouter(); const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]); const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -25,6 +26,8 @@ export default function TreesPage() {
return; return;
} }
setTrees(data ?? []); setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true); setReady(true);
}, [router]); }, [router]);
@@ -42,34 +45,26 @@ export default function TreesPage() {
} }
} }
async function logout() { async function remove(id: string) {
await api.POST("/api/v1/auth/logout"); await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
router.push("/login"); load();
}
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Your trees</h1>
<h1 className="text-2xl font-bold">Your trees</h1>
<Button variant="ghost" size="sm" onClick={logout}>
Sign out
</Button>
</div>
<Card> <Card>
<CardHeader> <CardContent className="p-5">
<CardTitle className="text-base">New tree</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createTree} className="flex gap-2"> <form onSubmit={createTree} className="flex gap-2">
<Input <Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
placeholder="Family name" <Button type="submit">Create tree</Button>
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit">Create</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -77,23 +72,52 @@ export default function TreesPage() {
{trees.length === 0 ? ( {trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p> <p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => ( {trees.map((tree) => (
<li key={tree.id}> <li key={tree.id}>
<Link href={`/trees/${tree.id}`}> <Card className="transition-colors hover:border-bronze/50">
<Card className="transition-colors hover:border-bronze/50"> <CardContent className="flex items-center justify-between p-4">
<CardContent className="flex items-center justify-between p-4"> <Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
<span className="font-medium">{tree.name}</span> <div className="truncate font-medium">{tree.name}</div>
<span className="text-xs uppercase tracking-wide text-bronze"> <div className="text-xs uppercase tracking-wide text-bronze">
{tree.visibility} {tree.visibility}
</span> </div>
</CardContent> </Link>
</Card> <button
</Link> onClick={() => remove(tree.id)}
className="ml-3 text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent>
</Card>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div> </div>
); );
} }
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { Menu } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
/**
* App chrome: a fixed sidebar on md+, and on small screens a top bar with a
* hamburger that opens the same sidebar as a slide-in drawer.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// The tree visualization wants the whole canvas; everything else reads better
// in a centered, max-width column.
const fullWidth = /^\/trees\/[^/]+\/tree$/.test(pathname);
return (
<div className="flex min-h-screen">
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
<AppSidebar />
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Mobile top bar */}
<div className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
<button
onClick={() => setOpen(true)}
aria-label="Open menu"
className="text-[var(--muted)] hover:text-[var(--foreground)]"
>
<Menu className="h-6 w-6" />
</button>
<Link href="/" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
</Link>
</div>
{/* Mobile drawer */}
{open && (
<div className="fixed inset-0 z-50 md:hidden">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setOpen(false)}
aria-hidden
/>
<aside className="absolute left-0 top-0 h-full w-72 max-w-[85%] border-r border-[var(--border)] bg-[var(--surface)] shadow-xl">
<AppSidebar onNavigate={() => setOpen(false)} />
</aside>
</div>
)}
<div
className={
fullWidth
? "w-full px-4 py-6 md:px-6"
: "mx-auto w-full max-w-4xl px-6 py-10 md:px-10"
}
>
{children}
</div>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import {
Archive,
ArrowDownUp,
BookText,
FolderTree,
Image as ImageIcon,
LogOut,
Network,
Settings,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const router = useRouter();
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
const [treeName, setTreeName] = useState<string | null>(null);
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!treeId) {
setTreeName(null);
return;
}
api
.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } })
.then((r) => setTreeName(r.data?.name ?? null));
}, [treeId]);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
async function logout() {
onNavigate?.();
await api.POST("/api/v1/auth/logout");
router.push("/login");
}
const Item = ({
href,
label,
icon: Icon,
active,
}: {
href: string;
label: string;
icon: typeof Users;
active: boolean;
}) => (
<Link
href={href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-bronze/12 font-medium text-bronze"
: "text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
return (
<nav className="flex h-full flex-col gap-1 p-4">
<Link href="/" className="mb-5 flex items-center px-2" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
{treeId && (
<div className="mt-5 flex flex-col gap-1">
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
{treeName ?? "Tree"}
</div>
<Item
href={`/trees/${treeId}/tree`}
label="Tree"
icon={Network}
active={pathname.startsWith(`/trees/${treeId}/tree`)}
/>
<Item
href={`/trees/${treeId}`}
label="People"
icon={Users}
active={pathname === `/trees/${treeId}` || pathname.startsWith(`/trees/${treeId}/persons`)}
/>
<Item
href={`/trees/${treeId}/sources`}
label="Sources"
icon={BookText}
active={pathname.startsWith(`/trees/${treeId}/sources`)}
/>
<Item
href={`/trees/${treeId}/media`}
label="Media"
icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)}
/>
<Item
href={`/trees/${treeId}/gedcom`}
label="Import / Export"
icon={ArrowDownUp}
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
icon={Archive}
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
/>
</div>
)}
<div ref={menuRef} className="relative mt-auto">
{menuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<Link
href="/settings"
onClick={() => {
setMenuOpen(false);
onNavigate?.();
}}
className="flex items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
>
<Settings className="h-4 w-4 shrink-0" />
Settings
</Link>
<button
onClick={logout}
className="flex w-full items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-bronze"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</div>
)}
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-[var(--foreground)] transition-colors hover:bg-bronze/[0.07]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-bronze/15 text-xs font-semibold uppercase text-bronze">
{(me?.display_name || me?.email || "?").slice(0, 1)}
</span>
<span className="min-w-0 flex-1 truncate">
{me?.display_name || me?.email || "Account"}
</span>
</button>
</div>
</nav>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
// Radial fan chart of a focus person's ancestors (family-chart has no fan).
// Each generation is a ring; slot p in generation g descends from slot floor(p/2)
// in g-1. Click a wedge to refocus.
type Props = {
focusId: string;
parentsOf: (id: string) => string[];
nameOf: (id: string) => string;
yearOf: (id: string) => string;
onSelect: (id: string) => void;
generations?: number;
};
const SIZE = 720;
const CENTER = SIZE / 2;
const FOCUS_R = 46;
const SPAN = Math.PI * 1.6; // 288° fan
function polar(r: number, a: number): [number, number] {
// a = 0 points up, increasing clockwise.
return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)];
}
function sector(r0: number, r1: number, a0: number, a1: number): string {
const [x0, y0] = polar(r1, a0);
const [x1, y1] = polar(r1, a1);
const [x2, y2] = polar(r0, a1);
const [x3, y3] = polar(r0, a0);
const large = a1 - a0 > Math.PI ? 1 : 0;
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
}
function clip(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
export function FanChart({
focusId,
parentsOf,
nameOf,
yearOf,
onSelect,
generations = 4,
}: Props) {
const gens: (string | null)[][] = [[focusId]];
for (let g = 1; g <= generations; g++) {
const row: (string | null)[] = [];
for (const slot of gens[g - 1]) {
const ps = slot ? parentsOf(slot) : [];
row.push(ps[0] ?? null, ps[1] ?? null);
}
gens.push(row);
}
const ringT = (CENTER - 60 - FOCUS_R) / generations;
const start = -SPAN / 2;
const wedges: React.ReactNode[] = [];
for (let g = 1; g <= generations; g++) {
const row = gens[g];
const w = SPAN / row.length;
const r0 = FOCUS_R + (g - 1) * ringT;
const r1 = FOCUS_R + g * ringT;
row.forEach((id, i) => {
const a0 = start + i * w;
const a1 = start + (i + 1) * w;
const mid = (a0 + a1) / 2;
const [tx, ty] = polar((r0 + r1) / 2, mid);
let deg = (mid * 180) / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep text upright
wedges.push(
<g
key={`${g}-${i}`}
onClick={() => id && onSelect(id)}
style={{ cursor: id ? "pointer" : "default" }}
>
<path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)"
/>
{id && (
<text
x={tx}
y={ty}
transform={`rotate(${deg} ${tx} ${ty})`}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: g >= 3 ? 9 : 11, fill: "var(--foreground)" }}
>
{clip(nameOf(id), g >= 3 ? 12 : 18)}
</text>
)}
</g>,
);
});
}
const [fx, fy] = [CENTER, CENTER];
return (
<div className="overflow-auto">
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="mx-auto block w-full max-w-3xl">
{wedges}
<circle cx={fx} cy={fy} r={FOCUS_R} fill="var(--color-bronze)" />
<text
x={fx}
y={fy - 4}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 12, fill: "var(--color-paper)", fontWeight: 600 }}
>
{clip(nameOf(focusId), 12)}
</text>
<text
x={fx}
y={fy + 12}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 10, fill: "var(--color-paper)" }}
>
{yearOf(focusId)}
</text>
</svg>
</div>
);
}
+331
View File
@@ -0,0 +1,331 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
type Preview = components["schemas"]["ImportPreview"];
type Dup = components["schemas"]["DuplicateMatch"];
type Tree = components["schemas"]["TreeRead"];
type Action = "new" | "skip" | "merge" | "overwrite";
const ACTIONS: { value: Action; label: string }[] = [
{ value: "new", label: "Import as new" },
{ value: "merge", label: "Merge into existing" },
{ value: "skip", label: "Skip (use existing)" },
{ value: "overwrite", label: "Overwrite existing" },
];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
/**
* GEDCOM import with a selectable destination (a new tree, or an existing one).
* Importing into an existing tree previews likely duplicates so each can be
* resolved (new / skip / merge / overwrite) before anything is written.
*
* @param fixedTreeId When set, the destination defaults to that tree (the
* tree-scoped Import page). When omitted (the global Import page), the user
* picks a destination.
*/
export function GedcomImport({ fixedTreeId }: { fixedTreeId?: string }) {
const [trees, setTrees] = useState<Tree[]>([]);
const [mode, setMode] = useState<"new" | "existing">(fixedTreeId ? "existing" : "new");
const [chosenTreeId, setChosenTreeId] = useState(fixedTreeId ?? "");
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [resolutions, setResolutions] = useState<Record<string, Action>>({});
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.GET("/api/v1/trees").then((r) => setTrees(r.data ?? []));
}, []);
function resetRun() {
setReport(null);
setImportedTreeId(null);
setPreview(null);
setFile(null);
setResolutions({});
}
async function postImport(tid: string, f: File, resolutionsJson?: string) {
const fd = new FormData();
fd.append("file", f);
if (resolutionsJson) fd.append("resolutions", resolutionsJson);
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
setReport(await resp.json());
setImportedTreeId(tid);
}
}
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (fileRef.current) fileRef.current.value = "";
if (!f) return;
setBusy(true);
resetRun();
if (mode === "new") {
const { data } = await api.POST("/api/v1/trees", {
body: { name: newName.trim() || "Imported tree" },
});
if (data) await postImport(data.id, f);
setBusy(false);
return;
}
if (!chosenTreeId) {
setBusy(false);
return;
}
// Existing tree — preview duplicates first.
setFile(f);
const fd = new FormData();
fd.append("file", f);
const resp = await fetch(`/api/v1/trees/${chosenTreeId}/gedcom/preview`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
const pv: Preview = await resp.json();
setPreview(pv);
const init: Record<string, Action> = {};
for (const d of pv.potential_duplicates) init[d.xref] = d.score === "high" ? "merge" : "new";
setResolutions(init);
}
setBusy(false);
}
async function runImport() {
if (!file || !chosenTreeId) return;
setBusy(true);
const map: Record<string, { action: Action; target_id: string }> = {};
for (const d of preview?.potential_duplicates ?? []) {
const action = resolutions[d.xref] ?? "new";
if (action !== "new") map[d.xref] = { action, target_id: d.existing_person_id };
}
await postImport(chosenTreeId, file, JSON.stringify(map));
setPreview(null);
setFile(null);
setBusy(false);
}
const dups = preview?.potential_duplicates ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!preview && (
<>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={mode === "new"}
onChange={() => {
setMode("new");
resetRun();
}}
/>
Import into a <strong>new tree</strong>
</label>
{mode === "new" && (
<Input
className="max-w-xs"
placeholder="New tree name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={mode === "existing"}
onChange={() => {
setMode("existing");
resetRun();
}}
/>
Import into an <strong>existing tree</strong> (checks for duplicates)
</label>
{mode === "existing" && (
<select
className={`${fieldCls} max-w-xs`}
value={chosenTreeId}
onChange={(e) => setChosenTreeId(e.target.value)}
>
<option value=""> choose a tree </option>
{trees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
<input
ref={fileRef}
type="file"
accept=".ged,.gedcom,text/plain"
onChange={onFile}
className="hidden"
/>
<Button
onClick={() => fileRef.current?.click()}
disabled={busy || (mode === "existing" && !chosenTreeId)}
>
{busy ? "Working…" : "Choose GEDCOM file"}
</Button>
</>
)}
{/* Duplicate-resolution step */}
{preview && (
<div className="space-y-4">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
{Object.entries(preview.counts).map(([k, v]) => (
<span key={k}>
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
</span>
))}
</div>
{dups.length === 0 ? (
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm">
No likely duplicates found everyone will be imported as new.
</p>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
{dups.length} possible duplicate{dups.length === 1 ? "" : "s"}
</h3>
<label className="flex items-center gap-2 text-xs text-[var(--muted)]">
Set all to
<select
className={fieldCls}
defaultValue=""
onChange={(e) => {
const a = e.target.value as Action;
const all: Record<string, Action> = {};
for (const d of dups) all[d.xref] = a;
setResolutions(all);
}}
>
<option value="" disabled>
choose
</option>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</label>
</div>
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
{dups.map((d: Dup) => (
<li
key={d.xref}
className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div className="min-w-0">
<span className="font-medium">{d.incoming_name}</span>
{d.incoming_birth_year && (
<span className="text-[var(--muted)]"> b. {d.incoming_birth_year}</span>
)}
<span className="text-[var(--muted)]"> </span>
<span>{d.existing_name}</span>
{d.existing_birth_year && (
<span className="text-[var(--muted)]"> b. {d.existing_birth_year}</span>
)}
<span
className={`ml-2 rounded px-1.5 py-0.5 text-xs ${
d.score === "high"
? "bg-bronze/15 text-bronze"
: "bg-[var(--border)]/50 text-[var(--muted)]"
}`}
>
{d.score}
</span>
</div>
<select
className={fieldCls}
value={resolutions[d.xref] ?? "new"}
onChange={(e) =>
setResolutions((r) => ({ ...r, [d.xref]: e.target.value as Action }))
}
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<Button onClick={runImport} disabled={busy}>
{busy ? "Importing…" : "Run import"}
</Button>
<Button variant="ghost" onClick={resetRun} disabled={busy}>
Cancel
</Button>
</div>
</div>
)}
{report && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<div className="font-medium">Import complete</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
{Object.entries(report.counts).map(([k, v]) => (
<span key={k}>
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
</span>
))}
</div>
{report.unmapped_tags.length > 0 && (
<div className="text-xs text-[var(--muted)]">
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
</div>
)}
{importedTreeId && (
<Link
href={`/trees/${importedTreeId}`}
className="inline-block text-sm text-bronze hover:underline"
>
Open the tree
</Link>
)}
</div>
)}
</CardContent>
</Card>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"];
/**
* A type-to-filter person picker. Shows a text input; as you type, a dropdown
* of matching people appears. Selecting one sets `value` (a person id) and
* fills the input with their name. Replaces a plain <select> when the list is
* long enough that scanning it by hand is painful.
*/
export function PersonCombobox({
people,
value,
onChange,
placeholder = "Search for a person…",
className,
}: {
people: Person[];
value: string;
onChange: (id: string) => void;
placeholder?: string;
className?: string;
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const nameOf = useMemo(
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])),
[people],
);
// Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add).
useEffect(() => {
if (!value) {
setQuery("");
} else if (!open) {
setQuery(nameOf.get(value) ?? "");
}
}, [value, open, nameOf]);
// Close on outside click.
useEffect(() => {
function onDoc(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
const pool = q
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: people;
return pool.slice(0, 10);
}, [query, people]);
const base =
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
return (
<div ref={wrapRef} className="relative">
<input
className={`${base} ${className ?? ""}`}
value={query}
placeholder={placeholder}
onFocus={() => setOpen(true)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
if (value) onChange(""); // typing invalidates the prior pick
}}
/>
{open && matches.length > 0 && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
onChange(p.id);
setQuery(p.primary_name ?? "Unnamed");
setOpen(false);
}}
className={`block w-full px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))] ${
p.id === value ? "text-bronze" : ""
}`}
>
{p.primary_name ?? "Unnamed"}
</button>
</li>
))}
</ul>
)}
</div>
);
}
+1418 -7
View File
File diff suppressed because it is too large Load Diff
+2511 -13
View File
File diff suppressed because it is too large Load Diff
+429
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"family-chart": "^0.9.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"openapi-fetch": "^0.13.0", "openapi-fetch": "^0.13.0",
@@ -1111,12 +1112,390 @@
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true "dev": true
}, },
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true "dev": true
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1134,6 +1513,14 @@
} }
} }
}, },
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1156,6 +1543,14 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/family-chart": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
"integrity": "sha512-+JdLr1Oo+YFnQWUXgdnk4nCMTbe1MXKdpbx3KEBXPeq2oX+2v5ccmrcK39CZ761/zQfgSHFZ2cT/+gbaeeACcA==",
"dependencies": {
"d3": "^7.9.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1181,6 +1576,17 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/index-to-position": { "node_modules/index-to-position": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
@@ -1193,6 +1599,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1734,6 +2148,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+11 -10
View File
@@ -10,23 +10,24 @@
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false" "gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
}, },
"dependencies": { "dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"openapi-fetch": "^0.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^2.6.0", "family-chart": "^0.9.0",
"lucide-react": "^0.469.0" "lucide-react": "^0.469.0",
"next": "^15.1.0",
"openapi-fetch": "^0.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.0", "@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "openapi-typescript": "^7.5.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"openapi-typescript": "^7.5.0" "tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
} }
} }
+2
View File
@@ -0,0 +1,2 @@
declare module "family-chart";
declare module "family-chart/dist/styles/family-chart.css";