mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 08:22:03 +00:00
Merge upstream/main : premières photos, Docker tout-en-un, dépendances
Reprise de upstream (IliesAllali/main) dans la branche Immich. Seul conflit : l'import des icônes dans PersonCard.jsx — les deux lignes sont gardées (ImagePlus pour le portrait vide de « Premières photos », Sparkles pour le bouton « Depuis Immich »). Tout le reste fusionne automatiquement ; l'intégration Immich (routes, exports de media.ts, mapping immichPersonId, panneau) est inchangée. Vérifié après la fusion : tests Immich (16), tests backend focalisés gedcom-photos (4), build tsc, eslint et les 107 tests du front. Seul « rate-limits login endpoint » dépasse son délai de 5 s, déjà le cas sur main avant la fusion (bcrypt, machine lente). Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
commit
47083ec274
37 files changed
+2572
-1566
No files matched your search
@@ -34,6 +34,11 @@ CORS_ORIGIN=http://localhost
|
||||
# MEDIA_QUOTA_PER_TREE_MB=5120
|
||||
# MEDIA_PENDING_QUOTA_PER_TREE_MB=500
|
||||
|
||||
# ── Images ────────────────────────────────────────────────────────────────────
|
||||
# Version des images ghcr.io/iliesallali/parenthese-api et parenthese-web : latest (dernière
|
||||
# version de main), ou une version figée, par exemple 1.0.
|
||||
# PARENTHESE_VERSION=latest
|
||||
|
||||
# ── Web ───────────────────────────────────────────────────────────────────────
|
||||
# Port publié sur la machine hôte (80 = http://localhost sans préciser de port).
|
||||
WEB_PORT=80
|
||||
@@ -182,12 +182,67 @@ jobs:
|
||||
- name: Test de fumée par le port web
|
||||
run: bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel.
|
||||
- name: Front avec une autre adresse d'API
|
||||
run: |
|
||||
WEB_IMAGE="$(docker compose config --images | grep parenthese-web)"
|
||||
API_CONTAINER="$(docker compose ps -q api | xargs docker inspect -f '{{.Name}}' | tr -d /)"
|
||||
docker run -d --name web-upstream --network parenthese_default -p 8081:80 \
|
||||
-e API_UPSTREAM="$API_CONTAINER:4000" "$WEB_IMAGE"
|
||||
sleep 3
|
||||
docker exec web-upstream grep -q "http://$API_CONTAINER:4000" /etc/nginx/conf.d/default.conf
|
||||
bash tools/docker-smoke.sh http://localhost:8081
|
||||
|
||||
- name: État et journaux en cas d'échec
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose ps -a
|
||||
docker compose logs --no-color --timestamps
|
||||
docker logs web-upstream || true
|
||||
|
||||
- name: Arrêt et suppression des volumes
|
||||
if: always()
|
||||
run: docker compose down -v --remove-orphans
|
||||
run: |
|
||||
docker rm -f web-upstream || true
|
||||
docker compose down -v --remove-orphans
|
||||
|
||||
docker-aio:
|
||||
name: Docker tout-en-un (Unraid, NAS)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SMOKE_WAIT_SECONDS: 180
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Construction
|
||||
run: DOCKER_BUILDKIT=1 docker build -f Dockerfile.aio -t parenthese-aio:test .
|
||||
|
||||
- name: Démarrage, sans aucune variable
|
||||
run: docker run -d --name aio -p 8080:80 -v aio-data:/data parenthese-aio:test
|
||||
|
||||
- name: Test de fumée par le port web
|
||||
run: bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Les secrets et la base de /data doivent servir tels quels au démarrage suivant (mise à jour, reboot).
|
||||
- name: Redémarrage puis nouveau test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker restart aio
|
||||
bash tools/docker-smoke.sh http://localhost:8080
|
||||
docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1
|
||||
|
||||
- name: Journaux en cas d'échec
|
||||
if: failure()
|
||||
run: |
|
||||
docker ps -a
|
||||
docker logs --timestamps aio || true
|
||||
docker run --rm -v aio-data:/data busybox cat /data/postgres/postgres.log || true
|
||||
|
||||
- name: Arrêt et suppression du volume
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f aio || true
|
||||
docker volume rm aio-data || true
|
||||
@@ -0,0 +1,153 @@
|
||||
name: Images
|
||||
|
||||
# Publie les images Docker de l'API et du front sur ghcr.io, pour un auto-hébergement sans le code
|
||||
# source : docker-compose.yml + .env suffisent.
|
||||
# - après une CI réussie sur un push sur main : tags `latest` et `sha-<commit>`
|
||||
# - sur un tag de version vX.Y.Z : tags `X.Y.Z` et `X.Y`
|
||||
# Puis l'installation est testée dans un dossier vide, avec ces deux seuls fichiers.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: images-${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and push (${{ matrix.image }})
|
||||
if: >-
|
||||
github.repository == 'IliesAllali/parenthese' &&
|
||||
(
|
||||
github.event_name != 'workflow_run' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == 'IliesAllali/parenthese'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: parenthese-api
|
||||
context: backend
|
||||
file: backend/Dockerfile
|
||||
- image: parenthese-web
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
# Tout-en-un (base, API, front) pour Unraid et les NAS
|
||||
- image: parenthese-aio
|
||||
context: .
|
||||
file: Dockerfile.aio
|
||||
steps:
|
||||
# Sur workflow_run, le commit exact que la CI a validé, pas la tête de main au moment du run.
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Short commit
|
||||
id: commit
|
||||
run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Tags and labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/iliesallali/${{ matrix.image }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'workflow_run' }}
|
||||
type=raw,value=sha-${{ steps.commit.outputs.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
|
||||
|
||||
compose-only:
|
||||
name: Install from the images only
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
path: src
|
||||
persist-credentials: false
|
||||
|
||||
# Tant que les paquets ne sont pas publics, le pull demande une connexion.
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Empty folder with docker-compose.yml and .env only
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir parenthese
|
||||
cp src/docker-compose.yml src/.env.example parenthese/
|
||||
cd parenthese
|
||||
cp .env.example .env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env
|
||||
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$(openssl rand -base64 48)|" .env
|
||||
echo "PARENTHESE_VERSION=sha-$(git -C ../src rev-parse --short=7 HEAD)" >> .env
|
||||
ls -A
|
||||
|
||||
- name: docker compose up -d (pull, no build)
|
||||
working-directory: parenthese
|
||||
run: docker compose up -d --pull always --no-build --wait --wait-timeout 300
|
||||
|
||||
- name: Smoke test through the web port
|
||||
run: bash src/tools/docker-smoke.sh http://localhost
|
||||
|
||||
- name: Containers and logs on failure
|
||||
if: failure()
|
||||
working-directory: parenthese
|
||||
run: |
|
||||
docker compose ps -a
|
||||
docker compose logs --no-color --timestamps
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### First photos
|
||||
|
||||
- Tree creation offers "I already have a tree": the .ged file is imported right away, without creating a "you" entry that would end up duplicated.
|
||||
- GEDCOM import reads the photos each person had in the original software (`OBJE` / `FILE`, inline or by reference, primary photo first). After an import, the owner picks the folder where those photos live and Parenthèse places each portrait by file name. Paths stay on the device, nothing is stored on the server.
|
||||
- After an import, a window suggests five faces to start with: the close family of the person marked "Me", otherwise the most recent generations.
|
||||
- For the owner, an empty portrait invites a photo: the empty frame on a profile is a button outside edit mode, and hovering an empty portrait in the tree shows "+ photo".
|
||||
- Phone photos over 5 MB are scaled down to 2000 px before upload instead of being refused.
|
||||
- Owner portrait uploads are limited to 120 per minute (was 30), still within the tree's storage quota.
|
||||
- Anonymous events: `gedcom_imported`, `portraits_bulk_placed`, `portrait_added` (rounded counts, no names, paths or tree address).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Docker: on a first `docker compose up`, the API now gets 120 s to run its migrations before its health check counts (was 30 s). The web container waits for a healthy API and used to give up, so it had to be started again by hand.
|
||||
- Children of a family with only one known parent (common in GEDCOM files) are linked to that parent, under a single "Unknown" partner, instead of floating in the tree without a link.
|
||||
- Importing a GEDCOM file into a tree that already holds it no longer duplicates everyone: people with the same first name, last name, birth and death dates are reused, and existing unions and parent links are kept.
|
||||
|
||||
## 1.0.3 (2026-10-10)
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- Single-container image `ghcr.io/iliesallali/parenthese-aio` (PostgreSQL, API and front end), for Unraid and NAS systems that install one container per app. It needs no configuration: the database, its password and the session key are created in `/data` on first start. Tested in CI by a fresh start, a restart and the smoke test.
|
||||
- The `parenthese-web` image reads the API address from `API_UPSTREAM` (`api:4000` by default, unchanged for existing installs). Set it when the name `api` is already taken on the Docker network, for example on Umbrel where apps share one network.
|
||||
|
||||
## 1.0.2 (2026-10-09)
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- Docker images for the API and the front end are published on GitHub's registry, for amd64 and arm64: `ghcr.io/iliesallali/parenthese-api` and `ghcr.io/iliesallali/parenthese-web`. `docker-compose.yml` and `.env` are enough, no clone needed. `PARENTHESE_VERSION` in `.env` pins a version (`latest` follows main). Each publication is tested by an install in an empty folder with those two files only.
|
||||
- Creating a tree works on an instance served over plain HTTP on a local network. The browser only offers `crypto.randomUUID` over HTTPS or on localhost, and the sharing password now falls back to `crypto.getRandomValues` (#44, fixed in #45 by @coutadeurf).
|
||||
|
||||
### Security
|
||||
|
||||
- fast-jwt 6.3.4: its verifier cache could accept an expired token that has no `iat` claim.
|
||||
- Build tools only, nothing that runs in the browser or on the server: source-map-js 1.2.2 and postcss-selector-parser 7.1.6 (CPU exhaustion on crafted input).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Landing on Vite 8 and @vitejs/plugin-react 6, with the same target browsers as before, like the app.
|
||||
- Minor and patch updates (lucide, posthog-js, ESLint, PostCSS).
|
||||
|
||||
## 1.0.1 (2026-10-01)
|
||||
|
||||
Security release, from an independent review of 1.0.0.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Parenthèse en un seul conteneur : PostgreSQL, API et front servi par nginx. Contexte de build : la racine du dépôt.
|
||||
# Pour les systèmes qui installent une application par conteneur (Unraid, NAS). docker-compose.yml reste
|
||||
# l'installation de référence, avec un conteneur par service.
|
||||
#
|
||||
# docker run -d --name parenthese -p 8080:80 -v parenthese-data:/data ghcr.io/iliesallali/parenthese-aio
|
||||
#
|
||||
# Tout ce qui doit survivre à une mise à jour vit dans /data : base (postgres/), photos et vidéos (media/),
|
||||
# secrets générés au premier démarrage (secrets/). Voir deploy/aio/entrypoint.sh.
|
||||
|
||||
# Étape 1 : API (mêmes étapes que backend/Dockerfile).
|
||||
FROM node:24-bookworm-slim AS api-build
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
COPY backend/prisma ./prisma
|
||||
RUN npm ci --no-audit --no-fund
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY backend/tsconfig.json ./
|
||||
COPY backend/src ./src
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Étape 2 : front (mêmes étapes que Dockerfile.web).
|
||||
FROM node:24-alpine AS web-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
ENV VITE_API_BASE_URL=""
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Étape 3 : image finale.
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# postgresql-common d'abord, pour qu'il ne crée pas de base par défaut à l'installation de PostgreSQL :
|
||||
# la base vit dans /data et naît au premier démarrage.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-common \
|
||||
&& sed -i 's/^#\?\s*create_main_cluster.*/create_main_cluster = false/' /etc/postgresql-common/createcluster.conf \
|
||||
&& apt-get install -y --no-install-recommends postgresql-15 nginx openssl ca-certificates tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -f /etc/nginx/sites-enabled/default \
|
||||
&& mkdir -p /run/postgresql && chown postgres:postgres /run/postgresql
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& npm cache clean --force
|
||||
|
||||
# CLI Prisma à part, à la version du package-lock (voir backend/Dockerfile).
|
||||
RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version")" \
|
||||
&& mkdir -p /opt/prisma \
|
||||
&& cd /opt/prisma \
|
||||
&& echo '{"private":true}' > package.json \
|
||||
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
|
||||
&& npm cache clean --force
|
||||
|
||||
COPY --from=api-build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY backend/prisma ./prisma
|
||||
COPY --from=api-build /app/dist ./dist
|
||||
|
||||
# nginx : la configuration du conteneur web de compose, l'API étant ici sur la même machine.
|
||||
COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's|http://api:4000|http://127.0.0.1:4000|g' /etc/nginx/conf.d/default.conf \
|
||||
&& ! grep -q 'api:4000' /etc/nginx/conf.d/default.conf
|
||||
COPY --from=web-build /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY deploy/aio/entrypoint.sh /usr/local/bin/parenthese-aio
|
||||
RUN chmod 755 /usr/local/bin/parenthese-aio
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
CHECKPOINT_DISABLE=1 \
|
||||
PATH=/opt/prisma/node_modules/.bin:/usr/lib/postgresql/15/bin:$PATH
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD node -e "fetch('http://127.0.0.1/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/parenthese-aio"]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Contexte de build de Dockerfile.aio (BuildKit lit ce fichier à la place de .dockerignore) :
|
||||
# l'API, le front, la configuration nginx et le script de démarrage
|
||||
*
|
||||
!backend
|
||||
!frontend
|
||||
!deploy/nginx.docker.conf
|
||||
!deploy/aio
|
||||
backend/node_modules
|
||||
backend/dist
|
||||
backend/.local
|
||||
backend/storage
|
||||
backend/test
|
||||
backend/openapi
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.env
|
||||
frontend/.env.*
|
||||
**/*.log
|
||||
+8
-1
@@ -19,7 +19,14 @@ RUN npm run build
|
||||
|
||||
FROM nginx:1.30-alpine
|
||||
|
||||
COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf
|
||||
# Adresse de l'API lue au démarrage du conteneur, `api:4000` par défaut (le service de docker-compose.yml).
|
||||
# À changer quand le nom `api` est déjà pris sur le réseau, comme sur Umbrel où les apps partagent un réseau.
|
||||
# L'image nginx passe les fichiers de /etc/nginx/templates par envsubst et n'y remplace que les variables
|
||||
# d'environnement définies : $host, $uri et les autres variables de nginx restent telles quelles.
|
||||
ENV API_UPSTREAM=api:4000
|
||||
COPY deploy/nginx.docker.conf /etc/nginx/templates/default.conf.template
|
||||
RUN sed -i 's|http://api:4000|http://${API_UPSTREAM}|g' /etc/nginx/templates/default.conf.template \
|
||||
&& ! grep -q 'api:4000' /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
+23
-3
@@ -32,6 +32,14 @@ cd parenthese
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Le clone est facultatif. Les images de l'API et du front sont publiées sur le registre de GitHub (`ghcr.io/iliesallali/parenthese-api` et `ghcr.io/iliesallali/parenthese-web`, en amd64 et arm64), deux fichiers suffisent donc :
|
||||
|
||||
```bash
|
||||
mkdir parenthese && cd parenthese
|
||||
curl -fsSLO https://raw.githubusercontent.com/IliesAllali/parenthese/main/docker-compose.yml
|
||||
curl -fsSL https://raw.githubusercontent.com/IliesAllali/parenthese/main/.env.example -o .env
|
||||
```
|
||||
|
||||
Ouvrez `.env` et remplissez les deux valeurs vides :
|
||||
|
||||
- `POSTGRES_PASSWORD` : un mot de passe pour la base (lettres et chiffres).
|
||||
@@ -43,9 +51,9 @@ Puis :
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Le premier démarrage construit les images, applique les migrations de la base, puis sert l'application sur [http://localhost](http://localhost). Créez votre compte depuis l'écran d'accueil : le premier compte est un compte comme les autres, il n'y a pas d'administrateur global.
|
||||
Le premier démarrage télécharge les images (ou les construit depuis les sources si elles sont introuvables), applique les migrations de la base, puis sert l'application sur [http://localhost](http://localhost). Créez votre compte depuis l'écran d'accueil : le premier compte est un compte comme les autres, il n'y a pas d'administrateur global.
|
||||
|
||||
Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (nginx qui sert le front et relaie l'API). Seul `web` est exposé, sur le port choisi par `WEB_PORT` (80 par défaut). Si ce port est déjà pris, changez `WEB_PORT` dans `.env` et adaptez `CORS_ORIGIN` en conséquence.
|
||||
Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (nginx qui sert le front et relaie l'API). Seul `web` est exposé, sur le port choisi par `WEB_PORT` (80 par défaut). Si ce port est déjà pris, changez `WEB_PORT` dans `.env` et adaptez `CORS_ORIGIN` en conséquence. Le conteneur `web` trouve l'API à `api:4000` ; si vous lancez les images sous d'autres noms de service, donnez `API_UPSTREAM` à `web` (par exemple `API_UPSTREAM=monapi:4000`).
|
||||
|
||||
Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HTTPS devant le port `web` (Caddy, Traefik, nginx) et mettez l'adresse publique dans `CORS_ORIGIN`, par exemple `https://arbre.votre-domaine.fr`.
|
||||
|
||||
@@ -63,6 +71,16 @@ docker compose down # arrêt, les données sont conservées
|
||||
|
||||
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base.
|
||||
|
||||
### Un seul conteneur (Unraid, NAS)
|
||||
|
||||
Pour les systèmes qui installent un conteneur par application, `ghcr.io/iliesallali/parenthese-aio` fait tourner la base, l'API et le front ensemble :
|
||||
|
||||
```bash
|
||||
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /chemin/vers/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
|
||||
```
|
||||
|
||||
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
|
||||
|
||||
### Où sont les données
|
||||
|
||||
Deux volumes Docker, conservés entre les redémarrages et les mises à jour :
|
||||
@@ -88,9 +106,11 @@ Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des m
|
||||
### Mettre à jour
|
||||
|
||||
```bash
|
||||
git pull && docker compose up -d --build
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
`latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources.
|
||||
|
||||
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude.
|
||||
|
||||
## Développer
|
||||
|
||||
@@ -38,6 +38,14 @@ cd parenthese
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
You can also skip the clone. The API and front-end images are published on GitHub's registry (`ghcr.io/iliesallali/parenthese-api` and `ghcr.io/iliesallali/parenthese-web`, for amd64 and arm64), so two files are enough:
|
||||
|
||||
```bash
|
||||
mkdir parenthese && cd parenthese
|
||||
curl -fsSLO https://raw.githubusercontent.com/IliesAllali/parenthese/main/docker-compose.yml
|
||||
curl -fsSL https://raw.githubusercontent.com/IliesAllali/parenthese/main/.env.example -o .env
|
||||
```
|
||||
|
||||
Open `.env` and fill in the two empty values:
|
||||
|
||||
- `POSTGRES_PASSWORD`: a password for the database (letters and digits only).
|
||||
@@ -49,9 +57,9 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The first start builds the images, applies the database migrations, then serves the app on [http://localhost](http://localhost). Create your account from the home screen. The first account is an account like any other, there is no global administrator.
|
||||
The first start downloads the images (or builds them from the source when they can't be pulled), applies the database migrations, then serves the app on [http://localhost](http://localhost). Create your account from the home screen. The first account is an account like any other, there is no global administrator.
|
||||
|
||||
Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (nginx, which serves the front end and proxies the API). Only `web` is exposed, on the port set by `WEB_PORT` (80 by default). If that port is taken, change `WEB_PORT` in `.env` and update `CORS_ORIGIN` to match.
|
||||
Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (nginx, which serves the front end and proxies the API). Only `web` is exposed, on the port set by `WEB_PORT` (80 by default). If that port is taken, change `WEB_PORT` in `.env` and update `CORS_ORIGIN` to match. The `web` container finds the API at `api:4000`; if you run the images under other service names, set `API_UPSTREAM` on `web` (for example `API_UPSTREAM=myapi:4000`).
|
||||
|
||||
To open the instance to your family over the Internet, put an HTTPS reverse proxy in front of the `web` port (Caddy, Traefik, nginx) and set the public address in `CORS_ORIGIN`, for example `https://tree.your-domain.com`.
|
||||
|
||||
@@ -69,6 +77,16 @@ docker compose down # stop, data is kept
|
||||
|
||||
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database.
|
||||
|
||||
### Single container (Unraid, NAS)
|
||||
|
||||
For systems that install one container per app, `ghcr.io/iliesallali/parenthese-aio` runs the database, the API and the front end together:
|
||||
|
||||
```bash
|
||||
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /path/to/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
|
||||
```
|
||||
|
||||
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
|
||||
|
||||
### Where the data lives
|
||||
|
||||
Two Docker volumes, kept across restarts and updates:
|
||||
@@ -94,9 +112,11 @@ The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, che
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
git pull && docker compose up -d --build
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
`latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead.
|
||||
|
||||
Database migrations apply on their own when the API starts. Make a backup first, as a habit.
|
||||
|
||||
## Development
|
||||
|
||||
Generated
+269
-184
@@ -479,7 +479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "4.0.5",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
|
||||
"integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -494,13 +496,13 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^3.0.0"
|
||||
"fast-uri": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
||||
"integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==",
|
||||
"version": "11.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz",
|
||||
"integrity": "sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -519,6 +521,8 @@
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -532,7 +536,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/fast-json-stringify-compiler": {
|
||||
"version": "5.0.3",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz",
|
||||
"integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -545,11 +551,13 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-json-stringify": "^6.0.0"
|
||||
"fast-json-stringify": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/forwarded": {
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz",
|
||||
"integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -563,9 +571,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
|
||||
"integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.4.tgz",
|
||||
"integrity": "sha512-xaRULl7ZLLmmclAWc0gaNHCrK32A+uho8wMJkHfxQ+EXIXIBdCLnbe/89uTlNbQoR2w4i/p24DzOuldvM52ETA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -587,6 +595,8 @@
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -603,7 +613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/proxy-addr": {
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz",
|
||||
"integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -621,9 +633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz",
|
||||
"integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==",
|
||||
"version": "11.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.1.tgz",
|
||||
"integrity": "sha512-uTQjii3tJUnb6ZyEMcunuvKE3FDKNZEaPpxdyWADDgthHnUV1swdvwuiZfi3DDDgxm1iBrhOkQ/w0yDBQ8qJtA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1172,15 +1184,17 @@
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.152.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -1190,10 +1204,14 @@
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.19.2",
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -1276,9 +1294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
||||
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1294,9 +1312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1312,9 +1330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1330,9 +1348,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1348,9 +1366,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1366,9 +1384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
||||
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1384,9 +1402,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1402,9 +1420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1420,9 +1438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1438,9 +1456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1456,9 +1474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1474,9 +1492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1492,9 +1510,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1510,9 +1528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1528,9 +1546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1586,11 +1604,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.13",
|
||||
"version": "24.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
|
||||
"integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
@@ -1953,6 +1973,8 @@
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
@@ -1973,6 +1995,8 @@
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
@@ -1986,6 +2010,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
@@ -2010,13 +2050,27 @@
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/avvio": {
|
||||
"version": "9.1.0",
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz",
|
||||
"integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/error": "^4.0.0",
|
||||
@@ -2025,6 +2079,8 @@
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
@@ -2079,9 +2135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2133,6 +2189,8 @@
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2161,6 +2219,8 @@
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -2183,9 +2243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.5.tgz",
|
||||
"integrity": "sha512-aBrGvt6KhjxbEnatqMWOMidftwsrCiCvqh1yNIGB9QyF0J+VcWkKDMsvYOEm7ZS82oiV+BT1sTOtIlAmUlTpwQ==",
|
||||
"version": "18.0.6",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.6.tgz",
|
||||
"integrity": "sha512-ZMVmohV6CvAJtj++pZNyggluUt6hbRdQAxc2+Z635RL6/AG/ruXv5lHnybUFoxMPL/yrTL/tsF4paZu2l7RA6Q==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"dotenv": "dist/index.cjs"
|
||||
@@ -2297,9 +2357,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
|
||||
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz",
|
||||
"integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2334,10 +2394,14 @@
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stringify": {
|
||||
"version": "6.3.0",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
|
||||
"integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2353,15 +2417,15 @@
|
||||
"@fastify/merge-json-schemas": "^0.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^3.0.0",
|
||||
"fast-uri": "^4.0.0",
|
||||
"json-schema-ref-resolver": "^3.0.0",
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-jwt": {
|
||||
"version": "6.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz",
|
||||
"integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==",
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.4.tgz",
|
||||
"integrity": "sha512-wq1bK/Pi0OARHYrEkXyTH793Xbqx4dHxBVxYWWHymqFxSEmhllkDj5ZCvbYZcfwUQmitjnBKSgy7nv7Duxg2BQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
@@ -2384,9 +2448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2401,6 +2465,8 @@
|
||||
},
|
||||
"node_modules/fastfall": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
|
||||
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.0"
|
||||
@@ -2458,48 +2524,10 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastify/node_modules/fast-json-stringify": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
|
||||
"integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/merge-json-schemas": "^0.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^4.0.0",
|
||||
"json-schema-ref-resolver": "^3.0.0",
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify/node_modules/fast-uri": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastparallel": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
||||
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4",
|
||||
@@ -2507,7 +2535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
||||
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -2515,6 +2545,8 @@
|
||||
},
|
||||
"node_modules/fastseries": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
|
||||
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.0",
|
||||
@@ -2593,23 +2625,27 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
|
||||
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
|
||||
"integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -2618,6 +2654,8 @@
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2635,10 +2673,14 @@
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2658,6 +2700,8 @@
|
||||
},
|
||||
"node_modules/light-my-request/node_modules/process-warning": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
|
||||
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2944,9 +2988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
||||
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2960,16 +3004,18 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.0",
|
||||
"version": "0.40.5",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.5.tgz",
|
||||
"integrity": "sha512-egXDkYJsKZCizjk8FydZ3g8TOigBwt+dIaehgKcQSAE3DCkCTWG4N94trvwR1ZYeAHo1exxE+ZhZOUuj01HdNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3020,6 +3066,8 @@
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
@@ -3045,6 +3093,8 @@
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -3052,6 +3102,8 @@
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3084,7 +3136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz",
|
||||
"integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
@@ -3094,7 +3148,7 @@
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"real-require": "^1.0.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
@@ -3105,6 +3159,8 @@
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
@@ -3112,6 +3168,8 @@
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
@@ -3134,9 +3192,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3155,9 +3213,9 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"nanoid": "^3.3.19",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -3224,6 +3282,8 @@
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
@@ -3252,14 +3312,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3276,6 +3337,8 @@
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -3284,17 +3347,19 @@
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
||||
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.152.0",
|
||||
"@oxc-project/types": "=0.153.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3304,21 +3369,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||
"@rolldown/binding-android-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
||||
"@rolldown/binding-android-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-x64": "1.2.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
@@ -3365,6 +3430,8 @@
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -3378,6 +3445,8 @@
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3404,6 +3473,8 @@
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
@@ -3457,6 +3528,8 @@
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
@@ -3475,20 +3548,24 @@
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/steed": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastfall": "^1.5.0",
|
||||
@@ -3499,19 +3576,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
|
||||
"integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3546,10 +3625,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
|
||||
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@@ -3614,22 +3695,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.9",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3795,6 +3878,8 @@
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Photos citées par un fichier GEDCOM. Le fichier ne contient pas les images, seulement leur chemin sur
|
||||
// l'ordinateur de la personne (« C:\Généalogie\Photos\jean.jpg »). L'import renvoie ce chemin pour chaque
|
||||
// personne, et l'app les relie aux fichiers du dossier que la personne choisit, sans rien garder en base.
|
||||
|
||||
export type GedcomPhotoNode = {
|
||||
xref?: string
|
||||
tag: string
|
||||
value: string
|
||||
children: GedcomPhotoNode[]
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif'])
|
||||
const MAX_PATH_LENGTH = 500
|
||||
|
||||
function child(node: GedcomPhotoNode | undefined, tag: string): GedcomPhotoNode | undefined {
|
||||
return node?.children.find((c) => c.tag === tag)
|
||||
}
|
||||
|
||||
function isImagePath(value: string): boolean {
|
||||
if (!value || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false
|
||||
const name = value.split(/[\\/]/).pop() || ''
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot > 0 && IMAGE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase())
|
||||
}
|
||||
|
||||
// Un OBJE est soit écrit sous la personne (« 1 OBJE » puis « 2 FILE »), soit un renvoi (« 1 OBJE @M1@ »)
|
||||
// vers un enregistrement « 0 @M1@ OBJE » en fin de fichier. Les logiciels marquent la photo principale
|
||||
// par _PRIM Y (sous OBJE ou sous FILE) : elle passe devant, sinon la première image citée.
|
||||
export function photoPathForPerson(
|
||||
indi: GedcomPhotoNode,
|
||||
objeByXref: Map<string, GedcomPhotoNode>,
|
||||
): string | null {
|
||||
let first: string | null = null
|
||||
for (const ref of indi.children) {
|
||||
if (ref.tag !== 'OBJE') continue
|
||||
const pointer = ref.value.match(/^@([^@]+)@$/)
|
||||
const obje = pointer ? objeByXref.get(pointer[1]) : ref
|
||||
if (!obje) continue
|
||||
const isPrimary = (node: GedcomPhotoNode | undefined) => child(node, '_PRIM')?.value?.toUpperCase().startsWith('Y')
|
||||
for (const file of obje.children.filter((c) => c.tag === 'FILE')) {
|
||||
const filePath = file.value.trim()
|
||||
if (!isImagePath(filePath) || filePath.length > MAX_PATH_LENGTH) continue
|
||||
if (isPrimary(ref) || isPrimary(obje) || isPrimary(file)) return filePath
|
||||
first ??= filePath
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
export function indexObjeRecords(roots: GedcomPhotoNode[]): Map<string, GedcomPhotoNode> {
|
||||
const map = new Map<string, GedcomPhotoNode>()
|
||||
for (const node of roots) {
|
||||
if (node.tag === 'OBJE' && node.xref) map.set(node.xref, node)
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
|
||||
import { indexObjeRecords, photoPathForPerson } from '../lib/gedcom-photos.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
|
||||
const treeIdParamSchema = z.object({
|
||||
@@ -52,7 +53,7 @@ type GedcomNode = {
|
||||
children: GedcomNode[]
|
||||
}
|
||||
|
||||
function parseGedcom(text: string): GedcomNode[] {
|
||||
export function parseGedcom(text: string): GedcomNode[] {
|
||||
const lines = text.split(/\r\n|\r|\n/)
|
||||
const roots: GedcomNode[] = []
|
||||
const stack: GedcomNode[] = []
|
||||
@@ -176,6 +177,18 @@ function parseGedcomName(value: string): { firstName: string; lastName: string }
|
||||
return { firstName: value.trim(), lastName: '' }
|
||||
}
|
||||
|
||||
// Réimport : une personne du fichier déjà présente dans l'arbre (même prénom, nom, naissance et décès)
|
||||
// est reprise au lieu d'être créée une seconde fois
|
||||
function personKey(firstName: string, lastName: string, birthDate: Date | null, deathDate: Date | null): string {
|
||||
const norm = (value: string) => value.normalize('NFC').trim().toLowerCase()
|
||||
const day = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : '')
|
||||
return [norm(firstName), norm(lastName), day(birthDate), day(deathDate)].join('|')
|
||||
}
|
||||
|
||||
function pairKey(a: string, b: string | null): string {
|
||||
return b ? [a, b].sort().join('|') : `${a}|`
|
||||
}
|
||||
|
||||
function sanitizeXref(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
}
|
||||
@@ -466,7 +479,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
const xrefToPersonId = new Map<string, string>()
|
||||
const objeByXref = indexObjeRecords(roots)
|
||||
const photoRefs: Array<{ personId: string; file: string }> = []
|
||||
|
||||
// Ce que l'arbre contient déjà : un second import du même fichier complète l'arbre sans rien doubler.
|
||||
// Chaque personne existante ne sert qu'une fois, deux fiches identiques du fichier restent deux personnes.
|
||||
const [existingPersons, existingUnions, existingLinks] = await Promise.all([
|
||||
app.prisma.person.findMany({
|
||||
where: { treeId, deletedAt: null },
|
||||
select: { id: true, firstName: true, lastName: true, birthDate: true, deathDate: true, avatarPath: true },
|
||||
}),
|
||||
app.prisma.union.findMany({ where: { treeId, deletedAt: null }, select: { id: true, partner1PersonId: true, partner2PersonId: true } }),
|
||||
app.prisma.parentChildLink.findMany({ where: { treeId, deletedAt: null }, select: { parentPersonId: true, childPersonId: true } }),
|
||||
])
|
||||
const reusablePersons = new Map<string, Array<{ id: string; hasAvatar: boolean }>>()
|
||||
for (const person of existingPersons) {
|
||||
const key = personKey(person.firstName, person.lastName, person.birthDate, person.deathDate)
|
||||
if (!reusablePersons.has(key)) reusablePersons.set(key, [])
|
||||
reusablePersons.get(key)!.push({ id: person.id, hasAvatar: Boolean(person.avatarPath) })
|
||||
}
|
||||
const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id]))
|
||||
const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`))
|
||||
|
||||
let personsCreated = 0
|
||||
let personsReused = 0
|
||||
let unionsCreated = 0
|
||||
let linksCreated = 0
|
||||
|
||||
@@ -498,14 +534,29 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
notes = clip([noteNode.value, ...contLines].join('\n'), 2000)
|
||||
}
|
||||
|
||||
const personFirstName = (firstName || '?').slice(0, 120)
|
||||
const personLastName = (lastName || '').slice(0, 120)
|
||||
const birthDate = isoDateToDbDate(birthDateIso)
|
||||
const deathDate = isoDateToDbDate(deathDateIso)
|
||||
const photoPath = photoPathForPerson(indi, objeByXref)
|
||||
|
||||
const existing = reusablePersons.get(personKey(personFirstName, personLastName, birthDate, deathDate))?.shift()
|
||||
if (existing) {
|
||||
xrefToPersonId.set(indi.xref!, existing.id)
|
||||
personsReused++
|
||||
// Une personne qui a déjà son portrait le garde
|
||||
if (photoPath && !existing.hasAvatar) photoRefs.push({ personId: existing.id, file: photoPath })
|
||||
continue
|
||||
}
|
||||
|
||||
const person = await tx.person.create({
|
||||
data: {
|
||||
treeId,
|
||||
firstName: (firstName || '?').slice(0, 120),
|
||||
lastName: (lastName || '').slice(0, 120),
|
||||
firstName: personFirstName,
|
||||
lastName: personLastName,
|
||||
sex,
|
||||
birthDate: isoDateToDbDate(birthDateIso),
|
||||
deathDate: isoDateToDbDate(deathDateIso),
|
||||
birthDate,
|
||||
deathDate,
|
||||
birthPlace,
|
||||
profession,
|
||||
notes,
|
||||
@@ -516,6 +567,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
xrefToPersonId.set(indi.xref!, person.id)
|
||||
personsCreated++
|
||||
if (photoPath) photoRefs.push({ personId: person.id, file: photoPath })
|
||||
}
|
||||
|
||||
for (const fam of famNodes) {
|
||||
@@ -530,16 +582,22 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const marrNode = findChild(fam, 'MARR')
|
||||
const marriageDateIso = gedcomDateToIso(findChild(marrNode, 'DATE')?.value ?? '')
|
||||
|
||||
const union = await tx.union.create({
|
||||
data: {
|
||||
treeId,
|
||||
partner1PersonId: partner1Id ?? partner2Id!,
|
||||
partner2PersonId: partner1Id ? (partner2Id ?? null) : null,
|
||||
startDate: isoDateToDbDate(marriageDateIso),
|
||||
},
|
||||
})
|
||||
|
||||
unionsCreated++
|
||||
const firstPartner = partner1Id ?? partner2Id!
|
||||
const secondPartner = partner1Id ? (partner2Id ?? null) : null
|
||||
let unionId = unionByPair.get(pairKey(firstPartner, secondPartner))
|
||||
if (!unionId) {
|
||||
const union = await tx.union.create({
|
||||
data: {
|
||||
treeId,
|
||||
partner1PersonId: firstPartner,
|
||||
partner2PersonId: secondPartner,
|
||||
startDate: isoDateToDbDate(marriageDateIso),
|
||||
},
|
||||
})
|
||||
unionId = union.id
|
||||
unionByPair.set(pairKey(firstPartner, secondPartner), unionId)
|
||||
unionsCreated++
|
||||
}
|
||||
|
||||
const chilNodes = findAllChildren(fam, 'CHIL')
|
||||
const seenChildren = new Set<string>()
|
||||
@@ -555,14 +613,17 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const parentIds = [partner1Id, partner2Id].filter((id): id is string => Boolean(id))
|
||||
|
||||
for (const parentId of parentIds) {
|
||||
const linkKey = `${parentId}:${childId}`
|
||||
if (linkKeys.has(linkKey)) continue
|
||||
await tx.parentChildLink.create({
|
||||
data: {
|
||||
treeId,
|
||||
parentPersonId: parentId,
|
||||
childPersonId: childId,
|
||||
viaUnionId: union.id,
|
||||
viaUnionId: unionId,
|
||||
},
|
||||
})
|
||||
linkKeys.add(linkKey)
|
||||
linksCreated++
|
||||
}
|
||||
}
|
||||
@@ -576,7 +637,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
action: 'gedcom_imported',
|
||||
entityType: 'tree',
|
||||
entityId: treeId,
|
||||
payloadJson: { personsCreated, unionsCreated, linksCreated },
|
||||
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, photoRefs: photoRefs.length },
|
||||
},
|
||||
})
|
||||
}, { timeout: 60000 })
|
||||
@@ -584,6 +645,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
importInProgress = false
|
||||
}
|
||||
|
||||
return reply.send({ personsCreated, unionsCreated, linksCreated })
|
||||
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne (rien n'est gardé)
|
||||
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, photoRefs })
|
||||
})
|
||||
}
|
||||
@@ -187,6 +187,9 @@ export async function exceedsStorageQuota(app: FastifyRequest['server'], treeId:
|
||||
|
||||
// Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts)
|
||||
const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
|
||||
// Portraits : réservés au propriétaire, mais l'app peut en placer quelques centaines d'un coup (photos
|
||||
// retrouvées après un import GEDCOM), envoyées une par une. Le quota de stockage de l'arbre s'applique.
|
||||
const AVATAR_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }
|
||||
|
||||
export async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
|
||||
const membership = await app.prisma.treeMembership.findUnique({
|
||||
@@ -462,7 +465,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(201).send({ media, status: media.status })
|
||||
})
|
||||
|
||||
app.post('/trees/:id/persons/:personId/avatar', UPLOAD_RATE_LIMIT, async (request, reply) => {
|
||||
app.post('/trees/:id/persons/:personId/avatar', AVATAR_RATE_LIMIT, async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { indexObjeRecords, photoPathForPerson } from '../src/lib/gedcom-photos'
|
||||
import { parseGedcom } from '../src/routes/gedcom'
|
||||
|
||||
function photosOf(text: string): Record<string, string | null> {
|
||||
const roots = parseGedcom(text)
|
||||
const objes = indexObjeRecords(roots)
|
||||
const out: Record<string, string | null> = {}
|
||||
for (const indi of roots.filter((n) => n.tag === 'INDI' && n.xref)) {
|
||||
out[indi.xref!] = photoPathForPerson(indi, objes)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('photos citées par un GEDCOM', () => {
|
||||
it('lit un OBJE écrit sous la personne (GEDCOM 5.5)', () => {
|
||||
const text = [
|
||||
'0 @I1@ INDI',
|
||||
'1 NAME Jean /Dupont/',
|
||||
'1 OBJE',
|
||||
'2 FORM jpg',
|
||||
'2 FILE C:\\Genealogie\\Photos\\jean.jpg',
|
||||
].join('\n')
|
||||
expect(photosOf(text)).toEqual({ I1: 'C:\\Genealogie\\Photos\\jean.jpg' })
|
||||
})
|
||||
|
||||
it('suit un renvoi vers un enregistrement OBJE (Gramps, GEDCOM 5.5.1)', () => {
|
||||
const text = [
|
||||
'0 @I1@ INDI',
|
||||
'1 NAME Marie /Martin/',
|
||||
'1 OBJE @M7@',
|
||||
'0 @M7@ OBJE',
|
||||
'1 FILE photos/marie 1920.png',
|
||||
'2 FORM png',
|
||||
].join('\n')
|
||||
expect(photosOf(text)).toEqual({ I1: 'photos/marie 1920.png' })
|
||||
})
|
||||
|
||||
it('préfère la photo marquée principale, sinon la première image', () => {
|
||||
const text = [
|
||||
'0 @I1@ INDI',
|
||||
'1 OBJE',
|
||||
'2 FILE acte-naissance.pdf',
|
||||
'1 OBJE',
|
||||
'2 FILE groupe.jpg',
|
||||
'1 OBJE',
|
||||
'2 FILE portrait.jpg',
|
||||
'2 _PRIM Y',
|
||||
'0 @I2@ INDI',
|
||||
'1 OBJE',
|
||||
'2 FILE acte.pdf',
|
||||
'1 OBJE',
|
||||
'2 FILE premier.JPEG',
|
||||
'1 OBJE',
|
||||
'2 FILE second.jpg',
|
||||
].join('\n')
|
||||
expect(photosOf(text)).toEqual({ I1: 'portrait.jpg', I2: 'premier.JPEG' })
|
||||
})
|
||||
|
||||
it('ignore les adresses web, les documents et les renvois cassés', () => {
|
||||
const text = [
|
||||
'0 @I1@ INDI',
|
||||
'1 OBJE',
|
||||
'2 FILE https://exemple.org/photo.jpg',
|
||||
'1 OBJE @M404@',
|
||||
'1 OBJE',
|
||||
'2 FILE registre.pdf',
|
||||
'0 @I2@ INDI',
|
||||
'1 NAME Sans /Photo/',
|
||||
].join('\n')
|
||||
expect(photosOf(text)).toEqual({ I1: null, I2: null })
|
||||
})
|
||||
})
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
# Démarrage de l'image tout-en-un (Dockerfile.aio) : PostgreSQL, puis l'API, puis nginx.
|
||||
#
|
||||
# Au premier démarrage, crée dans /data la base, le mot de passe de la base et la clé de signature des sessions
|
||||
# (JWT_SECRET), gardés pour les démarrages suivants. Une variable JWT_SECRET passée au conteneur l'emporte sur
|
||||
# la clé générée. Si l'un des trois processus s'arrête, le conteneur s'arrête, et le redémarrage automatique
|
||||
# de Docker le relance.
|
||||
set -euo pipefail
|
||||
|
||||
DATA_DIR=/data
|
||||
PGDATA="$DATA_DIR/postgres"
|
||||
MEDIA_DIR="$DATA_DIR/media"
|
||||
SECRETS_DIR="$DATA_DIR/secrets"
|
||||
DB_NAME=parenthese
|
||||
DB_USER=parenthese
|
||||
|
||||
log() { echo "[parenthese] $*" >&2; }
|
||||
|
||||
mkdir -p "$MEDIA_DIR" "$SECRETS_DIR" /run/postgresql
|
||||
chmod 700 "$SECRETS_DIR"
|
||||
chown postgres:postgres /run/postgresql
|
||||
chown node:node "$MEDIA_DIR"
|
||||
|
||||
# Secrets générés une fois, lisibles par root seulement.
|
||||
secret() {
|
||||
local file="$SECRETS_DIR/$1"
|
||||
if [ ! -s "$file" ]; then
|
||||
(umask 077 && openssl rand -hex "$2" > "$file")
|
||||
log "secret $1 créé dans $file"
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
DB_PASSWORD="$(secret db_password 24)"
|
||||
if [ -z "${JWT_SECRET:-}" ]; then
|
||||
JWT_SECRET="$(secret jwt_secret 48)"
|
||||
fi
|
||||
|
||||
# PostgreSQL, à l'écoute de la machine seule.
|
||||
FIRST_START=0
|
||||
if [ ! -s "$PGDATA/PG_VERSION" ]; then
|
||||
log "première installation : création de la base dans $PGDATA"
|
||||
mkdir -p "$PGDATA"
|
||||
chown postgres:postgres "$PGDATA"
|
||||
chmod 700 "$PGDATA"
|
||||
runuser -u postgres -- initdb -D "$PGDATA" --encoding=UTF8 --locale=C.UTF-8 \
|
||||
--auth-local=peer --auth-host=scram-sha-256 >/dev/null
|
||||
FIRST_START=1
|
||||
fi
|
||||
chown -R postgres:postgres "$PGDATA"
|
||||
chmod 700 "$PGDATA"
|
||||
|
||||
runuser -u postgres -- pg_ctl -D "$PGDATA" -w -t 60 \
|
||||
-o "-c listen_addresses=127.0.0.1 -c unix_socket_directories=/run/postgresql" \
|
||||
-l "$PGDATA/postgres.log" start >/dev/null
|
||||
|
||||
if [ "$FIRST_START" = 1 ]; then
|
||||
runuser -u postgres -- psql -q -v ON_ERROR_STOP=1 -c "CREATE ROLE $DB_USER LOGIN"
|
||||
runuser -u postgres -- createdb -O "$DB_USER" "$DB_NAME"
|
||||
fi
|
||||
# Le mot de passe suit le fichier de /data à chaque démarrage (hexadécimal, sans guillemet possible).
|
||||
runuser -u postgres -- psql -q -v ON_ERROR_STOP=1 -c "ALTER ROLE $DB_USER PASSWORD '$DB_PASSWORD'"
|
||||
|
||||
export NODE_ENV=production
|
||||
export PORT=4000
|
||||
export HOST=127.0.0.1
|
||||
export DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@127.0.0.1:5432/$DB_NAME"
|
||||
export MEDIA_STORAGE_PATH="$MEDIA_DIR"
|
||||
export JWT_SECRET
|
||||
|
||||
stop_all() {
|
||||
log "arrêt"
|
||||
[ -n "${NGINX_PID:-}" ] && kill -QUIT "$NGINX_PID" 2>/dev/null || true
|
||||
[ -n "${API_PID:-}" ] && kill -TERM "$API_PID" 2>/dev/null || true
|
||||
wait 2>/dev/null || true
|
||||
runuser -u postgres -- pg_ctl -D "$PGDATA" -m fast -w stop >/dev/null 2>&1 || true
|
||||
}
|
||||
trap 'stop_all; exit 0' TERM INT
|
||||
|
||||
cd /app
|
||||
log "migrations"
|
||||
runuser -u node -- env PATH="$PATH" prisma migrate deploy
|
||||
|
||||
log "démarrage de l'API"
|
||||
runuser -u node -- env PATH="$PATH" node dist/server.js &
|
||||
API_PID=$!
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if node -e "fetch('http://127.0.0.1:4000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then
|
||||
break
|
||||
fi
|
||||
kill -0 "$API_PID" 2>/dev/null || { log "l'API s'est arrêtée au démarrage"; stop_all; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log "démarrage de nginx"
|
||||
nginx -g 'daemon off;' &
|
||||
NGINX_PID=$!
|
||||
|
||||
log "prêt sur le port 80"
|
||||
set +e
|
||||
wait -n "$API_PID" "$NGINX_PID"
|
||||
status=$?
|
||||
log "un processus s'est arrêté (code $status)"
|
||||
stop_all
|
||||
exit "$status"
|
||||
+8
-1
@@ -4,6 +4,9 @@
|
||||
# docker compose logs -f api suivre l'API
|
||||
# docker compose down arrêter (les volumes db-data et media sont conservés)
|
||||
# bash tools/docker-smoke.sh vérifier l'installation
|
||||
# Les images de l'API et du front sont publiées sur ghcr.io (version choisie par PARENTHESE_VERSION,
|
||||
# `latest` par défaut) : ce fichier et le .env suffisent, sans le code. Si l'image est introuvable,
|
||||
# compose la construit depuis les sources (dépôt cloné) ; `--build` force cette construction.
|
||||
|
||||
name: parenthese
|
||||
|
||||
@@ -24,6 +27,7 @@ services:
|
||||
retries: 12
|
||||
|
||||
api:
|
||||
image: ghcr.io/iliesallali/parenthese-api:${PARENTHESE_VERSION:-latest}
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -46,9 +50,12 @@ services:
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
# Premier lancement : les migrations passent avant que l'API réponde, 30 s ne suffisaient pas
|
||||
# et le conteneur web, qui attend une API prête, abandonnait (signalé sur Reddit le 11/10).
|
||||
start_period: 120s
|
||||
|
||||
web:
|
||||
image: ghcr.io/iliesallali/parenthese-web:${PARENTHESE_VERSION:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
|
||||
Generated
+48
-47
@@ -8,9 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.49.0",
|
||||
"lucide-react": "^1.49.0",
|
||||
"posthog-js": "^1.435.6",
|
||||
"@lucide/icons": "^1.52.0",
|
||||
"lucide-react": "^1.52.0",
|
||||
"posthog-js": "^1.438.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
@@ -18,15 +18,15 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.29",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.3.1",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
},
|
||||
@@ -566,9 +566,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lucide/icons": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.49.0.tgz",
|
||||
"integrity": "sha512-TasECItykhUz9AGH+gKpujDlj+VsDeCZeO4RKvvFDPEyhS/+dtCQvQPMq7do4kwCZOX/IIuSZS6vvB8BFpb2hA==",
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.52.0.tgz",
|
||||
"integrity": "sha512-nO0tVQ0mzkLAI3PrB1uErtE8o+5wvmJTPTYYOp6GZ2tzunQa+VoauqYCVL0mIbHhJ3MBz7FiDObz6kViMl9nrA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
@@ -627,18 +627,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.55.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.3.tgz",
|
||||
"integrity": "sha512-XnmG9jo7WlxTeN4oCNWH4ESLQHR6ZPZ+mcq+8JxU62k5i1BAe3BSrybnmXHjqKp7e3rUvLK2bOzGZGSlfHvlNA==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.57.0.tgz",
|
||||
"integrity": "sha512-ZpIWCSkog0v4whufAXetagBsVLqE1Zjl5DsHC2squTy8nS/vqZ9W5JvWkJFgHK8hk5x1gdwMoXY7FK2S0YRT1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.413.0"
|
||||
"@posthog/types": "^1.415.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.413.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.413.0.tgz",
|
||||
"integrity": "sha512-tYAVzNojWckuG/wxBB0fwE70sFruQQq4xbhkci7eH2ARU3mMfUTkvpN1HwBMpm0AtbxidLiH0IFvagmdJ8opPw==",
|
||||
"version": "1.415.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.415.0.tgz",
|
||||
"integrity": "sha512-LGVu1f8r1Hi97yCwXg6PJn7chIwjxosAkkNrh7RIDRtFC5Kc+rkqHqFv73KrjEgCj7Se+0OR5esI4Tg0io6kuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
@@ -970,9 +970,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
|
||||
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -984,7 +984,7 @@
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.145.0",
|
||||
"oxc-transform-react": "^0.152.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -1498,9 +1498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
|
||||
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
|
||||
"version": "10.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
|
||||
"integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -2417,9 +2417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.49.0.tgz",
|
||||
"integrity": "sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==",
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
|
||||
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2694,9 +2694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2714,9 +2714,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"nanoid": "^3.3.19",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -2832,9 +2832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
|
||||
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2852,14 +2852,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.435.6",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.435.6.tgz",
|
||||
"integrity": "sha512-PdBZw1Np4Ch2ZzvQ/Ij0CTBrJSkSeEmDJsyJfbaipTYH8XJKJk45BYLtNgL4z9DlevRcYJxhwb15VrDzbw7shA==",
|
||||
"version": "1.438.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.0.tgz",
|
||||
"integrity": "sha512-8dmylTANRuoEyY+4f6Tj1ScAIEehrUZufh0zNzYedn371jMxDNJD2yq1FMxzSS7RfTxjYl36kC8McwEZ4BFzLQ==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.9.1",
|
||||
"@posthog/core": "^1.55.3",
|
||||
"@posthog/types": "^1.413.0",
|
||||
"@posthog/core": "^1.57.0",
|
||||
"@posthog/types": "^1.415.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -3144,10 +3144,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3366,16 +3367,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
|
||||
"integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.9",
|
||||
"rolldown": "~1.2.11",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+10
-7
@@ -12,9 +12,9 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.49.0",
|
||||
"lucide-react": "^1.49.0",
|
||||
"posthog-js": "^1.435.6",
|
||||
"@lucide/icons": "^1.52.0",
|
||||
"lucide-react": "^1.52.0",
|
||||
"posthog-js": "^1.438.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
@@ -22,15 +22,18 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.29",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.3.1",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss-selector-parser": "^7.1.6"
|
||||
}
|
||||
}
|
||||
+169
-9
@@ -12,6 +12,7 @@ import AccessGate from './components/AccessGate'
|
||||
import AccountDashboard from './components/AccountDashboard'
|
||||
import AccountPanel from './components/AccountPanel'
|
||||
import TreeCreationWizard from './components/TreeCreationWizard'
|
||||
import PhotoStartPanel from './components/PhotoStartPanel.jsx'
|
||||
import ContributionPanel from './components/ContributionPanel'
|
||||
import EditModeOverlay from './components/EditSessionBar.jsx'
|
||||
import EditConfirmModal from './components/EditConfirmModal'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
batchAnnotations,
|
||||
deletePersonAvatar,
|
||||
getTree,
|
||||
importGedcom,
|
||||
linkTreeAccess,
|
||||
updateTree,
|
||||
rotateTreePasswords,
|
||||
@@ -53,7 +55,8 @@ import { useEditMode } from './hooks/useEditMode'
|
||||
import { useAnnotations } from './hooks/useAnnotations'
|
||||
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
|
||||
import TextInputOverlay from './components/annotations/TextInputOverlay'
|
||||
import { filiations, medias, persons, unions } from './data/mockData'
|
||||
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
|
||||
import { loadPhotoRefs, pickFirstFaces, prepareImageForUpload, savePhotoRefs, sizeBucket } from './utils/photoImport'
|
||||
import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages'
|
||||
import { getSiblingParentLinks } from './utils/familyLinks'
|
||||
import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload'
|
||||
@@ -476,6 +479,9 @@ function App() {
|
||||
})
|
||||
|
||||
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
|
||||
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
|
||||
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], faces: [] })
|
||||
const photoStartDirtyRef = useRef(false)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const [showDraftBanner, setShowDraftBanner] = useState(false)
|
||||
const [accountPanelVisible, setAccountPanelVisible] = useState(false)
|
||||
@@ -494,6 +500,7 @@ function App() {
|
||||
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
|
||||
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
|
||||
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
|
||||
const [treeImportError, setTreeImportError] = useState('')
|
||||
const [contribInviteState, setContribInviteState] = useState({
|
||||
shareUrl: '',
|
||||
knownPasswords: {
|
||||
@@ -553,6 +560,30 @@ function App() {
|
||||
persons.find((candidate) => String(candidate.id) === String(linkedSelfPersonId)) || null
|
||||
), [linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId])
|
||||
|
||||
// Les visages à demander en premier : la famille proche de « Moi » (ou de la personne de départ),
|
||||
// sinon les générations les plus récentes
|
||||
const computeFirstFaces = useCallback(() => pickFirstFaces({
|
||||
persons,
|
||||
selfId: linkedSelfPersonId || tree.treeContext.rootPersonId || null,
|
||||
getParents,
|
||||
getChildren,
|
||||
getPartners: (id) => getPersonUnions(id)
|
||||
.map((union) => (union.partner1Id === id ? union.partner2Id : union.partner1Id))
|
||||
.map((partnerId) => persons.find((person) => person.id === partnerId))
|
||||
.filter(Boolean),
|
||||
}), [linkedSelfPersonId, tree.treeContext.rootPersonId])
|
||||
|
||||
const openPhotoStart = useCallback(({ personsImported = null, refs = null, treeId = null } = {}) => {
|
||||
photoStartDirtyRef.current = false
|
||||
setPhotoStart({
|
||||
visible: true,
|
||||
personsImported,
|
||||
refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId),
|
||||
faces: computeFirstFaces(),
|
||||
})
|
||||
}, [computeFirstFaces, tree.treeContext.treeId])
|
||||
|
||||
|
||||
const accountDisplayName = useMemo(() => {
|
||||
if (linkedSelfPerson) {
|
||||
const fullName = `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim()
|
||||
@@ -1475,27 +1506,28 @@ function App() {
|
||||
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!accessToken || !tree.treeContext.treeId) return
|
||||
|
||||
const validationError = validateMediaFile(file, 'photo')
|
||||
setAvatarActionState({ loading: true, error: '' })
|
||||
const prepared = await prepareImageForUpload(file)
|
||||
const validationError = validateMediaFile(prepared, 'photo')
|
||||
if (validationError) {
|
||||
setAvatarActionState({ loading: false, error: validationError })
|
||||
return
|
||||
}
|
||||
|
||||
setAvatarActionState({ loading: true, error: '' })
|
||||
|
||||
try {
|
||||
const dataBase64 = await fileToBase64(file)
|
||||
const dataBase64 = await fileToBase64(prepared)
|
||||
await uploadPersonAvatar(
|
||||
tree.treeContext.treeId,
|
||||
personId,
|
||||
{
|
||||
fileName: file.name || 'avatar.jpg',
|
||||
mimeType: getUploadMimeType(file, 'photo'),
|
||||
sizeBytes: file.size,
|
||||
fileName: prepared.name || 'avatar.jpg',
|
||||
mimeType: getUploadMimeType(prepared, 'photo'),
|
||||
sizeBytes: prepared.size,
|
||||
dataBase64,
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
trackAppEvent('portrait_added', { via: 'card' })
|
||||
await refreshTreeAndKeepSelection(personId, { relayout: true })
|
||||
setAvatarActionState({ loading: false, error: '' })
|
||||
} catch (error) {
|
||||
@@ -2290,6 +2322,49 @@ function App() {
|
||||
)
|
||||
trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) })
|
||||
|
||||
// Arbre déjà tenu dans un logiciel : on importe son fichier au lieu de commencer par « vous »,
|
||||
// sinon la personne saisie ici existerait deux fois après l'import
|
||||
if (payload.gedcomFile) {
|
||||
let imported = null
|
||||
let importError = ''
|
||||
try {
|
||||
const content = await payload.gedcomFile.text()
|
||||
imported = content.trim()
|
||||
? await importGedcom(createdTree.id, content, token)
|
||||
: null
|
||||
if (!imported) importError = t('Le fichier GEDCOM est vide.')
|
||||
} catch (error) {
|
||||
importError = getAccountErrorMessage(error) || t("Impossible d'importer ce fichier GEDCOM.")
|
||||
}
|
||||
|
||||
await auth.loadAccountTrees(token)
|
||||
await tree.openTreeGraph(createdTree.id, token, {
|
||||
treeName: createdTree.name || payload.treeName,
|
||||
treeDescription: createdTree.description || '',
|
||||
role: 'owner',
|
||||
accessMode: 'member',
|
||||
rootPersonId: null,
|
||||
promptWizard: false,
|
||||
})
|
||||
await auth.persistLastOpenedTree({ treeId: createdTree.id, accessMode: 'member' })
|
||||
setTreeWizardVisible(false)
|
||||
|
||||
if (imported) {
|
||||
const refs = Array.isArray(imported.photoRefs) ? imported.photoRefs : []
|
||||
savePhotoRefs(createdTree.id, refs)
|
||||
trackAppEvent('gedcom_imported', {
|
||||
persons: sizeBucket(imported.personsCreated || 0),
|
||||
photo_refs: sizeBucket(refs.length),
|
||||
via: 'wizard',
|
||||
})
|
||||
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs })
|
||||
} else {
|
||||
// L'arbre existe déjà : on l'ouvre vide et on dit où réessayer, plutôt que d'en créer un second
|
||||
setTreeImportError(importError)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const createdSelf = await createPerson(
|
||||
createdTree.id,
|
||||
{
|
||||
@@ -2440,8 +2515,71 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGedcomImported = useCallback(async () => {
|
||||
const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => {
|
||||
await refreshTreeAndKeepSelection(null, { relayout: true })
|
||||
const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : []
|
||||
savePhotoRefs(treeIdOverride || tree.treeContext.treeId, refs)
|
||||
trackAppEvent('gedcom_imported', {
|
||||
persons: sizeBucket(result?.personsCreated || 0),
|
||||
photo_refs: sizeBucket(refs.length),
|
||||
})
|
||||
setAdminPanelVisible(false)
|
||||
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs })
|
||||
}, [refreshTreeAndKeepSelection, openPhotoStart, tree.treeContext.treeId])
|
||||
|
||||
// Un portrait envoyé sans recharger l'arbre (rechargé une fois, à la fermeture de la fenêtre).
|
||||
// Renvoie un message d'erreur, ou '' si c'est fait.
|
||||
const sendPortrait = useCallback(async (personId, file) => {
|
||||
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!accessToken || !tree.treeContext.treeId) return t('Session expirée, reconnectez-vous.')
|
||||
const prepared = await prepareImageForUpload(file)
|
||||
const validationError = validateMediaFile(prepared, 'photo')
|
||||
if (validationError) return validationError
|
||||
const payload = {
|
||||
fileName: prepared.name || 'avatar.jpg',
|
||||
mimeType: getUploadMimeType(prepared, 'photo'),
|
||||
sizeBytes: prepared.size,
|
||||
dataBase64: await fileToBase64(prepared),
|
||||
}
|
||||
// Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await uploadPersonAvatar(tree.treeContext.treeId, personId, payload, accessToken)
|
||||
photoStartDirtyRef.current = true
|
||||
return ''
|
||||
} catch (error) {
|
||||
if (error?.status === 429 && attempt < 2) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 15000))
|
||||
continue
|
||||
}
|
||||
return getAccountErrorMessage(error)
|
||||
}
|
||||
}
|
||||
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
|
||||
|
||||
const handlePhotoStartUploadOne = useCallback(async (personId, file) => {
|
||||
const error = await sendPortrait(personId, file)
|
||||
if (!error) trackAppEvent('portrait_added', { via: 'first_faces' })
|
||||
return error
|
||||
}, [sendPortrait])
|
||||
|
||||
const handlePhotoStartBulk = useCallback(async (matches, onProgress) => {
|
||||
const placedIds = []
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const error = await sendPortrait(matches[i].personId, matches[i].file)
|
||||
if (!error) placedIds.push(matches[i].personId)
|
||||
onProgress?.(i + 1)
|
||||
}
|
||||
trackAppEvent('portraits_bulk_placed', { placed: sizeBucket(placedIds.length), failed: sizeBucket(matches.length - placedIds.length) })
|
||||
return { placedIds }
|
||||
}, [sendPortrait])
|
||||
|
||||
const handlePhotoStartClose = useCallback(async () => {
|
||||
setPhotoStart((current) => ({ ...current, visible: false }))
|
||||
if (photoStartDirtyRef.current) {
|
||||
photoStartDirtyRef.current = false
|
||||
await refreshTreeAndKeepSelection(null, { relayout: true, avatarCacheBust: Date.now() })
|
||||
}
|
||||
}, [refreshTreeAndKeepSelection])
|
||||
|
||||
const handleSelectSelfPerson = (personId) => {
|
||||
@@ -2534,6 +2672,16 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{treeImportError && (
|
||||
<div className="tree-created-prompt tree-created-prompt--error" role="alert">
|
||||
<strong>{t("Votre arbre est créé, mais l'import n'a pas abouti.")}</strong>
|
||||
{` ${treeImportError} `}
|
||||
{t("Vous pouvez réessayer depuis les paramètres de l'arbre.")}
|
||||
<button type="button" onClick={() => setTreeImportError('')} aria-label={t('Fermer')}>
|
||||
{t('Fermer')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Bouton premier ajout (arbre vide) */}
|
||||
{isEmptyTree && (
|
||||
<button
|
||||
@@ -2691,6 +2839,7 @@ function App() {
|
||||
zoomApiRef={galaxyZoomApiRef}
|
||||
focusPersonId={tree.treeContext.rootPersonId}
|
||||
assetRevision={tree.assetRevision}
|
||||
photoHint={tree.canEditCurrentTree}
|
||||
/>
|
||||
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
|
||||
{tree.selectedPerson && (
|
||||
@@ -2774,8 +2923,19 @@ function App() {
|
||||
authToken={tree.treeContext.accessToken}
|
||||
onTreeDeleted={handleTreeDeleted}
|
||||
onGedcomImported={handleGedcomImported}
|
||||
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
|
||||
onClose={() => setAdminPanelVisible(false)}
|
||||
/>
|
||||
<PhotoStartPanel
|
||||
visible={photoStart.visible}
|
||||
personsImported={photoStart.personsImported}
|
||||
photoRefs={photoStart.refs}
|
||||
faces={photoStart.faces}
|
||||
onUploadOne={handlePhotoStartUploadOne}
|
||||
onBulkPlace={handlePhotoStartBulk}
|
||||
onRefsChange={(refs) => savePhotoRefs(tree.treeContext.treeId, refs)}
|
||||
onClose={handlePhotoStartClose}
|
||||
/>
|
||||
{/* Annotation Toolbar (visible uniquement en mode édition) */}
|
||||
{editMode.isActive && (
|
||||
<AnnotationToolbar
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Download, Trash2, X } from 'lucide-react'
|
||||
import { Download, ImagePlus, Trash2, X } from 'lucide-react'
|
||||
import './SettingsPanels.css'
|
||||
import {
|
||||
deleteTree,
|
||||
@@ -30,6 +30,7 @@ const AdminPanel = ({
|
||||
onClose,
|
||||
onTreeDeleted,
|
||||
onGedcomImported,
|
||||
onOpenPhotoStart,
|
||||
}) => {
|
||||
const [section, setSection] = useState('tree')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -383,6 +384,21 @@ const AdminPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onOpenPhotoStart && (
|
||||
<div className="ts-row">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('Ajouter des portraits')}</strong>
|
||||
<p className="pz-small">{t("Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.")}</p>
|
||||
</div>
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={onOpenPhotoStart} disabled={saving}>
|
||||
<ImagePlus size={15} aria-hidden="true" />
|
||||
{t('Ajouter des portraits')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ts-row">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('Télécharger en GEDCOM')}</strong>
|
||||
|
||||
@@ -29,12 +29,15 @@ const MAX_SCALE = 3.2
|
||||
const FOCUS_MIN_READABLE_SCALE = 0.5
|
||||
const FOCUS_SCALE = 0.95
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0 }) => {
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => {
|
||||
const canvasRef = useRef(null)
|
||||
// Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours
|
||||
const focusPersonIdRef = useRef(focusPersonId)
|
||||
// Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration)
|
||||
useEffect(() => { focusPersonIdRef.current = focusPersonId }, [focusPersonId])
|
||||
// Propriétaire : un portrait vide survolé montre « + photo »
|
||||
const photoHintRef = useRef(photoHint)
|
||||
useEffect(() => { photoHintRef.current = photoHint }, [photoHint])
|
||||
const layoutDataRef = useRef(null)
|
||||
const [layoutReady, setLayoutReady] = useState(false)
|
||||
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
|
||||
@@ -571,6 +574,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
mediaHoverState: mediaHoverState.current,
|
||||
entrance, entranceActive, elapsed,
|
||||
view,
|
||||
photoHint: photoHintRef.current,
|
||||
})
|
||||
drawRenvoiPills(ctx)
|
||||
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus
|
||||
|
||||
@@ -44,6 +44,16 @@
|
||||
}
|
||||
.pc-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.pc-photo-initials { font: 400 30px/1 var(--pz-serif); color: var(--pz-a700); }
|
||||
.pc-photo--add {
|
||||
border: 1.5px dashed var(--pz-a300); background: var(--pz-a50); box-shadow: none; cursor: pointer;
|
||||
align-content: center; gap: 5px; padding: 6px; color: var(--pz-a700);
|
||||
font: 500 11.5px/1.15 var(--pz-sans); text-align: center;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 140ms var(--pz-ease);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .pc-photo--add:hover { background: var(--pz-a100); border-color: var(--pz-a500); } }
|
||||
.pc-photo--add:active:not(:disabled) { transform: scale(.97); }
|
||||
.pc-photo--add:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
|
||||
.pc-photo--add:disabled { opacity: .6; cursor: default; }
|
||||
.pc-photo-actions { display: grid; gap: 4px; justify-items: center; }
|
||||
.pc-photo-btn {
|
||||
border: 0; background: none; padding: 2px 4px; cursor: pointer;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, Sparkles } from 'lucide-react'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Sparkles } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData'
|
||||
import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||||
@@ -338,21 +338,36 @@ const PersonCard = ({
|
||||
{/* ── En-tête : visage, nom, repères ── */}
|
||||
<header className="pc-head">
|
||||
<div className="pc-photo-wrap">
|
||||
<div className="pc-photo">
|
||||
{person.photo
|
||||
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
|
||||
: <span className="pc-photo-initials">{initials}</span>}
|
||||
</div>
|
||||
{onAvatarUpload && (
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="pc-hidden-input"
|
||||
onChange={handleAvatarFileChange}
|
||||
disabled={avatarActionLoading}
|
||||
/>
|
||||
)}
|
||||
{/* Portrait vide chez le propriétaire : le cadre lui-même invite à poser la photo, sans passer par l'édition */}
|
||||
{!person.photo && onAvatarUpload && !editMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pc-photo pc-photo--add"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={avatarActionLoading}
|
||||
>
|
||||
<ImagePlus size={22} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>{avatarActionLoading ? t('Envoi') : t('Ajouter une photo')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="pc-photo">
|
||||
{person.photo
|
||||
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
|
||||
: <span className="pc-photo-initials">{initials}</span>}
|
||||
</div>
|
||||
)}
|
||||
{editMode && onAvatarUpload && (
|
||||
<div className="pc-photo-actions">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="pc-hidden-input"
|
||||
onChange={handleAvatarFileChange}
|
||||
disabled={avatarActionLoading}
|
||||
/>
|
||||
<button type="button" className="pc-photo-btn" onClick={() => avatarInputRef.current?.click()} disabled={avatarActionLoading}>
|
||||
{person.photo ? t('Changer') : t('Ajouter une photo')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* Premiers visages après un import : retrouver les photos du logiciel, puis cinq portraits à demander */
|
||||
|
||||
.psp { width: min(100%, 560px); gap: 22px; }
|
||||
.psp .pz-title em { font-style: italic; color: var(--pz-a700); }
|
||||
|
||||
.psp-block { display: grid; gap: 12px; }
|
||||
.psp-block--folder {
|
||||
padding: 18px;
|
||||
background: var(--pz-a50);
|
||||
border: 1px solid var(--pz-a100);
|
||||
border-radius: var(--pz-r-m);
|
||||
}
|
||||
.psp-h { margin: 0; font: 500 16px/1.35 var(--pz-sans); color: var(--pz-ink); }
|
||||
.psp-p { margin: 0; font: 400 15px/1.55 var(--pz-sans); color: var(--pz-ink-2); }
|
||||
.psp-p strong { font-weight: 600; color: var(--pz-ink); }
|
||||
.psp-block .pz-hint { overflow-wrap: anywhere; }
|
||||
.psp-folder-btn { justify-self: start; }
|
||||
.psp-row-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.psp-found { display: grid; gap: 10px; justify-items: start; }
|
||||
|
||||
.psp-progress { display: grid; gap: 8px; }
|
||||
.psp-bar { height: 8px; border-radius: 999px; background: var(--pz-surface); box-shadow: inset 0 0 0 1px var(--pz-a100); overflow: hidden; }
|
||||
.psp-bar span { display: block; height: 100%; background: var(--pz-a500); border-radius: inherit; transition: width 240ms var(--pz-ease); }
|
||||
|
||||
.psp-faces { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
|
||||
.psp-face {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 62px;
|
||||
padding: 6px 4px;
|
||||
border-bottom: 1px solid var(--pz-line-soft);
|
||||
}
|
||||
.psp-face:last-child { border-bottom: 0; }
|
||||
.psp-face-photo {
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: var(--pz-bg);
|
||||
border: 1.5px dashed var(--pz-a300);
|
||||
font: 400 16px var(--pz-serif);
|
||||
color: var(--pz-muted);
|
||||
}
|
||||
.psp-face-photo.is-set { border: 2px solid #fff; box-shadow: 0 0 0 1px var(--pz-line), var(--pz-shadow); }
|
||||
.psp-face-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.psp-face-id { flex: 1; min-width: 0; display: grid; gap: 1px; }
|
||||
.psp-face-name { font: 400 17px/1.25 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.psp-face-years { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
|
||||
.psp-face-error { font: 400 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); }
|
||||
.psp-face-done { color: var(--pz-success); }
|
||||
.psp-face .pz-btn { flex: none; gap: 6px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.psp { gap: 18px; }
|
||||
.psp-block--folder { padding: 16px; }
|
||||
.psp-face .pz-btn--soft { padding: 0 12px; }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, FolderOpen, ImagePlus, X } from 'lucide-react'
|
||||
import './PhotoStartPanel.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { matchPhotoFiles } from '../utils/photoImport.js'
|
||||
|
||||
function withEmphasis(text) {
|
||||
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
|
||||
return <>{before}<em>{emphasized}</em>{after}</>
|
||||
}
|
||||
|
||||
function lifeYears(person) {
|
||||
if (!person.birthYear) return ''
|
||||
return person.deathYear ? `${person.birthYear} – ${person.deathYear}` : String(person.birthYear)
|
||||
}
|
||||
|
||||
// Singulier ou pluriel, les deux formes passent par t()
|
||||
function plural(n, one, many, vars = {}) {
|
||||
return n === 1 ? t(one, vars) : t(many, { n, ...vars })
|
||||
}
|
||||
|
||||
function initialsOf(person) {
|
||||
return `${person.firstName?.[0] || ''}${person.lastName?.[0] || ''}`.toUpperCase() || '?'
|
||||
}
|
||||
|
||||
// Dossier où le logiciel d'origine rangeait la première photo citée, pour aider à le retrouver
|
||||
function folderOf(filePath) {
|
||||
const parts = String(filePath || '').split(/([\\/])/)
|
||||
if (parts.length < 3) return ''
|
||||
return parts.slice(0, -2).join('')
|
||||
}
|
||||
|
||||
// Après un import GEDCOM (ou depuis les paramètres) : retrouver les photos que le logiciel connaissait,
|
||||
// et demander les premiers visages un par un.
|
||||
export default function PhotoStartPanel({
|
||||
visible,
|
||||
personsImported = null,
|
||||
photoRefs = [],
|
||||
faces = [],
|
||||
onUploadOne,
|
||||
onBulkPlace,
|
||||
onRefsChange,
|
||||
onClose,
|
||||
}) {
|
||||
const folderInputRef = useRef(null)
|
||||
const faceInputRef = useRef(null)
|
||||
const faceTargetRef = useRef(null)
|
||||
const [remaining, setRemaining] = useState(photoRefs)
|
||||
const [found, setFound] = useState(null)
|
||||
const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, failed: 0 })
|
||||
const [faceState, setFaceState] = useState({})
|
||||
const [shownFaces, setShownFaces] = useState(faces)
|
||||
const [placedTotal, setPlacedTotal] = useState(0)
|
||||
|
||||
// La liste des visages et les chemins sont figés à l'ouverture : une ligne ne disparaît pas sous le doigt
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
setRemaining(photoRefs)
|
||||
setFound(null)
|
||||
setBulk({ running: false, done: 0, total: 0, placed: null, failed: 0 })
|
||||
setFaceState({})
|
||||
setShownFaces(faces)
|
||||
setPlacedTotal(0)
|
||||
}, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return undefined
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !bulk.running) onClose?.() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [visible, onClose, bulk.running])
|
||||
|
||||
// Les aperçus locaux sont libérés quand la fenêtre se ferme
|
||||
const previewsRef = useRef([])
|
||||
useEffect(() => {
|
||||
if (visible) return undefined
|
||||
previewsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
||||
previewsRef.current = []
|
||||
return undefined
|
||||
}, [visible])
|
||||
|
||||
const exampleFolder = useMemo(() => folderOf(remaining[0]?.file), [remaining])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const handleFolder = (event) => {
|
||||
const files = Array.from(event.target.files || [])
|
||||
event.target.value = ''
|
||||
if (files.length === 0) return
|
||||
setBulk((b) => ({ ...b, placed: null, failed: 0 }))
|
||||
setFound(matchPhotoFiles(remaining, files))
|
||||
}
|
||||
|
||||
const handlePlace = async () => {
|
||||
if (!found?.matches.length || bulk.running) return
|
||||
const total = found.matches.length
|
||||
setBulk({ running: true, done: 0, total, placed: null, failed: 0 })
|
||||
const result = await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done })))
|
||||
const placedIds = new Set(result.placedIds)
|
||||
// Une personne de la liste des visages dont la photo vient d'être placée passe en « Ajoutée »
|
||||
const shownIds = new Set(shownFaces.map((p) => String(p.id)))
|
||||
const placedFaces = {}
|
||||
for (const match of found.matches) {
|
||||
if (!placedIds.has(match.personId) || !shownIds.has(String(match.personId))) continue
|
||||
const preview = URL.createObjectURL(match.file)
|
||||
previewsRef.current.push(preview)
|
||||
placedFaces[match.personId] = { busy: false, error: '', preview, fromFolder: true }
|
||||
}
|
||||
setFaceState((s) => ({ ...s, ...placedFaces }))
|
||||
const nextRemaining = remaining.filter((ref) => !placedIds.has(ref.personId))
|
||||
setRemaining(nextRemaining)
|
||||
onRefsChange?.(nextRemaining)
|
||||
setFound(null)
|
||||
setPlacedTotal((n) => n + placedIds.size)
|
||||
setBulk({ running: false, done: total, total, placed: placedIds.size, failed: total - placedIds.size })
|
||||
}
|
||||
|
||||
const pickFace = (personId) => {
|
||||
faceTargetRef.current = personId
|
||||
faceInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFaceFile = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
const personId = faceTargetRef.current
|
||||
if (!file || !personId) return
|
||||
setFaceState((s) => ({ ...s, [personId]: { ...s[personId], busy: true, error: '' } }))
|
||||
const error = await onUploadOne(personId, file)
|
||||
setFaceState((s) => {
|
||||
const previous = s[personId]
|
||||
if (error) return { ...s, [personId]: { ...previous, busy: false, error } }
|
||||
const preview = URL.createObjectURL(file)
|
||||
previewsRef.current.push(preview)
|
||||
return { ...s, [personId]: { busy: false, error: '', preview } }
|
||||
})
|
||||
}
|
||||
|
||||
const refsKnown = photoRefs.length > 0
|
||||
const addedCount = Object.values(faceState).filter((s) => s?.preview && !s.fromFolder).length + placedTotal
|
||||
|
||||
return (
|
||||
<div className="pz-overlay psp-overlay">
|
||||
<div className="pz-modal psp" role="dialog" aria-modal="true" aria-labelledby="pspTitle">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} disabled={bulk.running} aria-label={t('Fermer')}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<div className="pz-modal-head">
|
||||
{personsImported != null && <p className="pz-eyebrow">{t('Import terminé')}</p>}
|
||||
<h2 id="pspTitle" className="pz-title">{withEmphasis(t('Il manque les <em>visages</em>'))}</h2>
|
||||
<p className="pz-sub">
|
||||
{personsImported != null
|
||||
? t('{n} personnes sont dans votre arbre. Avec une photo, la famille se reconnaît tout de suite.', { n: personsImported.toLocaleString() })
|
||||
: t('Avec une photo, la famille se reconnaît tout de suite.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{refsKnown && (
|
||||
<section className="psp-block psp-block--folder" aria-labelledby="pspFolderTitle">
|
||||
<h3 id="pspFolderTitle" className="psp-h">{t('Vos photos sont déjà sur votre ordinateur')}</h3>
|
||||
{remaining.length > 0 ? (
|
||||
<>
|
||||
<p className="psp-p">
|
||||
{plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')}
|
||||
</p>
|
||||
{exampleFolder && (
|
||||
<p className="pz-hint">{t('Il les rangeait dans {folder}', { folder: exampleFolder })}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="psp-p">{t('Toutes les photos de votre logiciel sont placées.')}</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="pz-sr"
|
||||
tabIndex={-1}
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={handleFolder}
|
||||
/>
|
||||
|
||||
{bulk.running ? (
|
||||
<div className="psp-progress" role="status">
|
||||
<div className="psp-bar"><span style={{ width: `${Math.round((bulk.done / Math.max(1, bulk.total)) * 100)}%` }} /></div>
|
||||
<p className="pz-small">{t('Envoi de {done} sur {total}', { done: bulk.done, total: bulk.total })}</p>
|
||||
</div>
|
||||
) : found ? (
|
||||
<div className="psp-found">
|
||||
{found.matches.length > 0 ? (
|
||||
<>
|
||||
<p className="psp-p"><strong>{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}</strong></p>
|
||||
<div className="psp-row-actions">
|
||||
<button type="button" className="pz-btn pz-btn--accent" onClick={handlePlace}>
|
||||
{plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => folderInputRef.current?.click()}>
|
||||
{t('Un autre dossier')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="pz-error">{t("Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.")}</p>
|
||||
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
|
||||
{t('Choisir un autre dossier')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : remaining.length > 0 ? (
|
||||
<button type="button" className="pz-btn pz-btn--accent psp-folder-btn" onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
|
||||
{bulk.placed != null ? t('Choisir un autre dossier') : t('Choisir le dossier des photos')}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{bulk.placed != null && !bulk.running && (
|
||||
<p className="pz-success" role="status">
|
||||
{plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
|
||||
{bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`}
|
||||
</p>
|
||||
)}
|
||||
{!bulk.running && remaining.length > 0 && (
|
||||
<p className="pz-hint">{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shownFaces.length > 0 && (
|
||||
<section className="psp-block" aria-labelledby="pspFacesTitle">
|
||||
<h3 id="pspFacesTitle" className="psp-h">{refsKnown ? t('Ou commencez par ces visages') : t('Commencez par ces visages')}</h3>
|
||||
<input ref={faceInputRef} type="file" accept="image/*" className="pz-sr" tabIndex={-1} onChange={handleFaceFile} />
|
||||
<ul className="psp-faces">
|
||||
{shownFaces.map((person) => {
|
||||
const state = faceState[person.id] || {}
|
||||
const years = lifeYears(person)
|
||||
return (
|
||||
<li key={person.id} className="psp-face">
|
||||
<span className={`psp-face-photo ${state.preview ? 'is-set' : ''}`} aria-hidden="true">
|
||||
{state.preview ? <img src={state.preview} alt="" /> : initialsOf(person)}
|
||||
</span>
|
||||
<span className="psp-face-id">
|
||||
<span className="psp-face-name">{`${person.firstName} ${person.lastName || ''}`.trim()}</span>
|
||||
{years && <span className="psp-face-years">{years}</span>}
|
||||
{state.error && <span className="psp-face-error">{state.error}</span>}
|
||||
</span>
|
||||
{state.preview && !state.busy ? (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm psp-face-done" onClick={() => pickFace(person.id)}>
|
||||
<Check size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('Ajoutée')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="pz-btn pz-btn--soft pz-btn--sm" onClick={() => pickFace(person.id)} disabled={state.busy || bulk.running}>
|
||||
<PzBusy busy={Boolean(state.busy)} busyLabel={t('Envoi')}>
|
||||
<ImagePlus size={16} strokeWidth={2} aria-hidden="true" />
|
||||
{t('Ajouter une photo')}
|
||||
</PzBusy>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="pz-modal-actions pz-modal-actions--split">
|
||||
<p className="pz-small">
|
||||
{addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')}
|
||||
</p>
|
||||
<button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={bulk.running}>
|
||||
{t("Voir l'arbre")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
import { FileUp, ImagePlus } from 'lucide-react'
|
||||
import './TreeCreationWizard.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
@@ -10,6 +10,9 @@ const INITIAL_STATE = {
|
||||
selfLastName: '',
|
||||
selfBirthYear: '',
|
||||
photoFile: null,
|
||||
// « J'ai déjà un arbre » : fichier GEDCOM exporté d'un logiciel, importé à la création
|
||||
start: 'self',
|
||||
gedcomFile: null,
|
||||
}
|
||||
|
||||
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
|
||||
@@ -46,8 +49,9 @@ function TreeCreationWizard({
|
||||
const canContinue = useMemo(() => form.treeName.trim().length >= 2 && !loading, [form.treeName, loading])
|
||||
const canCreate = useMemo(() => {
|
||||
if (loading) return false
|
||||
if (form.start === 'import') return Boolean(form.gedcomFile)
|
||||
return form.selfFirstName.trim().length > 0 && form.selfLastName.trim().length > 0
|
||||
}, [form.selfFirstName, form.selfLastName, loading])
|
||||
}, [form.selfFirstName, form.selfLastName, form.start, form.gedcomFile, loading])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
@@ -68,6 +72,11 @@ function TreeCreationWizard({
|
||||
if (!canCreate) return
|
||||
|
||||
setError('')
|
||||
if (form.start === 'import') {
|
||||
const submitError = await onSubmit({ treeName: form.treeName.trim(), gedcomFile: form.gedcomFile })
|
||||
if (submitError) setError(submitError)
|
||||
return
|
||||
}
|
||||
const submitError = await onSubmit({
|
||||
treeName: form.treeName.trim(),
|
||||
selfFirstName: form.selfFirstName.trim(),
|
||||
@@ -123,6 +132,40 @@ function TreeCreationWizard({
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handleCreate} className="tw-form">
|
||||
<div className="pz-tabs tw-start" role="tablist" aria-label={t('Point de départ')}>
|
||||
<button type="button" role="tab" aria-selected={form.start === 'self'} className={`pz-tab ${form.start === 'self' ? 'is-active' : ''}`} onClick={() => updateField('start', 'self')}>
|
||||
{t('Je pars de moi')}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={form.start === 'import'} className={`pz-tab ${form.start === 'import' ? 'is-active' : ''}`} onClick={() => updateField('start', 'import')}>
|
||||
{t("J'ai déjà un arbre")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.start === 'import' ? (
|
||||
<>
|
||||
<div className="pz-modal-head">
|
||||
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Importez <em>votre arbre</em>'))}</h2>
|
||||
<p className="pz-sub">{t('Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.')}</p>
|
||||
</div>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="wizardGedcom" className="tw-photo">
|
||||
<span className="tw-photo-icon" aria-hidden="true"><FileUp size={20} strokeWidth={1.8} /></span>
|
||||
<span className="tw-photo-text">
|
||||
<strong>{form.gedcomFile ? form.gedcomFile.name : t('Choisir le fichier .ged')}</strong>
|
||||
<span>{form.gedcomFile ? t('Cliquez pour en choisir un autre') : t('Exporté depuis le logiciel où vous tenez votre arbre')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="wizardGedcom"
|
||||
className="tw-photo-input"
|
||||
type="file"
|
||||
accept=".ged,text/x-gedcom,text/plain"
|
||||
onChange={(event) => updateField('gedcomFile', event.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="pz-modal-head">
|
||||
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Commencez <em>par vous</em>'))}</h2>
|
||||
<p className="pz-sub">{t("L'arbre « {name} » démarre avec votre fiche. Vous ajouterez les autres ensuite.", { name: form.treeName.trim() })}</p>
|
||||
@@ -186,6 +229,8 @@ function TreeCreationWizard({
|
||||
onChange={(event) => updateField('photoFile', event.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="pz-error tree-wizard-error" role="alert">{error}</div>}
|
||||
|
||||
@@ -194,7 +239,9 @@ function TreeCreationWizard({
|
||||
{t('Retour')}
|
||||
</button>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canCreate}>
|
||||
<PzBusy busy={loading} busyLabel={t("Création de l'arbre")}>{t('Créer mon arbre')}</PzBusy>
|
||||
<PzBusy busy={loading} busyLabel={form.start === 'import' ? t('Import en cours') : t("Création de l'arbre")}>
|
||||
{form.start === 'import' ? t('Importer mon arbre') : t('Créer mon arbre')}
|
||||
</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -311,13 +311,16 @@ export function computeLayout(graphData = null) {
|
||||
allNodes.push(node)
|
||||
}
|
||||
|
||||
// Unions virtuelles (parent unique → inconnu)
|
||||
// Unions virtuelles (parent unique → inconnu) : une par parent, ses enfants sans autre parent connu
|
||||
// partagent le même « Inconnu » (frères et sœurs d'un GEDCOM où un seul parent est renseigné)
|
||||
const virtualUnions = []
|
||||
const virtualByParent = new Set()
|
||||
normalizedFiliations.forEach(f => {
|
||||
if (!f.unionId && f.parentId) {
|
||||
if (!f.unionId && f.parentId && !virtualByParent.has(f.parentId)) {
|
||||
virtualByParent.add(f.parentId)
|
||||
virtualUnions.push({
|
||||
elkId: `vu-${f.id}`,
|
||||
unknownElkId: `unknown-${f.id}`,
|
||||
elkId: `vu-${f.parentId}`,
|
||||
unknownElkId: `unknown-${f.parentId}`,
|
||||
parentId: f.parentId,
|
||||
filiation: f,
|
||||
})
|
||||
@@ -382,7 +385,7 @@ export function computeLayout(graphData = null) {
|
||||
// union/vu elkId → [child elkId]
|
||||
const childrenBySourceId = new Map()
|
||||
normalizedFiliations.forEach(f => {
|
||||
const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.id}`
|
||||
const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.parentId}`
|
||||
const targetId = `p-${f.childId}`
|
||||
if (!childrenBySourceId.has(sourceId)) childrenBySourceId.set(sourceId, [])
|
||||
childrenBySourceId.get(sourceId).push(targetId)
|
||||
@@ -515,7 +518,7 @@ export function computeLayout(graphData = null) {
|
||||
.map(e => ({ ...e }))
|
||||
|
||||
normalizedFiliations.forEach(f => {
|
||||
const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.id}`
|
||||
const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.parentId}`
|
||||
const targetId = `p-${f.childId}`
|
||||
if (!validNodeIds.has(sourceId) || !validNodeIds.has(targetId)) return
|
||||
allEdges.push({
|
||||
|
||||
@@ -426,7 +426,7 @@ function measureTextCached(ctx, text, fontKey) {
|
||||
// ============================================================
|
||||
function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
nodeRandomData, imageCache, hoverScales, selectedGlowScales, searchGlowScales,
|
||||
mediaHoverState, entrance, entranceActive, elapsed, view = null,
|
||||
mediaHoverState, entrance, entranceActive, elapsed, view = null, photoHint = false,
|
||||
}) {
|
||||
const { cx, cy } = pos
|
||||
const person = getPersonById(node._data.id) || node._data
|
||||
@@ -562,7 +562,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
ctx.shadowOffsetY = shadowY
|
||||
}
|
||||
|
||||
drawFrame(ctx, frameType, person, img)
|
||||
drawFrame(ctx, frameType, person, img, photoHint && !img ? hs : 0)
|
||||
ctx.restore()
|
||||
|
||||
// --- Labels (estompés puis absents en dézoom) ---
|
||||
@@ -880,7 +880,7 @@ function resolveFrameType(person, img) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function drawFrame(ctx, frameType, person, img) {
|
||||
function drawFrame(ctx, frameType, person, img, hint = 0) {
|
||||
if (frameType === 'polaroid') {
|
||||
const pw = PERSON_R + 4
|
||||
const ph = PERSON_R + 4
|
||||
@@ -898,7 +898,7 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(-pw, -ph, pw * 2, ph * 2, 2)
|
||||
ctx.clip()
|
||||
drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2)
|
||||
drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2, hint)
|
||||
} else if (frameType === 'square') {
|
||||
const s = PERSON_R + 4
|
||||
const pad = 3
|
||||
@@ -914,7 +914,7 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(-s, -s, s * 2, s * 2, 2)
|
||||
ctx.clip()
|
||||
drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2)
|
||||
drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2, hint)
|
||||
} else if (frameType === 'rect') {
|
||||
const ratio = img?.naturalWidth && img?.naturalHeight
|
||||
? img.naturalWidth / img.naturalHeight
|
||||
@@ -936,7 +936,7 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(-rw, -rh, rw * 2, rh * 2, 3)
|
||||
ctx.clip()
|
||||
drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2)
|
||||
drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2, hint)
|
||||
} else {
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
@@ -951,14 +951,14 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, PERSON_R, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2)
|
||||
drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2, hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dessiner l'image ou le placeholder initiales
|
||||
// ============================================================
|
||||
function drawPersonImage(ctx, img, person, x, y, w, h) {
|
||||
function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) {
|
||||
if (img) {
|
||||
drawImageCover(ctx, img, x, y, w, h)
|
||||
if (!person.isAlive) {
|
||||
@@ -968,11 +968,27 @@ function drawPersonImage(ctx, img, person, x, y, w, h) {
|
||||
} else {
|
||||
ctx.fillStyle = person.isAlive ? COLORS.nodeAlive : COLORS.nodeDead
|
||||
ctx.fillRect(x, y, w, h)
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '400 16px "Newsreader", Georgia, serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
// Au survol, chez le propriétaire, les initiales cèdent la place à « + photo »
|
||||
const baseAlpha = ctx.globalAlpha
|
||||
if (hint > 0.01) {
|
||||
ctx.fillStyle = `rgba(248, 228, 219, ${hint})`
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
ctx.globalAlpha = baseAlpha * (1 - hint)
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '400 16px "Newsreader", Georgia, serif'
|
||||
ctx.fillText((person.firstName[0] || '') + (person.lastName[0] || ''), 0, 0)
|
||||
if (hint > 0.01) {
|
||||
ctx.globalAlpha = baseAlpha * hint
|
||||
ctx.fillStyle = '#93402A'
|
||||
ctx.font = '300 22px "DM Sans", system-ui, sans-serif'
|
||||
ctx.fillText('+', 0, -6)
|
||||
ctx.font = '500 9.5px "DM Sans", system-ui, sans-serif'
|
||||
ctx.fillText(t('photo'), 0, 10)
|
||||
}
|
||||
ctx.globalAlpha = baseAlpha
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,22 +163,32 @@ export function loadGraphData(graph, options = {}) {
|
||||
const unionIdSet = new Set(unions.map((union) => union.id))
|
||||
|
||||
const seenUnionChildKey = new Set()
|
||||
const seenDirectKey = new Set()
|
||||
filiations = remoteFiliations
|
||||
.map((filiation, index) => ({
|
||||
id: filiation.id,
|
||||
childId: filiation.childPersonId,
|
||||
unionId: filiation.viaUnionId || null,
|
||||
parentId: filiation.viaUnionId ? null : filiation.parentPersonId,
|
||||
parentageType: filiation.parentageType || 'biologique',
|
||||
displayOrder: Number.isFinite(filiation.displayOrder) ? filiation.displayOrder : index + 1,
|
||||
}))
|
||||
.map((filiation, index) => {
|
||||
// Union à un seul parent connu (fréquente dans un GEDCOM) : l'union est écartée plus haut, l'enfant
|
||||
// est relié directement à ce parent, sinon il flotterait dans l'arbre sans aucun lien
|
||||
const viaKeptUnion = filiation.viaUnionId && unionIdSet.has(filiation.viaUnionId)
|
||||
return {
|
||||
id: filiation.id,
|
||||
childId: filiation.childPersonId,
|
||||
unionId: viaKeptUnion ? filiation.viaUnionId : null,
|
||||
parentId: viaKeptUnion ? null : filiation.parentPersonId,
|
||||
parentageType: filiation.parentageType || 'biologique',
|
||||
displayOrder: Number.isFinite(filiation.displayOrder) ? filiation.displayOrder : index + 1,
|
||||
}
|
||||
})
|
||||
.filter((filiation) => {
|
||||
if (!personIdSet.has(filiation.childId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!filiation.unionId) {
|
||||
return filiation.parentId ? personIdSet.has(filiation.parentId) : false
|
||||
if (!filiation.parentId || !personIdSet.has(filiation.parentId)) return false
|
||||
const key = `${filiation.parentId}:${filiation.childId}`
|
||||
if (seenDirectKey.has(key)) return false
|
||||
seenDirectKey.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!unionIdSet.has(filiation.unionId)) {
|
||||
|
||||
@@ -55,3 +55,24 @@ describe('loadGraphData avatar mapping', () => {
|
||||
expect(getPersonById('p-2')?.immichPersonId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadGraphData, union à un seul parent', () => {
|
||||
it('relie les enfants directement au parent connu au lieu de les perdre', async () => {
|
||||
const { getParents, getChildren } = await import('./mockData')
|
||||
const person = (id, firstName) => ({ id, firstName, lastName: 'Lemaire', birthDate: null, deathDate: null, notes: null, avatarUrlPath: null })
|
||||
loadGraphData({
|
||||
persons: [person('julien', 'Julien'), person('hugo', 'Hugo'), person('lea', 'Léa')],
|
||||
unions: [{ id: 'u-seul', partner1PersonId: 'julien', partner2PersonId: null }],
|
||||
filiations: [
|
||||
{ id: 'f-1', parentPersonId: 'julien', childPersonId: 'hugo', viaUnionId: 'u-seul' },
|
||||
{ id: 'f-2', parentPersonId: 'julien', childPersonId: 'lea', viaUnionId: 'u-seul' },
|
||||
{ id: 'f-3', parentPersonId: 'julien', childPersonId: 'lea', viaUnionId: 'u-seul' },
|
||||
],
|
||||
medias: [],
|
||||
annotations: [],
|
||||
})
|
||||
|
||||
expect(getParents('hugo').map((p) => p.id)).toEqual(['julien'])
|
||||
expect(getChildren('julien').map((p) => p.id).sort()).toEqual(['hugo', 'lea'])
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import arbre from './arbre.js'
|
||||
import fiche from './fiche.js'
|
||||
import contribution from './contribution.js'
|
||||
import compte from './compte.js'
|
||||
import photos from './photos.js'
|
||||
|
||||
export default {
|
||||
...app,
|
||||
@@ -11,5 +12,6 @@ export default {
|
||||
...fiche,
|
||||
...contribution,
|
||||
...compte,
|
||||
...photos,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Traductions anglaises, clé = texte français exact passé à t().
|
||||
// Lot « photos » : premiers visages après un import, photos retrouvées, import dès la création de l'arbre.
|
||||
export default {
|
||||
// Fenêtre des premiers visages (PhotoStartPanel)
|
||||
"Import terminé": "Import complete",
|
||||
"Il manque les <em>visages</em>": "The <em>faces</em> are missing",
|
||||
"{n} personnes sont dans votre arbre. Avec une photo, la famille se reconnaît tout de suite.": "{n} people are in your tree. With a photo, the family recognises each other straight away.",
|
||||
"Avec une photo, la famille se reconnaît tout de suite.": "With a photo, the family recognises each other straight away.",
|
||||
"Vos photos sont déjà sur votre ordinateur": "Your photos are already on your computer",
|
||||
"Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.": "Your software had a photo for 1 person. Choose the folder where it keeps it, and Parenthèse places the portrait.",
|
||||
"Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.": "Your software had a photo for {n} people. Choose the folder where it keeps them, and Parenthèse places each portrait.",
|
||||
"Il les rangeait dans {folder}": "It kept them in {folder}",
|
||||
"Toutes les photos de votre logiciel sont placées.": "All the photos from your software are in place.",
|
||||
"Envoi de {done} sur {total}": "Sending {done} of {total}",
|
||||
"1 photo retrouvée sur {k}.": "1 photo found out of {k}.",
|
||||
"{n} photos retrouvées sur {k}.": "{n} photos found out of {k}.",
|
||||
"Placer ce portrait": "Place this portrait",
|
||||
"Placer les {n} portraits": "Place the {n} portraits",
|
||||
"Un autre dossier": "Another folder",
|
||||
"Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.": "No photo in this folder matches. Try the folder shown above, or the one that contains it.",
|
||||
"Choisir un autre dossier": "Choose another folder",
|
||||
"Choisir le dossier des photos": "Choose the photo folder",
|
||||
"1 portrait placé.": "1 portrait placed.",
|
||||
"{n} portraits placés.": "{n} portraits placed.",
|
||||
"{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).": "{f} couldn't be sent (unsupported format or file too large).",
|
||||
"Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.": "Only the photos that match a person are sent, and only to your tree.",
|
||||
"Ou commencez par ces visages": "Or start with these faces",
|
||||
"Commencez par ces visages": "Start with these faces",
|
||||
"Envoi": "Sending",
|
||||
"Ajoutée": "Added",
|
||||
"1 photo ajoutée.": "1 photo added.",
|
||||
"{n} photos ajoutées.": "{n} photos added.",
|
||||
"Vous pourrez en ajouter plus tard depuis chaque fiche.": "You can add more later from each profile.",
|
||||
"Voir l'arbre": "See the tree",
|
||||
"Session expirée, reconnectez-vous.": "Session expired, please sign in again.",
|
||||
|
||||
// Portrait vide sur l'arbre, au survol
|
||||
"photo": "photo",
|
||||
|
||||
// Paramètres de l'arbre
|
||||
"Ajouter des portraits": "Add portraits",
|
||||
"Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.": "Bring back the photos from your old software, or add the first faces one by one.",
|
||||
|
||||
// Création de l'arbre, « J'ai déjà un arbre »
|
||||
"Point de départ": "Starting point",
|
||||
"Je pars de moi": "Start with me",
|
||||
"J'ai déjà un arbre": "I already have a tree",
|
||||
"Importez <em>votre arbre</em>": "Import <em>your tree</em>",
|
||||
"Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.": "Heredis, Geneanet, Gramps, MyHeritage and Ancestry can all export a .ged file. Parenthèse brings in the people, links and dates, then helps you find the photos.",
|
||||
"Choisir le fichier .ged": "Choose the .ged file",
|
||||
"Cliquez pour en choisir un autre": "Click to choose another one",
|
||||
"Exporté depuis le logiciel où vous tenez votre arbre": "Exported from the software where you keep your tree",
|
||||
"Importer mon arbre": "Import my tree",
|
||||
"Votre arbre est créé, mais l'import n'a pas abouti.": "Your tree is created, but the import didn't go through.",
|
||||
"Vous pouvez réessayer depuis les paramètres de l'arbre.": "You can try again from the tree settings.",
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Photos après un import GEDCOM : relier les chemins cités par le logiciel d'origine aux fichiers du dossier
|
||||
// choisi par la personne, et choisir les premiers visages à demander.
|
||||
|
||||
import { MAX_IMAGE_BYTES } from './mediaUpload.js'
|
||||
|
||||
const MAX_SIDE = 2000
|
||||
const JPEG_QUALITY = 0.88
|
||||
|
||||
function normalizeName(value) {
|
||||
return String(value || '').normalize('NFC').toLowerCase()
|
||||
}
|
||||
|
||||
// Dernier élément d'un chemin Windows, macOS ou Linux
|
||||
export function baseNameOf(filePath) {
|
||||
return String(filePath || '').split(/[\\/]/).filter(Boolean).pop() || ''
|
||||
}
|
||||
|
||||
function pathParts(filePath) {
|
||||
return normalizeName(filePath).split(/[\\/]/).filter(Boolean)
|
||||
}
|
||||
|
||||
// Nombre de dossiers communs en partant du nom de fichier : départage deux « portrait.jpg »
|
||||
function commonSuffixLength(a, b) {
|
||||
const pa = pathParts(a)
|
||||
const pb = pathParts(b)
|
||||
let n = 0
|
||||
while (n < pa.length && n < pb.length && pa[pa.length - 1 - n] === pb[pb.length - 1 - n]) n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
// photoRefs : [{ personId, file }] renvoyé par l'import. files : les File du dossier choisi
|
||||
// (webkitRelativePath renseigné). Renvoie les paires trouvées et les références sans fichier.
|
||||
export function matchPhotoFiles(photoRefs, files) {
|
||||
const byName = new Map()
|
||||
for (const file of files || []) {
|
||||
const key = normalizeName(file.name)
|
||||
if (!key) continue
|
||||
if (!byName.has(key)) byName.set(key, [])
|
||||
byName.get(key).push(file)
|
||||
}
|
||||
|
||||
const matches = []
|
||||
const missing = []
|
||||
for (const ref of photoRefs || []) {
|
||||
const candidates = byName.get(normalizeName(baseNameOf(ref.file))) || []
|
||||
if (candidates.length === 0) {
|
||||
missing.push(ref)
|
||||
continue
|
||||
}
|
||||
let best = candidates[0]
|
||||
let bestScore = -1
|
||||
for (const candidate of candidates) {
|
||||
const score = commonSuffixLength(ref.file, candidate.webkitRelativePath || candidate.name)
|
||||
if (score > bestScore) {
|
||||
best = candidate
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
matches.push({ personId: ref.personId, file: best })
|
||||
}
|
||||
return { matches, missing }
|
||||
}
|
||||
|
||||
// Les premiers visages à demander : la famille proche de « Moi » si on la connaît, sinon les générations
|
||||
// les plus récentes, dont les photos sont sous la main. Seulement des personnes sans portrait.
|
||||
export function pickFirstFaces({ persons, selfId = null, getParents, getChildren, getPartners, limit = 5 }) {
|
||||
const withoutPhoto = (persons || []).filter((p) => !p.photo)
|
||||
if (withoutPhoto.length === 0) return []
|
||||
const wanted = new Set(withoutPhoto.map((p) => String(p.id)))
|
||||
const byId = new Map((persons || []).map((p) => [String(p.id), p]))
|
||||
|
||||
const picked = []
|
||||
if (selfId && byId.has(String(selfId))) {
|
||||
const seen = new Set([String(selfId)])
|
||||
let frontier = [String(selfId)]
|
||||
if (wanted.has(String(selfId))) picked.push(byId.get(String(selfId)))
|
||||
while (frontier.length > 0 && picked.length < limit) {
|
||||
const next = []
|
||||
for (const id of frontier) {
|
||||
const around = [
|
||||
...(getParents?.(id) || []),
|
||||
...(getPartners?.(id) || []),
|
||||
...(getChildren?.(id) || []),
|
||||
]
|
||||
for (const person of around) {
|
||||
const key = String(person?.id ?? '')
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
next.push(key)
|
||||
}
|
||||
}
|
||||
const ring = next
|
||||
.filter((key) => wanted.has(key))
|
||||
.map((key) => byId.get(key))
|
||||
.sort(byRecentBirth)
|
||||
for (const person of ring) {
|
||||
if (picked.length >= limit) break
|
||||
picked.push(person)
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
}
|
||||
|
||||
if (picked.length < limit) {
|
||||
const already = new Set(picked.map((p) => String(p.id)))
|
||||
const rest = withoutPhoto.filter((p) => !already.has(String(p.id))).sort(byRecentBirth)
|
||||
picked.push(...rest.slice(0, limit - picked.length))
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
function byRecentBirth(a, b) {
|
||||
const ya = Number.isFinite(a?.birthYear) ? a.birthYear : -Infinity
|
||||
const yb = Number.isFinite(b?.birthYear) ? b.birthYear : -Infinity
|
||||
if (ya !== yb) return yb - ya
|
||||
if (Boolean(a?.isAlive) !== Boolean(b?.isAlive)) return a?.isAlive ? -1 : 1
|
||||
return 0
|
||||
}
|
||||
|
||||
// Une photo de téléphone dépasse souvent 5 Mo : elle est réduite à 2000 px en JPEG avant l'envoi.
|
||||
// Un format que le navigateur ne sait pas lire (HEIC sur Chrome) part tel quel, le serveur tranche.
|
||||
export async function prepareImageForUpload(file) {
|
||||
if (!file || !String(file.type || '').startsWith('image/') || file.type === 'image/gif') return file
|
||||
let bitmap
|
||||
try {
|
||||
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
const largest = Math.max(bitmap.width, bitmap.height)
|
||||
const isSimpleFormat = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
if (largest <= MAX_SIDE && file.size <= MAX_IMAGE_BYTES && isSimpleFormat) {
|
||||
bitmap.close?.()
|
||||
return file
|
||||
}
|
||||
const ratio = Math.min(1, MAX_SIDE / largest)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * ratio))
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * ratio))
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
bitmap.close?.()
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY))
|
||||
if (!blob) return file
|
||||
const name = baseNameOf(file.name).replace(/\.[^.]+$/, '') || 'photo'
|
||||
return new File([blob], `${name}.jpg`, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
// Les chemins de l'import, gardés sur l'appareil pour rouvrir l'étape plus tard (rien côté serveur)
|
||||
const REFS_KEY = (treeId) => `parenthese_photo_refs:${treeId}`
|
||||
|
||||
export function savePhotoRefs(treeId, refs) {
|
||||
if (!treeId) return
|
||||
try {
|
||||
if (refs?.length) localStorage.setItem(REFS_KEY(treeId), JSON.stringify(refs))
|
||||
else localStorage.removeItem(REFS_KEY(treeId))
|
||||
} catch {
|
||||
// Stockage plein ou indisponible : l'étape ne se rouvrira pas, l'import reste fait
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPhotoRefs(treeId) {
|
||||
if (!treeId) return []
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(REFS_KEY(treeId)) || '[]')
|
||||
return Array.isArray(parsed) ? parsed.filter((r) => r && r.personId && r.file) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Effectifs arrondis pour PostHog : un ordre de grandeur, jamais un compte exact d'un arbre
|
||||
export function sizeBucket(n) {
|
||||
if (!n) return '0'
|
||||
if (n < 10) return '1-9'
|
||||
if (n < 50) return '10-49'
|
||||
if (n < 200) return '50-199'
|
||||
if (n < 1000) return '200-999'
|
||||
return '1000+'
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { baseNameOf, matchPhotoFiles, pickFirstFaces, sizeBucket } from './photoImport.js'
|
||||
|
||||
const file = (name, webkitRelativePath = name) => ({ name, webkitRelativePath })
|
||||
|
||||
describe('baseNameOf', () => {
|
||||
it('lit le nom de fichier d\'un chemin Windows, macOS ou relatif', () => {
|
||||
expect(baseNameOf('C:\\Genealogie\\Photos\\jean.jpg')).toBe('jean.jpg')
|
||||
expect(baseNameOf('/Users/anne/Heredis/marie.png')).toBe('marie.png')
|
||||
expect(baseNameOf('photos/robert 1920.jpg')).toBe('robert 1920.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchPhotoFiles', () => {
|
||||
it('relie chaque chemin du GEDCOM au fichier du même nom, sans tenir compte de la casse', () => {
|
||||
const refs = [
|
||||
{ personId: 'a', file: 'C:\\Photos\\Jean.JPG' },
|
||||
{ personId: 'b', file: 'C:\\Photos\\marie.jpg' },
|
||||
{ personId: 'c', file: 'C:\\Photos\\absente.jpg' },
|
||||
]
|
||||
const { matches, missing } = matchPhotoFiles(refs, [file('jean.jpg', 'Photos/jean.jpg'), file('marie.jpg', 'Photos/marie.jpg')])
|
||||
expect(matches.map((m) => [m.personId, m.file.name])).toEqual([['a', 'jean.jpg'], ['b', 'marie.jpg']])
|
||||
expect(missing.map((m) => m.personId)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('départage deux fichiers du même nom par les dossiers du chemin', () => {
|
||||
const refs = [{ personId: 'a', file: 'D:\\Arbre\\Dupont\\portrait.jpg' }]
|
||||
const files = [file('portrait.jpg', 'Arbre/Martin/portrait.jpg'), file('portrait.jpg', 'Arbre/Dupont/portrait.jpg')]
|
||||
expect(matchPhotoFiles(refs, files).matches[0].file.webkitRelativePath).toBe('Arbre/Dupont/portrait.jpg')
|
||||
})
|
||||
|
||||
it('rapproche un nom accentué écrit sous deux formes Unicode', () => {
|
||||
const refs = [{ personId: 'a', file: 'Hélène.jpg' }]
|
||||
const decomposed = 'He\u0301le\u0300ne.jpg'
|
||||
expect(matchPhotoFiles(refs, [file(decomposed)]).matches).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickFirstFaces', () => {
|
||||
const people = [
|
||||
{ id: 'moi', firstName: 'Lou', birthYear: 1990, isAlive: true },
|
||||
{ id: 'mere', firstName: 'Anne', birthYear: 1962, isAlive: true },
|
||||
{ id: 'pere', firstName: 'Marc', birthYear: 1960, isAlive: true, photo: '/p.jpg' },
|
||||
{ id: 'fils', firstName: 'Tom', birthYear: 2020, isAlive: true },
|
||||
{ id: 'aieul', firstName: 'Jules', birthYear: 1880, isAlive: false },
|
||||
{ id: 'cousin', firstName: 'Paul', birthYear: 1995, isAlive: true },
|
||||
]
|
||||
const byId = new Map(people.map((p) => [p.id, p]))
|
||||
const parents = { moi: ['mere', 'pere'], mere: ['aieul'] }
|
||||
const children = { moi: ['fils'], mere: ['moi'], pere: ['moi'], aieul: ['mere'] }
|
||||
const helpers = {
|
||||
getParents: (id) => (parents[id] || []).map((x) => byId.get(x)),
|
||||
getChildren: (id) => (children[id] || []).map((x) => byId.get(x)),
|
||||
getPartners: () => [],
|
||||
}
|
||||
|
||||
it('part de « Moi » puis de ses proches, sans les personnes qui ont déjà un portrait', () => {
|
||||
const picked = pickFirstFaces({ persons: people, selfId: 'moi', ...helpers, limit: 3 })
|
||||
expect(picked.map((p) => p.id)).toEqual(['moi', 'fils', 'mere'])
|
||||
})
|
||||
|
||||
it('sans « Moi », prend les naissances les plus récentes', () => {
|
||||
const picked = pickFirstFaces({ persons: people, ...helpers, limit: 3 })
|
||||
expect(picked.map((p) => p.id)).toEqual(['fils', 'cousin', 'moi'])
|
||||
})
|
||||
|
||||
it('complète avec les plus récents quand la famille proche ne suffit pas', () => {
|
||||
const picked = pickFirstFaces({ persons: people, selfId: 'aieul', ...helpers, limit: 3 })
|
||||
expect(picked.map((p) => p.id)).toEqual(['aieul', 'mere', 'moi'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sizeBucket', () => {
|
||||
it('arrondit les effectifs envoyés à PostHog', () => {
|
||||
expect([0, 3, 12, 80, 450, 2697].map(sizeBucket)).toEqual(['0', '1-9', '10-49', '50-199', '200-999', '1000+'])
|
||||
})
|
||||
})
|
||||
Generated
+472
-1244
File diff suppressed because it is too large.
Load diff
@@ -10,15 +10,18 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"posthog-js": "^1.435.6",
|
||||
"posthog-js": "^1.438.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.3.4"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss-selector-parser": "^7.1.6"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
// Mêmes navigateurs qu'avec Vite 7, comme le front (Vite 8 relève sa cible par défaut)
|
||||
target: ['chrome107', 'edge107', 'firefox104', 'safari16'],
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user