Compare commits
239 Commits
2c96752330
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcf568ed4 | ||
|
|
ddb1ec4df8 | ||
| d650b6c066 | |||
|
|
e63eaadc33 | ||
|
|
d4a25e34d8 | ||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
9a9e1c4c40 | ||
|
|
62c8ce4cb2 | ||
|
|
4c620619d4 | ||
|
|
44baff9c9c | ||
|
|
4634da9865 | ||
|
|
79e4a3f9db | ||
|
|
70e8a6e6ad | ||
|
|
3af1095d13 | ||
|
|
8c835e957a | ||
|
|
fe8fcba7a7 | ||
|
|
e0c80ac193 | ||
|
|
005265b5a8 | ||
|
|
684c6e63de | ||
|
|
e27d52b9ee | ||
|
|
6f5497c7bf | ||
|
|
e0fac783e8 | ||
|
|
202ea85a58 | ||
|
|
7679596c70 | ||
|
|
3d5dcd1f18 | ||
|
|
52fca38f0f | ||
|
|
662a8f3e80 | ||
|
|
cbba95c3f8 | ||
|
|
3536ed884c | ||
|
|
5a939d9222 | ||
|
|
93e90424ab | ||
|
|
e8f3004c4f | ||
|
|
9637ebbca2 | ||
|
|
df10a42069 | ||
|
|
64120a30b5 | ||
|
|
25252fc709 | ||
|
|
1f379a161d | ||
|
|
c0d034c85d | ||
|
|
ca93cde06e | ||
|
|
7629e35897 | ||
|
|
cd741b9f57 | ||
|
|
ddf378aaac | ||
|
|
20cfe41f21 | ||
|
|
43601a3770 | ||
|
|
6603bc5333 | ||
|
|
6753d115f9 | ||
|
|
73dd6c80fa | ||
|
|
9ade36dd3b | ||
|
|
378da60ae8 | ||
|
|
6d267f2269 | ||
|
|
ff76a3784f | ||
|
|
534665459f | ||
|
|
fd792f6d78 | ||
|
|
bafbf609eb | ||
|
|
2710f2e233 | ||
|
|
80f6468d52 | ||
|
|
a58378e8f0 | ||
|
|
d000170f52 | ||
|
|
d1ed9c022f | ||
|
|
1e5e8e43e8 | ||
|
|
8c198f22be | ||
|
|
6fd05e08d8 | ||
|
|
ab469b744c | ||
|
|
f07527158c | ||
|
|
9f75de0350 | ||
|
|
8a9fbc6aef | ||
|
|
0336d07980 | ||
|
|
61256942e1 | ||
|
|
6aaf8ddb9e | ||
|
|
1b9707c6cd | ||
|
|
8353e71eed | ||
|
|
0693cfddd1 | ||
|
|
f656f7c1ff | ||
|
|
7316c51d4a | ||
|
|
cf457cb96f | ||
|
|
83e0afb466 | ||
|
|
12db7b3596 | ||
|
|
26b45f1c78 | ||
|
|
e6ce00035e | ||
|
|
b1f77bcfb6 | ||
|
|
4d1a5862d0 | ||
|
|
4e8a430dc3 | ||
|
|
e1d404609e | ||
|
|
b36addde22 | ||
|
|
456e019c3d | ||
|
|
d3bb08e7ff | ||
|
|
6703347468 | ||
|
|
1d55901388 | ||
|
|
0cd4882ef4 | ||
|
|
a85b22efcf | ||
|
|
7627589844 | ||
|
|
96a1afe09a | ||
|
|
c1b125bdb2 | ||
|
|
e4a9999f2f | ||
|
|
e48c794c12 | ||
|
|
add619d81d | ||
|
|
a46c3b416b | ||
|
|
7e8b90c8ee | ||
|
|
fc5c837d2c | ||
|
|
4f874bf4e9 | ||
|
|
28997fc391 | ||
|
|
003bc9b8cb | ||
|
|
485e13cfea | ||
|
|
439a386a37 | ||
|
|
23006a6562 | ||
|
|
c35f51d209 | ||
|
|
5297c70453 | ||
|
|
ad820955fd | ||
|
|
27b6d58632 | ||
|
|
4db2e97490 | ||
|
|
25b23843c9 | ||
|
|
ad067d2e0e | ||
|
|
29015ee864 | ||
|
|
b1b8505b93 | ||
|
|
abe860bec7 | ||
|
|
ec9d46da7a | ||
|
|
e562b3bbea | ||
|
|
e725910402 | ||
|
|
782a34e34b | ||
|
|
30f450b0d1 | ||
|
|
d4c0287e92 | ||
|
|
301cfc5c9e | ||
|
|
724c3881e4 | ||
|
|
fab2930ca8 | ||
|
|
d83707ec3b | ||
|
|
caea0d5633 | ||
|
|
2bf14aeab9 | ||
|
|
5b565d5271 | ||
|
|
df0f4879b8 | ||
|
|
98d081397e | ||
|
|
4e68b81bf7 | ||
|
|
985b31f71f | ||
|
|
3fb312b1c6 | ||
|
|
e2ec45f819 | ||
|
|
7d9526440a | ||
|
|
13bbfa7abd | ||
|
|
975223c972 | ||
|
|
403a043d51 | ||
|
|
e259908d6a | ||
|
|
7d37e610da | ||
|
|
9c1eb7608b | ||
|
|
9bba5e4a7a | ||
|
|
751a48b22c | ||
|
|
58a30a6e2e | ||
|
|
2430092e43 | ||
|
|
4a93543645 | ||
|
|
b453c13bae | ||
|
|
599c3977fb | ||
|
|
03e2615fa7 | ||
|
|
3db6a3bf8f | ||
|
|
0e06626eef | ||
|
|
a47564934d | ||
|
|
02fb16a0bd | ||
|
|
4757a174c9 | ||
|
|
75293c6aa8 | ||
|
|
4e9b13c0e4 | ||
|
|
ad27c1f757 | ||
|
|
0e30e5c570 | ||
|
|
a6a8552a48 | ||
|
|
b0d28c1e0b | ||
|
|
420c0e3e10 | ||
|
|
cb61e63b02 | ||
|
|
8eb321ccea | ||
|
|
e16b7402bd | ||
|
|
229c1b0539 | ||
|
|
f24c415b04 | ||
|
|
4c57a2262f | ||
|
|
b8e01f997d | ||
|
|
e8e57d2712 | ||
|
|
817835fd6a | ||
|
|
c361b3cd45 | ||
|
|
5c8034d298 | ||
|
|
8b1b070254 | ||
|
|
4ca1c967d2 | ||
|
|
24d9d975d1 | ||
|
|
8a1cc2d1f0 | ||
|
|
d5bf401085 | ||
|
|
4944918692 | ||
|
|
bf90427bfa |
@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
|
||||
```
|
||||
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
||||
|
||||
3. **Upgrading VPS tier before profiling**
|
||||
3. **Upgrading hardware before profiling**
|
||||
```
|
||||
# "The app feels slow" → upgrade from CX32 to CX42
|
||||
# "The app feels slow" → order more RAM / a faster CPU
|
||||
# Actual cause: unindexed query scanning 100k rows
|
||||
```
|
||||
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
||||
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
|
||||
Prometheus + Loki + Alertmanager
|
||||
```
|
||||
|
||||
### Monthly Cost: ~23 EUR
|
||||
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
### Monthly Cost: ~6 EUR (excl. server)
|
||||
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
|
||||
### Reference Documentation
|
||||
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
||||
|
||||
19
.env.example
19
.env.example
@@ -72,6 +72,25 @@ VITE_SENTRY_DSN=
|
||||
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# NL search — Ollama LLM inference
|
||||
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
|
||||
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
|
||||
APP_OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
|
||||
# Raise to 7.5 on CX42 for full throughput.
|
||||
OLLAMA_CPU_LIMIT=4.0
|
||||
|
||||
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
|
||||
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
|
||||
OLLAMA_MEM_LIMIT=8g
|
||||
|
||||
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
|
||||
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
|
||||
OLLAMA_API_KEY=
|
||||
|
||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||
# APP_BASE_URL=https://your-domain.example.com
|
||||
# MAIL_HOST=smtp.example.com
|
||||
|
||||
127
.gitea/actions/deploy-obs/action.yml
Normal file
127
.gitea/actions/deploy-obs/action.yml
Normal file
@@ -0,0 +1,127 @@
|
||||
name: Deploy observability stack
|
||||
description: >-
|
||||
Deploy observability configs + secrets to /opt/familienarchiv, validate the
|
||||
compose config, start the stack, and assert the five healthchecked services
|
||||
are healthy. Per-environment values arrive as inputs.
|
||||
|
||||
inputs:
|
||||
grafana_admin_password:
|
||||
description: Grafana admin password (secret)
|
||||
required: true
|
||||
grafana_db_password:
|
||||
description: Read-only grafana_reader DB role password (secret, issue #651)
|
||||
required: true
|
||||
glitchtip_secret_key:
|
||||
description: GlitchTip Django secret key (secret)
|
||||
required: true
|
||||
postgres_password:
|
||||
description: PostgreSQL password for the environment (secret)
|
||||
required: true
|
||||
postgres_host:
|
||||
description: >-
|
||||
Compose project + service hostname, e.g. archiv-staging-db-1. Derived
|
||||
from the Compose project name and service name — a project rename
|
||||
requires updating the caller's value. Plain input, not a secret.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Deploy observability configs
|
||||
shell: bash
|
||||
# Copies the compose file and config tree from the workspace checkout
|
||||
# into /opt/familienarchiv/ — the permanent location that persists
|
||||
# between CI runs. Containers started in the next step bind-mount
|
||||
# from there, so a future workspace wipe cannot corrupt a running
|
||||
# config file.
|
||||
#
|
||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||
# Gitea is always the single source of truth for secret rotation.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
#
|
||||
# secrets.* is NOT available inside a composite action, so the values
|
||||
# arrive as inputs mapped to env: below and are referenced as $VAR in
|
||||
# the heredoc. The delimiter MUST stay unquoted (<<EOF, not <<'EOF') so
|
||||
# the shell expands $VAR — a quoted delimiter would write the literal
|
||||
# string "$GRAFANA_ADMIN_PASSWORD" and `config --quiet` would still pass
|
||||
# (the var is present, just wrong). Do not stage these into intermediate
|
||||
# variables either, or Gitea log masking can be lost.
|
||||
env:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ inputs.grafana_admin_password }}
|
||||
GRAFANA_DB_PASSWORD: ${{ inputs.grafana_db_password }}
|
||||
GLITCHTIP_SECRET_KEY: ${{ inputs.glitchtip_secret_key }}
|
||||
POSTGRES_PASSWORD: ${{ inputs.postgres_password }}
|
||||
POSTGRES_HOST: ${{ inputs.postgres_host }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<EOF
|
||||
GRAFANA_ADMIN_PASSWORD=$GRAFANA_ADMIN_PASSWORD
|
||||
GRAFANA_DB_PASSWORD=$GRAFANA_DB_PASSWORD
|
||||
GLITCHTIP_SECRET_KEY=$GLITCHTIP_SECRET_KEY
|
||||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
POSTGRES_HOST=$POSTGRES_HOST
|
||||
EOF
|
||||
# Five-key non-empty guard: a bare presence check matches an empty
|
||||
# `KEY=` line, so assert each key has a value. Fail loudly on any
|
||||
# missing/empty key rather than starting the stack with broken auth.
|
||||
for key in GRAFANA_ADMIN_PASSWORD GRAFANA_DB_PASSWORD GLITCHTIP_SECRET_KEY POSTGRES_PASSWORD POSTGRES_HOST; do
|
||||
grep -Eq "^${key}=.+" /opt/familienarchiv/obs-secrets.env \
|
||||
|| { echo "::error::obs-secrets.env missing or empty: ${key}"; exit 1; }
|
||||
done
|
||||
# chmod 600 MUST be the final operation: the ordering is the security
|
||||
# property — there is no window where the file is world-readable.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
|
||||
- name: Validate observability compose config
|
||||
shell: bash
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys. POSTGRES_HOST
|
||||
# is environment-specific and supplied only by obs-secrets.env — obs.env
|
||||
# documents it but deliberately does not set a value.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
|
||||
- name: Start observability stack
|
||||
shell: bash
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
shell: bash
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
41
.gitea/actions/reload-caddy/action.yml
Normal file
41
.gitea/actions/reload-caddy/action.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Reload Caddy
|
||||
description: >-
|
||||
Reload the host Caddy service from a DooD job container via a privileged
|
||||
sibling container and nsenter. No inputs.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Reload Caddy
|
||||
shell: bash
|
||||
# Apply any committed Caddyfile changes before smoke-testing the
|
||||
# public surface. Without this step, a Caddyfile edit lands in the
|
||||
# repo but Caddy keeps serving the previous config until someone
|
||||
# reloads it manually — the smoke test would then catch a stale
|
||||
# header or a still-proxied /actuator route rather than confirming
|
||||
# the current config is live.
|
||||
#
|
||||
# The runner executes job steps inside Docker containers (DooD).
|
||||
# `systemctl` is not present in container images and cannot reach
|
||||
# the host's systemd directly. We use the Docker socket (mounted
|
||||
# into every job container via runner-config.yaml) to spin up a
|
||||
# privileged sibling container in the host PID namespace; nsenter
|
||||
# then enters the host's namespaces so systemctl talks to the real
|
||||
# host systemd daemon. No sudoers entry is required — the Docker
|
||||
# socket already grants root-equivalent host access.
|
||||
#
|
||||
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
||||
# tooling, and the digest is pinned so any upstream change requires
|
||||
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
||||
# at run time; apk add takes ~1 s on the warm VPS cache.
|
||||
#
|
||||
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
||||
# config in-process without dropping TLS connections. `restart`
|
||||
# would briefly stop the service, losing in-flight requests.
|
||||
#
|
||||
# If Caddy is not running this step fails fast before the smoke test
|
||||
# issues a misleading "port 443 refused" error.
|
||||
run: |
|
||||
docker run --rm --privileged --pid=host \
|
||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||
58
.gitea/actions/smoke-test/action.yml
Normal file
58
.gitea/actions/smoke-test/action.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Smoke test
|
||||
description: >-
|
||||
Verify the deployed public surface (login reachable, HSTS pinned,
|
||||
Permissions-Policy present, /actuator blocked) against a given vhost.
|
||||
|
||||
inputs:
|
||||
host:
|
||||
description: Public vhost to smoke-test, e.g. staging.raddatz.cloud
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Smoke test deployed environment
|
||||
shell: bash
|
||||
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
||||
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||
# header dropped, /actuator block bypassed.
|
||||
#
|
||||
# --resolve pins the public host to the Docker bridge gateway IP
|
||||
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||
# and is therefore reachable from the container via that IP.
|
||||
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||
#
|
||||
# --resolve is stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||
# separate arguments; a quoted string would pass the flag and its value
|
||||
# as one token and curl would reject it as an unknown option.
|
||||
#
|
||||
# Gateway detection reads /proc/net/route (always present, no package
|
||||
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||
env:
|
||||
HOST: ${{ inputs.host }}
|
||||
run: |
|
||||
set -e
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "::error::could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "::error::expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
@@ -108,6 +108,32 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert deploy-obs writes obs-secrets.env via an unquoted heredoc (#603)
|
||||
shell: bash
|
||||
run: |
|
||||
# Inside a composite action, secrets arrive as $VAR from env: (secrets.*
|
||||
# is unavailable there), so the obs-secrets.env heredoc MUST use an
|
||||
# unquoted delimiter (<<EOF) for $VAR to expand. A quoted delimiter
|
||||
# (<<'EOF') would write the literal string "$GRAFANA_ADMIN_PASSWORD",
|
||||
# and the action's five-key non-empty guard would STILL pass (the line
|
||||
# is present, just wrong). This guard enforces the invariant in CI so a
|
||||
# future re-quote cannot ship broken obs auth green. See ADR-029 / #603.
|
||||
action='.gitea/actions/deploy-obs/action.yml'
|
||||
quoted='obs-secrets\.env\s*<<-?\s*[\x27\x22]'
|
||||
# Self-test: the regex must catch a quoted delimiter and ignore the unquoted one.
|
||||
printf "obs-secrets.env <<'EOF'\n" | grep -qP "$quoted" \
|
||||
|| { echo "FAIL: guard self-test — regex missed the quoted <<'EOF' form"; exit 1; }
|
||||
printf 'obs-secrets.env <<EOF\n' | grep -qvP "$quoted" \
|
||||
|| { echo "FAIL: guard self-test — regex wrongly flagged the unquoted <<EOF form"; exit 1; }
|
||||
# Positive: the unquoted heredoc must be present at all.
|
||||
grep -qP 'obs-secrets\.env\s*<<-?EOF\b' "$action" \
|
||||
|| { echo "::error::$action no longer writes obs-secrets.env via an unquoted <<EOF heredoc (ADR-029 / #603)"; exit 1; }
|
||||
# Negative: never a quoted delimiter on the obs-secrets.env heredoc.
|
||||
if grep -nP "$quoted" "$action"; then
|
||||
echo "::error::$action writes obs-secrets.env with a quoted heredoc delimiter — secrets would be written as literal \$VAR strings. Use unquoted <<EOF (ADR-029 / #603)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run unit and component tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -23,6 +23,11 @@ name: nightly
|
||||
# - host ports: backend 8081, frontend 3001
|
||||
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
||||
#
|
||||
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||
# release.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||
# only exists on disk after checkout.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# STAGING_POSTGRES_PASSWORD
|
||||
# STAGING_MINIO_PASSWORD
|
||||
@@ -55,6 +60,8 @@ jobs:
|
||||
# for the same repo is within that boundary.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# MUST be first: the composite actions below live under .gitea/actions/
|
||||
# and only exist on disk once the repo is checked out (ADR-029).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write staging env file
|
||||
@@ -92,6 +99,7 @@ jobs:
|
||||
# `compose config` renders both shorthand and longform mounts as
|
||||
# `target: /import` + `read_only: true`, so we assert against
|
||||
# the rendered form rather than the raw source YAML.
|
||||
# App-compose check (not obs), nightly-only — stays inline.
|
||||
run: |
|
||||
set -e
|
||||
docker compose \
|
||||
@@ -128,150 +136,21 @@ jobs:
|
||||
--profile staging \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Copies the compose file and config tree from the workspace checkout
|
||||
# into /opt/familienarchiv/ — the permanent location that persists
|
||||
# between CI runs. Containers started in the next step bind-mount
|
||||
# from there, so a future workspace wipe cannot corrupt a running
|
||||
# config file.
|
||||
#
|
||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||
# Gitea is always the single source of truth for secret rotation.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-staging-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
# POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
- uses: ./.gitea/actions/deploy-obs
|
||||
with:
|
||||
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
postgres_host: archiv-staging-db-1
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
- uses: ./.gitea/actions/reload-caddy
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# Apply any committed Caddyfile changes before smoke-testing the
|
||||
# public surface. Without this step, a Caddyfile edit lands in the
|
||||
# repo but Caddy keeps serving the previous config until someone
|
||||
# reloads it manually — the smoke test would then catch a stale
|
||||
# header or a still-proxied /actuator route rather than confirming
|
||||
# the current config is live.
|
||||
#
|
||||
# The runner executes job steps inside Docker containers (DooD).
|
||||
# `systemctl` is not present in container images and cannot reach
|
||||
# the host's systemd directly. We use the Docker socket (mounted
|
||||
# into every job container via runner-config.yaml) to spin up a
|
||||
# privileged sibling container in the host PID namespace; nsenter
|
||||
# then enters the host's namespaces so systemctl talks to the real
|
||||
# host systemd daemon. No sudoers entry is required — the Docker
|
||||
# socket already grants root-equivalent host access.
|
||||
#
|
||||
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
||||
# tooling, and the digest is pinned so any upstream change requires
|
||||
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
||||
# at run time; apk add takes ~1 s on the warm VPS cache.
|
||||
#
|
||||
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
||||
# config in-process without dropping TLS connections. `restart`
|
||||
# would briefly stop the service, losing in-flight requests.
|
||||
#
|
||||
# If Caddy is not running this step fails fast before the smoke test
|
||||
# issues a misleading "port 443 refused" error.
|
||||
run: |
|
||||
docker run --rm --privileged --pid=host \
|
||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
||||
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||
# header dropped, /actuator block bypassed.
|
||||
#
|
||||
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
|
||||
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||
# and is therefore reachable from the container via that IP.
|
||||
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||
#
|
||||
# Gateway detection reads /proc/net/route (always present, no package
|
||||
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||
run: |
|
||||
set -e
|
||||
HOST="staging.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
- uses: ./.gitea/actions/smoke-test
|
||||
with:
|
||||
host: staging.raddatz.cloud
|
||||
|
||||
- name: Cleanup env file
|
||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||
|
||||
@@ -23,6 +23,11 @@ name: release
|
||||
# - host ports: backend 8080, frontend 3000
|
||||
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
||||
#
|
||||
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||
# nightly.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||
# only exists on disk after checkout.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# PROD_POSTGRES_PASSWORD
|
||||
# PROD_MINIO_PASSWORD
|
||||
@@ -53,6 +58,8 @@ jobs:
|
||||
# advertised label of our single-tenant self-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# MUST be first: the composite actions below live under .gitea/actions/
|
||||
# and only exist on disk once the repo is checked out (ADR-029).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write production env file
|
||||
@@ -100,117 +107,21 @@ jobs:
|
||||
--env-file .env.production \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-production-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
# POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
- uses: ./.gitea/actions/deploy-obs
|
||||
with:
|
||||
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
postgres_host: archiv-production-db-1
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
- uses: ./.gitea/actions/reload-caddy
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# See nightly.yml — same rationale and mechanism: DooD job containers
|
||||
# cannot call systemctl directly; nsenter via a privileged sibling
|
||||
# container reaches the host systemd. Must run after deploy (so the
|
||||
# latest Caddyfile is on disk) and before the smoke test (so the
|
||||
# public surface reflects the current config). Alpine with pinned
|
||||
# digest; reload not restart — see nightly.yml for full rationale.
|
||||
run: |
|
||||
docker run --rm --privileged --pid=host \
|
||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# See nightly.yml — same three checks, against the prod vhost.
|
||||
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||
# separate arguments; a quoted string would pass the flag and its value
|
||||
# as one token and curl would reject it as an unknown option.
|
||||
# Gateway detection via /proc/net/route — no iproute2 dependency.
|
||||
# See nightly.yml for the full network topology explanation.
|
||||
run: |
|
||||
set -e
|
||||
HOST="archiv.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
- uses: ./.gitea/actions/smoke-test
|
||||
with:
|
||||
host: archiv.raddatz.cloud
|
||||
|
||||
- name: Cleanup env file
|
||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||
|
||||
@@ -194,7 +194,6 @@ frontend/src/routes/
|
||||
│ ├── [id]/edit/ Person edit form
|
||||
│ ├── new/ Create person form
|
||||
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
|
||||
@@ -28,4 +28,18 @@ Authorization: Basic Gast_User gast
|
||||
###Groups
|
||||
#GET
|
||||
GET http://localhost:8080/api/admin/tags
|
||||
Authorization: Basic admin admin123
|
||||
Authorization: Basic admin admin123
|
||||
|
||||
### One-time backfill: re-sync already-stale auto-titles (#726)
|
||||
# RUNBOOK: a one-shot ADMIN maintenance call, NOT part of normal operation. Run it ONCE
|
||||
# after deploying #726 to clean the existing backlog of stale titles (e.g. a title still
|
||||
# showing "2028" after the date was corrected to "1928"). It is synchronous and idempotent
|
||||
# — a second run returns {"count": 0} and writes nothing. Hit the backend DIRECTLY on
|
||||
# port 8080 (NOT through the SvelteKit proxy) so the sweep can't trip the proxy timeout.
|
||||
# Returns {"count": <documents rewritten>}.
|
||||
POST http://localhost:8080/api/admin/backfill-titles
|
||||
Authorization: Basic admin admin123
|
||||
|
||||
### NEGATIV-TEST: ein Nicht-Admin darf den Backfill NICHT auslösen -> 403 Forbidden
|
||||
POST http://localhost:8080/api/admin/backfill-titles
|
||||
Authorization: Basic Gast_User gast
|
||||
@@ -41,6 +41,27 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-servlet</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-servlets</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-webapp</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-ee</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -137,6 +158,12 @@
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-jetty12</artifactId>
|
||||
<version>3.9.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Excel Bearbeitung (Apache POI) -->
|
||||
<dependency>
|
||||
|
||||
@@ -46,7 +46,6 @@ import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -443,17 +442,6 @@ public class DocumentController {
|
||||
return documentVersionService.getVersion(id, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/conversation")
|
||||
public List<Document> getConversation(
|
||||
@RequestParam UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(defaultValue = "DESC") String dir) {
|
||||
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
||||
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -58,6 +57,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
|
||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
@@ -81,32 +81,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
"((d.sender.id = :person1 AND r.id = :person2) " +
|
||||
" OR " +
|
||||
" (d.sender.id = :person2 AND r.id = :person1)) " +
|
||||
"AND d.documentDate BETWEEN :from AND :to")
|
||||
List<Document> findConversation(
|
||||
@Param("person1") UUID person1,
|
||||
@Param("person2") UUID person2,
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to,
|
||||
Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"LEFT JOIN d.receivers r " +
|
||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||
"AND d.documentDate BETWEEN :from AND :to")
|
||||
List<Document> findSinglePersonCorrespondence(
|
||||
@Param("personId") UUID personId,
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to,
|
||||
Sort sort);
|
||||
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
|
||||
@@ -32,6 +32,8 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -68,6 +70,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
@@ -379,8 +382,14 @@ public class DocumentService {
|
||||
|
||||
DocumentStatus statusBefore = doc.getStatus();
|
||||
|
||||
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
|
||||
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
|
||||
// skips nulls, so the old state must be read first. The submitted title is the catalog
|
||||
// auto-title iff it equals this; only then does it follow date/location forward.
|
||||
String autoTitleBefore = documentTitleFactory.build(doc);
|
||||
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
applyDatePrecision(doc, dto);
|
||||
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||
@@ -424,7 +433,11 @@ public class DocumentService {
|
||||
doc.setScriptType(dto.getScriptType());
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
|
||||
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
|
||||
// segment is originalFilename, so after a replace the stored title no longer matches
|
||||
// build(currentState) and the row is treated as manual — neither save-time nor backfill
|
||||
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
|
||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||
if (fileReplaced) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
@@ -453,22 +466,68 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields only when the DTO carries them.
|
||||
* A null field means "not submitted" — overwriting the stored value with null
|
||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
||||
* an unrelated edit (e.g. a location typo) is saved.
|
||||
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
|
||||
* submission is never persisted (title is always present) — it falls back to the rebuilt
|
||||
* auto-title, which always carries at least the index.
|
||||
*/
|
||||
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
|
||||
if (submitted == null || submitted.isBlank()) {
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
if (!Objects.equals(submitted, autoBefore)) {
|
||||
return submitted;
|
||||
}
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* The document state the regenerated title is built from. It is composed from the SAME
|
||||
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
|
||||
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
|
||||
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
|
||||
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
|
||||
* is never touched by a metadata edit.
|
||||
*/
|
||||
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||
return Document.builder()
|
||||
.originalFilename(doc.getOriginalFilename())
|
||||
.documentDate(dto.getDocumentDate())
|
||||
.location(dto.getLocation())
|
||||
.metaDatePrecision(effectivePrecision(doc, dto))
|
||||
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
|
||||
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
|
||||
* so the stored value is kept rather than overwritten with null — which would fabricate a
|
||||
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
|
||||
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
|
||||
* the stored value back when the DTO omits a field is a harmless no-op).
|
||||
*/
|
||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
if (dto.getMetaDatePrecision() != null) {
|
||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||
}
|
||||
if (dto.getMetaDateEnd() != null) {
|
||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||
}
|
||||
if (dto.getMetaDateRaw() != null) {
|
||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||
}
|
||||
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
|
||||
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
|
||||
}
|
||||
|
||||
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
|
||||
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
|
||||
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
|
||||
}
|
||||
|
||||
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
|
||||
}
|
||||
|
||||
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -918,22 +977,6 @@ public class DocumentService {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||
// Findet alle Briefe ZWISCHEN zwei Personen (egal wer Sender/Empfänger war)
|
||||
public List<Document> getConversation(UUID personA, UUID personB) {
|
||||
|
||||
// Fall 1: A schreibt an B
|
||||
Specification<Document> aToB = Specification.where(hasSender(personA)).and(hasReceiver(personB));
|
||||
|
||||
// Fall 2: B schreibt an A
|
||||
Specification<Document> bToA = Specification.where(hasSender(personB)).and(hasReceiver(personA));
|
||||
|
||||
// Wir wollen (A->B) ODER (B->A)
|
||||
Specification<Document> conversation = aToB.or(bToA);
|
||||
|
||||
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
||||
Document doc = getDocumentById(documentId);
|
||||
@@ -992,13 +1035,26 @@ public class DocumentService {
|
||||
return documentRepository.findByReceiversId(receiverId);
|
||||
}
|
||||
|
||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
if (receiverId == null) {
|
||||
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
|
||||
}
|
||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
|
||||
Person person = personService.getById(personId);
|
||||
Specification<Document> spec = buildPersonSpec(person, from, to);
|
||||
Page<Document> page = documentRepository.findAll(spec, pageable);
|
||||
List<DocumentListItem> items = enrichItems(page.getContent(), null);
|
||||
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
|
||||
return (root, query, cb) -> {
|
||||
if (query != null) query.distinct(true);
|
||||
var receiversJoin = root.join("receivers", JoinType.LEFT);
|
||||
var senderPredicate = cb.equal(root.get("sender"), person);
|
||||
var receiverPredicate = cb.equal(receiversJoin, person);
|
||||
var personPredicate = cb.or(senderPredicate, receiverPredicate);
|
||||
var predicates = new ArrayList<>(List.of(personPredicate));
|
||||
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
public long getIncompleteCount() {
|
||||
@@ -1035,6 +1091,43 @@ public class DocumentService {
|
||||
tagService.delete(tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
|
||||
* stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
|
||||
* the title from the row's current state and persists it only when it actually changed.
|
||||
* Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
|
||||
* left untouched.
|
||||
*
|
||||
* <p>Saves via {@code documentRepository.save} directly — it must NOT route through
|
||||
* {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
|
||||
* precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
|
||||
*
|
||||
* @return the number of documents whose title was rewritten
|
||||
*/
|
||||
@Transactional
|
||||
public int backfillTitles() {
|
||||
List<Document> docs = documentRepository.findAll();
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
for (Document doc : docs) {
|
||||
if (!DocumentTitleBackfillMatcher.isOverwritable(
|
||||
doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
String rebuilt = documentTitleFactory.build(doc);
|
||||
if (rebuilt.equals(doc.getTitle())) {
|
||||
skipped++; // already correct — keep idempotent, no write
|
||||
continue;
|
||||
}
|
||||
doc.setTitle(rebuilt);
|
||||
documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
|
||||
updated++;
|
||||
}
|
||||
log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillFileHashes() {
|
||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Heuristic overwrite test for the one-time title backfill (#726, FR-004): decides whether a
|
||||
* STORED title is a machine-generated auto-title (and so may be rebuilt from the row's current
|
||||
* state) versus hand-written prose (left untouched). Used ONLY by the backfill — save-time
|
||||
* regeneration uses an exact old-vs-new comparison instead, with no heuristic.
|
||||
*
|
||||
* <p>A stored title is overwritable iff, after stripping the literal {@code index} prefix:
|
||||
* <ol>
|
||||
* <li>it is exactly {@code {index}}, or</li>
|
||||
* <li>{@code {index} – {dateLabel}} with an optional trailing {@code – {location}} segment
|
||||
* (any location — a present, valid date label is itself strong evidence of a machine
|
||||
* title), or</li>
|
||||
* <li>{@code {index} – {location}} where the segment equals the document's current location
|
||||
* (no date label, so the segment must match the known location to be distinguished from
|
||||
* prose).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Security: the {@code index} is compared <em>literally</em> via {@link String#startsWith}
|
||||
* (never compiled into a regex) because {@code originalFilename} is user-controlled and may carry
|
||||
* regex metacharacters — an unquoted pattern would be a ReDoS / regex-injection vector
|
||||
* (CWE-1333 / CWE-625). The date-label sub-patterns use only bounded, non-nested quantifiers over
|
||||
* short tokens, so there is no catastrophic backtracking. Fail-closed: any null/blank index or
|
||||
* structural surprise returns {@code false}.
|
||||
*/
|
||||
final class DocumentTitleBackfillMatcher {
|
||||
|
||||
private static final String SEPARATOR = " – ";
|
||||
|
||||
// German month tokens derived from the SAME Locale.GERMAN formatters DocumentTitleFormatter
|
||||
// uses, so the matcher's accepted spellings cannot drift from what the factory emits (full
|
||||
// names "Januar"…"Dezember"; abbreviations "Jan."…"Dez." — note May/June/July/März carry no
|
||||
// period). Pattern.quote each so a "." in an abbreviation is literal, never a wildcard.
|
||||
private static final String FULL_MONTH = monthAlternation("MMMM");
|
||||
private static final String ABBR_MONTH = monthAlternation("MMM");
|
||||
private static final String SEASON = "(?:Frühling|Sommer|Herbst|Winter)";
|
||||
private static final String YEAR = "\\d{1,4}";
|
||||
private static final String DAY_NUM = "\\d{1,2}";
|
||||
|
||||
// One complete date label, anchored, optionally followed by a free-form trailing location
|
||||
// segment. Only bounded/non-nested quantifiers over short tokens plus a single trailing
|
||||
// ".+" → linear, no catastrophic backtracking (FR-004 ReDoS guard).
|
||||
private static final Pattern DATE_LABEL_WITH_OPTIONAL_LOCATION = Pattern.compile(
|
||||
"^(?:" + String.join("|",
|
||||
YEAR, // 1916
|
||||
"ca\\. " + YEAR, // ca. 1920
|
||||
FULL_MONTH + " " + YEAR, // Juni 1916
|
||||
DAY_NUM + "\\. " + FULL_MONTH + " " + YEAR, // 24. Dezember 1943
|
||||
SEASON + " " + YEAR, // Sommer 1916
|
||||
"Datum unbekannt",
|
||||
DAY_NUM + "\\.–" + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10.–11. Jan. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Jan. – 2. Feb. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Dez. 1916 – 2. Jan. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10. Jan. 1917 (range end == start)
|
||||
"ab " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR) // ab 10. Jan. 1917
|
||||
+ ")(?: – .+)?$");
|
||||
|
||||
private DocumentTitleBackfillMatcher() {
|
||||
}
|
||||
|
||||
static boolean isOverwritable(String title, String index, String location) {
|
||||
if (title == null || index == null || index.isBlank()) {
|
||||
return false; // fail closed
|
||||
}
|
||||
if (!title.startsWith(index)) {
|
||||
return false; // index is matched LITERALLY, never as a regex
|
||||
}
|
||||
String tail = title.substring(index.length());
|
||||
if (tail.isEmpty()) {
|
||||
return true; // exactly {index}
|
||||
}
|
||||
if (!tail.startsWith(SEPARATOR)) {
|
||||
return false;
|
||||
}
|
||||
String body = tail.substring(SEPARATOR.length());
|
||||
if (DATE_LABEL_WITH_OPTIONAL_LOCATION.matcher(body).matches()) {
|
||||
return true; // {dateLabel} (+ optional trailing location)
|
||||
}
|
||||
// No date label: the lone segment must equal the document's current location to be
|
||||
// distinguished from hand-written prose.
|
||||
return location != null && !location.isBlank() && body.equals(location);
|
||||
}
|
||||
|
||||
private static String monthAlternation(String pattern) {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.GERMAN);
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
for (int month = 1; month <= 12; month++) {
|
||||
tokens.add(formatter.format(LocalDate.of(2000, month, 15)));
|
||||
}
|
||||
return tokens.stream().map(Pattern::quote).collect(Collectors.joining("|", "(?:", ")"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Single source of truth for the auto-generated document title
|
||||
* {@code {index} – {dateLabel} – {location}}.
|
||||
*
|
||||
* <p>The {@code document} package owns this formula; {@code importing} consumes it
|
||||
* (see ADR for issue #726). The leading {@code index} is the document's
|
||||
* {@code originalFilename}; the date label is the honest German label produced by
|
||||
* {@link DocumentTitleFormatter} (the Java half of the #666 date-label split); the
|
||||
* trailing location is the {@code meta_location} verbatim, omitted when blank.
|
||||
*/
|
||||
@Component
|
||||
public class DocumentTitleFactory {
|
||||
|
||||
static final String SEPARATOR = " – ";
|
||||
|
||||
/**
|
||||
* Composes the auto-title from the document's current state. The date segment is
|
||||
* dropped for UNKNOWN precision or a null date (the honest "no date" case); the
|
||||
* location segment is dropped when blank.
|
||||
*/
|
||||
public String build(Document doc) {
|
||||
// originalFilename is NOT NULL in production; guard only so a synthetic/partial entity
|
||||
// never trips StringBuilder(null) with an opaque NPE.
|
||||
StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename());
|
||||
if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) {
|
||||
title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate(
|
||||
doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
doc.getMetaDateEnd(), doc.getMetaDateRaw()));
|
||||
}
|
||||
if (doc.getLocation() != null && !doc.getLocation().isBlank()) {
|
||||
title.append(SEPARATOR).append(doc.getLocation());
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
|
||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public static DomainException serviceUnavailable(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -74,6 +75,7 @@ public class DocumentImporter {
|
||||
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||
|
||||
private final DocumentService documentService;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
@@ -181,7 +183,7 @@ public class DocumentImporter {
|
||||
applyAttribution(doc, row);
|
||||
applyDates(doc, row);
|
||||
applyAuthoritativeAssociations(doc, row);
|
||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
||||
applyFileMetadata(doc, s3Key, contentType, status);
|
||||
applyComputedFlags(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -217,14 +219,15 @@ public class DocumentImporter {
|
||||
attachTag(doc, row.get("tags"));
|
||||
}
|
||||
|
||||
// S3 key, content type, status, and the index-derived title.
|
||||
// S3 key, content type, status, and the index-derived title. The title formula lives in
|
||||
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
|
||||
// applyDates has populated the date/location and originalFilename carries the index.
|
||||
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||
DocumentStatus status, String index) {
|
||||
DocumentStatus status) {
|
||||
doc.setStatus(status);
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
}
|
||||
|
||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||
@@ -235,20 +238,6 @@ public class DocumentImporter {
|
||||
|| !doc.getReceivers().isEmpty());
|
||||
}
|
||||
|
||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
||||
LocalDate end, String raw, String location) {
|
||||
StringBuilder title = new StringBuilder(index);
|
||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
||||
}
|
||||
if (location != null && !location.isBlank()) {
|
||||
title.append(" – ").append(location);
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||
|
||||
private Person resolveSender(String slug, String rawName) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||
*/
|
||||
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
@@ -29,14 +30,36 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
||||
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
|
||||
// add a unique(lower(alias)) constraint — see ADR-033.
|
||||
Optional<Person> findByAlias(String alias);
|
||||
|
||||
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
||||
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
||||
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
|
||||
List<Person> findAllByAliasIgnoreCase(String alias);
|
||||
|
||||
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||
Optional<Person> findBySourceRef(String sourceRef);
|
||||
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
||||
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
||||
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
||||
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
|
||||
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
||||
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
||||
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
||||
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
||||
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
||||
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
|
||||
// --- PersonSummaryDTO with document count ---
|
||||
|
||||
@@ -189,18 +212,15 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||
|
||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
|
||||
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
|
||||
// ON DELETE CASCADE (V71) also fires beneath the session.
|
||||
|
||||
@Modifying
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
||||
// delete cannot orphan a documents.sender_id FK (the column is nullable).
|
||||
@Modifying
|
||||
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
|
||||
void reassignSenderToNull(@Param("source") UUID source);
|
||||
|
||||
@Modifying
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(value = """
|
||||
INSERT INTO document_receivers (document_id, person_id)
|
||||
SELECT document_id, :target FROM document_receivers
|
||||
@@ -210,8 +230,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
@Modifying
|
||||
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
|
||||
void deleteReceiverReferences(@Param("source") UUID source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -23,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonService {
|
||||
|
||||
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||
// past position 10 among partials is never discarded.
|
||||
private static final int MAX_TOKENS = 8;
|
||||
private static final int MAX_CANDIDATES = 10;
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@@ -68,15 +84,13 @@ public class PersonService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
||||
* cannot orphan an FK and fail with a 500.
|
||||
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||
*/
|
||||
@Transactional
|
||||
public void deletePerson(UUID id) {
|
||||
getById(id);
|
||||
personRepository.reassignSenderToNull(id);
|
||||
personRepository.deleteReceiverReferences(id);
|
||||
personRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -100,6 +114,96 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public List<Person> findByDisplayNameContaining(String fragment) {
|
||||
return personRepository.searchByName(fragment);
|
||||
}
|
||||
|
||||
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||
static Set<String> tokenize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||
if (!part.isEmpty()) {
|
||||
tokens.add(part);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||
* are reachable during classification (see ADR-022).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public NameMatches resolveByName(String name) {
|
||||
Set<String> queryTokens = capTokens(tokenize(name));
|
||||
if (queryTokens.isEmpty()) {
|
||||
log.debug("resolveByName outcome=no-match tokens=0");
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
return classify(fetchPool(queryTokens), queryTokens);
|
||||
}
|
||||
|
||||
private Set<String> capTokens(Set<String> tokens) {
|
||||
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||
for (String token : queryTokens) {
|
||||
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||
pool.putIfAbsent(candidate.getId(), candidate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(pool.values());
|
||||
}
|
||||
|
||||
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||
List<Person> direct = new ArrayList<>();
|
||||
List<Person> partial = new ArrayList<>();
|
||||
for (Person candidate : pool) {
|
||||
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||
direct.add(candidate);
|
||||
} else {
|
||||
partial.add(candidate);
|
||||
}
|
||||
}
|
||||
List<Person> cappedDirect = cap(direct);
|
||||
List<Person> cappedPartial = cap(partial);
|
||||
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||
return new NameMatches(cappedDirect, cappedPartial);
|
||||
}
|
||||
|
||||
private static Set<String> personTokens(Person person) {
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
tokens.addAll(tokenize(person.getFirstName()));
|
||||
tokens.addAll(tokenize(person.getLastName()));
|
||||
tokens.addAll(tokenize(person.getAlias()));
|
||||
tokens.addAll(tokenize(person.getTitle()));
|
||||
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||
tokens.addAll(tokenize(alias.getFirstName()));
|
||||
tokens.addAll(tokenize(alias.getLastName()));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static List<Person> cap(List<Person> people) {
|
||||
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||
}
|
||||
|
||||
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||
if (direct.size() == 1) return "direct=1";
|
||||
if (direct.size() >= 2) return "direct>=2";
|
||||
if (!partial.isEmpty()) return "partial-only";
|
||||
return "no-match";
|
||||
}
|
||||
|
||||
public List<Person> findAllFamilyMembers() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
@@ -112,7 +216,19 @@ public class PersonService {
|
||||
}
|
||||
|
||||
public Optional<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
|
||||
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
|
||||
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
|
||||
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
|
||||
if (exact.isPresent()) return exact;
|
||||
List<Person> caseInsensitive =
|
||||
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
|
||||
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
|
||||
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
|
||||
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
|
||||
// lowest-id fallback. See ADR-033.
|
||||
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
||||
@@ -127,32 +243,45 @@ public class PersonService {
|
||||
PersonType type = PersonTypeClassifier.classify(alias);
|
||||
if (type == PersonType.SKIP) return null;
|
||||
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
}
|
||||
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||
Optional<Person> exact = personRepository.findByAlias(alias);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins
|
||||
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
// Create-when-absent: institution/group keep the full label in lastName; a person name
|
||||
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
});
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,6 +424,12 @@ public class PersonService {
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the source person into the target, then deletes the source. Sender references move
|
||||
* to the target; receiver references the target lacks are inserted. The source's leftover
|
||||
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
|
||||
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
|
||||
*/
|
||||
@Transactional
|
||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||
if (sourceId.equals(targetId)) {
|
||||
@@ -311,9 +446,7 @@ public class PersonService {
|
||||
// Add target as receiver where source is receiver but target is not yet
|
||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||
|
||||
// Remove all remaining source receiver references (duplicates already handled)
|
||||
personRepository.deleteReceiverReferences(sourceId);
|
||||
|
||||
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||
personRepository.deleteById(sourceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `findAll(String q)` | document, dashboard | List all persons |
|
||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
| `count()` | dashboard | Total person count for stats |
|
||||
|
||||
@@ -20,7 +20,14 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
}
|
||||
|
||||
|
||||
Optional<Tag> findByNameIgnoreCase(String name);
|
||||
// Tag-name resolution (see TagService.findOrCreate). Names that collide case-insensitively across
|
||||
// the canonical tree are VALID — a parent and its same-named lowercase child (e.g. "Geburt" /
|
||||
// "Geburt/geburt") are distinct nodes with their own source_ref and document attachments. So
|
||||
// resolution must be exact-case first, then a non-throwing list for the case-insensitive fallback.
|
||||
// Do NOT add a unique(lower(name)) constraint — it would reject these legitimate rows. See #730.
|
||||
Optional<Tag> findByName(String name);
|
||||
|
||||
List<Tag> findAllByNameIgnoreCase(String name);
|
||||
|
||||
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
||||
Optional<Tag> findBySourceRef(String sourceRef);
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -45,6 +46,10 @@ public class TagService {
|
||||
return enrichWithRelatives(matched);
|
||||
}
|
||||
|
||||
public List<Tag> findByNameContaining(String fragment) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||
@@ -55,10 +60,21 @@ public class TagService {
|
||||
return tagRepository.findBySourceRef(sourceRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
|
||||
* collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
|
||||
* parent and its same-named lowercase child), so resolution prefers an exact-case match, then
|
||||
* falls back to the lowest-id case-insensitive match, then creates. See #730.
|
||||
*/
|
||||
public Tag findOrCreate(String name) {
|
||||
String cleanName = name.trim();
|
||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
Optional<Tag> exact = tagRepository.findByName(cleanName);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins (edit round-trip replays the stored name)
|
||||
List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
|
||||
}
|
||||
return tagRepository.save(Tag.builder().name(cleanName).build()); // create-when-absent (orphan tag: null sourceRef/parentId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,12 @@ public class AdminController {
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-titles")
|
||||
public ResponseEntity<BackfillResult> backfillTitles() {
|
||||
int count = documentService.backfillTitles();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/generate-thumbnails")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||
thumbnailBackfillService.runBackfillAsync();
|
||||
|
||||
@@ -11,3 +11,4 @@ springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Move person-delete referential integrity from application code into the database (#684).
|
||||
--
|
||||
-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed
|
||||
-- document_receivers rows in Java before deleting the person, because the two V1 FKs into
|
||||
-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual
|
||||
-- psql, a batch job) could still orphan rows or 500. This migration makes the database the
|
||||
-- single source of truth so a person delete is safe from every path.
|
||||
--
|
||||
-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches
|
||||
-- documents rows — a cascade into documents would destroy historical letters. sender_id is
|
||||
-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join
|
||||
-- row and the @-mention sidecar row are dropped.
|
||||
--
|
||||
-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second
|
||||
-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table.
|
||||
--
|
||||
-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade.
|
||||
-- The person-delete action itself is still logged at the service layer.
|
||||
|
||||
-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives).
|
||||
ALTER TABLE public.documents
|
||||
DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m,
|
||||
ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m
|
||||
FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL;
|
||||
|
||||
-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric
|
||||
-- completion of V14, which added the same to the document_id side of this table.
|
||||
ALTER TABLE public.document_receivers
|
||||
DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s,
|
||||
ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
|
||||
-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK
|
||||
-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE.
|
||||
-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical
|
||||
-- but is intentionally left untouched, because editing an already-applied migration changes its
|
||||
-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record.
|
||||
-- Clean up pre-existing orphans first — production likely holds dangling rows because the old
|
||||
-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them.
|
||||
-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC
|
||||
-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing.
|
||||
DO $$
|
||||
DECLARE removed int;
|
||||
BEGIN
|
||||
DELETE FROM transcription_block_mentioned_persons m
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);
|
||||
GET DIAGNOSTICS removed = ROW_COUNT;
|
||||
RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT fk_tbmp_person
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
@@ -262,67 +262,6 @@ class DocumentRepositoryTest {
|
||||
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
||||
}
|
||||
|
||||
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
|
||||
|
||||
@Test
|
||||
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
|
||||
Person sender = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person receiver1 = personRepository.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
Person receiver2 = personRepository.save(Person.builder()
|
||||
.firstName("Bertha").lastName("Wagner").build());
|
||||
Person receiver3 = personRepository.save(Person.builder()
|
||||
.firstName("Clara").lastName("Koch").build());
|
||||
|
||||
// Document addressed to all three receivers
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Rundschreiben")
|
||||
.originalFilename("rundschreiben.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
|
||||
.documentDate(LocalDate.of(1950, 6, 1))
|
||||
.build());
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||
|
||||
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
|
||||
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||
receiver1.getId(), from, to, sort);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
|
||||
Person sender = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person receiver = personRepository.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief als Absender")
|
||||
.originalFilename("brief_absender.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.receivers(new HashSet<>(Set.of(receiver)))
|
||||
.documentDate(LocalDate.of(1950, 6, 1))
|
||||
.build());
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||
|
||||
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||
sender.getId(), from, to, sort);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── findSegmentationQueue ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -685,4 +624,88 @@ class DocumentRepositoryTest {
|
||||
.reviewed(reviewed)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
|
||||
|
||||
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
|
||||
Specification<Document> spec = (root, query, cb) -> {
|
||||
if (query != null) query.distinct(true);
|
||||
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
|
||||
var personPredicate = cb.or(
|
||||
cb.equal(root.get("sender"), person),
|
||||
cb.equal(receiversJoin, person));
|
||||
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
|
||||
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
|
||||
};
|
||||
return documentRepository.findAll(spec, PageRequest.of(0, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Senderbrief").originalFilename("sender.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(person).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Empfängerbrief").originalFilename("receiver.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("SenderEmpfänger").originalFilename("both.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(person)
|
||||
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).hasSize(1);
|
||||
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document inside = documentRepository.save(Document.builder()
|
||||
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
|
||||
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
|
||||
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Person other = personRepository.save(Person.builder().lastName("Braun").build());
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Fremder Brief").originalFilename("other.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(other).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
@@ -74,6 +75,9 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -228,6 +232,216 @@ class DocumentServiceTest {
|
||||
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||
}
|
||||
|
||||
// ─── updateDocument save-time auto-title regeneration (#726) ──────────────
|
||||
//
|
||||
// Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
|
||||
// title equals what the factory builds from the CURRENTLY-persisted state. The edit form
|
||||
// round-trips the stored title verbatim when untouched, so an equal submission means the
|
||||
// user did not type over it. makeStored() seeds index/date/precision/location and sets the
|
||||
// stored title to the matching auto-title, mirroring a freshly-imported row.
|
||||
|
||||
private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename(index)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
|
||||
private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
|
||||
DatePrecision precision, String location) {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(submittedTitle);
|
||||
dto.setDocumentDate(date);
|
||||
dto.setMetaDatePrecision(precision);
|
||||
dto.setLocation(location);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
|
||||
when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
|
||||
when(documentRepository.save(any())).thenReturn(stored);
|
||||
documentService.updateDocument(stored.getId(), dto, null, null);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// user changed the date AND typed a new title in the same save
|
||||
DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// location cleared (null), title untouched
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).doesNotContain("2028");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
// stored auto-title "C-0029 – 1928"; set a full day at DAY precision
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
|
||||
Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
|
||||
// stored auto-title is just "C-0029"; add a 1928 YEAR date
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsSeasonLabel() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
dto.setMetaDateRaw("Frühling 1943");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
|
||||
// Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
|
||||
// must carry them from the entity (exercises the skip-null effective* resolvers).
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1944");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
|
||||
Document stored = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename("C-0029")
|
||||
.documentDate(LocalDate.of(1917, 1, 10))
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(LocalDate.of(1917, 1, 11))
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – 10.–11. Jan. 1917"
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(stored.getTitle());
|
||||
dto.setDocumentDate(LocalDate.of(1918, 1, 10));
|
||||
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||
dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 10.–11. Jan. 1918");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
|
||||
// originalFilename was reassigned by an earlier file-replace, so the stored title (built
|
||||
// at import from the old index) no longer matches build(currentState) → treated as manual.
|
||||
Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_idempotent_whenNothingChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
String before = stored.getTitle();
|
||||
DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo(before);
|
||||
}
|
||||
|
||||
// ─── updateDocument date-range validation (#678) ──────────────────────────
|
||||
|
||||
/** Builds a stored doc ready for an updateDocument call (collections initialised). */
|
||||
@@ -481,6 +695,59 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── backfillTitles — one-time stale-title cleanup (#726, FR-003) ─────────
|
||||
|
||||
@Test
|
||||
void backfillTitles_rewritesStaleAutoTitle_andCountsIt() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin"); // stale stored title (date typo never fixed)
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isEqualTo(1);
|
||||
assertThat(stale.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
verify(documentRepository).save(stale);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_skipsProse() {
|
||||
Document prose = makeStored("C-0030", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
prose.setTitle("C-0030 – Brief an Mutter");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(prose));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
assertThat(prose.getTitle()).isEqualTo("C-0030 – Brief an Mutter");
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_isIdempotent_forAlreadyCorrectTitle() {
|
||||
Document fresh = makeStored("C-0031", LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||
// title already equals build(current state) → nothing to do
|
||||
when(documentRepository.findAll()).thenReturn(List.of(fresh));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_neverRecordsVersions() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
verify(documentVersionService, never()).recordVersion(any());
|
||||
}
|
||||
|
||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -1128,53 +1395,6 @@ class DocumentServiceTest {
|
||||
.isEqualTo("19650332_Mueller_Hans");
|
||||
}
|
||||
|
||||
// ─── getConversationFiltered ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
LocalDate from = LocalDate.of(1940, 1, 1);
|
||||
LocalDate to = LocalDate.of(1960, 12, 31);
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||
|
||||
verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
ArgumentCaptor<LocalDate> fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
|
||||
assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_usesTodayForTo_whenToIsNull() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
ArgumentCaptor<LocalDate> toCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
|
||||
assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
|
||||
}
|
||||
|
||||
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -1760,35 +1980,6 @@ class DocumentServiceTest {
|
||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
// ─── getConversationFiltered (single-person mode) ─────────────────────────
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(personId, null, null, null, sort);
|
||||
|
||||
verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort));
|
||||
verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
||||
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── searchDocuments — SENDER sort includes documents with null sender ─────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end backfill against a real Postgres (#726, FR-003). H2 is unusable here — the
|
||||
* {@code title} column is NOT NULL and the title-sync semantics depend on that — so this pins the
|
||||
* behaviour on {@code postgres:16-alpine}: a stale auto-title is rewritten, the sweep is
|
||||
* idempotent, prose is left alone, and the mechanical rename writes no {@code document_versions}
|
||||
* rows. Permission enforcement (401/403) is covered faster by the {@code @WebMvcTest} slice in
|
||||
* {@code AdminControllerTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class DocumentTitleBackfillIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentVersionRepository documentVersionRepository;
|
||||
|
||||
private Document persist(String index, String title, LocalDate date, DatePrecision precision, String location) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.originalFilename(index)
|
||||
.title(title)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_rewritesStaleAutoTitle() {
|
||||
Document stale = persist("C-0029", "C-0029 – 2028 – Berlin",
|
||||
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isEqualTo(1); // exactly the one stale row seeded (clean test DB)
|
||||
assertThat(documentRepository.findById(stale.getId()).orElseThrow().getTitle())
|
||||
.isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_isIdempotent_secondRunChangesNothing() {
|
||||
persist("C-0029", "C-0029 – 2028 – Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
documentService.backfillTitles();
|
||||
int secondRun = documentService.backfillTitles();
|
||||
|
||||
assertThat(secondRun).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_skipsProse() {
|
||||
Document prose = persist("C-0030", "C-0030 – Brief an Mutter",
|
||||
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
assertThat(documentRepository.findById(prose.getId()).orElseThrow().getTitle())
|
||||
.isEqualTo("C-0030 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_addsNoDocumentVersionRows() {
|
||||
persist("C-0029", "C-0029 – 2028 – Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
long versionsBefore = documentVersionRepository.count();
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
assertThat(documentVersionRepository.count()).isEqualTo(versionsBefore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The backfill overwrite heuristic (FR-004) in isolation — every emittable date-label form is
|
||||
* recognised, prose is left alone, and a regex-metacharacter index is matched literally without
|
||||
* hanging. The exact label spellings mirror {@code docs/date-label-fixtures.json}.
|
||||
*/
|
||||
class DocumentTitleBackfillMatcherTest {
|
||||
|
||||
private static boolean overwritable(String title, String location) {
|
||||
return DocumentTitleBackfillMatcher.isOverwritable(title, "C-0029", location);
|
||||
}
|
||||
|
||||
// ─── each date-label form (index + form) is overwritable ──────────────────
|
||||
|
||||
@Test
|
||||
void year_form() {
|
||||
assertThat(overwritable("C-0029 – 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approx_form() {
|
||||
assertThat(overwritable("C-0029 – ca. 1920", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void month_form() {
|
||||
assertThat(overwritable("C-0029 – Juni 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void day_form() {
|
||||
assertThat(overwritable("C-0029 – 24. Dezember 1943", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void season_form() {
|
||||
assertThat(overwritable("C-0029 – Sommer 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_label_form() {
|
||||
assertThat(overwritable("C-0029 – Datum unbekannt", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_same_month_form() {
|
||||
assertThat(overwritable("C-0029 – 10.–11. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_cross_month_form() {
|
||||
assertThat(overwritable("C-0029 – 30. Jan. – 2. Feb. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_cross_year_form() {
|
||||
assertThat(overwritable("C-0029 – 30. Dez. 1916 – 2. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_single_day_form() {
|
||||
assertThat(overwritable("C-0029 – 10. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_open_form() {
|
||||
assertThat(overwritable("C-0029 – ab 10. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
// ─── date label + trailing location (any location) ────────────────────────
|
||||
|
||||
@Test
|
||||
void date_form_with_trailing_location() {
|
||||
assertThat(overwritable("C-0029 – 1916 – Berlin", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_with_internal_separator_plus_trailing_location() {
|
||||
// The range label itself contains " – "; the trailing " – Berlin" must still be peeled.
|
||||
assertThat(overwritable("C-0029 – 30. Jan. – 2. Feb. 1917 – Berlin", null)).isTrue();
|
||||
}
|
||||
|
||||
// ─── index-only and index+location cases ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void exactly_index() {
|
||||
assertThat(overwritable("C-0029", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_plus_location_equal_to_current() {
|
||||
assertThat(overwritable("C-0029 – Berlin", "Berlin")).isTrue();
|
||||
}
|
||||
|
||||
// ─── prose is left untouched ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void prose_segment_not_matching_location_is_skipped() {
|
||||
assertThat(overwritable("C-0029 – Brief an Mutter", "Berlin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void location_only_segment_is_skipped_when_no_current_location() {
|
||||
// No date label, and the doc has no location to compare against → cannot prove machine.
|
||||
assertThat(overwritable("C-0029 – Berlin", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void title_not_starting_with_index_is_skipped() {
|
||||
assertThat(overwritable("Ganz anderer Titel", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── near-miss: shapes that look almost machine-built but are not ──────────
|
||||
|
||||
@Test
|
||||
void ascii_hyphen_instead_of_en_dash_separator_is_skipped() {
|
||||
// The separator is " – " (en dash); a plain " - " is not the machine separator.
|
||||
assertThat(overwritable("C-0029 - 1916", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void date_label_without_separator_before_trailing_text_is_skipped() {
|
||||
// "1916 Berlin" is not a date label and is not joined by " – "; prose, not machine.
|
||||
assertThat(overwritable("C-0029 – 1916 Berlin", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void year_with_trailing_letters_is_not_a_year_label() {
|
||||
assertThat(overwritable("C-0029 – 1916er Brief", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_immediately_followed_by_text_without_separator_is_skipped() {
|
||||
assertThat(overwritable("C-0029x – 1916", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── fail-closed guards ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void null_title_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(null, "C-0029", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void null_index_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable("C-0029 – 1916", null, null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blank_index_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(" – 1916", " ", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── ReDoS / regex-metacharacter index is matched literally and terminates ─
|
||||
|
||||
@Test
|
||||
@Timeout(value = 5, unit = TimeUnit.SECONDS)
|
||||
void index_with_regex_metacharacters_is_matched_literally_and_terminates() {
|
||||
String hostileIndex = "C-0029(.*).pdf";
|
||||
// Literal prefix → matches; trailing date label → overwritable. Must not hang.
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
|
||||
hostileIndex + " – 1916", hostileIndex, null)).isTrue();
|
||||
// A title that does NOT start with the literal hostile index is skipped, also fast.
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
|
||||
"C-0029 – 1916", hostileIndex, null)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The auto-title composition {@code {index} – {dateLabel} – {location}} in isolation.
|
||||
* The honest date-label forms themselves are pinned by {@link DocumentTitleFormatterTest}
|
||||
* against the shared #666 fixture; here we assert only how the factory composes the
|
||||
* three segments and which segments it omits.
|
||||
*/
|
||||
class DocumentTitleFactoryTest {
|
||||
|
||||
private final DocumentTitleFactory factory = new DocumentTitleFactory();
|
||||
|
||||
private static Document.DocumentBuilder doc(String index) {
|
||||
return Document.builder()
|
||||
.originalFilename(index)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_only_when_no_date_and_no_location() {
|
||||
assertThat(factory.build(doc("C-0029").build())).isEqualTo("C-0029");
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_and_year_date() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_date_and_location() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.location("Berlin")
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void location_without_date_attaches_directly_to_index() {
|
||||
Document d = doc("C-0029").location("Berlin").build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_precision_omits_the_date_segment() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blank_location_is_omitted() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.location(" ")
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bare_document_with_null_index_builds_empty_string_not_npe() {
|
||||
// originalFilename is NOT NULL in production; the guard keeps a synthetic/partial entity
|
||||
// from tripping StringBuilder(null) with an opaque NPE.
|
||||
assertThat(factory.build(Document.builder().build())).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void day_precision_renders_the_full_german_label() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.DAY)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* #730 — tag-name resolution against a real Postgres. A mocked repo can't prove the two things that
|
||||
* actually break: that {@code findAllByNameIgnoreCase} folds case the way Postgres {@code LOWER()}
|
||||
* does (critical for umlauts like {@code ü}), and that saving a document tagged with a case-colliding
|
||||
* tag no longer throws {@code NonUniqueResultException}. H2 folds case differently, so this pins the
|
||||
* behaviour on {@code postgres:16-alpine}. The four-branch resolution logic itself is covered faster
|
||||
* by the mocked {@code TagServiceTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TagCaseCollisionIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
@Autowired TagService tagService;
|
||||
|
||||
private Tag persistTag(String name, String sourceRef, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).sourceRef(sourceRef).parentId(parentId).build());
|
||||
}
|
||||
|
||||
private Document persistDocTaggedWith(Tag tag) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7301")
|
||||
.title("Weihnachtsbrief")
|
||||
.documentDate(LocalDate.of(1928, 1, 1))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(new HashSet<>(Set.of(tag)))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChild() throws Exception {
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = persistDocTaggedWith(child);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Weihnachtsbrief");
|
||||
dto.setDocumentDate(LocalDate.of(1930, 1, 1)); // change the date — the field that 500'd on staging
|
||||
dto.setMetaDatePrecision(DatePrecision.YEAR);
|
||||
dto.setTags("weihnachten"); // the edit form round-trips the stored child name
|
||||
|
||||
assertThatCode(() -> documentService.updateDocument(doc.getId(), dto, null, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); // child kept, not the parent
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow() {
|
||||
// The regression catcher: a plain-ASCII pair would stay green even if Postgres folded ü wrongly.
|
||||
Tag parent = persistTag("Glückwünsche", "Glückwünsche", null);
|
||||
Tag child = persistTag("glückwünsche", "Glückwünsche/glückwünsche", parent.getId());
|
||||
|
||||
// Proof that real Postgres LOWER() folds the umlaut so both rows match case-insensitively.
|
||||
// Query with the UPPERCASE form findOrCreate actually passes — folding LOWER('GLÜCKWÜNSCHE')
|
||||
// against LOWER(name) is the exact step under test; a lowercase probe wouldn't exercise it.
|
||||
assertThat(tagRepository.findAllByNameIgnoreCase("GLÜCKWÜNSCHE")).hasSize(2);
|
||||
|
||||
// No exact-case "GLÜCKWÜNSCHE" row exists → resolution falls through to the case-insensitive
|
||||
// branch with two candidates and must pick the lowest id deterministically, never throwing.
|
||||
UUID expected = List.of(parent, child).stream().min(Comparator.comparing(Tag::getId)).orElseThrow().getId();
|
||||
Tag first = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
Tag second = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
|
||||
assertThat(first.getId()).isEqualTo(expected);
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkEdit_resolvesCaseCollidingTagThroughFindOrCreate_withoutThrow() {
|
||||
// Bulk-edit shares resolveTags → findOrCreate; this guards a future refactor that bypasses it.
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7302")
|
||||
.title("Brief")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
|
||||
DocumentBulkEditDTO dto = new DocumentBulkEditDTO();
|
||||
dto.setTagNames(List.of("weihnachten"));
|
||||
|
||||
assertThatCode(() -> documentService.applyBulkEditToDocument(doc.getId(), dto, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
@@ -30,6 +32,7 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
private UUID documentId;
|
||||
@@ -55,8 +58,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void mentionedPersons_roundTripsTwoEntries() {
|
||||
UUID auguste = UUID.randomUUID();
|
||||
UUID hermann = UUID.randomUUID();
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
|
||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId)
|
||||
@@ -97,8 +101,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||
UUID augusteId = UUID.randomUUID();
|
||||
UUID hermannId = UUID.randomUUID();
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
|
||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -37,6 +38,30 @@ class GlobalExceptionHandlerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
|
||||
// #731: before the fix, a case-colliding alias/name made Hibernate throw
|
||||
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
|
||||
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
|
||||
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
|
||||
// no "2 results were returned" row count reaching the client (CWE-209).
|
||||
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
|
||||
"query did not return a unique result: 2 results were returned", 1, 2);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(500);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
assertThat(response.getBody().message())
|
||||
.isEqualTo("An unexpected error occurred")
|
||||
.doesNotContain("results were returned")
|
||||
.doesNotContain("NonUnique")
|
||||
.doesNotContain("IncorrectResultSize");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
|
||||
// A DataIntegrityViolationException carries the constraint name + SQL in its message;
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -59,8 +60,10 @@ class DocumentImporterTest {
|
||||
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
||||
lenient().when(fileStreamOpener.open(any(File.class)))
|
||||
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
||||
importer = new DocumentImporter(documentService, personService, tagService, s3Client,
|
||||
thumbnailAsyncRunner, fileStreamOpener);
|
||||
// Real factory (pure, dependency-free) so the title-content assertions below exercise
|
||||
// the shared composition rather than a stub — the #726 single source of truth.
|
||||
importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService,
|
||||
tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener);
|
||||
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import jakarta.persistence.PersistenceContext;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -120,37 +121,60 @@ class PersonRepositoryTest {
|
||||
.containsExactly("Anna", "Clara");
|
||||
}
|
||||
|
||||
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
|
||||
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
|
||||
|
||||
@Test
|
||||
void findByAliasIgnoreCase_returnsMatchingPerson() {
|
||||
void findByAlias_returnsExactCaseMatchOnly() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
||||
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Karl");
|
||||
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
|
||||
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
|
||||
|
||||
assertThat(found).isEmpty();
|
||||
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
|
||||
}
|
||||
|
||||
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
|
||||
@Test
|
||||
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
|
||||
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
|
||||
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
|
||||
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
|
||||
|
||||
@Test
|
||||
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
|
||||
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
|
||||
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
||||
|
||||
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
|
||||
"maria", "raddatz");
|
||||
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
|
||||
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
|
||||
}
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Maria");
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
|
||||
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
|
||||
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
|
||||
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ───────────────────────────────────────────────────
|
||||
@@ -366,30 +390,6 @@ class PersonRepositoryTest {
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
||||
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc1 = documentRepository.save(Document.builder()
|
||||
.title("Brief 1").originalFilename("b1.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
Document doc2 = documentRepository.save(Document.builder()
|
||||
.title("Brief 2").originalFilename("b2.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
|
||||
personRepository.deleteReceiverReferences(toDelete.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -428,6 +428,67 @@ class PersonRepositoryTest {
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_findsByAliasFirstName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Wilhelmina");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_ordersByLastNameThenFirstName() {
|
||||
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Cram");
|
||||
|
||||
assertThat(results).extracting(Person::getFirstName)
|
||||
.containsExactly("Anna", "Bernd", "Clara");
|
||||
}
|
||||
|
||||
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
|
||||
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
|
||||
// fetch query actually finds an alias-only match and feeds it into classification. These walk
|
||||
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
|
||||
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
|
||||
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -707,4 +768,146 @@ class PersonRepositoryTest {
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getGeneration()).isNull();
|
||||
}
|
||||
|
||||
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
|
||||
// A raw deleteById (bypassing PersonService) must keep referential integrity:
|
||||
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
|
||||
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
|
||||
// real Postgres because the FK ON DELETE behaviour never fires on H2.
|
||||
|
||||
@Test
|
||||
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
|
||||
|
||||
Document sent = documentRepository.save(Document.builder()
|
||||
.title("Gesendet").originalFilename("sent.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||
Document received = documentRepository.save(Document.builder()
|
||||
.title("Empfangen").originalFilename("received.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(Set.of(target)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
|
||||
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
|
||||
|
||||
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||
assertThat(reloadedReceived.getReceivers())
|
||||
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
|
||||
|
||||
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
|
||||
// layer — both documents themselves survive. Guards against a future migration turning
|
||||
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
|
||||
assertThat(documentRepository.findById(sent.getId())).isPresent();
|
||||
assertThat(documentRepository.findById(received.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId()); // co-receiver untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
|
||||
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
|
||||
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Selbstbrief").originalFilename("self.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getSender()).isNull();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
|
||||
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
|
||||
// in transcription_blocks.text and must stay visible as plain text after the person goes.
|
||||
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
|
||||
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build());
|
||||
entityManager.flush();
|
||||
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
|
||||
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
|
||||
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
|
||||
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
|
||||
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
|
||||
// Two mention rows on the same block: the deleted person and an innocent bystander.
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, mentioned.getId())
|
||||
.setParameter(3, "Auguste Raddatz").executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, survivor.getId())
|
||||
.setParameter(3, "Clara Cram").executeUpdate();
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(mentioned.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Number mentionRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, mentioned.getId()).getSingleResult();
|
||||
assertThat(mentionRows.longValue()).isZero();
|
||||
|
||||
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
|
||||
Number survivorRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, survivor.getId()).getSingleResult();
|
||||
assertThat(survivorRows.longValue()).isEqualTo(1);
|
||||
|
||||
String text = (String) entityManager.createNativeQuery(
|
||||
"SELECT text FROM transcription_blocks WHERE id = ?1")
|
||||
.setParameter(1, blockId).getSingleResult();
|
||||
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
@@ -16,10 +17,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -33,6 +37,7 @@ class PersonServiceIntegrationTest {
|
||||
@Autowired PersonService personService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentService documentService;
|
||||
|
||||
@PersistenceContext EntityManager entityManager;
|
||||
|
||||
@@ -75,6 +80,93 @@ class PersonServiceIntegrationTest {
|
||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
// ─── #731: case-colliding alias resolution against real Postgres ───────────
|
||||
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
|
||||
// plain-ASCII test would stay green while umlaut aliases regressed.
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
|
||||
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
|
||||
|
||||
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
|
||||
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
|
||||
Person resolved = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(resolved.getId()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
|
||||
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
|
||||
Person first = personService.findOrCreateByAlias("MÜLLER");
|
||||
Person second = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
// ─── #731: filename-based sender resolution against real Postgres ──────────
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
|
||||
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
|
||||
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
|
||||
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
|
||||
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
|
||||
personRepository.save(Person.builder().lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
|
||||
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
|
||||
// and pick up the last-name-only row.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||
}
|
||||
|
||||
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
|
||||
return documentService.storeDocument(file, null);
|
||||
}
|
||||
|
||||
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
||||
|
||||
@Test
|
||||
@@ -180,9 +272,9 @@ class PersonServiceIntegrationTest {
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
|
||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
||||
// the documents themselves survive.
|
||||
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
|
||||
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
|
||||
// orphan and the documents themselves survive.
|
||||
Person target = personRepository.save(Person.builder()
|
||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||
Person bystander = personRepository.save(Person.builder()
|
||||
@@ -196,16 +288,16 @@ class PersonServiceIntegrationTest {
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||
|
||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
||||
// the database directly without the persistence context holding stale references that
|
||||
// would re-flush a now-deleted person as a transient association.
|
||||
// Persist the fixture and detach everything so the delete operates on the database
|
||||
// directly without the persistence context holding stale references.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.deletePerson(target.getId());
|
||||
|
||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
||||
// reads observe the post-delete database state, not stale managed entities.
|
||||
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
|
||||
// cache so the asserting reads observe the post-delete database state, not stale
|
||||
// managed entities still holding the dropped sender/receiver associations.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
@@ -220,4 +312,38 @@ class PersonServiceIntegrationTest {
|
||||
// The other person and the documents themselves survive the delete.
|
||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
|
||||
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
|
||||
// target as sender of A and receiver of B, drops the source's leftover receiver row via
|
||||
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
|
||||
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
|
||||
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document docA = documentRepository.save(Document.builder()
|
||||
.title("Von Anna").originalFilename("a.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(source).build());
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("An Anna").originalFilename("b.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.mergePersons(source.getId(), target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(source.getId())).isEmpty();
|
||||
|
||||
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
|
||||
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
|
||||
|
||||
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
|
||||
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
|
||||
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -147,9 +148,11 @@ class PersonServiceTest {
|
||||
|
||||
personService.deletePerson(id);
|
||||
|
||||
verify(personRepository).reassignSenderToNull(id);
|
||||
verify(personRepository).deleteReceiverReferences(id);
|
||||
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
|
||||
// existence then deletes; it no longer detaches sender/receiver references itself.
|
||||
verify(personRepository).findById(id);
|
||||
verify(personRepository).deleteById(id);
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -372,14 +375,57 @@ class PersonServiceTest {
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
||||
String alias = "Walter de Gruyter";
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
||||
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||
String alias = "müller";
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
assertThat(result).isEqualTo(exact);
|
||||
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
|
||||
String alias = "Müller";
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(exact);
|
||||
// exact-case short-circuits — the case-insensitive siblings are never consulted.
|
||||
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
String alias = "müller";
|
||||
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(only);
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
|
||||
String alias = "müller";
|
||||
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
|
||||
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
|
||||
|
||||
Person first = personService.findOrCreateByAlias(alias);
|
||||
Person second = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
|
||||
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -387,7 +433,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
@@ -400,7 +447,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
||||
String alias = "Clara Cram geb. de Gruyter";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
@@ -422,7 +470,8 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
||||
String alias = "Arthur Collignon GmbH";
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -439,7 +488,8 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
||||
String alias = "Geschwister de Gruyter";
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -457,7 +507,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
@@ -469,11 +520,54 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
||||
verify(personRepository).findByAlias("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── findByName (filename-based sender resolution) ────────────────────────
|
||||
|
||||
@Test
|
||||
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
|
||||
|
||||
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
|
||||
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||
.thenReturn(List.of(only));
|
||||
|
||||
assertThat(personService.findByName("hans", "müller")).contains(only);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
|
||||
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||
.thenReturn(List.of(a, b));
|
||||
|
||||
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
|
||||
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
|
||||
assertThat(personService.findByName("hans", "müller")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
|
||||
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||
.thenReturn(List.of());
|
||||
|
||||
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||
}
|
||||
|
||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||
@@ -700,10 +794,14 @@ class PersonServiceTest {
|
||||
|
||||
personService.mergePersons(sourceId, targetId);
|
||||
|
||||
verify(personRepository).findById(sourceId);
|
||||
verify(personRepository).findById(targetId);
|
||||
verify(personRepository).reassignSender(sourceId, targetId);
|
||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
|
||||
// deleteById — merge no longer deletes them explicitly.
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||
@@ -800,4 +898,165 @@ class PersonServiceTest {
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDisplayNameContaining_delegatesToSearchByName() {
|
||||
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
|
||||
|
||||
List<Person> result = personService.findByDisplayNameContaining("Walter");
|
||||
|
||||
assertThat(result).containsExactly(walter);
|
||||
verify(personRepository).searchByName("Walter");
|
||||
}
|
||||
|
||||
// ─── tokenize (name-match contract) ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void tokenize_hyphenatedName_splitsOnHyphen() {
|
||||
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_apostropheName_splitsOnApostrophe() {
|
||||
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_umlautName_lowercasesToSingleToken() {
|
||||
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_doubleSpace_dropsEmptyTokens() {
|
||||
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_allWhitespace_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_null_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(null)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── resolveByName (direct / partial classification) ──────────────────────
|
||||
|
||||
@Test
|
||||
void resolveByName_singleDirectMatch_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAliasToken_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
|
||||
.type(PersonNameAliasType.MAIDEN_NAME).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_middleName_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_reorderedTokens_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Cram Clara");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_cramVsCramer_classifiesAsPartial() {
|
||||
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.partial()).containsExactly(cramer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
|
||||
NameMatches result = personService.resolveByName(" - ");
|
||||
|
||||
assertThat(result.direct()).isEmpty();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
|
||||
List<Person> pool = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
|
||||
}
|
||||
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
pool.add(direct);
|
||||
when(personRepository.searchByName("clara")).thenReturn(pool);
|
||||
when(personRepository.searchByName("cram")).thenReturn(pool);
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(direct);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
|
||||
personService.resolveByName("a b c d e f g h i j");
|
||||
|
||||
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
|
||||
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
|
||||
// candidate is classified once, not twice.
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,20 +53,68 @@ class TagServiceTest {
|
||||
// ─── findOrCreate ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreate_returnsExisting_whenNameFound() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
|
||||
void findOrCreate_exactCaseWins_overCaseInsensitiveSibling() {
|
||||
// "Geburt" (parent) and "geburt" (child) both exist; the edit round-trip replays the stored
|
||||
// name "geburt", which must bind to the exact-case row, not the parent.
|
||||
Tag exact = Tag.builder().id(UUID.randomUUID()).name("geburt").build();
|
||||
when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exact));
|
||||
|
||||
Tag result = tagService.findOrCreate("Familie");
|
||||
Tag result = tagService.findOrCreate("geburt");
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
assertThat(result).isEqualTo(exact);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_createsNew_whenNameNotFound() {
|
||||
void findOrCreate_exactCaseWins_evenWhenItsIdIsNotTheLowest() {
|
||||
// Adversarial guard: exact-case must short-circuit BEFORE the lowest-id rule. Here the exact row
|
||||
// has the higher id, so a naive "always pick lowest id across all CI matches" would pick wrong.
|
||||
Tag exactHigherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000009")).name("geburt").build();
|
||||
when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exactHigherId));
|
||||
|
||||
Tag result = tagService.findOrCreate("geburt");
|
||||
|
||||
assertThat(result).isEqualTo(exactHigherId);
|
||||
verify(tagRepository, never()).findAllByNameIgnoreCase(any()); // exact-case wins without consulting the CI list
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
// Stored name is "Weihnachten"; a save replays "weihnachten" (no exact-case row) → bind to the
|
||||
// single case-insensitive match rather than creating a duplicate.
|
||||
Tag stored = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").build();
|
||||
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(stored));
|
||||
|
||||
Tag result = tagService.findOrCreate("weihnachten");
|
||||
|
||||
assertThat(result).isEqualTo(stored);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
|
||||
// Two rows collide case-insensitively and neither equals the query exactly. Resolution must be
|
||||
// deterministic (lowest id) and never throw — proven by calling twice and getting the same id.
|
||||
Tag lowerId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).name("Reisepläne").build();
|
||||
Tag higherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).name("reisepläne").build();
|
||||
when(tagRepository.findByName("REISEPLÄNE")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("REISEPLÄNE")).thenReturn(List.of(higherId, lowerId));
|
||||
|
||||
Tag first = tagService.findOrCreate("REISEPLÄNE");
|
||||
Tag second = tagService.findOrCreate("REISEPLÄNE");
|
||||
|
||||
assertThat(first.getId()).isEqualTo(lowerId.getId());
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_createsOrphanTag_whenNameAbsent() {
|
||||
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findByName("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("Krieg")).thenReturn(List.of());
|
||||
when(tagRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Tag result = tagService.findOrCreate("Krieg");
|
||||
@@ -76,13 +124,15 @@ class TagServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_trimsWhitespaceBeforeLookup() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
|
||||
void findOrCreate_trimsWhitespace_thenLandsOnCaseInsensitiveChild() {
|
||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("weihnachten").build();
|
||||
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(child));
|
||||
|
||||
tagService.findOrCreate(" Urlaub ");
|
||||
Tag result = tagService.findOrCreate(" weihnachten ");
|
||||
|
||||
verify(tagRepository).findByNameIgnoreCase("Urlaub");
|
||||
assertThat(result).isEqualTo(child);
|
||||
verify(tagRepository).findByName("weihnachten");
|
||||
}
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
@@ -616,4 +666,17 @@ class TagServiceTest {
|
||||
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
||||
verify(tagRepository, atLeastOnce()).findAllById(any());
|
||||
}
|
||||
|
||||
// ─── findByNameContaining ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByNameContaining_delegatesToRepository() {
|
||||
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
|
||||
|
||||
List<Tag> result = tagService.findByNameContaining("krieg");
|
||||
|
||||
assertThat(result).containsExactly(krieg);
|
||||
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,31 @@ class AdminControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/backfill-titles (#726) ────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillTitles_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillTitles_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillTitles_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillTitles()).thenReturn(7);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(7));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -48,8 +48,6 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
||||
|
||||
A **derived domain** has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains.
|
||||
|
||||
**`conversation`** (route: `/briefwechsel`) — bilateral letter timeline between two `Person`s. Derived from `Document` sender/receiver relationships. The `DocumentRepository` bidirectional query is the only data source.
|
||||
|
||||
**`activity`** (route: `/aktivitaeten`) — family activity feed. Derived from `audit_log`, `notifications`, and document events. No aggregation table; computed on-the-fly by `DashboardService` and composed in the SvelteKit load function.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,11 +52,12 @@ The OCR service requires significant RAM for model loading. The dev compose sets
|
||||
|
||||
| Production target | RAM | Recommended OCR limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Hetzner CX42 | 16 GB | 12 GB | Recommended for OCR-enabled production |
|
||||
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
|
||||
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
|
||||
A CX32 cannot honour the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
|
||||
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
|
||||
|
||||
### Dev vs production differences
|
||||
|
||||
@@ -140,7 +141,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
|
||||
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on servers with 8 GB RAM; leave unset (12g default) on servers with ≥ 16 GB RAM | `12g` (prod compose default) | — | — |
|
||||
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||
|
||||
@@ -264,6 +265,7 @@ git.raddatz.cloud A <server IP>
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
|
||||
```bash
|
||||
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||
# Expected: docker compose up -d --wait succeeds for archiv-staging, then
|
||||
|
||||
@@ -45,6 +45,9 @@ _See also [TranscriptionBlock](#transcriptionblock-transcriptionblock)._
|
||||
|
||||
**raw attribution** (`Document.senderText`, `Document.receiverText`, `Document.metaDateRaw`) — the original spreadsheet cell text for a document's sender, receiver, and date, preserved verbatim even after a `Person` or normalized date is linked. It keeps provenance intact and enables an "as written in the original" view.
|
||||
|
||||
**auto-generated title** (`DocumentTitleFactory`) — a `Document` title composed by the formula `{index} – {dateLabel} – {location}` (index = `originalFilename`; date label honest at the row's precision; location omitted when blank). On edit, an unchanged auto-title follows a corrected date/location forward (exact old-vs-new match in `DocumentService.updateDocument`); a hand-written title is kept verbatim. `POST /api/admin/backfill-titles` rewrites already-stale ones in one sweep using a grammar heuristic (`DocumentTitleBackfillMatcher`).
|
||||
_Not to be confused with a hand-written title_ — only a title that still equals what the factory builds is treated as machine-generated and rewritten; prose is left untouched.
|
||||
|
||||
**DocumentVersion** (`DocumentVersion`) — an append-only snapshot of a `Document`'s metadata at a point in time. Append-only by convention; no consumer-facing create or update endpoint exists. The entity uses Lombok `@Data` (which generates setters), so immutability is enforced by application convention, not at the Java level.
|
||||
|
||||
**Tag** (`Tag`) — a hierarchical category that can be applied to `Document`s. Tags are self-referencing via a `parent_id` foreign key, forming a tree structure.
|
||||
@@ -111,16 +114,21 @@ _See also [PersonRelationship](#person-person)._
|
||||
|
||||
**seeded rank** (`Person.generation`) — the imported generation index on a `Person` (G 0 = founders, increasing downward), used as a strict row anchor in `buildLayout.ts`. The iterative fallback heuristic never overrides a seeded rank, and spouse-pulldown never pulls a seeded rank — only unseeded nodes (no `generation`) flow through the heuristic.
|
||||
|
||||
**sibling block** — a layout unit holding the children of a single parent-set at one generation, used inside `buildLayout.ts`. Each block has a center computed from the parents' midpoint; blocks are then packed left-to-right within a generation row. Two adjacent sibling blocks at the same rank can be merged if a `SPOUSE_OF` edge crosses them (intra-family marriage, AC2).
|
||||
**family forest** — the model the Stammbaum horizontal layout reasons over (ADR-030, `familyForest.ts`): a forest of **units** rather than per-generation rows. Replaces the old per-generation "sibling block" packer. The canonical fixture is ~24 root units over 62 nodes.
|
||||
|
||||
**loose spouse** — a person at a given generation who is a spouse of someone in a sibling block but is not themselves a parented child of anyone in the graph. Loose spouses are attached adjacent to their parented partner (right side per Leonie's UX rule) so the spouse line stays short.
|
||||
_Not to be confused with [parented](#parented-layout)_ — loose is the absence of parent edges into the graph.
|
||||
**unit** `[layout]` — one bloodline carrier (the **primary**) plus the spouse(s) absorbed into its run, rendered as one adjacent row of cards. `members[0]` is the primary; the rest are spouses in marriage-year order (#361). A lone person is a unit of one. A unit's children are the units anchored by the couple's offspring. The unit — not the individual — is the node the tidy-tree packs.
|
||||
|
||||
**parented** `[layout]` — a layout flag on a sibling-block member indicating that the person has at least one `PARENT_OF` edge incoming from a node already in the graph at the prior generation. Parented members are the layout anchors of their block (the block is centred so the average index of parented members sits under the parents' midpoint); non-parented members (loose spouses) ride along on the side.
|
||||
**tidy tree** — the bottom-up Reingold–Tilford contour packer (`tidyTree.ts`) that assigns each unit's horizontal `x`: lay out child subtrees first, pack them so their contours clear by `COL_GAP` at every level, then centre the unit over the span of its children. Contours are indexed by absolute generation level, so unrelated roots at different generations share x-columns. `x` comes from structure; `y` still comes from rank (`assignRanks`, #689).
|
||||
|
||||
**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°).
|
||||
**structural owner** — for a couple, the spouse that keeps the bloodline (hierarchy) position: lower `birthYear`, then stable `id` (`pickStructuralOwner` in `familyForest.ts`). The other spouse is absorbed into the owner's run. Reused by the cross-link, cycle, and intra-family paths so the rule is defined once.
|
||||
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
|
||||
**loose spouse** — a person who marries into the graph with no `PARENT_OF` edges of their own. They are absorbed into their partner's unit run (no ancestor subtree), but any children of theirs still anchor through the couple unit.
|
||||
|
||||
**bloodline** — the set of people reachable from a root unit via structural-owner `PARENT_OF` edges; renders as one contiguous horizontal band with no foreign node interleaved (the contiguity invariant that fixed the smeared-bloodline bug, #724).
|
||||
|
||||
**cross-link** `[layout]` — a `PARENT_OF` edge whose child is positioned in a spouse's run elsewhere (a cross-level intra-family marriage). The connector draws it with a distinct `2 6` dash at reduced opacity — never the `4 4` ended-marriage cadence — with geometry still landing on the child (WCAG 1.4.1).
|
||||
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints have parents in the graph. The couple is always exactly adjacent in the owner's run; when the two spouses' parents sit at the same structural level the displaced parent edge stays solid (the adjacency case), otherwise it renders as a cross-link. The canonical fixture has two such marriages (Walter⚭Eugenie, Clara⚭Herbert), covered in `buildLayout.test.ts`.
|
||||
|
||||
**marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person.
|
||||
|
||||
@@ -139,9 +147,6 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
|
||||
**Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline.
|
||||
_See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Briefwechsel** `[user-facing]` — the bilateral conversation timeline between two `Person`s, derived from `Document` sender/receiver relationships. Accessible at `/briefwechsel`. Not a persistent entity — data is computed from existing `Document` records.
|
||||
_See also [Derived domain](#derived-domain)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
@@ -156,11 +161,12 @@ _See also [Derived domain](#derived-domain)._
|
||||
|
||||
**Cross-cutting** — code that lives in `lib/shared/` (frontend) or cross-domain packages (backend) because it has no entity of its own, no user-facing CRUD, AND is used by two or more domains OR is framework infrastructure (error handling, API client, i18n utilities).
|
||||
|
||||
**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. Current derived domains: `conversation` (from `Document` sender/receivers) and `activity` (from audit, notifications, document events).
|
||||
_See also [Briefwechsel](#briefwechsel-user-facing)._
|
||||
**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. The current derived domain is `activity` (from audit, notifications, document events).
|
||||
|
||||
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
||||
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Terms
|
||||
|
||||
@@ -35,7 +35,7 @@ Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (alrea
|
||||
|
||||
**Harder:**
|
||||
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on memory-constrained hosts. A busy backfill alongside OCR can approach the 3 GB heap on an 8 GB server — acceptable but not comfortable. The current production server (64 GB) has ample headroom. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
|
||||
### Operational caveats (intentional)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ADR 012 — Browser-Mode Test Mocking Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-11 (revised 2026-05-12)
|
||||
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553)
|
||||
**Date:** 2026-05-11 (revised 2026-05-12, 2026-06-02)
|
||||
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553) · [#560 — shared-mock-body dedup](https://git.raddatz.cloud/marcel/familienarchiv/issues/560)
|
||||
|
||||
---
|
||||
|
||||
@@ -71,19 +71,19 @@ The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for
|
||||
|
||||
`EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed.
|
||||
|
||||
**Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.**
|
||||
**Therefore: under `**/\*.svelte.{test,spec}.ts`, every `vi.mock`factory body must be synchronous. No`await`, no `import(...)`.\*\*
|
||||
|
||||
If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist:
|
||||
|
||||
```ts
|
||||
const { mockNavigating } = vi.hoisted(() => ({
|
||||
mockNavigating: { type: null as string | null }
|
||||
mockNavigating: { type: null as string | null },
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
get navigating() {
|
||||
return mockNavigating;
|
||||
}
|
||||
vi.mock("$app/state", () => ({
|
||||
get navigating() {
|
||||
return mockNavigating;
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
@@ -91,7 +91,7 @@ The getter defers the read until consumption time; `vi.hoisted` guarantees the r
|
||||
|
||||
### Architectural follow-on: prefer `$app/state` over `$app/stores`
|
||||
|
||||
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec.
|
||||
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the _need_ to mock a store at all in that spec.
|
||||
|
||||
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
|
||||
|
||||
@@ -112,9 +112,9 @@ This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vites
|
||||
**Enforcement layers** (added in #553's second cycle, extending the four-layer chain above):
|
||||
|
||||
5. **In-suite meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` globs `src/**/*.svelte.{test,spec}.ts`, extracts every `vi.mock` first-arg string, canonicalises by stripping a trailing `.js`/`.ts` after `.svelte`, and fails if any canonical ID is referenced under two or more distinct spellings. Same shape as `no-async-mock-factories.test.ts`.
|
||||
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.0.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
|
||||
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.6.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
|
||||
|
||||
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.0.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
|
||||
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.6.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +129,48 @@ This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vites
|
||||
3. **In-suite meta-test** at `frontend/src/__meta__/no-async-mock-factories.test.ts` globs `src/**/*.svelte.{test,spec}.ts` and asserts none match the banned pattern. Catches at every vitest invocation — the layer hardest to disable.
|
||||
4. **CI birpc assert** runs after the coverage step and fails the build if `[birpc] rpc is closed` appears in any log line. Catches the symptom even if all the upstream layers were bypassed.
|
||||
5. **In-suite duplicate-ID meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` enforces the one-canonical-ID-per-module rule from the duplicate-id-hazard section above.
|
||||
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.0.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
|
||||
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.6.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
|
||||
- **Acceptance verification:** `coverage-flake-probe.yml` is a `workflow_dispatch`-triggered matrix workflow that runs the coverage suite 20× in parallel against a single SHA and asserts zero birpc lines. One fire, parallel cost, deterministic signal — replaces accumulating 20 sequential push events.
|
||||
- **When to revisit the LibLoader home:** If three or more components adopt this pattern, consider extracting a shared `$lib/types/lib-loader.ts` or a generic `DynamicImportLoader<T>` type to avoid parallel type definitions across modules.
|
||||
|
||||
---
|
||||
|
||||
## Revision 2026-06-02 (#560 — shared mock bodies, no-factory ban)
|
||||
|
||||
### No-factory `vi.mock` of a virtual module is forbidden
|
||||
|
||||
PR #657 attempted to delete `vi.mock` factories entirely and rely on Vitest auto-resolving a bare `vi.mock('$app/navigation')` to an adjacent `src/__mocks__/$app/navigation.ts`, the way Jest's `__mocks__/` directory works. **This is empirically false for SvelteKit virtual modules in browser-mode Vitest.** A no-factory `vi.mock(virtualModule)` substitutes _some_ exports (plain function references like `goto`) but leaves others bound to the live implementation — notably `replaceState`, which SvelteKit re-exports through a getter delegating to the live router. CI #1857 failed on `admin/tags/[id]` with `Cannot call replaceState(...) before router is initialized`, raised from a `$effect`. A partial auto-mock is therefore unsafe.
|
||||
|
||||
**Rule:** under `**/*.svelte.{spec,test}.ts`, a `vi.mock` of a virtual module must always pass a factory. The factory body must still be synchronous (the original binding invariant above). Enforced by a seventh layer:
|
||||
|
||||
7. **In-suite no-factory-ban meta-test** at `frontend/src/__meta__/no-factory-ban.test.ts` — same source-scan mechanism as the other meta-tests; fails if any browser spec contains a `vi.mock('mod')` with no second argument.
|
||||
|
||||
### Cross-file sharing of a virtual-module mock body is infeasible (the third false premise)
|
||||
|
||||
The original #560 plan ("Option A") proposed deduplicating the non-trivial interceptor factories by importing a shared body from `src/__mocks__/` into a sync factory:
|
||||
|
||||
```ts
|
||||
import * as formsMock from "$mocks/$app/forms";
|
||||
vi.mock("$app/forms", () => ({ ...formsMock }));
|
||||
```
|
||||
|
||||
**CI proved this does not work in `@vitest/browser-playwright` 4.1.6**, across two runs:
|
||||
|
||||
1. The static-import form above fails at runtime — vitest hoists `vi.mock` _above_ the import, so the factory references an uninitialised binding: `vi.mock factory: make sure there are no top level variables inside, since this call is hoisted`.
|
||||
2. The documented escape, loading the body through an async hoisted import, fails to even parse in browser mode — vitest's hoist transform mangles it: `SyntaxError: Unexpected identifier 'vi'`.
|
||||
|
||||
```ts
|
||||
const formsMock = await vi.hoisted(() => import("$mocks/$app/forms")); // parse error in browser mode
|
||||
```
|
||||
|
||||
`vi.hoisted` has the _same_ constraint as `vi.mock` (its factory can't reference top-level imports either, since it too is hoisted above them), so there is no way to get an external module's body into the hoisted context here. **Therefore: do not share virtual-module mock bodies across spec files. Define each `vi.mock` factory inline, with a synchronous body.** Duplicating the handful of interceptor factories is the accepted cost — it is the only pattern that works. The `src/__mocks__/$app/*` modules and the `$mocks` alias added for Option A were removed. (Revisit on a newer `@vitest/browser-playwright` whose hoist transform handles async `vi.hoisted` imports.)
|
||||
|
||||
The no-factory-ban above still stands: every `vi.mock` of a virtual module must pass an _inline_ sync factory — never no factory, never a spread of an imported binding.
|
||||
|
||||
### Rejected: Option C (config-level auto-resolve)
|
||||
|
||||
Re-enabling implicit `__mocks__/` auto-resolution through a Vitest config flag or a `setupFiles` shim was rejected. It trades auditability for cosmetics: the mock binding becomes a hidden default invisible at the call site, and its failure mode (a partial mock) is the hardest to debug — exactly the PR #657 class. The no-factory-ban meta-test deliberately keeps the door closed.
|
||||
|
||||
### Patch pin
|
||||
|
||||
`@vitest/browser-playwright` is exact-pinned (no caret) to `4.1.6` in `package.json` so `patches/@vitest+browser-playwright+4.1.6.patch` keeps applying; a caret range could float onto a version the patch rejects. Pin and patch are both removed once the library ships a release containing [PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
|
||||
|
||||
@@ -62,7 +62,7 @@ The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extractio
|
||||
## Alternatives considered
|
||||
|
||||
**Approach B — Enlarge `/tmp` to 4 GB**
|
||||
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on servers with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||
|
||||
**Approach C — Both TMPDIR redirect and enlarged /tmp**
|
||||
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred
|
||||
|
||||
**Date:** 2026-05-28
|
||||
**Status:** Accepted
|
||||
**Status:** Accepted — superseded in part by [ADR-030](./030-stammbaum-bloodline-tidy-tree-layout.md)
|
||||
**Issue:** #361
|
||||
**Supersedes:** _none_
|
||||
**Supersedes-on-trigger:** A future ADR-027 if any acceptance criterion below stops converging in-house.
|
||||
**Superseded-by:** ADR-030 (#724) replaces the **per-generation block packer** below with
|
||||
a bottom-up tidy-tree after its position-within-rank model stranded ancestors and smeared
|
||||
bloodlines across the canvas — the UX stop-trigger named in this ADR. The **in-house /
|
||||
no-dagre** decision and the seeded-rank invariant (#689) are retained.
|
||||
**Supersedes-on-trigger:** _(triggered)_ The UX stop-trigger fired; see ADR-030.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,6 +121,7 @@ threshold, so `packBlocks.ts` is **not** yet warranted.
|
||||
is the source-of-truth probe against live data; the function is the
|
||||
capture-time and fixture-time signal that the predicate's count crossed
|
||||
zero.
|
||||
|
||||
- **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under
|
||||
this ADR; reactivates only under ADR-027 (dagre adoption).
|
||||
- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()`
|
||||
|
||||
60
docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md
Normal file
60
docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# ADR-028 — pdf.js wasm decoders are served same-origin; a future CSP must allow them
|
||||
|
||||
**Date:** 2026-06-01
|
||||
**Status:** Accepted
|
||||
**Issue:** #708 (scanned PDFs with CCITT/JBIG2 images render blank)
|
||||
**Milestone:** Pre-prod read-path hardening
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
pdf.js 5.x moved the **JBIG2, CCITTFax, and JPEG2000 image decoders into
|
||||
WebAssembly**. A single `jbig2.wasm` module decodes both JBIG2 and CCITTFax;
|
||||
`openjpeg.wasm` decodes JPEG2000. These modules live in
|
||||
`node_modules/pdfjs-dist/wasm/` and are not on the web path by default, and
|
||||
`getDocument` will not load them unless it is given a `wasmUrl`. Without that,
|
||||
bi-level black-and-white scans (CCITT G4 fax — ~16% of the archive) painted a
|
||||
blank canvas in production while JPEG scans rendered fine.
|
||||
|
||||
Two cross-cutting, long-lived constraints fall out of the fix and are not
|
||||
obvious from reading any single file — hence this record.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Serve the pdf.js wasm from our own origin**, at the unversioned path
|
||||
`/pdfjs-wasm/`, copied from `node_modules/pdfjs-dist/wasm/` into
|
||||
`build/client/` at build time by `vite-plugin-static-copy` (a devDependency;
|
||||
see `frontend/vite.config.ts`). `getDocument` is called with
|
||||
`wasmUrl: '/pdfjs-wasm/'`. **Never point `wasmUrl` at a public CDN** — a
|
||||
decoder on the core read path must not become a supply-chain RCE surface.
|
||||
|
||||
2. **Any future `Content-Security-Policy` MUST include
|
||||
`script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:`.** pdf.js
|
||||
instantiates WebAssembly and runs its renderer in a worker created from a
|
||||
`blob:` URL. A CSP without these directives silently re-breaks PDF rendering
|
||||
for the exact class of documents #708 fixed. No CSP is set today
|
||||
(`infra/caddy/Caddyfile` `(security_headers)`); the Caddyfile carries a
|
||||
pointer to this ADR so the future CSP author cannot miss it.
|
||||
|
||||
3. **The wasm shipping is guarded at build time.** `frontend/postbuild`
|
||||
(`scripts/assert-pdfjs-wasm.mjs`) fails the build loudly if `jbig2.wasm` or
|
||||
`openjpeg.wasm` is absent from `build/client/pdfjs-wasm/` — so a future
|
||||
`pdfjs-dist` bump that renames or relocates the wasm cannot regress to a
|
||||
blank canvas unnoticed. This runs in CI and in the Docker build stage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The decoders load from the same origin as the app — no third-party trust, no
|
||||
SRI to manage, correct `Content-Type: application/wasm` served by
|
||||
adapter-node.
|
||||
- `/pdfjs-wasm/` is **not** content-hashed, so it must not be served
|
||||
`immutable` — a revalidating cache avoids serving a stale `.wasm` against a
|
||||
newer worker after a pdfjs upgrade.
|
||||
- The CSP constraint is a standing obligation on whoever introduces a CSP. If
|
||||
that work happens, this ADR and the Caddyfile note are the source of truth.
|
||||
- No new container or external system is introduced, so the C4 L1/L2 diagrams
|
||||
are unaffected; `/pdfjs-wasm/` is a static asset served by the existing
|
||||
frontend container.
|
||||
- Render/decode failures are no longer silent: the viewer surfaces a localized
|
||||
message plus a working download link (see #708).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-029: Composite actions for cross-workflow deploy logic
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The `nightly.yml` (staging) and `release.yml` (production) workflows shared three
|
||||
blocks of deploy logic verbatim: the four observability-stack steps (deploy configs,
|
||||
validate, start, assert health), the Caddy reload step, and the public-surface smoke
|
||||
test. The only per-environment differences were secret names (`STAGING_*` vs `PROD_*`),
|
||||
the `POSTGRES_HOST` value, and the smoke-test hostname.
|
||||
|
||||
This duplication was held together by `# Keep in sync with nightly.yml` comments — an
|
||||
honour-system invariant. Any change (a new healthchecked service, a different rsync flag,
|
||||
a new secret) had to be applied in two places, and nothing enforced that it was. Issue #603
|
||||
documents a real instance: the obs secret set had grown to five keys while a refactor draft
|
||||
listed only four.
|
||||
|
||||
### Decision drivers
|
||||
|
||||
1. Cross-workflow deploy logic must have a single definition, enforced — not a
|
||||
discipline-based "keep in sync" promise.
|
||||
2. Per-environment variation must be expressed as explicit, typed inputs, not by forking
|
||||
the whole step block.
|
||||
3. The mechanism must work on the existing single-tenant self-hosted Gitea runner with no
|
||||
new infrastructure.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
**A: Reusable workflow (`workflow_call`)** — Gitea supports called workflows. Rejected for
|
||||
this case: reusable workflows run as a separate job with their own runner context, which
|
||||
breaks the in-job, sequential `deploy → reload → smoke` ordering these steps rely on and
|
||||
complicates passing the already-checked-out workspace. Composite actions run inline in the
|
||||
calling job, preserving step order and the workspace.
|
||||
|
||||
**B: Shared shell script invoked from both workflows** — e.g. `scripts/deploy-obs.sh`.
|
||||
Rejected: loses the typed-input contract and per-step CI log sections, and reintroduces
|
||||
manual argument threading that is as error-prone as the duplication it replaces.
|
||||
|
||||
**C: Keep the `# Keep in sync` comments** — status quo. Rejected: unenforced; issue #603
|
||||
is direct evidence it fails.
|
||||
|
||||
## Decision
|
||||
|
||||
Extract the shared logic into three single-responsibility Gitea composite actions under
|
||||
`.gitea/actions/`: `deploy-obs` (five inputs), `reload-caddy` (no inputs), and `smoke-test`
|
||||
(`host` input). Both workflows invoke each via a single `uses: ./.gitea/actions/<name>` call,
|
||||
passing per-environment values as `with:` inputs. This is the repository's first composite
|
||||
action and sets the convention; `docs/infrastructure/ci-gitea.md` documents it.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Shared deploy logic has one enforced definition; a change lands once and both
|
||||
environments get it. The `# Keep in sync` comments are deleted.
|
||||
- Per-environment variation is a typed input contract, not a forked block.
|
||||
- Runs inline on the existing runner — no reusable-workflow job context, no new
|
||||
infrastructure.
|
||||
|
||||
**Negative / constraints:**
|
||||
- Workflows now depend on a checked-out `.gitea/actions/` tree: `actions/checkout` MUST run
|
||||
before the first `uses: ./…` (a local action does not exist on disk until checkout).
|
||||
- Secrets cannot be read from the `secrets.*` context inside a composite action; they must
|
||||
be passed as inputs and mapped to `env:`. The `obs-secrets.env` heredoc therefore uses an
|
||||
unquoted delimiter so `$VAR` expands at the shell layer.
|
||||
- The `reload-caddy` pinned alpine digest now lives in the action, not the workflow file —
|
||||
it must be added to Renovate's watch list so it does not go stale.
|
||||
54
docs/adr/030-briefwechsel-removal-unidirectional-search.md
Normal file
54
docs/adr/030-briefwechsel-removal-unidirectional-search.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ADR-030 — Removing Briefwechsel trades bidirectional correspondence for a unidirectional search filter
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Accepted
|
||||
**Issue:** #716 (remove the Briefwechsel view; retarget its links to document search)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The standalone **Briefwechsel** view (`/briefwechsel`) was a bilateral letter timeline
|
||||
between two `Person`s. It was not in the main navigation; its only inbound product link
|
||||
was the "Häufige Korrespondenten" card on a person's detail page. It was backed by a
|
||||
dedicated endpoint (`GET /api/documents/conversation`) and two repository queries
|
||||
(`findConversation`, `findSinglePersonCorrespondence`) that nothing else used.
|
||||
|
||||
The view's data source, `findConversation`, was **bidirectional**: it returned letters
|
||||
where `(sender = A AND receiver = B) OR (sender = B AND receiver = A)` — i.e. the
|
||||
exchange in both directions. We removed the view entirely (frontend and backend) and
|
||||
retargeted the one inbound link into the existing **document search**
|
||||
(`/documents?senderId=A&receiverId=B`).
|
||||
|
||||
Document search composes its `senderId`/`receiverId` filters with AND
|
||||
(`sender.id = A` **AND** `receivers contains B`), so the retargeted link shows **only the
|
||||
A→B direction**. The reverse direction (B's replies to A) is no longer surfaced by
|
||||
clicking a correspondent.
|
||||
|
||||
## Decision
|
||||
|
||||
**Accept the behaviour change: the retargeted card link is unidirectional (A→B only).**
|
||||
The reverse direction is intentionally dropped rather than preserved with a redirect
|
||||
shim or a new bidirectional search filter.
|
||||
|
||||
- The card link sets both params (`senderId=A&receiverId=B`); the destination is the
|
||||
consistent, already-tested document search rather than a separate dedicated view.
|
||||
- The "×N" badge on each correspondent chip remains **bilateral** — it counts shared
|
||||
letters in both directions as a relationship-strength signal — so the badge may exceed
|
||||
the unidirectional search result count. This is surfaced in the badge's title, not
|
||||
recomputed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Regression:** a reader can no longer reach B→A replies in one click from the
|
||||
correspondents card. They must run a second search with sender/receiver swapped.
|
||||
- The bilateral query code (`findConversation`, `findSinglePersonCorrespondence`, the
|
||||
`/api/documents/conversation` endpoint, and the `getConversationFiltered` service
|
||||
method) is fully removed — no dormant dead code.
|
||||
- No data migration and no schema change: only query/endpoint code was removed; the
|
||||
`documents`, `persons` and join tables are untouched. The `hasSender`/`hasReceiver`
|
||||
specifications stay — document search still uses them.
|
||||
- **Future enhancement (out of scope here):** a bidirectional "between these two people,
|
||||
either direction" document-search filter would restore the dropped direction without
|
||||
reviving the standalone view. If built, it supersedes the unidirectional link.
|
||||
110
docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md
Normal file
110
docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# ADR-030 — Stammbaum Bloodline-Contiguous Tidy-Tree Layout
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Accepted
|
||||
**Issue:** #724
|
||||
**Supersedes:** ADR-026 (in part — the per-generation block-packer decision and its
|
||||
position-within-rank fix path; the in-house / no-dagre decision and the seeded-rank
|
||||
invariant from #689 are retained)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-026 kept Stammbaum horizontal placement in-house with a **per-generation block
|
||||
packer** and pre-committed a successor ADR "if any acceptance criterion stops
|
||||
converging in-house." Its single UX stop-trigger was Albert de Gruyter's marriages
|
||||
failing the read test.
|
||||
|
||||
The block packer hit a worse, structural failure: it placed each generation
|
||||
**independently**, centring sibling blocks under already-placed parents and only ever
|
||||
**shoving right** on collision. Two consequences followed — a deep branch that could
|
||||
not fit at its ideal centre dragged everything downstream rightward and stranded the
|
||||
ancestor at the **left edge** of its own descendants; and a parent placed before its
|
||||
descendants existed could never be re-centred over them. Extreme symptom: Albert de
|
||||
Gruyter (G0) far left, a great-great-grandchild far right — one bloodline smeared
|
||||
across the full canvas. That is exactly the "UX failure against the canonical fixture"
|
||||
ADR-026 named as the trigger to revisit the layout.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace the per-generation block packer with a bottom-up "tidy tree"
|
||||
(Reingold–Tilford / Walker contour pack), still in-house, no new dependency.**
|
||||
|
||||
The horizontal `x` rewrite is split into three reviewable, unit-tested modules
|
||||
(mirroring the `panZoom.ts` / `panZoomGestures.ts` / `animateView.ts` split):
|
||||
|
||||
- **`layout/tidyTree.ts`** — domain-agnostic contour packer over abstract
|
||||
`{ id, width, children, level? }` nodes, zero generated-API imports. Contours are
|
||||
indexed by **absolute generation level**, not tree depth, so unrelated roots at
|
||||
different generations share x-columns instead of smearing the forest wide.
|
||||
- **`layout/familyForest.ts`** — all genealogy semantics: the **unit** model (a
|
||||
bloodline-carrying primary plus the spouse(s) absorbed into its run),
|
||||
`pickStructuralOwner` (lower birthYear, then stable id), loose-spouse absorption,
|
||||
multi-spouse runs (#361), sibling/branch order (birthYear ASC NULLS LAST →
|
||||
displayName → id), intra-family resolution, and cross-link classification.
|
||||
- **`layout/buildLayout.ts`** — orchestrates forest → tidy-pack → per-person
|
||||
positions. `assignRanks` (y from rank, #689 seeding), the `generations` map, and
|
||||
`computeViewBox` are reused **unchanged**; `x` comes from structure, `y` from rank.
|
||||
|
||||
Two decisions taken during implementation and confirmed with the maintainer:
|
||||
|
||||
1. **Intra-family marriage = hybrid.** A couple is always exactly adjacent in the
|
||||
owner's run. When the two spouses' parents sit at the **same structural level** the
|
||||
displaced parent edge renders as a normal solid connector (the "adjacency" case);
|
||||
when they are **cross-level** (e.g. the canonical Clara⚭Herbert, where one parent
|
||||
is nested under Albert and the other hangs off a separate root), the structural
|
||||
owner keeps the hierarchy edge and the other parent→spouse edge renders as a
|
||||
distinct cross-link.
|
||||
2. **Cross-link is rendered with a distinct `2 6` dash at 0.7 opacity** in
|
||||
`StammbaumConnectors.svelte` — never the `4 4` ended-marriage cadence. Geometry
|
||||
still lands on the correct child top, so meaning is carried redundantly (WCAG
|
||||
1.4.1); the 0.7 opacity clears the WCAG 1.4.11 3:1 non-text floor.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Accepted
|
||||
|
||||
- **Ancestor centring** — every unit is centred over its child-units' span (named-bug
|
||||
guard `great_great_grandparent_is_not_stranded_left_of_descendants` + a fixture-wide
|
||||
loop over canonical and synthetic trees).
|
||||
- **Bloodline contiguity** — each bloodline is one band with no foreign node
|
||||
interleaved. Albert de Gruyter's bloodline shrank from a full ~4860px smear to
|
||||
~960px.
|
||||
- **#361 / #689 preserved** — multi-spouse runs in marriage-year order, seeded ranks,
|
||||
spouse pull-down; the existing `buildLayout.test.ts` cases stay green.
|
||||
- **Determinism** — every comparator ends in a stable id; a seeded permutation of
|
||||
nodes/edges yields byte-identical positions.
|
||||
- **Fail-closed on cycles** — `assignRanks`' iteration ceiling plus a forest structure
|
||||
(each unit has ≤1 hierarchy parent, cycles are unreachable from roots) guarantee a
|
||||
finite layout with every node placed exactly once.
|
||||
|
||||
### Trade-off — total canvas width replaces the ADR-026 width assumption
|
||||
|
||||
Centring every ancestor inherently makes a forest of ~24 root-bands **wider** overall
|
||||
than the old per-generation left-packer that interleaved everyone into compact shared
|
||||
rows (canonical: ~7960px vs the old ~4860px). Total canvas width is therefore the
|
||||
wrong success metric; **per-bloodline span** is. The width regression test asserts
|
||||
each contiguous bloodline stays far under the old full-canvas smear. The wider canvas
|
||||
is navigated by the pan/zoom from #692 (ADR-027) and is an accepted trade-off for
|
||||
readability.
|
||||
|
||||
### Operational
|
||||
|
||||
- **No CI, image, compose, or dependency change.** Pure frontend layout. The
|
||||
`d3-flextree` escape hatch from #724 was not needed.
|
||||
|
||||
### Deferred (follow-ups, per #724)
|
||||
|
||||
- Connector legibility at 320px — the issue's "open verification"; a manual
|
||||
`/stammbaum` pass, with a connector-clarity issue spun only if drops/cross-links
|
||||
tangle.
|
||||
- Polished cross-link routing + a relationship tooltip.
|
||||
|
||||
## Notes
|
||||
|
||||
- ADR-026's retained parts: the **no-dagre / in-house** decision and the seeded-rank
|
||||
invariant (#689) still hold — this ADR changes only _position-within / across_ rank,
|
||||
not rank assignment, and adds no dependency.
|
||||
- The `validateFixture` sanity gates and the AC3 revisit probe from ADR-026 are
|
||||
unchanged.
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR-031 — The document title is a shared `document`-package factory, re-synced by an exact match on save and a grammar heuristic on a one-time backfill
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Accepted
|
||||
**Issue:** #726 (auto-sync document titles with date/location: save-time + one-time backfill)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
A document title was a string built **once**, at import time, by a private
|
||||
`DocumentImporter.buildTitle()` composing `{index} – {dateLabel} – {location}` (index =
|
||||
`originalFilename`, date label honest at the row's precision via `DocumentTitleFormatter`,
|
||||
location verbatim). Nothing rebuilt it afterwards. When an archivist later corrected a date
|
||||
or location in the edit form, the title kept its stale value (e.g. it still read `2028`
|
||||
after the date was fixed to `1928`), because the edit form round-trips the stored title
|
||||
verbatim and `updateDocument` simply re-persisted it.
|
||||
|
||||
Two distinct problems live here:
|
||||
|
||||
1. **Going forward**, an edit to date/location must flow into a title that was machine-built
|
||||
— but must never overwrite a title a human wrote.
|
||||
2. **The existing backlog** of already-stale titles must be cleaned once. For these rows the
|
||||
pre-edit state is gone, so there is no exact value to compare against.
|
||||
|
||||
The composition formula also existed only inside `importing`, which is the wrong owner: a
|
||||
title is a `document` concern, and three call sites (import, save-time, backfill) must share
|
||||
one rule or they will drift.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One formula, owned by the `document` package (`DocumentTitleFactory`)
|
||||
|
||||
Extract the composition into `DocumentTitleFactory` (a `@Component` in the `document`
|
||||
package) with `build(Document)`. `DocumentImporter` (package `importing`) now consumes it.
|
||||
`DocumentTitleFormatter` moves into `document` alongside the factory (it stays
|
||||
package-private; `importing` reaches the formula only through the factory). The direction is
|
||||
deliberate: `document` owns the rule, `importing` depends on it — not the reverse. The
|
||||
German date *label* remains the deliberate Java/TS dual implementation pinned by
|
||||
`docs/date-label-fixtures.json` (#666); this ADR touches the **composition** only and does
|
||||
not collapse the frontend `formatDocumentDate`.
|
||||
|
||||
### 2. Save-time regeneration is an EXACT match, not a heuristic
|
||||
|
||||
In `DocumentService.updateDocument` only (bulk edit is out of scope), capture
|
||||
`autoTitleBefore = titleFactory.build(doc)` from the **currently-persisted** state *before*
|
||||
any setter runs. Then:
|
||||
|
||||
- if the **submitted** title equals `autoTitleBefore`, it was the machine value → rebuild
|
||||
from the new state;
|
||||
- otherwise keep the submitted title verbatim (hand-written or freshly typed).
|
||||
|
||||
This is an exact old-vs-new comparison — no false positives, no false negatives — relying on
|
||||
the edit form round-tripping an untouched title verbatim. `projectedState` mirrors the
|
||||
existing setter asymmetry exactly: `documentDate`/`location` overwrite unconditionally (a
|
||||
null clears them), while precision/end/raw are taken from the DTO only when non-null and
|
||||
otherwise kept from the entity. A blank submission is never persisted (the title is always
|
||||
present) — it falls back to the rebuilt auto-title, which always carries at least the index.
|
||||
|
||||
### 3. The one-time backlog cleanup is a grammar heuristic, behind an ADMIN endpoint
|
||||
|
||||
`POST /api/admin/backfill-titles` (synchronous, under `AdminController`'s class-level
|
||||
`@RequirePermission(Permission.ADMIN)`) sweeps every document and, for each whose stored
|
||||
title passes the overwrite test, rebuilds it via the factory. Because the pre-edit state is
|
||||
gone, the test (`DocumentTitleBackfillMatcher`, used **only** here) is a grammar heuristic:
|
||||
after stripping the **literal** index prefix, the remainder must be exactly the index, a
|
||||
known date-label form (+ an optional trailing location), or a lone segment equal to the
|
||||
document's current location. Prose is left untouched; anything malformed fails closed.
|
||||
|
||||
The backfill saves via `documentRepository.save` directly and **never** routes through
|
||||
`updateDocument` — following the `backfillFileHashes` precedent — so a mechanical rename does
|
||||
not snapshot the whole corpus into `document_versions`. It is idempotent (a second run
|
||||
rewrites nothing) and logs one SLF4J-parameterized `scanned/updated/skipped` line; the
|
||||
response is `BackfillResult(count)`.
|
||||
|
||||
### 4. Edit-form feedback (FR-005)
|
||||
|
||||
A localized helper line (de/en/es) under the title input explains that the title is built
|
||||
from date/place and that a hand-edit is preserved, wired via `aria-describedby` and shown
|
||||
only on the single-document edit form. A live preview was considered and declined.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The three call sites can never diverge — there is exactly one formula
|
||||
(`NFR-MAINT-001`). Save-time cost is a string build + compare; the backfill is one
|
||||
synchronous transactional sweep over a low-thousands corpus.
|
||||
- Security: the index is compared **literally** (`String.startsWith` / `Pattern.quote`)
|
||||
because `originalFilename` is user-controlled and may carry regex metacharacters — an
|
||||
unquoted pattern would be a ReDoS / regex-injection vector (CWE-1333 / CWE-625). The
|
||||
date-label sub-patterns use only bounded, non-nested quantifiers.
|
||||
- **File-replaced documents are treated as manual, by design.** The index is
|
||||
`originalFilename`, which `updateDocument` reassigns to the uploaded file's name on a
|
||||
file-replace. After a replace the stored title no longer matches `build(currentState)`, so
|
||||
neither save-time nor backfill rewrites it. This is the accepted fail-safe of overloading
|
||||
`originalFilename` rather than adding a dedicated `catalogIndex` column.
|
||||
- The save-time heuristic risk is zero (exact match); the backfill heuristic can, by its
|
||||
documented FR-004 rule, treat `{index} – {valid date label} – {anything}` as machine-built
|
||||
and rewrite the trailing segment. This is the accepted trade for cleaning the backlog
|
||||
without the lost pre-edit state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A dedicated `catalogIndex` column** instead of overloading `originalFilename` — rejected;
|
||||
it adds a migration and a second source of truth for the index for no current benefit, and
|
||||
the file-replace fail-safe is acceptable.
|
||||
- **A heuristic at save-time too** (instead of the exact match) — rejected; the stored title
|
||||
is available pre-edit, so an exact comparison is strictly better (no false positives).
|
||||
- **A live title preview in the edit form** — rejected (FR-005); a static helper line is
|
||||
calmer for the 60+ audience and avoids a second client-side mirror of the formula.
|
||||
- **Collapsing the frontend `formatDocumentDate` into the backend** — out of scope; the
|
||||
Java/TS date-label split is the deliberate #666 design, pinned by a shared fixture.
|
||||
64
docs/adr/032-person-delete-db-level-integrity.md
Normal file
64
docs/adr/032-person-delete-db-level-integrity.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# ADR-032 — Person-delete referential integrity lives in the database, and the cascade never reaches `documents`
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Issue:** #684 (move person-delete FK detach to database-level `ON DELETE`)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Deleting a `Person` had to detach the two FKs into `persons` that lacked any `ON DELETE`
|
||||
behaviour: `documents.sender_id` and `document_receivers.person_id` (both from V1).
|
||||
`PersonService.deletePerson` and `mergePersons` did this in Java — nulling the sender and
|
||||
deleting receiver join rows before `deleteById` — so the integrity guarantee lived in
|
||||
application code. Any other delete path (a future endpoint, a manual `psql`, a batch job)
|
||||
could still orphan rows or fail with an FK-violation 500.
|
||||
|
||||
A related soft reference made it worse: `transcription_block_mentioned_persons.person_id`
|
||||
was a UUID column with **no FK** (V56, a deliberate "no FK" choice), so a person delete left
|
||||
dangling `@`-mention rows. The literal `@DisplayName` lives in `transcription_blocks.text`,
|
||||
so only the *link* was ever at stake — not the visible name.
|
||||
|
||||
## Decision
|
||||
|
||||
Move person-delete integrity into the database (migration V71) and thin the service to a
|
||||
plain `deleteById`:
|
||||
|
||||
- `documents.sender_id` → `ON DELETE SET NULL` (`documents.senderText` preserves the raw
|
||||
textual attribution, so nulling the link loses no historical record).
|
||||
- `document_receivers.person_id` → `ON DELETE CASCADE` (the symmetric completion of V14,
|
||||
which gave the `document_id` side the same).
|
||||
- `transcription_block_mentioned_persons.person_id` → a real FK with `ON DELETE CASCADE`,
|
||||
reversing V56's "no FK" decision. The read renderer already degrades a `@DisplayName` with
|
||||
no sidecar row to plain escaped text, so removing the link is invisible to the reader.
|
||||
|
||||
**Cascade-boundary invariant:** the cascade stays strictly at the join/reference layer and
|
||||
**never reaches `documents` rows** — a cascade into `documents` would destroy historical
|
||||
letters. This is pinned by a non-negotiable document-survival assertion in
|
||||
`PersonRepositoryTest`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A person delete is safe from every path, not just `PersonService`. The service and merge
|
||||
stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences`
|
||||
are deleted.
|
||||
- This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely
|
||||
invisible refactor. Note the read renderer strips the `@` prefix when it emits a live
|
||||
mention link, but the degraded (deleted-person) path leaves the literal `@Name` in the
|
||||
block text as-is — the reader sees `@Auguste Raddatz` as plain text, never a dead link.
|
||||
- DB cascades run below `AuditService`, so the row-level cleanup is intentionally not
|
||||
audit-logged; the person-delete action itself is still logged at the service layer.
|
||||
- The V71 FK validation requires cleaning pre-existing orphan mention rows first; the
|
||||
migration does this in a `DO` block that logs the purge count via `RAISE NOTICE`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep integrity in Java** — rejected; it only protects the one code path and re-breaks the
|
||||
moment a second delete path appears.
|
||||
- **Cascade `documents.sender_id`** — rejected; it would delete historical letters when a
|
||||
sender is removed. `SET NULL` keeps the letter and its `senderText`.
|
||||
- **Leave the mention sidecar FK-less (honour V56)** — rejected; the "no FK" rationale was
|
||||
stale, the name survives in the block text regardless, and the FK removes the orphan-row
|
||||
class of bug.
|
||||
148
docs/adr/033-tag-name-resolution-tolerates-case-collisions.md
Normal file
148
docs/adr/033-tag-name-resolution-tolerates-case-collisions.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# ADR-033 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Issue:** #730 (document with a case-colliding tag cannot be saved — `findByNameIgnoreCase` `NonUniqueResultException`)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`TagService.findOrCreate(name)` is the single point that turns a tag **name** into a `Tag`
|
||||
row. The document edit form, bulk-edit, and the upload batch all round-trip tag **names**
|
||||
(the edit form sends `tags.map(t => t.name).join(',')`) and re-resolve them on **every**
|
||||
save through `resolveTags → findOrCreate`. The old implementation resolved with
|
||||
`tagRepository.findByNameIgnoreCase(name)`, a derived query returning `Optional<Tag>`.
|
||||
|
||||
That signature encodes an invariant the data does **not** hold: that a name is globally
|
||||
unique case-insensitively. The canonical tag tree legitimately contains names that differ
|
||||
only by case — a parent container and its same-named lowercase **child** (`Geburt` /
|
||||
`Geburt/geburt`, `Weihnachten` / `Weihnachten/weihnachten`, …), or two siblings
|
||||
(`Reise/Reisepläne` / `Reise/reisepläne`). Each is a distinct node with its own
|
||||
`source_ref` (the stable identity, per ADR-025) and its own document attachments — **not** an
|
||||
accidental duplicate. When two rows matched case-insensitively, Hibernate threw
|
||||
`NonUniqueResultException` → `IncorrectResultSizeDataAccessException` → a generic HTTP 500.
|
||||
|
||||
The effect was severe and opaque: every document carrying one of ~10 colliding tags (≈180
|
||||
document-tag attachments on staging) became **un-editable** — any field change failed on save
|
||||
because the whole tag set is re-resolved — and the user saw only "an unexpected error", with
|
||||
no hint that a tag was the cause.
|
||||
|
||||
This is a **lookup** problem, not a data problem: the collisions are valid canonical nodes
|
||||
and must be preserved.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Resolution is exact-case first, then a non-throwing deterministic fallback
|
||||
|
||||
`findOrCreate` resolves in three ordered steps and never throws on a collision:
|
||||
|
||||
1. `findByName(cleanName)` — **exact-case** derived query. If present, return it. The edit
|
||||
round-trip replays the stored name verbatim, so the exact-case row is the right binding
|
||||
(typing the bare child name `weihnachten` binds to the child; `Weihnachten` binds to the
|
||||
parent container).
|
||||
2. else `findAllByNameIgnoreCase(cleanName)` — the **plural** case-insensitive list. If
|
||||
non-empty, return the element with the **lowest `id`** (`min(comparing(Tag::getId))`).
|
||||
3. else create the tag (an orphan: null `sourceRef`/`parentId`).
|
||||
|
||||
The two repository methods are deliberately **two distinctly-named methods** — Spring Data
|
||||
cannot disambiguate an `Optional<Tag>` from a `List<Tag>` derived query by return type alone.
|
||||
The throwing `Optional<Tag> findByNameIgnoreCase` is **deleted** so the non-unique-throwing
|
||||
call cannot be reintroduced; `findOrCreate` was its only production caller.
|
||||
|
||||
### 2. The tie-break is `id`, and it is load-bearing
|
||||
|
||||
`id` is a stable, always-present, unique column, so "lowest id" is a total, deterministic
|
||||
order over the candidates: the same name resolves to the same row on every call, forever,
|
||||
without throwing. This matters only in the free-text authoring path (step 2), where no
|
||||
exact-case row exists yet two case-folding rows do.
|
||||
|
||||
### 3. No `unique(lower(name))` constraint — and a load-bearing comment says so
|
||||
|
||||
A global case-insensitive uniqueness constraint is **wrong**: it would reject the legitimate
|
||||
parent/child canonical nodes. It would also **fail to apply** against the existing rows,
|
||||
turning a code-only deploy into a failed Flyway migration that blocks startup. A comment at
|
||||
both `findOrCreate` and the repository methods records this so the constraint is not "helpfully"
|
||||
added later.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Code-only, zero migration, fully reversible** (roll back the JAR). No tag data is touched;
|
||||
the colliding rows stay exactly as the canonical importer produced them.
|
||||
- One change fixes all three write paths — single-document edit, bulk-edit, and upload batch —
|
||||
because they all funnel through `resolveTags → findOrCreate`, which stays the single source
|
||||
of truth (resolution logic is **not** hoisted into `DocumentService`).
|
||||
- **Free-text tag semantics under collision are accepted as-is** (issue #730, option A): the
|
||||
bare word `weihnachten` binds to the deep child and `Weihnachten` to the parent container.
|
||||
Correct for the edit round-trip and acceptable for authoring; making the typeahead show the
|
||||
tree path so an author can tell a container from its same-named child is a separate
|
||||
follow-up.
|
||||
- The wire response stays opaque: after the fix this path no longer throws
|
||||
`IncorrectResultSizeDataAccessException`, and `GlobalExceptionHandler`'s generic handler maps
|
||||
any stray one to `INTERNAL_ERROR` with no Hibernate/SQL leak — so no dedicated handler was
|
||||
added.
|
||||
- **The sibling Person path is fixed the same way — see the Person extension below (#731).**
|
||||
- Postgres `LOWER()` folding of umlauts (`ü`/`ä`) is the actual correctness hinge of the
|
||||
fallback and cannot be proven by a mocked repo, so it is pinned by a Testcontainers
|
||||
`postgres:16-alpine` test on a `Glückwünsche`/`glückwünsche` pair; a plain-ASCII test would
|
||||
stay green while the bug reappeared for umlaut tags.
|
||||
|
||||
## Person extension (#731)
|
||||
|
||||
The Person domain carried the same latent throw on **two** user-influenced lookup surfaces, and
|
||||
is fixed with the same exact-case-first, non-throwing pattern — but with a deliberately
|
||||
**different fallback per surface**, because the two paths have different consequences.
|
||||
|
||||
- **Alias path — `PersonService.findOrCreateByAlias` — deterministic lowest-id (mirrors tag).**
|
||||
`findByAliasIgnoreCase` (`Optional`) is replaced by `findByAlias` (exact) → `findAllByAliasIgnoreCase`
|
||||
(plural, lowest id) → the existing create-when-absent branch (INSTITUTION/GROUP and the
|
||||
maiden-name alias are preserved verbatim). There is no human in the importer loop and the path
|
||||
creates-on-absent anyway, so a deterministic guess is the right behaviour — exactly like tags.
|
||||
|
||||
- **Name/sender path — `PersonService.findByName` — bail to null on ambiguity (the new wrinkle).**
|
||||
Used only by `DocumentService.storeDocument` to resolve the upload **sender** from the parsed
|
||||
filename. `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` (`Optional`) is replaced by
|
||||
`findByFirstNameAndLastName` (exact) → `findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase`
|
||||
(plural). Resolution returns the exact-case match, else the single case-insensitive match, else
|
||||
— on **two or more** matches — **empty**. The sender is left unset rather than guessing.
|
||||
|
||||
**Why this diverges from the alias (and tag) decision:** the archive's value is correct
|
||||
provenance. A confidently-wrong pre-filled `Hans Müller` is worse than an empty field, because a
|
||||
senior reviewer will not re-check a value that is already filled in, whereas an empty sender
|
||||
routes the document into the "needs completion" state (`metadataComplete=false`) for a human to
|
||||
assign. The load-bearing comment at `findByName` records this so a future "consistency cleanup"
|
||||
does not reintroduce the confidently-wrong-sender bug by switching it to lowest-id.
|
||||
|
||||
- **Fail-closed on a null first name.** A parsed filename can lack a first name. The two new name
|
||||
methods use explicit HQL equality (`= :firstName`) rather than a derived
|
||||
`…IgnoreCase` query, because Spring Data folds a null derived-query argument to `first_name IS
|
||||
NULL` — which would silently widen the match and pull a last-name-only / institution row in as a
|
||||
"sender" (a quiet provenance-integrity defect). With HQL equality a null binds as `= NULL`,
|
||||
which never matches, so a null first name resolves to **no sender**. This is pinned by a
|
||||
real-Postgres repository test.
|
||||
|
||||
- **Scope — "ambiguous" is case-insensitive only.** Both exact-case lookups (`findByAlias`,
|
||||
`findByFirstNameAndLastName`) return `Optional`, so two **byte-identical same-case** rows would
|
||||
still throw `NonUniqueResultException`. That is a true data anomaly, deliberately out of scope
|
||||
(it is not a case-collision), and it surfaces as the opaque `INTERNAL_ERROR` — never a silently
|
||||
wrong row — so it is no worse than any other unexpected error and needs no extra handling here.
|
||||
|
||||
- **Same stance as tags otherwise:** no `unique(lower(alias))` / `unique(lower(name))` constraint
|
||||
(collisions are valid human labels; `source_ref` is the stable identity per ADR-025), no
|
||||
merge/dedupe, code-only and reversible, and no shared `resolveExactThenCi(...)` helper — the
|
||||
two Person paths have different fallbacks, so the exact→CI→fallback logic is inlined at each
|
||||
with its load-bearing comment (KISS).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A `unique(lower(name))` index** — rejected: the collisions are valid canonical nodes, and
|
||||
the migration would fail against the existing data and block startup.
|
||||
- **Merging/deduping the colliding tags** — rejected: each has a distinct `source_ref`, tree
|
||||
position, and real document attachments; they are not duplicates.
|
||||
- **Round-tripping tag IDs instead of names** so resolution can't be ambiguous at all — the
|
||||
cleaner long-term shape (removes the name-as-key smell), but a larger change with frontend
|
||||
surface; deferred to #732. The lookup fix here is the minimal correct unblock.
|
||||
- **Hoisting resolution into `DocumentService.resolveTags`** — rejected: it would duplicate the
|
||||
rule across the edit, bulk-edit, and import paths and let them drift; `findOrCreate` stays
|
||||
the one owner.
|
||||
53
docs/adr/034-remove-nl-search.md
Normal file
53
docs/adr/034-remove-nl-search.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Accepted
|
||||
**Issue:** #772
|
||||
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
|
||||
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
|
||||
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
|
||||
|
||||
The feature went through two major iterations:
|
||||
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
|
||||
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
|
||||
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
|
||||
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
|
||||
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
|
||||
|
||||
Both approaches shared the same fundamental problem: inference on the production server
|
||||
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
|
||||
typical query latencies of 10–30 seconds. Users got better and faster results from
|
||||
the existing keyword search with date/person/tag filters.
|
||||
|
||||
## Decision
|
||||
|
||||
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
|
||||
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
|
||||
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
|
||||
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
|
||||
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
|
||||
Grafana dashboard, and all i18n keys are removed.
|
||||
|
||||
The existing structured search (FTS keyword + person/tag/date/directional filters) is
|
||||
sufficient for the archive's current audience and search workload.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Capability removed:** users can no longer enter free-form natural-language queries.
|
||||
They must use the structured filter bar (keyword text box + person/tag/date/directional
|
||||
dropdowns). For documents where these filters are sufficient, there is no regression.
|
||||
- **Operational simplification:** the Docker Compose stack loses two services
|
||||
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
|
||||
production host is freed. No external model weights need to be kept warm.
|
||||
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
|
||||
server-side LLM inference would be straightforward given the clean separation of the
|
||||
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
|
||||
infrastructure or stub code in place — start clean if and when that becomes viable.
|
||||
- **No data or schema change:** only query/endpoint code and Docker services are removed.
|
||||
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.
|
||||
@@ -104,7 +104,7 @@ C4Component
|
||||
ContainerDb(minio, "MinIO")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, and batch metadata updates.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state via GET /api/admin/import-status (IDLE/RUNNING/DONE/FAILED).")
|
||||
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
@@ -112,7 +112,7 @@ C4Component
|
||||
Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs four idempotent loaders (TagTree → PersonRegister → PersonTree → Document) in a fixed DAG over the normalizer's committed canonical artifacts (canonical-*.xlsx + canonical-persons-tree.json) from /import — see diagram 3b. Owns the IDLE/RUNNING/DONE/FAILED state machine.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ C4Component
|
||||
|
||||
### 3c — People, Stories & Discovery
|
||||
|
||||
Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles.
|
||||
Person directory, activity feed, stories, family tree, and user profiles.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
@@ -454,7 +454,6 @@ C4Component
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
@@ -466,7 +465,6 @@ C4Component
|
||||
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
|
||||
@@ -9,10 +9,12 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
||||
System_Ext(ollama, "Ollama (self-hosted)", "Local LLM inference server (qwen2.5:7b). Parses natural-language search queries into structured filters. Runs in the same Docker Compose stack.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
||||
Rel(familienarchiv, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -18,7 +18,7 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
}
|
||||
|
||||
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
||||
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
||||
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
||||
@@ -45,6 +45,7 @@ Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
|
||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
||||
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
|
||||
@@ -8,19 +8,21 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED). Hosts the one-shot maintenance backfills (versions, file-hashes, titles) — synchronous, ADMIN-only.")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. On update, regenerates an unchanged auto-title from the new date/location (exact old-vs-new match, #726); exposes backfillTitles() to clean already-stale titles in one sweep. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs the four canonical loaders in an explicit dependency DAG (TagTree → PersonRegister → PersonTree → Document). Smoke-checks all four artifacts before starting, owns the IDLE/RUNNING/DONE/FAILED state machine, fails closed on a malformed artifact.")
|
||||
Component(tagTreeLoader, "TagTreeImporter", "Spring Component", "Upserts the tag hierarchy from canonical-tag-tree.xlsx via TagService (by canonical tag_path).")
|
||||
Component(personRegLoader, "PersonRegisterImporter", "Spring Component", "Upserts register persons from canonical-persons.xlsx via PersonService (by normalizer person_id).")
|
||||
Component(personTreeLoader, "PersonTreeImporter", "Spring Component", "Upserts tree persons + relationships from canonical-persons-tree.json via PersonService and RelationshipService.")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and resolves each PDF by index (importDir/<index>.pdf) guarded by strict index validation + canonical-path containment + %PDF magic-byte check (no recursive walk).")
|
||||
Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds the title via DocumentTitleFactory, keeps the S3 upload + thumbnail plumbing, and resolves each PDF by index (importDir/<index>.pdf) guarded by strict index validation + canonical-path containment + %PDF magic-byte check (no recursive walk).")
|
||||
Component(titleFactory, "DocumentTitleFactory", "Spring Component", "Single source of truth for the auto-title {index} – {dateLabel} – {location} (#726). The document package owns this formula; importer, save-time regeneration, and the backfill all build through it so they never diverge.")
|
||||
Component(titleFmt, "DocumentTitleFormatter", "Pure helper (document pkg)", "Formats the date label at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
|
||||
Component(titleMatcher, "DocumentTitleBackfillMatcher", "Pure helper", "Backfill-only heuristic deciding whether a STORED title is machine-generated (overwritable) vs hand-written prose. Index matched literally (no regex injection / ReDoS); fail-closed.")
|
||||
Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
@@ -44,7 +46,11 @@ Rel(importOrch, docLoader, "4. Loads documents")
|
||||
Rel(tagTreeLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(personRegLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, titleFmt, "Builds honest title date")
|
||||
Rel(docLoader, titleFactory, "Builds the auto-title")
|
||||
Rel(docSvc, titleFactory, "Regenerates auto-title (save-time + backfill)")
|
||||
Rel(docSvc, titleMatcher, "Backfill overwrite test")
|
||||
Rel(titleFactory, titleFmt, "Formats the honest date label")
|
||||
Rel(adminCtrl, docSvc, "backfillTitles() / backfillFileHashes()")
|
||||
Rel(tagTreeLoader, tagSvc, "Upserts tags by source_ref")
|
||||
Rel(personRegLoader, personSvc, "Upserts persons by source_ref")
|
||||
Rel(personTreeLoader, personSvc, "Upserts persons by source_ref")
|
||||
|
||||
@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
|
||||
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
|
||||
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
|
||||
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
|
||||
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
|
||||
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
|
||||
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
|
||||
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
|
||||
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
|
||||
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
|
||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
|
||||
@@ -10,7 +10,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory (server-side filtered + paginated) and detail. Directory: type/family/has-documents chips, reader default (familyMember OR documentCount > 0), writer-only show-all toggle. Detail: metadata, document list sent/received, correspondents, family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
@@ -24,7 +23,6 @@ Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearchResult), GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
|
||||
@@ -260,7 +260,7 @@ package "Transcription" {
|
||||
|
||||
entity transcription_block_mentioned_persons {
|
||||
block_id : UUID <<FK>>
|
||||
person_id : UUID NOT NULL
|
||||
person_id : UUID NOT NULL <<FK>>
|
||||
--
|
||||
display_name : VARCHAR(200) NOT NULL
|
||||
}
|
||||
@@ -386,9 +386,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id
|
||||
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
@@ -420,6 +420,7 @@ transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
|
||||
@@ -79,9 +79,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id
|
||||
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
@@ -113,6 +113,7 @@ transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
|
||||
@@ -68,6 +68,8 @@ Job containers are unprivileged and do not share the host's PID/mount/network na
|
||||
|
||||
Alpine is used instead of Ubuntu: ~5 MB vs ~70 MB, and the digest is pinned to a specific sha256 so any upstream change requires an explicit Renovate bump PR. `util-linux` (which ships `nsenter`) is not part of the Alpine base image but is installed at run time in ~1 s from the warm VPS cache.
|
||||
|
||||
This exact step now lives in the `reload-caddy` composite action (see [Composite actions](#composite-actions) below); both deploy workflows call it via `uses: ./.gitea/actions/reload-caddy`. The pinned digest moved with it, so Renovate's privileged-digest watch covers `.gitea/actions/**` as well as `.gitea/workflows/**`.
|
||||
|
||||
#### Why not `sudo systemctl` in the job container?
|
||||
|
||||
Job containers run as root inside an unprivileged Docker namespace. There is no systemd PID 1 inside the container — `systemctl` would attempt to reach a socket that does not exist. `sudo` is not present in container images and would not help even if it were.
|
||||
@@ -170,6 +172,72 @@ See `docs/DEPLOYMENT.md §3.1` and ADR-015 for the full setup rationale.
|
||||
|
||||
---
|
||||
|
||||
## Composite actions
|
||||
|
||||
The `nightly.yml` (staging) and `release.yml` (production) deploy workflows share their observability-stack deploy, Caddy reload, and smoke-test logic through three single-responsibility composite actions under `.gitea/actions/` (ADR-029). Before this, the shared logic was duplicated in both workflows and held together by `# Keep in sync with nightly.yml` comments — an unenforced honour-system invariant.
|
||||
|
||||
| Action | Inputs | Purpose |
|
||||
|---|---|---|
|
||||
| `deploy-obs` | `grafana_admin_password`, `grafana_db_password`, `glitchtip_secret_key`, `postgres_password`, `postgres_host` | Deploy obs configs + secrets to `/opt/familienarchiv`, validate the compose config, start the stack, assert the five healthchecked services |
|
||||
| `reload-caddy` | — | Reload host Caddy via the privileged-sibling + nsenter pattern |
|
||||
| `smoke-test` | `host` | Verify the public surface (login reachable, HSTS pinned, Permissions-Policy present, `/actuator → 404`) |
|
||||
|
||||
A workflow calls them by relative path, passing per-environment values as `with:` inputs:
|
||||
|
||||
```yaml
|
||||
- uses: ./.gitea/actions/deploy-obs
|
||||
with:
|
||||
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
postgres_host: archiv-staging-db-1
|
||||
- uses: ./.gitea/actions/reload-caddy
|
||||
- uses: ./.gitea/actions/smoke-test
|
||||
with:
|
||||
host: staging.raddatz.cloud
|
||||
```
|
||||
|
||||
### Checkout-first ordering rule
|
||||
|
||||
A local composite action (`uses: ./…`) only exists on disk **after** the repo is checked out. `actions/checkout@v4` MUST therefore be the **first step** of any job that calls one — if a future reorder moves checkout later, every `uses: ./.gitea/actions/…` call fails because the action file is not yet on disk. Both deploy workflows pin checkout as step 1 for exactly this reason.
|
||||
|
||||
### Secrets inside composite actions
|
||||
|
||||
The `secrets.*` context is **not** available inside a composite action. Secrets are passed in as `inputs`, mapped to an `env:` block, and referenced as `$VAR`:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
grafana_admin_password:
|
||||
required: true # no default — a missing secret must fail loudly, never fall back to empty
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash # composite steps do NOT default the shell — always declare it
|
||||
env:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ inputs.grafana_admin_password }}
|
||||
run: |
|
||||
cat > obs-secrets.env <<EOF # unquoted EOF — $VAR expands at the shell layer
|
||||
GRAFANA_ADMIN_PASSWORD=$GRAFANA_ADMIN_PASSWORD
|
||||
EOF
|
||||
```
|
||||
|
||||
Two load-bearing details:
|
||||
|
||||
- **Unquoted heredoc delimiter (`<<EOF`, not `<<'EOF'`).** With a quoted delimiter the shell writes the literal string `$GRAFANA_ADMIN_PASSWORD`, and `docker compose config --quiet` still passes (the variable is *present, just wrong*). The `deploy-obs` action guards against this with a five-key **non-empty** check (`grep -Eq "^KEY=.+"`) immediately after writing `obs-secrets.env`. `chmod 600` is the action's final operation so the file is never world-readable.
|
||||
- **Every `run:` step declares `shell: bash`.** Composite actions do not inherit the workflow's default shell; a step without it fails to run.
|
||||
|
||||
### Adding an input to an action
|
||||
|
||||
To thread a new per-environment value (e.g. a new secret) through `deploy-obs`:
|
||||
|
||||
1. Add it under `inputs:` in `.gitea/actions/deploy-obs/action.yml` with `required: true` and **no `default:`**.
|
||||
2. Map it in the relevant step's `env:` block: `NEW_KEY: ${{ inputs.new_key }}`.
|
||||
3. Reference it as `$NEW_KEY` in the `run:` script — add a `NEW_KEY=$NEW_KEY` line to the heredoc **and** a matching entry to the five-key guard loop.
|
||||
4. Pass it from **both** workflows' `with:` blocks. That is the whole point of the action: the contract lives in one place, so neither environment can silently drift.
|
||||
|
||||
---
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
|
||||
### Context Variable Names
|
||||
|
||||
@@ -20,24 +20,19 @@ The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a
|
||||
|
||||
---
|
||||
|
||||
## VPS Sizing Recommendations
|
||||
## Server Sizing
|
||||
|
||||
### Recommended: Hetzner CX32
|
||||
### Current Production Server: Hetzner Dedicated (Serverbörse)
|
||||
|
||||
**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD · **Cost**: 17 EUR/mo
|
||||
**Specs**: Intel Core i7-6700 (4C/8T, 3.4 GHz), 64 GB RAM · acquired via Hetzner server auction
|
||||
|
||||
Sufficient for the application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy) on a CX32 today. Once the observability stack lands (Prometheus/Loki/Grafana/Alertmanager add ~2 GB) consider a CX42.
|
||||
Comfortably handles the full application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy, full observability stack) with headroom to spare. The 64 GB RAM means OCR, Ollama inference, and the observability stack can all run concurrently without memory pressure.
|
||||
|
||||
### When to Upgrade: Hetzner CX42
|
||||
### When to Reconsider Hardware
|
||||
|
||||
**Specs**: 8 vCPU, 16 GB RAM · **Cost**: 29 EUR/mo
|
||||
|
||||
Upgrade when:
|
||||
- Observability stack adds memory pressure (Loki + Grafana with >30 days retention)
|
||||
- OCR throughput needs scaling beyond a single-node Surya/Kraken setup
|
||||
- Real user load profiled in Grafana shows response-time degradation
|
||||
|
||||
Never upgrade the VPS tier before profiling — most perceived performance issues are application bugs, not resource constraints.
|
||||
- CPU is Skylake (2015) — single-threaded performance is the likely bottleneck before RAM
|
||||
- Profile with Grafana dashboards before concluding hardware is the constraint
|
||||
- Most perceived performance issues are application bugs (unindexed queries, N+1 loads), not resource limits
|
||||
|
||||
---
|
||||
|
||||
@@ -45,12 +40,11 @@ Never upgrade the VPS tier before profiling — most perceived performance issue
|
||||
|
||||
| Service | Cost |
|
||||
|---|---|
|
||||
| Hetzner CX32 VPS | 17.00 EUR |
|
||||
| Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM) | see invoice |
|
||||
| Hetzner DNS | 0.00 EUR |
|
||||
| Hetzner SMTP relay | ~1.00 EUR |
|
||||
| **Total** | **~18 EUR/mo** |
|
||||
|
||||
MinIO data lives on the VPS disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
|
||||
MinIO data lives on the server disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
|
||||
|
||||
Equivalent SaaS stack: 200–300 EUR/mo.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
808
docs/specs/lesereisen-editor-spec.html
Normal file
808
docs/specs/lesereisen-editor-spec.html
Normal file
@@ -0,0 +1,808 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── Editor chrome (shared with writer spec) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
|
||||
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
|
||||
.ed-split{display:flex;flex:1;overflow:hidden;}
|
||||
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
|
||||
.ed-sb-section{padding:12px 12px 10px;}
|
||||
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
|
||||
.ed-sb-divider{height:1px;background:#e4e2d7;}
|
||||
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
|
||||
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
|
||||
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
|
||||
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
|
||||
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
|
||||
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
|
||||
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
|
||||
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
|
||||
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
|
||||
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
|
||||
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
|
||||
|
||||
/* ── Journey Editor main area ── */
|
||||
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
|
||||
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
|
||||
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
|
||||
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
|
||||
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
|
||||
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
|
||||
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
|
||||
|
||||
/* ── Item rows ── */
|
||||
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
|
||||
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
|
||||
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
|
||||
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
|
||||
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
|
||||
.je-body{flex:1;padding:7px 8px 7px 4px;}
|
||||
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
|
||||
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
|
||||
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
|
||||
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
|
||||
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
|
||||
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
|
||||
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
|
||||
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
|
||||
|
||||
/* ── Add bar ── */
|
||||
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
|
||||
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
|
||||
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
|
||||
|
||||
/* ── Inline note editing state (highlight) ── */
|
||||
.je-note-editing{border-color:var(--navy);background:#fff;}
|
||||
|
||||
/* ── Mobile journey editor ── */
|
||||
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
|
||||
.mob-back{font-size:8px;color:var(--color-text-muted);}
|
||||
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
|
||||
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
|
||||
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
|
||||
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
|
||||
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
|
||||
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
|
||||
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
|
||||
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
|
||||
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
|
||||
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
|
||||
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
|
||||
.mob-je-body{flex:1;padding:6px 7px;}
|
||||
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
|
||||
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
|
||||
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
|
||||
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Journey-Editor</h1>
|
||||
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">E</div>
|
||||
<div>
|
||||
<h2>Journey-Editor</h2>
|
||||
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
|
||||
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
|
||||
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
|
||||
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Leerer Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-1 — Journey-Editor leer</h3>
|
||||
<span class="scr-id">Issue #753 · LE-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
|
||||
<div class="desk" style="min-height:500px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Neue Lesereise
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
<div class="je-empty">
|
||||
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
|
||||
</div>
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost">Entwurf speichern</button>
|
||||
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-1 Leerer Editor</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}<JourneyEditor />{:else}<GeschichteEditor />{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
|
||||
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
|
||||
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
|
||||
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
|
||||
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
|
||||
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
|
||||
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
|
||||
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Leerstate</td></tr>
|
||||
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
|
||||
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
|
||||
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Editor mit Einträgen</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
|
||||
<span class="scr-id">Issue #753 · LE-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
|
||||
<div class="desk" style="min-height:580px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Lesereise bearbeiten
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
|
||||
<span class="ed-delete-link">Löschen</span>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area with items -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
|
||||
<!-- Item 1: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="je-item je-interlude-bg">
|
||||
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num" style="color:var(--orange-dark);">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="je-body" style="padding-top:6px;">
|
||||
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
|
||||
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
|
||||
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 4: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">3</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 3. September 1939</div>
|
||||
<div class="je-doc-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
|
||||
Franz Raddatz
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
|
||||
Emma Müller
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
|
||||
<button class="ed-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-2 Items-Liste</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Inline-Notiz-Editing</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
|
||||
<span class="scr-id">Issue #753 · LE-3</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
|
||||
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:560px;">
|
||||
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
|
||||
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
|
||||
<!-- Item before (no note) -->
|
||||
<div class="je-item" style="margin-bottom:5px;">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item with opened note textarea (focused) -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · Franz → Emma</div>
|
||||
<!-- Focused textarea -->
|
||||
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
|
||||
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
|
||||
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-3 Inline-Notiz</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
|
||||
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
|
||||
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
|
||||
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
|
||||
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
|
||||
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
|
||||
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
|
||||
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
|
||||
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
|
||||
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Mobile Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-4 — Mobile Journey-Editor</h3>
|
||||
<span class="scr-id">Issue #753 · LE-4</span>
|
||||
</div>
|
||||
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
|
||||
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
|
||||
<div class="phone" style="min-height:580px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">KR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-topbar">
|
||||
<span class="mob-back">←</span>
|
||||
<span class="mob-label">Lesereise bearbeiten</span>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VERÖFF.</div>
|
||||
</div>
|
||||
<div class="mob-body">
|
||||
<input class="mob-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
|
||||
<!-- Item 1: Document -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mob-je-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="mob-je-item mob-je-interlude">
|
||||
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
|
||||
<div class="mob-je-body">
|
||||
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
|
||||
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag"></div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Postkarte Aug. 1938</div>
|
||||
<div class="mob-je-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div style="display:flex;gap:5px;padding:4px 0;">
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Personen -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Personen
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible: Status -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Status & Speichern
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-savebar">
|
||||
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
|
||||
<button class="mob-btn mob-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-4 Mobile</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Journey-Editor</h2>
|
||||
|
||||
<h3>Neue Komponente</h3>
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
|
||||
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Edit-Page-Integration</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
|
||||
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
|
||||
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
|
||||
</ul>
|
||||
|
||||
<h3>API-Calls</h3>
|
||||
<table>
|
||||
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
|
||||
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
|
||||
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
|
||||
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td>—</td></tr>
|
||||
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Optimistische Updates</h3>
|
||||
<ul>
|
||||
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
|
||||
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
|
||||
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
|
||||
</ul>
|
||||
|
||||
<h3>DocumentPicker-Integration</h3>
|
||||
<ul>
|
||||
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
|
||||
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
|
||||
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Drag-to-Reorder</h3>
|
||||
<ul>
|
||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code>-Element — kommuniziert die Ordnung an Screenreader.</li>
|
||||
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
|
||||
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
|
||||
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
|
||||
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Abgrenzung zu GeschichteEditor</h3>
|
||||
<ul>
|
||||
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
|
||||
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
|
||||
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
|
||||
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
727
docs/specs/lesereisen-reader-spec.html
Normal file
727
docs/specs/lesereisen-reader-spec.html
Normal file
@@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── List row (re-used from reader-journey spec) ── */
|
||||
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
|
||||
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
|
||||
.g-row:last-child{border-bottom:none;}
|
||||
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
|
||||
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
|
||||
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
|
||||
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
|
||||
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
|
||||
.g-date{font-size:6.5px;color:#6B6A63;}
|
||||
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
|
||||
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
|
||||
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
|
||||
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
|
||||
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
|
||||
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey badge in list ── */
|
||||
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
|
||||
|
||||
/* ── Type selector cards ── */
|
||||
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
|
||||
.type-selector-inner{max-width:520px;width:100%;}
|
||||
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
|
||||
.type-cards{display:flex;gap:10px;}
|
||||
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
|
||||
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
|
||||
.type-card-icon{font-size:16px;margin-bottom:2px;}
|
||||
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
|
||||
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
|
||||
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
|
||||
.type-card-check svg{width:8px;height:8px;}
|
||||
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
|
||||
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey reader ── */
|
||||
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
|
||||
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
|
||||
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
|
||||
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
|
||||
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
|
||||
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
|
||||
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
|
||||
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
|
||||
|
||||
/* Journey items in reader */
|
||||
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
|
||||
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
|
||||
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
|
||||
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
|
||||
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
|
||||
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
|
||||
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
|
||||
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
|
||||
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
|
||||
|
||||
/* Mobile list row */
|
||||
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
|
||||
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
|
||||
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
|
||||
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
|
||||
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
|
||||
.m-filters::-webkit-scrollbar{display:none;}
|
||||
|
||||
/* Mobile journey reader */
|
||||
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
|
||||
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
|
||||
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
|
||||
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
|
||||
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
|
||||
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
|
||||
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
|
||||
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
|
||||
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
|
||||
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
|
||||
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
|
||||
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
|
||||
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
|
||||
|
||||
/* ── Editor topbar (type selector screen) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Reader-Integration</h1>
|
||||
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">R</div>
|
||||
<div>
|
||||
<h2>Lesereisen — Reader</h2>
|
||||
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
|
||||
<div class="fl">/geschichten · /geschichten/new · /geschichten/[id]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
|
||||
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Typauswahl</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-0 — Typauswahl /geschichten/new</h3>
|
||||
<span class="scr-id">Issue #752 · LR-0</span>
|
||||
</div>
|
||||
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
|
||||
<div class="desk" style="min-height:320px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label">Neue Geschichte</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="type-selector">
|
||||
<div class="type-selector-inner">
|
||||
<div class="type-selector-q">Was möchtest du erstellen?</div>
|
||||
<div class="type-cards">
|
||||
<!-- Story card -->
|
||||
<div class="type-card">
|
||||
<div class="type-card-icon">✍️</div>
|
||||
<div class="type-card-title">Geschichte</div>
|
||||
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
|
||||
</div>
|
||||
<!-- Journey card (selected) -->
|
||||
<div class="type-card selected">
|
||||
<div class="type-card-icon">📜</div>
|
||||
<div class="type-card-title">Lesereise</div>
|
||||
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
|
||||
<div class="type-card-check">
|
||||
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-next-bar">
|
||||
<button class="type-next-btn">
|
||||
Weiter
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-0 Typauswahl</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout</td></tr>
|
||||
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
|
||||
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
|
||||
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
|
||||
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
|
||||
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
|
||||
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
|
||||
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
|
||||
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
|
||||
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Navigation</td></tr>
|
||||
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
|
||||
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
|
||||
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Übersichtsliste</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-1 — Reise-Badge in /geschichten</h3>
|
||||
<span class="scr-id">Issue #752 · LR-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
|
||||
<div class="g-page-hdr" style="padding:0 0 8px;">
|
||||
<span class="g-page-title">Geschichten</span>
|
||||
<button class="g-new-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Neue Geschichte
|
||||
</button>
|
||||
</div>
|
||||
<div class="g-list-card">
|
||||
<div class="g-filters">
|
||||
<span class="g-pill active">Alle</span>
|
||||
<span class="g-pill">Franz Raddatz</span>
|
||||
<span class="g-pill">Emma Müller</span>
|
||||
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
|
||||
</div>
|
||||
<!-- Row 1: Story (no badge) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-navy">MR</div>
|
||||
<div class="g-author">Maria Raddatz</div>
|
||||
<div class="g-date">14. März 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Der Sommer in Breslau</div>
|
||||
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Journey (badge!) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-purple">KR</div>
|
||||
<div class="g-author">Klaus Raddatz</div>
|
||||
<div class="g-date">15. Mai 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
<span class="j-badge">REISE</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: Story -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-teal">GK</div>
|
||||
<div class="g-author">Gertrud Koch</div>
|
||||
<div class="g-date">18. Okt. 2024</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
|
||||
Emma Müller
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Die Hochzeit im Krieg</div>
|
||||
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px</span>
|
||||
<div class="phone">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
|
||||
<div style="padding:8px 10px 4px;">
|
||||
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
|
||||
</div>
|
||||
<div class="m-filters">
|
||||
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
|
||||
</div>
|
||||
<div style="background:#fff;flex:1;">
|
||||
<!-- Story row -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
|
||||
<span class="m-author-name">Maria Raddatz</span>
|
||||
<span class="m-date">14. Mrz. 2025</span>
|
||||
</div>
|
||||
<div class="m-title">Der Sommer in Breslau</div>
|
||||
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
|
||||
</div>
|
||||
<!-- Journey row (badge) -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
|
||||
<span class="m-author-name">Klaus Raddatz</span>
|
||||
<span class="m-date">15. Mai 2025</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
|
||||
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 1938–1942</div>
|
||||
<span class="j-badge" style="flex-shrink:0;">REISE</span>
|
||||
</div>
|
||||
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Badge</td></tr>
|
||||
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
|
||||
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
|
||||
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
|
||||
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
|
||||
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}<span …>REISE</span>{/if}</td><td>kein Badge für STORY</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Journey-Leseansicht</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
|
||||
<span class="scr-id">Issue #752 · LR-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
|
||||
<div class="desk" style="min-height:600px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
|
||||
<div class="jr-article">
|
||||
<div class="jr-back">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück zu Geschichten
|
||||
</div>
|
||||
<div class="jr-badge">LESEREISE</div>
|
||||
<div class="jr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="jr-metabar">
|
||||
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
|
||||
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
|
||||
</div>
|
||||
<div class="jr-metabar-r">
|
||||
<button class="jr-edit-btn">Bearbeiten</button>
|
||||
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Intro -->
|
||||
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
|
||||
<!-- Item 1: Document, no annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="jr-card-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="jr-interlude">
|
||||
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
|
||||
</div>
|
||||
<!-- Item 2: Document with annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="jr-card-meta">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="jr-annotation">
|
||||
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item 3: Document -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 3. September 1939</div>
|
||||
<div class="jr-card-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · Leserin</span>
|
||||
<div class="phone" style="min-height:520px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:10px;">
|
||||
<div class="mjr-article">
|
||||
<div class="mjr-back">
|
||||
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück
|
||||
</div>
|
||||
<div class="mjr-badge">LESEREISE</div>
|
||||
<div class="mjr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="mjr-metabar">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
|
||||
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
|
||||
</div>
|
||||
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
|
||||
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
|
||||
<!-- Item 1 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="mjr-interlude">
|
||||
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Postkarte Aug. 1938</div>
|
||||
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
|
||||
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Lesereisen Reader</h2>
|
||||
|
||||
<h3>Geänderte Views und Routen</h3>
|
||||
<table>
|
||||
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
|
||||
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Neue Komponenten</h3>
|
||||
<ul>
|
||||
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
|
||||
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
|
||||
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Datenmodell (nach #750)</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
|
||||
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
|
||||
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
|
||||
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
|
||||
</ul>
|
||||
|
||||
<h3>Typauswahl — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
|
||||
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
|
||||
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code> → <code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
|
||||
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Badge — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
|
||||
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Reader — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
|
||||
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
|
||||
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Berechtigungen</h3>
|
||||
<ul>
|
||||
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code> semantisch für die geordnete Briefliste. Interludes sind <code><li></code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
|
||||
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
|
||||
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1212
docs/specs/nl-search-spec.html
Normal file
1212
docs/specs/nl-search-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
418
docs/specs/zeitstrahl-event-editor-spec.html
Normal file
418
docs/specs/zeitstrahl-event-editor-spec.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zeitstrahl — Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
|
||||
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:13px;color:#555;max-width:790px;line-height:1.75;margin-top:8px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
|
||||
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tg.mint{background:#a1dcd8;color:#012851}
|
||||
.tg.slate{background:#607080;color:#e8edf2}
|
||||
|
||||
.sh{margin:60px 0 22px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
|
||||
.sh h2{font-size:17px;font-weight:900;color:#012851}
|
||||
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:790px;line-height:1.65}
|
||||
|
||||
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
|
||||
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
|
||||
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
|
||||
.callout code{font-family:'Courier New',monospace;font-size:11px;background:#fff;padding:1px 4px;border-radius:2px}
|
||||
.callout strong{font-weight:800;color:#012851}
|
||||
|
||||
/* desktop chrome */
|
||||
.dchrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:9px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.12)}
|
||||
.dbar{height:22px;background:#E2DFD8;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;gap:4px;padding:0 9px}
|
||||
.ddot{width:7px;height:7px;border-radius:50%;background:#C4BFB8}
|
||||
.durl{flex:1;height:10px;background:#D2CEC8;border-radius:5px;margin:0 8px;max-width:360px}
|
||||
.dnav{height:32px;background:#012851;display:flex;align-items:center;gap:13px;padding:0 16px}
|
||||
.dnav .nlogo{font-family:'Tinos',serif;font-size:10px;color:#fff;font-weight:700}
|
||||
.dnav .nlink{font-size:7.5px;color:rgba(255,255,255,.5);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
|
||||
.dnav .nlink.on{color:#fff;border-bottom:2px solid #a1dcd8;padding-bottom:9px}
|
||||
.dnav .av{margin-left:auto;width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.55)}
|
||||
.dcanvas{background:#f0efe9;padding:20px 26px 26px}
|
||||
|
||||
/* form atoms (mirror real Tailwind look) */
|
||||
.lbl{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:6px}
|
||||
.inp{border:1px solid #e4e2d7;border-radius:4px;background:#fff;padding:8px 11px;font-size:12px;color:#012851}
|
||||
.inp.title{font-family:'Tinos',serif;font-size:22px;font-weight:700;padding:11px 13px}
|
||||
.card{border:1px solid #e4e2d7;border-radius:6px;background:#fff;padding:15px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.card h3{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:3px}
|
||||
.card .hint{font-size:9.5px;color:#9a9a96;margin-bottom:9px}
|
||||
.seg{display:inline-flex;border:1px solid #012851;border-radius:5px;overflow:hidden}
|
||||
.seg span{font-size:10px;font-weight:700;padding:5px 13px;color:#012851}
|
||||
.seg span.on{background:#012851;color:#fff}
|
||||
.seg span+span{border-left:1px solid #012851}
|
||||
.chips{border:1px solid #e4e2d7;border-radius:5px;background:#fff;padding:7px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
||||
.chip-sel{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:4px;padding:3px 8px;font-size:10px;color:#012851}
|
||||
.chip-sel .x{color:#012851;opacity:.45;font-size:11px}
|
||||
.chip-in{flex:1;min-width:90px;font-size:10.5px;color:#9a9a96;padding:3px 4px}
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 16px;border-radius:5px;font-size:12px;font-weight:600}
|
||||
.btn.primary{background:#012851;color:#fff}
|
||||
.btn.ghost{background:#fff;border:1px solid #e4e2d7;color:#012851}
|
||||
.btn.danger{background:#fff;border:1px solid #e7c9c4;color:#c0392b}
|
||||
.tagchip{display:inline-flex;align-items:center;gap:3px;font-size:8px;border-radius:9px;padding:2px 7px;color:#a0522d;background:#f6ece6}
|
||||
.tagchip i{width:6px;height:6px;border-radius:2px;background:#a0522d;display:inline-block}
|
||||
|
||||
/* annotation callouts on mockups */
|
||||
.anno{display:flex;gap:9px;align-items:flex-start;font-size:11.5px;color:#4a4a46;line-height:1.55;margin-bottom:7px}
|
||||
.anno .n{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;margin-top:1px}
|
||||
.anno b{color:#012851}
|
||||
.anno code{font-size:10px;background:#F0EFE9;padding:1px 4px;border-radius:2px}
|
||||
.annogrid{display:grid;grid-template-columns:1fr 1fr;gap:6px 26px;margin-top:14px}
|
||||
|
||||
/* dropdown */
|
||||
.dd{border:1px solid #e4e2d7;border-radius:6px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.12);overflow:hidden;margin-top:3px}
|
||||
.dd .opt{padding:7px 11px;font-size:11px;color:#012851;border-bottom:1px solid #f3f1ea;cursor:pointer}
|
||||
.dd .opt:hover,.dd .opt.hl{background:#f5f4ef}
|
||||
.dd .opt:last-child{border-bottom:none}
|
||||
.dd .opt .d{font-size:8.5px;color:#9a9a96}
|
||||
|
||||
/* states grid */
|
||||
.states{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
|
||||
.state{border:1px solid #E0DDD6;border-radius:8px;background:#fff;overflow:hidden}
|
||||
.state .sh2{background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 12px;font-size:8.5px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;color:#012851;display:flex;justify-content:space-between}
|
||||
.state .sb{padding:13px}
|
||||
.cap{font-size:11px;color:#888;font-style:italic;line-height:1.55;margin-top:8px}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.impl-ref table{width:100%;border-collapse:collapse}
|
||||
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
|
||||
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
|
||||
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:185px}
|
||||
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
|
||||
hr{border:none;border-top:2px dashed #C8C4BE;margin:52px 0}
|
||||
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ══ MASTHEAD ══ -->
|
||||
<div class="mh">
|
||||
<h1>Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument</h1>
|
||||
<p>Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (<code style="font-family:monospace;font-size:12px">TimelineEvent.documents</code>): der <strong>Ereignis-Editor</strong> unter <code style="font-family:monospace;font-size:12px">/zeitstrahl/events/[id]/edit</code> (Kurator baut, verlinkt viele Briefe) und die <strong>Quick-Action im Dokument-Detail</strong> (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.</p>
|
||||
<div class="tag-row">
|
||||
<span class="tg">Milestone #14 · Zeitstrahl</span>
|
||||
<span class="tg mint">Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect</span>
|
||||
<span class="tg slate">WRITE_ALL</span>
|
||||
</div>
|
||||
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · gegründet auf Code: GeschichteEditor.svelte · DocumentMetadataDrawer.svelte · DocumentMultiSelect.svelte</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 1 · TWO ENTRY POINTS ══ -->
|
||||
<div class="sh">
|
||||
<h2>1 · Zwei Einstiegspunkte, ein Datenmodell</h2>
|
||||
<p>Manuelle Gruppierung = ein <code>TimelineEvent</code> mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.</p>
|
||||
</div>
|
||||
<div class="states" style="grid-template-columns:1fr 1fr">
|
||||
<div class="callout navy" style="margin:0">
|
||||
<strong>A · Ereignis-zuerst</strong> — der Kurator baut den Zeitstrahl. <code>/zeitstrahl/events/new · [id]/edit</code> mit Dokument-Mehrfach-Picker = <b>Bulk-Linking</b> vieler Briefe auf einmal. Spiegelt 1:1 den <code>GeschichteEditor</code> (gleiche zwei-Spalten-Form, Sidebar-Picker, Sticky-Save-Bar).
|
||||
</div>
|
||||
<div class="callout mint" style="margin:0">
|
||||
<strong>B · Dokument-zuerst</strong> — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen <i>oder</i> neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende <b>Geschichten-Spalte</b> im Details-Drawer (<code>DocumentMetadataDrawer.svelte</code>).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 2 · EVENT EDITOR ══ -->
|
||||
<div class="sh">
|
||||
<h2>2 · Ereignis-Editor — <code style="font-size:14px">/zeitstrahl/events/[id]/edit</code></h2>
|
||||
<p>Form-Actions-Muster, gegated mit <code>WRITE_ALL</code>. Layout & Verhalten 1:1 vom <code>GeschichteEditor</code> übernommen: Hauptspalte + Sidebar (<code>lg:grid-cols-[2fr_1fr]</code>), Sticky-Save-Bar, <code>beforeNavigate</code>-Warnung bei ungespeicherten Änderungen.</p>
|
||||
</div>
|
||||
|
||||
<div class="dchrome" style="margin-bottom:16px">
|
||||
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
|
||||
<div class="dnav"><span class="nlogo">Familienarchiv</span><span class="nlink">Dokumente</span><span class="nlink">Personen</span><span class="nlink on">Zeitstrahl</span><span class="nlink">Stammbaum</span><span class="av">KR</span></div>
|
||||
<div class="dcanvas">
|
||||
<div style="font-size:8px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#8a8a86;margin-bottom:10px">‹ Zurück zum Zeitstrahl</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:18px;font-weight:700;color:#012851;margin-bottom:16px">Ereignis bearbeiten</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:22px">
|
||||
<!-- MAIN COLUMN -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<!-- ① title -->
|
||||
<div>
|
||||
<div class="inp title" style="width:100%">Briefe von der Front</div>
|
||||
</div>
|
||||
<!-- ② type + ③ date/precision -->
|
||||
<div style="display:flex;gap:22px;flex-wrap:wrap">
|
||||
<div>
|
||||
<div class="lbl">② Typ</div>
|
||||
<div class="seg"><span class="on">Persönlich</span><span>Historisch</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="lbl">③ Datum · Präzision</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<div class="inp" style="width:120px">1915</div>
|
||||
<div class="inp" style="display:flex;align-items:center;gap:18px;color:#6b7280">Jahr <span style="font-size:8px">▾</span></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ④ description -->
|
||||
<div>
|
||||
<div class="lbl">④ Beschreibung <span style="color:#bbb;font-weight:600">· optional</span></div>
|
||||
<div class="inp" style="width:100%;min-height:96px;color:#4a4a46;font-family:'Tinos',serif;line-height:1.6">Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …</div>
|
||||
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside style="display:flex;flex-direction:column;gap:16px">
|
||||
<!-- ⑤ linked letters = grouping -->
|
||||
<div class="card" style="border-color:#a1dcd8;box-shadow:0 2px 10px rgba(161,220,216,.3)">
|
||||
<h3 style="color:#012851">⑤ Verknüpfte Briefe · 24</h3>
|
||||
<div class="hint">Diese Briefe bilden den Cluster. <code style="font-size:9px">DocumentMultiSelect</code></div>
|
||||
<div class="chips">
|
||||
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Brief an Elfriede · Sep 1915 <span class="x">×</span></span>
|
||||
<span class="chip-in">Brief suchen …</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ⑥ persons -->
|
||||
<div class="card">
|
||||
<h3>⑥ Beteiligte Personen</h3>
|
||||
<div class="hint">Treibt die Lebensweg-Ansicht & Filter. <code style="font-size:9px">PersonMultiSelect</code></div>
|
||||
<div class="chips">
|
||||
<span class="chip-sel">Karl Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-sel">Elfriede Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-sel">Hans Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-in">Person suchen …</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ⑦ sticky save bar -->
|
||||
<div style="margin:18px -26px -26px;border-top:1px solid #e4e2d7;background:#fff;box-shadow:0 -2px 8px rgba(0,0,0,.05);padding:13px 26px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:10px;color:#9a9a96">Änderungen werden erst beim Speichern übernommen.</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<span class="btn danger">Löschen</span>
|
||||
<span class="btn ghost">Abbrechen</span>
|
||||
<span class="btn primary">Speichern</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annogrid">
|
||||
<div class="anno"><span class="n">①</span><span><b>Titel</b> — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).</span></div>
|
||||
<div class="anno"><span class="n">②</span><span><b>Typ</b> — <code>PERSONAL</code> / <code>HISTORICAL</code> Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).</span></div>
|
||||
<div class="anno"><span class="n">③</span><span><b>Datum + Präzision</b> — geteilte <code>DatePrecisionInput</code> (gleiche Logik wie Dokument-Datum, <code>metaDatePrecision</code>). „Zeitspanne" blendet End-Datum ein.</span></div>
|
||||
<div class="anno"><span class="n">④</span><span><b>Beschreibung</b> — optionales Textfeld (<code>TEXT</code>), bewusst schlicht.</span></div>
|
||||
<div class="anno"><span class="n">⑤</span><span><b>Verknüpfte Briefe</b> — <b>hier wird gruppiert.</b> Wiederverwendung von <code>DocumentMultiSelect</code> (Typeahead, Chips, Hidden-Inputs).</span></div>
|
||||
<div class="anno"><span class="n">⑥</span><span><b>Beteiligte Personen</b> — <code>PersonMultiSelect</code>. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.</span></div>
|
||||
<div class="anno"><span class="n">⑦</span><span><b>Sticky-Save-Bar</b> — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).</span></div>
|
||||
<div class="anno"><span class="n">+</span><span><b>/new</b> — leeres Formular. Mit <code>?documentId=…</code> ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 3 · GROUPING / DOCUMENT PICKER ══ -->
|
||||
<div class="sh">
|
||||
<h2>3 · Brief-Gruppierung im Editor — der Dokument-Picker</h2>
|
||||
<p>Feld ⑤ ist der unveränderte <code>DocumentMultiSelect</code>: Tippen sucht über <code>/api/documents/search?q=…</code> (debounced 300 ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.</p>
|
||||
</div>
|
||||
|
||||
<div class="states">
|
||||
<div class="state">
|
||||
<div class="sh2"><span>Suche aktiv — Dropdown</span><span style="color:#9a9a96">DocumentMultiSelect</span></div>
|
||||
<div class="sb">
|
||||
<div class="chips" style="margin-bottom:0">
|
||||
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
|
||||
<span class="chip-in" style="color:#012851">Verdun▏</span>
|
||||
</div>
|
||||
<div class="dd">
|
||||
<div class="opt hl">Feldpost aus Verdun <span class="d">· Brief · Juli 1915</span></div>
|
||||
<div class="opt">Brief aus dem Verdun-Lazarett <span class="d">· Brief · August 1916</span></div>
|
||||
<div class="opt">Rückkehr aus Verdun <span class="d">· Brief · ca. 1917</span></div>
|
||||
</div>
|
||||
<div class="cap">Label = <code style="font-size:10px">title · formatDocumentDate(precision)</code>. Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state">
|
||||
<div class="sh2"><span>Inline „+ Ereignis" am Jahres-Band</span><span style="color:#9a9a96">Zeitstrahl</span></div>
|
||||
<div class="sb">
|
||||
<div style="position:relative;padding-left:20px">
|
||||
<div style="position:absolute;left:6px;top:2px;bottom:2px;width:2px;background:linear-gradient(#a1dcd8,#012851)"></div>
|
||||
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;margin-bottom:8px">1915</div>
|
||||
<div style="background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:7px 9px;margin-bottom:8px"><div style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</div><div style="font-size:8px;color:#9a9a96">Monats-Dichte ▾</div></div>
|
||||
<button style="display:inline-flex;align-items:center;gap:5px;border:1px dashed #a1dcd8;background:#fff;border-radius:6px;padding:5px 11px;font-size:10px;font-weight:600;color:#012851">+ Ereignis aus diesem Jahr anlegen</button>
|
||||
</div>
|
||||
<div class="cap">Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr & Briefe des Bandes vorbefüllt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ══ 4 · QUICK ACTION IN DOCUMENT DETAIL ══ -->
|
||||
<div class="sh">
|
||||
<h2>4 · Quick-Action im Dokument-Detail — wo sie lebt</h2>
|
||||
<p>Die Dokument-Detailseite ist ein <b>vollflächiger Viewer ohne Sidebar</b> (<code>fixed inset</code>). Aktions-Flächen gibt es nur zwei: die <code>DocumentTopBar</code> und den aufklappbaren <b>Details-Drawer</b>. Die Quick-Action lebt an beiden — primär als <b>„Zeitstrahl"-Spalte im Drawer</b> (spiegelt die Geschichten-Spalte), plus ein <b>Top-Bar-Button</b> für den Ein-Klick-Weg.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout navy"><strong>Warum der Details-Drawer der richtige Ort ist:</strong> Er zeigt heute schon, <i>wozu ein Brief gehört</i> — Personen, Schlagwörter und <b>Geschichten</b> (mit „Zuordnen"-Aktion, gegated über <code>canBlogWrite</code>, <code>DocumentMetadataDrawer.svelte:210</code>). Zeitstrahl-Ereignisse sind strukturell identisch („dieser Brief gehört zu diesen Ereignissen") und bekommen daher eine gleichwertige vierte/fünfte Spalte. Konsistent & auffindbar dort, wo Nutzer ohnehin „Zugehörigkeit" suchen.</div>
|
||||
|
||||
<div class="dchrome" style="margin-bottom:16px">
|
||||
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
|
||||
<!-- document topbar -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;box-shadow:0 1px 3px rgba(0,0,0,.05);display:flex;align-items:center;height:54px">
|
||||
<div style="width:3px;height:100%;background:#012851"></div>
|
||||
<div style="width:34px;display:flex;justify-content:center;color:#6b7280;font-size:14px">‹</div>
|
||||
<div style="width:1px;height:22px;background:#e4e2d7;margin:0 6px"></div>
|
||||
<div style="flex:0 1 auto;min-width:0;padding-right:10px">
|
||||
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Brief über die Lage an der Westfront</div>
|
||||
<div style="font-size:8.5px;color:#6b7280">März 1915</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;margin-left:6px"><span style="width:20px;height:20px;border-radius:50%;background:#012851;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-size:9px;color:#9a9a96;align-self:center">→</span><span style="width:20px;height:20px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">ER</span></div>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:7px;padding-right:14px">
|
||||
<!-- Details toggle (active) -->
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;background:#012851;color:#fff;border-radius:5px;font-size:10px;font-weight:600;padding:6px 11px">Details ▴</span>
|
||||
<div style="width:1px;height:22px;background:#e4e2d7"></div>
|
||||
<!-- action buttons -->
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;font-weight:600;color:#012851;padding:6px 11px">✎ Transkribieren</span>
|
||||
<!-- NEW: Zeitstrahl quick button -->
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;background:#a1dcd8;color:#012851;border-radius:5px;font-size:10px;font-weight:700;padding:6px 11px">⊕ Zeitstrahl</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;color:#012851;padding:6px 9px">⤓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata drawer (opened) -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:20px 24px">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px">
|
||||
<!-- Details -->
|
||||
<div>
|
||||
<div class="lbl">Details</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Datum</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">März 1915</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Ort</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">Westfront</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Status</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851">Transkribiert</div>
|
||||
</div>
|
||||
<!-- Personen -->
|
||||
<div>
|
||||
<div class="lbl">Personen</div>
|
||||
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px"><span style="width:26px;height:26px;border-radius:50%;background:#012851;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Karl Raddatz</span></div>
|
||||
<div style="display:flex;align-items:center;gap:7px"><span style="width:26px;height:26px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">ER</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Elfriede Raddatz</span></div>
|
||||
</div>
|
||||
<!-- Schlagwörter -->
|
||||
<div>
|
||||
<div class="lbl">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px"><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Krieg</span><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Briefe von der Front</span></div>
|
||||
</div>
|
||||
<!-- NEW: Zeitstrahl column -->
|
||||
<div style="border-left:2px solid #eef6f5;padding-left:18px;margin-left:-6px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px">
|
||||
<span class="lbl" style="margin:0;color:#012851">Zeitstrahl</span>
|
||||
<span style="font-size:9px;font-weight:600;color:#6b7280">+ Zuordnen</span>
|
||||
</div>
|
||||
<!-- linked event -->
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px;margin-bottom:8px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="color:#9a9a96;font-size:11px">×</span></div>
|
||||
<div style="font-size:8px;color:#9a9a96;margin-top:1px">1915 · 24 Briefe · persönlich</div>
|
||||
<span class="tagchip" style="margin-top:5px"><i></i>Krieg</span>
|
||||
</div>
|
||||
<!-- quick add row -->
|
||||
<div style="display:flex;gap:6px">
|
||||
<span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:9px;color:#9a9a96;padding:5px 8px">Ereignis suchen …</span>
|
||||
<span style="background:#012851;color:#fff;border-radius:4px;font-size:9px;font-weight:600;padding:5px 8px;white-space:nowrap">+ Neu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:120px;background:repeating-linear-gradient(45deg,#ececec,#ececec 8px,#e4e4e4 8px,#e4e4e4 16px);display:flex;align-items:center;justify-content:center;color:#aaa;font-size:10px">↓ PDF-Viewer (Brief-Scan) …</div>
|
||||
</div>
|
||||
|
||||
<div class="annogrid">
|
||||
<div class="anno"><span class="n">A</span><span><b>Top-Bar-Button „⊕ Zeitstrahl"</b> — Mint-Akzent im Aktions-Cluster (<code>DocumentTopBarActions</code>). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.</span></div>
|
||||
<div class="anno"><span class="n">B</span><span><b>„Zeitstrahl"-Spalte im Details-Drawer</b> — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über <code>×</code>, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei <code>canWrite</code>.</span></div>
|
||||
<div class="anno"><span class="n">C</span><span><b>Quick-Add-Zeile</b> — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + <b>„+ Neu"</b>.</span></div>
|
||||
<div class="anno"><span class="n">D</span><span><b>„+ Neu"</b> → <code>/zeitstrahl/events/new?documentId={id}</code> — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt <code>/geschichten/new?documentId=</code>.</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 5 · QUICK-ADD STATES ══ -->
|
||||
<div class="sh"><h2>5 · Quick-Action — Zustände</h2><p>Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie <code>DocumentMultiSelect</code>, nur sucht es Ereignisse statt Dokumente.</p></div>
|
||||
|
||||
<div class="states" style="grid-template-columns:repeat(2,1fr)">
|
||||
<div class="state">
|
||||
<div class="sh2"><span>A · Nicht zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="font-size:10px;color:#9a9a96;font-style:italic;margin-bottom:9px">Noch keinem Ereignis zugeordnet.</div>
|
||||
<div style="display:flex;gap:6px"><span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:10px;color:#9a9a96;padding:6px 9px">Ereignis suchen …</span><span class="btn primary" style="height:30px;font-size:10px;padding:0 11px">+ Neu</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>B · Suche — Treffer</span></div>
|
||||
<div class="sb">
|
||||
<div style="border:1px solid #012851;border-radius:4px;font-size:10px;color:#012851;padding:6px 9px;margin-bottom:0">Front▏</div>
|
||||
<div class="dd">
|
||||
<div class="opt hl">Briefe von der Front <span class="d">· 1915 · 24 Briefe</span></div>
|
||||
<div class="opt">Kriegsausbruch <span class="d">· 1914 · 6 Briefe</span></div>
|
||||
<div class="opt" style="color:#012851;font-weight:600">+ „Front" als neues Ereignis anlegen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>C · Zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px"><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#2e7d57">✓ verknüpft <span style="color:#9a9a96">×</span></span></div><span class="tagchip" style="margin-top:5px"><i></i>Krieg</span></div>
|
||||
<div class="cap">Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", <code style="font-size:10px">aria-live</code>. Unlink über <code>×</code> (DELETE).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>D · Mehrfach zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="display:flex;flex-direction:column;gap:5px">
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#9a9a96">×</span></div>
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Weihnachten 1915</span><span style="font-size:9px;color:#9a9a96">×</span></div>
|
||||
</div>
|
||||
<div class="cap">Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 6 · TOKENS ══ -->
|
||||
<div class="sh"><h2>6 · Wiederverwendete Bausteine & Tokens</h2></div>
|
||||
<div class="callout mint"><strong>Maximal wiederverwenden:</strong> <code>DocumentMultiSelect</code> (Brief-Gruppierung, unverändert) · <code>PersonMultiSelect</code> (Beteiligte) · <code>GeschichteEditor</code>-Layout (zwei Spalten, Sticky-Save, <code>beforeNavigate</code>) · <code>DocumentMetadataDrawer</code>-Spaltenmuster (Quick-Action) · <code>useUnsavedWarning</code> · <code>formatDocumentDate</code> / <code>DatePrecision</code>. Brand-Tokens wie im Zeitstrahl-Spec: Navy <code>#012851</code>, Mint <code>#a1dcd8</code>, Linie <code>#e4e2d7</code>, ink-3 <code>#6b7280</code>, danger <code>#c0392b</code>; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).</div>
|
||||
|
||||
|
||||
<!-- ══ 7 · IMPL-REF ══ -->
|
||||
<div class="sh"><h2>7 · Implementierungs-Referenz & Barrierefreiheit</h2></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Baustein</th><th>Datei / Endpoint</th><th>Verantwortung</th></tr>
|
||||
<tr><td>Editor-Route (neu)</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td><code>+page.server.ts</code> (Form-Actions, <code>WRITE_ALL</code>) + <code>+page.svelte</code>; <code>?documentId=</code> vorbefüllt Feld ⑤</td></tr>
|
||||
<tr><td>Editor-Komponente (neu)</td><td><code>TimelineEventEditor.svelte</code></td><td>Spiegelt <code>GeschichteEditor</code>: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; <code>beforeNavigate</code></td></tr>
|
||||
<tr><td>Brief-Gruppierung (reuse)</td><td><code>DocumentMultiSelect.svelte</code></td><td>Unverändert — Typeahead <code>/api/documents/search</code>, Chips, Hidden-Inputs <code>documentIds</code></td></tr>
|
||||
<tr><td>Personen (reuse)</td><td><code>PersonMultiSelect.svelte</code></td><td>Unverändert — Beteiligte Personen</td></tr>
|
||||
<tr><td>Datum + Präzision</td><td><code>DatePrecisionInput</code> (geteilt)</td><td>Wie Dokument-Datum (<code>metaDatePrecision</code>); „Zeitspanne" → End-Datum; <code>formatDocumentDate</code> fürs Label</td></tr>
|
||||
<tr><td>Quick-Action-Spalte (neu)</td><td><code>DocumentTimelineColumn.svelte</code></td><td>Im <code>DocumentMetadataDrawer</code> neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei <code>canWrite</code></td></tr>
|
||||
<tr><td>Quick-Add-Picker (neu)</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Ereignis-Typeahead; sofort verlinken oder <code>?documentId=</code> zum Editor; auch im Top-Bar-Popover</td></tr>
|
||||
<tr><td>Top-Bar-Button (neu)</td><td><code>DocumentTopBarActions</code> · <code>DocumentMobileMenu</code></td><td>„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover</td></tr>
|
||||
<tr><td>Backend — CRUD</td><td><code>POST · PUT · DELETE /api/timeline/events</code></td><td><code>TimelineEventController</code>, <code>WRITE_ALL</code>; <code>TimelineEventRequest</code> mit <code>documentIds</code> / <code>personIds</code></td></tr>
|
||||
<tr><td>Backend — Link/Unlink</td><td><code>PUT /api/timeline/events/{id}</code></td><td>Verlinken/Lösen läuft über das Event-Update (<code>documents</code>-Set); kein neuer ErrorCode nötig</td></tr>
|
||||
<tr><td>Barrierefreiheit</td><td>—</td><td>Picker-Dropdowns Tastatur-navigierbar (↑↓↵), <code>aria-live</code> für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="note">Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: <b>B als MVP</b> (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
391
docs/specs/zeitstrahl-final-spec.html
Normal file
391
docs/specs/zeitstrahl-final-spec.html
Normal file
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* Masthead */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
|
||||
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:8px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
|
||||
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tg.mint{background:#a1dcd8;color:#012851}
|
||||
.tg.slate{background:#607080;color:#e8edf2}
|
||||
|
||||
.sh{margin:60px 0 24px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
|
||||
.sh h2{font-size:17px;font-weight:900;color:#012851}
|
||||
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:780px;line-height:1.65}
|
||||
|
||||
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
|
||||
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
|
||||
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
|
||||
.callout strong{font-weight:800;color:#012851}
|
||||
|
||||
/* legend */
|
||||
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.lg{background:#fff;border:1px solid #E0DDD6;border-radius:7px;padding:12px 14px;display:flex;gap:11px}
|
||||
.lg .ico{flex-shrink:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px}
|
||||
.lg .ttl{font-size:10.5px;font-weight:800;color:#012851;margin-bottom:2px}
|
||||
.lg .body{font-size:9.5px;color:#5a5a56;line-height:1.5}
|
||||
.lg .body code{font-size:8.5px;background:#F0EFE9;padding:1px 3px;border-radius:2px;color:#444}
|
||||
|
||||
/* precision table */
|
||||
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.rules table{width:100%;border-collapse:collapse}
|
||||
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||
.rules td{font-size:11px;color:#444;padding:7px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.5}
|
||||
.rules tr:last-child td{border-bottom:none}
|
||||
.rules td:first-child{font-weight:700;color:#012851;width:110px}
|
||||
.rules td code{font-size:10px;background:#F0EFE9;padding:1px 5px;border-radius:2px;color:#444}
|
||||
.rules .ex{font-family:'Tinos',serif;font-size:13px;color:#012851}
|
||||
|
||||
/* ── Timeline atoms (Konzept A) ── */
|
||||
.tl-canvas{background:#f0efe9;border:1.5px solid #E0DDD6;border-radius:10px;padding:24px 26px 30px}
|
||||
.dh{font-family:'Tinos',serif;font-size:20px;font-weight:700;color:#012851}
|
||||
.dh-sub{font-size:9.5px;color:#7a7a76;margin-bottom:20px}
|
||||
|
||||
/* desktop centered axis */
|
||||
.axis{position:relative;max-width:780px;margin:0 auto;padding:4px 0}
|
||||
.axis::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2.5px;background:linear-gradient(#a1dcd8,#012851,#607080);transform:translateX(-50%)}
|
||||
.ybadge{text-align:center;position:relative;margin:4px 0 14px}
|
||||
.ybadge span{background:#012851;color:#fff;font-family:'Tinos',serif;font-size:13px;font-weight:700;padding:2px 15px;border-radius:12px;position:relative;z-index:2}
|
||||
.pill{text-align:center;position:relative;margin-bottom:15px}
|
||||
.pill .inner{display:inline-flex;align-items:center;gap:9px;background:#fff;border:1.5px solid #012851;border-radius:22px;padding:5px 16px 5px 5px;position:relative;z-index:2;box-shadow:0 2px 7px rgba(1,40,81,.12)}
|
||||
.pill.curated .inner{border-color:#a1dcd8;border-width:2px}
|
||||
.pill .gly{width:28px;height:28px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.pill.curated .gly{background:#a1dcd8;color:#012851}
|
||||
.pill .tx{text-align:left}
|
||||
.pill .tx .t{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;display:block;line-height:1.15}
|
||||
.pill .tx .s{font-size:8.5px;color:#8a8a86}
|
||||
.wband{position:relative;margin:0 -26px 15px;padding:7px 26px;background:#EEEBE2;border-top:1px solid #ddd8cc;border-bottom:1px solid #ddd8cc;text-align:center}
|
||||
.wband .t{font-family:'Tinos',serif;font-style:italic;font-size:12px;color:#5a6776}
|
||||
.wband .s{font-size:8.5px;color:#8a8a86;margin-left:8px}
|
||||
.lrow{display:flex;align-items:flex-start;margin-bottom:12px}
|
||||
.lrow .half{flex:1}
|
||||
.lrow .dot{width:13px;height:13px;border-radius:50%;background:#fff;border:2.5px solid #a1dcd8;margin-top:9px;flex-shrink:0;z-index:2;position:relative}
|
||||
.lrow .a{padding-right:26px;text-align:right}
|
||||
.lrow .b{padding-left:26px;text-align:left}
|
||||
.lcard{display:inline-block;text-align:left;background:#fff;border:1px solid #e4e2d7;border-radius:5px;padding:8px 11px;box-shadow:0 1px 3px rgba(0,0,0,.05);max-width:300px}
|
||||
.lcard.ev{border-left:3px solid #a1dcd8}
|
||||
.lcard .t{font-size:11px;font-weight:700;color:#1A1A1A}
|
||||
.lcard.ev .t{font-family:'Tinos',serif}
|
||||
.lcard .m{font-size:8.5px;color:#9a9a96;margin-top:1px}
|
||||
|
||||
.chip{display:inline-flex;align-items:center;gap:3px;font-size:7.5px;border-radius:9px;padding:2px 7px;margin-top:5px}
|
||||
.chip i{width:6px;height:6px;border-radius:2px;display:inline-block;flex-shrink:0}
|
||||
.chip.krieg{color:#a0522d;background:#f6ece6}.chip.krieg i{background:#a0522d}
|
||||
.chip.weih{color:#c17a00;background:#fbf3e3}.chip.weih i{background:#c17a00}
|
||||
.chip.fam{color:#5a8a6a;background:#eaf1ec}.chip.fam i{background:#5a8a6a}
|
||||
|
||||
/* dense aggregate strip */
|
||||
.strip{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;background:#fff;border:1px solid #e4e2d7;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.05);padding:9px 13px}
|
||||
.strip .hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
||||
.strip .ct{font-size:10.5px;font-weight:700;color:#012851}
|
||||
.strip .ex{font-size:8px;color:#8a8a86}
|
||||
.spark{display:flex;align-items:flex-end;gap:1.5px;height:30px}
|
||||
.spark div{flex:1;background:#a1dcd8;border-radius:1px;min-height:1px}
|
||||
.strip .axl{display:flex;justify-content:space-between;margin-top:3px}
|
||||
.strip .axl span{font-size:6px;color:#bbb}
|
||||
|
||||
/* compressed gap */
|
||||
.gap{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;display:flex;align-items:center;gap:9px;padding:5px 14px;border:1px dashed #cfccc4;border-radius:18px;background:#f0efe9;color:#9a958c;font-size:9px;font-style:italic}
|
||||
.gap .ln{flex:1;height:1px;background:#ddd8cc}
|
||||
.gap b{color:#7a756c;font-style:normal;font-family:'Tinos',serif;font-size:10px}
|
||||
|
||||
/* undated bucket */
|
||||
.undated{max-width:540px;margin:20px auto 0;background:#fff;border:1px dashed #c4c0ba;border-radius:6px;padding:12px 15px}
|
||||
.undated .h{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#7a756c;margin-bottom:6px}
|
||||
|
||||
/* case tag floating */
|
||||
.casetag{display:inline-block;background:#012851;color:#a1dcd8;font-size:7px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-bottom:6px}
|
||||
|
||||
/* narrow (phone / rail) column */
|
||||
.col3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.modehd{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#012851;margin-bottom:4px;display:flex;align-items:center;gap:6px}
|
||||
.modehd .seg{background:#012851;color:#fff;font-size:7px;padding:2px 7px;border-radius:4px}
|
||||
.modesub{font-size:9.5px;color:#888;font-style:italic;margin-bottom:9px;line-height:1.45;min-height:42px}
|
||||
.nf{background:#fff;border:1px solid #e4e2d7;border-radius:6px;padding:14px}
|
||||
.nsp{position:relative;padding-left:21px}
|
||||
.nsp::before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:linear-gradient(#a1dcd8,#012851,#607080)}
|
||||
.nyr{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;margin:2px 0 7px;position:relative}
|
||||
.nyr::before{content:"";position:absolute;left:-14px;top:4px;width:8px;height:8px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851}
|
||||
.nnode{position:relative;margin-bottom:9px}
|
||||
.nnode .g{position:absolute;left:-18px;top:0;width:14px;height:14px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851;color:#a1dcd8;font-size:8px;display:flex;align-items:center;justify-content:center}
|
||||
.nnode .lt{font-family:'Tinos',serif;font-size:10.5px;font-weight:700;color:#012851;line-height:1.2}
|
||||
.nnode .lm{font-size:7.5px;color:#8a8a86}
|
||||
.nwb{position:relative;margin:0 0 9px -9px;padding:5px 8px;background:#EEEBE2;border-left:2px solid #607080;border-radius:0 3px 3px 0}
|
||||
.nwb .t{font-family:'Tinos',serif;font-style:italic;font-size:9.5px;color:#5a6776}
|
||||
.nwb .s{font-size:7px;color:#8a8a86}
|
||||
.nletter{position:relative;margin-bottom:7px}
|
||||
.nletter .d{position:absolute;left:-15px;top:3px;width:6px;height:6px;border-radius:50%;background:#fff;border:1.5px solid #a1dcd8}
|
||||
.ncard{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:5px 8px}
|
||||
.ncard .t{font-size:9px;font-weight:700;color:#1A1A1A}
|
||||
.ncard .m{font-size:7px;color:#9a9a96}
|
||||
.nstrip{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:6px 8px;margin-bottom:7px}
|
||||
.nbucket{border:1px solid #e4e2d7;border-radius:4px;padding:5px 8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.impl-ref table{width:100%;border-collapse:collapse}
|
||||
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
|
||||
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
|
||||
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:165px}
|
||||
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
|
||||
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:-1px;margin-right:5px;border:1px solid rgba(0,0,0,.12)}
|
||||
|
||||
hr{border:none;border-top:2px dashed #C8C4BE;margin:50px 0}
|
||||
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ══ MASTHEAD ══ -->
|
||||
<div class="mh">
|
||||
<h1>Globaler Zeitstrahl — Finale Spezifikation</h1>
|
||||
<p>Kanonische Spezifikation für <code style="font-family:monospace;font-size:12px">/zeitstrahl</code> auf Basis von <strong>Konzept A „Der Lebensfaden"</strong>: eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.</p>
|
||||
<div class="tag-row">
|
||||
<span class="tg">Milestone #14 · Zeitstrahl</span>
|
||||
<span class="tg mint">Konzept A — final</span>
|
||||
<span class="tg slate">Phone-first · honest DatePrecision</span>
|
||||
</div>
|
||||
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · ersetzt die A/B/C-Exploration zeitstrahl-global-concepts.html</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 1 · ANATOMIE ══ -->
|
||||
<div class="sh">
|
||||
<h2>1 · Anatomie von Konzept A</h2>
|
||||
<p>Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse & Jahre als zentrierte Pillen <i>unterbrechen</i> den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.</p>
|
||||
</div>
|
||||
|
||||
<div class="legend" style="margin-bottom:16px">
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8">⚭</div><div><div class="ttl">Lebensereignis-Pille</div><div class="body">Geburt <b>*</b> · Tod <b>†</b> · Heirat <b>⚭</b>. Abgeleitet aus <code>Person</code>-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus <code>personLifeDates.ts</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8;color:#012851">★</div><div><div class="ttl">Kuratierte Ereignis-Pille</div><div class="body"><code>PERSONAL</code> — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#EEEBE2;color:#607080;border:1px solid #607080">◍</div><div><div class="ttl">Welt-Band</div><div class="body"><code>HISTORICAL</code> — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8"></div><div><div class="ttl">Einzel-Brief</div><div class="body">Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu <code>/documents/[id]</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#a1dcd8;color:#012851;font-size:11px">▭</div><div><div class="ttl">Jahres-Strip</div><div class="body">Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. <code>MonthBucket</code> / <code>aggregateToYears</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#f0efe9;border:1px dashed #c4c0ba;color:#9a958c;font-size:11px">⋯</div><div><div class="ttl">Lücke & Ohne-Datum</div><div class="body">Ruhige/leere Jahre als dünne Span-Zeile gefaltet; <code>UNKNOWN</code>-Briefe im „Ohne Datum"-Eimer am Ende.</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="callout navy"><strong>Gruppierungs-Umschalter</strong> (oben rechts im Zeitstrahl): <span style="display:inline-flex;border:1.5px solid #012851;border-radius:5px;overflow:hidden;vertical-align:middle;margin:0 4px"><span style="background:#012851;color:#fff;font-size:9px;font-weight:700;padding:3px 11px">Datum</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Ereignis</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Thema</span></span> steuert <b>nur, wie lose Briefe gebündelt werden</b>. Lebensereignisse, kuratierte Ereignisse und Welt-Bänder bleiben in allen Modi gleich auf der Achse. Standard = <b>Datum</b>.</div>
|
||||
|
||||
|
||||
<!-- ══ 2 · GROUPING MODES ══ -->
|
||||
<div class="sh">
|
||||
<h2>2 · Die drei Gruppierungs-Modi</h2>
|
||||
<p>Gleicher Ausschnitt (1914–1915), dreimal gerendert. Nur die <b>losen Briefe</b> ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.</p>
|
||||
</div>
|
||||
|
||||
<div class="col3">
|
||||
|
||||
<!-- MODE: DATUM -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Datum</span> Chronologisch</div>
|
||||
<div class="modesub">Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard"><div class="t">✉ Kriegsausbruch — Brief an die Familie</div><div class="m">Karl → Elfriede · 4. Aug 1914</div></div></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nstrip">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px"><span style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</span><span style="font-size:7px;color:#8a8a86">Monate ▾</span></div>
|
||||
<div style="display:flex;align-items:flex-end;gap:1.5px;height:20px"><div style="flex:1;height:20%;background:#a1dcd8"></div><div style="flex:1;height:35%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:70%;background:#a1dcd8"></div><div style="flex:1;height:60%;background:#a1dcd8"></div><div style="flex:1;height:85%;background:#a1dcd8"></div><div style="flex:1;height:100%;background:#a1dcd8"></div><div style="flex:1;height:88%;background:#a1dcd8"></div><div style="flex:1;height:72%;background:#a1dcd8"></div><div style="flex:1;height:48%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:40%;background:#a1dcd8"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE: EREIGNIS -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Ereignis</span> Kuratiert</div>
|
||||
<div class="modesub">Briefe bündeln unter kuratierte Ereignisse (<code>TimelineEvent.documents</code>). Erzählende Cluster statt Listen.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Briefe von der Front · 24</div><div class="m">Karl ⇄ Elfriede & Hans · 1915 ▾</div><span class="chip krieg" style="margin-top:4px"><i></i>Krieg</span></div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Weihnachten 1915 · 3</div><div class="m">kuratiertes Ereignis ▾</div><span class="chip weih" style="margin-top:4px"><i></i>Weihnachten</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE: THEMA -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Thema</span> Nach Wurzel-Tag</div>
|
||||
<div class="modesub">Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg</span><span style="font-size:8px;color:#8a8a86">6 ▾</span></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg › Briefe von der Front</span><span style="font-size:8px;color:#8a8a86">24 ▾</span></div>
|
||||
<div class="nbucket"><span class="chip weih" style="margin:0"><i></i>Weihnachten</span><span style="font-size:8px;color:#8a8a86">3 ▾</span></div>
|
||||
<div class="nbucket"><span class="chip fam" style="margin:0"><i></i>Familie</span><span style="font-size:8px;color:#8a8a86">2 ▾</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note" style="margin-top:8px">Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 3 · ALL CASES PREVIEW ══ -->
|
||||
<div class="sh">
|
||||
<h2>3 · Vollständige Vorschau — alle Dichte-Fälle</h2>
|
||||
<p>Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout navy" style="margin-bottom:18px">
|
||||
<strong>Abgedeckte Fälle:</strong>
|
||||
① leere Jahre (gefaltete Lücke) ·
|
||||
② wenige Briefe ≤ 3 (einzelne Karten) ·
|
||||
③ hunderte Briefe (Jahres-Strip + Sparkline) ·
|
||||
④ kuratiertes Ereignis & Welt-Band ·
|
||||
⑤ ungenaue Präzision (<code>Sommer</code>, <code>ca.</code>) ·
|
||||
⑥ undatierte Briefe (Ohne-Datum-Eimer).
|
||||
</div>
|
||||
|
||||
<div class="tl-canvas">
|
||||
<div class="dh">Zeitstrahl</div>
|
||||
<div class="dh-sub">Die Familie Raddatz · 1899–1950 · 412 Briefe · 38 Ereignisse · <span style="color:#012851">Gruppierung: Datum</span></div>
|
||||
|
||||
<div style="text-align:center;margin-bottom:8px"><span class="casetag">① Leere Jahre → gefaltet</span></div>
|
||||
<div class="axis">
|
||||
|
||||
<!-- ① empty years gap -->
|
||||
<div class="gap"><span class="ln"></span><b>1899 – 1908</b> · keine Einträge<span class="ln"></span></div>
|
||||
|
||||
<!-- ② 3 letters -->
|
||||
<div class="ybadge"><span>1909</span></div>
|
||||
<div style="text-align:center;margin-bottom:4px"><span class="casetag">② Wenige Briefe → einzeln</span></div>
|
||||
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief aus Stettin</div><div class="m">Elfriede → Karl · Mai 1909</div><span class="chip fam"><i></i>Familie</span></div></div><div class="dot"></div><div class="half b"></div></div>
|
||||
<div class="lrow"><div class="half a"></div><div class="dot"></div><div class="half b"><div class="lcard"><div class="t">✉ Geburtstagsgruß</div><div class="m">Karl → Hans · Sep 1909</div></div></div></div>
|
||||
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief zum Jahresende</div><div class="m">Karl → Elfriede · Dez 1909</div><span class="chip weih"><i></i>Weihnachten</span></div></div><div class="dot"></div><div class="half b"></div></div>
|
||||
|
||||
<!-- ④ life event + world band -->
|
||||
<div class="ybadge"><span>1914</span></div>
|
||||
<div class="pill"><span class="inner"><span class="gly">⚭</span><span class="tx"><span class="t">Heirat: Karl & Elfriede Raddatz</span><span class="s">1914 · abgeleitet aus Beziehung</span></span></span></div>
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag">④ Welt-Band (RANGE 1914–1918)</span></div>
|
||||
<div class="wband"><span class="t">◍ Erster Weltkrieg</span><span class="s">1914–1918 · historisch · 187 Briefe in dieser Zeit</span></div>
|
||||
|
||||
<!-- ③ hundreds of letters strip -->
|
||||
<div class="ybadge"><span>1915</span></div>
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag">③ Hunderte Briefe → Jahres-Strip</span> <span class="casetag" style="background:#607080">⑤ „Sommer 1915"</span></div>
|
||||
<div class="pill"><span class="inner"><span class="gly">*</span><span class="tx"><span class="t">Geburt: Hans Raddatz</span><span class="s">Sommer 1915 · abgeleitet · SEASON</span></span></span></div>
|
||||
<div class="strip">
|
||||
<div class="hd"><span class="ct">✉ 187 Briefe</span><span class="ex">Monats-Dichte · antippen → Monate → Briefe ▾</span></div>
|
||||
<div class="spark"><div style="height:22%"></div><div style="height:30%"></div><div style="height:48%"></div><div style="height:66%"></div><div style="height:58%"></div><div style="height:80%"></div><div style="height:100%"></div><div style="height:92%"></div><div style="height:74%"></div><div style="height:50%"></div><div style="height:44%"></div><div style="height:34%"></div></div>
|
||||
<div class="axl"><span>Jan 1915</span><span>Dez 1915</span></div>
|
||||
</div>
|
||||
|
||||
<!-- empty-ish span with some letters collapsed -->
|
||||
<div class="gap"><span class="ln"></span><b>1916 – 1922</b> · Nachkriegsjahre · 96 Briefe<span class="ln"></span></div>
|
||||
|
||||
<!-- ⑤ approx precision world band -->
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag" style="background:#607080">⑤ „ca. 1923" → APPROX</span></div>
|
||||
<div class="wband"><span class="t">◍ Hyperinflation</span><span class="s">ca. 1923 · historisch</span></div>
|
||||
|
||||
<!-- curated personal event -->
|
||||
<div class="ybadge"><span>1924</span></div>
|
||||
<div class="pill curated"><span class="inner"><span class="gly">★</span><span class="tx"><span class="t">Auswanderung nach Argentinien</span><span class="s">Frühjahr 1924 · persönlich · kuratiert</span></span></span></div>
|
||||
|
||||
<!-- tail empty -->
|
||||
<div class="gap"><span class="ln"></span><b>1925 – 1950</b> · keine Einträge<span class="ln"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- ⑥ undated bucket -->
|
||||
<div style="text-align:center;margin:6px 0"><span class="casetag" style="background:#7a756c">⑥ Undatiert → eigener Eimer am Ende</span></div>
|
||||
<div class="undated">
|
||||
<div class="h">Ohne Datum · 11 Briefe</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid #f0eee8"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Brief ohne Jahresangabe</span><span style="font-size:8px;color:#aaa">Präzision UNKNOWN</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Fragment, Absender unklar</span><span style="font-size:8px;color:#aaa">+ 9 weitere ▾</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. <code>RANGE</code>-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.</div>
|
||||
|
||||
|
||||
<!-- ══ 4 · PRECISION ══ -->
|
||||
<div class="sh"><h2>4 · Datums-Präzision (geteilt von Ereignissen & Briefen)</h2><p>Eine Render-Logik für alle datierten Einträge — <code>dateLabel.ts</code>, gespeist von <code>DatePrecision</code>.</p></div>
|
||||
<div class="rules">
|
||||
<table>
|
||||
<tr><th>DatePrecision</th><th>Darstellung</th><th>Beispiel</th><th>Wirkung auf der Achse</th></tr>
|
||||
<tr><td>DAY</td><td>vollständiges Datum</td><td class="ex">28. Juli 1914</td><td>exakte Sortierung im Jahres-Band</td></tr>
|
||||
<tr><td>MONTH</td><td>Monat + Jahr</td><td class="ex">Juli 1914</td><td>Monats-Sortierung</td></tr>
|
||||
<tr><td>SEASON</td><td>Jahreszeit + Jahr</td><td class="ex">Sommer 1914</td><td>grobe Reihung</td></tr>
|
||||
<tr><td>YEAR</td><td>nur Jahr</td><td class="ex">1914</td><td>ans Band-Ende</td></tr>
|
||||
<tr><td>APPROX</td><td>„ca." + Jahr</td><td class="ex">ca. 1914</td><td>mit „ca."-Marker</td></tr>
|
||||
<tr><td>RANGE</td><td>Start–Ende</td><td class="ex">1914–1918</td><td>Start-Jahr, Spannen-Marker, nicht dupliziert</td></tr>
|
||||
<tr><td>UNKNOWN</td><td>undatiert</td><td class="ex">Ohne Datum</td><td>eigener Eimer am Ende</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 5 · RESPONSIVE ══ -->
|
||||
<div class="sh"><h2>5 · Responsiv — eine Komponente, drei Breiten</h2><p>Identisches Markup & identische Daten. Nur die Achs-Position wechselt per Container-Query.</p></div>
|
||||
<div class="legend" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▏</div><div><div class="ttl">≥ 1024px · Desktop</div><div class="body"><b>Zentrale Achse</b>, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▎</div><div><div class="ttl">< 1024px · Phone</div><div class="body"><b>Linke Achse</b>, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<code><ol></code>) — Screenreader liest linear.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▎</div><div><div class="ttl">Lebensweg-Rail · 35%</div><div class="body">Gleiche linke Achse in der Personenseite (<code><TimelineView personId></code>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.</div></div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 6 · TOKENS ══ -->
|
||||
<div class="sh"><h2>6 · Design-Tokens (echte, ausgelieferte Werte)</h2><p>Aus <code>frontend/src/routes/layout.css</code>. Keine Hardcodes in der Komponente.</p></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Rolle</th><th>Token</th><th>Wert</th><th>Einsatz</th></tr>
|
||||
<tr><td>Achse / Knoten / Header</td><td><code>brand-navy</code></td><td><span class="sw" style="background:#012851"></span>#012851</td><td>Spine, Lebensereignis-Pillen, Jahres-Badges, Titel</td></tr>
|
||||
<tr><td>Akzent / Brief-Punkt</td><td><code>brand-mint</code></td><td><span class="sw" style="background:#a1dcd8"></span>#a1dcd8</td><td>Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl</td></tr>
|
||||
<tr><td>Historisch / Welt</td><td><code>tag-slate</code></td><td><span class="sw" style="background:#607080"></span>#607080</td><td>Welt-Bänder & Glyphe ◍ — gedämpft</td></tr>
|
||||
<tr><td>Tag-Chip-Farben</td><td><code>--c-tag-*</code> (Wurzel)</td><td><span class="sw" style="background:#5a8a6a"></span><span class="sw" style="background:#a0522d"></span><span class="sw" style="background:#c17a00"></span><span class="sw" style="background:#7a4f9a"></span></td><td>sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label</td></tr>
|
||||
<tr><td>Seite / Karte / Linie</td><td><code>canvas · surface · line</code></td><td><span class="sw" style="background:#f0efe9"></span><span class="sw" style="background:#fff"></span><span class="sw" style="background:#e4e2d7"></span></td><td>#f0efe9 · #ffffff · #e4e2d7</td></tr>
|
||||
<tr><td>Text sekundär</td><td><code>text-ink-3</code></td><td><span class="sw" style="background:#6b7280"></span>#6b7280</td><td>Meta-Zeilen (4,8:1 auf weiß — AA ✓)</td></tr>
|
||||
<tr><td>Schrift</td><td><code>font-serif · font-sans</code></td><td>Tinos · Montserrat</td><td>Namen/Titel serif · Labels/Chrome sans</td></tr>
|
||||
<tr><td>Lebensdaten-Glyphen</td><td><code>personLifeDates.ts</code></td><td>* † ⚭</td><td>Geburt · Tod · Heirat — konsistent mit Personenkarten</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 7 · IMPL-REF ══ -->
|
||||
<div class="sh"><h2>7 · Implementierungs-Referenz & Barrierefreiheit</h2><p>Domain-Ordner <code>frontend/src/lib/timeline/</code>; Route <code>/zeitstrahl</code>; Backend <code>GET /api/timeline</code>.</p></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Baustein</th><th>Komponente / Datei</th><th>Verantwortung</th></tr>
|
||||
<tr><td>Orchestrator</td><td><code>TimelineView.svelte</code></td><td>Lädt <code>/api/timeline</code>; optionaler <code>personId</code> für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus</td></tr>
|
||||
<tr><td>Jahres-Band</td><td><code>YearBand.svelte</code></td><td>Jahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen</td></tr>
|
||||
<tr><td>Ereignis-Pille</td><td><code>EventCard.svelte</code></td><td>PERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label</td></tr>
|
||||
<tr><td>Brief-Karte</td><td><code>LetterCard.svelte</code></td><td>Einzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link <code>/documents/[id]</code></td></tr>
|
||||
<tr><td>Jahres-Strip</td><td><code>YearLetterStrip.svelte</code></td><td>Adaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus <code>MonthBucket</code> / <code>aggregateToYears</code> (<code>lib/document/timeline.ts</code>)</td></tr>
|
||||
<tr><td>Datums-Helfer</td><td><code>dateLabel.ts</code></td><td><code>DatePrecision</code> → deutsches Label; geteilt von Ereignissen & Briefen</td></tr>
|
||||
<tr><td>Kurator-Editor</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td>Ereignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); <code>WRITE_ALL</code></td></tr>
|
||||
<tr><td>Quick-Add</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Auf <code>/documents/[id]</code>: Ereignis wählen/neu anlegen; verlinkt einen Brief</td></tr>
|
||||
<tr><td>Daten-API</td><td><code>GET /api/timeline</code></td><td>Verschmilzt kuratierte + abgeleitete Ereignisse + Briefe in <code>TimelineDTO</code> (Jahres-Eimer + Ohne-Datum); Filter <code>personId · type · fromYear · toYear</code></td></tr>
|
||||
<tr><td>Barrierefreiheit</td><td>—</td><td>Achse = <code><ol></code>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; <code>prefers-reduced-motion</code>; axe in Light & Dark</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1061
docs/specs/zeitstrahl-global-concepts.html
Normal file
1061
docs/specs/zeitstrahl-global-concepts.html
Normal file
File diff suppressed because it is too large
Load Diff
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
182
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Family Timeline (Zeitstrahl) — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Approved — pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting.
|
||||
|
||||
## Goal
|
||||
|
||||
A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view:
|
||||
|
||||
1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.)
|
||||
2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct.
|
||||
3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them.
|
||||
|
||||
Two surfaces, one component:
|
||||
- **Global timeline** at `/zeitstrahl`.
|
||||
- **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page.
|
||||
|
||||
Built for phones (vertical scroll), honest about date precision, with no fabricated dates.
|
||||
|
||||
### Non-goals (YAGNI)
|
||||
|
||||
- ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy.
|
||||
- ❌ Importing an external historical-events dataset — historical events are hand-entered too.
|
||||
- ❌ A map / geographic view — that is a separate future feature (B2).
|
||||
- ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events.
|
||||
- ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text.
|
||||
|
||||
## Core principle: the year is the axis
|
||||
|
||||
Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore:
|
||||
|
||||
- The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band.
|
||||
- **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have.
|
||||
- **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision.
|
||||
- **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path).
|
||||
|
||||
### Date rendering (shared by events and letters)
|
||||
|
||||
| `DatePrecision` | German render | Example |
|
||||
|---|---|---|
|
||||
| `DAY` | full date | `28. Juli 1914` |
|
||||
| `MONTH` | month + year | `Juli 1914` |
|
||||
| `SEASON` | season + year | `Sommer 1914` |
|
||||
| `YEAR` | year only | `1914` |
|
||||
| `APPROX` | "ca." + year | `ca. 1914` |
|
||||
| `RANGE` | start–end year | `1914–1918` |
|
||||
| `UNKNOWN` | undated bucket | `Ohne Datum` |
|
||||
|
||||
A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers.
|
||||
|
||||
## Data model
|
||||
|
||||
A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750–753).
|
||||
|
||||
### `TimelineEvent` entity
|
||||
|
||||
Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `UUID` | `@GeneratedValue(UUID)` |
|
||||
| `title` | `String` | required |
|
||||
| `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` |
|
||||
| `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) |
|
||||
| `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters |
|
||||
| `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` |
|
||||
| `description` | `TEXT` (nullable) | free-text narrative for the event |
|
||||
| `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering |
|
||||
| `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) |
|
||||
| `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions |
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`).
|
||||
- Collections use `@Builder.Default new HashSet<>()`.
|
||||
- New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables.
|
||||
|
||||
### `EventType` enum
|
||||
|
||||
`PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable.
|
||||
|
||||
### Prerequisite: migrate `Person` birth/death to date + precision
|
||||
|
||||
Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum).
|
||||
|
||||
**Change:** replace `birthYear`/`deathYear` (`Integer`) with:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `birthDate` | `LocalDate` (nullable) | most precise date known |
|
||||
| `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. |
|
||||
| `deathDate` | `LocalDate` (nullable) | |
|
||||
| `deathDatePrecision` | `DatePrecision` (nullable) | |
|
||||
|
||||
**Flyway data migration:** existing `birth_year` → `birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns.
|
||||
|
||||
**Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import.
|
||||
|
||||
**Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector.
|
||||
|
||||
**Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted.
|
||||
|
||||
### Derived person-events (not persisted)
|
||||
|
||||
Assembled on read from the migrated `Person` data; never stored:
|
||||
|
||||
| Source | Derived event | `eventDate` | precision |
|
||||
|---|---|---|---|
|
||||
| `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` |
|
||||
| `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` |
|
||||
| `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` |
|
||||
|
||||
Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules).
|
||||
|
||||
### Letters
|
||||
|
||||
Placed by `Document.documentDate`:
|
||||
- Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket.
|
||||
- Sub-ordered within a band by full date when precision allows.
|
||||
- A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement.
|
||||
|
||||
## Assembly & API
|
||||
|
||||
A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories.
|
||||
|
||||
### DTOs
|
||||
|
||||
- `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events).
|
||||
- `TimelineYearDTO` — `{ year: int, entries: TimelineEntryDTO[] }`.
|
||||
- `TimelineDTO` — `{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`.
|
||||
- `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`.
|
||||
|
||||
Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes.
|
||||
|
||||
## Frontend
|
||||
|
||||
- New domain dir `frontend/src/lib/timeline/`:
|
||||
- `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views.
|
||||
- `YearBand.svelte` — one year section header + its entries.
|
||||
- `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label.
|
||||
- `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`.
|
||||
- `TimelineFilters.svelte` — person, generation, layer toggles, year range.
|
||||
- `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit).
|
||||
- Routes:
|
||||
- `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`).
|
||||
- `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern.
|
||||
- Person detail page gains a **Lebensweg** card section embedding `<TimelineView personId={person.id} />`.
|
||||
- Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens).
|
||||
- i18n keys added to `messages/{de,en,es}.json` (German primary).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup).
|
||||
- Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior.
|
||||
- Follow project test discipline: targeted single-file runs locally only; full sweep left to CI.
|
||||
|
||||
## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline")
|
||||
|
||||
Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events.
|
||||
|
||||
1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own.
|
||||
2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository.
|
||||
3. **Backend: TimelineEvent CRUD API** — `TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
|
||||
4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
|
||||
5. **Backend: timeline assembly endpoint** — `GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
|
||||
6. **Frontend: shared date-label helper + types** — `dateLabel.ts`, regen API types.
|
||||
7. **Frontend: global `/zeitstrahl` view** — `TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
|
||||
8. **Frontend: filters** — `TimelineFilters` (person / generation / layer / year range).
|
||||
9. **Frontend: curator event forms** — `/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
|
||||
10. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
|
||||
11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es).
|
||||
|
||||
> An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.
|
||||
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# spaCy NLP Service — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Prototype
|
||||
|
||||
## Problem
|
||||
|
||||
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 5–15 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
|
||||
|
||||
## Extraction Contract
|
||||
|
||||
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
|
||||
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
|
||||
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
|
||||
| `rawQuery` | `string` | Echo of the input query |
|
||||
|
||||
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
|
||||
|
||||
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
nlp-service/
|
||||
├── main.py # FastAPI app — /parse and /health endpoints
|
||||
├── extractor.py # NLP pipeline: NER → role → dates → keywords
|
||||
├── models.py # Pydantic request/response types
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
|
||||
|
||||
## Extraction Pipeline (`extractor.py`)
|
||||
|
||||
Five steps run in sequence on each query.
|
||||
|
||||
### Step 1 — NER pass
|
||||
|
||||
Run spaCy on the query using the model for the requested language. Collect:
|
||||
- All `PER` spans → candidates for `personNames`
|
||||
- All `DATE` spans → raw text strings for step 3
|
||||
|
||||
### Step 2 — Role detection
|
||||
|
||||
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
|
||||
|
||||
| Language | Sender prepositions | Receiver prepositions |
|
||||
|---|---|---|
|
||||
| `de` | von, vom | an, nach, für |
|
||||
| `en` | from, by | to, for |
|
||||
| `es` | de, por | para, a |
|
||||
|
||||
- One person + sender preposition → `personRole = "sender"`
|
||||
- One person + receiver preposition → `personRole = "receiver"`
|
||||
- One person + no match / two or more persons → `personRole = "any"`
|
||||
|
||||
Two-person queries always return `"any"` — Java derives direction from position.
|
||||
|
||||
### Step 3 — Date parsing
|
||||
|
||||
For each DATE span, inspect the token immediately before the span to detect range direction:
|
||||
|
||||
| Direction token | Effect |
|
||||
|---|---|
|
||||
| vor / before / antes de | Span → `dateTo` |
|
||||
| nach / after / después de | Span → `dateFrom` |
|
||||
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
|
||||
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1–Dec 31) |
|
||||
|
||||
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
|
||||
|
||||
Output as ISO strings (`YYYY-MM-DD`) or `null`.
|
||||
|
||||
### Step 4 — Keyword extraction
|
||||
|
||||
Collect tokens that satisfy all of:
|
||||
- POS tag is `NOUN` or `PROPN`
|
||||
- Not a stopword
|
||||
- Not inside any NER span (PER or DATE)
|
||||
- Lemma length ≥ 3
|
||||
|
||||
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
|
||||
|
||||
Examples:
|
||||
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
|
||||
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
|
||||
|
||||
### Step 5 — Assembly
|
||||
|
||||
```json
|
||||
{
|
||||
"personNames": ["Opa Hermann", "Marie"],
|
||||
"personRole": "any",
|
||||
"dateFrom": null,
|
||||
"dateTo": "1920-12-31",
|
||||
"keywords": ["brief"],
|
||||
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `POST /parse`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
|
||||
```
|
||||
|
||||
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
|
||||
|
||||
**Response:** extraction object as above, HTTP 200.
|
||||
|
||||
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
|
||||
|
||||
## Language Models
|
||||
|
||||
| `lang` | spaCy model |
|
||||
|---|---|
|
||||
| `de` | `de_core_news_sm` |
|
||||
| `en` | `en_core_web_sm` |
|
||||
| `es` | `es_core_news_sm` |
|
||||
|
||||
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Mirrors `ocr-service/` — `python:3.11-slim`, non-root user, models baked into the image:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m spacy download de_core_news_sm \
|
||||
&& python -m spacy download en_core_web_sm \
|
||||
&& python -m spacy download es_core_news_sm
|
||||
COPY . .
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
|
||||
&& chown -R nlp:nlp /app
|
||||
USER nlp
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
```
|
||||
|
||||
Image size: ~350 MB. No volume needed — models live in the image layer.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
cd nlp-service
|
||||
pip install -r requirements.txt
|
||||
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
|
||||
uvicorn main:app --reload --port 8001
|
||||
|
||||
curl -X POST http://localhost:8001/parse \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 1899–1950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
|
||||
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
|
||||
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
|
||||
|
||||
## Out of Scope (prototype)
|
||||
|
||||
- docker-compose integration (Ollama replacement)
|
||||
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient` → `NlParserClient`)
|
||||
- Tag lookup inside the Python service
|
||||
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service
|
||||
@@ -29,7 +29,6 @@ src/
|
||||
│ ├── +page.svelte # Home / document search dashboard
|
||||
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
|
||||
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
||||
│ ├── admin/ # User, group, tag, OCR, system management
|
||||
│ ├── api/ # Internal API proxies (server-side only)
|
||||
@@ -53,7 +52,6 @@ src/
|
||||
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
||||
│ ├── notification/ # Notification bell + dropdown + store
|
||||
│ ├── activity/ # Activity feed (Chronik) components
|
||||
│ ├── conversation/ # Bilateral conversation (Briefwechsel) components
|
||||
│ ├── ocr/ # OCR progress, training cards, trigger
|
||||
│ ├── user/ # User profile/password/groups section components
|
||||
│ ├── shared/ # Cross-domain utilities and primitives
|
||||
|
||||
@@ -25,7 +25,7 @@ frontend/e2e/
|
||||
├── focus-rings.spec.ts # Accessibility focus ring tests
|
||||
├── header.spec.ts # Navigation header
|
||||
├── history.spec.ts # Chronik / activity feed
|
||||
├── korrespondenz.spec.ts # Correspondence timeline
|
||||
├── briefwechsel-removed.spec.ts # Guards that the removed /briefwechsel route 404s
|
||||
├── lang.spec.ts # Language switching
|
||||
├── password-reset.spec.ts # Password reset flow
|
||||
├── permissions.spec.ts # Role-based access control
|
||||
|
||||
@@ -26,7 +26,7 @@ test.describe('Authentication', () => {
|
||||
});
|
||||
|
||||
test('protected routes redirect to /login without session', async ({ page }) => {
|
||||
for (const url of ['/documents/new', '/persons', '/briefwechsel']) {
|
||||
for (const url of ['/documents/new', '/persons']) {
|
||||
await page.goto(url);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedBilateralPair,
|
||||
cleanupBilateralPair,
|
||||
type BilateralPair
|
||||
} from './fixtures/bilateral-correspondence';
|
||||
|
||||
// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds
|
||||
// two persons + a bilateral document via the shared fixture so the page
|
||||
// reaches the results state (not the hero), then runs axe-core
|
||||
// (wcag2a + wcag2aa) across three viewports and two color schemes.
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const;
|
||||
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
let pair: BilateralPair;
|
||||
|
||||
test.describe('Accessibility — /briefwechsel row layout', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
pair = await seedBilateralPair(request, 'A11y');
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupBilateralPair(request, pair);
|
||||
});
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
for (const theme of THEMES) {
|
||||
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||
);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Assert we actually reached the row layout, not the hero — otherwise
|
||||
// the axe sweep silently scans the wrong DOM.
|
||||
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('main')
|
||||
.analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(
|
||||
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
|
||||
);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
15
frontend/e2e/briefwechsel-removed.spec.ts
Normal file
15
frontend/e2e/briefwechsel-removed.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// The standalone Briefwechsel view was removed (its one inbound link now
|
||||
// deep-links into document search). The old URL is allowed to 404 — no
|
||||
// redirect shim. This guard runs in the authenticated project so the route
|
||||
// genuinely 404s on the styled app error page instead of bouncing to /login.
|
||||
test.describe('Briefwechsel view removed', () => {
|
||||
test('/briefwechsel returns 404 on the styled app error page', async ({ page }) => {
|
||||
const response = await page.goto('/briefwechsel');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
// +error.svelte renders the status code prominently.
|
||||
await expect(page.getByText('404')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedBilateralPair,
|
||||
cleanupBilateralPair,
|
||||
type BilateralPair
|
||||
} from './fixtures/bilateral-correspondence';
|
||||
|
||||
// Visual + structural coverage for the new briefwechsel row layout.
|
||||
//
|
||||
// Seeds a bilateral correspondence pair via the shared fixture so the page
|
||||
// reaches the row state. The structural test asserts that a
|
||||
// ConversationThumbnail tile AND the DistributionBar render — regressions
|
||||
// that silently drop to the hero or break the {#each} wiring fail here.
|
||||
//
|
||||
// Snapshot assertions are gated on the VISUAL env flag because they need
|
||||
// pre-captured baselines (see `playwright test --update-snapshots` to
|
||||
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
|
||||
const VISUAL = process.env.VISUAL === '1';
|
||||
|
||||
let pair: BilateralPair;
|
||||
|
||||
test.describe('Briefwechsel — thumbnail-row layout', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
pair = await seedBilateralPair(request, 'Visual');
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupBilateralPair(request, pair);
|
||||
});
|
||||
|
||||
async function openBilateral(page: import('@playwright/test').Page) {
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||
);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
// Parity with the a11y spec: fail loudly if we ever end up on the hero
|
||||
// instead of the row layout.
|
||||
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||
}
|
||||
|
||||
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
|
||||
await openBilateral(page);
|
||||
|
||||
// Tile appears for the seeded document
|
||||
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
|
||||
|
||||
// DistributionBar is present (role=img with a descriptive aria-label)
|
||||
const bar = page.locator('[role="img"]');
|
||||
await expect(bar).toBeVisible();
|
||||
const label = (await bar.getAttribute('aria-label')) ?? '';
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
|
||||
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
|
||||
// unrelated runs; genuine layout changes are still caught because the
|
||||
// thumbnail tile and distribution bar dominate the frame.
|
||||
test.describe('snapshots', () => {
|
||||
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
test(`${viewport.name} / ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await openBilateral(page);
|
||||
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
|
||||
maxDiffPixels: 100,
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
36
frontend/e2e/document-title-autosync.spec.ts
Normal file
36
frontend/e2e/document-title-autosync.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Auto-title sync, full-stack happy path (#726). A document whose stored title equals its
|
||||
* machine-generated auto-title must follow a date correction forward on save; a hand-edit would
|
||||
* be kept. The exhaustive permutations live in the backend unit/integration suites — this is the
|
||||
* single end-to-end pass, and it also asserts the FR-005 helper line is present on the edit form.
|
||||
*/
|
||||
test.describe('Document auto-title sync (#726)', () => {
|
||||
test('editing the date rebuilds the auto-title, and the edit form explains it', async ({
|
||||
page
|
||||
}) => {
|
||||
// 1. Create a document with no date/location, so its stored title == its auto-title
|
||||
// (originalFilename only). createDocument derives originalFilename from the title.
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E Auto-Titel Sync');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
const detailUrl = page.url();
|
||||
|
||||
// 2. The edit form carries the FR-005 helper explaining the auto-generated title.
|
||||
await page.goto(`${detailUrl}/edit`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(page.locator('#title-help')).toBeVisible();
|
||||
|
||||
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
||||
await page.locator('#documentDate').fill('15.01.1928');
|
||||
await page.locator('#metaDatePrecision').selectOption('YEAR');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
// 4. The detail page shows the regenerated title carrying the new year.
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: /E2E Auto-Titel Sync.*1928/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -382,17 +382,32 @@ test.describe('PDF annotations — admin', () => {
|
||||
});
|
||||
// Record count now — the draw test may have created more than one annotation
|
||||
const countBefore = await page.locator('[data-testid^="annotation-"]').count();
|
||||
// Guard against a missing seed: without this, a count of 0 would turn the
|
||||
// post-delete assertion into toHaveCount(-1) and fail with a misleading timeout.
|
||||
expect(countBefore).toBeGreaterThan(0);
|
||||
|
||||
// Enable annotate mode to show delete buttons
|
||||
// Enable annotate mode — deletion is only available while annotating
|
||||
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||
|
||||
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
|
||||
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
||||
await deleteBtn.click();
|
||||
// The on-canvas delete button was removed (issue #722). Delete via the
|
||||
// kept keyboard shortcut: focus an annotation, press Delete, confirm.
|
||||
const annotation = page.locator('[data-testid^="annotation-"]').first();
|
||||
// Capture the identity of the specific annotation we delete so we can assert
|
||||
// that exact element is gone afterwards — a count drop alone is identity-blind.
|
||||
const deletedTestId = await annotation.getAttribute('data-testid');
|
||||
expect(deletedTestId).toBeTruthy();
|
||||
await annotation.click();
|
||||
await annotation.press('Delete');
|
||||
|
||||
await page.getByRole('button', { name: /^bestätigen$/i }).click();
|
||||
|
||||
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, {
|
||||
timeout: 8000
|
||||
});
|
||||
// Identity check: the specific annotation we deleted must no longer exist.
|
||||
await expect(page.locator(`[data-testid="${deletedTestId}"]`)).toHaveCount(0, {
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Test fixture for the briefwechsel row layout.
|
||||
*
|
||||
* Creates two persons and one document with sender/receiver between them so
|
||||
* that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row
|
||||
* state (not the hero). Each seed uses a `Date.now()`-suffixed last name so
|
||||
* parallel runs and reruns never collide.
|
||||
*
|
||||
* The backend does not expose a person-delete endpoint, so only the document
|
||||
* is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons
|
||||
* remain in the DB — acceptable for the test environment, and the unique
|
||||
* suffix means they cannot conflict with later runs.
|
||||
*/
|
||||
|
||||
export interface BilateralPair {
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export async function seedBilateralPair(
|
||||
request: APIRequestContext,
|
||||
prefix: string
|
||||
): Promise<BilateralPair> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const senderRes = await request.post('/api/persons', {
|
||||
data: { firstName: prefix, lastName: `Sender-${timestamp}` }
|
||||
});
|
||||
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
|
||||
const senderId = (await senderRes.json()).id as string;
|
||||
|
||||
const receiverRes = await request.post('/api/persons', {
|
||||
data: { firstName: prefix, lastName: `Receiver-${timestamp}` }
|
||||
});
|
||||
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
|
||||
const receiverId = (await receiverRes.json()).id as string;
|
||||
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: {
|
||||
title: `${prefix} Brief`,
|
||||
documentDate: '1950-06-15',
|
||||
senderId,
|
||||
receiverIds: receiverId
|
||||
}
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const documentId = (await docRes.json()).id as string;
|
||||
|
||||
return { senderId, receiverId, documentId };
|
||||
}
|
||||
|
||||
export async function cleanupBilateralPair(
|
||||
request: APIRequestContext,
|
||||
pair: BilateralPair
|
||||
): Promise<void> {
|
||||
// Only the document is purged — the backend has no person-delete endpoint
|
||||
// and the timestamped last names make orphaned person rows safe to leave.
|
||||
await request.delete(`/api/documents/${pair.documentId}`);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Korrespondenz – empty state', () => {
|
||||
test('shows the search heading when no person is selected', async ({ page }) => {
|
||||
await page.goto('/korrespondenz');
|
||||
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
|
||||
});
|
||||
|
||||
test('nav link goes to /korrespondenz', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Click the nav link (desktop text or mobile icon)
|
||||
const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first();
|
||||
await navLink.click();
|
||||
await expect(page).toHaveURL(/\/korrespondenz/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Korrespondenz – single-person mode', () => {
|
||||
test('shows hint bar and documents when navigated with senderId', async ({ page }) => {
|
||||
// Get a real person ID from the persons list
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
|
||||
// Extract the person ID from the URL
|
||||
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
// Navigate to korrespondenz in single-person mode
|
||||
await page.goto(`/korrespondenz?senderId=${personId}`);
|
||||
|
||||
// Hint bar should be visible
|
||||
await expect(page.getByText(/Alle Briefe von/i)).toBeVisible();
|
||||
|
||||
// Filter controls should be active (not dimmed)
|
||||
const filterStrip = page.locator('[aria-disabled="false"]').first();
|
||||
await expect(filterStrip).toBeAttached();
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
|
||||
});
|
||||
|
||||
test('sort toggle changes URL direction param', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`);
|
||||
await page.getByTestId('conv-sort-btn').click();
|
||||
|
||||
await expect(page).toHaveURL(/dir=ASC/);
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Korrespondenz – bilateral mode', () => {
|
||||
test('shows asymmetry bar when both persons have shared documents', async ({ page }) => {
|
||||
// Navigate to a person then follow a co-correspondent suggestion if available
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
// Try to find a co-correspondent link from the person detail page
|
||||
const corrLink = page
|
||||
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||
.first();
|
||||
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await corrLink.click();
|
||||
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||
|
||||
// Hint bar should NOT be shown in bilateral mode
|
||||
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
|
||||
} else {
|
||||
// E2E seed must include bilateral correspondents — a missing link is a test failure.
|
||||
throw new Error(
|
||||
`No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('swap button swaps sender and receiver in URL', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
const corrLink = page
|
||||
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||
.first();
|
||||
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const href = await corrLink.getAttribute('href');
|
||||
await corrLink.click();
|
||||
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||
|
||||
// Extract original receiverId from the href
|
||||
const url = new URL(href!, 'http://x');
|
||||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
// Click swap
|
||||
await page.getByTestId('conv-swap-btn').click();
|
||||
|
||||
// After swap the former receiver is now senderId
|
||||
await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`));
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' });
|
||||
} else {
|
||||
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,6 @@ import { test, expect } from '@playwright/test';
|
||||
test.describe('Stammbaum — issue #358', () => {
|
||||
test.skip();
|
||||
|
||||
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
|
||||
// Plan journey 4: the /briefwechsel route must stay intact even though the
|
||||
// AppNav now points at /stammbaum.
|
||||
const response = await page.goto('/briefwechsel');
|
||||
expect(response?.status()).toBeLessThan(400);
|
||||
await expect(page).toHaveURL(/\/briefwechsel/);
|
||||
});
|
||||
|
||||
test('/stammbaum renders the page heading', async ({ page }) => {
|
||||
await page.goto('/stammbaum');
|
||||
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
|
||||
|
||||
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal file
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { AxeBuilder } from '@axe-core/playwright';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
/**
|
||||
* E2E tests for the transcribe keyboard shortcuts + cheatsheet overlay — #327.
|
||||
*
|
||||
* Strategy mirrors annotations.spec: seed a document with two transcription
|
||||
* blocks via API in beforeAll (no OCR, no manual drawing), then drive the
|
||||
* keyboard. j/k navigation is exercised in read mode so no editable can trap
|
||||
* focus — the active region's resize overlay renders regardless of read/edit.
|
||||
*/
|
||||
|
||||
const RESIZE_AREA_LABEL = 'Annotationsgröße und -position ändern';
|
||||
|
||||
let docHref: string;
|
||||
let docId: string;
|
||||
let annotAId: string;
|
||||
let annotBId: string;
|
||||
|
||||
test.describe('Transcribe keyboard shortcuts', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Shortcuts Test', documentDate: '1945-05-08' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Erste Zeile.',
|
||||
label: 'Anrede'
|
||||
}
|
||||
});
|
||||
if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`);
|
||||
annotAId = (await blockARes.json()).annotationId;
|
||||
|
||||
const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.35,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Zweite Zeile.',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`);
|
||||
annotBId = (await blockBRes.json()).annotationId;
|
||||
});
|
||||
|
||||
async function openTranscribe(page: Page) {
|
||||
await page.goto(docHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await page.locator('.tabular-nums').waitFor({ timeout: 15_000 });
|
||||
await page.locator(`[data-testid="annotation-${annotAId}"]`).waitFor({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
function activeRegionOverlay(page: Page, annotationId: string) {
|
||||
return page.locator(`[data-testid="annotation-${annotationId}"]`).getByLabel(RESIZE_AREA_LABEL);
|
||||
}
|
||||
|
||||
test('? opens the cheatsheet; Esc closes it, then a second Esc closes the panel', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(30_000);
|
||||
await openTranscribe(page);
|
||||
|
||||
await page.keyboard.press('?');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Tastaturkürzel' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Panel still open after closing only the cheatsheet (Esc ladder rung 1).
|
||||
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('e toggles between read and edit mode', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await openTranscribe(page);
|
||||
|
||||
// The "mark for training" section renders only in the edit view.
|
||||
const editMarker = page.getByText('Für Training vormerken');
|
||||
|
||||
// Default for a writer with existing blocks is read mode.
|
||||
await expect(editMarker).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await expect(editMarker).toBeVisible();
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await expect(editMarker).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('j and k walk forward and back through the regions', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await openTranscribe(page);
|
||||
|
||||
await page.keyboard.press('j');
|
||||
await expect(activeRegionOverlay(page, annotAId)).toBeVisible();
|
||||
|
||||
await page.keyboard.press('j');
|
||||
await expect(activeRegionOverlay(page, annotBId)).toBeVisible();
|
||||
await expect(activeRegionOverlay(page, annotAId)).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('k');
|
||||
await expect(activeRegionOverlay(page, annotAId)).toBeVisible();
|
||||
});
|
||||
|
||||
test('the open cheatsheet has no critical accessibility violations', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await openTranscribe(page);
|
||||
|
||||
await page.keyboard.press('?');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include('dialog')
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze();
|
||||
const critical = results.violations.filter((v) => v.impact === 'critical');
|
||||
expect(critical).toEqual([]);
|
||||
|
||||
// The dialog exposes a modal role with an accessible name (labelled heading).
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,11 @@ export default defineConfig(
|
||||
// defense (the CI regex stays as a backstop). For any legitimate use (e.g.
|
||||
// trusted server-rendered Markdown), suppress with an inline
|
||||
// `<!-- eslint-disable-next-line svelte/no-at-html-tags -->` and a justification.
|
||||
'svelte/no-at-html-tags': 'error'
|
||||
'svelte/no-at-html-tags': 'error',
|
||||
// Reverse-tabnabbing (CWE-1022): any `target="_blank"` anchor must carry
|
||||
// `rel="noopener noreferrer"`, or the opened page can hijack window.opener.
|
||||
// Catches the pattern at lint time instead of relying on review. See #708.
|
||||
'svelte/no-target-blank': ['error', { allowReferrer: false, enforceDynamicLinks: 'always' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
"nav_persons": "Personen",
|
||||
"nav_conversations": "Briefwechsel",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
"layout_menu_open": "Menü öffnen",
|
||||
@@ -57,6 +56,7 @@
|
||||
"form_label_sender": "Absender",
|
||||
"form_label_receivers": "Empfänger",
|
||||
"form_label_title": "Titel",
|
||||
"form_helper_title_autogenerated": "Wird automatisch aus Datum und Ort gebildet — sobald du den Titel änderst, bleibt deine Version erhalten.",
|
||||
"form_label_tags": "Schlagworte",
|
||||
"form_label_content": "Inhalt",
|
||||
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
|
||||
@@ -126,7 +126,7 @@
|
||||
"doc_label_summary": "Zusammenfassung",
|
||||
"doc_loading": "Lade Dokument...",
|
||||
"doc_download_link": "Direkter Download versuchen",
|
||||
"doc_render_failed": "Dieser Scan konnte nicht angezeigt werden.",
|
||||
"doc_render_failed": "Dieser Scan ließ sich hier leider nicht anzeigen.",
|
||||
"doc_no_scan": "Kein Scan vorhanden",
|
||||
"persons_heading": "Personenverzeichnis",
|
||||
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
|
||||
@@ -189,29 +189,12 @@
|
||||
"person_role_sender": "Gesendet",
|
||||
"person_role_receiver": "Empfangen",
|
||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||
"person_correspondents_hint": "klicken für Konversation",
|
||||
"person_correspondents_search_title": "Briefe von {A} an {B} durchsuchen",
|
||||
"person_correspondents_search_hint": "klicken, um Briefe zu durchsuchen",
|
||||
"person_correspondents_badge_title": "Gemeinsame Briefe in beide Richtungen",
|
||||
"person_show_more": "+ {count} weitere anzeigen",
|
||||
"conv_label_person_a": "Person A (Absender)",
|
||||
"conv_label_person_b": "Korrespondent",
|
||||
"conv_label_from": "Zeitraum von",
|
||||
"conv_label_to": "Zeitraum bis",
|
||||
"conv_sort_label": "Sortierung:",
|
||||
"conv_sort_newest": "Neueste zuerst",
|
||||
"conv_sort_oldest": "Älteste zuerst",
|
||||
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
||||
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||
"conv_swap_btn": "Personen tauschen",
|
||||
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
|
||||
"conv_strip_sort_newest": "Neueste",
|
||||
"conv_strip_sort_oldest": "Älteste",
|
||||
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
||||
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
||||
"conv_letters_count": "{count} Briefe",
|
||||
"conv_hero_divider": "oder",
|
||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} von {name}",
|
||||
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
|
||||
"row_direction_sent": "Gesendet",
|
||||
@@ -292,7 +275,6 @@
|
||||
"topbar_overflow_heading": "Weitere Empfänger",
|
||||
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
"doc_conversation_title": "Konversation anzeigen",
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
"doc_image_alt": "Original-Scan",
|
||||
"doc_no_date": "Kein Datum",
|
||||
@@ -303,7 +285,6 @@
|
||||
"date_season_summer": "Sommer",
|
||||
"date_season_autumn": "Herbst",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Originaltext:",
|
||||
"date_unknown_icon_label": "Datum unbekannt",
|
||||
"form_label_date_precision": "Datumsgenauigkeit",
|
||||
"form_label_date_end": "Enddatum",
|
||||
@@ -507,6 +488,9 @@
|
||||
"person_mention_create_new": "Neue Person anlegen",
|
||||
"person_mention_results_count_singular": "1 Person gefunden",
|
||||
"person_mention_results_count_plural": "{count} Personen gefunden",
|
||||
"person_mention_edit_label": "Erwähnung bearbeiten",
|
||||
"person_mention_editing_announce": "Erwähnung wird bearbeitet: {displayName}",
|
||||
"person_mention_dismiss_label": "Suche schließen",
|
||||
"transcription_editor_aria_label": "Transkriptionstext",
|
||||
"person_born_name_prefix": "geb.",
|
||||
"page_title_home": "Archiv",
|
||||
@@ -944,6 +928,23 @@
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
"transcribe_coach_shortcut_hint_before": "Tipp: Drücken Sie",
|
||||
"transcribe_coach_shortcut_hint_after": "für eine Übersicht aller Tastenkürzel.",
|
||||
"shortcut_next_region": "Nächster Bereich",
|
||||
"shortcut_prev_region": "Vorheriger Bereich",
|
||||
"shortcut_toggle_mode": "Lese-/Bearbeitungsmodus wechseln",
|
||||
"shortcut_new_region": "Neuen Bereich zeichnen",
|
||||
"shortcut_toggle_training": "Für Training markieren",
|
||||
"shortcut_delete_region": "Aktuellen Bereich löschen",
|
||||
"shortcut_close_panel": "Panel schließen",
|
||||
"shortcut_help": "Tastaturkürzel anzeigen",
|
||||
"shortcut_draw_hint": "Ziehen Sie mit der Maus einen Bereich auf.",
|
||||
"key_cap_delete": "Entf",
|
||||
"cheatsheet_title": "Tastaturkürzel",
|
||||
"cheatsheet_close": "Kürzelübersicht schließen",
|
||||
"cheatsheet_autosave_hint": "Änderungen werden automatisch gespeichert.",
|
||||
"annotation_view_label": "Block anzeigen",
|
||||
"annotation_label_with_delete": "Block anzeigen, Entf zum Löschen.",
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
"nav_persons": "Persons",
|
||||
"nav_conversations": "Letters",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
"layout_menu_open": "Open menu",
|
||||
@@ -57,6 +56,7 @@
|
||||
"form_label_sender": "Sender",
|
||||
"form_label_receivers": "Recipients",
|
||||
"form_label_title": "Title",
|
||||
"form_helper_title_autogenerated": "Generated automatically from the date and place — as soon as you edit the title, your version is kept.",
|
||||
"form_label_tags": "Tags",
|
||||
"form_label_content": "Content",
|
||||
"form_placeholder_content": "Brief description of the content…",
|
||||
@@ -126,7 +126,7 @@
|
||||
"doc_label_summary": "Summary",
|
||||
"doc_loading": "Loading document...",
|
||||
"doc_download_link": "Try direct download",
|
||||
"doc_render_failed": "This scan could not be displayed.",
|
||||
"doc_render_failed": "This scan couldn’t be displayed here.",
|
||||
"doc_no_scan": "No scan available",
|
||||
"persons_heading": "Person directory",
|
||||
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
|
||||
@@ -189,29 +189,12 @@
|
||||
"person_role_sender": "Sent",
|
||||
"person_role_receiver": "Received",
|
||||
"person_co_correspondents_heading": "Frequent correspondents",
|
||||
"person_correspondents_hint": "click to view conversation",
|
||||
"person_correspondents_search_title": "Search letters from {A} to {B}",
|
||||
"person_correspondents_search_hint": "click to search letters",
|
||||
"person_correspondents_badge_title": "Shared letters in both directions",
|
||||
"person_show_more": "+ {count} more",
|
||||
"conv_label_person_a": "Person A (Sender)",
|
||||
"conv_label_person_b": "Correspondent",
|
||||
"conv_label_from": "Period from",
|
||||
"conv_label_to": "Period to",
|
||||
"conv_sort_label": "Sort:",
|
||||
"conv_sort_newest": "Newest first",
|
||||
"conv_sort_oldest": "Oldest first",
|
||||
"conv_empty_heading": "Whose letters would you like to read?",
|
||||
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
|
||||
"conv_no_results_heading": "No documents found.",
|
||||
"conv_no_results_text": "Try adjusting the time period.",
|
||||
"conv_swap_btn": "Swap persons",
|
||||
"conv_new_doc_link": "New document in this exchange",
|
||||
"conv_strip_sort_newest": "Newest",
|
||||
"conv_strip_sort_oldest": "Oldest",
|
||||
"conv_suggestions_heading": "Top correspondents",
|
||||
"conv_suggestions_all_label": "All correspondents of {name}",
|
||||
"conv_letters_count": "{count} letters",
|
||||
"conv_hero_divider": "or",
|
||||
"conv_empty_recent_label": "Recently opened",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} from {name}",
|
||||
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
|
||||
"row_direction_sent": "Sent",
|
||||
@@ -292,7 +275,6 @@
|
||||
"topbar_overflow_heading": "More receivers",
|
||||
"topbar_overflow_show": "Show {count} more receivers",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
"doc_conversation_title": "Show conversation",
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
"doc_image_alt": "Original scan",
|
||||
"doc_no_date": "No date",
|
||||
@@ -303,7 +285,6 @@
|
||||
"date_season_summer": "Summer",
|
||||
"date_season_autumn": "Autumn",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Original:",
|
||||
"date_unknown_icon_label": "Date unknown",
|
||||
"form_label_date_precision": "Date precision",
|
||||
"form_label_date_end": "End date",
|
||||
@@ -507,6 +488,9 @@
|
||||
"person_mention_create_new": "Create new person",
|
||||
"person_mention_results_count_singular": "1 person found",
|
||||
"person_mention_results_count_plural": "{count} persons found",
|
||||
"person_mention_edit_label": "Edit mention",
|
||||
"person_mention_editing_announce": "Editing mention: {displayName}",
|
||||
"person_mention_dismiss_label": "Close search",
|
||||
"transcription_editor_aria_label": "Transcription text",
|
||||
"person_born_name_prefix": "née",
|
||||
"page_title_home": "Archive",
|
||||
@@ -944,6 +928,23 @@
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
"transcribe_coach_shortcut_hint_before": "Tip: press",
|
||||
"transcribe_coach_shortcut_hint_after": "for an overview of all keyboard shortcuts.",
|
||||
"shortcut_next_region": "Next region",
|
||||
"shortcut_prev_region": "Previous region",
|
||||
"shortcut_toggle_mode": "Toggle read/edit mode",
|
||||
"shortcut_new_region": "Draw a new region",
|
||||
"shortcut_toggle_training": "Mark for training",
|
||||
"shortcut_delete_region": "Delete current region",
|
||||
"shortcut_close_panel": "Close panel",
|
||||
"shortcut_help": "Show keyboard shortcuts",
|
||||
"shortcut_draw_hint": "Drag a region with your mouse.",
|
||||
"key_cap_delete": "Del",
|
||||
"cheatsheet_title": "Keyboard shortcuts",
|
||||
"cheatsheet_close": "Close shortcut overview",
|
||||
"cheatsheet_autosave_hint": "Changes are saved automatically.",
|
||||
"annotation_view_label": "View block",
|
||||
"annotation_label_with_delete": "Show block, press Delete to remove.",
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
"nav_persons": "Personas",
|
||||
"nav_conversations": "Cartas",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Cerrar sesión",
|
||||
"layout_menu_open": "Abrir menú",
|
||||
@@ -57,6 +56,7 @@
|
||||
"form_label_sender": "Remitente",
|
||||
"form_label_receivers": "Destinatarios",
|
||||
"form_label_title": "Título",
|
||||
"form_helper_title_autogenerated": "Se genera automáticamente a partir de la fecha y el lugar; en cuanto edites el título, se conservará tu versión.",
|
||||
"form_label_tags": "Etiquetas",
|
||||
"form_label_content": "Contenido",
|
||||
"form_placeholder_content": "Breve descripción del contenido…",
|
||||
@@ -126,7 +126,7 @@
|
||||
"doc_label_summary": "Resumen",
|
||||
"doc_loading": "Cargando documento...",
|
||||
"doc_download_link": "Intentar descarga directa",
|
||||
"doc_render_failed": "No se pudo mostrar este escaneo.",
|
||||
"doc_render_failed": "No se pudo mostrar este escaneo aquí.",
|
||||
"doc_no_scan": "No hay escaneo disponible",
|
||||
"persons_heading": "Directorio de personas",
|
||||
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
|
||||
@@ -189,29 +189,12 @@
|
||||
"person_role_sender": "Enviado",
|
||||
"person_role_receiver": "Recibido",
|
||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||
"person_correspondents_hint": "clic para ver conversación",
|
||||
"person_correspondents_search_title": "Buscar cartas de {A} a {B}",
|
||||
"person_correspondents_search_hint": "haz clic para buscar cartas",
|
||||
"person_correspondents_badge_title": "Cartas compartidas en ambas direcciones",
|
||||
"person_show_more": "+ {count} más",
|
||||
"conv_label_person_a": "Persona A (Remitente)",
|
||||
"conv_label_person_b": "Corresponsal",
|
||||
"conv_label_from": "Período desde",
|
||||
"conv_label_to": "Período hasta",
|
||||
"conv_sort_label": "Ordenar:",
|
||||
"conv_sort_newest": "Más reciente primero",
|
||||
"conv_sort_oldest": "Más antiguo primero",
|
||||
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
||||
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
||||
"conv_no_results_heading": "No se encontraron documentos.",
|
||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||
"conv_swap_btn": "Intercambiar personas",
|
||||
"conv_new_doc_link": "Nuevo documento en este intercambio",
|
||||
"conv_strip_sort_newest": "Más reciente",
|
||||
"conv_strip_sort_oldest": "Más antiguo",
|
||||
"conv_suggestions_heading": "Corresponsales frecuentes",
|
||||
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
||||
"conv_letters_count": "{count} cartas",
|
||||
"conv_hero_divider": "o",
|
||||
"conv_empty_recent_label": "Recientemente abiertos",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} de {name}",
|
||||
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
|
||||
"row_direction_sent": "Enviada",
|
||||
@@ -292,7 +275,6 @@
|
||||
"topbar_overflow_heading": "Más destinatarios",
|
||||
"topbar_overflow_show": "Mostrar {count} destinatarios más",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
"doc_conversation_title": "Ver conversación",
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
"doc_image_alt": "Escaneado original",
|
||||
"doc_no_date": "Sin fecha",
|
||||
@@ -303,7 +285,6 @@
|
||||
"date_season_summer": "Verano",
|
||||
"date_season_autumn": "Otoño",
|
||||
"date_season_winter": "Invierno",
|
||||
"date_original_label": "Texto original:",
|
||||
"date_unknown_icon_label": "Fecha desconocida",
|
||||
"form_label_date_precision": "Precisión de la fecha",
|
||||
"form_label_date_end": "Fecha final",
|
||||
@@ -507,6 +488,9 @@
|
||||
"person_mention_create_new": "Crear nueva persona",
|
||||
"person_mention_results_count_singular": "1 persona encontrada",
|
||||
"person_mention_results_count_plural": "{count} personas encontradas",
|
||||
"person_mention_edit_label": "Editar mención",
|
||||
"person_mention_editing_announce": "Editando mención: {displayName}",
|
||||
"person_mention_dismiss_label": "Cerrar búsqueda",
|
||||
"transcription_editor_aria_label": "Texto de transcripción",
|
||||
"person_born_name_prefix": "n.",
|
||||
"page_title_home": "Archivo",
|
||||
@@ -944,6 +928,23 @@
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
"transcribe_coach_shortcut_hint_before": "Consejo: pulse",
|
||||
"transcribe_coach_shortcut_hint_after": "para ver todos los atajos de teclado.",
|
||||
"shortcut_next_region": "Región siguiente",
|
||||
"shortcut_prev_region": "Región anterior",
|
||||
"shortcut_toggle_mode": "Cambiar modo lectura/edición",
|
||||
"shortcut_new_region": "Dibujar una nueva región",
|
||||
"shortcut_toggle_training": "Marcar para entrenamiento",
|
||||
"shortcut_delete_region": "Eliminar la región actual",
|
||||
"shortcut_close_panel": "Cerrar panel",
|
||||
"shortcut_help": "Mostrar atajos de teclado",
|
||||
"shortcut_draw_hint": "Arrastre una región con el ratón.",
|
||||
"key_cap_delete": "Supr",
|
||||
"cheatsheet_title": "Atajos de teclado",
|
||||
"cheatsheet_close": "Cerrar el resumen de atajos",
|
||||
"cheatsheet_autosave_hint": "Los cambios se guardan automáticamente.",
|
||||
"annotation_view_label": "Ver bloque",
|
||||
"annotation_label_with_delete": "Mostrar bloque, pulse Supr para eliminar.",
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
|
||||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -32,7 +32,7 @@
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"//@vitest/browser-playwright": "Exact-pinned (no caret) to 4.1.6 so patches/@vitest+browser-playwright+4.1.6.patch (backport of vitest PR #10267, the duplicate-mock-id birpc race) keeps applying. TODO: remove this pin and the patch once @vitest/browser-playwright ships a release containing PR #10267. See docs/adr/012-browser-test-mocking-strategy.md.",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -47,7 +48,7 @@
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
87
frontend/src/__meta__/no-factory-ban.test.ts
Normal file
87
frontend/src/__meta__/no-factory-ban.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Belt-and-braces detector for the no-factory vi.mock anti-pattern named in
|
||||
// ADR-012 (the PR #657 failure class). A `vi.mock('$app/navigation')` with no
|
||||
// factory does NOT auto-resolve to an adjacent __mocks__ file the way Jest's
|
||||
// __mocks__/ does: for SvelteKit virtual modules, vitest substitutes some
|
||||
// exports (plain function refs like goto) but leaves others bound to the live
|
||||
// implementation (replaceState, which delegates through a getter). The result
|
||||
// is a partial mock that crashes when an unsubstituted export is hit.
|
||||
//
|
||||
// The sanctioned form keeps an INLINE sync factory:
|
||||
// vi.mock('$app/forms', () => ({ enhance(node, submit) { ... } }));
|
||||
// (Sharing the body via a module imported into the factory is infeasible in
|
||||
// browser mode — vitest hoists vi.mock above the import; see ADR-012.)
|
||||
//
|
||||
// ESLint and the CI grep guard catch the pattern earlier; this in-suite test
|
||||
// catches it at every vitest invocation — the layer hardest to disable. It
|
||||
// also forecloses ADR-012's rejected Option C (config-level auto-resolve).
|
||||
//
|
||||
// We scan source text rather than parsing AST: fast, no parser dependency,
|
||||
// good enough for the named anti-pattern. The pattern matches a `vi.mock`
|
||||
// call whose only argument is a string literal (no factory after a comma).
|
||||
|
||||
const NO_FACTORY_VI_MOCK = /vi\.mock\(\s*['"][^'"]+['"]\s*\)/;
|
||||
|
||||
export function hasNoFactoryViMock(source: string): boolean {
|
||||
return NO_FACTORY_VI_MOCK.test(source);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function findBrowserSpecs(): string[] {
|
||||
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
|
||||
)
|
||||
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
|
||||
}
|
||||
|
||||
describe('scan: hasNoFactoryViMock', () => {
|
||||
it('flags a vi.mock with a string id and no factory', () => {
|
||||
expect(hasNoFactoryViMock(`vi.mock('$app/navigation');`)).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a no-factory vi.mock written across multiple lines', () => {
|
||||
const fixture = `vi.mock(
|
||||
'$app/forms'
|
||||
);`;
|
||||
expect(hasNoFactoryViMock(fixture)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a vi.mock with an inline factory', () => {
|
||||
expect(hasNoFactoryViMock(`vi.mock('$app/forms', () => ({ enhance: () => () => {} }));`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('does not flag a vi.mock with a multi-line inline factory', () => {
|
||||
const fixture = `vi.mock('$app/forms', () => ({
|
||||
enhance: (node, submit) => ({ destroy() {} })
|
||||
}));`;
|
||||
expect(hasNoFactoryViMock(fixture)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag a vi.mock with a named factory reference', () => {
|
||||
expect(hasNoFactoryViMock(`vi.mock('$app/state', factory);`)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag source with no vi.mock at all', () => {
|
||||
expect(hasNoFactoryViMock(`const x = vi.fn();`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser specs: no no-factory vi.mock of a virtual module', () => {
|
||||
it('every src/**/*.svelte.{test,spec}.ts file keeps its factory', () => {
|
||||
const specFiles = findBrowserSpecs();
|
||||
expect(specFiles.length).toBeGreaterThan(0);
|
||||
const offenders = specFiles.filter((file) => hasNoFactoryViMock(readFileSync(file, 'utf-8')));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user