3 Commits

Author SHA1 Message Date
18e6c3eb9b Release v0.1.6 2026-07-07 15:55:37 +02:00
a5c24fe73e Release v0.1.5 2026-07-07 15:49:06 +02:00
ee5b06b1e7 Release v0.1.4 2026-07-02 14:59:52 +02:00
29 changed files with 2281 additions and 254 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/files
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/files
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/files
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/files
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/files
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -46,7 +46,10 @@ Frontend package:
@govoplan/files-webui @govoplan/files-webui
``` ```
The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing. The campaign module can integrate with files when both modules are installed,
for example for managed attachment selection and campaign file sharing. Files
does not import campaign internals; campaign share/existence checks use the core
`campaigns.access` capability registered by the campaign module.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.1", "version": "0.1.6",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.6",
"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",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-files" name = "govoplan-files"
version = "0.1.3" version = "0.1.6"
description = "GovOPlaN files module with backend and WebUI integration." description = "GovOPlaN files 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.6",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -30,5 +30,5 @@ class FilesCampaignCapability:
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability() return FilesCampaignCapability()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
@@ -54,10 +55,30 @@ ROLE_TEMPLATES = (
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_files.backend.db.models import FileAsset
return {"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count()}
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
owned_asset_count = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_group_id == group_id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_group_id == group_id).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.target_type == "group",
FileShare.target_id == group_id,
).count()
if owned_asset_count or owned_folder_count or shared_asset_count:
raise ValueError("Cannot remove the group while it owns files, folders or file shares.")
def _files_router(context: ModuleContext): def _files_router(context: ModuleContext):
from govoplan_files.backend.runtime import configure_runtime from govoplan_files.backend.runtime import configure_runtime
configure_runtime(settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_files.backend.router import router from govoplan_files.backend.router import router
return router return router
@@ -66,12 +87,14 @@ def _files_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="files", id="files",
name="Files", name="Files",
version="0.1.2", version="0.1.6",
dependencies=("access",), dependencies=("access",),
optional_dependencies=(), optional_dependencies=(),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_files_router, route_factory=_files_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
delete_veto_providers={"group": (_veto_group_delete,)},
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="files", module_id="files",
@@ -82,6 +105,28 @@ manifest = ModuleManifest(
module_id="files", module_id="files",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
retirement_notes="Destructive retirement drops files-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
), ),
capability_factories={ capability_factories={
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),

View File

@@ -9,11 +9,10 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_files.backend.schemas import ( from govoplan_files.backend.schemas import (
ArchiveRequest, ArchiveRequest,
BulkFileShareRequest, BulkFileShareRequest,
@@ -43,12 +42,11 @@ from govoplan_files.backend.schemas import (
TransferResponse, TransferResponse,
_conflict_resolutions, _conflict_resolutions,
) )
from govoplan_core.db.models import Group, UserGroupMembership from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileFolder, FileShare, FileVersion
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_files.backend.runtime import settings from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path
from govoplan_files.backend.storage.access import ensure_group_access, user_group_ids from govoplan_files.backend.storage.access import ensure_group_access, group_refs_for_ids, user_group_ids
from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import ( from govoplan_files.backend.storage.files import (
@@ -69,13 +67,14 @@ from govoplan_files.backend.storage.transfers import rename_selection, transfer_
router = APIRouter(prefix="/files", tags=["files"]) router = APIRouter(prefix="/files", tags=["files"])
def _campaign_models(): def _campaign_access_provider() -> CampaignAccessProvider:
try: registry = get_registry()
from govoplan_campaign.backend.db.models import Campaign, CampaignShare if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
except ModuleNotFoundError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed")
reraise_unless_missing_package(exc, "govoplan_campaign") capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed") from exc if not isinstance(capability, CampaignAccessProvider):
return Campaign, CampaignShare raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Campaign access capability is invalid")
return capability
def _is_admin(principal: ApiPrincipal) -> bool: def _is_admin(principal: ApiPrincipal) -> bool:
@@ -116,36 +115,19 @@ def _ensure_campaign_file_access(session: Session, principal: ApiPrincipal, camp
return return
if not has_scope(principal, "campaigns:campaign:read"): if not has_scope(principal, "campaigns:campaign:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: campaign:read") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: campaign:read")
Campaign, CampaignShare = _campaign_models() provider = _campaign_access_provider()
campaign = session.get(Campaign, campaign_id) if not provider.campaign_exists(session, tenant_id=principal.tenant_id, campaign_id=campaign_id):
if not campaign or campaign.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
if has_scope(principal, "tenant:*"): group_ids = set(user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id))
if provider.can_read_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
user_id=principal.user.id,
group_ids=group_ids,
tenant_admin=has_scope(principal, "tenant:*"),
):
return return
if campaign.owner_user_id == principal.user.id:
return
group_ids = {
row[0]
for row in session.query(UserGroupMembership.group_id)
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
.all()
}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return
share = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.user.id),
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(group_ids)),
),
)
.first()
)
if share is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign")
@@ -195,6 +177,92 @@ def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool
) )
def _asset_list_response(session: Session, assets: list[FileAsset], *, include_shares: bool = False) -> list[FileAssetResponse]:
if not assets:
return []
asset_ids = [asset.id for asset in assets]
version_ids = [asset.current_version_id for asset in assets if asset.current_version_id]
if len(version_ids) != len(assets):
raise FileStorageError("File has no current version")
version_blob_rows: list[tuple[FileVersion, FileBlob]] = []
for chunk in _chunks(version_ids):
version_blob_rows.extend(
session.query(FileVersion, FileBlob)
.join(FileBlob, FileBlob.id == FileVersion.blob_id)
.filter(FileVersion.id.in_(chunk))
.all()
)
versions_by_id = {version.id: version for version, _blob in version_blob_rows}
blobs_by_version_id = {version.id: blob for version, blob in version_blob_rows}
shares_by_asset_id: dict[str, list[FileShareResponse]] = {asset_id: [] for asset_id in asset_ids}
if include_shares:
for chunk in _chunks(asset_ids):
share_rows = (
session.query(FileShare)
.filter(FileShare.file_asset_id.in_(chunk))
.order_by(FileShare.created_at.desc())
.all()
)
for row in share_rows:
shares_by_asset_id.setdefault(row.file_asset_id, []).append(
FileShareResponse(
id=row.id,
target_type=row.target_type,
target_id=row.target_id,
permission=row.permission,
created_at=row.created_at.isoformat(),
revoked_at=row.revoked_at.isoformat() if row.revoked_at else None,
)
)
sent_asset_ids: set[str] = set()
for chunk in _chunks(asset_ids):
sent_rows = (
session.query(CampaignAttachmentUse.file_asset_id)
.filter(CampaignAttachmentUse.file_asset_id.in_(chunk), CampaignAttachmentUse.use_stage == "sent")
.distinct()
.all()
)
sent_asset_ids.update(row[0] for row in sent_rows)
responses: list[FileAssetResponse] = []
for asset in assets:
version = versions_by_id.get(asset.current_version_id or "")
blob = blobs_by_version_id.get(asset.current_version_id or "")
if not version or not blob:
raise FileStorageError("File version not found")
responses.append(
FileAssetResponse(
id=asset.id,
tenant_id=asset.tenant_id,
owner_type=asset.owner_type,
owner_id=_owner_id(asset),
display_path=asset.display_path,
filename=asset.filename,
description=asset.description,
size_bytes=blob.size_bytes,
content_type=blob.content_type,
checksum_sha256=blob.checksum_sha256,
version_id=version.id,
created_at=asset.created_at.isoformat(),
updated_at=asset.updated_at.isoformat(),
deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None,
audit_relevant=asset.id in sent_asset_ids,
metadata=asset.metadata_ or {},
shares=shares_by_asset_id.get(asset.id, []),
)
)
return responses
def _chunks(values: list[str], size: int = 900):
for index in range(0, len(values), size):
yield values[index:index + size]
def _folder_owner_id(folder: FileFolder) -> str: def _folder_owner_id(folder: FileFolder) -> str:
return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value] return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value]
@@ -246,7 +314,7 @@ def list_file_spaces(
] ]
group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal)) group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal))
if group_ids: if group_ids:
groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all() groups = group_refs_for_ids(tenant_id=principal.tenant_id, group_ids=group_ids)
spaces.extend( spaces.extend(
FileSpaceResponse( FileSpaceResponse(
id=f"group:{group.id}", id=f"group:{group.id}",
@@ -349,7 +417,7 @@ def list_files(
path_prefix=path_prefix, path_prefix=path_prefix,
is_admin=_is_admin(principal), is_admin=_is_admin(principal),
) )
return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets]) return FileListResponse(files=_asset_list_response(session, assets, include_shares=True))
@router.post("/upload", response_model=FileUploadResponse) @router.post("/upload", response_model=FileUploadResponse)

View File

@@ -2,15 +2,22 @@ from __future__ import annotations
from typing import Any from typing import Any
_runtime_registry: object | None = None
_runtime_settings: object | None = None _runtime_settings: object | None = None
def configure_runtime(*, settings: object | None = None) -> None: def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_settings global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None: if settings is not None:
_runtime_settings = settings _runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object: def get_settings() -> object:
if _runtime_settings is not None: if _runtime_settings is not None:
return _runtime_settings return _runtime_settings

View File

@@ -2,33 +2,50 @@ from __future__ import annotations
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, UserGroupMembership from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
)
from govoplan_core.core.runtime import get_registry
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
def _access_directory() -> AccessDirectory:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
raise FileStorageError("Access directory capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise FileStorageError("Access directory capability is invalid")
return capability
def group_refs_for_ids(*, tenant_id: str, group_ids: list[str]) -> list[GroupRef]:
if not group_ids:
return []
groups = _access_directory().get_groups(group_ids)
return sorted(
[group for group in groups.values() if group.tenant_id == tenant_id],
key=lambda group: group.name.casefold(),
)
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]: def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
del session
directory = _access_directory()
if include_admin_groups: if include_admin_groups:
return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()] return [group.id for group in directory.groups_for_tenant(tenant_id)]
return [ return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
row.group_id
for row in session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id)
.all()
]
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None: def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
group = session.get(Group, group_id) group = _access_directory().get_group(group_id)
if not group or group.tenant_id != tenant_id: if not group or group.tenant_id != tenant_id:
raise FileStorageError("Group not found") raise FileStorageError("Group not found")
if is_admin: if is_admin:
return return
membership = ( if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id):
session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id)
.one_or_none()
)
if membership is None:
raise FileStorageError("No access to this group file space") raise FileStorageError("No access to this group file space")
@@ -42,3 +59,21 @@ def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, ow
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin) ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
return return
raise FileStorageError("Files must be owned by a user or group") raise FileStorageError("Files must be owned by a user or group")
def ensure_share_target_exists(*, tenant_id: str, target_type: str, target_id: str) -> None:
target_type = target_type.lower().strip()
if target_type == "user":
user = _access_directory().get_user(target_id)
if not user or user.tenant_id != tenant_id or user.status != "active":
raise FileStorageError("User not found")
return
if target_type == "group":
group = _access_directory().get_group(target_id)
if not group or group.tenant_id != tenant_id or group.status != "active":
raise FileStorageError("Group not found")
return
if target_type == "tenant":
if target_id != tenant_id:
raise FileStorageError("Tenant not found")
return

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.db.models import FileAsset
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob from govoplan_files.backend.storage.files import create_file_asset, current_versions_and_blobs
from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path
@@ -45,9 +45,11 @@ def _read_zip_member(
def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None: def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None:
backend = get_storage_backend() backend = get_storage_backend()
asset_list = list(assets)
version_blobs = current_versions_and_blobs(session, asset_list)
with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for asset in assets: for asset in asset_list:
_version, blob = current_version_and_blob(session, asset) _version, blob = version_blobs[asset.id]
info = zipfile.ZipInfo(asset.display_path) info = zipfile.ZipInfo(asset.display_path)
info.compress_type = zipfile.ZIP_DEFLATED info.compress_type = zipfile.ZIP_DEFLATED
with archive.open(info, "w") as member: with archive.open(info, "w") as member:

View File

@@ -13,7 +13,9 @@ from typing import Any, Iterator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.db.models import FileAsset
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user, read_asset_bytes from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component
@@ -172,6 +174,8 @@ def prepare_campaign_snapshot(
owner_id = _asset_owner_id(asset) owner_id = _asset_owner_id(asset)
if owner_id: if owner_id:
assets_by_owner[(asset.owner_type, owner_id)].append(asset) assets_by_owner[(asset.owner_type, owner_id)].append(asset)
version_blobs = current_versions_and_blobs(session, shared_assets)
backend = get_storage_backend() if include_bytes else None
manifest: dict[str, ManagedAttachmentFile] = {} manifest: dict[str, ManagedAttachmentFile] = {}
prepared_by_id: dict[str, tuple[str, str]] = {} prepared_by_id: dict[str, tuple[str, str]] = {}
@@ -206,11 +210,14 @@ def prepare_campaign_snapshot(
continue continue
target = _safe_local_target(local_root, relative_path) target = _safe_local_target(local_root, relative_path)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
version, blob = version_blobs[asset.id]
if include_bytes: if include_bytes:
data, version, blob = read_asset_bytes(session, asset) try:
data = backend.get_bytes(blob.storage_key) if backend else b""
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
target.write_bytes(data) target.write_bytes(data)
else: else:
version, blob = current_version_and_blob(session, asset)
target.touch() target.touch()
local_key = str(target.resolve()) local_key = str(target.resolve())
manifest[local_key] = ManagedAttachmentFile( manifest[local_key] = ManagedAttachmentFile(

View File

@@ -2,16 +2,15 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import TYPE_CHECKING, Iterable from typing import Any, Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion
from govoplan_files.backend.storage.common import utcnow from govoplan_files.backend.storage.common import utcnow
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
if TYPE_CHECKING: CampaignJobLike = Any
from govoplan_campaign.backend.db.models import CampaignJob
def _candidate_match_keys(raw_match: str) -> set[str]: def _candidate_match_keys(raw_match: str) -> set[str]:
@@ -29,7 +28,7 @@ def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str
return job_id, file_version_id, filename_used, stage return job_id, file_version_id, filename_used, stage
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJobLike], *, stage: str) -> set[AttachmentUseKey]:
job_ids = {job.id for job in jobs if job.id} job_ids = {job.id for job in jobs if job.id}
if not job_ids: if not job_ids:
return set() return set()
@@ -72,13 +71,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s
return keys return keys
def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys(session: Session, job: CampaignJobLike, *, stage: str) -> set[AttachmentUseKey]:
return _known_use_keys_for_jobs(session, [job], stage=stage) return _known_use_keys_for_jobs(session, [job], stage=stage)
def _add_use( def _add_use(
session: Session, session: Session,
job: CampaignJob, job: CampaignJobLike,
*, *,
asset: FileAsset, asset: FileAsset,
version: FileVersion, version: FileVersion,
@@ -118,7 +117,7 @@ def _add_use(
def record_campaign_attachment_uses_for_jobs( def record_campaign_attachment_uses_for_jobs(
session: Session, session: Session,
jobs: Iterable[CampaignJob], jobs: Iterable[CampaignJobLike],
*, *,
stage: str = "built", stage: str = "built",
) -> None: ) -> None:
@@ -204,6 +203,7 @@ def record_campaign_attachment_uses_for_jobs(
) )
assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {} assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {}
version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]] = {}
for job_id, attachments in fallback_attachments_by_job.items(): for job_id, attachments in fallback_attachments_by_job.items():
job = job_by_id[job_id] job = job_by_id[job_id]
campaign_key = (job.tenant_id, job.campaign_id) campaign_key = (job.tenant_id, job.campaign_id)
@@ -221,6 +221,8 @@ def record_campaign_attachment_uses_for_jobs(
by_key[asset.display_path.strip("/")] = asset by_key[asset.display_path.strip("/")] = asset
by_key.setdefault(asset.filename, asset) by_key.setdefault(asset.filename, asset)
assets_by_campaign[campaign_key] = by_key assets_by_campaign[campaign_key] = by_key
version_blobs_by_campaign[campaign_key] = current_versions_and_blobs(session, assets)
version_blobs = version_blobs_by_campaign[campaign_key]
for attachment in attachments: for attachment in attachments:
matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else [] matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else []
@@ -230,7 +232,10 @@ def record_campaign_attachment_uses_for_jobs(
asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None) asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None)
if not asset: if not asset:
continue continue
version, blob = current_version_and_blob(session, asset) version_blob = version_blobs.get(asset.id)
if not version_blob:
continue
version, blob = version_blob
_add_use( _add_use(
session, session,
job, job,
@@ -243,13 +248,13 @@ def record_campaign_attachment_uses_for_jobs(
) )
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None: def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJobLike, *, stage: str = "built") -> None:
"""Record immutable managed file versions used by one built/sent job.""" """Record immutable managed file versions used by one built/sent job."""
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage) record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None: def mark_job_attachment_uses_sent(session: Session, job: CampaignJobLike) -> None:
record_campaign_attachment_uses_for_job(session, job, stage="built") record_campaign_attachment_uses_for_job(session, job, stage="built")
# Sessions use autoflush=False. Flush any compatibility-built evidence so # Sessions use autoflush=False. Flush any compatibility-built evidence so
# the following query can copy it to the sent stage in the same call. # the following query can copy it to the sent stage in the same call.

View File

@@ -9,23 +9,28 @@ from uuid import uuid4
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, Tenant, User from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.runtime import settings from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
def _campaign_model(): def _campaign_access_provider() -> CampaignAccessProvider:
try: registry = get_registry()
from govoplan_campaign.backend.db.models import Campaign if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
except ModuleNotFoundError as exc: raise FileStorageError("Campaign module is not installed")
reraise_unless_missing_package(exc, "govoplan_campaign") capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
raise FileStorageError("Campaign module is not installed") from exc if not isinstance(capability, CampaignAccessProvider):
return Campaign raise FileStorageError("Campaign access capability is invalid")
return capability
def _ensure_campaign_exists(session: Session, *, tenant_id: str, campaign_id: str) -> None:
if not _campaign_access_provider().campaign_exists(session, tenant_id=tenant_id, campaign_id=campaign_id):
raise FileStorageError("Campaign not found")
def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
@@ -265,6 +270,32 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe
return version, blob return version, blob
def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) -> dict[str, tuple[FileVersion, FileBlob]]:
asset_list = list(assets)
if not asset_list:
return {}
version_ids = [asset.current_version_id for asset in asset_list if asset.current_version_id]
if len(version_ids) != len(asset_list):
raise FileStorageError("File has no current version")
rows: list[tuple[FileVersion, FileBlob]] = []
for chunk in _chunks(version_ids):
rows.extend(
session.query(FileVersion, FileBlob)
.join(FileBlob, FileBlob.id == FileVersion.blob_id)
.filter(FileVersion.id.in_(chunk))
.all()
)
by_version_id = {version.id: (version, blob) for version, blob in rows}
result: dict[str, tuple[FileVersion, FileBlob]] = {}
for asset in asset_list:
version_blob = by_version_id.get(asset.current_version_id or "")
if not version_blob:
raise FileStorageError("File version not found")
result[asset.id] = version_blob
return result
def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]:
version, blob = current_version_and_blob(session, asset) version, blob = current_version_and_blob(session, asset)
backend = get_storage_backend() backend = get_storage_backend()
@@ -292,23 +323,10 @@ def share_file(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
if target_type == "user": if target_type in {"user", "group", "tenant"}:
target_user = session.get(User, target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type == "campaign": if target_type == "campaign":
Campaign = _campaign_model() _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
existing = ( existing = (
session.query(FileShare) session.query(FileShare)
.filter( .filter(
@@ -352,23 +370,10 @@ def share_files(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
if target_type == "user": if target_type in {"user", "group", "tenant"}:
target_user = session.get(User, target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type == "campaign": if target_type == "campaign":
Campaign = _campaign_model() _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
asset_list = list(assets) asset_list = list(assets)
if not asset_list: if not asset_list:
@@ -437,6 +442,11 @@ def _asset_owner_id(asset: FileAsset) -> str:
raise FileStorageError("File has no valid owner") raise FileStorageError("File has no valid owner")
def _chunks(values: list[str], size: int = 900):
for index in range(0, len(values), size):
yield values[index:index + size]
def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool: def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool:
return _active_asset_at_path( return _active_asset_at_path(
session, session,

View File

@@ -33,6 +33,7 @@ from govoplan_files.backend.storage.files import (
asset_is_audit_relevant, asset_is_audit_relevant,
create_file_asset, create_file_asset,
current_version_and_blob, current_version_and_blob,
current_versions_and_blobs,
get_asset_for_user, get_asset_for_user,
list_assets_for_user, list_assets_for_user,
read_asset_bytes, read_asset_bytes,
@@ -64,6 +65,7 @@ __all__ = [
"create_folder", "create_folder",
"create_zip_file", "create_zip_file",
"current_version_and_blob", "current_version_and_blob",
"current_versions_and_blobs",
"ensure_group_access", "ensure_group_access",
"ensure_owner_access", "ensure_owner_access",
"extract_zip_upload", "extract_zip_upload",

View File

@@ -69,17 +69,13 @@ def transfer_selection(
if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"): if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"):
raise FileStorageError("Cannot move a folder into itself or one of its child folders") raise FileStorageError("Cannot move a folder into itself or one of its child folders")
assets_by_id: dict[str, FileAsset] = {} assets_by_id = _selected_assets_for_owner(
for file_id in file_ids:
asset = get_asset_for_user(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, owner_type=source_owner_type,
asset_id=file_id, owner_id=source_owner_id,
require_write=operation == "move", file_ids=file_ids,
is_admin=is_admin,
) )
assets_by_id[asset.id] = asset
folder_asset_targets: dict[str, str] = {} folder_asset_targets: dict[str, str] = {}
folder_target_paths: dict[str, str] = {} folder_target_paths: dict[str, str] = {}
@@ -297,6 +293,33 @@ def _collapse_folder_roots(folder_paths: list[str]) -> list[str]:
return collapsed return collapsed
def _selected_assets_for_owner(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
file_ids: list[str],
) -> dict[str, FileAsset]:
selected_file_ids = list(dict.fromkeys(file_ids))
if not selected_file_ids:
return {}
assets = {
asset.id: asset
for asset in _asset_query_for_owner(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
)
.filter(FileAsset.id.in_(selected_file_ids), FileAsset.deleted_at.is_(None))
.all()
}
if len(assets) != len(selected_file_ids):
raise FileStorageError("File not found")
return assets
def _path_under_root(path: str, root: str) -> bool: def _path_under_root(path: str, root: str) -> bool:
normalized = normalize_logical_path(path) normalized = normalize_logical_path(path)
return normalized == root or normalized.startswith(f"{root}/") return normalized == root or normalized.startswith(f"{root}/")
@@ -349,13 +372,26 @@ def rename_selection(
if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1: if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
raise FileStorageError("Direct rename requires exactly one selected item") raise FileStorageError("Direct rename requires exactly one selected item")
owner_type_norm = owner_type.lower().strip() if owner_type else None
owner_id_norm = owner_id
assets: dict[str, FileAsset] = {} assets: dict[str, FileAsset] = {}
if selected_file_ids:
if owner_type_norm and owner_id_norm:
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin)
assets.update(
_selected_assets_for_owner(
session,
tenant_id=tenant_id,
owner_type=owner_type_norm,
owner_id=owner_id_norm,
file_ids=selected_file_ids,
)
)
else:
for file_id in selected_file_ids: for file_id in selected_file_ids:
asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin) asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin)
assets[asset.id] = asset assets[asset.id] = asset
owner_type_norm = owner_type.lower().strip() if owner_type else None
owner_id_norm = owner_id
folder_rows_by_path: dict[str, FileFolder] = {} folder_rows_by_path: dict[str, FileFolder] = {}
affected_folder_paths: set[str] = set() affected_folder_paths: set[str] = set()
if selected_folder_roots and owner_type_norm and owner_id_norm: if selected_folder_roots and owner_type_norm and owner_id_norm:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.2", "version": "0.1.6",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -21,7 +21,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.2" "@govoplan/core-webui": "^0.1.6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -1,4 +1,4 @@
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
export type FileSpace = { export type FileSpace = {
id: string; id: string;
@@ -181,13 +181,17 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number }; export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> { export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", { return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST", method: "POST",
body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" }) body: JSON.stringify({ file_ids: fileIds, target_type: target.type, target_id: target.id, permission: target.permission ?? "read" })
}); });
} }
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" });
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> { export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId); const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0]; const share = response.shares[0];

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; import { ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react";
import { import {
Button, Button,
@@ -78,6 +78,9 @@ import { useFileDragDropState } from "./hooks/useFileDragDropState";
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8;
export default function FilesPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { export default function FilesPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const canDownload = hasScope(auth, "files:download"); const canDownload = hasScope(auth, "files:download");
const canUpload = hasScope(auth, "files:upload"); const canUpload = hasScope(auth, "files:upload");
@@ -141,6 +144,28 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive);
return sortExplorerEntries(entries, sortColumn, sortDirection); return sortExplorerEntries(entries, sortColumn, sortDirection);
}, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]); }, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]);
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
const [fileListViewportHeight, setFileListViewportHeight] = useState(0);
const [fileListScrollTop, setFileListScrollTop] = useState(0);
const [fileListRowHeight, setFileListRowHeight] = useState(DEFAULT_FILE_LIST_ROW_HEIGHT);
const virtualExplorerRows = useMemo(() => {
const rowHeight = Math.max(1, fileListRowHeight);
const entryScrollTop = Math.max(0, fileListScrollTop - rowHeight);
const viewportHeight = Math.max(fileListViewportHeight, rowHeight * FILE_LIST_OVERSCAN_ROWS);
const startIndex = Math.max(0, Math.floor(entryScrollTop / rowHeight) - FILE_LIST_OVERSCAN_ROWS);
const endIndex = Math.min(
explorerEntries.length,
Math.ceil((entryScrollTop + viewportHeight) / rowHeight) + FILE_LIST_OVERSCAN_ROWS
);
return {
startIndex,
topSpacerHeight: startIndex * rowHeight,
bottomSpacerHeight: Math.max(0, (explorerEntries.length - endIndex) * rowHeight),
entries: explorerEntries.slice(startIndex, endIndex)
};
}, [explorerEntries, fileListRowHeight, fileListScrollTop, fileListViewportHeight]);
const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]); const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]);
const { const {
selectedFileIds, selectedFileIds,
@@ -172,6 +197,47 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
const downloadLabel = selectedDownloadFileIds.length > 1 ? "Download ZIP" : "Download"; const downloadLabel = selectedDownloadFileIds.length > 1 ? "Download ZIP" : "Download";
useEffect(() => {
const viewport = fileListViewportRef.current;
if (!viewport) return undefined;
const updateViewportHeight = () => {
setFileListViewportHeight(viewport.clientHeight);
setFileListScrollTop(viewport.scrollTop);
};
updateViewportHeight();
if (typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(viewport);
return () => observer.disconnect();
}, []);
useEffect(() => {
const row = fileListMeasureRowRef.current;
if (!row) return undefined;
const updateRowHeight = () => {
const measuredHeight = row.getBoundingClientRect().height;
if (measuredHeight > 0) setFileListRowHeight(Math.round(measuredHeight));
};
updateRowHeight();
if (typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(updateRowHeight);
observer.observe(row);
return () => observer.disconnect();
}, [activeSpaceId, currentFolder]);
useEffect(() => {
const viewport = fileListViewportRef.current;
if (!viewport) return;
viewport.scrollTop = 0;
setFileListScrollTop(0);
}, [activeSpaceId, currentFolder, searchActive, searchResults, sortColumn, sortDirection]);
async function loadSpaces() { async function loadSpaces() {
setBusy(true); setBusy(true);
setError(""); setError("");
@@ -1161,6 +1227,75 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
: undefined; : undefined;
const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress; const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress;
function renderExplorerEntry(entry: ExplorerEntry, rowIndex: number) {
const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null;
const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false;
return entry.kind === "folder" ? (
<div
key={entry.id}
className={`file-list-row folder-row ${isEntrySelected(entry) ? "is-selected" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
role="row"
aria-rowindex={rowIndex}
tabIndex={0}
draggable
onDragStart={(event) => handleInternalDragStart(entry, event)}
onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)}
onDragLeave={clearDropState}
onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)}
onMouseDown={preventTextSelectionOnShift}
onClick={(event) => handleEntrySelection(entry, event)}
onDoubleClick={() => { if (!busy && activeSpace) openFolder(activeSpace.id, entry.path); }}
onContextMenu={(event) => openContextMenu(event, "folder", entry)}
onKeyDown={(event) => handleEntryKeyDown(entry, event)}
>
<div className="file-list-name-cell">
<div className="file-list-name">
<Folder className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{entry.name}</strong><small>{folderContentLabel(entry)}</small></span>
</div>
</div>
<span>{formatBytes(entry.totalSize)}</span>
<span className="file-row-tail"><span>{formatDate(entry.updatedAt)}</span></span>
</div>
) : (
<div
key={entry.id}
className={`file-list-row file-row ${isEntrySelected(entry) ? "is-selected" : ""}`}
role="row"
aria-rowindex={rowIndex}
tabIndex={0}
draggable
onDragStart={(event) => handleInternalDragStart(entry, event)}
onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
onMouseDown={preventTextSelectionOnShift}
onClick={(event) => handleEntrySelection(entry, event)}
onDoubleClick={() => { if (!busy) void downloadFile(settings, entry.file); }}
onContextMenu={(event) => openContextMenu(event, "file", entry)}
onKeyDown={(event) => handleEntryKeyDown(entry, event)}
>
<div className="file-list-name-cell">
<div className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{searchActive ? entry.file.display_path : entry.file.filename}</strong>
<small
className={`file-linkage-status ${activeCampaignShareCount(entry.file) > 0 ? "is-linked" : ""}`}
title={campaignShareTitle(entry.file)}
>
{activeCampaignShareCount(entry.file) > 0 && <Link2 size={12} aria-hidden="true" />}
{campaignShareLabel(entry.file)}
</small>
</span>
</div>
</div>
<span>{formatBytes(entry.file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(entry.file.updated_at)}</span></span>
</div>
);
}
return ( return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen"> <div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
{error && ( {error && (
@@ -1258,20 +1393,24 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
<div <div
className="file-list-drop-target" className="file-list-drop-target"
ref={fileListViewportRef}
onScroll={(event) => setFileListScrollTop(event.currentTarget.scrollTop)}
onContextMenu={(event) => openContextMenu(event, "empty")} onContextMenu={(event) => openContextMenu(event, "empty")}
onClick={(event) => { onClick={(event) => {
if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>()); if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>());
}} }}
> >
<div className="file-list-table" role="table" aria-label="Managed files"> <div className="file-list-table" role="table" aria-label="Managed files" aria-rowcount={explorerEntries.length + 1}>
{(() => { {(() => {
const normalizedCurrentFolder = normalizeFolder(currentFolder); const normalizedCurrentFolder = normalizeFolder(currentFolder);
const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace); const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace);
const targetParentFolder = parentFolderPath(normalizedCurrentFolder); const targetParentFolder = parentFolderPath(normalizedCurrentFolder);
return ( return (
<div <div
ref={fileListMeasureRowRef}
className={`file-list-row folder-row file-parent-row ${canOpenParent ? "" : "is-disabled"}`} className={`file-list-row folder-row file-parent-row ${canOpenParent ? "" : "is-disabled"}`}
role="row" role="row"
aria-rowindex={1}
tabIndex={canOpenParent ? 0 : -1} tabIndex={canOpenParent ? 0 : -1}
aria-disabled={!canOpenParent} aria-disabled={!canOpenParent}
aria-label={canOpenParent ? `Open parent folder ${targetParentFolder || activeSpace?.label || "root"}` : "Already at the root folder"} aria-label={canOpenParent ? `Open parent folder ${targetParentFolder || activeSpace?.label || "root"}` : "Already at the root folder"}
@@ -1301,71 +1440,13 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
{explorerEntries.length === 0 && ( {explorerEntries.length === 0 && (
<div className="file-list-empty">This folder is empty. Use Upload or Create Folder to add content.</div> <div className="file-list-empty">This folder is empty. Use Upload or Create Folder to add content.</div>
)} )}
{explorerEntries.map((entry) => { {virtualExplorerRows.topSpacerHeight > 0 && (
const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null; <div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.topSpacerHeight }} aria-hidden="true" />
const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false; )}
return entry.kind === "folder" ? ( {virtualExplorerRows.entries.map((entry, index) => renderExplorerEntry(entry, virtualExplorerRows.startIndex + index + 2))}
<div {virtualExplorerRows.bottomSpacerHeight > 0 && (
key={entry.id} <div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.bottomSpacerHeight }} aria-hidden="true" />
className={`file-list-row folder-row ${isEntrySelected(entry) ? "is-selected" : ""} ${isDropTarget ? "is-drop-target" : ""}`} )}
role="row"
tabIndex={0}
draggable
onDragStart={(event) => handleInternalDragStart(entry, event)}
onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)}
onDragLeave={clearDropState}
onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)}
onMouseDown={preventTextSelectionOnShift}
onClick={(event) => handleEntrySelection(entry, event)}
onDoubleClick={() => { if (!busy && activeSpace) openFolder(activeSpace.id, entry.path); }}
onContextMenu={(event) => openContextMenu(event, "folder", entry)}
onKeyDown={(event) => handleEntryKeyDown(entry, event)}
>
<div className="file-list-name-cell">
<div className="file-list-name">
<Folder className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{entry.name}</strong><small>{folderContentLabel(entry)}</small></span>
</div>
</div>
<span>{formatBytes(entry.totalSize)}</span>
<span className="file-row-tail"><span>{formatDate(entry.updatedAt)}</span></span>
</div>
) : (
<div
key={entry.id}
className={`file-list-row file-row ${isEntrySelected(entry) ? "is-selected" : ""}`}
role="row"
tabIndex={0}
draggable
onDragStart={(event) => handleInternalDragStart(entry, event)}
onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
onMouseDown={preventTextSelectionOnShift}
onClick={(event) => handleEntrySelection(entry, event)}
onDoubleClick={() => { if (!busy) void downloadFile(settings, entry.file); }}
onContextMenu={(event) => openContextMenu(event, "file", entry)}
onKeyDown={(event) => handleEntryKeyDown(entry, event)}
>
<div className="file-list-name-cell">
<div className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{searchActive ? entry.file.display_path : entry.file.filename}</strong>
<small
className={`file-linkage-status ${activeCampaignShareCount(entry.file) > 0 ? "is-linked" : ""}`}
title={campaignShareTitle(entry.file)}
>
{activeCampaignShareCount(entry.file) > 0 && <Link2 size={12} aria-hidden="true" />}
{campaignShareLabel(entry.file)}
</small>
</span>
</div>
</div>
<span>{formatBytes(entry.file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(entry.file.updated_at)}</span></span>
</div>
);
})}
</div> </div>
</div> </div>
</section> </section>

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,13 @@ import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files"; import type { FileFolder, ManagedFile } from "../../../api/files";
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types"; import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
type FolderStats = {
directFiles: number;
childFolders: Set<string>;
totalSize: number;
updatedAt: string;
};
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] { export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
const byPath = new Map<string, FolderNode>(); const byPath = new Map<string, FolderNode>();
const ensureNode = (path: string, persisted: boolean): FolderNode | null => { const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
@@ -71,21 +78,23 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
const prefix = folder ? `${folder}/` : ""; const prefix = folder ? `${folder}/` : "";
const folderMap = new Map<string, FolderEntry>(); const folderMap = new Map<string, FolderEntry>();
const directFiles: FileEntry[] = []; const directFiles: FileEntry[] = [];
const folderStats = buildFolderStats(files, folders);
for (const persisted of folders) { for (const persisted of folders) {
const path = normalizeFolder(persisted.path); const path = normalizeFolder(persisted.path);
if (!path.startsWith(prefix) || path === folder) continue; if (!path.startsWith(prefix) || path === folder) continue;
const relative = prefix ? path.slice(prefix.length) : path; const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || relative.includes("/")) continue; if (!relative || relative.includes("/")) continue;
const stats = folderStats.get(path);
folderMap.set(path, { folderMap.set(path, {
kind: "folder", kind: "folder",
id: `folder:${path}`, id: `folder:${path}`,
name: relative, name: relative,
path, path,
fileCount: 0, fileCount: stats?.directFiles ?? 0,
folderCount: 0, folderCount: stats?.childFolders.size ?? 0,
totalSize: 0, totalSize: stats?.totalSize ?? 0,
updatedAt: persisted.updated_at, updatedAt: latestTimestamp(persisted.updated_at, stats?.updatedAt),
persisted: true persisted: true
}); });
} }
@@ -102,31 +111,22 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
} }
const folderName = relativePath.slice(0, slashIndex); const folderName = relativePath.slice(0, slashIndex);
const folderPath = prefix ? `${folder}/${folderName}` : folderName; const folderPath = prefix ? `${folder}/${folderName}` : folderName;
const existing = folderMap.get(folderPath); if (!folderMap.has(folderPath)) {
if (existing) { const stats = folderStats.get(folderPath);
existing.totalSize += file.size_bytes;
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
} else {
folderMap.set(folderPath, { folderMap.set(folderPath, {
kind: "folder", kind: "folder",
id: `folder:${folderPath}`, id: `folder:${folderPath}`,
name: folderName, name: folderName,
path: folderPath, path: folderPath,
fileCount: 0, fileCount: stats?.directFiles ?? 0,
folderCount: 0, folderCount: stats?.childFolders.size ?? 0,
totalSize: file.size_bytes, totalSize: stats?.totalSize ?? file.size_bytes,
updatedAt: file.updated_at, updatedAt: stats?.updatedAt || file.updated_at,
persisted: false 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]; return [...Array.from(folderMap.values()), ...directFiles];
} }
@@ -164,25 +164,55 @@ export function entryModifiedTime(entry: ExplorerEntry): number {
} }
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } { export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
const normalized = normalizeFolder(folderPath); const stats = buildFolderStats(files, folders).get(normalizeFolder(folderPath));
const prefix = normalized ? `${normalized}/` : ""; return { files: stats?.directFiles ?? 0, folders: stats?.childFolders.size ?? 0 };
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length; }
const childFolders = new Set<string>();
function buildFolderStats(files: ManagedFile[], folders: FileFolder[]): Map<string, FolderStats> {
const statsByPath = new Map<string, FolderStats>();
const ensureStats = (path: string): FolderStats => {
const normalized = normalizeFolder(path);
const existing = statsByPath.get(normalized);
if (existing) return existing;
const stats: FolderStats = { directFiles: 0, childFolders: new Set(), totalSize: 0, updatedAt: "" };
statsByPath.set(normalized, stats);
return stats;
};
for (const folder of folders) { for (const folder of folders) {
const path = normalizeFolder(folder.path); const path = normalizeFolder(folder.path);
if (!path.startsWith(prefix) || path === normalized) continue; if (!path) continue;
const relative = prefix ? path.slice(prefix.length) : path; const parts = path.split("/");
if (!relative) continue; const parentPath = normalizeFolder(parts.slice(0, -1).join("/"));
childFolders.add(relative.split("/")[0]); const name = parts[parts.length - 1];
if (name) ensureStats(parentPath).childFolders.add(name);
ensureStats(path);
} }
for (const file of files) { for (const file of files) {
const path = normalizeFilePath(file.display_path); const parts = normalizeFilePath(file.display_path || file.filename).split("/").filter(Boolean);
if (!path.startsWith(prefix)) continue; if (parts.length === 0) continue;
const relative = prefix ? path.slice(prefix.length) : path; const directParentPath = normalizeFolder(parts.slice(0, -1).join("/"));
if (!relative || !relative.includes("/")) continue; ensureStats(directParentPath).directFiles += 1;
childFolders.add(relative.split("/")[0]);
for (let index = 0; index < parts.length - 1; index += 1) {
const folderPath = parts.slice(0, index + 1).join("/");
const stats = ensureStats(folderPath);
stats.totalSize += file.size_bytes;
stats.updatedAt = latestTimestamp(stats.updatedAt, file.updated_at);
const parentPath = normalizeFolder(parts.slice(0, index).join("/"));
ensureStats(parentPath).childFolders.add(parts[index]);
} }
return { files: directFiles, folders: childFolders.size }; }
return statsByPath;
}
function latestTimestamp(current: string, candidate?: string): string {
if (!candidate) return current;
if (!current) return candidate;
return candidate > current ? candidate : current;
} }
export function folderContentLabel(entry: FolderEntry): string { export function folderContentLabel(entry: FolderEntry): string {
@@ -209,13 +239,14 @@ export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileF
const sortedDates = childFiles.map((file) => file.updated_at).sort(); const sortedDates = childFiles.map((file) => file.updated_at).sort();
const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined; const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined;
const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString(); const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString();
const counts = directFolderCounts(files, folders, normalizedPath);
return { return {
kind: "folder", kind: "folder",
id: `folder:${normalizedPath}`, id: `folder:${normalizedPath}`,
name: lastPathSegment(normalizedPath) || "Root", name: lastPathSegment(normalizedPath) || "Root",
path: normalizedPath, path: normalizedPath,
fileCount: directFolderCounts(files, folders, normalizedPath).files, fileCount: counts.files,
folderCount: directFolderCounts(files, folders, normalizedPath).folders, folderCount: counts.folders,
totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0), totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0),
updatedAt, updatedAt,
persisted: Boolean(persistedFolder) persisted: Boolean(persistedFolder)

View File

@@ -5,6 +5,8 @@ export { default as FilesPage } from "./features/files/FilesPage";
export * from "./api/files"; export * from "./api/files";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
export { FolderTree } from "./features/files/components/FileManagerComponents"; export { FolderTree } from "./features/files/components/FileManagerComponents";
export { default as ManagedFileChooser } from "./features/files/components/ManagedFileChooser";
export type { ManagedAttachmentSelection, ManagedFolderSelection } from "./features/files/components/ManagedFileChooser";
export { useFileTreeState } from "./features/files/hooks/useFileTreeState"; export { useFileTreeState } from "./features/files/hooks/useFileTreeState";
export type { FolderNode, SortColumn, SortDirection } from "./features/files/types"; export type { FolderNode, SortColumn, SortDirection } from "./features/files/types";
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils"; export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils";

View File

@@ -1,6 +1,8 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { FilesFileExplorerUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import type { FilesFileExplorerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { FolderTree } from "./features/files/components/FileManagerComponents"; import { FolderTree } from "./features/files/components/FileManagerComponents";
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
import { listFileSpaces, listFiles, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
const FilesPage = lazy(() => import("./features/files/FilesPage")); const FilesPage = lazy(() => import("./features/files/FilesPage"));
@@ -16,7 +18,15 @@ export const filesModule: PlatformWebModule = {
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) } { path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }
], ],
uiCapabilities: { uiCapabilities: {
"files.fileExplorer": { FolderTree } satisfies FilesFileExplorerUiCapability "files.fileExplorer": {
FolderTree,
ManagedFileChooser,
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFilesWithTarget
} satisfies FilesFileExplorerUiCapability
} }
}; };

View File

@@ -391,6 +391,11 @@
text-align: center; text-align: center;
} }
.file-list-virtual-spacer {
min-height: 0;
pointer-events: none;
}
.file-context-menu { .file-context-menu {
position: fixed; position: fixed;
z-index: 110; z-index: 110;
@@ -698,3 +703,467 @@
color: var(--text-strong); color: var(--text-strong);
outline: none; outline: none;
} }
/* Managed file chooser exposed through the files.fileExplorer capability. */
.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-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.managed-file-chooser-body {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: hidden;
padding: 0;
}
.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-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.file-tree-panel {
display: flex;
padding: 0;
overflow: hidden;
}
.managed-file-chooser-spaces .file-tree-list {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 12px;
}
.managed-file-chooser-spaces .file-tree-space.is-active {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.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-browser.folder-mode {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.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-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-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-list {
display: block;
padding: 10px;
min-height: 0;
overflow: auto;
}
.managed-file-entry {
display: grid;
gap: 9px;
align-items: center;
min-height: 52px;
padding: 8px 10px;
margin: 0 0 4px;
}
.managed-file-entry:last-child {
margin-bottom: 0;
}
.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-entry.is-context-only {
opacity: .56;
}
.managed-file-entry.is-parent:disabled {
opacity: .38;
}
.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-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-virtual-spacer {
min-height: 0;
pointer-events: none;
}
.managed-file-tree-virtual-list {
flex: 1 1 auto;
min-height: 80px;
overflow: auto;
}
.managed-file-chooser-note {
margin: 0;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--panel-soft);
}
.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 {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.managed-pattern-results .file-list-table {
min-width: 680px;
min-height: 0;
overflow: auto;
}
.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);
}
.split-field-action {
gap: 0;
}
.split-field-action > input,
.split-field-action > select,
.split-field-action > textarea {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-field-action > button,
.split-field-action > .button,
.split-field-action > .btn {
align-self: stretch;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
}
@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;
}
}
@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;
}
}