Release v0.1.4

This commit is contained in:
2026-07-02 14:59:52 +02:00
parent ab2343aa88
commit fb6fb67d45
27 changed files with 1437 additions and 1785 deletions

View File

@@ -61,6 +61,10 @@ Frontend package:
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Operations
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
## Release packaging
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.

View File

@@ -0,0 +1,77 @@
# Campaign Delivery Runbook
This runbook covers controlled campaign delivery after a campaign version has
been validated, built, reviewed, and locked.
## Operating Modes
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
small development runs can be processed with "Send queued now".
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
publishes delivery tasks, and the Review & Send page polls summary counters.
- Mock send: use only for development review. It does not prove real SMTP/IMAP
credentials or server policy.
## Before First Live Use
- Use dedicated non-production SMTP/IMAP credentials.
- Use a dedicated mailbox/folder for append-to-Sent tests.
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional
IMAP append target.
- Run one campaign each for no attachment, one attachment, and password-protected
ZIP before using production recipients.
- Keep the report page open during tests; it is the operational source of truth
for attempts, outcomes, and reconciliation.
## Queue And Send
1. Validate the version with file checks enabled.
2. Build the version and inspect all blocking review items.
3. Queue only after the selected version is the intended immutable execution
version.
4. In local mode, use "Send queued now" for small test runs.
5. In worker mode, verify queue counters move from queued/claimed/sending to a
terminal SMTP state.
## Outcome Handling
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
enqueue the append action.
- `failed_temporary`: Retry explicitly after checking the error and retry count.
- `failed_permanent`: Retry only if the operator has corrected the root cause and
intentionally includes permanent failures.
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
provider control panels, then reconcile as accepted or not sent.
- `claimed` or `sending` that does not progress: treat as a worker interruption.
Re-run worker handling or reconcile if SMTP may already have accepted the
message.
## Reconciliation
- Choose "Accepted" only with external evidence that SMTP accepted the message.
The job becomes protected from retry and may proceed to IMAP append.
- Choose "Not sent" only when SMTP did not accept the message. The job becomes a
temporary failure and can be selected by explicit retry.
- Add a note that identifies the evidence used, for example SMTP log line,
provider message ID, or operator ticket.
## Fault Injection Checklist
Use mock infrastructure first, then repeat against the non-production real test
bed where possible:
- SMTP temporary failure.
- SMTP permanent failure.
- Recipient refusal after partial SMTP acceptance.
- Connection drop or worker interruption during SMTP.
- IMAP append failure after SMTP acceptance.
- Worker restart with queued, claimed, and sending jobs.
## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately.
- Accepted and unknown jobs must not appear in retry selections.
- Reconciled accepted jobs must remain protected from resend.
- Reconciled not-sent jobs must appear only as explicit retry candidates.
- The final report should include SMTP attempts, IMAP append attempts, and any
reconciliation notes before the campaign is considered operationally closed.

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.1",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,8 +18,11 @@
"README.md",
"LICENSE"
],
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.1",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.3"
version = "0.1.4"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.3",
"govoplan-core>=0.1.4",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",

View File

@@ -489,6 +489,35 @@ class EntryConfig(StrictModel):
last_sent: str | None = None
class ImportColumnProvenance(StrictModel):
column_index: int = Field(ge=0)
header: str
kind: str
field_name: str | None = None
new_field_name: str | None = None
class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text"]
filename: str | None = None
sheet_name: str | None = None
encoding: str | None = None
delimiter: str | None = None
header_rows: int = Field(default=0, ge=0)
quoted: bool | None = None
value_separators: str | None = None
rows_total: int = Field(default=0, ge=0)
valid_rows: int = Field(default=0, ge=0)
invalid_rows: int = Field(default=0, ge=0)
imported_rows: int = Field(default=0, ge=0)
field_names_created: list[str] = Field(default_factory=list)
attachment_patterns: int = Field(default=0, ge=0)
mapping: list[ImportColumnProvenance] = Field(default_factory=list)
class SourceConfig(StrictModel):
type: SourceType
path: str
@@ -502,6 +531,7 @@ class EntriesConfig(StrictModel):
source: SourceConfig | None = None
mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None
imports: list[ImportProvenance] = Field(default_factory=list)
@model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig":

View File

@@ -144,6 +144,33 @@ class CampaignShare(Base, TimestampMixin):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
delimiter: Mapped[str] = mapped_column(String(8), nullable=False)
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
quoted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
tenant: Mapped[Tenant] = relationship()
owner: Mapped[User] = relationship()
class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)

View File

@@ -109,7 +109,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.2",
version="0.1.4",
dependencies=("access",),
optional_dependencies=("files", "mail"),
permissions=PERMISSIONS,

View File

