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/`.
|
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
|
## 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.
|
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",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,8 +18,11 @@
|
|||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"read-excel-file": "^9.2.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.3",
|
"govoplan-core>=0.1.4",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -489,6 +489,35 @@ class EntryConfig(StrictModel):
|
|||||||
last_sent: str | None = None
|
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):
|
class SourceConfig(StrictModel):
|
||||||
type: SourceType
|
type: SourceType
|
||||||
path: str
|
path: str
|
||||||
@@ -502,6 +531,7 @@ class EntriesConfig(StrictModel):
|
|||||||
source: SourceConfig | None = None
|
source: SourceConfig | None = None
|
||||||
mapping: dict[str, str] | None = None
|
mapping: dict[str, str] | None = None
|
||||||
defaults: EntryConfig | None = None
|
defaults: EntryConfig | None = None
|
||||||
|
imports: list[ImportProvenance] = Field(default_factory=list)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def inline_or_external(self) -> "EntriesConfig":
|
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)
|
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):
|
class CampaignVersion(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_versions"
|
__tablename__ = "campaign_versions"
|
||||||
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
|
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.2",
|
version="0.1.4",
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=("files", "mail"),
|
optional_dependencies=("files", "mail"),
|
||||||
permissions=PERMISSIONS,
|
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,
|
CampaignShareTargetsResponse,
|
||||||
CampaignShareUpsertRequest,
|
CampaignShareUpsertRequest,
|
||||||
CampaignOwnerUpdateRequest,
|
CampaignOwnerUpdateRequest,
|
||||||
|
RecipientImportMappingProfileListResponse,
|
||||||
|
RecipientImportMappingProfilePayload,
|
||||||
|
RecipientImportMappingProfileResponse,
|
||||||
CampaignJobsResponse,
|
CampaignJobsResponse,
|
||||||
CampaignJobDetailResponse,
|
CampaignJobDetailResponse,
|
||||||
CampaignRetryJobsRequest,
|
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.auth.dependencies import ApiPrincipal, has_scope, require_scope
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
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_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.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
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}")
|
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:
|
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
|
||||||
if proposed is None:
|
if proposed is None:
|
||||||
return False
|
return False
|
||||||
@@ -346,6 +370,109 @@ def list_campaigns(
|
|||||||
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in 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)
|
@router.get("/{campaign_id}", response_model=CampaignResponse)
|
||||||
def get_campaign(
|
def get_campaign(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
@@ -2008,4 +2135,3 @@ def preview_campaign_attachments(
|
|||||||
rules=rules,
|
rules=rules,
|
||||||
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
|
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -482,6 +482,13 @@
|
|||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"$ref": "#/$defs/entry"
|
"$ref": "#/$defs/entry"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/import_provenance"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -505,6 +512,13 @@
|
|||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"$ref": "#/$defs/entry"
|
"$ref": "#/$defs/entry"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/import_provenance"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1027,6 +1041,151 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"attachment_base_path": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
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
|
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||||
|
|
||||||
@@ -186,6 +186,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
|
|||||||
owner_group_id: str | None = None
|
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):
|
class CampaignJobsResponse(BaseModel):
|
||||||
jobs: list[dict[str, Any]]
|
jobs: list[dict[str, Any]]
|
||||||
page: int = 1
|
page: int = 1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.2",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
|
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"read-excel-file": "^9.2.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.2",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -92,6 +92,38 @@ export type CampaignWorkspaceQuery = {
|
|||||||
includeVersions?: boolean;
|
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 = {
|
export type CampaignVersionUpdatePayload = {
|
||||||
campaign_json?: Record<string, unknown> | null;
|
campaign_json?: Record<string, unknown> | null;
|
||||||
current_flow?: string | null;
|
current_flow?: string | null;
|
||||||
@@ -298,6 +330,36 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
|
|||||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
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> {
|
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { Pencil } from "lucide-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 type { ApiSettings } from "../../types";
|
||||||
import { listFileSpaces, type FileSpace } from "../../api/files";
|
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageTitle } 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 { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import ManagedFileChooser from "./components/ManagedFileChooser";
|
|
||||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
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 { 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";
|
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 }) {
|
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
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 { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
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 [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
@@ -71,16 +73,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filesModuleInstalled) {
|
if (!listManagedFileSpaces) {
|
||||||
setFileSpaces([]);
|
setFileSpaces([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void listFileSpaces(settings)
|
void listManagedFileSpaces(settings)
|
||||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
@@ -251,7 +253,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
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}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText="No attachment sources configured."
|
emptyText="No attachment sources configured."
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
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}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
zipConfig={zipConfig}
|
zipConfig={zipConfig}
|
||||||
filesModuleInstalled={filesModuleInstalled}
|
filesModuleInstalled={managedFilesAvailable}
|
||||||
previewContext={attachmentPreviewContext}
|
previewContext={attachmentPreviewContext}
|
||||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
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>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</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>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>
|
</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>
|
</Card>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
@@ -357,11 +363,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pathChooser && filesModuleInstalled && (
|
{pathChooser && ManagedFileChooser && (
|
||||||
<ManagedFileChooser
|
<ManagedFileChooser
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
storageScope={campaignId}
|
||||||
mode="folder"
|
mode="folder"
|
||||||
source={basePaths[pathChooser.index]?.source}
|
source={basePaths[pathChooser.index]?.source}
|
||||||
basePath={basePaths[pathChooser.index]?.path}
|
basePath={basePaths[pathChooser.index]?.path}
|
||||||
@@ -562,8 +568,8 @@ function uniqueStrings(values: string[]): string[] {
|
|||||||
type AttachmentSourceColumnContext = {
|
type AttachmentSourceColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
fileSpaces: FileSpace[];
|
fileSpaces: FilesFileSpace[];
|
||||||
filesModuleInstalled: boolean;
|
managedFilesAvailable: boolean;
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||||
addBasePath: (afterIndex?: number) => void;
|
addBasePath: (afterIndex?: number) => void;
|
||||||
@@ -572,7 +578,7 @@ type AttachmentSourceColumnContext = {
|
|||||||
setPathChooser: (state: PathChooserState | null) => void;
|
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 [
|
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 },
|
{ 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">
|
<div className="field-with-action split-field-action">
|
||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
readOnly={filesModuleInstalled}
|
readOnly={managedFilesAvailable}
|
||||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||||
placeholder="attachments"
|
placeholder="attachments"
|
||||||
onChange={(event) => {
|
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) => {
|
onKeyDown={(event) => {
|
||||||
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) {
|
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPathChooser({ index });
|
setPathChooser({ index });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
|
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
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 parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||||
const matchingSpace = parsedSource
|
const matchingSpace = parsedSource
|
||||||
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
|
import {
|
||||||
|
createRecipientImportMappingProfile,
|
||||||
|
listRecipientImportMappingProfiles,
|
||||||
|
updateRecipientImportMappingProfile,
|
||||||
|
type RecipientImportMappingProfilePayload
|
||||||
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { FileDropZone } from "@govoplan/core-webui";
|
import { FileDropZone } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
@@ -20,20 +27,33 @@ import { getBool } from "./utils/draftEditor";
|
|||||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||||
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
|
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
|
||||||
import {
|
import {
|
||||||
|
applyRecipientMappingProfile,
|
||||||
buildImportTable,
|
buildImportTable,
|
||||||
|
buildImportTableFromRows,
|
||||||
buildRecipientImportPreview,
|
buildRecipientImportPreview,
|
||||||
|
createRecipientImportProvenance,
|
||||||
|
createRecipientMappingProfile as buildRecipientMappingProfile,
|
||||||
defaultColumnMappings,
|
defaultColumnMappings,
|
||||||
importedRowsNeedAttachmentSource,
|
importedRowsNeedAttachmentSource,
|
||||||
|
matchRecipientMappingProfiles,
|
||||||
materializeRecipientImport,
|
materializeRecipientImport,
|
||||||
|
recipientImportHeaderFingerprints,
|
||||||
|
xlsxSheetsFromArrayBuffer,
|
||||||
type CsvDelimiter,
|
type CsvDelimiter,
|
||||||
type ImportedAddress,
|
type ImportedAddress,
|
||||||
type RecipientColumnKind,
|
type RecipientColumnKind,
|
||||||
type RecipientColumnMapping,
|
type RecipientColumnMapping,
|
||||||
type RecipientImportMode,
|
type RecipientImportMode,
|
||||||
type RecipientImportPreview
|
type RecipientImportPreview,
|
||||||
|
type RecipientImportProvenance,
|
||||||
|
type RecipientImportSourceType,
|
||||||
|
type RecipientImportSpreadsheetSheet,
|
||||||
|
type RecipientMappingProfile,
|
||||||
|
type RecipientMappingProfileMatch
|
||||||
} from "./utils/bulkImport";
|
} from "./utils/bulkImport";
|
||||||
import {
|
import {
|
||||||
bulkLinkFilesToCampaign,
|
bulkLinkFilesToCampaign,
|
||||||
|
type ImportFileLinkCapability,
|
||||||
renderedImportPatterns,
|
renderedImportPatterns,
|
||||||
resolveImportedAttachmentLinks,
|
resolveImportedAttachmentLinks,
|
||||||
type ImportFileLinkResolution
|
type ImportFileLinkResolution
|
||||||
@@ -178,7 +198,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||||
if (locked || !draft) return;
|
if (locked || !draft) return;
|
||||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||||
const currentAttachments = asRecord(draft.attachments);
|
const currentAttachments = asRecord(draft.attachments);
|
||||||
@@ -194,7 +214,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
|
||||||
const nextDraft = needsAttachmentSource
|
const nextDraft = needsAttachmentSource
|
||||||
? {
|
? {
|
||||||
...importedDraft,
|
...importedDraft,
|
||||||
@@ -359,12 +379,12 @@ type RecipientImportDialogProps = {
|
|||||||
existingFields: ReturnType<typeof getDraftFields>;
|
existingFields: ReturnType<typeof getDraftFields>;
|
||||||
defaultAttachmentBasePath: AttachmentBasePath | null;
|
defaultAttachmentBasePath: AttachmentBasePath | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
|
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
|
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: "upload", label: "Upload" },
|
||||||
{ id: "parse", label: "Parse" },
|
{ id: "parse", label: "Parse" },
|
||||||
{ id: "map", label: "Map" },
|
{ id: "map", label: "Map" },
|
||||||
@@ -387,28 +407,74 @@ const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: str
|
|||||||
{ value: "attachment_pattern", label: "Attachment pattern" }
|
{ 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) {
|
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 [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
|
||||||
const [csvText, setCsvText] = useState("");
|
const [csvText, setCsvText] = useState("");
|
||||||
|
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
|
||||||
const [filename, setFilename] = useState("");
|
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 [mode, setMode] = useState<RecipientImportMode>("append");
|
||||||
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
|
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
|
||||||
const [headerRows, setHeaderRows] = useState(1);
|
const [headerRows, setHeaderRows] = useState(1);
|
||||||
const [quoted, setQuoted] = useState(true);
|
const [quoted, setQuoted] = useState(true);
|
||||||
const [valueSeparators, setValueSeparators] = useState(",;|");
|
const [valueSeparators, setValueSeparators] = useState(",;|");
|
||||||
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
|
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 [fileError, setFileError] = useState("");
|
||||||
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
|
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
|
||||||
const [fileLinkResolving, setFileLinkResolving] = useState(false);
|
const [fileLinkResolving, setFileLinkResolving] = useState(false);
|
||||||
const [fileLinking, setFileLinking] = useState(false);
|
const [fileLinking, setFileLinking] = useState(false);
|
||||||
const [fileLinkError, setFileLinkError] = useState("");
|
const [fileLinkError, setFileLinkError] = useState("");
|
||||||
const [fileLinkNotice, setFileLinkNotice] = 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(
|
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 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 parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
|
||||||
const preview = useMemo(
|
const preview = useMemo(
|
||||||
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
|
() => 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 activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
|
||||||
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
|
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
|
||||||
const previousStep = 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 mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
|
||||||
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
|
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
|
||||||
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
setMappings([]);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setMappings(defaultColumnMappings(table.headers, existingFields));
|
setMappings(defaultColumnMappings(table.headers, existingFields));
|
||||||
}, [existingFields, tableHeaderKey]);
|
setMappingProfileNotice("");
|
||||||
|
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasContent) setActiveStep("upload");
|
if (!hasContent) setActiveStep("upload");
|
||||||
}, [hasContent]);
|
}, [hasContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// 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) {
|
async function readFile(file: File | undefined) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setFilename(file.name);
|
setFilename(file.name);
|
||||||
setFileError("");
|
setFileError("");
|
||||||
|
setWorkbookSheets([]);
|
||||||
|
setSelectedSheetName("");
|
||||||
try {
|
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");
|
setActiveStep("parse");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFileError(err instanceof Error ? err.message : String(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 === "upload") return true;
|
||||||
if (stepId === "parse") return hasContent;
|
if (stepId === "parse") return hasContent;
|
||||||
if (stepId === "map") return parseReady;
|
if (stepId === "map") return parseReady;
|
||||||
|
if (stepId === "files" && !fileLinkCapability) return false;
|
||||||
return parseReady && mappings.length > 0;
|
return parseReady && mappings.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +602,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
|
|
||||||
async function refreshFileLinks() {
|
async function refreshFileLinks() {
|
||||||
setFileLinkNotice("");
|
setFileLinkNotice("");
|
||||||
|
if (!fileLinkCapability) {
|
||||||
|
setFileLinkResolution(null);
|
||||||
|
setFileLinkError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!preview || preview.patternCount === 0) {
|
if (!preview || preview.patternCount === 0) {
|
||||||
setFileLinkResolution(null);
|
setFileLinkResolution(null);
|
||||||
setFileLinkError("");
|
setFileLinkError("");
|
||||||
@@ -488,7 +620,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
setFileLinkResolving(true);
|
setFileLinkResolving(true);
|
||||||
setFileLinkError("");
|
setFileLinkError("");
|
||||||
try {
|
try {
|
||||||
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
|
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFileLinkResolution(null);
|
setFileLinkResolution(null);
|
||||||
setFileLinkError(err instanceof Error ? err.message : String(err));
|
setFileLinkError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -498,12 +630,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function linkImportedFiles() {
|
async function linkImportedFiles() {
|
||||||
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
|
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
|
||||||
setFileLinking(true);
|
setFileLinking(true);
|
||||||
setFileLinkError("");
|
setFileLinkError("");
|
||||||
setFileLinkNotice("");
|
setFileLinkNotice("");
|
||||||
try {
|
try {
|
||||||
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
|
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
|
||||||
await refreshFileLinks();
|
await refreshFileLinks();
|
||||||
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
|
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
|
||||||
} catch (err) {
|
} 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) {
|
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
|
||||||
const columnCount = table?.headers.length ?? 0;
|
const columnCount = table?.headers.length ?? 0;
|
||||||
|
setMappingManuallyChanged(true);
|
||||||
setMappings((current) => {
|
setMappings((current) => {
|
||||||
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
|
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
|
||||||
byColumn.set(nextMapping.columnIndex, nextMapping);
|
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">
|
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||||
<FormField label="Recipient file">
|
<FormField label="Recipient file">
|
||||||
<FileDropZone
|
<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}
|
multiple={false}
|
||||||
label="Drop recipient file here"
|
label="Drop recipient file here"
|
||||||
actionLabel="or click to select CSV"
|
actionLabel="or click to select file"
|
||||||
note={filename || "CSV, TSV or text"}
|
note={filename || "CSV, TSV, text or XLSX"}
|
||||||
onFiles={(files) => readFile(files[0])}
|
onFiles={(files) => readFile(files[0])}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -553,21 +736,38 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
|
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
|
||||||
|
{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"}>
|
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
|
||||||
<textarea
|
<textarea
|
||||||
className="json-editor recipient-import-textarea"
|
className="json-editor recipient-import-textarea"
|
||||||
value={csvText}
|
value={csvText}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
setFileBuffer(null);
|
||||||
|
setSourceType(filename ? "csv" : "text");
|
||||||
setCsvText(event.target.value);
|
setCsvText(event.target.value);
|
||||||
if (!event.target.value.trim()) setFilename("");
|
if (!event.target.value.trim()) setFilename("");
|
||||||
}}
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : activeStep === "parse" ? (
|
) : activeStep === "parse" ? (
|
||||||
<>
|
<>
|
||||||
<div className="campaign-header-grid">
|
<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">
|
<FormField label="Header rows">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -577,6 +777,15 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
|
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{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">
|
<FormField label="Separator">
|
||||||
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
|
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
|
||||||
<option value="auto">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
@@ -588,11 +797,13 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
<div className="campaign-header-toggle recipient-import-toggles">
|
<div className="campaign-header-toggle recipient-import-toggles">
|
||||||
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
|
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{table && (
|
{table && (
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<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>Header rows</dt><dd>{table.headerRows.length}</dd></div>
|
||||||
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
|
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
|
||||||
<div><dt>Columns</dt><dd>{table.headers.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" ? (
|
) : 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">
|
<div className="campaign-header-grid recipient-import-map-controls">
|
||||||
<FormField label="Multi-value separators">
|
<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>
|
||||||
<FormField label="Mode">
|
<FormField label="Mode">
|
||||||
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
<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>
|
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
|
||||||
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</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={!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";
|
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 {
|
function sampleColumnValue(rows: string[][], columnIndex: number): string {
|
||||||
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
|
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 type { ApiSettings } from "../../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Dialog } 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 DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
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 { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||||
import { asRecord } from "../utils/campaignView";
|
import { asRecord } from "../utils/campaignView";
|
||||||
|
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||||
|
|
||||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||||
|
|
||||||
@@ -160,6 +161,9 @@ export function AttachmentRulesDataGrid({
|
|||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
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>) {
|
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||||
@@ -183,7 +187,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
function openFileChooser(ruleIndex: number) {
|
||||||
if (!filesModuleInstalled) return;
|
if (!managedFilesAvailable) return;
|
||||||
if (onOpenFileChooser) {
|
if (onOpenFileChooser) {
|
||||||
onOpenFileChooser(ruleIndex);
|
onOpenFileChooser(ruleIndex);
|
||||||
return;
|
return;
|
||||||
@@ -200,7 +204,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
setFileChooser({ ruleIndex, basePath });
|
setFileChooser({ ruleIndex, basePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAttachment(selection: ManagedAttachmentSelection) {
|
function selectAttachment(selection: FilesManagedAttachmentSelection) {
|
||||||
if (!fileChooser) return;
|
if (!fileChooser) return;
|
||||||
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
||||||
patchRule(fileChooser.ruleIndex, {
|
patchRule(fileChooser.ruleIndex, {
|
||||||
@@ -219,24 +223,26 @@ export function AttachmentRulesDataGrid({
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={id}
|
id={id}
|
||||||
rows={rules}
|
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)}
|
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||||
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
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" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
||||||
className="attachment-rules-table-wrap attachment-rules-table"
|
className="attachment-rules-table-wrap attachment-rules-table"
|
||||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||||
/>
|
/>
|
||||||
{fileChooser && filesModuleInstalled && (
|
{fileChooser && ManagedFileChooser && (
|
||||||
<ManagedFileChooser
|
<ManagedFileChooser
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
storageScope={campaignId}
|
||||||
|
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
|
||||||
mode="attachment"
|
mode="attachment"
|
||||||
source={fileChooser.basePath?.source}
|
source={fileChooser.basePath?.source}
|
||||||
basePath={fileChooser.basePath?.path ?? "."}
|
basePath={fileChooser.basePath?.path ?? "."}
|
||||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||||
previewContext={previewContext}
|
previewContext={previewContext}
|
||||||
|
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||||
onClose={() => setFileChooser(null)}
|
onClose={() => setFileChooser(null)}
|
||||||
onSelectAttachment={selectAttachment}
|
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 { asArray, asRecord, isRecord } from "./campaignView";
|
||||||
import { getBool, getText } from "./draftEditor";
|
import { getBool, getText } from "./draftEditor";
|
||||||
|
|
||||||
@@ -98,9 +97,14 @@ export type ManagedAttachmentSource = {
|
|||||||
ownerId: string;
|
ownerId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ManagedAttachmentSourceSpace = {
|
||||||
|
owner_type: "user" | "group";
|
||||||
|
owner_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
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}`;
|
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
type ImportRecord = Record<string, unknown>;
|
type ImportRecord = Record<string, unknown>;
|
||||||
|
type XlsxWorkbookSheet = {
|
||||||
|
sheet: string;
|
||||||
|
data: unknown[][];
|
||||||
|
};
|
||||||
|
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
|
||||||
|
|
||||||
export type ImportFieldDefinition = {
|
export type ImportFieldDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,13 +28,15 @@ export type CsvParseOptions = {
|
|||||||
quoted: boolean;
|
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 RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||||
|
|
||||||
export type RecipientColumnMapping = {
|
export type RecipientColumnMapping = {
|
||||||
columnIndex: number;
|
columnIndex: number;
|
||||||
kind: RecipientColumnKind;
|
kind: RecipientColumnKind;
|
||||||
fieldName?: string;
|
fieldName?: string | null;
|
||||||
newFieldName?: string;
|
newFieldName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecipientImportTable = {
|
export type RecipientImportTable = {
|
||||||
@@ -41,6 +48,11 @@ export type RecipientImportTable = {
|
|||||||
firstDataRowNumber: number;
|
firstDataRowNumber: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RecipientImportSpreadsheetSheet = {
|
||||||
|
name: string;
|
||||||
|
rows: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
export type ImportedAddress = {
|
export type ImportedAddress = {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -73,15 +85,84 @@ export type RecipientImportPreview = {
|
|||||||
patternCount: number;
|
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 = {
|
export type RecipientImportPreviewOptions = {
|
||||||
existingFields: ImportFieldDefinition[];
|
existingFields: ImportFieldDefinition[];
|
||||||
existingEntries?: Record<string, unknown>[];
|
existingEntries?: Record<string, unknown>[];
|
||||||
valueSeparators?: string;
|
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 = {
|
export type MaterializeImportOptions = {
|
||||||
mode: RecipientImportMode;
|
mode: RecipientImportMode;
|
||||||
attachmentBasePath?: ImportAttachmentBasePath | null;
|
attachmentBasePath?: ImportAttachmentBasePath | null;
|
||||||
|
provenance?: RecipientImportProvenance | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMAIL_COLUMNS = new Set(["email", "e_mail", "mail", "to", "to_email", "recipient", "recipient_email"]);
|
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 {
|
export function buildImportTable(text: string, options: CsvParseOptions): RecipientImportTable {
|
||||||
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
|
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
|
||||||
const rows = parseDelimitedText(text, delimiter, options.quoted);
|
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 normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
|
||||||
const headerRows = rows.slice(0, normalizedHeaderRows);
|
const headerRows = rows.slice(0, normalizedHeaderRows);
|
||||||
const dataRows = rows.slice(normalizedHeaderRows);
|
const dataRows = rows.slice(normalizedHeaderRows);
|
||||||
const columnCount = Math.max(0, ...rows.map((row) => row.length));
|
const columnCount = Math.max(0, ...rows.map((row) => row.length));
|
||||||
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
|
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
|
||||||
return {
|
return {
|
||||||
delimiter,
|
delimiter: options.delimiter ?? ",",
|
||||||
headers,
|
headers,
|
||||||
headerRows,
|
headerRows,
|
||||||
rows,
|
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[] {
|
export function defaultColumnMappings(headers: string[], existingFields: ImportFieldDefinition[]): RecipientColumnMapping[] {
|
||||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||||
return headers.map((header, columnIndex) => {
|
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(
|
export function buildRecipientImportPreview(
|
||||||
table: RecipientImportTable,
|
table: RecipientImportTable,
|
||||||
mappings: RecipientColumnMapping[],
|
mappings: RecipientColumnMapping[],
|
||||||
@@ -240,11 +436,13 @@ export function materializeRecipientImport(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const currentEntries = asRecord(draft.entries);
|
const currentEntries = asRecord(draft.entries);
|
||||||
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
|
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
|
||||||
|
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||||
const importedEntries = preview.rows
|
const importedEntries = preview.rows
|
||||||
.filter((row) => row.issues.length === 0)
|
.filter((row) => row.issues.length === 0)
|
||||||
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||||
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
|
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
|
||||||
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
|
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
|
||||||
|
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
|
||||||
delete nextEntries.source;
|
delete nextEntries.source;
|
||||||
delete nextEntries.mapping;
|
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 {
|
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
|
||||||
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
|
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}`;
|
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 {
|
function explicitFieldNameFromHeader(header: string): string {
|
||||||
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
|
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
|
||||||
return explicit ? sanitizeFieldName(explicit[1]) : "";
|
return explicit ? sanitizeFieldName(explicit[1]) : "";
|
||||||
@@ -369,6 +619,93 @@ function normalizeColumnKey(value: string): string {
|
|||||||
return value.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/^_+|_+$/g, "");
|
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 {
|
function isPatternColumn(key: string): boolean {
|
||||||
return key === "pattern"
|
return key === "pattern"
|
||||||
|| key === "patterns"
|
|| key === "patterns"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings, FilesFileExplorerUiCapability, FilesManagedFile } from "@govoplan/core-webui";
|
||||||
import { listFiles, resolveFilePatterns, shareFilesWithCampaign, type ManagedFile } from "../../../api/files";
|
|
||||||
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
|
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
|
||||||
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
|
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
|
||||||
|
|
||||||
@@ -10,17 +9,20 @@ export type RenderedImportPattern = {
|
|||||||
renderedPattern: string;
|
renderedPattern: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapability>, "listFiles" | "resolveFilePatterns" | "shareFilesWithTarget">;
|
||||||
|
|
||||||
export type ImportFileLinkResolution = {
|
export type ImportFileLinkResolution = {
|
||||||
basePath: AttachmentBasePath;
|
basePath: AttachmentBasePath;
|
||||||
patterns: RenderedImportPattern[];
|
patterns: RenderedImportPattern[];
|
||||||
matchedPatterns: { pattern: RenderedImportPattern; matches: ManagedFile[] }[];
|
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
|
||||||
unmatchedPatterns: RenderedImportPattern[];
|
unmatchedPatterns: RenderedImportPattern[];
|
||||||
files: ManagedFile[];
|
files: FilesManagedFile[];
|
||||||
linkedFiles: ManagedFile[];
|
linkedFiles: FilesManagedFile[];
|
||||||
linkableFiles: ManagedFile[];
|
linkableFiles: FilesManagedFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveImportedAttachmentLinks(
|
export async function resolveImportedAttachmentLinks(
|
||||||
|
filesCapability: ImportFileLinkCapability,
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
basePath: AttachmentBasePath,
|
basePath: AttachmentBasePath,
|
||||||
@@ -38,14 +40,14 @@ export async function resolveImportedAttachmentLinks(
|
|||||||
|
|
||||||
const root = normalizedPathPrefix(basePath.path);
|
const root = normalizedPathPrefix(basePath.path);
|
||||||
const [resolved, visibleFiles] = await Promise.all([
|
const [resolved, visibleFiles] = await Promise.all([
|
||||||
resolveFilePatterns(settings, {
|
filesCapability.resolveFilePatterns(settings, {
|
||||||
patterns: patterns.map((item) => item.renderedPattern),
|
patterns: patterns.map((item) => item.renderedPattern),
|
||||||
owner_type: parsedSource.ownerType,
|
owner_type: parsedSource.ownerType,
|
||||||
owner_id: parsedSource.ownerId,
|
owner_id: parsedSource.ownerId,
|
||||||
path_prefix: root || undefined,
|
path_prefix: root || undefined,
|
||||||
include_unmatched: false
|
include_unmatched: false
|
||||||
}),
|
}),
|
||||||
listFiles(settings, {
|
filesCapability.listFiles(settings, {
|
||||||
owner_type: parsedSource.ownerType,
|
owner_type: parsedSource.ownerType,
|
||||||
owner_id: parsedSource.ownerId,
|
owner_id: parsedSource.ownerId,
|
||||||
path_prefix: root || undefined
|
path_prefix: root || undefined
|
||||||
@@ -53,7 +55,7 @@ export async function resolveImportedAttachmentLinks(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
|
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 matchedPatterns = patterns.map((pattern, index) => {
|
||||||
const matches = (resolved.patterns[index]?.matches ?? [])
|
const matches = (resolved.patterns[index]?.matches ?? [])
|
||||||
.map((file) => visibleById.get(file.id) ?? file);
|
.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))];
|
const uniqueIds = [...new Set(files.map((file) => file.id).filter(Boolean))];
|
||||||
if (uniqueIds.length === 0) return 0;
|
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;
|
return response.shared_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ export function renderedImportPatterns(preview: RecipientImportPreview): Rendere
|
|||||||
return [...byKey.values()];
|
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));
|
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;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
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 {
|
.attachment-source-path-cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -1549,77 +1320,6 @@
|
|||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
box-shadow: none;
|
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 workflow page. */
|
||||||
.review-send-page {
|
.review-send-page {
|
||||||
--review-flow-purple: #9b86c7;
|
--review-flow-purple: #9b86c7;
|
||||||
@@ -2249,93 +1949,9 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
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-page .btn { text-decoration: none; }
|
||||||
.review-send-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 16px 0; }
|
.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 ------------------------------------------------ */
|
/* Delivery report additions ------------------------------------------------ */
|
||||||
.dialog-panel-wide {
|
.dialog-panel-wide {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
buildImportTableFromRows,
|
||||||
|
applyRecipientMappingProfile,
|
||||||
buildImportTable,
|
buildImportTable,
|
||||||
buildRecipientImportPreview,
|
buildRecipientImportPreview,
|
||||||
|
createRecipientImportProvenance,
|
||||||
|
createRecipientMappingProfile,
|
||||||
defaultColumnMappings,
|
defaultColumnMappings,
|
||||||
|
matchRecipientMappingProfiles,
|
||||||
materializeRecipientImport,
|
materializeRecipientImport,
|
||||||
parseDelimitedText,
|
parseDelimitedText,
|
||||||
|
xlsxSheetsFromArrayBuffer,
|
||||||
type RecipientColumnMapping
|
type RecipientColumnMapping
|
||||||
} from "../src/features/campaigns/utils/bulkImport";
|
} 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" };
|
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"', ";");
|
const parsed = parseDelimitedText('email;name;field.note\n"alice@example.org";"Alice; A";"hello"', ";");
|
||||||
assert(parsed[1][1] === "Alice; A", "quoted delimiters are preserved");
|
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, 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, 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");
|
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.validCount === 1, "one valid row is detected");
|
||||||
assert(preview.invalidCount === 2, "invalid rows are detected");
|
assert(preview.invalidCount === 2, "invalid rows are detected");
|
||||||
assert(preview.patternCount === 3, "all pattern cells are counted before invalid rows are skipped");
|
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.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].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");
|
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