Release v0.1.4
This commit is contained in:
@@ -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.
|
||||
|
||||
77
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal file
77
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ?? "";
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user