@@ -0,0 +1,65 @@
"""recipient import mapping profiles
Revision ID: 2c3d4e5f7081
Revises: 1b2c3d4e5f70
Create Date: 2026-06-26 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f7081"
down_revision = "1b2c3d4e5f70"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
return
op.create_table(
"campaign_recipient_import_mapping_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("column_count", sa.Integer(), nullable=False),
sa.Column("headers", sa.JSON(), nullable=False),
sa.Column("normalized_headers", sa.JSON(), nullable=False),
sa.Column("ordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("unordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("delimiter", sa.String(length=8), nullable=False),
sa.Column("header_rows", sa.Integer(), nullable=False),
sa.Column("quoted", sa.Boolean(), nullable=False),
sa.Column("value_separators", sa.String(length=50), nullable=False),
sa.Column("mappings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), "campaign_recipient_import_mapping_profiles", ["owner_user_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["ordered_header_fingerprint"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["unordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_owner", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id"], unique=False)
op.create_index("ix_recipient_import_profiles_ordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "ordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_unordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "unordered_header_fingerprint"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" not in inspector.get_table_names():
return
op.drop_index("ix_recipient_import_profiles_unordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_ordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_owner", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_table("campaign_recipient_import_mapping_profiles")

View File

@@ -19,6 +19,9 @@ from govoplan_campaign.backend.schemas import (
CampaignShareTargetsResponse,
CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
CampaignJobsResponse,
CampaignJobDetailResponse,
CampaignRetryJobsRequest,
@@ -40,7 +43,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, SendAttempt, User, UserGroupMembership
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt, User, UserGroupMembership
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
@@ -181,6 +184,27 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile:
profile = session.get(RecipientImportMappingProfile, profile_id)
if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found")
return profile
def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None:
profile.name = payload.name.strip()
profile.column_count = payload.column_count
profile.headers = list(payload.headers)
profile.normalized_headers = list(payload.normalized_headers)
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
profile.delimiter = payload.delimiter
profile.header_rows = payload.header_rows
profile.quoted = payload.quoted
profile.value_separators = payload.value_separators
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
if proposed is None:
return False
@@ -346,6 +370,109 @@ def list_campaigns(
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profiles = (
session.query(RecipientImportMappingProfile)
.filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id,
)
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
.all()
)
return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
)
@router.post(
"/recipient-import/mapping-profiles",
response_model=RecipientImportMappingProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_recipient_import_mapping_profile(
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = RecipientImportMappingProfile(
tenant_id=principal.tenant_id,
owner_user_id=principal.user.id,
name=payload.name.strip(),
column_count=payload.column_count,
headers=list(payload.headers),
normalized_headers=list(payload.normalized_headers),
ordered_header_fingerprint=payload.ordered_header_fingerprint,
unordered_header_fingerprint=payload.unordered_header_fingerprint,
delimiter=payload.delimiter,
header_rows=payload.header_rows,
quoted=payload.quoted,
value_separators=payload.value_separators,
mappings=[mapping.model_dump(mode="json") for mapping in payload.mappings],
)
session.add(profile)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_created",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.put("/recipient-import/mapping-profiles/{profile_id}", response_model=RecipientImportMappingProfileResponse)
def update_recipient_import_mapping_profile(
profile_id: str,
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
_apply_recipient_import_profile_payload(profile, payload)
session.add(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_updated",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.delete("/recipient-import/mapping-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipient_import_mapping_profile(
profile_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
details = {"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint}
session.delete(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_deleted",
object_type="recipient_import_mapping_profile",
object_id=profile_id,
details=details,
commit=True,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
@@ -2008,4 +2135,3 @@ def preview_campaign_attachments(
rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
)

View File

@@ -482,6 +482,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -505,6 +512,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -1027,6 +1041,151 @@
},
"additionalProperties": false
},
"import_provenance": {
"type": "object",
"required": [
"id",
"imported_at",
"mode",
"source_type",
"header_rows",
"rows_total",
"valid_rows",
"invalid_rows",
"imported_rows"
],
"properties": {
"id": {
"type": "string"
},
"imported_at": {
"type": "string",
"format": "date-time"
},
"mode": {
"type": "string",
"enum": [
"append",
"replace"
]
},
"source_type": {
"type": "string",
"enum": [
"csv",
"xlsx",
"text"
]
},
"filename": {
"type": [
"string",
"null"
]
},
"sheet_name": {
"type": [
"string",
"null"
]
},
"encoding": {
"type": [
"string",
"null"
]
},
"delimiter": {
"type": [
"string",
"null"
]
},
"header_rows": {
"type": "integer",
"minimum": 0,
"default": 0
},
"quoted": {
"type": [
"boolean",
"null"
]
},
"value_separators": {
"type": [
"string",
"null"
]
},
"rows_total": {
"type": "integer",
"minimum": 0
},
"valid_rows": {
"type": "integer",
"minimum": 0
},
"invalid_rows": {
"type": "integer",
"minimum": 0
},
"imported_rows": {
"type": "integer",
"minimum": 0
},
"field_names_created": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"attachment_patterns": {
"type": "integer",
"minimum": 0,
"default": 0
},
"mapping": {
"type": "array",
"items": {
"type": "object",
"required": [
"column_index",
"header",
"kind"
],
"properties": {
"column_index": {
"type": "integer",
"minimum": 0
},
"header": {
"type": "string"
},
"kind": {
"type": "string"
},
"field_name": {
"type": [
"string",
"null"
]
},
"new_field_name": {
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
},
"default": []
}
},
"additionalProperties": false
},
"attachment_base_path": {
"type": "object",
"required": [

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.mail.config import ImapConfig, SmtpConfig
@@ -186,6 +186,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
owner_group_id: str | None = None
RecipientImportColumnKind = Literal[
"ignore",
"id",
"active",
"name",
"from",
"to",
"cc",
"bcc",
"reply_to",
"field",
"new_field",
"attachment_pattern",
]
class RecipientImportColumnMappingPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
class RecipientImportMappingProfilePayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
if len(self.headers) != self.column_count:
raise ValueError("headers length must match columnCount")
if len(self.normalized_headers) != self.column_count:
raise ValueError("normalizedHeaders length must match columnCount")
for mapping in self.mappings:
if mapping.column_index >= self.column_count:
raise ValueError("mapping columnIndex exceeds columnCount")
return self
class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
class RecipientImportMappingProfileListResponse(BaseModel):
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,8 +13,11 @@
},
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
},
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -92,6 +92,38 @@ export type CampaignWorkspaceQuery = {
includeVersions?: boolean;
};
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientImportColumnMappingProfileItem = {
columnIndex: number;
kind: RecipientImportColumnKind;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientImportColumnMappingProfileItem[];
};
export type RecipientImportMappingProfilePayload = Omit<RecipientImportMappingProfile, "id" | "createdAt" | "updatedAt">;
export type RecipientImportMappingProfileListResponse = {
profiles: RecipientImportMappingProfile[];
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
@@ -298,6 +330,36 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
return response.profiles ?? [];
}
export async function createRecipientImportMappingProfile(
settings: ApiSettings,
payload: RecipientImportMappingProfilePayload
): Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function updateRecipientImportMappingProfile(
settings: ApiSettings,
profileId: string,
payload: RecipientImportMappingProfilePayload
): Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function deleteRecipientImportMappingProfile(settings: ApiSettings, profileId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}

View File

@@ -1,100 +0,0 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type FileSpace = {
id: string;
label: string;
owner_type: "user" | "group";
owner_id: string;
description?: string | null;
};
export type FileShare = {
id: string;
target_type: string;
target_id: string;
permission: string;
created_at: string;
revoked_at?: string | null;
};
export type ManagedFile = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
display_path: string;
filename: string;
description?: string | null;
size_bytes: number;
content_type?: string | null;
checksum_sha256: string;
version_id: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
shares?: FileShare[];
};
export type FileListResponse = { files: ManagedFile[] };
export type FileSpacesResponse = { spaces: FileSpace[] };
export type FileFolder = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
path: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type FileFoldersResponse = { folders: FileFolder[] };
export type PatternResolveResponse = {
patterns: { pattern: string; matches: ManagedFile[] }[];
unmatched: ManagedFile[];
};
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
}
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
const search = new URLSearchParams();
search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
}
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) search.set(key, value);
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
}
export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST",
body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" })
});
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0];
if (!share) throw new Error("File share was not created.");
return share;
}
export function resolveFilePatterns(
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
): Promise<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
@@ -18,7 +17,6 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
@@ -30,9 +28,13 @@ type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files");
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
const listManagedFileSpaces = filesModuleInstalled ? filesFileExplorer?.listFileSpaces : undefined;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion;
@@ -71,16 +73,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
);
useEffect(() => {
if (!filesModuleInstalled) {
if (!listManagedFileSpaces) {
setFileSpaces([]);
return;
}
let cancelled = false;
void listFileSpaces(settings)
void listManagedFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
@@ -251,7 +253,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
@@ -310,7 +312,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
@@ -321,9 +323,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>{filesModuleInstalled ? "Connected through Files" : "Manual paths"}</dd></div>
<div><dt>Upload support</dt><dd>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div>
</dl>
<p className="muted small-note">{filesModuleInstalled ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
<p className="muted small-note">{managedFilesAvailable
? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build."
: filesModuleInstalled
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability."
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
</Card>
</>
@@ -357,11 +363,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && filesModuleInstalled && (
{pathChooser && ManagedFileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
storageScope={campaignId}
mode="folder"
source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path}
@@ -562,8 +568,8 @@ function uniqueStrings(values: string[]): string[] {
type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
filesModuleInstalled: boolean;
fileSpaces: FilesFileSpace[];
managedFilesAvailable: boolean;
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
@@ -572,7 +578,7 @@ type AttachmentSourceColumnContext = {
setPathChooser: (state: PathChooserState | null) => void;
};
function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
@@ -586,23 +592,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
readOnly={managedFilesAvailable}
tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="attachments"
onChange={(event) => {
if (!filesModuleInstalled) patchBasePath(index, { path: event.target.value, source: "" });
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
}}
onClick={() => filesModuleInstalled && !locked && setPathChooser({ index })}
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) {
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
@@ -631,7 +637,7 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
}
];
}
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
const parsedSource = parseManagedAttachmentSource(basePath.source);
const matchingSpace = parsedSource
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)

View File

@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
listRecipientImportMappingProfiles,
updateRecipientImportMappingProfile,
type RecipientImportMappingProfilePayload
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
@@ -20,20 +27,33 @@ import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
import {
applyRecipientMappingProfile,
buildImportTable,
buildImportTableFromRows,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
matchRecipientMappingProfiles,
materializeRecipientImport,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview
type RecipientImportPreview,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
type RecipientMappingProfile,
type RecipientMappingProfileMatch
} from "./utils/bulkImport";
import {
bulkLinkFilesToCampaign,
type ImportFileLinkCapability,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkResolution
@@ -178,7 +198,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
@@ -194,7 +214,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
@@ -359,12 +379,12 @@ type RecipientImportDialogProps = {
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const recipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
const allRecipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
{ id: "upload", label: "Upload" },
{ id: "parse", label: "Parse" },
{ id: "map", label: "Map" },
@@ -387,28 +407,74 @@ const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: str
{ value: "attachment_pattern", label: "Attachment pattern" }
];
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
const RECIPIENT_IMPORT_ENCODINGS = [
{ value: "utf-8", label: "UTF-8" },
{ value: "windows-1252", label: "Windows-1252" },
{ value: "iso-8859-1", label: "ISO-8859-1" },
{ value: "utf-16le", label: "UTF-16 LE" },
{ value: "utf-16be", label: "UTF-16 BE" }
];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
return {
listFiles: filesFileExplorer.listFiles,
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
};
}, [filesFileExplorer]);
const recipientImportSteps = useMemo(
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
[fileLinkCapability]
);
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
const [filename, setFilename] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState("utf-8");
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
const [selectedSheetName, setSelectedSheetName] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
const [mappingProfileError, setMappingProfileError] = useState("");
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const hasContent = csvText.trim().length > 0;
const selectedSheet = useMemo(
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
[selectedSheetName, workbookSheets]
);
const hasContent = sourceType === "xlsx"
? Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim())))
: csvText.trim().length > 0;
const table = useMemo(
() => hasContent ? buildImportTable(csvText, { delimiter, headerRows, quoted }) : null,
[csvText, delimiter, hasContent, headerRows, quoted]
() => {
if (!hasContent) return null;
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
return buildImportTable(csvText, { delimiter, headerRows, quoted });
},
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const mappingProfileMatches = useMemo(
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
[mappingProfiles, table]
);
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
@@ -417,33 +483,93 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
let cancelled = false;
setMappingProfileError("");
void listRecipientImportMappingProfiles(settings)
.then((profiles) => {
if (!cancelled) setMappingProfiles(profiles);
})
.catch((err) => {
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!fileBuffer || sourceType !== "csv") return;
try {
setCsvText(decodeImportText(fileBuffer, encoding));
setFileError("");
} catch (err) {
setCsvText("");
setFileError(err instanceof Error ? err.message : String(err));
}
}, [encoding, fileBuffer, sourceType]);
useEffect(() => {
setMappingManuallyChanged(false);
}, [tableHeaderKey]);
useEffect(() => {
if (!table) {
setMappings([]);
setMappingProfileNotice("");
return;
}
if (mappingManuallyChanged) return;
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
if (automaticMatch) {
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
}, [existingFields, tableHeaderKey]);
setMappingProfileNotice("");
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files") void refreshFileLinks();
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
}, [activeStep, fileLinkCapability]);
useEffect(() => {
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, renderedPatterns.length]);
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
setWorkbookSheets([]);
setSelectedSheetName("");
try {
setCsvText(await file.text());
const buffer = await file.arrayBuffer();
if (isXlsxFile(file)) {
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
if (sheets.length === 0) {
throw new Error("Workbook contains no readable sheets.");
}
setSourceType("xlsx");
setFileBuffer(null);
setCsvText("");
setWorkbookSheets(sheets);
setSelectedSheetName(sheets[0]?.name ?? "");
} else {
setSourceType("csv");
setFileBuffer(buffer);
setCsvText(decodeImportText(buffer, encoding));
}
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
@@ -454,6 +580,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
if (stepId === "files" && !fileLinkCapability) return false;
return parseReady && mappings.length > 0;
}
@@ -475,6 +602,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
async function refreshFileLinks() {
setFileLinkNotice("");
if (!fileLinkCapability) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
@@ -488,7 +620,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
@@ -498,12 +630,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
}
async function linkImportedFiles() {
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
@@ -513,9 +645,60 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
}
}
function rememberCurrentMappingProfile() {
if (!table || mappings.length === 0) return;
const tableSnapshot = table;
const mappingsSnapshot = mappings;
const fallbackProfiles = mappingProfiles;
const filenameSnapshot = filename;
const parseOptions = { headerRows, quoted };
const valueSeparatorsSnapshot = valueSeparators;
void (async () => {
try {
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
const draftProfile = buildRecipientMappingProfile({
id: reusableProfile?.id,
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
table: tableSnapshot,
mappings: mappingsSnapshot,
parseOptions,
valueSeparators: valueSeparatorsSnapshot,
createdAt: reusableProfile?.createdAt,
updatedAt: new Date().toISOString()
});
const payload = mappingProfilePayload(draftProfile);
await (reusableProfile
? updateRecipientImportMappingProfile(settings, reusableProfile.id, payload)
: createRecipientImportMappingProfile(settings, payload));
} catch {
// Import acceptance must not be blocked by passive profile learning.
}
})();
}
function confirmRecipientImport(nextPreview: RecipientImportPreview) {
rememberCurrentMappingProfile();
const provenance = createRecipientImportProvenance({
preview: nextPreview,
mappings,
mode,
sourceType,
filename,
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
encoding: sourceType === "xlsx" ? null : encoding,
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
headerRows,
quoted: sourceType === "xlsx" ? null : quoted,
valueSeparators
});
onImport(nextPreview, mode, provenance);
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappingManuallyChanged(true);
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
@@ -537,11 +720,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="Recipient file">
<FileDropZone
accept=".csv,.tsv,text/csv,text/tab-separated-values,text/plain"
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple={false}
label="Drop recipient file here"
actionLabel="or click to select CSV"
note={filename || "CSV, TSV or text"}
actionLabel="or click to select file"
note={filename || "CSV, TSV, text or XLSX"}
onFiles={(files) => readFile(files[0])}
/>
</FormField>
@@ -553,21 +736,38 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
{sourceType === "xlsx" ? (
<DismissibleAlert tone="info" compact dismissible={false}>
Workbook loaded: {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
</DismissibleAlert>
) : (
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setFileBuffer(null);
setSourceType(filename ? "csv" : "text");
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
)}
</>
) : activeStep === "parse" ? (
<>
<div className="campaign-header-grid">
{sourceType === "xlsx" && (
<FormField label="Sheet">
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
{workbookSheets.map((sheet) => (
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
))}
</select>
</FormField>
)}
<FormField label="Header rows">
<input
type="number"
@@ -577,22 +777,33 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
/>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
{sourceType !== "xlsx" && (
<>
<FormField label="Encoding">
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
{RECIPIENT_IMPORT_ENCODINGS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
</>
)}
</div>
{table && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Separator</dt><dd>{formatDelimiter(table.delimiter)}</dd></div>
<div><dt>{sourceType === "xlsx" ? "Sheet" : "Separator"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "Workbook" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>Header rows</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>Columns</dt><dd>{table.headers.length}</dd></div>
@@ -603,9 +814,17 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</>
) : activeStep === "map" ? (
<>
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="Multi-value separators">
<input value={valueSeparators} onChange={(event) => setValueSeparators(event.target.value)} />
<input
value={valueSeparators}
onChange={(event) => {
setMappingManuallyChanged(true);
setValueSeparators(event.target.value);
}}
/>
</FormField>
<FormField label="Mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
@@ -729,10 +948,10 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</Button>}
{activeStep !== "files" ? (
{!isLastStep ? (
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>Next</Button>
) : (
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && onImport(preview, mode)}>Import valid rows</Button>
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>Import valid rows</Button>
)}
</>
)}
@@ -895,6 +1114,65 @@ function formatDelimiter(delimiter: "," | ";" | "\t"): string {
return "Comma";
}
function isXlsxFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
try {
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
} catch (err) {
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
return {
name: profile.name,
columnCount: profile.columnCount,
headers: profile.headers,
normalizedHeaders: profile.normalizedHeaders,
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
delimiter: profile.delimiter,
headerRows: profile.headerRows,
quoted: profile.quoted,
valueSeparators: profile.valueSeparators,
mappings: profile.mappings
};
}
function defaultMappingProfileName(filename: string, table: { headers: string[] }): string {
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
if (trimmedFilename) return trimmedFilename;
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
return headerLabel ? `Mapping: ${headerLabel}` : "Recipient import mapping";
}
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return "exact";
if (match.mode === "unordered") return "same headers";
return `${Math.round(match.score * 100)}%`;
}
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
}
function findReusableMappingProfile(table: { headers: string[] }, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint)
?? profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint)
?? null;
}
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return `Using mapping from previous import: ${match.profile.name}.`;
if (match.mode === "unordered") return `Using mapping from previous import: ${match.profile.name}; matched by header names.`;
return `Using mapping derived from previous import: ${match.profile.name} (${formatMappingProfileMatch(match)} match).`;
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}

View File

@@ -3,13 +3,14 @@ import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -160,6 +161,9 @@ export function AttachmentRulesDataGrid({
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
@@ -183,7 +187,7 @@ export function AttachmentRulesDataGrid({
}
function openFileChooser(ruleIndex: number) {
if (!filesModuleInstalled) return;
if (!managedFilesAvailable) return;
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
@@ -200,7 +204,7 @@ export function AttachmentRulesDataGrid({
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
function selectAttachment(selection: FilesManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
@@ -219,24 +223,26 @@ export function AttachmentRulesDataGrid({
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && filesModuleInstalled && (
{fileChooser && ManagedFileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>

View File

@@ -1,788 +0,0 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFilesWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
import {
buildExplorerEntries,
buildFolderTree,
formatBytes,
formatDate,
normalizeFilePath,
normalizeFolder,
parentFolderPath,
sortExplorerEntries
} from "../../files/utils/fileManagerUtils";
import {
encodeManagedAttachmentSource,
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
source: string;
};
export type ManagedAttachmentSelection = ManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: AttachmentChoiceMode;
};
type ManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
campaignId: string;
mode: ManagedFileChooserMode;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
previewContext?: Record<string, string>;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
previewContext,
onClose,
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const filesExplorerUi = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const FolderTreeComponent = filesExplorerUi?.FolderTree ?? FolderTree;
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
[campaignId, rememberKey]
);
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
const [spaces, setSpaces] = useState<FileSpace[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const patternRef = useRef("");
const rememberDraftTimerRef = useRef<number | null>(null);
const [patternHasText, setPatternHasText] = useState(false);
const [exactSelectionPattern, setExactSelectionPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
const [resolving, setResolving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
[currentFolder, files, folders]
);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const sortedEntries = useMemo(
() => sortExplorerEntries(entries, sortColumn, sortDirection),
[entries, sortColumn, sortDirection]
);
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const openFolder = useCallback((path: string) => {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}, [effectiveRoot, mode]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
const toggleSort = useCallback((column: SortColumn) => {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}, [sortColumn]);
const applyExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
patternRef.current = exactPattern;
setPattern(exactPattern);
setPatternHasText(true);
setExactSelectionPattern(exactPattern);
setPatternMatches([file]);
setPendingExactFile(null);
}, [effectiveRoot]);
const scheduleRememberedState = useCallback((patternValue = patternRef.current) => {
if (!open || !selectedSpaceId) return;
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
rememberDraftTimerRef.current = window.setTimeout(() => {
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue });
rememberDraftTimerRef.current = null;
}, 350);
}, [currentFolder, open, selectedSpaceId, storageKey]);
useEffect(() => () => {
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
}, []);
const requestExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = patternRef.current.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}, [applyExactFile, effectiveRoot]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
patternRef.current = initialPatternValue;
setPattern(initialPatternValue);
setPatternHasText(Boolean(initialPatternValue.trim()));
setExactSelectionPattern("");
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpace) return;
let cancelled = false;
setLoading(true);
setError("");
setPatternMatches(null);
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
scheduleRememberedState();
}, [currentFolder, pattern, scheduleRememberedState]);
function updatePatternInput(value: string) {
patternRef.current = value;
setPatternHasText((current) => {
const next = Boolean(value.trim());
return current === next ? current : next;
});
setExactSelectionPattern("");
if (patternMatches !== null) setPatternMatches(null);
scheduleRememberedState(value);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = patternRef.current.trim();
if (!trimmed) {
setPatternMatches([]);
setPattern("");
setPatternHasText(false);
return [];
}
const previewPatternValue = renderChooserPattern(trimmed, previewContext);
if (!previewPatternValue) {
setPatternMatches([]);
setPattern(trimmed);
return [];
}
setPattern(trimmed);
setPatternHasText(true);
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [previewPatternValue],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
include_unmatched: false
});
const matches = response.patterns[0]?.matches ?? [];
setPatternMatches(matches);
return matches;
} catch (reason) {
setError(errorMessage(reason));
return [];
} finally {
setResolving(false);
}
}
async function shareMatches(matches: ManagedFile[]) {
const fileIds = matches
.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)))
.map((file) => file.id);
const uniqueIds = [...new Set(fileIds)];
if (uniqueIds.length > 0) await shareFilesWithCampaign(settings, uniqueIds, campaignId);
}
async function confirmSelection() {
if (!selectedSpace || !sourceMatchesSpace) return;
setSubmitting(true);
setError("");
try {
if (mode === "folder") {
onSelectFolder?.({
space: selectedSpace,
folderPath: currentFolder,
source: encodeManagedAttachmentSource(selectedSpace)
});
return;
}
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = patternRef.current.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
source: encodeManagedAttachmentSource(selectedSpace),
fileFilter: trimmedPattern,
matchCount: matches.length,
fileIds: matches.map((file) => file.id),
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
});
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !patternHasText))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<FolderTreeComponent
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
dragDropEnabled={false}
contextMenuEnabled={false}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
</div>
</div>
{mode === "attachment" && (
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)}
/>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
campaignId={campaignId}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile}
/>
)}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace "${patternRef.current.trim()}" with the exact file "${relativePath(pendingExactFile.display_path, effectiveRoot)}"?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
return createPortal(dialog, document.body);
}
function ChooserSortButton({
column,
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
}
function ManagedPatternEditor({
value,
effectiveRoot,
resolving,
matchCount,
previewContext,
onDraftChange,
onPreview,
onBackToFolder
}: {
value: string;
effectiveRoot: string;
resolving: boolean;
matchCount: number | null;
previewContext?: Record<string, string>;
onDraftChange: (value: string) => void;
onPreview: () => void;
onBackToFolder: () => void;
}) {
const [draft, setDraft] = useState(value);
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext), [draft, previewContext]);
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
useEffect(() => {
setDraft(value);
}, [value]);
function updateDraft(next: string) {
setDraft(next);
onDraftChange(next);
}
return (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={draft}
onChange={(event) => updateDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") onPreview(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking..." : "Preview"}
</Button>
</div>
</label>
{matchCount !== null && (
<div className="managed-pattern-summary">
<span>{matchCount} current file{matchCount === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={onBackToFolder}>Back to folder</Button>
</div>
)}
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
</div>
);
}
const ChooserFileBrowser = memo(function ChooserFileBrowser({
mode,
loading,
currentFolder,
effectiveRoot,
sortedEntries,
sortColumn,
sortDirection,
campaignId,
selectedExactPattern,
onOpenFolder,
onToggleSort,
onRequestExactFile
}: {
mode: ManagedFileChooserMode;
loading: boolean;
currentFolder: string;
effectiveRoot: string;
sortedEntries: ChooserExplorerEntry[];
sortColumn: SortColumn;
sortDirection: SortDirection;
campaignId: string;
selectedExactPattern: string;
onOpenFolder: (path: string) => void;
onToggleSort: (column: SortColumn) => void;
onRequestExactFile: (file: ManagedFile) => void;
}) {
return (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size">-</span>
<span className="managed-file-entry-modified">-</span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => onOpenFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? onRequestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
);
});
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
</div>
<div className="file-list-table">
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{files.map((file) => (
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
</div>
</div>
);
}
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
}
function normalizeManagedBasePath(value: string): string {
const normalized = normalizeFolder(value);
return normalized === "." ? "" : normalized;
}
function relativePath(path: string, root: string): string {
const normalizedPath = normalizeFilePath(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return normalizedPath;
const prefix = `${normalizedRoot}/`;
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
}));
}
function parentWithinRoot(folder: string, root: string): string {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
const parent = parentFolderPath(normalizedFolder);
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
}
function isWithinRoot(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return nodes;
const node = findNode(nodes, normalizedRoot);
return node?.children ?? [];
}
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
for (const node of nodes) {
if (node.path === path) return node;
const child = findNode(node.children, path);
if (child) return child;
}
return null;
}
function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function renderChooserPattern(pattern: string, previewContext?: Record<string, string>): string {
const trimmed = pattern.trim();
if (!trimmed || !previewContext) return trimmed;
return renderTemplatePreviewText(trimmed, previewContext, false).trim();
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -1,4 +1,3 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
@@ -98,9 +97,14 @@ export type ManagedAttachmentSource = {
ownerId: string;
};
type ManagedAttachmentSourceSpace = {
owner_type: "user" | "group";
owner_id: string;
};
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
export function encodeManagedAttachmentSource(space: ManagedAttachmentSourceSpace): string {
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
}

View File

@@ -1,4 +1,9 @@
type ImportRecord = Record<string, unknown>;
type XlsxWorkbookSheet = {
sheet: string;
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
@@ -23,13 +28,15 @@ export type CsvParseOptions = {
quoted: boolean;
};
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientColumnMapping = {
columnIndex: number;
kind: RecipientColumnKind;
fieldName?: string;
newFieldName?: string;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportTable = {
@@ -41,6 +48,11 @@ export type RecipientImportTable = {
firstDataRowNumber: number;
};
export type RecipientImportSpreadsheetSheet = {
name: string;
rows: string[][];
};
export type ImportedAddress = {
email: string;
name?: string;
@@ -73,15 +85,84 @@ export type RecipientImportPreview = {
patternCount: number;
};
export type RecipientImportColumnProvenance = {
column_index: number;
header: string;
kind: RecipientColumnKind;
field_name?: string | null;
new_field_name?: string | null;
};
export type RecipientImportProvenance = {
id: string;
imported_at: string;
mode: RecipientImportMode;
source_type: RecipientImportSourceType;
filename?: string | null;
sheet_name?: string | null;
encoding?: string | null;
delimiter?: string | null;
header_rows: number;
quoted?: boolean | null;
value_separators?: string | null;
rows_total: number;
valid_rows: number;
invalid_rows: number;
imported_rows: number;
field_names_created: string[];
attachment_patterns: number;
mapping: RecipientImportColumnProvenance[];
};
export type RecipientImportPreviewOptions = {
existingFields: ImportFieldDefinition[];
existingEntries?: Record<string, unknown>[];
valueSeparators?: string;
};
export type RecipientMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientColumnMapping[];
};
export type RecipientMappingProfileMatchMode = "ordered" | "unordered" | "similar";
export type RecipientMappingProfileMatch = {
profile: RecipientMappingProfile;
mode: RecipientMappingProfileMatchMode;
score: number;
matchedHeaders: number;
missingHeaders: string[];
extraHeaders: string[];
};
export type RecipientMappingProfileInput = {
id?: string;
name: string;
table: RecipientImportTable;
mappings: RecipientColumnMapping[];
parseOptions: Pick<CsvParseOptions, "headerRows" | "quoted">;
valueSeparators: string;
createdAt?: string;
updatedAt?: string;
};
export type MaterializeImportOptions = {
mode: RecipientImportMode;
attachmentBasePath?: ImportAttachmentBasePath | null;
provenance?: RecipientImportProvenance | null;
};
const EMAIL_COLUMNS = new Set(["email", "e_mail", "mail", "to", "to_email", "recipient", "recipient_email"]);
@@ -92,13 +173,21 @@ const ACTIVE_COLUMNS = new Set(["active", "enabled", "send", "include"]);
export function buildImportTable(text: string, options: CsvParseOptions): RecipientImportTable {
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
const rows = parseDelimitedText(text, delimiter, options.quoted);
return buildImportTableFromRows(rows, { headerRows: options.headerRows, delimiter });
}
export function buildImportTableFromRows(
sourceRows: string[][],
options: { headerRows: number; delimiter?: "," | ";" | "\t" }
): RecipientImportTable {
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
const headerRows = rows.slice(0, normalizedHeaderRows);
const dataRows = rows.slice(normalizedHeaderRows);
const columnCount = Math.max(0, ...rows.map((row) => row.length));
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
return {
delimiter,
delimiter: options.delimiter ?? ",",
headers,
headerRows,
rows,
@@ -107,6 +196,24 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
};
}
export async function xlsxSheetsFromArrayBuffer(buffer: ArrayBuffer): Promise<RecipientImportSpreadsheetSheet[]> {
const readXlsxFile = await loadXlsxWorkbookReader();
const workbook = await readXlsxFile(buffer);
return workbook.map((sheet) => ({
name: sheet.sheet,
rows: sheet.data.map((row) => row.map((cell) => cellToImportText(cell)))
}));
}
async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
const module = await import("read-excel-file/browser");
return module.default as XlsxWorkbookReader;
}
const module = await import("read-excel-file/universal");
return module.default as XlsxWorkbookReader;
}
export function defaultColumnMappings(headers: string[], existingFields: ImportFieldDefinition[]): RecipientColumnMapping[] {
const existingFieldNames = new Set(existingFields.map((field) => field.name));
return headers.map((header, columnIndex) => {
@@ -128,6 +235,95 @@ export function defaultColumnMappings(headers: string[], existingFields: ImportF
});
}
export function createRecipientMappingProfile(input: RecipientMappingProfileInput): RecipientMappingProfile {
const fingerprints = recipientImportHeaderFingerprints(input.table.headers);
const updatedAt = input.updatedAt ?? new Date().toISOString();
const createdAt = input.createdAt ?? updatedAt;
const id = input.id ?? `recipient-import-${fingerprints.orderedHeaderFingerprint}-${Date.now().toString(36)}`;
return {
id,
name: input.name.trim() || "Recipient import mapping",
createdAt,
updatedAt,
columnCount: input.table.headers.length,
headers: input.table.headers.slice(),
normalizedHeaders: fingerprints.normalizedHeaders,
orderedHeaderFingerprint: fingerprints.orderedHeaderFingerprint,
unorderedHeaderFingerprint: fingerprints.unorderedHeaderFingerprint,
delimiter: input.table.delimiter,
headerRows: Math.max(0, Math.floor(input.parseOptions.headerRows || 0)),
quoted: input.parseOptions.quoted,
valueSeparators: input.valueSeparators,
mappings: normalizeMappingList(input.mappings, input.table.headers.length)
};
}
export function recipientImportHeaderFingerprints(headers: string[]): Pick<RecipientMappingProfile, "normalizedHeaders" | "orderedHeaderFingerprint" | "unorderedHeaderFingerprint"> {
const normalizedHeaders = headers.map(normalizeImportHeader);
return {
normalizedHeaders,
orderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders),
unorderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders.slice().sort())
};
}
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles
.map((profile): RecipientMappingProfileMatch | null => {
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
if (mode === "similar" && score < 0.45) return null;
return {
profile,
mode,
score,
matchedHeaders: diff.matchedHeaders,
missingHeaders: diff.missingHeaders,
extraHeaders: diff.extraHeaders
};
})
.filter((match): match is RecipientMappingProfileMatch => match !== null)
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
});
}
export function applyRecipientMappingProfile(
table: RecipientImportTable,
profile: RecipientMappingProfile,
existingFields: ImportFieldDefinition[]
): RecipientColumnMapping[] {
const defaults = defaultColumnMappings(table.headers, existingFields);
const fingerprints = recipientImportHeaderFingerprints(table.headers);
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint && table.headers.length === profileMappings.length;
if (exactOrdered) {
return table.headers.map((header, columnIndex) => normalizeProfileMapping(profileMappings[columnIndex], columnIndex, header, existingFields, defaults[columnIndex]));
}
const profileIndexesByHeader = new Map<string, number[]>();
profile.normalizedHeaders.forEach((header, index) => {
const indexes = profileIndexesByHeader.get(header) ?? [];
indexes.push(index);
profileIndexesByHeader.set(header, indexes);
});
return table.headers.map((header, columnIndex) => {
const normalizedHeader = normalizeImportHeader(header);
const profileIndex = profileIndexesByHeader.get(normalizedHeader)?.shift();
if (profileIndex === undefined) return defaults[columnIndex] ?? { columnIndex, kind: "ignore" };
return normalizeProfileMapping(profileMappings[profileIndex], columnIndex, header, existingFields, defaults[columnIndex]);
});
}
export function buildRecipientImportPreview(
table: RecipientImportTable,
mappings: RecipientColumnMapping[],
@@ -240,11 +436,13 @@ export function materializeRecipientImport(
): Record<string, unknown> {
const currentEntries = asRecord(draft.entries);
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
const previousImports = asArray(currentEntries.imports).map(asRecord);
const importedEntries = preview.rows
.filter((row) => row.issues.length === 0)
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
delete nextEntries.source;
delete nextEntries.mapping;
@@ -255,6 +453,52 @@ export function materializeRecipientImport(
};
}
export function createRecipientImportProvenance(input: {
preview: RecipientImportPreview;
mappings: RecipientColumnMapping[];
mode: RecipientImportMode;
sourceType: RecipientImportSourceType;
filename?: string | null;
sheetName?: string | null;
encoding?: string | null;
delimiter?: string | null;
headerRows: number;
quoted?: boolean | null;
valueSeparators?: string | null;
}): RecipientImportProvenance {
const now = new Date().toISOString();
const mappingsByColumn = new Map(input.mappings.map((mapping) => [mapping.columnIndex, mapping]));
return {
id: `recipient-import-${stableHash(`${now}\u001f${input.filename ?? ""}\u001f${input.sheetName ?? ""}\u001f${input.preview.rows.length}`)}`,
imported_at: now,
mode: input.mode,
source_type: input.sourceType,
filename: input.filename?.trim() || null,
sheet_name: input.sheetName?.trim() || null,
encoding: input.encoding?.trim() || null,
delimiter: input.delimiter?.trim() || null,
header_rows: Math.max(0, Math.floor(input.headerRows || 0)),
quoted: input.quoted ?? null,
value_separators: input.valueSeparators ?? null,
rows_total: input.preview.rows.length,
valid_rows: input.preview.validCount,
invalid_rows: input.preview.invalidCount,
imported_rows: input.preview.validCount,
field_names_created: input.preview.fieldNamesToCreate.slice(),
attachment_patterns: input.preview.patternCount,
mapping: input.preview.table.headers.map((header, columnIndex) => {
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
return {
column_index: columnIndex,
header,
kind: mapping.kind,
...(mapping.fieldName ? { field_name: mapping.fieldName } : {}),
...(mapping.newFieldName ? { new_field_name: mapping.newFieldName } : {})
};
})
};
}
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
@@ -360,6 +604,12 @@ function headerForColumn(headerRows: string[][], columnIndex: number): string {
return parts.length ? parts.join(" / ") : `Column ${columnIndex + 1}`;
}
function cellToImportText(value: unknown): string {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
return String(value);
}
function explicitFieldNameFromHeader(header: string): string {
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
return explicit ? sanitizeFieldName(explicit[1]) : "";
@@ -369,6 +619,93 @@ function normalizeColumnKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function normalizeImportHeader(value: string): string {
return normalizeColumnKey(value).replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
}
function fingerprintHeaderList(headers: string[]): string {
return stableHash(headers.join("\u001f"));
}
function stableHash(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): { matchedHeaders: number; missingHeaders: string[]; extraHeaders: string[] } {
const currentCounts = countHeaders(currentHeaders);
const profileCounts = countHeaders(profileHeaders);
let matchedHeaders = 0;
const missingHeaders: string[] = [];
const extraHeaders: string[] = [];
for (const [header, profileCount] of profileCounts) {
const currentCount = currentCounts.get(header) ?? 0;
matchedHeaders += Math.min(currentCount, profileCount);
for (let index = currentCount; index < profileCount; index += 1) missingHeaders.push(header);
}
for (const [header, currentCount] of currentCounts) {
const profileCount = profileCounts.get(header) ?? 0;
for (let index = profileCount; index < currentCount; index += 1) extraHeaders.push(header);
}
return { matchedHeaders, missingHeaders, extraHeaders };
}
function countHeaders(headers: string[]): Map<string, number> {
const counts = new Map<string, number>();
headers.forEach((header) => counts.set(header, (counts.get(header) ?? 0) + 1));
return counts;
}
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
const currentCounts = countHeaders(currentHeaders);
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length)
.filter((mapping) => mapping.kind !== "ignore")
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "")
.filter((header) => header && !currentCounts.has(header));
}
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
const byColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
return Array.from({ length: Math.max(0, columnCount) }, (_value, columnIndex) => normalizeMappingShape(byColumn.get(columnIndex), columnIndex));
}
function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, columnIndex: number): RecipientColumnMapping {
if (!mapping) return { columnIndex, kind: "ignore" };
const kind = mapping.kind;
if (kind === "field") return { columnIndex, kind, fieldName: mapping.fieldName ?? "" };
if (kind === "new_field") return { columnIndex, kind, newFieldName: mapping.newFieldName ?? "" };
return { columnIndex, kind };
}
function normalizeProfileMapping(
mapping: RecipientColumnMapping | undefined,
columnIndex: number,
header: string,
existingFields: ImportFieldDefinition[],
fallback: RecipientColumnMapping | undefined
): RecipientColumnMapping {
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
if (!mapping) return fallbackMapping;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
if (mapping.kind === "field") {
const fieldName = sanitizeFieldName(mapping.fieldName ?? "");
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
if (mapping.kind === "new_field") {
const fieldName = sanitizeFieldName(mapping.newFieldName || header);
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
return { columnIndex, kind: mapping.kind };
}
function isPatternColumn(key: string): boolean {
return key === "pattern"
|| key === "patterns"

View File

@@ -1,5 +1,4 @@
import type { ApiSettings } from "../../../types";
import { listFiles, resolveFilePatterns, shareFilesWithCampaign, type ManagedFile } from "../../../api/files";
import type { ApiSettings, FilesFileExplorerUiCapability, FilesManagedFile } from "@govoplan/core-webui";
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
@@ -10,17 +9,20 @@ export type RenderedImportPattern = {
renderedPattern: string;
};
export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapability>, "listFiles" | "resolveFilePatterns" | "shareFilesWithTarget">;
export type ImportFileLinkResolution = {
basePath: AttachmentBasePath;
patterns: RenderedImportPattern[];
matchedPatterns: { pattern: RenderedImportPattern; matches: ManagedFile[] }[];
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
unmatchedPatterns: RenderedImportPattern[];
files: ManagedFile[];
linkedFiles: ManagedFile[];
linkableFiles: ManagedFile[];
files: FilesManagedFile[];
linkedFiles: FilesManagedFile[];
linkableFiles: FilesManagedFile[];
};
export async function resolveImportedAttachmentLinks(
filesCapability: ImportFileLinkCapability,
settings: ApiSettings,
campaignId: string,
basePath: AttachmentBasePath,
@@ -38,14 +40,14 @@ export async function resolveImportedAttachmentLinks(
const root = normalizedPathPrefix(basePath.path);
const [resolved, visibleFiles] = await Promise.all([
resolveFilePatterns(settings, {
filesCapability.resolveFilePatterns(settings, {
patterns: patterns.map((item) => item.renderedPattern),
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined,
include_unmatched: false
}),
listFiles(settings, {
filesCapability.listFiles(settings, {
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined
@@ -53,7 +55,7 @@ export async function resolveImportedAttachmentLinks(
]);
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
const fileById = new Map<string, ManagedFile>();
const fileById = new Map<string, FilesManagedFile>();
const matchedPatterns = patterns.map((pattern, index) => {
const matches = (resolved.patterns[index]?.matches ?? [])
.map((file) => visibleById.get(file.id) ?? file);
@@ -75,10 +77,10 @@ export async function resolveImportedAttachmentLinks(
};
}
export async function bulkLinkFilesToCampaign(settings: ApiSettings, campaignId: string, files: ManagedFile[]): Promise<number> {
export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCapability, settings: ApiSettings, campaignId: string, files: FilesManagedFile[]): Promise<number> {
const uniqueIds = [...new Set(files.map((file) => file.id).filter(Boolean))];
if (uniqueIds.length === 0) return 0;
const response = await shareFilesWithCampaign(settings, uniqueIds, campaignId);
const response = await filesCapability.shareFilesWithTarget(settings, uniqueIds, { type: "campaign", id: campaignId, label: "campaign" });
return response.shared_count;
}
@@ -97,7 +99,7 @@ export function renderedImportPatterns(preview: RecipientImportPreview): Rendere
return [...byKey.values()];
}
export function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
export function isSharedWithCampaign(file: FilesManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}

View File

@@ -1,79 +0,0 @@
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent } from "react";
import { ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import type { FolderNode } from "../types";
import { normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
type FileActionTarget = { spaceId: string; folderPath: string };
export function FolderTree({
nodes,
activeSpaceId,
spaceId,
currentFolder,
dropTargetKey = "",
expandedKeys,
onOpen,
onToggle,
onContextMenu,
onDragOverTarget,
onDropOnTarget,
onClearDropState,
onRequestDragExpand,
onDragStartFolder,
onDragEndFolder,
dragDropEnabled = true,
contextMenuEnabled = true,
disabled,
depth = 1
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
dropTargetKey?: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
onClearDropState?: () => void;
onRequestDragExpand?: (spaceId: string, path: string) => void;
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
onDragEndFolder?: () => void;
dragDropEnabled?: boolean;
contextMenuEnabled?: boolean;
disabled?: boolean;
depth?: number;
}) {
return (
<ExplorerTree
nodes={nodes}
getNodeId={(node) => treeNodeKey(spaceId, node.path)}
getNodeLabel={(node) => node.name}
getNodeChildren={(node) => node.children}
activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""}
expandedIds={expandedKeys}
onOpen={(node) => onOpen(spaceId, node.path)}
onToggle={(node) => onToggle(spaceId, node.path)}
disabled={disabled}
depth={depth}
getNodeWrapClassName={(node) => {
const target = { spaceId, folderPath: node.path };
return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
}}
getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
getNodeDraggable={() => dragDropEnabled && !disabled}
onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
const target = { spaceId, folderPath: node.path };
onDragOverTarget(event, target);
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
/>
);
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
export function useFileTreeState({
activeSpaceId,
currentFolder,
onOpenFolder
}: {
activeSpaceId: string;
currentFolder: string;
onOpenFolder: (spaceId: string, path: string) => void;
}) {
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(() => new Set());
const dragExpandTimerRef = useRef<number | null>(null);
const dragExpandKeyRef = useRef<string | null>(null);
const suppressTreeAutoExpandKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!activeSpaceId) return;
const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true });
if (ancestors.length === 0) return;
const suppressedKey = suppressTreeAutoExpandKeyRef.current;
suppressTreeAutoExpandKeyRef.current = null;
setExpandedTreeNodes((current) => {
const next = new Set(current);
ancestors.forEach((path) => {
const key = treeNodeKey(activeSpaceId, path);
if (key !== suppressedKey) next.add(key);
});
return next;
});
}, [activeSpaceId, currentFolder]);
function cancelTreeDragExpand() {
if (dragExpandTimerRef.current !== null) {
window.clearTimeout(dragExpandTimerRef.current);
dragExpandTimerRef.current = null;
}
dragExpandKeyRef.current = null;
}
function expandTreeNode(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
setExpandedTreeNodes((current) => {
if (current.has(key)) return current;
const next = new Set(current);
next.add(key);
return next;
});
}
function scheduleTreeDragExpand(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return;
cancelTreeDragExpand();
dragExpandKeyRef.current = key;
dragExpandTimerRef.current = window.setTimeout(() => {
expandTreeNode(spaceId, folderPath);
dragExpandTimerRef.current = null;
dragExpandKeyRef.current = null;
}, 750);
}
function toggleTreeFolder(spaceId: string, folderPath: string) {
const normalizedPath = normalizeFolder(folderPath);
const key = treeNodeKey(spaceId, normalizedPath);
const isExpanded = expandedTreeNodes.has(key);
if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) {
suppressTreeAutoExpandKeyRef.current = key;
onOpenFolder(spaceId, normalizedPath);
}
setExpandedTreeNodes((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return {
expandedTreeNodes,
setExpandedTreeNodes,
cancelTreeDragExpand,
scheduleTreeDragExpand,
toggleTreeFolder
};
}

View File

@@ -1,32 +0,0 @@
import type { ManagedFile } from "../../api/files";
export type SortColumn = "name" | "size" | "modified";
export type SortDirection = "asc" | "desc";
export type FolderNode = {
name: string;
path: string;
children: FolderNode[];
fileCount: number;
persisted: boolean;
};
export type FolderEntry = {
kind: "folder";
id: string;
name: string;
path: string;
fileCount: number;
folderCount: number;
totalSize: number;
updatedAt: string;
persisted: boolean;
};
export type FileEntry = {
kind: "file";
id: string;
file: ManagedFile;
};
export type ExplorerEntry = FolderEntry | FileEntry;

View File

@@ -1,214 +0,0 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files";
import type { ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
const byPath = new Map<string, FolderNode>();
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
const normalized = normalizeFolder(path);
if (!normalized) return null;
const existing = byPath.get(normalized);
if (existing) {
existing.persisted = existing.persisted || persisted;
return existing;
}
const parts = normalized.split("/");
const node: FolderNode = {
name: parts[parts.length - 1],
path: normalized,
children: [],
fileCount: 0,
persisted
};
byPath.set(normalized, node);
const parentPath = parts.slice(0, -1).join("/");
const parent = ensureNode(parentPath, false);
if (parent) parent.children.push(node);
return node;
};
for (const folder of folders) ensureNode(folder.path, true);
for (const file of files) {
const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
for (let index = 0; index < parts.length - 1; index += 1) {
const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
if (node) node.fileCount += 1;
}
}
const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
sortFolderNodes(roots);
return roots;
}
export function sortFolderNodes(nodes: FolderNode[]) {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) sortFolderNodes(node.children);
}
export function treeNodeKey(spaceId: string, folderPath: string): string {
return `${spaceId}:${normalizeFolder(folderPath)}`;
}
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
const result: string[] = [];
for (let index = 1; index <= limit; index += 1) {
result.push(parts.slice(0, index).join("/"));
}
return result;
}
export function isPathUnderOrSame(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return true;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
const folder = normalizeFolder(currentFolder);
const prefix = folder ? `${folder}/` : "";
const folderMap = new Map<string, FolderEntry>();
const directFiles: FileEntry[] = [];
for (const persisted of folders) {
const path = normalizeFolder(persisted.path);
if (!path.startsWith(prefix) || path === folder) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || relative.includes("/")) continue;
folderMap.set(path, {
kind: "folder",
id: `folder:${path}`,
name: relative,
path,
fileCount: 0,
folderCount: 0,
totalSize: 0,
updatedAt: persisted.updated_at,
persisted: true
});
}
for (const file of files) {
const path = normalizeFilePath(file.display_path || file.filename);
if (prefix && !path.startsWith(prefix)) continue;
const relativePath = prefix ? path.slice(prefix.length) : path;
if (!relativePath) continue;
const slashIndex = relativePath.indexOf("/");
if (slashIndex === -1) {
directFiles.push({ kind: "file", id: file.id, file });
continue;
}
const folderName = relativePath.slice(0, slashIndex);
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
const existing = folderMap.get(folderPath);
if (existing) {
existing.totalSize += file.size_bytes;
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
} else {
folderMap.set(folderPath, {
kind: "folder",
id: `folder:${folderPath}`,
name: folderName,
path: folderPath,
fileCount: 0,
folderCount: 0,
totalSize: file.size_bytes,
updatedAt: file.updated_at,
persisted: false
});
}
}
for (const entry of folderMap.values()) {
const counts = directFolderCounts(files, folders, entry.path);
entry.fileCount = counts.files;
entry.folderCount = counts.folders;
}
return [...Array.from(folderMap.values()), ...directFiles];
}
export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] {
const factor = direction === "asc" ? 1 : -1;
return [...entries].sort((a, b) => {
if (column === "name") {
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
if (column === "size") {
const delta = entrySize(a) - entrySize(b);
if (delta !== 0) return factor * delta;
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
const timeA = entryModifiedTime(a);
const timeB = entryModifiedTime(b);
if (timeA !== timeB) return factor * (timeA - timeB);
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
});
}
export function entryName(entry: ExplorerEntry): string {
return entry.kind === "folder" ? entry.name : entry.file.filename;
}
export function entrySize(entry: ExplorerEntry): number {
return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes;
}
export function entryModifiedTime(entry: ExplorerEntry): number {
const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at;
const parsed = new Date(raw).getTime();
return Number.isNaN(parsed) ? 0 : parsed;
}
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
const normalized = normalizeFolder(folderPath);
const prefix = normalized ? `${normalized}/` : "";
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
const childFolders = new Set<string>();
for (const folder of folders) {
const path = normalizeFolder(folder.path);
if (!path.startsWith(prefix) || path === normalized) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative) continue;
childFolders.add(relative.split("/")[0]);
}
for (const file of files) {
const path = normalizeFilePath(file.display_path);
if (!path.startsWith(prefix)) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || !relative.includes("/")) continue;
childFolders.add(relative.split("/")[0]);
}
return { files: directFiles, folders: childFolders.size };
}
export function parentFolderPath(path: string): string {
const parts = normalizeFilePath(path).split("/").filter(Boolean);
return normalizeFolder(parts.slice(0, -1).join("/"));
}
export function normalizeFolder(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim();
}
export function normalizeFilePath(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim();
}
export function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = value / 1024;
for (const unit of units) {
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
size /= 1024;
}
return `${size.toFixed(1)} PB`;
}
export function formatDate(value: string): string {
return formatPlatformDateTime(value);
}

View File

@@ -1290,235 +1290,6 @@
font-weight: 700;
color: var(--text-primary);
}
/* Managed file picker shared by attachment sources and rule editors. */
.managed-file-chooser-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1080px, calc(100vw - 40px));
height: min(820px, calc(100vh - 40px));
max-height: min(820px, calc(100vh - 40px));
overflow: hidden;
}
.managed-file-chooser-body {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: hidden;
padding: 0;
}
.managed-file-chooser-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
flex: 1 1 auto;
min-height: 0;
max-height: none;
border: 0;
border-radius: 0;
overflow: hidden;
background: var(--panel);
}
.managed-file-chooser-spaces {
min-width: 0;
padding: 16px;
border-right: 1px solid var(--line);
background: var(--panel-soft);
overflow: auto;
}
.managed-file-chooser-spaces h3 {
margin: 0 0 12px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
}
.managed-file-space-list {
display: grid;
gap: 7px;
}
.managed-file-space-button,
.managed-file-entry {
width: 100%;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.managed-file-space-button {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 9px;
align-items: start;
padding: 9px;
}
.managed-file-space-button span,
.managed-file-entry > span:not(.managed-file-shared-badge) {
display: grid;
gap: 2px;
min-width: 0;
}
.managed-file-space-button small,
.managed-file-entry small {
color: var(--muted);
font-size: 11px;
}
.managed-file-space-button:hover:not(:disabled),
.managed-file-space-button.is-selected {
border-color: var(--line-dark);
background: var(--panel);
}
.managed-file-space-button.is-selected {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.managed-file-space-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.managed-file-chooser-browser {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
.managed-file-chooser-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.managed-file-breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
min-width: 0;
}
.managed-file-breadcrumb > button,
.managed-file-breadcrumb span button {
display: inline-flex;
gap: 5px;
align-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ink);
padding: 5px 6px;
cursor: pointer;
}
.managed-file-breadcrumb button:hover:not(:disabled) {
background: var(--panel-soft);
}
.managed-file-choice-tabs {
display: inline-flex;
border: 1px solid var(--line-dark);
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.managed-file-choice-tabs button {
border: 0;
border-right: 1px solid var(--line-dark);
background: var(--panel);
color: var(--muted);
padding: 7px 10px;
cursor: pointer;
}
.managed-file-choice-tabs button:last-child { border-right: 0; }
.managed-file-choice-tabs button.is-active {
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 700;
}
.managed-pattern-editor {
display: grid;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.managed-pattern-editor label {
display: grid;
gap: 6px;
font-size: 12px;
font-weight: 700;
}
.managed-file-entry-list {
display: grid;
align-content: start;
gap: 4px;
padding: 10px;
min-height: 0;
overflow: auto;
}
.managed-file-entry {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 52px;
padding: 8px 10px;
}
.managed-file-entry:hover:not(:disabled) {
border-color: var(--line);
background: var(--panel-soft);
}
.managed-file-entry.is-selected {
border-color: var(--accent);
background: var(--accent-soft);
}
.managed-file-entry:disabled {
color: var(--ink);
opacity: 1;
cursor: default;
}
.managed-file-entry.is-folder:not(:disabled) { cursor: pointer; }
.managed-file-shared-badge {
display: inline-flex;
gap: 4px;
align-items: center;
border: 1px solid var(--success-line, var(--line));
border-radius: 999px;
padding: 3px 7px;
color: var(--success, var(--muted));
font-size: 11px;
white-space: nowrap;
}
.managed-file-empty {
padding: 28px 16px;
text-align: center;
}
.managed-file-chooser-note {
margin: 0;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--panel-soft);
}
@media (max-width: 760px) {
.managed-file-chooser-dialog {
width: calc(100vw - 20px);
height: calc(100vh - 20px);
}
.managed-file-chooser-layout {
grid-template-columns: 1fr;
max-height: calc(100vh - 220px);
}
.managed-file-chooser-spaces {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 160px;
}
.managed-file-chooser-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
.attachment-source-path-cell {
display: grid;
gap: 5px;
@@ -1549,77 +1320,6 @@
border-bottom-left-radius: 0;
box-shadow: none;
}
.managed-file-chooser-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.managed-file-chooser-body > .alert {
margin: 14px 14px 0;
}
.managed-file-chooser-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
}
.managed-file-chooser-spaces.file-tree-panel {
display: flex;
padding: 0;
overflow: hidden;
}
.managed-file-chooser-spaces .file-tree-list {
padding-bottom: 12px;
}
.managed-pattern-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.managed-pattern-results {
min-width: 0;
min-height: 0;
overflow: auto;
}
.managed-pattern-results .file-list-table {
min-width: 680px;
}
.managed-pattern-result-row {
width: 100%;
border-top: 0;
border-right: 0;
border-left: 0;
background: transparent;
color: var(--ink);
text-align: left;
font: inherit;
cursor: pointer;
}
.managed-pattern-result-row .file-list-name > span {
display: grid;
min-width: 0;
}
.managed-pattern-result-row .file-list-name strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.managed-pattern-result-row .file-list-name small {
color: var(--muted);
font-size: 11px;
}
.managed-pattern-rendered {
display: block;
color: var(--muted);
}
/* Review & Send workflow page. */
.review-send-page {
--review-flow-purple: #9b86c7;
@@ -2249,93 +1949,9 @@
max-width: 100%;
min-width: 0;
}
/* Managed chooser list uses a stable, sortable four-column layout. */
.managed-file-entry-table {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.managed-file-entry-head,
.managed-file-entry {
grid-template-columns: 22px minmax(0, 1fr) 110px 160px;
}
.managed-file-entry-head {
display: grid;
gap: 9px;
align-items: center;
min-height: 38px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.managed-file-entry-head button {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
width: max-content;
max-width: 100%;
border: 0;
background: transparent;
color: inherit;
padding: 5px 0;
font: inherit;
cursor: pointer;
}
.managed-file-entry-head button.is-sorted {
color: var(--text-strong);
}
.managed-file-entry-name {
display: grid;
gap: 2px;
min-width: 0;
}
.managed-file-entry-name strong,
.managed-file-entry-name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.managed-file-entry-name small {
display: inline-flex;
align-items: center;
gap: 4px;
}
.managed-file-entry-size,
.managed-file-entry-modified {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.managed-file-entry.is-context-only {
opacity: .56;
}
.managed-file-entry.is-parent:disabled {
opacity: .38;
}
@media (max-width: 850px) {
.managed-file-entry-head,
.managed-file-entry {
grid-template-columns: 22px minmax(0, 1fr) 100px;
}
.managed-file-entry-head > :last-child,
.managed-file-entry-modified {
display: none;
}
}
.review-send-page .btn { text-decoration: none; }
.review-send-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 16px 0; }
.managed-file-chooser-browser.folder-mode {
grid-template-rows: auto minmax(0, 1fr) auto;
}
/* Delivery report additions ------------------------------------------------ */
.dialog-panel-wide {

View File

@@ -1,9 +1,15 @@
import {
buildImportTableFromRows,
applyRecipientMappingProfile,
buildImportTable,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
materializeRecipientImport,
parseDelimitedText,
xlsxSheetsFromArrayBuffer,
type RecipientColumnMapping
} from "../src/features/campaigns/utils/bulkImport";
@@ -19,6 +25,15 @@ function mappingFor(mappings: RecipientColumnMapping[], columnIndex: number): Re
return mappings.find((mapping) => mapping.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" };
}
function base64ToArrayBuffer(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
const parsed = parseDelimitedText('email;name;field.note\n"alice@example.org";"Alice; A";"hello"', ";");
assert(parsed[1][1] === "Alice; A", "quoted delimiters are preserved");
@@ -42,6 +57,44 @@ assert(table.headerRows.length === 1 && table.dataRows.length === 3, "header and
assert(mappingFor(mappings, 0).kind === "to", "email columns map to To");
assert(mappingFor(mappings, 2).kind === "field" && mappingFor(mappings, 2).fieldName === "customer_id", "existing fields are detected");
assert(mappingFor(mappings, 3).kind === "new_field" && mappingFor(mappings, 3).newFieldName === "contract_no", "explicit new field columns are detected");
const profile = createRecipientMappingProfile({
id: "profile-1",
name: "ERP export",
table,
mappings,
parseOptions: { headerRows: 1, quoted: true },
valueSeparators: ",;|",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z"
});
const exactProfileMatches = matchRecipientMappingProfiles(table, [profile]);
assert(exactProfileMatches[0]?.mode === "ordered" && exactProfileMatches[0].score === 1, "ordered header fingerprints match exactly");
const reorderedTable = buildImportTable(
[
"name;email;active;attachment_pattern;field.contract_no;customer_id",
"Alice;alice@example.org;yes;${customer_id}.pdf;CN-1;C-1"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const reorderedMatches = matchRecipientMappingProfiles(reorderedTable, [profile]);
assert(reorderedMatches[0]?.mode === "unordered", "unordered header fingerprints match reordered exports");
const reorderedMappings = applyRecipientMappingProfile(reorderedTable, profile, existingFields);
assert(mappingFor(reorderedMappings, 0).kind === "name", "reordered profiles apply mappings by header");
assert(mappingFor(reorderedMappings, 1).kind === "to", "email mapping follows the reordered header");
assert(mappingFor(reorderedMappings, 4).kind === "new_field" && mappingFor(reorderedMappings, 4).newFieldName === "contract_no", "new field mappings follow reordered headers");
const similarTable = buildImportTable(
[
"email;name;customer_id;attachment_pattern",
"alice@example.org;Alice;C-1;${customer_id}.pdf"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const similarMatches = matchRecipientMappingProfiles(similarTable, [profile]);
assert(similarMatches[0]?.mode === "similar", "similar profiles are suggested when headers changed");
assert(preview.validCount === 1, "one valid row is detected");
assert(preview.invalidCount === 2, "invalid rows are detected");
assert(preview.patternCount === 3, "all pattern cells are counted before invalid rows are skipped");
@@ -76,3 +129,33 @@ assert(importedEntry.email === "alice@example.org", "entry email is materialized
assert(attachments.length === 2, "valid row patterns become attachment rules");
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");
void runXlsxImportAssertions();
async function runXlsxImportAssertions(): Promise<void> {
const workbookBuffer = base64ToArrayBuffer("UEsDBBQAAAAIACQe2lzFLx19AQEAAC4CAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK2RzU7DMBCE7zyF5SuKnXJACCXpgZ8jcCgPsDibxIr/5HVL+vY4acsBlZ56Wtk7M9/IrtaTNWyHkbR3NV+JkjN0yrfa9TX/3LwWD5xRAteC8Q5rvkfi6+am2uwDEstmRzUfUgqPUpIa0AIJH9DlTeejhZSPsZcB1Ag9yruyvJfKu4QuFWnO4E31jB1sTWIvU74+FIloiLOng3Bm1RxCMFpBynu5c+0fSnEkiOxcNDToQLdZwOVZwrz5H3D0veeXibpF9gExvYHNKjkZ+e3j+OX9KC6HnGnpu04rbL3a2mwRFCJCSwNiskYsU1jQ7tT7An8Rk1zG6spFfvNPPeTy3c0PUEsDBBQAAAAIACQe2lwGWceCsgAAACgBAAALAAAAX3JlbHMvLnJlbHOFz00KwjAQBeC9pwizt6kuRKRpNyJ0K/UAMZ3+0CQTkqjt7c3SiuBymJnv8YpqNpo90YeRrIBdlgNDq6gdbS/g1ly2R2AhSttKTRYFLBigKjfFFbWM6ScMowssITYIGGJ0J86DGtDIkJFDmzYdeSNjGn3PnVST7JHv8/zA/acB5cpkdSvA1+0OWLO4FPzfpq4bFZ5JPQza+CPi6yLJ0vcYBcyav8hPd6IpSyjwsuCrguUbUEsDBBQAAAAIACQe2lyPkDDbwAAAACABAAAPAAAAeGwvd29ya2Jvb2sueG1sjY+7bsMwDEX3foXAvZHToSgM21mKAlmL9gNUiY6FWKRAqq+/L5vHnokv3Mt7ht1PWd0XimamEbabDhxS5JTpMML728v9EzhtgVJYmXCEX1TYTXfDN8vxg/noTE86wtJa7b3XuGAJuuGKZJeZpYRmoxy8VsGQdEFsZfUPXffoS8gEZ4debvHgec4Rnzl+FqR2NhFcQ7P0uuSqMA2nD3qpjkKx1K8Yc80mUaP53++TwYKTPlsj+7QFPw3+KvVXuukPUEsDBBQAAAAIACQe2lyabzx8tQAAACkBAAAaAAAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHOFz80KwjAMB/C7T1Fyd9k8iMi6XUTYVeYDlC77YFtbmvqxt7d4EAeCp5CE/P4kL5/zJO7kebBGQpakIMho2wymk3Ctz9sDCA7KNGqyhiQsxFAWm/xCkwrxhvvBsYiIYQl9CO6IyLqnWXFiHZm4aa2fVYit79ApPaqOcJeme/TfBhQrU1SNBF81GYh6cTH4v23bdtB0svo2kwk/IvBh/cg9UYio8h0FCZ8R47tkSVQBixxXHxYvUEsDBBQAAAAIACQe2lz+BM8T8AAAAAgCAAAYAAAAeGwvd29ya3NoZWV0cy9zaGVldDEueG1sdZFRTgMhEIbfPQXh3WW7D8YYlqo1vYB6gAk77RJh2MDE1tvLVrPRZnmDn/mGj0Fvz8GLT0zZRerlpmmlQLJxcHTs5fvb/vZeisxAA/hI2MsvzHJrbvQppo88IrIoDSj3cmSeHpTKdsQAuYkTUjk5xBSAyzYdVZ4SwnCBgldd296pAI6k0ZfsBRiMTvEkUhEpqZ0XTxspuJeOvCN85VRyl41mU25xXis2Ws2Bsr/Acw0gCLhSv6vVHxz6oRlwgsQBif+zqogutt1i21WaWUgeHvEMYfI4T2PNvAbvZnhNvQbsHQHZq9f+GKs/s1bLJ5pvUEsBAhQAFAAAAAgAJB7aXMUvHX0BAQAALgIAABMAAAAAAAAAAAAAAAAAAAAAAFtDb250ZW50X1R5cGVzXS54bWxQSwECFAAUAAAACAAkHtpcBlnHgrIAAAAoAQAACwAAAAAAAAAAAAAAAAAyAQAAX3JlbHMvLnJlbHNQSwECFAAUAAAACAAkHtpcj5Aw28AAAAAgAQAADwAAAAAAAAAAAAAAAAANAgAAeGwvd29ya2Jvb2sueG1sUEsBAhQAFAAAAAgAJB7aXJpvPHy1AAAAKQEAABoAAAAAAAAAAAAAAAAA+gIAAHhsL19yZWxzL3dvcmtib29rLnhtbC5yZWxzUEsBAhQAFAAAAAgAJB7aXP4EzxPwAAAACAIAABgAAAAAAAAAAAAAAAAA5wMAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbFBLBQYAAAAABQAFAEUBAAANBQAAAAA=");
const workbookSheets = await xlsxSheetsFromArrayBuffer(workbookBuffer);
assert(workbookSheets[0]?.name === "Recipients", "xlsx sheets are listed");
const xlsxTable = buildImportTableFromRows(workbookSheets[0].rows, { headerRows: 1 });
const xlsxMappings = defaultColumnMappings(xlsxTable.headers, []);
const xlsxPreview = buildRecipientImportPreview(xlsxTable, xlsxMappings, { existingFields: [], valueSeparators: ",;|" });
assert(xlsxPreview.validCount === 1, "xlsx rows become import preview rows");
assert(mappingFor(xlsxMappings, 2).kind === "new_field" && mappingFor(xlsxMappings, 2).newFieldName === "department", "xlsx headers use the same mapping guesser");
const provenance = createRecipientImportProvenance({
preview: xlsxPreview,
mappings: xlsxMappings,
mode: "replace",
sourceType: "xlsx",
filename: "recipients.xlsx",
sheetName: "Recipients",
headerRows: 1,
valueSeparators: ",;|"
});
const importedWithProvenance = materializeRecipientImport(draft, xlsxPreview, { mode: "replace", provenance });
const provenanceEntries = asRecord(importedWithProvenance.entries);
const imports = provenanceEntries.imports as Record<string, unknown>[];
assert(imports.length === 1, "recipient import provenance is stored with entries");
assert(imports[0].source_type === "xlsx" && imports[0].sheet_name === "Recipients", "xlsx provenance records source details");
assert((imports[0].mapping as Record<string, unknown>[]).length === xlsxTable.headers.length, "provenance stores column mapping");
}