3 Commits
Author SHA1 Message Date
zemion 958a9959c8 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:29 +02:00
zemion d4fa024034 fix(addresses): preserve complete import rollback evidence and batch previews
Module Package Release / publish-packages (push) Successful in 14s
Release v0.1.23. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:36 +02:00
zemion 53490e7be7 Release govoplan-addresses v0.1.22: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 13s
2026-09-08 01:32:21 +02:00
11 changed files with 888 additions and 125 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.21",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.21"
version = "0.1.23"
description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"defusedxml>=0.7.1",
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.45",
"ldap3>=2.9.1,<3",
"openpyxl>=3.1.5,<4",
]
+327 -35
View File
@@ -3,19 +3,28 @@ from __future__ import annotations
import base64
import binascii
import csv
import copy
import hashlib
import json
from collections import Counter
from datetime import UTC, datetime
from io import BytesIO, StringIO
from typing import Any
from sqlalchemy import and_, false, or_
from sqlalchemy.orm import Session
from sqlalchemy import and_, false, func, or_, select
from sqlalchemy.orm import Session, selectinload
from sqlalchemy.orm.attributes import set_committed_value
from govoplan_addresses.backend.db.models import (
AddressImportProfile,
AddressImportRun,
AddressListEntry,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPointQualityDecision,
ContactPostalAddress,
)
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
@@ -34,6 +43,12 @@ from govoplan_addresses.backend.schemas import (
)
from govoplan_addresses.backend.service import (
AddressBookError,
_contact_change_payload,
_record_address_contact_change,
_require_mutable_book,
_replace_emails,
_replace_phones,
_replace_postal_addresses,
create_contact,
delete_contact,
get_visible_address_book,
@@ -47,6 +62,16 @@ from govoplan_core.db.base import utcnow
MAX_IMPORT_BYTES = 10_000_000
MAX_IMPORT_COLUMNS = 200
CONTACT_LOOKUP_BATCH_SIZE = 250
ROLLBACK_SNAPSHOT_VERSION = 2
CONTACT_POINTS = {
"emails": (ContactEmail, ("email",), _replace_emails),
"phones": (ContactPhone, ("phone",), _replace_phones),
"postal_addresses": (
ContactPostalAddress, ("street", "postal_code", "locality", "region", "country"),
_replace_postal_addresses,
),
}
def _account_id(principal: ApiPrincipal) -> str:
@@ -241,15 +266,19 @@ def get_import_run(
session: Session,
principal: ApiPrincipal,
run_id: str,
*,
lock: bool = False,
) -> AddressImportRun:
visible_book_ids = [book.id for book in _visible_import_books(session, principal)]
if not visible_book_ids:
raise AddressBookError("Address import run not found.")
run = (
query = (
session.query(AddressImportRun)
.filter(AddressImportRun.id == run_id, AddressImportRun.address_book_id.in_(visible_book_ids))
.one_or_none()
)
if lock:
query = query.populate_existing().with_for_update()
run = query.one_or_none()
if run is None:
raise AddressBookError("Address import run not found.")
return run
@@ -262,13 +291,13 @@ def apply_address_import(
*,
expected_plan_hash: str,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
run = get_import_run(session, principal, run_id, lock=True)
if run.plan_hash != expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; create a new preview.")
if run.status == "applied":
return run
if run.status != "previewed":
raise AddressBookError(f"Import run cannot be applied from status {run.status!r}.")
if run.plan_hash != expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; create a new preview.")
if any(item.get("severity") == "error" for item in run.diagnostics or []):
raise AddressBookError("Import plans with error diagnostics cannot be applied.")
if any(item.get("action") == "conflict" for item in run.plan_data or []):
@@ -276,12 +305,15 @@ def apply_address_import(
created_ids: list[str] = []
updated: list[dict[str, Any]] = []
for item in run.plan_data or []:
# SQLAlchemy JSON columns do not track nested mutations. Keep the persisted
# preview untouched until assigning a genuinely changed complete plan.
applied_plan = copy.deepcopy(run.plan_data or [])
for item in applied_plan:
action = item.get("action")
if action in {"ignored", "unchanged"}:
continue
source_ref = str(item["source_ref"])
existing = _contact_by_source_ref(session, run.address_book_id, source_ref)
existing = _contact_by_source_ref(session, run.address_book_id, source_ref, lock=True)
if action == "create":
if existing is not None and existing.deleted_at is None:
raise AddressBookError("A target contact appeared after preview; preview the import again.")
@@ -299,18 +331,27 @@ def apply_address_import(
elif action == "update":
if existing is None:
raise AddressBookError("An import target disappeared after preview; preview the import again.")
_lock_contact_points(session, existing)
if _contact_hash(existing) != item.get("expected_contact_hash"):
raise AddressBookError(
f'Contact "{existing.display_name}" changed after preview; preview the import again.'
)
before = _contact_snapshot(existing)
_require_mutable_book(existing.address_book)
before = {
"version": ROLLBACK_SNAPSHOT_VERSION,
"contact": copy.deepcopy(_contact_snapshot(existing)),
"deleted_at": _deleted_at_value(existing.deleted_at),
}
update_payload = ContactUpdateRequest.model_validate(item["payload"])
points = _prepare_import_points(session, existing, update_payload)
if existing.deleted_at is not None:
restore_contact(session, principal, existing.id)
_apply_import_points(existing, points)
contact = update_contact(
session,
principal,
existing.id,
ContactUpdateRequest.model_validate(item["payload"]),
_without_contact_points(update_payload),
)
_stamp_import_contact(contact, run=run, item=item)
session.flush()
@@ -319,7 +360,7 @@ def apply_address_import(
run.status = "applied"
run.applied_at = utcnow()
run.plan_data = list(run.plan_data or [])
run.plan_data = applied_plan
run.result_evidence = {
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
@@ -337,7 +378,7 @@ def rollback_address_import(
run_id: str,
payload: AddressImportRollbackRequest,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
run = get_import_run(session, principal, run_id, lock=True)
if run.plan_hash != payload.expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; reload the import run.")
if run.status == "rolled_back":
@@ -347,6 +388,12 @@ def rollback_address_import(
evidence = dict(run.result_evidence or {})
updated = list(evidence.get("updated_contacts") or [])
created_ids = list(evidence.get("created_contact_ids") or [])
# Validate every before-image before touching any contact. Older runs did
# not record deletion state, so automatic recovery cannot infer it safely.
before_images = {
str(item["contact_id"]): _validated_rollback_snapshot(item.get("before"))
for item in updated
}
expected_hashes = {
str(item["contact_id"]): str(item["after_hash"])
@@ -359,12 +406,20 @@ def rollback_address_import(
if item.get("contact_id") in created_ids and item.get("after_hash")
}
)
for contact_id, expected_hash in expected_hashes.items():
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
if not set(created_ids).issubset(expected_hashes):
raise AddressBookError("Import rollback evidence is incomplete; automatic rollback is unsafe.")
for contact_id, expected_hash in sorted(expected_hashes.items()):
contact = get_visible_contact(session, principal, contact_id, include_deleted=True, lock=True)
_lock_contact_points(session, contact)
if contact.address_book_id != run.address_book_id:
raise AddressBookError("An import target moved to another address book; automatic rollback is unsafe.")
_require_mutable_book(contact.address_book)
if _contact_hash(contact) != expected_hash:
raise AddressBookError(
f'Contact "{contact.display_name}" changed after import; automatic rollback is unsafe.'
)
if contact_id in before_images:
_validate_point_restoration(session, contact, before_images[contact_id][0]["points"])
for contact_id in created_ids:
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
@@ -372,22 +427,23 @@ def rollback_address_import(
delete_contact(session, principal, contact.id)
for item in updated:
contact = get_visible_contact(session, principal, str(item["contact_id"]), include_deleted=True)
snapshot = dict(item["before"])
snapshot, previous_deleted_at = before_images[str(item["contact_id"])]
if contact.deleted_at is not None:
restore_contact(session, principal, contact.id)
update_contact(
session,
principal,
contact.id,
ContactUpdateRequest.model_validate(snapshot["payload"]),
)
contact.source_kind = snapshot.get("source_kind") or "local"
contact.source_ref = snapshot.get("source_ref")
contact.source_revision = snapshot.get("source_revision")
contact.source_payload_kind = snapshot.get("source_payload_kind")
contact.source_payload_raw = snapshot.get("source_payload_raw")
contact.provenance = dict(snapshot.get("provenance") or {})
contact.metadata_ = dict(snapshot.get("metadata") or {})
previous = _contact_change_payload(contact, prefix="previous_")
_restore_contact_points(contact, snapshot["points"])
# A validated stored before-image is not a fresh user edit: do not trim,
# normalize or coerce it through the generic update path a second time.
for field in ("display_name", "given_name", "family_name", "organization", "role_title", "note", "tags"):
setattr(contact, field, copy.deepcopy(snapshot["payload"][field]))
for field in ("source_kind", "source_ref", "source_revision", "source_payload_kind", "source_payload_raw", "provenance"):
setattr(contact, field, copy.deepcopy(snapshot[field]))
contact.metadata_ = copy.deepcopy(snapshot["metadata"])
contact.updated_by_account_id = _account_id(principal)
_record_address_contact_change(session, principal, contact=contact, operation="updated", previous=previous)
if previous_deleted_at is not None:
delete_contact(session, principal, contact.id)
contact.deleted_at = previous_deleted_at
run.status = "rolled_back"
run.rolled_back_at = utcnow()
@@ -701,6 +757,12 @@ def _plan_rows(
first_index.setdefault(key, index)
last_index[key] = index
existing_contacts = _contacts_by_source_refs(
session,
book_id,
[f"import:{profile.profile_key}:{key}" for key in key_counts],
)
for index, (row_number, row, key) in enumerate(keyed_rows):
if key_counts[key] > 1:
if config.duplicate_source_key_policy == "reject":
@@ -721,7 +783,7 @@ def _plan_rows(
)
diagnostics.extend(row_diagnostics)
source_ref = f"import:{profile.profile_key}:{key}"
existing = _contact_by_source_ref(session, book_id, source_ref)
existing = existing_contacts.get(source_ref)
payload = _payload_from_mapped(
mapped,
profile=profile,
@@ -951,13 +1013,79 @@ def _diagnostic(
}
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str) -> Contact | None:
return (
def _contacts_by_source_refs(session: Session, book_id: str, source_refs: list[str]) -> dict[str, Contact]:
contacts: dict[str, Contact] = {}
for offset in range(0, len(source_refs), CONTACT_LOOKUP_BATCH_SIZE):
candidates = (
select(
Contact.id,
func.row_number().over(
partition_by=Contact.source_ref,
order_by=(Contact.created_at.asc(), Contact.id.asc()),
).label("source_position"),
)
.where(
Contact.address_book_id == book_id,
Contact.source_ref.in_(source_refs[offset : offset + CONTACT_LOOKUP_BATCH_SIZE]),
)
.subquery()
)
rows = (
session.query(Contact)
.join(candidates, candidates.c.id == Contact.id)
.filter(candidates.c.source_position == 1)
.options(selectinload(Contact.emails), selectinload(Contact.phones), selectinload(Contact.postal_addresses))
.populate_existing()
.order_by(Contact.created_at.asc(), Contact.id.asc())
.all()
)
for contact in rows:
# Preserve the historical first-match choice for duplicate stored
# source references, independently of batch/database row order.
contacts.setdefault(str(contact.source_ref), contact)
return contacts
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str, *, lock: bool = False) -> Contact | None:
query = (
session.query(Contact)
.filter(Contact.address_book_id == book_id, Contact.source_ref == source_ref)
.order_by(Contact.created_at.asc(), Contact.id.asc())
.first()
)
if lock:
query = query.populate_existing().with_for_update()
return query.first()
def _validated_rollback_snapshot(value: object) -> tuple[dict[str, Any], datetime | None]:
if (
not isinstance(value, dict)
or value.get("version") != ROLLBACK_SNAPSHOT_VERSION
or "deleted_at" not in value
or not isinstance(value.get("contact"), dict)
):
raise AddressBookError(
"This import has incomplete legacy rollback evidence; automatic rollback is unsafe. "
"Review and reconcile the affected contacts manually."
)
snapshot = value["contact"]
required_fields = {
"display_name", "given_name", "family_name", "organization", "role_title",
"note", "tags", "emails", "phones", "postal_addresses", "provenance",
}
if (
not isinstance(snapshot.get("payload"), dict)
or not required_fields.issubset(snapshot["payload"])
or not {"source_kind", "source_ref", "source_revision", "source_payload_kind", "source_payload_raw", "provenance", "metadata"}.issubset(snapshot)
):
raise AddressBookError("Import rollback evidence is incomplete; automatic rollback is unsafe.")
try:
deleted_at = datetime.fromisoformat(value["deleted_at"]) if value["deleted_at"] is not None else None
ContactUpdateRequest.model_validate(snapshot["payload"])
_validate_point_snapshot(snapshot.get("points"))
except (TypeError, ValueError) as exc:
raise AddressBookError("Import rollback evidence is invalid; automatic rollback is unsafe.") from exc
return snapshot, deleted_at
def _stamp_import_contact(contact: Contact, *, run: AddressImportRun, item: dict[str, Any]) -> None:
@@ -1007,12 +1135,176 @@ def _contact_snapshot(contact: Contact) -> dict[str, Any]:
"source_payload_kind": contact.source_payload_kind,
"source_payload_raw": contact.source_payload_raw,
"provenance": dict(contact.provenance or {}),
"metadata": dict(contact.metadata_ or {}),
"metadata": copy.deepcopy(contact.metadata_),
"points": {
name: [_point_snapshot(point) for point in getattr(contact, name)]
for name in CONTACT_POINTS
},
}
def _contact_hash(contact: Contact) -> str:
return _hash_json({**_contact_snapshot(contact), "deleted_at": contact.deleted_at.isoformat() if contact.deleted_at else None})
return _hash_json({**_contact_snapshot(contact), "deleted_at": _deleted_at_value(contact.deleted_at)})
def _point_snapshot(point: ContactEmail | ContactPhone | ContactPostalAddress) -> dict[str, Any]:
# Include every persisted evidence field, including identity, originals,
# normalization, provenance, ordering and timestamps, but never the parent FK.
return {
column.key: (
_deleted_at_value(getattr(point, column.key))
if column.key in {"created_at", "updated_at"}
else copy.deepcopy(getattr(point, column.key))
)
for column in point.__table__.columns if column.key != "contact_id"
}
def _lock_contact_points(session: Session, contact: Contact) -> None:
# The parent is locked by the caller. Point-only evidence updates need their
# own row locks; they need not update the parent row on PostgreSQL.
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
positions = {point.id: index for index, point in enumerate(getattr(contact, name))}
points = session.query(model).filter(model.contact_id == contact.id).order_by(
model.id.asc(),
).populate_existing().with_for_update().all()
# Lock acquisition order is not presentation order. Preserve the
# relationship's existing order for tied indexes; new identities still
# enter the hash and therefore cannot evade the reviewed-state guard.
points.sort(key=lambda point: (point.order_index, positions.get(point.id, len(positions)), point.id))
set_committed_value(contact, name, points)
def _validate_point_snapshot(value: object) -> None:
if not isinstance(value, dict) or set(value) != set(CONTACT_POINTS):
raise ValueError("Missing complete contact-point evidence")
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
points = value[name]
columns = {column.key: column for column in model.__table__.columns if column.key != "contact_id"}
ids: set[str] = set()
if not isinstance(points, list):
raise ValueError("Invalid contact-point collection")
for point in points:
if not isinstance(point, dict) or set(point) != set(columns):
raise ValueError("Incomplete contact-point evidence")
for key, column in columns.items():
field = point[key]
if field is None and column.nullable:
continue
if key in {"created_at", "updated_at"}:
datetime.fromisoformat(field)
elif type(field) is not column.type.python_type:
raise ValueError("Invalid contact-point evidence type")
if not point["id"] or point["id"] in ids:
raise ValueError("Invalid contact-point identity")
ids.add(point["id"])
def _without_contact_points(payload: ContactUpdateRequest) -> ContactUpdateRequest:
return ContactUpdateRequest.model_validate(
payload.model_dump(exclude=set(CONTACT_POINTS), exclude_unset=True)
)
def _require_unreferenced_points(session: Session, contact: Contact, removed: set[str]) -> None:
if not removed:
return
# Do not detach address-list selections or silently retarget point-specific
# consent/quality decisions when an import removes or replaces a value.
referenced = session.query(AddressListEntry.id).filter(
AddressListEntry.contact_id == contact.id,
or_(AddressListEntry.contact_email_id.in_(removed), AddressListEntry.contact_postal_address_id.in_(removed)),
).first()
for model in (ContactChannelRule, ContactPointQualityDecision):
if referenced is not None:
break
referenced = session.query(model.id).filter(
model.contact_id == contact.id, model.contact_point_id.in_(removed),
).first()
if referenced is not None:
raise AddressBookError(
"An affected contact point has address-list or governance references; "
"review and reconcile those references before applying or rolling back this import."
)
def _prepare_import_points(session: Session, contact: Contact, payload: ContactUpdateRequest):
staged = Contact(
source_kind=contact.source_kind, source_ref=contact.source_ref,
source_revision=contact.source_revision,
created_by_account_id=contact.created_by_account_id,
updated_by_account_id=contact.updated_by_account_id,
)
result = {}
removed: set[str] = set()
for name, (_model, identity, replace) in CONTACT_POINTS.items():
if name not in payload.model_fields_set:
continue
replace(staged, getattr(payload, name) or [])
available = list(getattr(contact, name))
matches = []
for candidate in list(getattr(staged, name)):
# Detach the normalization-only parent before a new point enters the
# persistent collection; otherwise save-update cascade can enlist it.
candidate.contact = None
original = next((point for point in available if all(
getattr(point, key) == getattr(candidate, key) for key in identity
)), None)
if original is not None:
available.remove(original)
matches.append((original, candidate))
removed.update(point.id for point in available)
result[name] = matches
_require_unreferenced_points(session, contact, removed)
return result
def _apply_import_points(contact: Contact, prepared) -> None:
for name, matches in prepared.items():
points = []
for original, candidate in matches:
if original is None:
points.append(candidate)
else:
# Same value retains its exact original/normalized evidence and
# identity; only explicitly imported presentation fields change.
for field in ("label", "is_primary", "order_index"):
setattr(original, field, getattr(candidate, field))
points.append(original)
setattr(contact, name, points)
def _validate_point_restoration(session: Session, contact: Contact, points: dict[str, Any]) -> None:
removed: set[str] = set()
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
desired = {point["id"] for point in points[name]}
removed.update(point.id for point in getattr(contact, name) if point.id not in desired)
# A deleted original identity must never be reclaimed from another contact.
if desired and session.query(model.id).filter(
model.id.in_(desired), model.contact_id != contact.id,
).first() is not None:
raise AddressBookError("Contact-point identity changed; automatic rollback is unsafe.")
_require_unreferenced_points(session, contact, removed)
def _restore_contact_points(contact: Contact, snapshots: dict[str, Any]) -> None:
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
existing = {point.id: point for point in getattr(contact, name)}
restored = []
for snapshot in snapshots[name]:
point = existing.get(snapshot["id"])
if point is None:
point = model()
for key, value in snapshot.items():
setattr(point, key, datetime.fromisoformat(value) if key in {"created_at", "updated_at"} else copy.deepcopy(value))
restored.append(point)
setattr(contact, name, restored)
def _deleted_at_value(value: datetime | None) -> str | None:
if value is None:
return None
return (value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)).isoformat()
def _hash_json(value: object) -> str:
+48 -4
View File
@@ -438,7 +438,7 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.21",
version="0.1.23",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -714,9 +714,18 @@ manifest = ModuleManifest(
title="Reusable address ownership",
summary="Reusable person, organization, household, postal, and email recipient sources belong to the addresses module.",
body=(
"Open the book beside Address books for documentation of the address workspace. "
"Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, "
"recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows "
"are owned by govoplan-addresses."
"are owned by govoplan-addresses. The Address Book workspace keeps Reload directly "
"before Add address book at the upper right. Import / export, Connections, and Address "
"quality open labelled, scoped tools; Manage applies to the selected book or list and "
"separates archive actions from editing. Export version is chosen in Import / export. "
"Contact creation remains beside the contact list. Folder icons alone expand or collapse "
"the tree; labels select a group, book, or list without changing expansion. A selected "
"group is navigation, not an aggregate contact book. Reload preserves collapsed "
"branches. All permission and read-only reasons, confirmations, import safeguards, "
"and contact-to-list drag and drop still apply."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -738,9 +747,18 @@ manifest = ModuleManifest(
"Addresses-Modul."
),
"body": (
"Öffnen Sie das Buch neben Adressbücher für die Dokumentation des Adressarbeitsbereichs. "
"Campaigns darf unveränderliche campaignlokale Empfänger-Snapshots halten. Dauerhafte Adressverzeichnisse, "
"Empfängerquellendefinitionen, Einwilligungsmetadaten, Herkunft, Dublettenbereinigung sowie Import- und Exportabläufe "
"gehören jedoch govoplan-addresses."
"gehören jedoch govoplan-addresses. Im Adressbuch steht Neuladen oben rechts unmittelbar vor "
"Adressbuch hinzufügen. Import / Export, Verbindungen und Adressqualität öffnen beschriftete, "
"kontextbezogene Werkzeuge. Verwalten bezieht sich auf das ausgewählte Adressbuch oder die Liste "
"und trennt Archivieren vom Bearbeiten. Die Exportversion wird unter Import / Export gewählt. "
"Kontakte werden weiterhin direkt neben der Kontaktliste angelegt. Nur Ordnersymbole klappen "
"den Baum auf oder zu; Beschriftungen wählen eine Gruppe, ein Adressbuch oder eine Liste aus, "
"ohne die Aufklappstellung zu ändern. Eine ausgewählte Gruppe dient der Navigation und ist "
"kein zusammengefasstes Adressbuch. Neuladen bewahrt zugeklappte Zweige. Berechtigungs- und "
"Schreibschutzgründe, Bestätigungen, Importsicherungen und das Ziehen von Kontakten in Listen gelten unverändert."
),
}
},
@@ -748,6 +766,7 @@ manifest = ModuleManifest(
"seed": True,
"help_contexts": [
"addresses.page",
"addresses.explorer.transfer",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
@@ -801,7 +820,18 @@ manifest = ModuleManifest(
"CSV, XLSX, and LDIF files can be mapped with scoped, reusable profile versions. Each preview validates headers or attributes, "
"encodings, source keys, duplicates, blank values, format limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. Each new update "
"uses version-2 before-images for editable contact values, source metadata, complete contact-point identities, "
"original and normalized values, provenance, order and timestamps, and prior deletion state: rolling back an "
"import that restored a deleted contact archives it again. Older update runs without complete before-images require "
"manual reconciliation; automatic rollback stops before changing any contacts. Created-contact identities and "
"after-hashes are persisted with the applied plan and required for rollback; missing older guards or contacts "
"moved to another book also require reconciliation. Point-evidence edits are included in change guards. "
"Unchanged point values retain their identities and original evidence. Removing or replacing a point referenced "
"by an address list or point-specific consent/quality decision requires explicit reconciliation, including when "
"a new reference would otherwise be detached by rollback. Current book visibility and change "
"guards still apply. Preview source lookups and contact collections are loaded in bounded batches, without changing "
"duplicate policies, reviewed hashes, or apply-time validation. A persisted run "
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
@@ -833,6 +863,20 @@ manifest = ModuleManifest(
"Kontaktidentität vor jeder Änderung. Der geprüfte Eingabe- und Planhash wird mit zeilenbezogenen Wirkungen und Diagnosen "
"aufbewahrt. Die Anwendung ist idempotent, verwirft seit der Vorschau geänderte Kontakte und zeichnet ausreichende "
"Nachweise für eine gesicherte Rücknahme auf. Ein gespeicherter Lauf kann nach Navigation oder Neuladen über seinen Link "
"erneut geöffnet werden. Vorher-Bilder der Version 2 enthalten bearbeitbare Kontaktwerte, Quellmetadaten, "
"vollständige Kontaktpunktkennungen, Original- und normalisierte Werte, Herkunft, Reihenfolge, Zeitstempel und den "
"vorherigen Löschzustand: Die Rücknahme archiviert einen durch den Import wiederhergestellten Kontakt erneut. "
"Ältere Änderungsläufe ohne vollständige Vorher-Bilder erfordern einen manuellen Abgleich; die automatische "
"Rücknahme stoppt vor jeder Kontaktänderung. Kennungen neu angelegter Kontakte und Nachher-Hashes werden mit dem "
"angewendeten Plan gespeichert und sind für die Rücknahme erforderlich; fehlende ältere Sicherungen oder in ein "
"anderes Buch verschobene Kontakte erfordern ebenfalls einen Abgleich. Änderungen an Punktnachweisen werden vom "
"Änderungsschutz erfasst. Unveränderte Punktwerte behalten Kennung und Originalnachweise. Das Entfernen oder "
"Ersetzen eines in Adresslisten oder punktspezifischen Einwilligungs-/Qualitätsentscheidungen referenzierten "
"Punkts erfordert einen ausdrücklichen Abgleich; dies gilt auch für neue Referenzen, die eine Rücknahme sonst "
"lösen würde. Aktuelle Adressbuchsichtbarkeit und "
"Änderungsschutz bleiben wirksam. "
"Quellzuordnungen und Kontaktpunkte werden für die Vorschau in begrenzten Stapeln geladen, ohne Dublettenregeln, "
"geprüfte Hashes oder die erneute Prüfung bei Anwendung zu ändern. Ein gespeicherter Lauf kann über seinen Link "
"erneut geöffnet werden; Vorschau-, Anwendungs-, Rücknahme-, Ablauf- und Nichtverfügbarkeitszustände bleiben eindeutig. "
"Anwendung und Rücknahme übermitteln den geprüften Planhash. Fehlende, abgelaufene, verborgene und mandantenfremde Läufe "
"legen keine Quelldaten offen. XLSX-Formeln, Makros und ältere Arbeitsmappenformate werden niemals ausgeführt oder "
+5 -2
View File
@@ -3278,8 +3278,11 @@ def _filtered_contact_query(
return contact_query
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:
contact = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id).one_or_none()
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False, lock: bool = False) -> Contact:
query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id)
if lock:
query = query.populate_existing().with_for_update(of=Contact)
contact = query.one_or_none()
if contact is None:
raise AddressBookError("Contact not found.")
return contact
+315 -2
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import base64
from io import BytesIO
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from openpyxl import Workbook
from govoplan_addresses.backend.db.models import AddressBook, Contact
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone, ContactPostalAddress
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
@@ -18,6 +19,8 @@ from govoplan_addresses.backend.import_schemas import (
)
from govoplan_addresses.backend.imports import (
apply_address_import,
_contact_hash,
_contact_snapshot,
create_import_profile,
get_import_run,
import_run_payload,
@@ -26,6 +29,7 @@ from govoplan_addresses.backend.imports import (
update_import_profile,
)
from govoplan_core.db.base import Base
from govoplan_addresses.backend.service import delete_contact
class Principal:
@@ -91,6 +95,315 @@ class AddressTabularImportTests(unittest.TestCase):
)
self.session.flush()
def _preview_rows(self, count: int = 1, *, organization: str = "Office"):
return preview_address_import(
self.session, self.principal, self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id, filename="fixture.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
+ "".join(f"{index};Given;Family;u{index}@example.test;{organization}\n" for index in range(count))
),
),
)
def _apply(self, run):
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
self.session.commit()
def test_rollback_restores_previously_deleted_state_and_source_fields(self) -> None:
self._apply(self._preview_rows())
contact = self.session.query(Contact).one()
delete_contact(self.session, self.principal, contact.id)
self.session.commit()
prior_deleted_at = contact.deleted_at
prior_source_revision = contact.source_revision
run = self._preview_rows(organization="Changed")
self._apply(run)
self.assertIsNone(contact.deleted_at)
before = run.result_evidence["updated_contacts"][0]["before"]
self.assertEqual(2, before["version"])
self.assertEqual(prior_deleted_at.isoformat(), before["deleted_at"])
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Restore the reviewed previous state."),
)
self.session.commit()
self.assertEqual("rolled_back", run.status)
self.assertEqual(prior_deleted_at, contact.deleted_at)
self.assertEqual("Office", contact.organization)
self.assertEqual(prior_source_revision, contact.source_revision)
def test_rollback_restores_all_point_evidence_and_identities_after_reload(self) -> None:
self._apply(self._preview_rows())
contact = self.session.query(Contact).one()
contact.emails[0].original_email = " U0@EXAMPLE.TEST "
contact.emails[0].provenance = {"nested": {"original": "email evidence"}}
contact.note = " Exact retained note\r\n"
contact.tags = ["Exact", "Exact", " padded "]
contact.metadata_ = None
contact.phones.append(ContactPhone(
phone="+49 123", original_phone=" +49 (123) ", normalized_phone="+49123",
provenance={"original": "phone evidence"}, label="Office", is_primary=True, order_index=4,
))
contact.postal_addresses.append(ContactPostalAddress(
street="Main Street", original_value={"street": " Main Street "},
normalized_value={"street": "main street"}, provenance={"original": "postal evidence"},
is_primary=True, order_index=7,
))
self.session.commit()
before = _contact_snapshot(contact)["points"]
run = self._preview_rows(organization="Changed")
self._apply(run)
self.assertEqual(before["emails"], _contact_snapshot(contact)["points"]["emails"])
self.session.expire_all()
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Restore all original point evidence."),
)
self.session.commit()
self.session.expire_all()
self.assertEqual(before, _contact_snapshot(contact)["points"])
self.assertEqual(" Exact retained note\r\n", contact.note)
self.assertEqual(["Exact", "Exact", " padded "], contact.tags)
self.assertIsNone(contact.metadata_)
def test_post_import_point_provenance_edit_is_guarded(self) -> None:
self._apply(self._preview_rows())
run = self._preview_rows(organization="Changed")
self._apply(run)
contact = self.session.query(Contact).one()
after_hash = _contact_hash(contact)
contact.emails[0].provenance = {"later": "manual evidence"}
self.session.commit()
self.assertNotEqual(after_hash, _contact_hash(contact))
with self.assertRaisesRegex(ValueError, "changed after import"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Do not erase a later point edit."),
)
self.assertEqual({"later": "manual evidence"}, contact.emails[0].provenance)
def test_point_lock_order_does_not_change_tied_collection_order_or_reviewed_hash(self) -> None:
self._apply(self._preview_rows())
contact = self.session.query(Contact).one()
contact.emails.append(ContactEmail(
id="00000000-0000-0000-0000-000000000000", email="extra@example.test",
original_email="extra@example.test", normalized_email="extra@example.test",
label="Extra", is_primary=False, order_index=0,
))
self.session.commit()
self.session.expire_all()
before = _contact_snapshot(contact)["points"]
run = self._preview_rows(organization="Changed")
self._apply(run)
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Preserve tied contact-point ordering."),
)
self.session.commit()
self.session.expire_all()
self.assertEqual(before, _contact_snapshot(contact)["points"])
def _link_email(self, contact):
address_list = AddressList(address_book_id=self.book.id, tenant_id="tenant-1", name="Recipients")
entry = AddressListEntry(address_list=address_list, contact=contact, contact_email=contact.emails[0], target_kind="email")
self.session.add(entry)
self.session.commit()
return entry
def test_unchanged_point_keeps_address_list_identity_through_apply_and_rollback(self) -> None:
self._apply(self._preview_rows())
contact = self.session.query(Contact).one()
entry = self._link_email(contact)
point_id = contact.emails[0].id
run = self._preview_rows(organization="Changed")
self._apply(run)
self.session.expire_all()
self.assertEqual(point_id, entry.contact_email_id)
self.assertEqual(point_id, contact.emails[0].id)
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Keep the explicit recipient reference."),
)
self.session.commit()
self.session.expire_all()
self.assertEqual(point_id, entry.contact_email_id)
self.assertEqual(point_id, contact.emails[0].id)
def test_replacing_a_linked_point_requires_reconciliation_before_contact_mutation(self) -> None:
self._apply(self._preview_rows())
contact = self.session.query(Contact).one()
entry = self._link_email(contact)
run = preview_address_import(
self.session, self.principal, self.book.id,
AddressImportPreviewRequest(profile_id=self.profile.id, filename="fixture.csv", content_base64=encoded(
"id;first;last;email;organization\n0;Given;Family;different@example.test;Changed\n"
)),
)
before = _contact_hash(contact)
with self.assertRaisesRegex(ValueError, "address-list or governance references"):
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
self.assertEqual(before, _contact_hash(contact))
self.assertEqual(contact.emails[0].id, entry.contact_email_id)
self.assertEqual("previewed", run.status)
def test_new_reference_to_imported_point_blocks_destructive_rollback(self) -> None:
self._apply(self._preview_rows())
run = preview_address_import(
self.session, self.principal, self.book.id,
AddressImportPreviewRequest(profile_id=self.profile.id, filename="fixture.csv", content_base64=encoded(
"id;first;last;email;organization\n0;Given;Family;different@example.test;Changed\n"
)),
)
self._apply(run)
contact = self.session.query(Contact).one()
entry = self._link_email(contact)
before = _contact_hash(contact)
with self.assertRaisesRegex(ValueError, "address-list or governance references"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Retain the newly referenced recipient point."),
)
self.assertEqual(before, _contact_hash(contact))
self.assertEqual(contact.emails[0].id, entry.contact_email_id)
self.assertEqual("applied", run.status)
def test_version_one_point_incomplete_evidence_is_not_accepted(self) -> None:
import copy
self._apply(self._preview_rows())
run = self._preview_rows(organization="Changed")
self._apply(run)
evidence = copy.deepcopy(run.result_evidence)
evidence["updated_contacts"][0]["before"]["version"] = 1
run.result_evidence = evidence
self.session.commit()
with self.assertRaisesRegex(ValueError, "incomplete legacy rollback evidence"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Do not infer missing point evidence."),
)
def test_legacy_or_incomplete_before_images_fail_before_any_rollback_mutation(self) -> None:
self._apply(self._preview_rows())
run = self._preview_rows(2, organization="Changed")
self._apply(run)
evidence = dict(run.result_evidence)
updates = [dict(item) for item in evidence["updated_contacts"]]
updates[0]["before"] = updates[0]["before"]["contact"]
evidence["updated_contacts"] = updates
run.result_evidence = evidence
self.session.commit()
with self.assertRaisesRegex(ValueError, "incomplete legacy rollback evidence"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Reject an incomplete previous state."),
)
self.assertEqual("applied", run.status)
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
self.assertTrue(all(contact.organization == "Changed" for contact in self.session.query(Contact)))
def test_preview_queries_are_batched_and_relationships_are_eager(self) -> None:
queries = []
def capture(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith("SELECT"):
queries.append(statement)
event.listen(self.session.bind, "before_cursor_execute", capture)
try:
first = self._preview_rows(50)
self.assertEqual(50, first.statistics["create"])
self.assertLessEqual(len(queries), 3)
self._apply(first)
self.session.expunge_all()
queries.clear()
repeat = self._preview_rows(50)
self.assertEqual(50, repeat.statistics["unchanged"])
self.assertLessEqual(len(queries), 6)
finally:
event.remove(self.session.bind, "before_cursor_execute", capture)
def test_preview_batches_preserve_first_source_match_and_book_scope(self) -> None:
self._apply(self._preview_rows(5))
original = self.session.query(Contact).order_by(Contact.created_at, Contact.id).first()
other_book = AddressBook(tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Other", source_kind="local", read_only=False)
self.session.add(other_book)
self.session.flush()
self.session.add_all([
Contact(tenant_id="tenant-1", address_book_id=original.address_book_id, display_name="Later duplicate", source_ref=original.source_ref),
Contact(tenant_id="tenant-1", address_book_id=other_book.id, display_name="Other book", source_ref=original.source_ref),
])
self.session.commit()
with patch("govoplan_addresses.backend.imports.CONTACT_LOOKUP_BATCH_SIZE", 2):
repeat = self._preview_rows(5)
self.assertEqual(5, repeat.statistics["unchanged"])
def test_missing_created_after_hash_blocks_rollback(self) -> None:
run = self._preview_rows()
self._apply(run)
run.plan_data = [{key: value for key, value in item.items() if key != "after_hash"} for item in run.plan_data]
self.session.commit()
with self.assertRaisesRegex(ValueError, "evidence is incomplete"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Incomplete evidence must not delete contacts."),
)
self.assertEqual(1, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_applied_effect_hashes_survive_commit_and_reload(self) -> None:
run = self._preview_rows()
self._apply(run)
run_id, plan_hash = run.id, run.plan_hash
self.session.expunge_all()
reloaded = get_import_run(self.session, self.principal, run_id)
self.assertTrue(reloaded.plan_data[0]["contact_id"])
self.assertEqual(64, len(reloaded.plan_data[0]["after_hash"]))
rollback_address_import(
self.session, self.principal, run_id,
AddressImportRollbackRequest(expected_plan_hash=plan_hash, reason="Durable after-images guard rollback."),
)
self.session.commit()
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_replayed_apply_still_requires_the_reviewed_plan_hash(self) -> None:
run = self._preview_rows()
self._apply(run)
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
apply_address_import(self.session, self.principal, run.id, expected_plan_hash="0" * 64)
def test_rollback_rejects_a_moved_target_without_archiving_it(self) -> None:
run = self._preview_rows()
self._apply(run)
other_book = AddressBook(tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Other", source_kind="local", read_only=False)
self.session.add(other_book)
self.session.flush()
contact = self.session.query(Contact).one()
contact.address_book_id = other_book.id
self.session.commit()
with self.assertRaisesRegex(ValueError, "moved to another address book"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Moved contacts require manual reconciliation."),
)
self.assertIsNone(contact.deleted_at)
self.assertEqual("applied", run.status)
def test_rollback_retains_post_import_edits(self) -> None:
run = self._preview_rows()
self._apply(run)
contact = self.session.query(Contact).one()
contact.organization = "Later manual edit"
self.session.commit()
with self.assertRaisesRegex(ValueError, "changed after import"):
rollback_address_import(
self.session, self.principal, run.id,
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Later changes must remain untouched."),
)
self.assertIsNone(contact.deleted_at)
self.assertEqual("Later manual edit", contact.organization)
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
payload = AddressImportPreviewRequest(
profile_id=self.profile.id,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.21",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -17,5 +17,14 @@ assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
assert.match(page, /<PageActionBar[\s\S]*variant="collection"[\s\S]*reloadAction=[\s\S]*createAction=/);
assert.doesNotMatch(page, /renderSelectedBookActions|address-icon-actions/);
assert.match(page, /renderAddressActions\(\)/);
assert.match(page, /<FormSection variant="separated" title="i18n:govoplan-addresses\.explorer\.archive_section"/);
const openTreeNode = page.slice(page.indexOf(" function openTreeNode("), page.indexOf(" function toggleTreeNode("));
assert.doesNotMatch(openTreeNode, /toggleTreeNode\(/, "Labels only select, never expand or collapse.");
assert.match(openTreeNode, /setSelectedTreeGroup\(\{ id: node\.id, label: node\.label \}\)/);
assert.match(page, /selectedTreeGroup\?\.id \?\?/);
assert.match(page, /if \(!previousBranchIds\.has\(id\)\) next\.add\(id\)/, "Reload preserves collapsed branches.");
console.log("Address-book flat selections use central components.");
@@ -1,6 +1,6 @@
import { MetricGrid } from "@govoplan/core-webui";
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, Settings2, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
ApiError,
@@ -18,6 +18,7 @@ import { DialogSection, DialogForm, FormGrid, ActionToolbar,
FormSection,
LoadingFrame,
MetricCard,
PageActionBar,
PasswordField,
SegmentedControl,
SelectionList,
@@ -659,7 +660,7 @@ function canMergeConflict(conflict: AddressSyncConflict): boolean {
return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict));
}
function defaultConflictMergeChoices(conflict: AddressSyncConflict): Record<string, ConflictMergeChoice> {
function defaultConflictMergeChoices(_conflict: AddressSyncConflict): Record<string, ConflictMergeChoice> {
return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice]));
}
@@ -963,7 +964,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState("");
const [selectedTreeGroup, setSelectedTreeGroup] = useState<Pick<AddressTreeNode, "id" | "label"> | null>(null);
const [expandedTreeIds, setExpandedTreeIds] = useState<Set<string>>(() => new Set());
const knownTreeBranchIds = useRef(new Set<string>());
const [addressActionsOpen, setAddressActionsOpen] = useState<"book" | "transfer" | "connections" | null>(null);
const [query, setQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(false);
@@ -1136,8 +1140,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
.then((run) => {
if (!active) return;
setImportRun(run);
setSelectedTreeGroup(null);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
setSelectedImportProfileId(run.profile_id ?? "");
})
.catch((err) => {
if (!active) return;
@@ -1161,9 +1166,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return () => {active = false;};
}, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
const selectedBook = selectedTreeGroup ? null : books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedList = selectedTreeGroup ? null : addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedBookSyncSources = useMemo(
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
[selectedBook, syncSources]
@@ -1172,7 +1177,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = contacts;
const visibleContacts = selectedTreeGroup ? [] : contacts;
const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates
@@ -1208,7 +1213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return [];
}, [channelRuleForm.channel, governanceContact]);
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
const activeTreeId = selectedTreeGroup?.id ?? (selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "");
const loadingReason = loading ? "Address books are loading." : "";
const savingReason = saving ? "An address-book action is already in progress." : "";
const createBookReason = disabledReason(
@@ -1570,10 +1575,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
useEffect(() => {
const defaultExpanded = expandedAddressTreeIds(addressTreeNodes);
if (defaultExpanded.size === 0) return;
const previousBranchIds = knownTreeBranchIds.current;
knownTreeBranchIds.current = defaultExpanded;
setExpandedTreeIds((current) => {
const next = new Set(current);
for (const id of defaultExpanded) next.add(id);
const next = new Set([...current].filter((id) => defaultExpanded.has(id)));
for (const id of defaultExpanded) {
if (!previousBranchIds.has(id)) next.add(id);
}
return next;
});
}, [addressTreeNodes]);
@@ -1950,6 +1958,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) {
setSelectedTreeGroup(null);
setContactPage(1);
setSelectedBookId(node.book.id);
setSelectedListId("");
@@ -1957,13 +1966,16 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return;
}
if (node.kind === "list" && node.list) {
setSelectedTreeGroup(null);
setContactPage(1);
setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id);
setSelectedContactId("");
return;
}
toggleTreeNode(node);
setSelectedTreeGroup({ id: node.id, label: node.label });
setSelectedListId("");
setSelectedContactId("");
}
function toggleTreeNode(node: AddressTreeNode) {
@@ -2450,8 +2462,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
try {
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
retainImportRun(run);
setSelectedTreeGroup(null);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
setSelectedImportProfileId(run.profile_id ?? "");
} catch (err) {
setImportRun(null);
setImportRunUnavailable(
@@ -2555,7 +2568,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function downloadImportCorrections() {
if (!importRun) return;
const quote = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const quote = (value: unknown) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [
["severity", "row", "field", "code", "message"].map(quote).join(","),
...importRun.diagnostics.map((item) => [item.severity, item.row_number ?? "", item.field ?? "", item.code, item.message].map(quote).join(","))
@@ -2911,6 +2924,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
function renderSelectedBookPanel() {
if (selectedTreeGroup) return <div className="address-source-summary">
<strong>{selectedTreeGroup.label}</strong>
<p>i18n:govoplan-addresses.explorer.choose_book_in_group</p>
</div>;
if (!selectedBook) return <p className="address-empty-note">No address book selected.</p>;
return (
<div className="address-source-summary">
@@ -2952,40 +2969,68 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
);
}
function renderSelectedBookActions() {
return (
<>
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<Button type="button" title="Review address quality" aria-label="Review address quality" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={15} /></Button>
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import contacts" aria-label="Import contacts" onClick={openImportDialog} disabledReason={importBookReason}><Upload size={15} /></Button>
<select aria-label="vCard export version" value={vcardExportVersion} onChange={(event) => setVcardExportVersion(event.target.value as "3.0" | "4.0")}>
<option value="4.0">vCard 4.0</option>
<option value="3.0">vCard 3.0</option>
</select>
<Button type="button" title={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} aria-label={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<Button type="button" title="Connect LDAP or Active Directory" aria-label="Connect LDAP or Active Directory" onClick={openLdapDialog} disabledReason={connectLdapReason}><Network size={15} /></Button>
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ?
selectedList.deleted_at ?
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
<>
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
</> :
selectedBook?.deleted_at ?
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
<>
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
</>
}
</>
);
function chooseAddressAction(action: () => void) {
setAddressActionsOpen(null);
action();
}
function renderAddressActions() {
return <Dialog
open={addressActionsOpen !== null}
title={addressActionsOpen === "transfer" ? "i18n:govoplan-addresses.explorer.transfer" : addressActionsOpen === "connections" ? "i18n:govoplan-addresses.explorer.connections" : "i18n:govoplan-addresses.explorer.manage_selection"}
description={selectedList?.name ?? selectedBook?.name ?? "i18n:govoplan-addresses.explorer.no_book"}
onClose={() => setAddressActionsOpen(null)}
footer={<Button type="button" onClick={() => setAddressActionsOpen(null)}>i18n:govoplan-core.close.bbfa773e</Button>}>
{addressActionsOpen === "book" && <>
<FormSection title="i18n:govoplan-addresses.explorer.manage_selection">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => chooseAddressAction(openCreateListDialog)} disabledReason={createListReason}><Plus size={16} aria-hidden="true" /> Add address list</Button>
{selectedList?.deleted_at ?
<Button type="button" disabledReason={restoreListReason(selectedList)} onClick={() => chooseAddressAction(() => void restoreDeletedList(selectedList))}><RotateCcw size={16} aria-hidden="true" /> Restore address list</Button> : selectedList ?
<Button type="button" disabledReason={editListReason(selectedList)} onClick={() => chooseAddressAction(() => openEditListDialog(selectedList))}><Edit3 size={16} aria-hidden="true" /> Edit address list</Button> : selectedBook?.deleted_at ?
<Button type="button" disabledReason={restoreBookReason(selectedBook)} onClick={() => chooseAddressAction(() => void restoreBook(selectedBook))}><RotateCcw size={16} aria-hidden="true" /> Restore address book</Button> :
<Button type="button" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && chooseAddressAction(() => openEditBookDialog(selectedBook))}><Edit3 size={16} aria-hidden="true" /> Edit address book</Button>}
</ActionToolbar>
</FormSection>
{(selectedList ? !selectedList.deleted_at : selectedBook && !selectedBook.deleted_at) && <FormSection variant="separated" title="i18n:govoplan-addresses.explorer.archive_section">
<ActionToolbar surface="plain">
{selectedList ?
<Button type="button" variant="danger" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteListReason(selectedList)} onClick={() => chooseAddressAction(() => setConfirmState({ kind: "list", list: selectedList }))}><Trash2 size={16} aria-hidden="true" /> Delete address list</Button> : selectedBook &&
<Button type="button" variant="danger" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteBookReason(selectedBook)} onClick={() => chooseAddressAction(() => setConfirmState({ kind: "book", book: selectedBook }))}><Trash2 size={16} aria-hidden="true" /> Delete address book</Button>}
</ActionToolbar>
</FormSection>}
</>}
{addressActionsOpen === "transfer" && <>
<FormSection title="i18n:govoplan-addresses.explorer.import_section">
<ActionToolbar surface="plain"><Button type="button" onClick={() => chooseAddressAction(openImportDialog)} disabledReason={importBookReason}><Upload size={16} aria-hidden="true" /> Import contacts</Button></ActionToolbar>
</FormSection>
<FormSection variant="separated" title="i18n:govoplan-addresses.explorer.export_section">
<FormGrid columns={2} collapseAt="standard">
<FormField label="i18n:govoplan-addresses.explorer.vcard_version">
<select value={vcardExportVersion} onChange={(event) => setVcardExportVersion(event.target.value as "3.0" | "4.0")}>
<option value="4.0">vCard 4.0</option><option value="3.0">vCard 3.0</option>
</select>
</FormField>
</FormGrid>
<ActionToolbar surface="plain"><Button type="button" onClick={() => chooseAddressAction(() => void exportSelectedBook())} disabledReason={exportBookReason}><Download size={16} aria-hidden="true" /> {selectedList ? "i18n:govoplan-addresses.explorer.export_list" : "i18n:govoplan-addresses.explorer.export_book"}</Button></ActionToolbar>
</FormSection>
</>}
{addressActionsOpen === "connections" && <>
<FormSection title="i18n:govoplan-addresses.explorer.connect_section">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => chooseAddressAction(openCardDavDialog)} disabledReason={connectCardDavReason}><Link2 size={16} aria-hidden="true" /> Connect CardDAV</Button>
<Button type="button" onClick={() => chooseAddressAction(openLdapDialog)} disabledReason={connectLdapReason}><Network size={16} aria-hidden="true" /> Connect LDAP or Active Directory</Button>
</ActionToolbar>
</FormSection>
<FormSection variant="separated" title="i18n:govoplan-addresses.explorer.sync_section" description={selectedSyncSource ? syncSourceLabel(selectedSyncSource) : "i18n:govoplan-addresses.explorer.no_sync_source"}>
<ActionToolbar surface="plain">
<Button type="button" onClick={() => selectedSyncSource && chooseAddressAction(() => void openSyncInspector(selectedSyncSource))} disabledReason={inspectSyncReason}><Search size={16} aria-hidden="true" /> Inspect sync source</Button>
<Button type="button" onClick={() => chooseAddressAction(() => void previewSelectedSync())} disabledReason={previewSyncReason}>Preview sync</Button>
<Button type="button" onClick={() => chooseAddressAction(() => void runSelectedSync())} disabledReason={runSyncReason}><RefreshCw size={16} aria-hidden="true" /> Run sync</Button>
</ActionToolbar>
</FormSection>
</>}
</Dialog>;
}
function renderContactRow(contact: Contact) {
@@ -3196,6 +3241,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
<PageActionBar
title="Address books"
titleHelp={<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />}
variant="collection"
label="i18n:govoplan-addresses.explorer.page_actions"
className="address-page-actions"
refreshable
reloadAction={{ onReload: () => void refreshAll(), loading, disabledReason: refreshReason }}
contextActions={<>
<Button type="button" helpContextId="addresses.explorer.transfer" helpModuleId="addresses" helpTopicId="addresses.boundary" onClick={() => setAddressActionsOpen("transfer")} disabledReason={savingReason}><Upload size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.transfer</Button>
<Button type="button" onClick={() => setAddressActionsOpen("connections")} disabledReason={savingReason}><Link2 size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.connections</Button>
<Button type="button" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.quality</Button>
</>}
createAction={<Button type="button" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={16} aria-hidden="true" /> Add address book</Button>}
/>
{error && <DismissibleAlert className="address-error" compact tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert className="address-error" compact tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!canWriteBooks && !canWriteLists && !canWriteContacts && <ActionBlockerHint
@@ -3219,10 +3279,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<h2>Address books</h2>
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
</div>
<div className="button-row compact-actions address-icon-actions">
<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />
{renderSelectedBookActions()}
</div>
<Button type="button" onClick={() => setAddressActionsOpen("book")} disabledReason={savingReason || (!selectedBook ? "i18n:govoplan-addresses.explorer.no_book" : "")}><Settings2 size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.manage</Button>
</header>
<div className="address-tree-filter-row">
<ToggleSwitch
@@ -3266,7 +3323,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p>
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedTreeGroup ? 0 : contactTotal} contact{!selectedTreeGroup && contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p>
</div>
@@ -3274,7 +3331,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedList &&
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
}
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Add contact</Button>
</div>
</header>
<ActionToolbar className="address-contact-toolbar">
@@ -3299,7 +3356,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
totalRows={selectedTreeGroup ? 0 : contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
@@ -3317,6 +3374,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
</LoadingFrame>
{renderAddressActions()}
<Dialog
open={Boolean(bookDialog)}
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
@@ -3480,7 +3539,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<article className="address-governance-rule" key={rule.id}>
<div className="address-governance-rule-main">
<span className="address-governance-rule-heading">
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")}</strong>
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replace(/_/g, " ")}</strong>
<StatusBadge status={state} />
</span>
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
@@ -4480,7 +4539,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<StatusBadge status={conflictDialog.status} />
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</div>
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
{Boolean(conflictDialog.metadata?.message) && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
</div>
<div className="address-conflict-grid">
+54
View File
@@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-addresses.explorer.page_actions": "Address book actions",
"i18n:govoplan-addresses.explorer.transfer": "Import / export",
"i18n:govoplan-addresses.explorer.connections": "Connections",
"i18n:govoplan-addresses.explorer.quality": "Address quality",
"i18n:govoplan-addresses.explorer.manage": "Manage",
"i18n:govoplan-addresses.explorer.manage_selection": "Manage selected book or list",
"i18n:govoplan-addresses.explorer.no_book": "Select an address book first.",
"i18n:govoplan-addresses.explorer.choose_book_in_group": "Select an address book in this group. Use the folder icon to expand or collapse it; clicking the label only selects it.",
"i18n:govoplan-addresses.explorer.archive_section": "Archive selected book or list",
"i18n:govoplan-addresses.explorer.import_section": "Import into the selected book",
"i18n:govoplan-addresses.explorer.export_section": "Export the selected book or list",
"i18n:govoplan-addresses.explorer.vcard_version": "vCard export version",
"i18n:govoplan-addresses.explorer.export_list": "Export address list",
"i18n:govoplan-addresses.explorer.export_book": "Export address book",
"i18n:govoplan-addresses.explorer.connect_section": "Connect an address source",
"i18n:govoplan-addresses.explorer.sync_section": "Synchronization for the selected book",
"i18n:govoplan-addresses.explorer.no_sync_source": "The selected book has no connected synchronization source.",
"Restore address list": "Restore address list",
"Restore address book": "Restore address book",
"Delete address list": "Delete address list",
"Delete address book": "Delete address book",
"Import contacts": "Import contacts",
"Connect CardDAV": "Connect CardDAV",
"Connect LDAP or Active Directory": "Connect LDAP or Active Directory",
"Inspect sync source": "Inspect sync source",
"Preview sync": "Preview sync",
"Run sync": "Run sync",
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
@@ -88,6 +115,33 @@ export const generatedTranslations: PlatformTranslations = {
"Note": "Note"
},
de: {
"i18n:govoplan-addresses.explorer.page_actions": "Adressbuchaktionen",
"i18n:govoplan-addresses.explorer.transfer": "Import / Export",
"i18n:govoplan-addresses.explorer.connections": "Verbindungen",
"i18n:govoplan-addresses.explorer.quality": "Adressqualität",
"i18n:govoplan-addresses.explorer.manage": "Verwalten",
"i18n:govoplan-addresses.explorer.manage_selection": "Ausgewähltes Adressbuch oder Liste verwalten",
"i18n:govoplan-addresses.explorer.no_book": "Wählen Sie zuerst ein Adressbuch aus.",
"i18n:govoplan-addresses.explorer.choose_book_in_group": "Wählen Sie ein Adressbuch in dieser Gruppe aus. Das Ordnersymbol klappt auf oder zu; ein Klick auf die Beschriftung wählt nur aus.",
"i18n:govoplan-addresses.explorer.archive_section": "Ausgewähltes Adressbuch oder Liste archivieren",
"i18n:govoplan-addresses.explorer.import_section": "In das ausgewählte Adressbuch importieren",
"i18n:govoplan-addresses.explorer.export_section": "Ausgewähltes Adressbuch oder Liste exportieren",
"i18n:govoplan-addresses.explorer.vcard_version": "vCard-Exportversion",
"i18n:govoplan-addresses.explorer.export_list": "Adressliste exportieren",
"i18n:govoplan-addresses.explorer.export_book": "Adressbuch exportieren",
"i18n:govoplan-addresses.explorer.connect_section": "Eine Adressquelle verbinden",
"i18n:govoplan-addresses.explorer.sync_section": "Synchronisierung des ausgewählten Adressbuchs",
"i18n:govoplan-addresses.explorer.no_sync_source": "Das ausgewählte Adressbuch hat keine verbundene Synchronisierungsquelle.",
"Restore address list": "Adressliste wiederherstellen",
"Restore address book": "Adressbuch wiederherstellen",
"Delete address list": "Adressliste löschen",
"Delete address book": "Adressbuch löschen",
"Import contacts": "Kontakte importieren",
"Connect CardDAV": "CardDAV verbinden",
"Connect LDAP or Active Directory": "LDAP oder Active Directory verbinden",
"Inspect sync source": "Synchronisierungsquelle prüfen",
"Preview sync": "Synchronisierungsvorschau",
"Run sync": "Synchronisierung starten",
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
+8 -19
View File
@@ -34,8 +34,8 @@
}
.address-book-page.address-book-fullscreen {
display: grid;
grid-template-rows: 1fr;
display: flex;
flex-direction: column;
height: calc(100vh - 115px);
overflow: hidden;
padding: 0;
@@ -43,7 +43,8 @@
}
.address-workspace-frame {
height: 100%;
flex: 1 1 auto;
height: auto;
min-height: 0;
}
@@ -73,22 +74,10 @@
background: var(--panel-header);
}
.address-tree-header .button-row {
flex-wrap: wrap;
}
.address-icon-actions {
max-width: 150px;
}
.address-icon-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
.address-page-actions {
border-bottom: var(--border-line);
flex: 0 0 auto;
padding: var(--space-3);
}
.address-tree-filter-row {