Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2baa8f2657 | ||
|
|
6176e9f40e | ||
|
|
11b9b7c4c6 | ||
|
|
ab08c73777 | ||
|
|
74c26090d7 | ||
|
|
62047e7a20 | ||
|
|
378f4d6ac5 |
@@ -139,12 +139,20 @@ cd /mnt/DATA/git/govoplan-files/dev/connectors
|
||||
```
|
||||
|
||||
The script reads `.env` from this directory when present and seeds tiny WebDAV,
|
||||
Nextcloud, SMB, and MinIO fixtures. WebDAV and Nextcloud are browsed and imported
|
||||
through the pinned connector transport. SMB and S3 product access deliberately
|
||||
fails closed until their SDK transports support peer pinning, so their fixtures
|
||||
are retained for transport development and reported as expected optional skips.
|
||||
The `--require-smb` and `--require-s3` switches are useful only while developing
|
||||
that transport support and currently make the smoke check fail by design.
|
||||
Nextcloud, SMB, and MinIO fixtures. WebDAV and Nextcloud use the pinned HTTP
|
||||
transport. SMB uses the Files-owned `smbprotocol` connection cache so initial
|
||||
connections, reconnects, aliases, and DFS referral targets are pinned. S3 uses
|
||||
pinned botocore pools for retries, redirects, discovery, and aliases. Every
|
||||
socket destination is revalidated when it opens, including when private-network
|
||||
access is enabled for these local fixtures.
|
||||
|
||||
SMB and S3 are optional by default: a missing provider dependency or unavailable
|
||||
target is reported as `SKIP`. Use `--require-smb` and `--require-s3` to turn such
|
||||
provider failures into a non-zero smoke result during deployment validation. The
|
||||
bundled Samba service is a deterministic single-share target, not a DFS
|
||||
topology. Referral and reconnect behavior is covered by the transport contract
|
||||
tests; deployments using DFS must additionally run the required SMB smoke check
|
||||
against their actual referral topology and record the resolved peers.
|
||||
|
||||
SMB smoke checks need the optional Python dependency in the environment running
|
||||
the script:
|
||||
|
||||
@@ -61,11 +61,10 @@ Linked connector space:
|
||||
- read-only flag from provider/policy
|
||||
- active/deleted state
|
||||
|
||||
The linked space should be addressable as a normal file space in the files UI.
|
||||
For the first implementation slice, actions may use the existing connector
|
||||
browse/sync APIs and managed file storage. A linked space can therefore browse
|
||||
the remote folder and sync selected files into managed storage. Later slices can
|
||||
add a native remote listing view or background sync jobs.
|
||||
The linked space is addressable as a normal file space in the Files UI. It can
|
||||
browse the remote folder, synchronize one selected file, or run a bounded manual
|
||||
folder synchronization into managed storage. Background sync jobs remain a
|
||||
separate scheduling concern.
|
||||
|
||||
## Policy Semantics
|
||||
|
||||
@@ -105,6 +104,9 @@ Implemented:
|
||||
- Files UI sync dialog for choosing a profile, browsing a remote folder, and
|
||||
syncing a selected file into a managed destination folder
|
||||
- Files UI connector-space view with provider/read-only/manual-sync state
|
||||
- bounded folder-level manual sync with recursive traversal, pagination,
|
||||
per-item review outcomes, source-provenance matching, and one summary audit
|
||||
event
|
||||
- Docker dev stack smoke checks for WebDAV, Nextcloud, and SMB
|
||||
|
||||
Missing:
|
||||
@@ -137,9 +139,14 @@ Missing:
|
||||
- Remaining: edit/manage actions for existing linked spaces.
|
||||
|
||||
4. Add sync orchestration.
|
||||
- Manual sync selected file is already available.
|
||||
- Add folder-level manual sync.
|
||||
- Add optional scheduled/background sync with conflict reporting.
|
||||
- Manual selected-file and folder-level synchronization are available.
|
||||
- Folder sync preserves remote relative paths, bounds file count and depth,
|
||||
and reports created, updated, unchanged, skipped, conflict, policy-denied,
|
||||
and failed items without rolling back successful siblings.
|
||||
- The default conflict strategy is `skip`, so unrelated managed files are
|
||||
left untouched. `rename`, `reject`, and `overwrite` remain explicit choices.
|
||||
- Add optional scheduled/background sync separately; it must reuse the same
|
||||
policy, provenance, result, and audit contracts.
|
||||
|
||||
5. Add provider-specific expansion.
|
||||
- OAuth/secret-store credentials.
|
||||
|
||||
+52
-7
@@ -85,6 +85,14 @@ storage, or reinterpret an infrastructure replacement as safe. Use the Files
|
||||
integrity and Ops checks after deployment and complete migration/recovery review
|
||||
before changing an active backend.
|
||||
|
||||
The Files infrastructure dependency provider makes that review concrete. Its
|
||||
authorized, non-secret Ops inventory reports the active runtime binding and
|
||||
aggregates persisted `FileBlob` rows by storage backend with blob counts and
|
||||
byte totals. A host apply that changes `files.storage` requires a fresh,
|
||||
complete inventory from the same installation and shows the migration and
|
||||
checksum-verification action before any service is replaced. Object keys,
|
||||
tenant identifiers and storage credentials are not exported.
|
||||
|
||||
## User tasks
|
||||
|
||||
The Files page is available at `/files`. Actions appear only when the current
|
||||
@@ -107,8 +115,11 @@ Files resources when their owner is specified. Linked connector spaces appear
|
||||
beside managed spaces when they are active and visible to the user.
|
||||
|
||||
A connector space is read-only by default. It is a view of an approved remote
|
||||
location and a starting point for importing or synchronizing selected files
|
||||
into managed storage. An administrator may opt an S3 space into two-way mode
|
||||
location and a starting point for importing a selected file or synchronizing a
|
||||
selected file or bounded folder tree into managed storage. Folder sync preserves
|
||||
relative remote paths in the linked owner's managed space and returns a
|
||||
reviewable result for every discovered or deliberately skipped item. An
|
||||
administrator may opt an S3 space into two-way mode
|
||||
only after enabling the profile's `write` capability. Even then, Files exposes
|
||||
explicit, conditional file write-back—not a mounted filesystem. Automatic
|
||||
remote delete, rename, move, and ACL propagation stay disabled.
|
||||
@@ -281,6 +292,19 @@ inside the chosen owner space:
|
||||
- identical checksum and size updates provenance and returns `unchanged`;
|
||||
- changed bytes append a version and return `updated`.
|
||||
|
||||
Folder-level manual sync uses
|
||||
`POST /api/v1/files/connector-spaces/{space_id}/sync`. The interactive request
|
||||
is bounded by file count and traversal depth, follows provider pagination, and
|
||||
can include or exclude subfolders. Every file is isolated in a database
|
||||
savepoint, so one conflict, policy denial, invalid provider result, or transport
|
||||
failure remains reviewable without discarding successful siblings. Results are
|
||||
classified as `created`, `updated`, `unchanged`, `skipped`, `conflict`,
|
||||
`policy_denied`, or `failed`. The UI defaults unrelated target collisions to
|
||||
`skip`; `rename`, `reject`, and `overwrite` require an explicit operator choice.
|
||||
The response marks a run as truncated when its bound is reached so the operator
|
||||
can continue with a narrower remote folder. This is interactive orchestration,
|
||||
not background scheduling.
|
||||
|
||||
Browse, import, and inbound sync never mutate the remote source. An S3 space in
|
||||
explicit two-way mode can write a selected managed file to one remote object
|
||||
path through `POST /api/v1/files/connector-spaces/{space_id}/write-back`.
|
||||
@@ -394,7 +418,7 @@ those relationships first.
|
||||
| --- | --- |
|
||||
| `files:file:read` | List and inspect accessible files, folders, spaces, and visible connectors |
|
||||
| `files:file:download` | Download an accessible current version or ZIP archive |
|
||||
| `files:file:upload` | Upload managed assets and import/sync selected connector files |
|
||||
| `files:file:upload` | Upload managed assets and import/sync selected connector files or bounded connector folders |
|
||||
| `files:file:organize` | Create folders, rename, move/copy, and manage linked connector spaces |
|
||||
| `files:file:share` | Create or update file shares |
|
||||
| `files:file:delete` | Soft-delete accessible writable files and folders |
|
||||
@@ -813,7 +837,7 @@ All routes below are under `/api/v1/files`.
|
||||
| File access | `GET /{file_id}`, `GET /{file_id}/download`, `DELETE /{file_id}`, `POST /bulk-delete` |
|
||||
| Organization | `POST /bulk-rename`, `POST /transfer`, `POST /archive.zip`, `POST /resolve-patterns` |
|
||||
| Sharing | `POST /{file_id}/shares`, `POST /bulk-shares` |
|
||||
| Connector spaces | `GET/POST /connector-spaces`, `PATCH/DELETE /connector-spaces/{space_id}`, `POST /connector-spaces/{space_id}/restore`, `POST /connector-spaces/{space_id}/write-back` |
|
||||
| Connector spaces | `GET/POST /connector-spaces`, `PATCH/DELETE /connector-spaces/{space_id}`, `POST /connector-spaces/{space_id}/restore`, `POST /connector-spaces/{space_id}/sync`, `POST /connector-spaces/{space_id}/write-back` |
|
||||
| Connector catalog/discovery | `GET /connectors/providers`, `POST /connectors/discover` |
|
||||
| Connector profiles | `GET/POST /connectors/profiles`, `GET/PATCH/DELETE /connectors/profiles/{profile_id}` |
|
||||
| Browse/import/sync | `GET /connectors/profiles/{profile_id}/browse`, `POST /connectors/profiles/{profile_id}/import`, `POST /connectors/profiles/{profile_id}/sync` |
|
||||
@@ -916,7 +940,7 @@ events. External metadata is provider/user input and is not a digital signature.
|
||||
Files records canonical audit events for:
|
||||
|
||||
- connector discovery attempts, before the attempted external I/O;
|
||||
- connector imports and manual syncs;
|
||||
- connector imports, selected-file syncs, and bounded folder sync summaries;
|
||||
- download/archive access to connector-originated managed files;
|
||||
- immediate connector profile and credential deletion/scrubbing;
|
||||
- credential scrubbing during destructive module retirement.
|
||||
@@ -924,6 +948,10 @@ Files records canonical audit events for:
|
||||
Connector audit details include the managed asset/version/blob, checksum, size,
|
||||
operation, source revision, and provenance where applicable. Deletion audit
|
||||
details name secret/reference kinds but never the secret values.
|
||||
`files.connector.folder_synced` additionally records the connector profile,
|
||||
provider, library/root, managed target, traversal mode, truncation state, typed
|
||||
counts, and bounded per-item source/target/action references. It does not embed
|
||||
downloaded bytes, credentials, or transport content.
|
||||
|
||||
Assets, folders, shares, profiles, credentials, policies, and connector spaces
|
||||
also feed Core's incremental change sequence for UI synchronization. A change
|
||||
@@ -1014,6 +1042,16 @@ attempt. Botocore retries, redirects, endpoint discovery, and aliases, plus SMB
|
||||
reconnects and DFS referrals, must pass through the pinned factories. A changed
|
||||
or unsupported SDK seam must fail before a usable client/session is returned.
|
||||
|
||||
SMB deployments use `smbprotocol>=1.13`; signing is required by default and SMB
|
||||
encryption can be required per connector profile. Private targets additionally
|
||||
require the governed private-network setting, but that setting never bypasses
|
||||
per-connection address validation. The bundled development Samba target checks
|
||||
live initial browse/import behavior with `smoke.py --require-smb`. It is a
|
||||
single-share fixture rather than a DFS topology: reconnect and referral handling
|
||||
is enforced by the transport contract suite, while operators using DFS must run
|
||||
the same required smoke check against their institutional referral topology and
|
||||
record the initial and referred peers as deployment evidence.
|
||||
|
||||
### Imported evidence and sync
|
||||
|
||||
Given a permitted WebDAV file is imported, its managed response and audit event
|
||||
@@ -1021,6 +1059,13 @@ must carry source identity, revision when available, current version, checksum,
|
||||
and size. Re-syncing identical bytes must return `unchanged`; changed bytes must
|
||||
create a higher version while preserving the previous version.
|
||||
|
||||
Given a permitted linked connector folder contains changed, unchanged,
|
||||
colliding, and policy-denied files, a manual folder sync must preserve remote
|
||||
relative paths, commit the allowed siblings, return one typed outcome for every
|
||||
discovered/skipped item, and emit a matching summary audit event. Reaching the
|
||||
configured file bound must mark the run truncated rather than imply complete
|
||||
coverage.
|
||||
|
||||
### Campaign freeze
|
||||
|
||||
Given a campaign snapshot selected version V1, when the managed asset later
|
||||
@@ -1053,10 +1098,10 @@ returning different content or credentials.
|
||||
| Deletion/retention | Soft-delete and restore assets/folders/spaces; optimistic retention and legal-hold controls; preview-bound, approval-referenced hard purge; reference-checked blob GC; immediate audited connector-secret scrubbing | Automatic time-based purge scheduling and richer lifecycle administration UI |
|
||||
| Privacy requests | Tenant-scoped bounded DSAR metadata search; retained/manual/revoke/detach planning; idempotent share revocation and mutable actor-reference detachment; explicit separation from byte purge | Content-specific automated redaction and policy-specific approval remain manual or belong to the owning process |
|
||||
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
|
||||
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
|
||||
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import, selected-file sync, and folder sync | Background sync, remote mutation, long-running transfer workers |
|
||||
| SMB and S3 connectors | Provider descriptors, browse/import/manual sync, pinned SDK transports, redirect/retry/referral transport-contract tests, and explicit conditional S3 write-back with Core-ledger recovery | Live topology smoke evidence, provider-specific OAuth, additional provider writes, and background indexing remain separate deployment or connector-module concerns |
|
||||
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
|
||||
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete/restore, read-only default, and opt-in S3 two-way mode | Background sync and automatic remote delete/rename/move/ACL propagation |
|
||||
| Connector spaces | User/group link, browse, bounded manual file/folder sync, edit/disable/delete/restore, read-only default, and opt-in S3 two-way mode | Background sync and automatic remote delete/rename/move/ACL propagation |
|
||||
| Profile capabilities | Stored, displayed, and enforced for explicit connector write-back | Broader provider-specific capability negotiation |
|
||||
| Audit | Connector discovery/import/sync/access and connector deletion; campaign exact-use evidence | Dedicated canonical audit events for every ordinary Files mutation |
|
||||
| Preview | File metadata and attachment download | Dedicated safe content-preview service |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -26,7 +26,7 @@
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"@govoplan/core-webui": "^0.1.18"
|
||||
"@govoplan/core-webui": "^0.1.44"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-files"
|
||||
version = "0.1.19"
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN files module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.19",
|
||||
"govoplan-core>=0.1.44",
|
||||
"defusedxml>=0.7,<1",
|
||||
"pyzipper>=0.3.6,<1",
|
||||
"python-multipart>=0.0.31,<1",
|
||||
|
||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationDiagnostic,
|
||||
@@ -21,19 +23,30 @@ from govoplan_core.core.configuration_packages import (
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapability,
|
||||
InfrastructureCapabilityReceipt,
|
||||
InfrastructureDependency,
|
||||
InfrastructureDependencyProvider,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_files.backend.db.models import FileBlob
|
||||
from govoplan_files.backend.runtime import settings as runtime_settings
|
||||
|
||||
|
||||
FILES_CONFIGURATION_CAPABILITY = "files.configuration"
|
||||
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
|
||||
"infrastructure.dependency_inventory.files"
|
||||
)
|
||||
MANAGED_STORAGE_FRAGMENT = "managed_storage"
|
||||
_PAYLOAD_KEYS = frozenset(
|
||||
{"capability_id", "expected_backend", "expected_source"}
|
||||
)
|
||||
|
||||
|
||||
class FilesConfigurationProvider(ConfigurationProvider):
|
||||
class FilesConfigurationProvider(
|
||||
ConfigurationProvider,
|
||||
InfrastructureDependencyProvider,
|
||||
):
|
||||
module_id = "files"
|
||||
capability_ids = ("files.storage",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -166,6 +179,61 @@ class FilesConfigurationProvider(ConfigurationProvider):
|
||||
item for item in import_result.diagnostics if item.severity == "blocker"
|
||||
)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
active_backend = _normalized_backend(
|
||||
getattr(self._settings, "file_storage_backend", "local")
|
||||
)
|
||||
dependencies = [
|
||||
InfrastructureDependency(
|
||||
capability_id="files.storage",
|
||||
module_id="files",
|
||||
dependency_type="runtime_storage_binding",
|
||||
dependency_ref=f"files-storage:{active_backend}",
|
||||
state="runtime_binding",
|
||||
scope="system",
|
||||
summary=(
|
||||
"The active Files runtime is bound to this deployment storage backend."
|
||||
),
|
||||
metrics={},
|
||||
required_action=(
|
||||
"Provision and verify the replacement backend before rebinding the Files runtime."
|
||||
),
|
||||
)
|
||||
]
|
||||
with get_database().session() as session:
|
||||
rows = session.execute(
|
||||
select(
|
||||
FileBlob.storage_backend,
|
||||
func.count(FileBlob.id),
|
||||
func.coalesce(func.sum(FileBlob.size_bytes), 0),
|
||||
)
|
||||
.group_by(FileBlob.storage_backend)
|
||||
.order_by(FileBlob.storage_backend)
|
||||
)
|
||||
for backend, blob_count, size_bytes in rows:
|
||||
normalized_backend = _normalized_backend(str(backend or "local"))
|
||||
dependencies.append(
|
||||
InfrastructureDependency(
|
||||
capability_id="files.storage",
|
||||
module_id="files",
|
||||
dependency_type="stored_blob_set",
|
||||
dependency_ref=f"file-blobs:{normalized_backend}",
|
||||
state="data_present",
|
||||
scope="all-tenants",
|
||||
summary=(
|
||||
"Persisted Files blob metadata references content in this storage backend."
|
||||
),
|
||||
metrics={
|
||||
"blob_count": int(blob_count or 0),
|
||||
"content_bytes": int(size_bytes or 0),
|
||||
},
|
||||
required_action=(
|
||||
"Copy and checksum-verify every referenced blob, switch the runtime binding, and retain rollback evidence before removing or replacing storage."
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(dependencies)
|
||||
|
||||
def _binding_diagnostics(
|
||||
self,
|
||||
fragment: ConfigurationPackageFragment,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic
|
||||
|
||||
|
||||
_TRANSLATIONS = {
|
||||
"files.tabular-content": {
|
||||
"title": "Verwaltete CSV- und XLSX-Versionen als gesteuerte Datenquellen verwenden",
|
||||
"summary": "Exakte autorisierte Dateiversionen für Connectors bereitstellen, ohne Files-Kontrollen zu umgehen.",
|
||||
"body": (
|
||||
"Files listet nur CSV- und XLSX-Dateien auf, die für die aktuelle Person im Mandanten sichtbar sind. Das Öffnen erfordert zusätzlich die Download-Berechtigung und eine exakte unveränderliche Versionsreferenz. Größenobergrenzen werden vor dem Speicherzugriff geprüft; Prüfsumme, Quarantäne, Verschlüsselungshülle, Löschung, Eigentum und Freigaben bleiben in Files maßgeblich. Connectors erhält Metadaten und verifizierte Bytes über die Core-Fähigkeit und importiert weder Files-Modelle noch Speicherschlüssel oder Verschlüsselungsinterna. Eine neuere aktuelle Version ersetzt eine gebundene Quellversion nie still; Connectors meldet die Änderung zur ausdrücklichen Prüfung."
|
||||
),
|
||||
},
|
||||
"files.records.exact-version-source": {
|
||||
"title": "Eine exakte Dateiversion in einer eAkte ablegen",
|
||||
"summary": "Records nach aktueller Zugriffs- und Integritätsprüfung eine unveränderliche Files-Versionsreferenz übergeben.",
|
||||
"body": (
|
||||
"Wenn Records aktiv ist, stellt Files exakte Identitäten verwalteter FileVersion-Objekte über den anbieterneutralen Aktenquellenvertrag bereit. Die Ablage prüft aktiven Mandanten, aktuelle Files-Berechtigung, Eigentums- oder Freigabezugriff, unveränderliche Versionsidentität und Integrität des verwalteten Blobs. Records erhält Dateiname, Pfad, Version, Digest, Medientyp, Größe, Schutzstatus und Aufruflink; Files bleibt Eigentümer der Bytes."
|
||||
),
|
||||
},
|
||||
"files.search.managed-content": {
|
||||
"title": "Verwaltete Dateien und Ordner durchsuchen",
|
||||
"summary": "Dateinamen, logische Pfade und Beschreibungen für die berechtigungsbewusste Plattformsuche bereitstellen.",
|
||||
"body": (
|
||||
"Wenn Search installiert ist, trägt Files verwaltete Dateien und Ordner zu dessen abgeleitetem Index bei. Jedes Ergebnis ist auf den Mandanten begrenzt und prüft vor der Ausgabe aktuelles Eigentum, Gruppenmitgliedschaft, direkte Freigaben, Ablauf, Widerruf, Löschung und Files-Berechtigungen erneut. Bestätigte Datei- und Freigabeänderungen werden über die Plattform-Event-Outbox übertragen; eine Administration kann den abgeleiteten Index neu aufbauen, ohne führende Files-Daten zu verändern."
|
||||
),
|
||||
},
|
||||
"files.forms-runtime.managed-evidence": {
|
||||
"title": "Formularanhänge als verwaltete Nachweise erfassen",
|
||||
"summary": "Kurzlebige Upload-Freigaben ausstellen und verwaltete Dateiversionen an genau eine Formulareinreichung binden.",
|
||||
"body": (
|
||||
"Fordert Forms Runtime einen Dokumentnachweis an, stellt Files einer aktiven, mandantengleichen verwahrenden Person eine zweckgebundene Freigabe aus. Das Bearer-Token wird einmal angezeigt, nur als SHA-256-Digest gespeichert, läuft spätestens nach 15 Minuten ab und kann genau eine verwaltete Dateiversion erzeugen. Bei der endgültigen Einreichung werden exaktes Formular, Freigabe, Asset, Version, Prüfsumme, Löschstatus und Files-Integritätsgrenze erneut geprüft. Öffentliche Erfassung erhält dadurch keine allgemeinen Files-Berechtigungen; ein Dokument für eine Einreichung kann nicht still für eine andere wiederverwendet werden."
|
||||
),
|
||||
},
|
||||
"files.workflow.organize-managed-files": {
|
||||
"title": "Verwaltete Dateien und Ordner organisieren",
|
||||
"summary": "Ordner anlegen und zugängliche Inhalte mit ausdrücklicher Konfliktbehandlung umbenennen, verschieben oder kopieren.",
|
||||
"body": (
|
||||
"Die Organisation bleibt in gesteuerten persönlichen oder Gruppenbereichen. Verschieben erhält die Asset-Identität; Kopieren erzeugt neue Assets und Versionen, die unveränderliche Blob-Bytes wiederverwenden. Jeder Zielkonflikt muss ausdrücklich abgelehnt, durch Umbenennen gelöst, überschrieben oder übersprungen werden."
|
||||
),
|
||||
},
|
||||
"files.workflow.find-and-download-files": {
|
||||
"title": "Verwaltete Dateien finden und herunterladen",
|
||||
"summary": "Zugängliche Inhalte suchen und eine aktuelle Dateiversion oder ein ZIP einer Auswahl herunterladen.",
|
||||
"body": (
|
||||
"Files kann nach logischem Pfad oder Namensmuster sortiert und durchsucht werden. Ein Download verwendet immer die zugängliche aktuelle Version; aus einer Mehrfachauswahl kann ein temporäres ZIP-Archiv erzeugt werden. Einen eigenen Dienst zur Inhaltsvorschau gibt es derzeit nicht."
|
||||
),
|
||||
},
|
||||
"files.workflow.share-managed-files": {
|
||||
"title": "Zugriff auf verwaltete Dateien steuern",
|
||||
"summary": "Direkte Lese-, Schreib- und Verwaltungsfreigaben auflisten, vergeben, ändern, befristen oder widerrufen, ohne das Eigentum zu ändern.",
|
||||
"body": (
|
||||
"Dateieigentümer und Files-Administrationen verwalten direkte Freigaben für Personen, Gruppen, den Mandanten und Campaign. Abgelaufene oder widerrufene Freigaben berechtigen sofort nicht mehr; unabhängige aktive Freigaben bleiben wirksam. Der Freigabedialog zeigt aktive und historische Einträge, und ein Widerruf ist idempotent. Die Zugriffserklärung prüft standardmäßig die angemeldete Person. Erlaubt Policy ausgewählte Fremdprüfungen, kann derselbe zentrale Dialog eine andere aktive Person im Mandanten bewerten; diese Diagnose wird im Audit protokolliert und gewährt keinen Zugriff."
|
||||
),
|
||||
},
|
||||
"files.workflow.delete-managed-files": {
|
||||
"title": "Verwaltete Dateien und Ordner löschen",
|
||||
"summary": "Zugängliche Dateien oder einen Ordnerbaum nach aktueller Richtlinie weich löschen.",
|
||||
"body": (
|
||||
"Löschen blendet ausgewählte verwaltete Assets aus, statt gespeicherte Nachweise physisch zu vernichten. Ordner werden standardmäßig einschließlich ihrer Unterordner und Dateien rekursiv gelöscht; eine nicht rekursive Anforderung scheitert bei einem nicht leeren Ordner. Eine autorisierte Wiederherstellung aktiviert dasselbe Asset mit Versionen, Provenienz, Ordnerbaum oder Connector-Bereich erneut, sofern Pfad oder Bezeichnung frei ist. Die unwiderrufliche Vernichtung ist ein eigener Administrationsablauf mit Aufbewahrungs- und Legal-Hold-Prüfungen."
|
||||
),
|
||||
},
|
||||
"files.workflow.restore-retain-and-purge": {
|
||||
"title": "Verwaltete Dateien wiederherstellen, aufbewahren und vernichten",
|
||||
"summary": "Weich gelöschte Ressourcen wiederherstellen und irreversible Löschung mit Aufbewahrung, Legal Hold, Vorschau, Freigabe und Recovery-Nachweis steuern.",
|
||||
"body": (
|
||||
"Wiederherstellen erhält ursprüngliche Asset-Identität, Versionen, Blob-Referenzen und Connector-Provenienz. Eine Aufbewahrungsadministration kann mit Begründung und Revisionsprüfung ein Aufbewahrungsdatum oder Legal Hold setzen. Eine physische Vernichtung benötigt eigene Berechtigung, aktuellen unveränderlichen Vorschau-Hash, Idempotenzschlüssel, die wörtliche Bestätigung PURGE und eine Freigabereferenz. Aktive Aufbewahrung, Legal Hold, Freigaben sowie Campaign- und Form-Nachweise blockieren sie. Zuerst werden berechtigte Datenbankeinträge entfernt und Blobs freigegeben; eine getrennte begrenzte Garbage Collection prüft unter derselben verteilten Blob-Sperre wie Uploads alle FileVersion-Referenzen erneut, bevor Bytes und Metadaten gelöscht werden. Jede irreversible Aktion erscheint im Core-Recovery-Ledger und Audit-Nachweis."
|
||||
),
|
||||
},
|
||||
"files.privacy.data-subject-requests": {
|
||||
"title": "Files-Daten in einer Datenschutzanfrage prüfen",
|
||||
"summary": "Sichere Files-Metadaten ermitteln und Entscheidungen über Aufbewahrung, Nachweise und Byte-Löschung ausdrücklich behandeln.",
|
||||
"body": (
|
||||
"Der Files-DSAR-Anbieter sucht nur im wirksamen Mandanten und verlangt eine direkte Mitgliedschaft oder namensraumbezogene Files-Benutzerreferenz. Er exportiert begrenzte Datei-, Versions-, Ordner-, Freigabe-, Nachweis-, Connector-Konfigurations- und Integritätsmetadaten ohne Dateibytes, Speicherorte, Tokens, Passwörter, Geheimnisreferenzen oder verschlüsselte Zugangsdaten. Pläne dürfen eine aktive Freigabe für die betroffene Person widerrufen oder eine veränderbare Akteursreferenz lösen. Legal Hold, aktive Aufbewahrung, Form- und Campaign-Nachweise, Konfigurationshistorie und Integritätsnachweise bleiben mit Begründung erhalten. Dateiinhalte, Eigentum, Namen und Pfade benötigen manuelle Prüfung. Freigegebene physische Löschung muss den getrennt autorisierten Files-Vernichtungs- und Blob-Garbage-Collection-Ablauf nutzen, damit DSAR keine Nachweisblocker, Freigabe, Audit- oder Recovery-Kontrollen umgeht."
|
||||
),
|
||||
},
|
||||
"files.reference.integrity-recovery-and-fail-closed-transports": {
|
||||
"title": "Files-Integrität, Recovery und ausfallsichere Connector-Transporte betreiben",
|
||||
"summary": "Datenbanknachweise, Blob-Chiffretexte und Encryption-Verwahrung als eine Recovery-Einheit sichern und jeden SDK-verwalteten Gegenpunkt binden.",
|
||||
"body": (
|
||||
"Dauerhafter lokaler Speicher ist die betriebliche Basis. Files wird aus einem abgestimmten Datenbank-/Blob-Snapshot mit passenden Encryption-Tabellen und ursprünglichem Deployment-Hauptschlüssel wiederhergestellt; anschließend ist der begrenzte fortsetzbare Integritätsscan auszuführen und geschützter sowie ungeschützter Zugriff stichprobenartig zu prüfen. Scan- und Befundaktionen benötigen die angezeigte Revision. Geschützte Scans prüfen erst den Chiffretext, dann nach Entschlüsselung den semantischen Klartextnachweis. Unter PostgreSQL sichern lease-gebundene Core-Recovery-Absichten Objektauswirkungen ab; Abweichungen werden quarantänisiert und bleiben in Ops sichtbar. SQLite verwendet für Entwicklung eine prozesslokale Sperre und ist kein Produktions-Recovery-Profil; nach hartem Prozessverlust ist ein Integritätsscan erforderlich. Fehlende oder abweichende Blobs werden quarantänisiert, verwaiste Objekte vor einer ausdrücklich autorisierten Bereinigung zunächst nur gemeldet. Physische Vernichtung und Blob-Garbage-Collection prüfen ihre irreversiblen Wirkungen getrennt. S3-Schreibvorgänge nutzen bedingte Effekte und digestbasierte Vorwärts-Recovery; S3- und SMB-Transporte binden Wiederholungen, Umleitungen, Aliasse und erkannte Endpunkte und wenden die Richtlinie für private Netze vor jeder Verbindung erneut an. Fehlt eine verifizierbare Transportnaht, wird geschlossen abgebrochen. Destruktive Modulstilllegung entfernt Datenbanktabellen, nicht jedoch Blob-Objekte im Backend."
|
||||
),
|
||||
},
|
||||
"files.reference.snapshot-provenance-and-capabilities": {
|
||||
"title": "Snapshot-Provenienz und Files-Fähigkeiten integrieren",
|
||||
"summary": "Stabile Files-Fähigkeiten oder HTTP-Verträge verwenden und exakte Versionsnachweise bewahren, statt Files-Interna zu importieren.",
|
||||
"body": (
|
||||
"files.access erklärt Ressourcenzugriff; files.campaign_attachments friert Campaign-Eingaben auf exaktes Asset, Version, Blob, Prüfsumme und Quellrevision ein. Externe Inhalte müssen vor einer gesteuerten Nutzung importiert und ihre Provenienz auf abgeleiteten Snapshots erhalten werden. Zusammenarbeit, Provider-Synchronisierung, OAuth, externe Änderungen und fachlicher Workflow-Zustand bleiben in den jeweils verantwortlichen Modulen."
|
||||
),
|
||||
},
|
||||
"files.assurance.process-and-release-readiness": {
|
||||
"title": "Files-gestützte Prozesse und Releases absichern",
|
||||
"summary": "Einen Prozess gegen die implementierte Files-Grenze prüfen, erlaubte und abgelehnte Pfade erproben und vor Freigabe Nachweise bewahren.",
|
||||
"body": (
|
||||
"Prozessverantwortliche müssen implementierte Kontrollen von geplanten Fähigkeiten unterscheiden. Ein Release ist erst bereit, wenn Paket- und Manifestversionen übereinstimmen und repräsentative Pfade für Autorisierung, Upload-Grenzen, Konflikte, Download, Löschung, Connectoren und Recovery erprobt wurden. Wiederherstellung, Aufbewahrung, Legal Hold, gesteuerte Vernichtung, Blob-Garbage-Collection, Freigabelebenszyklus und ausdrückliches S3-Zurückschreiben erzeugen eigene Policy-, Recovery- oder Audit-Nachweise. Gewöhnliche Organisationsänderungen stützen sich weiterhin vor allem auf die Files-Änderungssequenz statt auf ein eigenes kanonisches Audit-Ereignis. Verbleibende Grenzen sind in der Prozessbewertung zu dokumentieren und Nachweisarten dürfen nicht gleichgesetzt werden."
|
||||
),
|
||||
},
|
||||
"files.reference.shared-storage-profile": {
|
||||
"title": "Files mit einem gemeinsamen Speicherprofil betreiben",
|
||||
"summary": "Lokalen, hostweit gemeinsamen oder S3-basierten Speicher passend zur Laufzeittopologie wählen.",
|
||||
"body": (
|
||||
"Core stellt das gemeinsame lokale/S3-Objektspeicher-Backend bereit; Files verantwortet Dateimetadaten und Objektschlüssel. Lokaler Speicher eignet sich für einen Laufzeitprozess, ein gemeinsames Host-Volume für Replikate auf demselben Host. Unabhängige Hosts benötigen einen ausdrücklich vertrauenswürdigen HTTPS-S3-kompatiblen Endpunkt. PostgreSQL, Objekte und Hauptschlüssel müssen auf denselben abgestimmten Recovery-Zeitpunkt wiederhergestellt werden."
|
||||
),
|
||||
},
|
||||
"files.reference.generated-artifact-store": {
|
||||
"title": "Erzeugte Modulausgaben speichern",
|
||||
"summary": "Optionalen Erzeugermodulen erlauben, Ausgaben über die Autoritätsgrenze von Files dauerhaft abzulegen.",
|
||||
"body": (
|
||||
"Die Fähigkeit files.artifact_store nimmt erzeugte Bytes mit begrenzter, nicht geheimer Provenienz entgegen, wendet Upload-Autorisierung, Eigentums-, Pfad-, Versions- und Blob-Speicherregeln von Files an und liefert anbieterneutrale Datei-/Versionsreferenzen. Idempotenz beruht auf der Quellprovenienz. Die Annahme eines Artefakts beweist weder Druck, Versand noch eine andere externe Wirkung."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
localized: list[DocumentationTopic] = []
|
||||
for topic in topics:
|
||||
german = _TRANSLATIONS.get(topic.id)
|
||||
if german is None:
|
||||
localized.append(topic)
|
||||
continue
|
||||
translations = {
|
||||
locale: dict(value) for locale, value in topic.translations.items()
|
||||
}
|
||||
translations["de"] = {**translations.get("de", {}), **german}
|
||||
localized.append(replace(topic, translations=translations))
|
||||
return tuple(localized)
|
||||
@@ -0,0 +1,458 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'files.assurance.process-and-release-readiness': {'outcome': 'Der Prozess oder die '
|
||||
'Veröffentlichung hat eine explizite '
|
||||
'Genehmigungsaufzeichnung, die an '
|
||||
'abgestimmte Versionen, '
|
||||
'repräsentative Nachweise, bekannte '
|
||||
'Einschränkungen und eigene '
|
||||
'Restrisiken gebunden ist.',
|
||||
'prerequisites': ['Ein benannter '
|
||||
'Prozessbesitzer hat die '
|
||||
'beabsichtigten Benutzer, '
|
||||
'Datenklassifizierung, '
|
||||
'Aufbewahrungserwartungen und '
|
||||
'Integrationen definiert.',
|
||||
'Ein Kandidaten-Release wird '
|
||||
'in einer sauberen Datenbank '
|
||||
'und einer Upgrade-Kopie mit '
|
||||
'ausgerichteten Python-, '
|
||||
'Root-Paket-, WebUI-Paket- '
|
||||
'und Modulmanifestversionen '
|
||||
'installiert.',
|
||||
'Repräsentative zugelassene '
|
||||
'und verweigerte Konten, '
|
||||
'begrenzte Testdateien und '
|
||||
'ein koordiniertes '
|
||||
'Datenbank-/Blob-/Key-Wiederherstellungsset '
|
||||
'sind verfügbar.'],
|
||||
'steps': ['Vergleichen Sie die '
|
||||
'Prozessanforderungen mit der '
|
||||
'implementierten / geplanten Grenze '
|
||||
'des Handbuchs und notieren Sie jede '
|
||||
'nicht unterstützte Anforderung oder '
|
||||
'Kompensationskontrolle.',
|
||||
'Bestätigen Sie die '
|
||||
'Versionsausrichtung, wenden Sie '
|
||||
'Migrationen auf bereinigte und '
|
||||
'aktualisierte Datenbanken an und '
|
||||
'führen Sie das Repository sowie '
|
||||
'Meta-Repository-Sicherheits- und '
|
||||
'Statikanalyse-Gates aus.',
|
||||
'Übung erlaubt und verweigert '
|
||||
'persönlichen, Gruppen und teilen Sie '
|
||||
'den Zugriff mit repräsentativen '
|
||||
'Konten.',
|
||||
'Übung begrenzte Archivvorschau und '
|
||||
'Bestätigung, jede erforderliche '
|
||||
'Konfliktstrategie, Organisation, '
|
||||
'Download und weiche Löschung.',
|
||||
'Wenn Konnektoren konfiguriert sind, '
|
||||
'überprüfen Sie die '
|
||||
'Richtlinienerklärung und einen '
|
||||
'angehängten HTTP-Provider; '
|
||||
'überprüfen Sie die '
|
||||
'Installateur-eigene Garage, wenn Sie '
|
||||
'ausgewählt sind, und üben Sie dann '
|
||||
'konfigurierte S3-Retries/Aliases und '
|
||||
'SMB-Reconnect/Referral-Ziele unter '
|
||||
'der Bereitstellungsrichtlinie für '
|
||||
'das private Netzwerk aus, um zu '
|
||||
'bestätigen, dass eine inkompatible '
|
||||
'SDK-Transportnaht nicht geschlossen '
|
||||
'ist.',
|
||||
'Stellen Sie ein koordiniertes '
|
||||
'Datenbank-/Blob-/Key-Backup wieder '
|
||||
'her und vergleichen Sie '
|
||||
'repräsentative heruntergeladene '
|
||||
'Bytes mit den aufgezeichneten '
|
||||
'SHA-256-Prüfsummen.',
|
||||
'Notieren Sie die getesteten '
|
||||
'Versionen, Ergebnisse, bekannte '
|
||||
'Einschränkungen, Evidenzorte, '
|
||||
'Restrisiken, Eigentümer und '
|
||||
'Genehmigungsentscheidung.'],
|
||||
'verification': 'Ein Überprüfer kann die '
|
||||
'aufgezeichneten '
|
||||
'Berechtigungs-/Verweigerungs-, '
|
||||
'Integritäts-, Konnektor- und '
|
||||
'Wiederherstellungsüberprüfungen '
|
||||
'reproduzieren und jede nicht '
|
||||
'erfüllte Anforderung auf eine '
|
||||
'dokumentierte Einschränkung '
|
||||
'oder akzeptierte '
|
||||
'Kompensationskontrolle '
|
||||
'zurückführen.'},
|
||||
'files.forms-runtime.managed-evidence': {'limitations': ['Das erste Signaturprofil ist eine '
|
||||
'authentifizierte Bestätigung; '
|
||||
'fortgeschrittene und qualifizierte '
|
||||
'elektronische Signaturen erfordern '
|
||||
'einen separaten Anbieter.'],
|
||||
'outcome': 'Der Entwurf verweist auf eine '
|
||||
'unveränderliche verwaltete Dateiversion mit '
|
||||
'genauen Prüfsummennachweisen.',
|
||||
'prerequisites': ['Formulare Laufzeit und Dateien sind '
|
||||
'aktiviert, und das Aufnahmeprofil hat '
|
||||
'eine aktive Benutzerdepotbank für den '
|
||||
'gleichen Mandant.'],
|
||||
'steps': ['Fordern Sie eine Beweisbewilligung für '
|
||||
'Dateien von der editierbaren Formularinstanz '
|
||||
'an, laden Sie sie über die zurückgegebene '
|
||||
'einmalige Trägerroute hoch und fügen Sie dann '
|
||||
'die zurückgegebene Beweisreferenz zum Entwurf '
|
||||
'hinzu.'],
|
||||
'verification': 'Reichen Sie das Formular ein und '
|
||||
'vergewissern Sie sich, dass geänderte, '
|
||||
'gelöschte, unter Quarantäne gestellte, '
|
||||
'mieterübergreifende und '
|
||||
'gebührenübergreifende Referenzen '
|
||||
'abgelehnt werden.'},
|
||||
'files.governed-connectors-and-provenance': {'limitations': ['Die manuelle '
|
||||
'Ordner-Synchronisierung plant keine '
|
||||
'zukünftigen Runs oder mutiert, '
|
||||
'löscht, umbenennt, verschiebt oder '
|
||||
'ändert ACLs für entfernte Inhalte.',
|
||||
'Ein erfolgreicher Teildurchlauf ist '
|
||||
'keine vollständige Abdeckung, wenn '
|
||||
'die Antwort verkürzt wird oder '
|
||||
'Überprüfungsergebnisse enthält.'],
|
||||
'outcome': 'Zulässige Konnektordateien werden zu '
|
||||
'verwalteten Assets oder Versionen, '
|
||||
'während jede Ausnahme explizit und '
|
||||
'überprüfbar bleibt.',
|
||||
'prerequisites': ['Ein sichtbarer aktiver '
|
||||
'Verbindungsraum verwendet '
|
||||
'manuelle Synchronisierung und '
|
||||
'Links zu einem zugänglichen '
|
||||
'verwalteten Benutzer- oder '
|
||||
'Gruppenraum.',
|
||||
'Der Betreiber hat '
|
||||
'file:file:upload und die '
|
||||
'effektive Konnektorrichtlinie '
|
||||
'erlaubt die angeforderten Root- '
|
||||
'und Source-Pfade.'],
|
||||
'steps': ['Öffnen Sie den Ordner des verknüpften '
|
||||
'Verbinders, wählen Sie den Ordner Sync '
|
||||
'und überprüfen Sie das verwaltete Ziel.',
|
||||
'Halten Sie den sicheren Sprung '
|
||||
'standardmäßig oder wählen Sie explizit '
|
||||
'umbenennen, ablehnen oder überschreiben '
|
||||
'für nicht verwandte Zielkollisionen.',
|
||||
'Legen Sie die Auswahl der begrenzten '
|
||||
'Dateianzahl und des Unterordners fest, '
|
||||
'führen Sie die Synchronisierung aus und '
|
||||
'überprüfen Sie jedes eingegebene '
|
||||
'Elementergebnis und jede '
|
||||
'Abkürzungswarnung.',
|
||||
'Setzen Sie einen verkürzten Lauf aus '
|
||||
'einem schmaleren entfernten Ordner fort; '
|
||||
'konfigurieren Sie die Planung separat, '
|
||||
'wenn eine Hintergrundoperation '
|
||||
'erforderlich ist.'],
|
||||
'verification': 'Vergleichen Sie die '
|
||||
'Antwortzusammenfassung und die '
|
||||
'Ergebnisse pro Element mit dem '
|
||||
'Auditereignis '
|
||||
'files.connector.folder synced und '
|
||||
'prüfen Sie dann die Herkunft der '
|
||||
'erstellten, aktualisierten und '
|
||||
'unveränderten repräsentativen '
|
||||
'Assets.'},
|
||||
'files.privacy.data-subject-requests': {'limitations': ['E-Mail-, Konto- oder '
|
||||
'Identitätsselektoren allein können nicht '
|
||||
'von Dateien aufgelöst werden, da Dateien '
|
||||
'nicht im Besitz des '
|
||||
'Access-Verzeichnisses sind; geben Sie '
|
||||
'die bestätigte Mitgliedschaftsreferenz '
|
||||
'an.',
|
||||
'Der Anbieter bettet keine rohen '
|
||||
'Dateiinhalte in den JSON-Export ein und '
|
||||
'führt niemals eine physische '
|
||||
'Blob-Löschung als DSAR-Nebenwirkung '
|
||||
'durch.'],
|
||||
'outcome': 'Dateieigene Betreffreferenzen werden '
|
||||
'überprüft oder entfernt, ohne den '
|
||||
'zurückgehaltenen Inhalt oder Nachweise '
|
||||
'stillschweigend zu zerstören.',
|
||||
'prerequisites': ['Die Anfrage wurde autorisiert und '
|
||||
'enthält eine direkte '
|
||||
'Mandantenmitgliedschaft oder eine '
|
||||
'Betreffreferenz Dateien.',
|
||||
'Der Datenschutzprüfer kann den '
|
||||
'Zugriffsexport von der Löschbehörde '
|
||||
'und der Löschbehörde für Dateien '
|
||||
'unterscheiden.'],
|
||||
'steps': ['Führen Sie die Anbietersuche aus und '
|
||||
'bestätigen Sie die vollständige Abdeckung der '
|
||||
'Dateiberichte anstelle eines ausgefallenen '
|
||||
'oder abwesenden Anbieters.',
|
||||
'Überprüfen Sie Datei-/Versionsmetadaten, '
|
||||
'Gründe für die Aufbewahrung von Nachweisen und '
|
||||
'den Quellpfad für die manuelle '
|
||||
'Inhaltsüberprüfung.',
|
||||
'Generieren Sie den Löschplan und führen Sie '
|
||||
'nur die genehmigten reversiblen '
|
||||
'Aktienrücknahme- oder Subjektreferenzaktionen '
|
||||
'aus.',
|
||||
'Für die genehmigte Byte-Löschung lösen Sie '
|
||||
'jeden Lifecycle-Blocker auf und verwenden Sie '
|
||||
'Files Purge Preview, Execution und Blob '
|
||||
'Garbage Collection separat.'],
|
||||
'verification': 'Bestätigen Sie, dass jeder '
|
||||
'Dateidatensatz eine Aufbewahrungs-, '
|
||||
'Überprüfungs-, Widerrufs- oder '
|
||||
'Loslösungsdisposition hat und prüfen Sie '
|
||||
'jede separate Bereinigung durch seine '
|
||||
'Audit- und Wiederherstellungsnachweise.'},
|
||||
'files.records.exact-version-source': {'outcome': 'Records behält eine exakt geregelte Referenz, '
|
||||
'während Files Byte Authority bleibt.',
|
||||
'prerequisites': ['Die genaue Dateiversion existiert und '
|
||||
'übergibt das aktuelle Files Integrity '
|
||||
'Gate.',
|
||||
'Sie haben derzeit die Datei Lesezugriff '
|
||||
'und Records Einreichung Behörde.'],
|
||||
'steps': ['Wählen Sie die genaue verwaltete Dateiversion '
|
||||
'aus, die zum institutionellen Datensatz gehört.',
|
||||
'Wählen Sie den Zieldatensatz und geben Sie den '
|
||||
'Zugangszweck und den Anmeldegrund an.',
|
||||
'Bestätigen Sie die Einreichung; Dateien '
|
||||
'überprüft den aktuellen Zugriff erneut und löst '
|
||||
'die genaue Version auf.',
|
||||
'Öffnen Sie die Datensatzchronologie und '
|
||||
'überprüfen Sie die Versionsidentität und den '
|
||||
'SHA-256 Digest.']},
|
||||
'files.reference.integrity-recovery-and-fail-closed-transports': {'verification': 'Führen Sie '
|
||||
'nach der '
|
||||
'Wiederherstellung '
|
||||
'einen '
|
||||
'Checksummen-fähigen '
|
||||
'Integritätsscan '
|
||||
'aus, lösen Sie '
|
||||
'jedes fehlende '
|
||||
'/ korrupte '
|
||||
'Finden, '
|
||||
'genehmigen '
|
||||
'oder behalten '
|
||||
'Sie jedes '
|
||||
'gemeldete '
|
||||
'Waisenkind, '
|
||||
'inspizieren '
|
||||
'Sie die '
|
||||
'Wiederherstellungsvorgänge '
|
||||
'von Dateien in '
|
||||
'Ops, '
|
||||
'überprüfen Sie '
|
||||
'den '
|
||||
'autorisierten '
|
||||
'und '
|
||||
'verweigerten '
|
||||
'Zugriff und '
|
||||
'testen Sie '
|
||||
'konfigurierte '
|
||||
'HTTP-, S3- und '
|
||||
'SMB-Verbindungen '
|
||||
'mit ihrer '
|
||||
'aufgezeichneten '
|
||||
'Zieltopologie.'},
|
||||
'files.reference.shared-storage-profile': {'limitations': ['Installer-verwaltete Garage ist '
|
||||
'Single-Node, es sei denn, ein '
|
||||
'externer Multi-Node-Cluster wird '
|
||||
'separat betrieben.',
|
||||
'Die Anwendung erstellt oder '
|
||||
'verifiziert keine '
|
||||
'PostgreSQL/Objekt/Schlüssel-Backups.'],
|
||||
'verification': 'Führen Sie die '
|
||||
'Dateispeicher-Round-Trip-Prüfung und '
|
||||
'eine koordinierte '
|
||||
'Wiederherstellungsübung gegen die '
|
||||
'genaue Bereitstellungstopologie aus.'},
|
||||
'files.tabular-content': {'consequence_classes': {'fail_closed': 'Nicht verfügbare, '
|
||||
'überdimensionierte, unter '
|
||||
'Quarantäne gestellte oder nicht '
|
||||
'autorisierte Inhalte werden '
|
||||
'nicht analysiert oder in der '
|
||||
'Vorschau angezeigt.',
|
||||
'pin_exact_version': 'Eine Quelle behält ihre '
|
||||
'überprüfte '
|
||||
'unveränderliche '
|
||||
'Dateiversion, bis sie '
|
||||
'explizit aktualisiert '
|
||||
'wird.',
|
||||
'preserve_file_controls': 'Zugriff auf Dateien, '
|
||||
'Integrität, '
|
||||
'Verschlüsselung, '
|
||||
'Aufbewahrung und '
|
||||
'rechtliche Kontrolle '
|
||||
'bleiben '
|
||||
'autoritativ.'}},
|
||||
'files.workflow.delete-managed-files': {'limitations': ['Löschung ist eine weiche Löschung, keine '
|
||||
'harte Säuberung.',
|
||||
'Die Wiederherstellung schlägt fehl, wenn '
|
||||
'eine aktive Datei, ein Ordner oder ein '
|
||||
'Verbindungsraum bereits den Pfad oder '
|
||||
'das Label verwendet.'],
|
||||
'outcome': 'Der ausgewählte Inhalt wird aus aktiven '
|
||||
'Dateiansichten unter dem aktuellen '
|
||||
'Soft-Löschmodell ausgeblendet.',
|
||||
'prerequisites': ['Sie können die ausgewählten '
|
||||
'verwalteten Inhalte anzeigen und '
|
||||
'löschen und haben Schreib- oder '
|
||||
'Eigentümerzugriff auf jedes betroffene '
|
||||
'Asset oder jeden Ordner.',
|
||||
'Sie haben den gesamten Ordnerbaum beim '
|
||||
'rekursiven Löschen überprüft.'],
|
||||
'steps': ['Öffnen Sie Dateien und wählen Sie die Dateien '
|
||||
'oder den Ordner zum Löschen aus.',
|
||||
'Überprüfen Sie die Auswahl und, für einen '
|
||||
'Ordner, alle Inhalte darunter.',
|
||||
'Bestätigen Sie die Löschaktion.',
|
||||
'Erfrischen oder öffnen Sie den Raum und '
|
||||
'überprüfen Sie, ob die ausgewählten Pfade '
|
||||
'nicht mehr aktiv sind.'],
|
||||
'verification': 'Bestätigen Sie, dass die gelöschten '
|
||||
'Pfade nicht mehr im aktiven Raum '
|
||||
'erscheinen; behandeln Sie die Aktion '
|
||||
'nicht als physisches Löschen.'},
|
||||
'files.workflow.find-and-download-files': {'outcome': 'Die autorisierten aktuellen Dateibytes '
|
||||
'oder das generierte Archiv werden auf das '
|
||||
'lokale Gerät heruntergeladen.',
|
||||
'prerequisites': ['Sie können verwaltete Dateien im '
|
||||
'entsprechenden Bereich anzeigen und '
|
||||
'herunterladen.'],
|
||||
'steps': ['Öffnen Sie Dateien und wählen Sie den '
|
||||
'entsprechenden Personen- oder Gruppenraum '
|
||||
'aus.',
|
||||
'Navigieren Sie in Ordnern, sortieren Sie '
|
||||
'die Liste oder verwenden Sie ein Pfad- / '
|
||||
'Namensmuster, um den beabsichtigten Inhalt '
|
||||
'zu finden.',
|
||||
'Überprüfen Sie den angezeigten Besitzer, '
|
||||
'Pfad, Größe, Prüfsumme und Versionsdetails.',
|
||||
'Laden Sie eine aktuelle Dateiversion '
|
||||
'herunter oder wählen Sie mehrere Dateien '
|
||||
'aus und wählen Sie ZIP herunterladen.'],
|
||||
'verification': 'Bestätigen Sie die heruntergeladenen '
|
||||
'Namen und vergleichen Sie, wo '
|
||||
'Integrität wichtig ist, die '
|
||||
'Dateibytes mit der angezeigten '
|
||||
'Prüfsumme.'},
|
||||
'files.workflow.organize-managed-files': {'outcome': 'Der ausgewählte Inhalt hat den '
|
||||
'beabsichtigten verwalteten Eigentümer und '
|
||||
'logischen Pfad.',
|
||||
'prerequisites': ['Sie können verwaltete Dateien '
|
||||
'anzeigen und organisieren.',
|
||||
'Sie haben Schreib- oder '
|
||||
'Eigentümerzugriff auf alle von der '
|
||||
'Operation verwendeten Quellelemente '
|
||||
'und Zielbereiche; die globale '
|
||||
'Organisationsberechtigung allein '
|
||||
'gewährt keinen Ressourcenzugriff.'],
|
||||
'steps': ['Öffnen Sie Dateien und wählen Sie den '
|
||||
'persönlichen oder Gruppenraum zum '
|
||||
'Organisieren aus.',
|
||||
'Erstellen Sie die erforderlichen Zielordner '
|
||||
'oder wählen Sie die Dateien und Ordner aus, '
|
||||
'um sie umzubenennen, zu verschieben oder zu '
|
||||
'kopieren.',
|
||||
'Wählen Sie das Ziel aus und lösen Sie jeden '
|
||||
'Zielkonflikt explizit.',
|
||||
'Wenden Sie die Operation an und öffnen Sie '
|
||||
'den Zielordner erneut.'],
|
||||
'verification': 'Bestätigen Sie jeden resultierenden '
|
||||
'Pfad und Besitzer; für eine Bewegung '
|
||||
'bestätigen Sie auch, dass der alte '
|
||||
'Pfad verschwunden ist, und für eine '
|
||||
'Kopie bestätigen Sie, dass die Quelle '
|
||||
'verbleibt.'},
|
||||
'files.workflow.restore-retain-and-purge': {'limitations': ['Automatische zeitbasierte '
|
||||
'Bereinigungsplanung ist nicht '
|
||||
'enthalten; ein autorisierter '
|
||||
'handelnde Person startet Vorschau, '
|
||||
'Ausführung und Blob-Sammlung.',
|
||||
'Der Verschlüsselungsumschlag eines '
|
||||
'geschützten Blobs folgt der eigenen '
|
||||
'Aufbewahrungs- und '
|
||||
'Schlüssel-Verwahrungsrichtlinie des '
|
||||
'Verschlüsselungsmoduls.'],
|
||||
'outcome': 'Berechtigte Metadaten und nicht '
|
||||
'referenzierte Bytes werden mit separat '
|
||||
'überprüfbaren Richtlinien-, Audit- und '
|
||||
'Wiederherstellungsnachweisen gelöscht.',
|
||||
'prerequisites': ['Die handelnde Person hat die '
|
||||
'eindeutige Wiederherstellungs-, '
|
||||
'Aufbewahrungs- oder '
|
||||
'Bereinigungsberechtigung, die für '
|
||||
'die beabsichtigte Aktion '
|
||||
'erforderlich ist.',
|
||||
'Ein Bereinigungsziel ist bereits '
|
||||
'sanft gelöscht und alle abhängigen '
|
||||
'Nachweise oder Aktien wurden '
|
||||
'rechtmäßig gelöst.'],
|
||||
'steps': ['Festlegen oder Überprüfen von '
|
||||
'Aufbewahrungs- und Haltezustand unter '
|
||||
'Verwendung der angezeigten '
|
||||
'Lifecycle-Revision.',
|
||||
'Überprüfen Sie zum Wiederherstellen, ob '
|
||||
'der ursprüngliche Pfad oder das '
|
||||
'ursprüngliche Label frei ist, und stellen '
|
||||
'Sie die gleiche Ressource wieder her.',
|
||||
'Erstellen Sie zum Löschen eine begrenzte '
|
||||
'Purge-Vorschau und lösen Sie jeden '
|
||||
'benannten Blocker auf.',
|
||||
'Führen Sie die passende Vorschau mit einem '
|
||||
'idempotency-Schlüssel, PURGE-Bestätigung '
|
||||
'und Genehmigungsreferenz aus.',
|
||||
'Führen Sie eine begrenzte '
|
||||
'Blob-Garbage-Sammlung aus und prüfen Sie '
|
||||
'alle Wiederherstellungs- oder '
|
||||
'Ergebnis-unbekannten Operationen in Ops.'],
|
||||
'verification': 'Überprüfen Sie, ob die '
|
||||
'Asset-/Versionszeilen fehlen, der '
|
||||
'Blob bleibt referenziert, nicht '
|
||||
'referenzierte Bytes fehlen nach GC '
|
||||
'und jede Operation hat eine gültige '
|
||||
'Wiederherstellungsnachweiskette.'},
|
||||
'files.workflow.share-managed-files': {'limitations': ['Kampagnenziel-Zuschüsse werden '
|
||||
'normalerweise durch die '
|
||||
'Kampagnenintegration erstellt und nicht '
|
||||
'manuell im Dateidialog ausgewählt.',
|
||||
'Ein Benutzer kann den Zugriff über einen '
|
||||
'anderen aktiven direkten Zuschuss oder '
|
||||
'Besitzpfad behalten, nachdem eine Aktie '
|
||||
'widerrufen wurde.',
|
||||
'Ausgewählte benutzererklärungen erfordern '
|
||||
'die richtlinienberechtigung und sind nur '
|
||||
'diagnosen; sie geben sich nicht als '
|
||||
'benutzer aus oder gewähren dateizugriff.'],
|
||||
'outcome': 'Direkter Zugriff hat die angeforderte '
|
||||
'Berechtigung und Lebensdauer, während das '
|
||||
'verwaltete Asset seinen Eigentümer behält.',
|
||||
'prerequisites': ['Sie haben die Freigabeberechtigung für '
|
||||
'Dateien und besitzen die Datei oder '
|
||||
'verwalten Dateiräume für den aktiven '
|
||||
'Mandanten.',
|
||||
'Das beabsichtigte Benutzer-, Gruppen-, '
|
||||
'Mandant- oder Kampagnenziel existiert '
|
||||
'und ist aktiv.'],
|
||||
'steps': ['Wählen Sie eine verwaltete Datei aus, wählen '
|
||||
'Sie Shares verwalten und überprüfen Sie die '
|
||||
'effektiven und historischen direkten Zuschüsse.',
|
||||
'Wählen Sie den beabsichtigten Benutzer, die '
|
||||
'Gruppe oder den Mandanten aus und wählen Sie '
|
||||
'den Zugriff lesen, schreiben oder verwalten '
|
||||
'sowie einen optionalen Ablauf.',
|
||||
'Geben oder aktualisieren Sie die Freigabe, ohne '
|
||||
'den Dateibesitz zu ändern.',
|
||||
'Widerrufen Sie einen Zuschuss, wenn er nicht '
|
||||
'mehr benötigt wird; wiederholter Widerruf ist '
|
||||
'ein No-Op.'],
|
||||
'verification': 'Testen Sie einen beabsichtigten und einen '
|
||||
'verweigerten Pfad, verfallen oder '
|
||||
'widerrufen Sie die Gewährung und stellen '
|
||||
'Sie sicher, dass nur noch unabhängige '
|
||||
'Zugangspfade verbleiben.'}}
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_files.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -34,7 +37,9 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
@@ -52,9 +57,13 @@ from govoplan_core.db.base import Base
|
||||
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||
from govoplan_files.backend.configuration_provider import (
|
||||
FILES_CONFIGURATION_CAPABILITY,
|
||||
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
|
||||
)
|
||||
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
||||
from govoplan_files.backend.documentation import documentation_topics
|
||||
from govoplan_files.backend.german_documentation import (
|
||||
localize_documentation_topics,
|
||||
)
|
||||
from govoplan_files.backend.dsar_provider import FILES_DSAR_CAPABILITY
|
||||
from govoplan_files.backend.form_evidence import (
|
||||
CAPABILITY_FORM_EVIDENCE_FILES,
|
||||
@@ -452,7 +461,7 @@ def _dsar_provider(context: ModuleContext) -> object:
|
||||
manifest = ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="0.1.19",
|
||||
version="0.1.25",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -599,6 +608,31 @@ manifest = ModuleManifest(
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="records.files",
|
||||
module_id="files",
|
||||
label="i18n:govoplan-core.product_surface.files",
|
||||
description="i18n:govoplan-core.product_surface.files_description",
|
||||
icon="folder",
|
||||
entry_path="/documents",
|
||||
route_path="/files",
|
||||
surface_ids=("files.nav.files", "files.route.files"),
|
||||
presentations=("task", "reader"),
|
||||
search_source_ids=("files.objects",),
|
||||
help_context_ids=("files.list",),
|
||||
documentation_topic_ids=("files.quick-access-and-product-area",),
|
||||
required_any=("files:file:read",),
|
||||
order=10,
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="i18n:govoplan-core.product_surface.unavailable",
|
||||
description="i18n:govoplan-core.product_surface.unavailable_description",
|
||||
resolution="i18n:govoplan-core.product_surface.unavailable_resolution",
|
||||
responsible_role="i18n:govoplan-core.access_administrator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="files.recent",
|
||||
@@ -617,7 +651,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
documentation=localize_documentation_topics((
|
||||
DocumentationTopic(
|
||||
id="files.tabular-content",
|
||||
title="Use managed CSV and XLSX versions as governed data sources",
|
||||
@@ -714,7 +748,8 @@ manifest = ModuleManifest(
|
||||
"The Files configuration provider validates the files.storage capability against the effective local or S3 runtime. "
|
||||
"It checks the backend, sanitized endpoint, bucket, trust or management marker, durable local path, and presence of referenced environment secrets. "
|
||||
"Matching configuration is already effective and therefore reports skip on every apply. Drift blocks the package; Files never copies secret values, rewrites process environment, "
|
||||
"or treats a storage replacement as an implicit migration. Run Files integrity and Ops checks after deployment changes."
|
||||
"or treats a storage replacement as an implicit migration. Before files.storage changes, Files reports the active runtime binding plus persisted blob counts and byte totals per backend "
|
||||
"through the non-secret Ops dependency inventory. Missing, stale, or incomplete inventory blocks host apply. Run Files integrity and Ops checks before and after deployment changes."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
@@ -746,7 +781,8 @@ manifest = ModuleManifest(
|
||||
"Der Files-Konfigurationsprovider prüft die Fähigkeit files.storage gegen die wirksame lokale oder S3-Laufzeitkonfiguration. "
|
||||
"Geprüft werden Backend, bereinigter Endpunkt, Bucket, Vertrauens- oder Verwaltungskennzeichen, dauerhafter lokaler Pfad sowie das Vorhandensein referenzierter Umgebungsgeheimnisse. "
|
||||
"Eine passende Konfiguration ist bereits wirksam und meldet deshalb bei jeder Anwendung skip. Abweichungen blockieren das Paket; Files kopiert keine Geheimwerte, verändert keine Prozessumgebung "
|
||||
"und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Nach Bereitstellungsänderungen sind die Integritäts- und Ops-Prüfungen auszuführen."
|
||||
"und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Vor einer Änderung von files.storage meldet Files die aktive Laufzeitbindung sowie gespeicherte Blob-Anzahlen und Byte-Summen je Backend "
|
||||
"im nicht geheimen Ops-Abhängigkeitsinventar. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Host-Anwendung. Die Integritäts- und Ops-Prüfungen sind vor und nach der Bereitstellungsänderung auszuführen."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -762,7 +798,8 @@ manifest = ModuleManifest(
|
||||
title="Files in Records and documents and Quick Access",
|
||||
summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.",
|
||||
body=(
|
||||
"Files contributes its authorized workspace to Records and documents. When Quick Access is enabled, the owner-rendered "
|
||||
"Files contributes its authorized workspace to the stable Files destination at /documents in Records and documents. "
|
||||
"The owner route /files remains available through All available tools and as a compatible deep link. When Quick Access is enabled, the owner-rendered "
|
||||
"selector shows at most seven recently changed files and can return one exact file-version reference to the current task. "
|
||||
"Accounts with upload permission can launch the full Files upload dialog into a freshly reauthorized managed space. Opening "
|
||||
"either path causes Files to re-run its own provider, space, folder, object, and scope checks; completion and cancellation are "
|
||||
@@ -792,7 +829,8 @@ manifest = ModuleManifest(
|
||||
"title": "Dateien in Akten und Dokumente sowie im Schnellzugriff",
|
||||
"summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.",
|
||||
"body": (
|
||||
"Files ordnet seinen berechtigten Arbeitsbereich Akten und Dokumente zu. Ist der Schnellzugriff aktiviert, erscheint "
|
||||
"Files ordnet seinen berechtigten Arbeitsbereich dem stabilen Produktziel Dateien unter /documents in Akten und Dokumente zu. "
|
||||
"Der Eigentümerpfad /files bleibt unter Alle verfügbaren Werkzeuge und als kompatibler Direktlink erreichbar. Ist der Schnellzugriff aktiviert, erscheint "
|
||||
"die vom Modul gerenderte Auswahl von höchstens sieben zuletzt geänderten berechtigten Dateien rechts neben der aktuellen "
|
||||
"Seite und kann einen exakten Dateiversionsverweis zurückgeben. Mit Upload-Berechtigung lässt sich der vollständige "
|
||||
"Upload-Dialog in einem erneut berechtigungsgeprüften verwalteten Bereich öffnen. Files prüft Anbieter, Bereich, Ordner, "
|
||||
@@ -1299,12 +1337,14 @@ manifest = ModuleManifest(
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="files.governed-connectors-and-provenance",
|
||||
title="Govern file connections and credential deletion",
|
||||
summary="Keep endpoint profiles, reusable credentials, and inherited connector policy separate, and understand what DELETE removes immediately.",
|
||||
title="Govern file connections, folder sync, and credential deletion",
|
||||
summary="Keep endpoints, credentials, inherited policy, and bounded manual synchronization separate, with reviewable outcomes and provenance.",
|
||||
body=(
|
||||
"System, tenant, and one user/group/campaign leaf form the effective policy chain: deny rules win and every configured allow rule must match. "
|
||||
"Responses redact secret values and deployment references. Deleting a database-managed credential or profile immediately scrubs Files-owned encrypted material and private metadata in the same transaction as a non-secret audit event; dependent profiles are disabled, while legacy non-owned references are only detached and audited. "
|
||||
"Removing a connector space is a separate owner-authorized operation: it retires only the local virtual-space link and leaves provider content, imported managed files and shares, profiles, credentials, and remote references untouched. Intrinsic user and group managed spaces cannot be removed. Connector spaces remain read-only by default. An administrator may explicitly enable two-way mode only for an S3 profile carrying the write capability; each write requires separate authority, inherited path policy, a conditional create or expected revision, and durable recovery evidence. Automatic remote deletion, rename, and move propagation remain disabled."
|
||||
"Removing a connector space is a separate owner-authorized operation: it retires only the local virtual-space link and leaves provider content, imported managed files and shares, profiles, credentials, and remote references untouched. Intrinsic user and group managed spaces cannot be removed. "
|
||||
"A manual connector-folder sync traverses provider pagination with explicit file-count and depth bounds, preserves remote relative paths in the linked owner's managed space, and evaluates policy for the root and every downloaded item. Matching source identities produce unchanged or version-appending updates; new identities create managed assets. Unrelated target collisions default to skip, while rename, reject, and overwrite are explicit choices. Per-file savepoints keep successful siblings while conflicts, skips, policy denials, and transport failures remain visible in the response. The files.connector.folder_synced audit event records provenance, typed counts, truncation, and bounded result references without content or credentials. Scheduling remains separate. "
|
||||
"Connector spaces remain read-only by default. An administrator may explicitly enable two-way mode only for an S3 profile carrying the write capability; each write requires separate authority, inherited path policy, a conditional create or expected revision, and durable recovery evidence. Automatic remote deletion, rename, and move propagation remain disabled."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
@@ -1343,12 +1383,30 @@ manifest = ModuleManifest(
|
||||
href="/api/v1/files/connectors/credentials",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Manual connector-folder sync API",
|
||||
href="/api/v1/files/connector-spaces/{space_id}/sync",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Files handbook",
|
||||
href="govoplan-files/docs/FILES_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dateiverbindungen, Ordnersynchronisierung und das Löschen von Zugangsdaten steuern",
|
||||
"summary": "Endpunkte, Zugangsdaten, vererbte Richtlinien und begrenzte manuelle Synchronisierung getrennt und mit prüfbaren Ergebnissen sowie Herkunftsnachweisen verwalten.",
|
||||
"body": (
|
||||
"System, Mandant und genau eine Benutzer-, Gruppen- oder Kampagnenebene bilden die wirksame Richtlinienkette: Ablehnungsregeln haben Vorrang und jede konfigurierte Erlaubnisregel muss zutreffen. "
|
||||
"Antworten blenden Geheimwerte und Bereitstellungsverweise aus. Beim Löschen datenbankverwalteter Zugangsdaten oder Profile entfernt Files eigenes verschlüsseltes Material und private Metadaten in derselben Transaktion wie das nicht geheime Audit-Ereignis. Abhängige Profile werden deaktiviert; ältere, nicht Files gehörende Verweise werden nur getrennt und auditiert. "
|
||||
"Das Entfernen eines Connector-Bereichs ist ein eigener, eigentümerberechtigter Vorgang: Nur die lokale Verknüpfung des virtuellen Bereichs wird außer Kraft gesetzt. Inhalte beim Anbieter, importierte verwaltete Dateien und Freigaben, Profile, Zugangsdaten und Remote-Verweise bleiben erhalten. Intrinsische Benutzer- und Gruppenbereiche können nicht entfernt werden. "
|
||||
"Eine manuelle Connector-Ordnersynchronisierung durchläuft die Anbieter-Paginierung innerhalb ausdrücklicher Grenzen für Dateizahl und Tiefe, erhält relative Remote-Pfade im verwalteten Bereich des verknüpften Eigentümers und prüft die Richtlinie für die Wurzel sowie jede heruntergeladene Datei. Übereinstimmende Quellidentitäten bleiben unverändert oder erhalten eine neue Version; neue Identitäten erzeugen verwaltete Dateien. Nicht zugehörige Zielkonflikte werden standardmäßig übersprungen; Umbenennen, Ablehnen und Ersetzen sind ausdrückliche Entscheidungen. Savepoints je Datei bewahren erfolgreiche Geschwister, während Konflikte, Überspringungen, Richtlinienablehnungen und Transportfehler in der Antwort prüfbar bleiben. Das Audit-Ereignis files.connector.folder_synced hält Herkunft, typisierte Zähler, Abbruch am Grenzwert und begrenzte Ergebnisverweise ohne Inhalte oder Zugangsdaten fest. Zeitplanung bleibt getrennt. "
|
||||
"Connector-Bereiche bleiben standardmäßig schreibgeschützt. Eine Administration kann den Zweiwege-Modus nur für ein S3-Profil mit Schreib-Capability ausdrücklich aktivieren. Jeder Schreibvorgang benötigt eigene Berechtigung, die vererbte Pfadrichtlinie, eine bedingte Neuanlage oder erwartete Revision sowie dauerhafte Wiederherstellungsnachweise. Automatisches Löschen, Umbenennen und Verschieben auf dem Remote-System bleibt deaktiviert."
|
||||
),
|
||||
}
|
||||
},
|
||||
related_modules=("access", "audit", "mail"),
|
||||
unlocks=(
|
||||
"Scoped, explainable external-file access without exposing credentials to consuming modules.",
|
||||
@@ -1369,12 +1427,34 @@ manifest = ModuleManifest(
|
||||
"files.connectors",
|
||||
"files.connector.credentials",
|
||||
"files.connector.policy",
|
||||
"files.connector-folder-sync.remote-path",
|
||||
"files.connector-folder-sync.target-folder",
|
||||
"files.connector-folder-sync.conflict-strategy",
|
||||
"files.connector-folder-sync.max-files",
|
||||
"files.connector-folder-sync.recursive",
|
||||
],
|
||||
"prerequisites": [
|
||||
"A visible active connector space uses manual sync and links to an accessible managed user or group space.",
|
||||
"The operator has files:file:upload and the effective connector policy permits the requested root and source paths.",
|
||||
],
|
||||
"steps": [
|
||||
"Open the linked connector folder, choose Sync folder, and review the managed destination.",
|
||||
"Keep the safe skip default or explicitly choose rename, reject, or overwrite for unrelated target collisions.",
|
||||
"Set the bounded file count and subfolder choice, run the sync, and review every typed item outcome and any truncation warning.",
|
||||
"Continue a truncated run from a narrower remote folder; configure scheduling separately if background operation is required.",
|
||||
],
|
||||
"limitations": [
|
||||
"Manual folder sync does not schedule future runs or mutate, delete, rename, move, or change ACLs on remote content.",
|
||||
"A successful partial run is not complete coverage when the response is truncated or contains review outcomes.",
|
||||
],
|
||||
"outcome": "Permitted connector files become governed managed assets or versions while every exception remains explicit and auditable.",
|
||||
"verification": "Compare the response summary and per-item outcomes with the files.connector.folder_synced audit event, then inspect provenance on representative created, updated, and unchanged assets.",
|
||||
"security_invariants": [
|
||||
"New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.",
|
||||
"Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.",
|
||||
"Legacy non-owned external references are detached and audited, never sent to an arbitrary provider delete operation.",
|
||||
"Connector-space removal is local and soft; it never claims to delete remote or previously imported managed content.",
|
||||
"Folder sync is bounded, path-contained, policy-checked per item, and never includes remote bytes or credentials in audit details.",
|
||||
"Two-way connector mode is explicit and S3-only; it never enables automatic remote delete, rename, move, or ACL propagation.",
|
||||
],
|
||||
"related_topic_ids": [
|
||||
@@ -1708,7 +1788,7 @@ manifest = ModuleManifest(
|
||||
related_modules=("templates", "campaigns", "reporting"),
|
||||
metadata={"kind": "reference", "route": "/files"},
|
||||
),
|
||||
),
|
||||
)),
|
||||
documentation_providers=(documentation_topics,),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="files",
|
||||
@@ -1736,6 +1816,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
capability_factories={
|
||||
FILES_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: _configuration_provider,
|
||||
CAPABILITY_FILES_ACCESS: lambda context: __import__(
|
||||
"govoplan_files.backend.capabilities", fromlist=["access_capability"]
|
||||
).access_capability(context),
|
||||
@@ -1834,5 +1915,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -9,6 +9,10 @@ from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_files.backend.schemas import (
|
||||
FileConnectorBrowseItem,
|
||||
FileConnectorBrowseResponse,
|
||||
FileConnectorFolderSyncItemResponse,
|
||||
FileConnectorFolderSyncRequest,
|
||||
FileConnectorFolderSyncResponse,
|
||||
FileConnectorFolderSyncSummary,
|
||||
FileConnectorImportRequest,
|
||||
FileConnectorSyncResponse,
|
||||
FileConnectorWriteRequest,
|
||||
@@ -28,6 +32,11 @@ from govoplan_files.backend.storage.connector_imports import (
|
||||
ConnectorImportError,
|
||||
ConnectorImportUnsupported,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_folder_sync import (
|
||||
connector_relative_path,
|
||||
discover_connector_folder,
|
||||
join_connector_path,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_deployment import (
|
||||
connector_effective_endpoint_url,
|
||||
)
|
||||
@@ -43,6 +52,7 @@ from govoplan_files.backend.storage.files import (
|
||||
sync_file_asset_from_source,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_spaces import (
|
||||
connector_space_owner_id,
|
||||
get_connector_space_for_user,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_writes import write_connector_file
|
||||
@@ -54,6 +64,7 @@ from govoplan_files.backend.route_support import (
|
||||
_audit_connector_sync,
|
||||
_connector_browse_next_token,
|
||||
_connector_policy_error,
|
||||
_connector_space_policy_decision,
|
||||
_download_connector_payload,
|
||||
_ensure_campaign_file_access,
|
||||
_http_error,
|
||||
@@ -64,6 +75,259 @@ from govoplan_files.backend.route_support import (
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/connector-spaces/{space_id}/sync",
|
||||
response_model=FileConnectorFolderSyncResponse,
|
||||
)
|
||||
def sync_connector_space_folder(
|
||||
space_id: str,
|
||||
payload: FileConnectorFolderSyncRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("files:file:upload")),
|
||||
):
|
||||
try:
|
||||
space = get_connector_space_for_user(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
space_id=space_id,
|
||||
is_admin=_is_admin(principal),
|
||||
)
|
||||
except FileStorageError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc, not_found=True) from exc
|
||||
|
||||
try:
|
||||
if space.sync_mode != "manual":
|
||||
raise FileStorageError("This connector space is not configured for manual sync")
|
||||
profile = _visible_connector_profile(
|
||||
session, principal, space.connector_profile_id
|
||||
)
|
||||
remote_path = join_connector_path(space.remote_path, payload.path)
|
||||
target_folder = join_connector_path(payload.target_folder)
|
||||
decision = _connector_space_policy_decision(
|
||||
profile,
|
||||
library_id=space.library_id,
|
||||
remote_path=remote_path,
|
||||
operation="sync",
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise ConnectorPolicyDenied(decision)
|
||||
discovery = discover_connector_folder(
|
||||
profile,
|
||||
path=remote_path,
|
||||
library_id=space.library_id,
|
||||
recursive=payload.recursive,
|
||||
max_files=payload.max_files,
|
||||
max_depth=payload.max_depth,
|
||||
)
|
||||
except ConnectorPolicyDenied as exc:
|
||||
session.rollback()
|
||||
raise _connector_policy_error(exc) from exc
|
||||
except ConnectorBrowseUnsupported as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
|
||||
) from exc
|
||||
except (ConnectorBrowseError, FileStorageError, ValueError) as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
owner_id = connector_space_owner_id(space)
|
||||
items: list[FileConnectorFolderSyncItemResponse] = [
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=skipped.path,
|
||||
action="skipped",
|
||||
detail=skipped.reason,
|
||||
)
|
||||
for skipped in discovery.skipped
|
||||
]
|
||||
counts = {
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": len(discovery.skipped),
|
||||
"conflicts": 0,
|
||||
"policy_denied": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
for source in discovery.files:
|
||||
source_path = normalize_connector_browse_path(source.path)
|
||||
try:
|
||||
relative_path = connector_relative_path(
|
||||
space_root=space.remote_path,
|
||||
item_path=source_path,
|
||||
)
|
||||
target_path = join_connector_path(target_folder, relative_path)
|
||||
except (ConnectorBrowseError, ValueError) as exc:
|
||||
counts["failed"] += 1
|
||||
items.append(
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=source_path,
|
||||
action="failed",
|
||||
detail=str(exc),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
with session.begin_nested():
|
||||
file_payload = FileConnectorImportRequest(
|
||||
library_id=space.library_id or "",
|
||||
path=source_path,
|
||||
owner_type=space.owner_type,
|
||||
owner_id=owner_id,
|
||||
target_path=target_path,
|
||||
conflict_strategy=payload.conflict_strategy,
|
||||
source_revision=source.etag,
|
||||
metadata={
|
||||
**dict(source.metadata),
|
||||
**payload.metadata,
|
||||
"connector_space_id": space.id,
|
||||
"browse_name": source.name,
|
||||
"browse_path": source_path,
|
||||
"browse_modified_at": source.modified_at,
|
||||
"browse_etag": source.etag,
|
||||
"folder_sync": True,
|
||||
},
|
||||
)
|
||||
_source_path, downloaded, metadata = _download_connector_payload(
|
||||
profile, file_payload, operation="sync"
|
||||
)
|
||||
stored, sync_action, previous_version_id = (
|
||||
sync_file_asset_from_source(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
owner_type=space.owner_type,
|
||||
owner_id=owner_id,
|
||||
user_id=principal.user.id,
|
||||
filename=downloaded.filename,
|
||||
data=downloaded.data,
|
||||
display_path=target_path,
|
||||
content_type=downloaded.content_type,
|
||||
metadata=metadata,
|
||||
conflict_strategy=payload.conflict_strategy,
|
||||
is_admin=_is_admin(principal),
|
||||
)
|
||||
)
|
||||
_audit_connector_sync(
|
||||
session,
|
||||
principal,
|
||||
stored.asset,
|
||||
sync_action=sync_action,
|
||||
previous_version_id=previous_version_id,
|
||||
)
|
||||
file_response = _asset_response(
|
||||
session, stored.asset, include_shares=True
|
||||
)
|
||||
counts[sync_action] += 1
|
||||
items.append(
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
action=sync_action,
|
||||
file=file_response,
|
||||
previous_version_id=previous_version_id,
|
||||
current_version_id=stored.version.id,
|
||||
source_revision=file_response.source_revision,
|
||||
)
|
||||
)
|
||||
except ConnectorPolicyDenied as exc:
|
||||
counts["policy_denied"] += 1
|
||||
items.append(
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
action="policy_denied",
|
||||
source_revision=source.etag,
|
||||
detail=str(exc),
|
||||
policy_decision=exc.decision.to_dict(),
|
||||
)
|
||||
)
|
||||
except FileStorageError as exc:
|
||||
detail = str(exc)
|
||||
if detail.startswith("Skipped upload target:"):
|
||||
action = "skipped"
|
||||
counts["skipped"] += 1
|
||||
elif detail.startswith("Target file already exists:"):
|
||||
action = "conflict"
|
||||
counts["conflicts"] += 1
|
||||
else:
|
||||
action = "failed"
|
||||
counts["failed"] += 1
|
||||
items.append(
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
action=action,
|
||||
source_revision=source.etag,
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
except (
|
||||
ConnectorImportError,
|
||||
UnsafeFilePathError,
|
||||
OSError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
) as exc:
|
||||
counts["failed"] += 1
|
||||
items.append(
|
||||
FileConnectorFolderSyncItemResponse(
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
action="failed",
|
||||
source_revision=source.etag,
|
||||
detail=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
summary = FileConnectorFolderSyncSummary(
|
||||
discovered=len(discovery.files),
|
||||
**counts,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="files.connector.folder_synced",
|
||||
object_type="file_connector_space",
|
||||
object_id=space.id,
|
||||
details={
|
||||
"connector_profile_id": profile.id,
|
||||
"provider": profile.provider,
|
||||
"library_id": space.library_id,
|
||||
"remote_path": remote_path,
|
||||
"target_folder": target_folder,
|
||||
"recursive": payload.recursive,
|
||||
"truncated": discovery.truncated,
|
||||
"summary": summary.model_dump(),
|
||||
"results": [
|
||||
{
|
||||
"source_path": item.source_path,
|
||||
"target_path": item.target_path,
|
||||
"action": item.action,
|
||||
"file_id": item.file.id if item.file else None,
|
||||
"current_version_id": item.current_version_id,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return FileConnectorFolderSyncResponse(
|
||||
connector_space_id=space.id,
|
||||
connector_profile_id=profile.id,
|
||||
provider=profile.provider,
|
||||
remote_path=remote_path,
|
||||
target_folder=target_folder,
|
||||
recursive=payload.recursive,
|
||||
truncated=discovery.truncated,
|
||||
summary=summary,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/connector-spaces/{space_id}/write-back",
|
||||
response_model=FileConnectorWriteResponse,
|
||||
|
||||
@@ -530,7 +530,7 @@ class FileConnectorImportRequest(BaseModel):
|
||||
target_folder: str | None = None
|
||||
target_path: str | None = None
|
||||
campaign_id: str | None = None
|
||||
conflict_strategy: Literal["reject", "overwrite", "rename"] = "reject"
|
||||
conflict_strategy: Literal["reject", "overwrite", "rename", "skip"] = "reject"
|
||||
source_revision: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -542,6 +542,59 @@ class FileConnectorSyncResponse(BaseModel):
|
||||
current_version_id: str
|
||||
|
||||
|
||||
class FileConnectorFolderSyncRequest(BaseModel):
|
||||
path: str = ""
|
||||
target_folder: str | None = None
|
||||
recursive: bool = True
|
||||
conflict_strategy: Literal["reject", "overwrite", "rename", "skip"] = "skip"
|
||||
max_files: int = Field(default=100, ge=1, le=500)
|
||||
max_depth: int = Field(default=12, ge=0, le=50)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FileConnectorFolderSyncItemResponse(BaseModel):
|
||||
source_path: str
|
||||
target_path: str | None = None
|
||||
action: Literal[
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"skipped",
|
||||
"conflict",
|
||||
"policy_denied",
|
||||
"failed",
|
||||
]
|
||||
file: FileAssetResponse | None = None
|
||||
previous_version_id: str | None = None
|
||||
current_version_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
detail: str | None = None
|
||||
policy_decision: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FileConnectorFolderSyncSummary(BaseModel):
|
||||
discovered: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
unchanged: int = 0
|
||||
skipped: int = 0
|
||||
conflicts: int = 0
|
||||
policy_denied: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
class FileConnectorFolderSyncResponse(BaseModel):
|
||||
connector_space_id: str
|
||||
connector_profile_id: str
|
||||
provider: str
|
||||
remote_path: str
|
||||
target_folder: str
|
||||
recursive: bool
|
||||
truncated: bool = False
|
||||
summary: FileConnectorFolderSyncSummary
|
||||
items: list[FileConnectorFolderSyncItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FileConnectorWriteRequest(BaseModel):
|
||||
file_id: str
|
||||
remote_path: str
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from govoplan_files.backend.storage.connector_browse import (
|
||||
ConnectorBrowseItem,
|
||||
browse_connector_profile,
|
||||
normalize_connector_browse_path,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorFolderSkip:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectorFolderDiscovery:
|
||||
files: list[ConnectorBrowseItem] = field(default_factory=list)
|
||||
skipped: list[ConnectorFolderSkip] = field(default_factory=list)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
def discover_connector_folder(
|
||||
profile: ConnectorProfile,
|
||||
*,
|
||||
path: str,
|
||||
library_id: str | None,
|
||||
recursive: bool,
|
||||
max_files: int,
|
||||
max_depth: int,
|
||||
) -> ConnectorFolderDiscovery:
|
||||
"""Discover a bounded, deterministic set of files below one connector folder."""
|
||||
|
||||
root_path = normalize_connector_browse_path(path)
|
||||
queue: deque[tuple[str, int]] = deque([(root_path, 0)])
|
||||
visited_folders: set[str] = set()
|
||||
seen_files: set[tuple[str, str]] = set()
|
||||
result = ConnectorFolderDiscovery()
|
||||
|
||||
while queue:
|
||||
folder_path, depth = queue.popleft()
|
||||
if folder_path in visited_folders:
|
||||
continue
|
||||
visited_folders.add(folder_path)
|
||||
|
||||
continuation_token: str | None = None
|
||||
seen_tokens: set[str] = set()
|
||||
while True:
|
||||
items = browse_connector_profile(
|
||||
profile,
|
||||
path=folder_path,
|
||||
library_id=library_id,
|
||||
continuation_token=continuation_token,
|
||||
)
|
||||
for item in items:
|
||||
item_path = normalize_connector_browse_path(item.path)
|
||||
if item.kind == "file":
|
||||
identity = (str(item.external_id or ""), item_path)
|
||||
if identity in seen_files:
|
||||
continue
|
||||
if len(result.files) >= max_files:
|
||||
result.truncated = True
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path or folder_path,
|
||||
reason=f"The manual sync limit of {max_files} files was reached.",
|
||||
)
|
||||
)
|
||||
return result
|
||||
seen_files.add(identity)
|
||||
result.files.append(item)
|
||||
continue
|
||||
|
||||
if item.kind == "folder":
|
||||
if not recursive:
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason="Subfolder skipped because recursive sync is disabled.",
|
||||
)
|
||||
)
|
||||
elif depth >= max_depth:
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason=f"Subfolder skipped at the configured depth limit of {max_depth}.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
queue.append((item_path, depth + 1))
|
||||
continue
|
||||
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason=f"Connector item kind {item.kind!r} cannot be synchronized as a file.",
|
||||
)
|
||||
)
|
||||
|
||||
next_token = _next_continuation_token(items)
|
||||
if not next_token or next_token in seen_tokens:
|
||||
break
|
||||
seen_tokens.add(next_token)
|
||||
continuation_token = next_token
|
||||
|
||||
result.files.sort(key=lambda item: normalize_connector_browse_path(item.path).casefold())
|
||||
result.skipped.sort(key=lambda item: (item.path.casefold(), item.reason))
|
||||
return result
|
||||
|
||||
|
||||
def connector_relative_path(*, space_root: str, item_path: str) -> str:
|
||||
root = normalize_connector_browse_path(space_root)
|
||||
item = normalize_connector_browse_path(item_path)
|
||||
if not root:
|
||||
return item
|
||||
if item == root:
|
||||
return item.rsplit("/", 1)[-1]
|
||||
prefix = f"{root}/"
|
||||
if not item.startswith(prefix):
|
||||
raise ValueError("Connector returned a file outside the linked connector-space root")
|
||||
return item[len(prefix) :]
|
||||
|
||||
|
||||
def join_connector_path(*parts: str | None) -> str:
|
||||
return normalize_connector_browse_path(
|
||||
"/".join(str(part or "").strip("/\\") for part in parts if str(part or "").strip("/\\"))
|
||||
)
|
||||
|
||||
|
||||
def _next_continuation_token(items: list[ConnectorBrowseItem]) -> str | None:
|
||||
for item in reversed(items):
|
||||
value = item.metadata.get("next_continuation_token")
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return None
|
||||
@@ -1108,7 +1108,7 @@ def _resolution_by_path(conflict_resolutions: Iterable[FileConflictResolution] |
|
||||
|
||||
def _normalize_conflict_strategy(strategy: str | None) -> str:
|
||||
normalized = (strategy or "reject").lower().strip()
|
||||
if normalized not in {"reject", "overwrite", "rename"}:
|
||||
if normalized not in {"reject", "overwrite", "rename", "skip"}:
|
||||
raise FileStorageError("Unsupported conflict strategy")
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPreflightContext,
|
||||
@@ -10,10 +14,14 @@ from govoplan_core.core.configuration_packages import (
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_files.backend.configuration_provider import (
|
||||
FILES_CONFIGURATION_CAPABILITY,
|
||||
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
|
||||
FilesConfigurationProvider,
|
||||
)
|
||||
from govoplan_files.backend.db.models import FileBlob
|
||||
from govoplan_files.backend.manifest import manifest
|
||||
|
||||
|
||||
@@ -78,6 +86,67 @@ def _s3_settings(**overrides):
|
||||
class FilesConfigurationProviderTests(unittest.TestCase):
|
||||
def test_provider_is_registered(self) -> None:
|
||||
self.assertIn(FILES_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(
|
||||
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
def test_inventory_reports_runtime_binding_and_persisted_blob_aggregate(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-files-inventory-") as root:
|
||||
database_path = Path(root) / "files.sqlite3"
|
||||
engine = create_engine(f"sqlite:///{database_path}")
|
||||
Base.metadata.create_all(engine, tables=(FileBlob.__table__,))
|
||||
configure_database(
|
||||
f"sqlite:///{database_path}",
|
||||
engine=engine,
|
||||
dispose_previous=True,
|
||||
)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
FileBlob.__table__.insert(),
|
||||
[
|
||||
{
|
||||
"id": "blob-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"storage_backend": "local",
|
||||
"storage_key": "tenant-1/a",
|
||||
"checksum_sha256": "a" * 64,
|
||||
"size_bytes": 7,
|
||||
"protection_discriminator": "plaintext",
|
||||
"ref_count": 1,
|
||||
"integrity_status": "unchecked",
|
||||
},
|
||||
{
|
||||
"id": "blob-2",
|
||||
"tenant_id": "tenant-1",
|
||||
"storage_backend": "local",
|
||||
"storage_key": "tenant-1/b",
|
||||
"checksum_sha256": "b" * 64,
|
||||
"size_bytes": 11,
|
||||
"protection_discriminator": "plaintext",
|
||||
"ref_count": 1,
|
||||
"integrity_status": "unchecked",
|
||||
},
|
||||
],
|
||||
)
|
||||
provider = FilesConfigurationProvider(
|
||||
settings=_local_settings(),
|
||||
environment={},
|
||||
)
|
||||
|
||||
dependencies = provider.infrastructure_dependencies()
|
||||
finally:
|
||||
reset_database()
|
||||
engine.dispose()
|
||||
|
||||
self.assertEqual(
|
||||
["runtime_storage_binding", "stored_blob_set"],
|
||||
[item.dependency_type for item in dependencies],
|
||||
)
|
||||
blob_set = dependencies[1]
|
||||
self.assertEqual(2, blob_set.metrics["blob_count"])
|
||||
self.assertEqual(18, blob_set.metrics["content_bytes"])
|
||||
|
||||
def test_matching_local_storage_is_an_idempotent_noop(self) -> None:
|
||||
provider = FilesConfigurationProvider(
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
from govoplan_files.backend.routes.connector_io import sync_connector_space_folder
|
||||
from govoplan_files.backend.schemas import (
|
||||
FileAssetResponse,
|
||||
FileConnectorFolderSyncRequest,
|
||||
)
|
||||
from govoplan_files.backend.storage.common import FileStorageError, UploadedStoredFile
|
||||
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseItem
|
||||
from govoplan_files.backend.storage.connector_folder_sync import (
|
||||
connector_relative_path,
|
||||
discover_connector_folder,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_imports import ConnectorDownloadedFile
|
||||
from govoplan_files.backend.storage.connector_policy import ConnectorPolicyDenied
|
||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||
|
||||
|
||||
def _item(kind: str, path: str, *, token: str | None = None) -> ConnectorBrowseItem:
|
||||
return ConnectorBrowseItem(
|
||||
kind=kind,
|
||||
name=path.rsplit("/", 1)[-1],
|
||||
path=path,
|
||||
external_id=f"external:{path}",
|
||||
etag=f"etag:{path}",
|
||||
metadata={"next_continuation_token": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
class ConnectorFolderDiscoveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = ConnectorProfile(
|
||||
id="profile-1",
|
||||
label="Documents",
|
||||
provider="webdav",
|
||||
capabilities=("browse", "sync"),
|
||||
)
|
||||
|
||||
@patch("govoplan_files.backend.storage.connector_folder_sync.browse_connector_profile")
|
||||
def test_recursively_discovers_files_across_pages_in_deterministic_order(
|
||||
self, browse: Mock
|
||||
) -> None:
|
||||
browse.side_effect = [
|
||||
[_item("file", "root/z.txt", token="page-2")],
|
||||
[_item("folder", "root/nested"), _item("file", "root/a.txt")],
|
||||
[_item("file", "root/nested/b.txt")],
|
||||
]
|
||||
|
||||
result = discover_connector_folder(
|
||||
self.profile,
|
||||
path="root",
|
||||
library_id="library-1",
|
||||
recursive=True,
|
||||
max_files=10,
|
||||
max_depth=3,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["root/a.txt", "root/nested/b.txt", "root/z.txt"],
|
||||
[item.path for item in result.files],
|
||||
)
|
||||
self.assertFalse(result.truncated)
|
||||
self.assertEqual([], result.skipped)
|
||||
self.assertEqual(
|
||||
[
|
||||
call(
|
||||
self.profile,
|
||||
path="root",
|
||||
library_id="library-1",
|
||||
continuation_token=None,
|
||||
),
|
||||
call(
|
||||
self.profile,
|
||||
path="root",
|
||||
library_id="library-1",
|
||||
continuation_token="page-2",
|
||||
),
|
||||
call(
|
||||
self.profile,
|
||||
path="root/nested",
|
||||
library_id="library-1",
|
||||
continuation_token=None,
|
||||
),
|
||||
],
|
||||
browse.call_args_list,
|
||||
)
|
||||
|
||||
@patch("govoplan_files.backend.storage.connector_folder_sync.browse_connector_profile")
|
||||
def test_non_recursive_and_file_limit_skips_are_explicit(self, browse: Mock) -> None:
|
||||
browse.return_value = [
|
||||
_item("folder", "root/nested"),
|
||||
_item("file", "root/a.txt"),
|
||||
_item("file", "root/b.txt"),
|
||||
]
|
||||
|
||||
result = discover_connector_folder(
|
||||
self.profile,
|
||||
path="root",
|
||||
library_id=None,
|
||||
recursive=False,
|
||||
max_files=1,
|
||||
max_depth=0,
|
||||
)
|
||||
|
||||
self.assertEqual(["root/a.txt"], [item.path for item in result.files])
|
||||
self.assertTrue(result.truncated)
|
||||
self.assertEqual(
|
||||
["root/nested", "root/b.txt"],
|
||||
[item.path for item in result.skipped],
|
||||
)
|
||||
|
||||
def test_relative_path_rejects_provider_results_outside_linked_root(self) -> None:
|
||||
self.assertEqual(
|
||||
"nested/report.csv",
|
||||
connector_relative_path(
|
||||
space_root="linked/root", item_path="linked/root/nested/report.csv"
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "outside"):
|
||||
connector_relative_path(
|
||||
space_root="linked/root", item_path="another/root/report.csv"
|
||||
)
|
||||
|
||||
|
||||
class ConnectorFolderSyncRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.session = MagicMock()
|
||||
self.space = SimpleNamespace(
|
||||
id="space-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_user_id="user-1",
|
||||
owner_group_id=None,
|
||||
connector_profile_id="profile-1",
|
||||
provider="webdav",
|
||||
library_id="library-1",
|
||||
remote_path="linked/root",
|
||||
sync_mode="manual",
|
||||
)
|
||||
self.profile = ConnectorProfile(
|
||||
id="profile-1",
|
||||
label="Documents",
|
||||
provider="webdav",
|
||||
capabilities=("browse", "sync"),
|
||||
)
|
||||
self.principal = SimpleNamespace(
|
||||
tenant_id="tenant-1", user=SimpleNamespace(id="user-1")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stored(index: int) -> UploadedStoredFile:
|
||||
return UploadedStoredFile(
|
||||
asset=SimpleNamespace(id=f"asset-{index}"),
|
||||
version=SimpleNamespace(id=f"version-{index}"),
|
||||
blob=SimpleNamespace(id=f"blob-{index}"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response(index: int, action: str) -> FileAssetResponse:
|
||||
now = datetime(2026, 8, 21, tzinfo=UTC).isoformat()
|
||||
return FileAssetResponse(
|
||||
id=f"asset-{index}",
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
display_path=f"Imports/file-{index}.txt",
|
||||
filename=f"file-{index}.txt",
|
||||
size_bytes=index,
|
||||
checksum_sha256=str(index) * 64,
|
||||
version_id=f"version-{index}",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
source_revision=f"revision-{action}",
|
||||
)
|
||||
|
||||
@patch("govoplan_files.backend.routes.connector_io.audit_from_principal")
|
||||
@patch("govoplan_files.backend.routes.connector_io._asset_response")
|
||||
@patch("govoplan_files.backend.routes.connector_io._audit_connector_sync")
|
||||
@patch("govoplan_files.backend.routes.connector_io.sync_file_asset_from_source")
|
||||
@patch("govoplan_files.backend.routes.connector_io._download_connector_payload")
|
||||
@patch("govoplan_files.backend.routes.connector_io.discover_connector_folder")
|
||||
@patch("govoplan_files.backend.routes.connector_io._connector_space_policy_decision")
|
||||
@patch("govoplan_files.backend.routes.connector_io._visible_connector_profile")
|
||||
@patch("govoplan_files.backend.routes.connector_io.get_connector_space_for_user")
|
||||
@patch("govoplan_files.backend.routes.connector_io._is_admin", return_value=False)
|
||||
def test_returns_reviewable_per_item_results_and_audits_the_summary(
|
||||
self,
|
||||
_is_admin: Mock,
|
||||
get_space: Mock,
|
||||
visible_profile: Mock,
|
||||
policy_decision: Mock,
|
||||
discover: Mock,
|
||||
download: Mock,
|
||||
sync_file: Mock,
|
||||
audit_sync: Mock,
|
||||
asset_response: Mock,
|
||||
audit: Mock,
|
||||
) -> None:
|
||||
get_space.return_value = self.space
|
||||
visible_profile.return_value = self.profile
|
||||
policy_decision.return_value = SimpleNamespace(allowed=True)
|
||||
sources = [_item("file", f"linked/root/file-{index}.txt") for index in range(1, 6)]
|
||||
discover.return_value = SimpleNamespace(
|
||||
files=sources,
|
||||
skipped=[SimpleNamespace(path="linked/root/ignored", reason="Unsupported item")],
|
||||
truncated=False,
|
||||
)
|
||||
downloads = [
|
||||
(
|
||||
source.path,
|
||||
ConnectorDownloadedFile(
|
||||
filename=source.name,
|
||||
data=source.name.encode(),
|
||||
revision=source.etag,
|
||||
),
|
||||
{"source_provenance": {"connector_id": "profile-1"}},
|
||||
)
|
||||
for source in sources
|
||||
]
|
||||
denied_decision = SimpleNamespace(
|
||||
reason="Path denied",
|
||||
to_dict=Mock(return_value={"allowed": False, "reason": "Path denied"}),
|
||||
)
|
||||
downloads[-1] = ConnectorPolicyDenied(denied_decision) # type: ignore[assignment]
|
||||
download.side_effect = downloads
|
||||
sync_file.side_effect = [
|
||||
(self._stored(1), "created", None),
|
||||
(self._stored(2), "updated", "previous-2"),
|
||||
(self._stored(3), "unchanged", "previous-3"),
|
||||
FileStorageError("Target file already exists: Imports/file-4.txt"),
|
||||
]
|
||||
asset_response.side_effect = [
|
||||
self._response(1, "created"),
|
||||
self._response(2, "updated"),
|
||||
self._response(3, "unchanged"),
|
||||
]
|
||||
|
||||
response = sync_connector_space_folder(
|
||||
"space-1",
|
||||
FileConnectorFolderSyncRequest(target_folder="Imports"),
|
||||
session=self.session,
|
||||
principal=self.principal, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"discovered": 5,
|
||||
"created": 1,
|
||||
"updated": 1,
|
||||
"unchanged": 1,
|
||||
"skipped": 1,
|
||||
"conflicts": 1,
|
||||
"policy_denied": 1,
|
||||
"failed": 0,
|
||||
},
|
||||
response.summary.model_dump(),
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"skipped",
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"conflict",
|
||||
"policy_denied",
|
||||
],
|
||||
[item.action for item in response.items],
|
||||
)
|
||||
self.assertEqual("Imports/file-1.txt", response.items[1].target_path)
|
||||
self.assertEqual(
|
||||
{"allowed": False, "reason": "Path denied"},
|
||||
response.items[-1].policy_decision,
|
||||
)
|
||||
self.session.commit.assert_called_once_with()
|
||||
audit_sync.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
self.session,
|
||||
self.principal,
|
||||
self._stored(index).asset,
|
||||
sync_action=action,
|
||||
previous_version_id=previous,
|
||||
)
|
||||
for index, action, previous in (
|
||||
(1, "created", None),
|
||||
(2, "updated", "previous-2"),
|
||||
(3, "unchanged", "previous-3"),
|
||||
)
|
||||
]
|
||||
)
|
||||
summary_details = audit.call_args.kwargs["details"]
|
||||
self.assertEqual(response.summary.model_dump(), summary_details["summary"])
|
||||
self.assertEqual(6, len(summary_details["results"]))
|
||||
self.assertNotIn("detail", summary_details["results"][-1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,7 @@
|
||||
from govoplan_files.backend.manifest import get_manifest
|
||||
|
||||
|
||||
def test_static_documentation_has_complete_german_reference_copy() -> None:
|
||||
for topic in get_manifest().documentation:
|
||||
german = topic.translations.get("de", {})
|
||||
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
|
||||
@@ -154,6 +154,11 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
"files.connectors",
|
||||
"files.connector.credentials",
|
||||
"files.connector.policy",
|
||||
"files.connector-folder-sync.remote-path",
|
||||
"files.connector-folder-sync.target-folder",
|
||||
"files.connector-folder-sync.conflict-strategy",
|
||||
"files.connector-folder-sync.max-files",
|
||||
"files.connector-folder-sync.recursive",
|
||||
},
|
||||
set(topic.metadata["help_contexts"]),
|
||||
)
|
||||
@@ -162,6 +167,9 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
self.assertIn("same transaction", topic.body)
|
||||
self.assertIn("read-only by default", topic.body)
|
||||
self.assertIn("S3", topic.body)
|
||||
self.assertIn("manual connector-folder sync", topic.body)
|
||||
self.assertIn("Per-file savepoints", topic.body)
|
||||
self.assertIn("files.connector.folder_synced", topic.body)
|
||||
self.assertIn("Automatic remote deletion", topic.body)
|
||||
self.assertTrue(
|
||||
any(
|
||||
@@ -176,6 +184,11 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
self.assertIn(
|
||||
"/api/v1/files/connectors/credentials", {link.href for link in topic.links}
|
||||
)
|
||||
self.assertIn(
|
||||
"/api/v1/files/connector-spaces/{space_id}/sync",
|
||||
{link.href for link in topic.links},
|
||||
)
|
||||
self.assertIn("de", topic.translations)
|
||||
|
||||
def test_operator_topic_covers_recovery_and_pinned_s3_smb(self) -> None:
|
||||
topic = self.topic(
|
||||
|
||||
@@ -57,7 +57,7 @@ class FilesRouterContractTests(unittest.TestCase):
|
||||
actual = self._operation_keys(router)
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
self.assertEqual(62, len(actual))
|
||||
self.assertEqual(63, len(actual))
|
||||
self.assertFalse(
|
||||
[operation for operation, count in Counter(actual).items() if count > 1]
|
||||
)
|
||||
@@ -83,6 +83,7 @@ class FilesRouterContractTests(unittest.TestCase):
|
||||
(("GET",), "/files/connectors/profiles/{profile_id}/browse"),
|
||||
(("POST",), "/files/connectors/profiles/{profile_id}/import"),
|
||||
(("POST",), "/files/connectors/profiles/{profile_id}/sync"),
|
||||
(("POST",), "/files/connector-spaces/{space_id}/sync"),
|
||||
(("POST",), "/files/connector-spaces/{space_id}/write-back"),
|
||||
(("GET",), "/files/connectors/credentials"),
|
||||
(("POST",), "/files/connectors/credentials"),
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,6 +14,7 @@
|
||||
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:connector-folder-sync": "node scripts/test-connector-folder-sync-structure.mjs",
|
||||
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs",
|
||||
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs",
|
||||
"test:connector-space-removal": "node scripts/test-connector-space-removal-structure.mjs",
|
||||
@@ -27,7 +28,7 @@
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"@govoplan/core-webui": "^0.1.18"
|
||||
"@govoplan/core-webui": "^0.1.44"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const page = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8");
|
||||
const api = readFileSync(new URL("../src/api/files.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(api, /connector-spaces\/\$\{encodeURIComponent\(spaceId\)\}\/sync/);
|
||||
assert.match(api, /conflict_strategy: "skip", max_files: 100, max_depth: 12/);
|
||||
assert.match(page, /syncFileConnectorSpaceFolder\(settings, space\.connector_space_id/);
|
||||
assert.match(page, /recursive: folderSyncRecursive/);
|
||||
assert.match(page, /conflict_strategy: folderSyncConflictStrategy/);
|
||||
assert.match(page, /folderSyncResult\.items\.map/);
|
||||
assert.match(page, /folderSyncActionLabel\(item\.action\)/);
|
||||
|
||||
for (const contextId of [
|
||||
"files.connector-folder-sync.remote-path",
|
||||
"files.connector-folder-sync.target-folder",
|
||||
"files.connector-folder-sync.conflict-strategy",
|
||||
"files.connector-folder-sync.max-files",
|
||||
"files.connector-folder-sync.recursive"
|
||||
]) {
|
||||
assert.ok(page.includes(`helpContextId="${contextId}"`), `missing contextual help for ${contextId}`);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(page, /window\.confirm\([\s\S]*connector-folder-sync/);
|
||||
|
||||
console.log("Connector folder-sync structure checks passed.");
|
||||
+54
-1
@@ -426,6 +426,48 @@ export type FileConnectorSyncResponse = {
|
||||
previous_version_id?: string | null;
|
||||
current_version_id: string;
|
||||
};
|
||||
export type FileConnectorFolderSyncAction = "created" | "updated" | "unchanged" | "skipped" | "conflict" | "policy_denied" | "failed";
|
||||
export type FileConnectorFolderSyncItem = {
|
||||
source_path: string;
|
||||
target_path?: string | null;
|
||||
action: FileConnectorFolderSyncAction;
|
||||
file?: ManagedFile | null;
|
||||
previous_version_id?: string | null;
|
||||
current_version_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
detail?: string | null;
|
||||
policy_decision?: FileConnectorPolicyDecision | null;
|
||||
};
|
||||
export type FileConnectorFolderSyncSummary = {
|
||||
discovered: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
policy_denied: number;
|
||||
failed: number;
|
||||
};
|
||||
export type FileConnectorFolderSyncResponse = {
|
||||
connector_space_id: string;
|
||||
connector_profile_id: string;
|
||||
provider: string;
|
||||
remote_path: string;
|
||||
target_folder: string;
|
||||
recursive: boolean;
|
||||
truncated: boolean;
|
||||
summary: FileConnectorFolderSyncSummary;
|
||||
items: FileConnectorFolderSyncItem[];
|
||||
};
|
||||
export type FileConnectorFolderSyncPayload = {
|
||||
path?: string;
|
||||
target_folder?: string | null;
|
||||
recursive?: boolean;
|
||||
conflict_strategy?: "reject" | "overwrite" | "rename" | "skip";
|
||||
max_files?: number;
|
||||
max_depth?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type FileConnectorWritePayload = {
|
||||
file_id: string;
|
||||
remote_path: string;
|
||||
@@ -502,7 +544,7 @@ export type FileBlobGcResponse = {
|
||||
export type RenameResponse = {dry_run: boolean;items: {kind: "file" | "folder";id: string;file_id?: string | null;folder_path?: string | null;old_path: string;new_path: string;}[];};
|
||||
export type TransferResponse = {operation: "move" | "copy";files: number;folders: number;};
|
||||
export type ConflictAction = "overwrite" | "rename" | "skip";
|
||||
export type ConflictStrategy = "reject" | "overwrite" | "rename";
|
||||
export type ConflictStrategy = "reject" | "overwrite" | "rename" | "skip";
|
||||
export type ConflictResolution = {target_path: string;action: ConflictAction;new_path?: string;};
|
||||
export type PatternResolveResponse = {
|
||||
patterns: {pattern: string;matches: ManagedFile[];}[];
|
||||
@@ -1176,6 +1218,17 @@ payload: FileConnectorFilePayload)
|
||||
});
|
||||
}
|
||||
|
||||
export function syncFileConnectorSpaceFolder(
|
||||
settings: ApiSettings,
|
||||
spaceId: string,
|
||||
payload: FileConnectorFolderSyncPayload)
|
||||
: Promise<FileConnectorFolderSyncResponse> {
|
||||
return apiFetch<FileConnectorFolderSyncResponse>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}/sync`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: "", target_folder: "", recursive: true, conflict_strategy: "skip", max_files: 100, max_depth: 12, metadata: {}, ...payload })
|
||||
});
|
||||
}
|
||||
|
||||
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
|
||||
return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" });
|
||||
}
|
||||
|
||||
@@ -1061,7 +1061,7 @@ export default function FileConnectorSettingsPanel({
|
||||
)}
|
||||
<div className="file-connector-settings-section file-connector-policy-locks">
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_connection_limits.4f9838bc" checked={policyDraft.allowLowerConnectionLimits} disabled={saving || !canWrite} onChange={(allowLowerConnectionLimits) => patchPolicyDraft({ allowLowerConnectionLimits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" helpContextId="files.connector.policy" helpModuleId="files" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_provider_limits.5816270a" checked={policyDraft.allowLowerProviderLimits} disabled={saving || !canWrite} onChange={(allowLowerProviderLimits) => patchPolicyDraft({ allowLowerProviderLimits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_path_limits.1abcccb1" checked={policyDraft.allowLowerPathLimits} disabled={saving || !canWrite} onChange={(allowLowerPathLimits) => patchPolicyDraft({ allowLowerPathLimits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-files.lower_endpoint_limits.3fd781d5" checked={policyDraft.allowLowerUrlLimits} disabled={saving || !canWrite} onChange={(allowLowerUrlLimits) => patchPolicyDraft({ allowLowerUrlLimits })} />
|
||||
@@ -1101,14 +1101,14 @@ export default function FileConnectorSettingsPanel({
|
||||
<p>Choose where this credential can be used and how it signs in.</p>
|
||||
</header>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="i18n:govoplan-files.credential_id.9432a6e1">
|
||||
<FormField label="i18n:govoplan-files.credential_id.9432a6e1" helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<input className={!editingCredentialId && !credentialDraft.id.trim() ? "field-input-missing" : undefined} aria-invalid={!editingCredentialId && !credentialDraft.id.trim() || undefined} value={credentialDraft.id} disabled={Boolean(editingCredentialId) || saving} onChange={(event) => patchCredentialDraft({ id: event.target.value })} placeholder={`${scopeType}-webdav-credentials`} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-files.label.74341e3c">
|
||||
<input className={!credentialDraft.label.trim() ? "field-input-missing" : undefined} aria-invalid={!credentialDraft.label.trim() || undefined} value={credentialDraft.label} disabled={saving} onChange={(event) => patchCredentialDraft({ label: event.target.value })} placeholder="i18n:govoplan-files.govoplan_webdav_credentials.bc696d69" />
|
||||
</FormField>
|
||||
{credentialAttachProfileId &&
|
||||
<FormField label="i18n:govoplan-files.connection_credential.178babe0">
|
||||
<FormField label="i18n:govoplan-files.connection_credential.178babe0" helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<input value={credentialAttachedProfile ? `${credentialAttachedProfile.label} (${credentialAttachedProfile.id})` : credentialAttachProfileId} disabled readOnly />
|
||||
</FormField>
|
||||
}
|
||||
@@ -1130,8 +1130,8 @@ export default function FileConnectorSettingsPanel({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION}>
|
||||
<select value={credentialDraft.credentialMode} disabled={saving} onChange={(event) => patchCredentialDraft({ credentialMode: event.target.value as CredentialMode })}>
|
||||
<FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION} helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<select data-help-context-id="files.connector.credentials" data-help-module-id="files" value={credentialDraft.credentialMode} disabled={saving} onChange={(event) => patchCredentialDraft({ credentialMode: event.target.value as CredentialMode })}>
|
||||
<option value="none">i18n:govoplan-files.none.6eef6648</option>
|
||||
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
|
||||
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
|
||||
@@ -1142,8 +1142,8 @@ export default function FileConnectorSettingsPanel({
|
||||
</FormGrid>
|
||||
<div className="file-connector-settings-section">
|
||||
<ToggleSwitch label="i18n:govoplan-files.enabled.df174a3f" checked={credentialDraft.enabled} disabled={saving} onChange={(enabled) => patchCredentialDraft({ enabled })} />
|
||||
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_password.7442260d" checked={credentialDraft.clearPassword} disabled={saving} onChange={(clearPassword) => patchCredentialDraft({ clearPassword })} />}
|
||||
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_token.a9c670aa" checked={credentialDraft.clearToken} disabled={saving} onChange={(clearToken) => patchCredentialDraft({ clearToken })} />}
|
||||
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_password.7442260d" helpContextId="files.connector.credentials" helpModuleId="files" checked={credentialDraft.clearPassword} disabled={saving} onChange={(clearPassword) => patchCredentialDraft({ clearPassword })} />}
|
||||
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_token.a9c670aa" helpContextId="files.connector.credentials" helpModuleId="files" checked={credentialDraft.clearToken} disabled={saving} onChange={(clearToken) => patchCredentialDraft({ clearToken })} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1179,8 +1179,8 @@ export default function FileConnectorSettingsPanel({
|
||||
disabled={saving}
|
||||
showPassword={false} />
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="i18n:govoplan-files.secret_reference.04ed2221">
|
||||
<input value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
|
||||
<FormField label="i18n:govoplan-files.secret_reference.04ed2221" helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<input data-help-context-id="files.connector.credentials" data-help-module-id="files" value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</>
|
||||
@@ -1304,8 +1304,8 @@ export default function FileConnectorSettingsPanel({
|
||||
<FormField label="i18n:govoplan-files.base_path.6a4867ca">
|
||||
<input value={draft.basePath} disabled={saving} onChange={(event) => patchDraft({ basePath: event.target.value })} placeholder="GovOPlaN" />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION}>
|
||||
<select value={draft.credentialMode} disabled={saving} onChange={(event) => patchDraft({ credentialMode: event.target.value as CredentialMode })}>
|
||||
<FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION} helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<select data-help-context-id="files.connector.credentials" data-help-module-id="files" value={draft.credentialMode} disabled={saving} onChange={(event) => patchDraft({ credentialMode: event.target.value as CredentialMode })}>
|
||||
<option value="none">i18n:govoplan-files.none.6eef6648</option>
|
||||
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
|
||||
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
|
||||
@@ -1313,8 +1313,8 @@ export default function FileConnectorSettingsPanel({
|
||||
<option value="secret_ref">i18n:govoplan-files.secret_reference.04ed2221</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-files.credential.8bede3ea">
|
||||
<select value={profileCredentialSelectValue} disabled={saving || profileCredentialOptions.length === 0} onChange={(event) => patchDraft({ credentialProfileId: event.target.value })}>
|
||||
<FormField label="i18n:govoplan-files.credential.8bede3ea" helpContextId="files.connector.credentials" helpModuleId="files">
|
||||
<select data-help-context-id="files.connector.credentials" data-help-module-id="files" value={profileCredentialSelectValue} disabled={saving || profileCredentialOptions.length === 0} onChange={(event) => patchDraft({ credentialProfileId: event.target.value })}>
|
||||
{profileCredentialOptions.length === 0 && <option value="">i18n:govoplan-files.no_saved_credential.30a5a951</option>}
|
||||
{profileCredentialOptions.map((credential) => <option key={credential.id} value={credential.id}>{credential.label}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FormGrid, ActionToolbar,
|
||||
ResourceAccessExplanation,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
usePlatformLanguage,
|
||||
type ApiSettings,
|
||||
type AuthInfo, i18nMessage } from
|
||||
"@govoplan/core-webui";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
listManagedFileSnapshot,
|
||||
previewArchiveUpload,
|
||||
resolveFilePatterns,
|
||||
syncFileConnectorSpaceFolder,
|
||||
syncFileConnectorFile,
|
||||
transferFiles,
|
||||
uploadFiles,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
type ConflictResolution,
|
||||
type ConflictStrategy,
|
||||
type FileConnectorBrowseItem,
|
||||
type FileConnectorFolderSyncResponse,
|
||||
type FileConnectorProfile,
|
||||
type FileDeltaResponse,
|
||||
type FileCampaignUsageFilter,
|
||||
@@ -120,6 +123,7 @@ const FILES_WORKFLOW_DOCUMENTATION = {
|
||||
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const canDownload = hasScope(auth, "files:download");
|
||||
const canUpload = hasScope(auth, "files:upload");
|
||||
const canOrganize = hasScope(auth, "files:organize");
|
||||
@@ -175,6 +179,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const [connectorSpaceRemovalTarget, setConnectorSpaceRemovalTarget] = useState<FileSpace | null>(null);
|
||||
const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false);
|
||||
const [connectorSpaceError, setConnectorSpaceError] = useState("");
|
||||
const [folderSyncRecursive, setFolderSyncRecursive] = useState(true);
|
||||
const [folderSyncConflictStrategy, setFolderSyncConflictStrategy] = useState<"skip" | "rename" | "reject" | "overwrite">("skip");
|
||||
const [folderSyncMaxFiles, setFolderSyncMaxFiles] = useState(100);
|
||||
const [folderSyncResult, setFolderSyncResult] = useState<FileConnectorFolderSyncResponse | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
|
||||
@@ -555,10 +563,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
}
|
||||
|
||||
function openDialog(kind: DialogKind, target: FileActionTarget | null = null) {
|
||||
if ((kind === "upload" || kind === "connector-sync") && !canUpload) return;
|
||||
if ((kind === "upload" || kind === "connector-sync" || kind === "connector-folder-sync") && !canUpload) return;
|
||||
if (kind === "connector-space" && !canOrganize) return;
|
||||
if (kind && ["create-folder", "rename", "single-rename", "transfer"].includes(kind) && !canOrganize) return;
|
||||
if (target && isConnectorSpace(findSpace(target.spaceId)) && kind !== "connector-sync") return;
|
||||
if (target && isConnectorSpace(findSpace(target.spaceId)) && kind !== "connector-sync" && kind !== "connector-folder-sync") return;
|
||||
if (kind === "create-folder") {
|
||||
setNewFolderName("");
|
||||
setNewFolderError("");
|
||||
@@ -586,6 +594,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
setConnectorSelectedItem(null);
|
||||
setConnectorSpaceLabel("");
|
||||
setConnectorSpaceReadOnly(true);
|
||||
setFolderSyncResult(null);
|
||||
resetArchiveUploadState();
|
||||
}
|
||||
|
||||
@@ -739,6 +748,56 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
}
|
||||
}
|
||||
|
||||
function openConnectorFolderSyncDialog() {
|
||||
if (!canUpload || !activeSpace || !isConnectorSpace(activeSpace) || !activeSpace.connector_space_id) return;
|
||||
setFolderSyncRecursive(true);
|
||||
setFolderSyncConflictStrategy("skip");
|
||||
setFolderSyncMaxFiles(100);
|
||||
setFolderSyncResult(null);
|
||||
setDialog("connector-folder-sync");
|
||||
}
|
||||
|
||||
async function syncActiveConnectorFolder() {
|
||||
const space = activeSpace;
|
||||
if (!canUpload || !space || !isConnectorSpace(space) || !space.connector_space_id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
setConnectorSpaceError("");
|
||||
try {
|
||||
const response = await syncFileConnectorSpaceFolder(settings, space.connector_space_id, {
|
||||
path: currentFolder,
|
||||
target_folder: "",
|
||||
recursive: folderSyncRecursive,
|
||||
conflict_strategy: folderSyncConflictStrategy,
|
||||
max_files: folderSyncMaxFiles,
|
||||
max_depth: 12,
|
||||
metadata: {
|
||||
initiated_from: "files_connector_space",
|
||||
connector_space_label: space.label
|
||||
}
|
||||
});
|
||||
setFolderSyncResult(response);
|
||||
const summary = response.summary;
|
||||
setMessage(i18nMessage("i18n:govoplan-files.folder_sync.finished", {
|
||||
value0: summary.created,
|
||||
value1: summary.updated,
|
||||
value2: summary.unchanged,
|
||||
value3: summary.skipped,
|
||||
value4: summary.conflicts + summary.policy_denied + summary.failed
|
||||
}));
|
||||
const ownerSpace = spaces.find((item) => item.space_type !== "connector" && item.owner_type === space.owner_type && item.owner_id === space.owner_id);
|
||||
if (ownerSpace) await loadSpaceContents(ownerSpace, { silent: true });
|
||||
await loadConnectorSpaceContents(space, { folderPath: currentFolder, silent: true });
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
setError(detail);
|
||||
setConnectorSpaceError(detail);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadArchivePreview(
|
||||
@@ -2197,6 +2256,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const accessExplanationBlocker = workingBlocker || (activeSpaceIsConnector ? "Access explanations apply to managed files and folders." : "") || (!canExplainResourceAccess ? "Permission to inspect resource access is required." : "") || (!accessExplainableTarget ? "Select one file or folder first." : "");
|
||||
const deleteBlocker = workingBlocker || managedSpaceBlocker || (!canDelete ? "File deletion permission is required." : "") || selectionBlocker;
|
||||
const syncBlocker = workingBlocker || (!activeSpace ? "Select a file space first." : "") || (!canUpload ? "File upload permission is required to import synchronized content." : "") || (activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file" ? "Select one remote file to synchronize." : "");
|
||||
const folderSyncBlocker = workingBlocker || (!activeSpaceIsConnector || !activeSpace?.connector_space_id ? "i18n:govoplan-files.folder_sync.blocker.select_space" : "") || (!canUpload ? "i18n:govoplan-files.folder_sync.blocker.permission" : "") || (connectorSpaceLoading ? "i18n:govoplan-files.folder_sync.blocker.loading" : "");
|
||||
const folderSyncSummaryRows = folderSyncResult ? [
|
||||
["i18n:govoplan-files.folder_sync.summary.discovered", folderSyncResult.summary.discovered],
|
||||
["i18n:govoplan-files.folder_sync.summary.created", folderSyncResult.summary.created],
|
||||
["i18n:govoplan-files.folder_sync.summary.updated", folderSyncResult.summary.updated],
|
||||
["i18n:govoplan-files.folder_sync.summary.unchanged", folderSyncResult.summary.unchanged],
|
||||
["i18n:govoplan-files.folder_sync.summary.skipped", folderSyncResult.summary.skipped],
|
||||
["i18n:govoplan-files.folder_sync.summary.conflicts", folderSyncResult.summary.conflicts],
|
||||
["i18n:govoplan-files.folder_sync.summary.policy_denied", folderSyncResult.summary.policy_denied],
|
||||
["i18n:govoplan-files.folder_sync.summary.failed", folderSyncResult.summary.failed]
|
||||
] as const : [];
|
||||
|
||||
const toolbar =
|
||||
<ActionToolbar className="file-manager-toolbar" aria-label="i18n:govoplan-files.file_actions.9e1b94c5">
|
||||
@@ -2208,6 +2278,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309
|
||||
</Button>
|
||||
{activeSpaceIsConnector &&
|
||||
<Button onClick={openConnectorFolderSyncDialog} disabled={Boolean(folderSyncBlocker)} disabledReason={folderSyncBlocker}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.folder_sync.button
|
||||
</Button>
|
||||
}
|
||||
<Button onClick={() => void openConnectorSpaceDialog()} disabled={busy || !canOrganize} disabledReason={workingBlocker || (!canOrganize ? "File organization permission is required to add a connector space." : "")}>
|
||||
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
|
||||
</Button>
|
||||
@@ -2218,7 +2293,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
<Button onClick={() => openTransferDialog("copy")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
|
||||
{hasSelection && <Button onClick={openRenameDialog} disabled={Boolean(organizeBlocker)} disabledReason={organizeBlocker}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
|
||||
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
|
||||
<Button variant="danger" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
|
||||
<Button variant="danger" helpContextId="files.list" helpModuleId="files" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
|
||||
{activeSpaceIsConnector &&
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -2718,8 +2793,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
{archivePreview.requires_password &&
|
||||
<FormField
|
||||
label="Archive password"
|
||||
helpContextId="files.list"
|
||||
helpModuleId="files"
|
||||
help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
|
||||
<PasswordField
|
||||
helpContextId="files.list"
|
||||
helpModuleId="files"
|
||||
value={archivePassword}
|
||||
onValueChange={setArchivePassword}
|
||||
disabled={busy}
|
||||
@@ -2782,6 +2861,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
</Button>
|
||||
{archivePreview.requires_password &&
|
||||
<Button
|
||||
helpContextId="files.list"
|
||||
helpModuleId="files"
|
||||
onClick={() => activeDialogTarget && void loadArchivePreview(archiveFile, activeDialogTarget, { preserveSelection: true })}
|
||||
disabled={busy || !archivePassword}>
|
||||
<RefreshCw size={15} aria-hidden="true" /> Verify password
|
||||
@@ -2973,6 +3054,97 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
</FileDialog>
|
||||
}
|
||||
|
||||
{dialog === "connector-folder-sync" && activeSpace && activeSpaceIsConnector &&
|
||||
<FileDialog title="i18n:govoplan-files.folder_sync.title" onClose={closeDialog}>
|
||||
<p className="muted">i18n:govoplan-files.folder_sync.description</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField
|
||||
label="i18n:govoplan-files.folder_sync.remote.label"
|
||||
help="i18n:govoplan-files.folder_sync.remote.help"
|
||||
interfaceId="files.connector-folder-sync.remote-path"
|
||||
helpContextId="files.connector-folder-sync.remote-path"
|
||||
helpModuleId="files"
|
||||
helpTopicId="files.governed-connectors-and-provenance">
|
||||
<input value={currentFolder || translateText("i18n:govoplan-files.root.e96857c5")} readOnly />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="i18n:govoplan-files.folder_sync.destination.label"
|
||||
help="i18n:govoplan-files.folder_sync.destination.help"
|
||||
interfaceId="files.connector-folder-sync.target-folder"
|
||||
helpContextId="files.connector-folder-sync.target-folder"
|
||||
helpModuleId="files"
|
||||
helpTopicId="files.governed-connectors-and-provenance">
|
||||
<input value={spaces.find((item) => item.space_type !== "connector" && item.owner_type === activeSpace.owner_type && item.owner_id === activeSpace.owner_id)?.label || `${activeSpace.owner_type}:${activeSpace.owner_id}`} readOnly />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="i18n:govoplan-files.folder_sync.conflict.label"
|
||||
help="i18n:govoplan-files.folder_sync.conflict.help"
|
||||
interfaceId="files.connector-folder-sync.conflict-strategy"
|
||||
helpContextId="files.connector-folder-sync.conflict-strategy"
|
||||
helpModuleId="files"
|
||||
helpTopicId="files.governed-connectors-and-provenance">
|
||||
<select value={folderSyncConflictStrategy} onChange={(event) => setFolderSyncConflictStrategy(event.target.value as typeof folderSyncConflictStrategy)} disabled={busy}>
|
||||
<option value="skip">i18n:govoplan-files.folder_sync.conflict.skip</option>
|
||||
<option value="rename">i18n:govoplan-files.folder_sync.conflict.rename</option>
|
||||
<option value="reject">i18n:govoplan-files.folder_sync.conflict.reject</option>
|
||||
<option value="overwrite">i18n:govoplan-files.folder_sync.conflict.overwrite</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="i18n:govoplan-files.folder_sync.limit.label"
|
||||
help="i18n:govoplan-files.folder_sync.limit.help"
|
||||
interfaceId="files.connector-folder-sync.max-files"
|
||||
helpContextId="files.connector-folder-sync.max-files"
|
||||
helpModuleId="files"
|
||||
helpTopicId="files.governed-connectors-and-provenance">
|
||||
<input type="number" min={1} max={500} value={folderSyncMaxFiles} onChange={(event) => setFolderSyncMaxFiles(Math.min(500, Math.max(1, Number(event.target.value) || 1)))} disabled={busy} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-files.folder_sync.recursive.label"
|
||||
checked={folderSyncRecursive}
|
||||
onChange={setFolderSyncRecursive}
|
||||
disabled={busy}
|
||||
help="i18n:govoplan-files.folder_sync.recursive.help"
|
||||
interfaceId="files.connector-folder-sync.recursive"
|
||||
helpContextId="files.connector-folder-sync.recursive"
|
||||
helpModuleId="files"
|
||||
helpTopicId="files.governed-connectors-and-provenance" />
|
||||
|
||||
{folderSyncResult &&
|
||||
<div className="connector-folder-sync-review" aria-live="polite">
|
||||
<div className="connector-folder-sync-summary" aria-label="i18n:govoplan-files.folder_sync.summary.aria_label">
|
||||
{folderSyncSummaryRows.map(([label, value]) =>
|
||||
<span key={label}><strong>{value}</strong> {label}</span>
|
||||
)}
|
||||
</div>
|
||||
{folderSyncResult.truncated && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-files.folder_sync.truncated</DismissibleAlert>}
|
||||
<div className="connector-folder-sync-results" role="table" aria-label="i18n:govoplan-files.folder_sync.results.aria_label">
|
||||
<div className="connector-folder-sync-result is-header" role="row">
|
||||
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.source</strong>
|
||||
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.result</strong>
|
||||
<strong role="columnheader">i18n:govoplan-files.folder_sync.results.detail</strong>
|
||||
</div>
|
||||
{folderSyncResult.items.map((item, index) =>
|
||||
<div className={`connector-folder-sync-result is-${item.action}`} role="row" key={`${item.source_path}:${index}`}>
|
||||
<span role="cell">{item.source_path}</span>
|
||||
<strong role="cell">{folderSyncActionLabel(item.action)}</strong>
|
||||
<span role="cell">{item.target_path || item.detail || item.policy_decision?.reason || "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={closeDialog} disabled={busy}>{folderSyncResult ? "i18n:govoplan-files.close.bbfa773e" : "i18n:govoplan-files.cancel.77dfd213"}</Button>
|
||||
<Button variant="primary" onClick={() => void syncActiveConnectorFolder()} disabled={busy || connectorSpaceLoading}>
|
||||
<RefreshCw size={15} aria-hidden="true" /> {folderSyncResult ? "i18n:govoplan-files.folder_sync.run_again" : "i18n:govoplan-files.folder_sync.button"}
|
||||
</Button>
|
||||
</div>
|
||||
</FileDialog>
|
||||
}
|
||||
|
||||
{dialog === "create-folder" &&
|
||||
<FileDialog title="i18n:govoplan-files.create_folder.e59f63fa" onClose={closeDialog}>
|
||||
<FormField label="i18n:govoplan-files.folder_name.b2ce023b" help="i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76">
|
||||
@@ -3075,6 +3247,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
|
||||
}
|
||||
|
||||
function folderSyncActionLabel(action: FileConnectorFolderSyncResponse["items"][number]["action"]): string {
|
||||
const labels: Record<FileConnectorFolderSyncResponse["items"][number]["action"], string> = {
|
||||
created: "i18n:govoplan-files.folder_sync.action.created",
|
||||
updated: "i18n:govoplan-files.folder_sync.action.updated",
|
||||
unchanged: "i18n:govoplan-files.folder_sync.action.unchanged",
|
||||
skipped: "i18n:govoplan-files.folder_sync.action.skipped",
|
||||
conflict: "i18n:govoplan-files.folder_sync.action.conflict",
|
||||
policy_denied: "i18n:govoplan-files.folder_sync.action.policy_denied",
|
||||
failed: "i18n:govoplan-files.folder_sync.action.failed"
|
||||
};
|
||||
return labels[action];
|
||||
}
|
||||
|
||||
function FilePropertyFilterRow({
|
||||
campaignUsage,
|
||||
auditRelevant,
|
||||
|
||||
@@ -264,7 +264,7 @@ export function FileContextMenu({
|
||||
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
|
||||
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
|
||||
<button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
|
||||
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
|
||||
{showDelete && <button type="button" role="menuitem" className="danger" data-help-context-id="files.list" data-help-module-id="files" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type RenameMode = "prefix" | "suffix" | "replace";
|
||||
export type SortColumn = "name" | "size" | "modified";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type TransferMode = "move" | "copy";
|
||||
export type DialogKind = "upload" | "connector-sync" | "connector-space" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
|
||||
export type DialogKind = "upload" | "connector-sync" | "connector-folder-sync" | "connector-space" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
|
||||
export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
|
||||
export type ContextMenuState = {
|
||||
x: number;
|
||||
|
||||
@@ -165,6 +165,49 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-files.folder_name.b2ce023b": "Folder name",
|
||||
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
|
||||
"i18n:govoplan-files.folder.30baa249": "Folder",
|
||||
"i18n:govoplan-files.folder_sync.action.conflict": "Conflict",
|
||||
"i18n:govoplan-files.folder_sync.action.created": "Created",
|
||||
"i18n:govoplan-files.folder_sync.action.failed": "Failed",
|
||||
"i18n:govoplan-files.folder_sync.action.policy_denied": "Policy denied",
|
||||
"i18n:govoplan-files.folder_sync.action.skipped": "Skipped",
|
||||
"i18n:govoplan-files.folder_sync.action.unchanged": "Unchanged",
|
||||
"i18n:govoplan-files.folder_sync.action.updated": "Updated",
|
||||
"i18n:govoplan-files.folder_sync.blocker.loading": "Wait until the connector folder has finished loading.",
|
||||
"i18n:govoplan-files.folder_sync.blocker.permission": "File upload permission is required to synchronize a connector folder.",
|
||||
"i18n:govoplan-files.folder_sync.blocker.select_space": "Select a linked connector space first.",
|
||||
"i18n:govoplan-files.folder_sync.button": "Sync folder",
|
||||
"i18n:govoplan-files.folder_sync.conflict.help": "Skip is privacy-safe and leaves unrelated managed files untouched. Rename creates a distinct managed path; reject reports conflicts; overwrite retires a colliding unrelated target.",
|
||||
"i18n:govoplan-files.folder_sync.conflict.label": "Existing target",
|
||||
"i18n:govoplan-files.folder_sync.conflict.overwrite": "Replace unrelated target",
|
||||
"i18n:govoplan-files.folder_sync.conflict.reject": "Report as conflict",
|
||||
"i18n:govoplan-files.folder_sync.conflict.rename": "Keep both with a new name",
|
||||
"i18n:govoplan-files.folder_sync.conflict.skip": "Skip unrelated conflicts",
|
||||
"i18n:govoplan-files.folder_sync.description": "Synchronize the visible remote folder into the linked owner's managed Files space. Existing connector identities become new versions; every skip, conflict, policy denial, and transport failure remains visible below.",
|
||||
"i18n:govoplan-files.folder_sync.destination.help": "Remote relative paths are preserved in the user or group space that owns this connector link.",
|
||||
"i18n:govoplan-files.folder_sync.destination.label": "Managed destination",
|
||||
"i18n:govoplan-files.folder_sync.finished": "Folder sync finished: {value0} created, {value1} updated, {value2} unchanged, {value3} skipped, {value4} need review.",
|
||||
"i18n:govoplan-files.folder_sync.limit.help": "Bounds one interactive run. If the limit is reached, the result is marked truncated and can be continued with a narrower folder.",
|
||||
"i18n:govoplan-files.folder_sync.limit.label": "Maximum files",
|
||||
"i18n:govoplan-files.folder_sync.recursive.help": "Subfolders are traversed to a bounded depth. Disable this to synchronize files in the visible folder only.",
|
||||
"i18n:govoplan-files.folder_sync.recursive.label": "Include subfolders",
|
||||
"i18n:govoplan-files.folder_sync.remote.help": "The folder is resolved below the linked connector-space root; parent-directory traversal is rejected.",
|
||||
"i18n:govoplan-files.folder_sync.remote.label": "Remote folder",
|
||||
"i18n:govoplan-files.folder_sync.results.aria_label": "Folder sync results",
|
||||
"i18n:govoplan-files.folder_sync.results.detail": "Managed target or detail",
|
||||
"i18n:govoplan-files.folder_sync.results.result": "Result",
|
||||
"i18n:govoplan-files.folder_sync.results.source": "Source",
|
||||
"i18n:govoplan-files.folder_sync.run_again": "Run again",
|
||||
"i18n:govoplan-files.folder_sync.summary.aria_label": "Folder sync summary",
|
||||
"i18n:govoplan-files.folder_sync.summary.conflicts": "conflicts",
|
||||
"i18n:govoplan-files.folder_sync.summary.created": "created",
|
||||
"i18n:govoplan-files.folder_sync.summary.discovered": "discovered",
|
||||
"i18n:govoplan-files.folder_sync.summary.failed": "failed",
|
||||
"i18n:govoplan-files.folder_sync.summary.policy_denied": "policy denied",
|
||||
"i18n:govoplan-files.folder_sync.summary.skipped": "skipped",
|
||||
"i18n:govoplan-files.folder_sync.summary.unchanged": "unchanged",
|
||||
"i18n:govoplan-files.folder_sync.summary.updated": "updated",
|
||||
"i18n:govoplan-files.folder_sync.title": "Sync connector folder",
|
||||
"i18n:govoplan-files.folder_sync.truncated": "The file limit was reached. Select a narrower remote folder or raise the bounded maximum to continue.",
|
||||
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
|
||||
"i18n:govoplan-files.folders.19adc47b": "Folders",
|
||||
"i18n:govoplan-files.gb.505a44fa": "GB",
|
||||
@@ -521,6 +564,49 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-files.folder_name.b2ce023b": "Ordnername",
|
||||
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
|
||||
"i18n:govoplan-files.folder.30baa249": "Ordner",
|
||||
"i18n:govoplan-files.folder_sync.action.conflict": "Konflikt",
|
||||
"i18n:govoplan-files.folder_sync.action.created": "Erstellt",
|
||||
"i18n:govoplan-files.folder_sync.action.failed": "Fehlgeschlagen",
|
||||
"i18n:govoplan-files.folder_sync.action.policy_denied": "Durch Richtlinie abgelehnt",
|
||||
"i18n:govoplan-files.folder_sync.action.skipped": "Übersprungen",
|
||||
"i18n:govoplan-files.folder_sync.action.unchanged": "Unverändert",
|
||||
"i18n:govoplan-files.folder_sync.action.updated": "Aktualisiert",
|
||||
"i18n:govoplan-files.folder_sync.blocker.loading": "Warten Sie, bis der Connector-Ordner vollständig geladen ist.",
|
||||
"i18n:govoplan-files.folder_sync.blocker.permission": "Zum Synchronisieren eines Connector-Ordners ist die Berechtigung zum Hochladen von Dateien erforderlich.",
|
||||
"i18n:govoplan-files.folder_sync.blocker.select_space": "Wählen Sie zuerst einen verknüpften Connector-Bereich aus.",
|
||||
"i18n:govoplan-files.folder_sync.button": "Ordner synchronisieren",
|
||||
"i18n:govoplan-files.folder_sync.conflict.help": "Überspringen belässt nicht zugehörige verwaltete Dateien unverändert. Umbenennen erstellt einen eigenen verwalteten Pfad; Ablehnen meldet Konflikte; Ersetzen setzt ein kollidierendes, nicht zugehöriges Ziel außer Kraft.",
|
||||
"i18n:govoplan-files.folder_sync.conflict.label": "Vorhandenes Ziel",
|
||||
"i18n:govoplan-files.folder_sync.conflict.overwrite": "Nicht zugehöriges Ziel ersetzen",
|
||||
"i18n:govoplan-files.folder_sync.conflict.reject": "Als Konflikt melden",
|
||||
"i18n:govoplan-files.folder_sync.conflict.rename": "Beide unter neuem Namen behalten",
|
||||
"i18n:govoplan-files.folder_sync.conflict.skip": "Nicht zugehörige Konflikte überspringen",
|
||||
"i18n:govoplan-files.folder_sync.description": "Synchronisiert den sichtbaren Remote-Ordner in den verwalteten Files-Bereich des verknüpften Eigentümers. Bereits bekannte Connector-Identitäten erhalten neue Versionen; Überspringungen, Konflikte, Richtlinienablehnungen und Transportfehler bleiben unten einzeln prüfbar.",
|
||||
"i18n:govoplan-files.folder_sync.destination.help": "Relative Remote-Pfade bleiben im Benutzer- oder Gruppenbereich erhalten, dem diese Connector-Verknüpfung gehört.",
|
||||
"i18n:govoplan-files.folder_sync.destination.label": "Verwaltetes Ziel",
|
||||
"i18n:govoplan-files.folder_sync.finished": "Ordnersynchronisierung abgeschlossen: {value0} erstellt, {value1} aktualisiert, {value2} unverändert, {value3} übersprungen, {value4} zu prüfen.",
|
||||
"i18n:govoplan-files.folder_sync.limit.help": "Begrenzt einen interaktiven Lauf. Wird der Grenzwert erreicht, ist das Ergebnis als abgeschnitten markiert und kann mit einem engeren Ordner fortgesetzt werden.",
|
||||
"i18n:govoplan-files.folder_sync.limit.label": "Maximale Dateien",
|
||||
"i18n:govoplan-files.folder_sync.recursive.help": "Unterordner werden nur bis zu einer begrenzten Tiefe durchlaufen. Deaktivieren Sie dies, um ausschließlich Dateien im sichtbaren Ordner zu synchronisieren.",
|
||||
"i18n:govoplan-files.folder_sync.recursive.label": "Unterordner einbeziehen",
|
||||
"i18n:govoplan-files.folder_sync.remote.help": "Der Ordner wird unterhalb der Wurzel des verknüpften Connector-Bereichs aufgelöst; übergeordnete Pfade werden abgelehnt.",
|
||||
"i18n:govoplan-files.folder_sync.remote.label": "Remote-Ordner",
|
||||
"i18n:govoplan-files.folder_sync.results.aria_label": "Ergebnisse der Ordnersynchronisierung",
|
||||
"i18n:govoplan-files.folder_sync.results.detail": "Verwaltetes Ziel oder Detail",
|
||||
"i18n:govoplan-files.folder_sync.results.result": "Ergebnis",
|
||||
"i18n:govoplan-files.folder_sync.results.source": "Quelle",
|
||||
"i18n:govoplan-files.folder_sync.run_again": "Erneut ausführen",
|
||||
"i18n:govoplan-files.folder_sync.summary.aria_label": "Zusammenfassung der Ordnersynchronisierung",
|
||||
"i18n:govoplan-files.folder_sync.summary.conflicts": "Konflikte",
|
||||
"i18n:govoplan-files.folder_sync.summary.created": "erstellt",
|
||||
"i18n:govoplan-files.folder_sync.summary.discovered": "gefunden",
|
||||
"i18n:govoplan-files.folder_sync.summary.failed": "fehlgeschlagen",
|
||||
"i18n:govoplan-files.folder_sync.summary.policy_denied": "durch Richtlinie abgelehnt",
|
||||
"i18n:govoplan-files.folder_sync.summary.skipped": "übersprungen",
|
||||
"i18n:govoplan-files.folder_sync.summary.unchanged": "unverändert",
|
||||
"i18n:govoplan-files.folder_sync.summary.updated": "aktualisiert",
|
||||
"i18n:govoplan-files.folder_sync.title": "Connector-Ordner synchronisieren",
|
||||
"i18n:govoplan-files.folder_sync.truncated": "Der Dateigrenzwert wurde erreicht. Wählen Sie zum Fortsetzen einen engeren Remote-Ordner oder erhöhen Sie den begrenzten Höchstwert.",
|
||||
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
|
||||
"i18n:govoplan-files.folders.19adc47b": "Ordner",
|
||||
"i18n:govoplan-files.gb.505a44fa": "GB",
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import {
|
||||
type PlatformWebModule,
|
||||
type QuickAccessToolsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
|
||||
import { FolderTree } from "./features/files/components/FileManagerComponents";
|
||||
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
|
||||
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
|
||||
@@ -22,8 +23,8 @@ const FileIntegrityPanel = lazy(() => import("./features/files/FileIntegrityPane
|
||||
|
||||
const fileRead = ["files:file:read"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
|
||||
};
|
||||
const fileDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
|
||||
@@ -1256,6 +1256,78 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.connector-folder-sync-review {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.connector-folder-sync-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connector-folder-sync-summary span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.connector-folder-sync-summary strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.connector-folder-sync-results {
|
||||
max-height: min(320px, 42vh);
|
||||
overflow: auto;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.connector-folder-sync-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(92px, 0.45fr) minmax(220px, 1.5fr);
|
||||
gap: 12px;
|
||||
padding: 9px 11px;
|
||||
border-bottom: var(--border-line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connector-folder-sync-result:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.connector-folder-sync-result > * {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.connector-folder-sync-result.is-header {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: var(--panel-raised, var(--panel));
|
||||
}
|
||||
|
||||
.connector-folder-sync-result.is-created strong,
|
||||
.connector-folder-sync-result.is-updated strong,
|
||||
.connector-folder-sync-result.is-unchanged strong {
|
||||
color: var(--success, var(--text-strong));
|
||||
}
|
||||
|
||||
.connector-folder-sync-result.is-conflict strong,
|
||||
.connector-folder-sync-result.is-policy_denied strong,
|
||||
.connector-folder-sync-result.is-failed strong {
|
||||
color: var(--danger, var(--text-strong));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.managed-file-entry-head,
|
||||
.managed-file-entry {
|
||||
@@ -1269,6 +1341,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.connector-folder-sync-result {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.managed-file-chooser-dialog {
|
||||
width: calc(100vw - 20px);
|
||||
height: calc(100vh - 20px);
|
||||
|
||||
Reference in New Issue
Block a user