Prepare v0.1.0 release

This commit is contained in:
2026-06-24 20:00:11 +02:00
parent 934c6f25d8
commit d66637711f
9 changed files with 134 additions and 113 deletions

View File

@@ -49,3 +49,7 @@ Frontend package:
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.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Release packaging
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/files-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/file-manager.css": "./webui/src/styles/file-manager.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.0",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
}
}

View File

@@ -10,7 +10,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 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_version_and_blob
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
@@ -51,9 +51,12 @@ def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path:
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:
try:
for chunk in backend.iter_bytes(blob.storage_key): for chunk in backend.iter_bytes(blob.storage_key):
if chunk: if chunk:
member.write(chunk) member.write(chunk)
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
def extract_zip_upload( def extract_zip_upload(

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Iterable, Protocol from typing import Iterable, Protocol
@@ -26,33 +26,43 @@ class StorageBackend(Protocol):
@dataclass(slots=True) @dataclass(slots=True)
class LocalFilesystemStorageBackend: class LocalFilesystemStorageBackend:
root: Path root: Path
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
name: str = "local" name: str = "local"
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.root = self.root.expanduser().resolve() self.root = self.root.expanduser().resolve()
self.fallback_roots = tuple(root.expanduser().resolve() for root in self.fallback_roots if root)
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
def _path(self, key: str) -> Path: def _path_for_root(self, root: Path, key: str) -> Path:
path = (self.root / key).resolve() path = (root / key).resolve()
if not path.is_relative_to(self.root): if not path.is_relative_to(root):
raise StorageBackendError("Storage key escapes local storage root") raise StorageBackendError("Storage key escapes local storage root")
return path return path
def _path(self, key: str) -> Path:
return self._path_for_root(self.root, key)
def _readable_path(self, key: str) -> Path:
primary = self._path(key)
if primary.exists() and primary.is_file():
return primary
for root in self.fallback_roots:
candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file():
return candidate
raise StorageBackendError("Stored object does not exist")
def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None:
path = self._path(key) path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data) path.write_bytes(data)
def get_bytes(self, key: str) -> bytes: def get_bytes(self, key: str) -> bytes:
path = self._path(key) return self._readable_path(key).read_bytes()
if not path.exists() or not path.is_file():
raise StorageBackendError("Stored object does not exist")
return path.read_bytes()
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]:
path = self._path(key) path = self._readable_path(key)
if not path.exists() or not path.is_file():
raise StorageBackendError("Stored object does not exist")
with path.open("rb") as handle: with path.open("rb") as handle:
while True: while True:
chunk = handle.read(chunk_size) chunk = handle.read(chunk_size)
@@ -66,8 +76,11 @@ class LocalFilesystemStorageBackend:
path.unlink() path.unlink()
def exists(self, key: str) -> bool: def exists(self, key: str) -> bool:
path = self._path(key) try:
return path.exists() and path.is_file() self._readable_path(key)
except StorageBackendError:
return False
return True
@dataclass(slots=True) @dataclass(slots=True)
@@ -125,10 +138,15 @@ class S3StorageBackend:
return False return False
def _fallback_roots() -> tuple[Path, ...]:
raw = getattr(settings, "file_storage_local_fallback_roots", "") or ""
return tuple(Path(item.strip()) for item in str(raw).split(",") if item.strip())
def get_storage_backend() -> StorageBackend: def get_storage_backend() -> StorageBackend:
backend = settings.file_storage_backend.lower().strip() backend = settings.file_storage_backend.lower().strip()
if backend in {"local", "filesystem", "fs"}: if backend in {"local", "filesystem", "fs"}:
return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root)) return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root), fallback_roots=_fallback_roots())
if backend in {"s3", "garage"}: if backend in {"s3", "garage"}:
return S3StorageBackend( return S3StorageBackend(
bucket=settings.file_storage_s3_bucket or settings.s3_bucket, bucket=settings.file_storage_s3_bucket or settings.s3_bucket,

View File

@@ -14,7 +14,7 @@ from govoplan_campaign.backend.db.models import Campaign
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 settings
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
from govoplan_files.backend.storage.backends import 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
@@ -56,13 +56,22 @@ def _get_or_create_blob(
.one_or_none() .one_or_none()
) )
if blob: if blob:
backend = get_storage_backend()
if not backend.exists(blob.storage_key):
try:
backend.put_bytes(blob.storage_key, data, content_type=content_type)
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
blob.ref_count += 1 blob.ref_count += 1
session.add(blob) session.add(blob)
return blob return blob
storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum, filename=filename) storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum, filename=filename)
backend = get_storage_backend() backend = get_storage_backend()
try:
backend.put_bytes(storage_key, data, content_type=content_type) backend.put_bytes(storage_key, data, content_type=content_type)
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
blob = FileBlob( blob = FileBlob(
tenant_id=tenant_id, tenant_id=tenant_id,
storage_backend=_storage_backend_name(), storage_backend=_storage_backend_name(),
@@ -250,7 +259,10 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe
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()
try:
return backend.get_bytes(blob.storage_key), version, blob return backend.get_bytes(blob.storage_key), version, blob
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
def share_file( def share_file(

View File

@@ -1,6 +1,6 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog } from "@govoplan/core-webui"; import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
@@ -111,80 +111,35 @@ export function FolderTree({
disabled?: boolean; disabled?: boolean;
depth?: number; depth?: number;
}) { }) {
if (nodes.length === 0) return null;
return ( return (
<div className="file-tree-children"> <ExplorerTree
{nodes.map((node) => { nodes={nodes}
const isActive = activeSpaceId === spaceId && currentFolder === node.path; 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 }; const target = { spaceId, folderPath: node.path };
const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`; return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
const hasChildren = node.children.length > 0;
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
return (
<div key={node.path}>
<div
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
draggable={dragDropEnabled && !disabled}
onDragStart={dragDropEnabled && onDragStartFolder ? (event) => onDragStartFolder(spaceId, node.path, event) : undefined}
onDragEnd={dragDropEnabled ? onDragEndFolder : undefined}
onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined}
onDragOver={dragDropEnabled && onDragOverTarget ? (event) => {
onDragOverTarget(event, target);
if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled ? onClearDropState : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined}
>
<button
type="button"
className="file-tree-toggle"
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(spaceId, node.path);
}} }}
disabled={disabled || !hasChildren} getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.name}`} getNodeDraggable={() => dragDropEnabled && !disabled}
aria-expanded={hasChildren ? isExpanded : undefined} onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
> onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
{isExpanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />} onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
</button> onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
<button const target = { spaceId, folderPath: node.path };
type="button" onDragOverTarget(event, target);
className="file-tree-node" if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
onClick={() => onOpen(spaceId, node.path)} } : undefined}
disabled={disabled} onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
> onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
<span>{node.name}</span>
</button>
</div>
{hasChildren && isExpanded && (
<FolderTree
nodes={node.children}
activeSpaceId={activeSpaceId}
spaceId={spaceId}
currentFolder={currentFolder}
dropTargetKey={dropTargetKey}
expandedKeys={expandedKeys}
onOpen={onOpen}
onToggle={onToggle}
onContextMenu={onContextMenu}
onDragOverTarget={onDragOverTarget}
onDropOnTarget={onDropOnTarget}
onClearDropState={onClearDropState}
onRequestDragExpand={onRequestDragExpand}
onDragStartFolder={onDragStartFolder}
onDragEndFolder={onDragEndFolder}
dragDropEnabled={dragDropEnabled}
contextMenuEnabled={contextMenuEnabled}
disabled={disabled}
depth={depth + 1}
/> />
)}
</div>
);
})}
</div>
); );
} }

View File

@@ -1,3 +1,4 @@
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";
@@ -297,7 +298,5 @@ export function formatBytes(value: number): string {
} }
export function formatDate(value: string): string { export function formatDate(value: string): string {
const date = new Date(value); return formatPlatformDateTime(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
} }

View File

@@ -1,4 +1,3 @@
import { Folder } from "lucide-react";
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type { PlatformWebModule } from "@govoplan/core-webui";
const FilesPage = lazy(() => import("./features/files/FilesPage")); const FilesPage = lazy(() => import("./features/files/FilesPage"));
@@ -10,7 +9,7 @@ export const filesModule: PlatformWebModule = {
label: "Files", label: "Files",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
navItems: [{ to: "/files", label: "Files", icon: Folder, anyOf: fileRead, order: 40 }], navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }],
routes: [ routes: [
{ 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 }) }
] ]

View File

@@ -59,7 +59,7 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 10px 8px 16px; padding: 0px 0px 16px;
} }
.file-tree-space + .file-tree-space { .file-tree-space + .file-tree-space {
@@ -73,7 +73,7 @@
grid-template-columns: 26px minmax(0, 1fr); grid-template-columns: 26px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 2px; gap: 2px;
border-radius: 9px; border-radius: 0px;
} }
.file-tree-node, .file-tree-node,
@@ -90,7 +90,7 @@
place-items: center; place-items: center;
width: 26px; width: 26px;
height: 32px; height: 32px;
border-radius: 9px; border-radius: 0px;
color: var(--muted); color: var(--muted);
} }
@@ -106,7 +106,7 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
padding: 8px 9px; padding: 8px 9px;
border-radius: 9px; border-radius: 0px;
font: inherit; font: inherit;
text-align: left; text-align: left;
user-select: none; user-select: none;
@@ -137,7 +137,7 @@
.file-tree-root:hover:not(:disabled), .file-tree-root:hover:not(:disabled),
.file-tree-root:focus-visible, .file-tree-root:focus-visible,
.file-tree-root.is-active { .file-tree-root.is-active {
background: rgba(13, 110, 253, .08); background: var(--line);
outline: none; outline: none;
} }
@@ -149,8 +149,8 @@
.file-tree-node-wrap.is-drop-target, .file-tree-node-wrap.is-drop-target,
.file-tree-root.is-drop-target { .file-tree-root.is-drop-target {
background: rgba(13, 110, 253, .14); background: var(--line);
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); box-shadow: inset 0 0 0 2px var(--line-dark);
} }
.file-tree-node-wrap.is-selected .file-tree-select { .file-tree-node-wrap.is-selected .file-tree-select {
@@ -240,8 +240,8 @@
.file-list-drop-target.is-active, .file-list-drop-target.is-active,
.file-list-drop-target.is-drop-target { .file-list-drop-target.is-drop-target {
background: rgba(13, 110, 253, .05); background: var(--line);
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .22); box-shadow: inset 0 0 0 2px var(--line-dark);
} }
.file-list-table { .file-list-table {
@@ -295,18 +295,18 @@
} }
.file-list-row:focus-visible { .file-list-row:focus-visible {
outline: 2px solid rgba(13, 110, 253, .35); outline: 2px solid var(--line-dark);
outline-offset: -2px; outline-offset: -2px;
} }
.file-list-row:hover, .file-list-row:hover,
.file-list-row.is-selected { .file-list-row.is-selected {
background: rgba(13, 110, 253, .07); background: var(--line);
} }
.file-list-row.is-drop-target { .file-list-row.is-drop-target {
background: rgba(13, 110, 253, .13); background: var(--line);
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); box-shadow: inset 0 0 0 2px var(--line-dark);
} }
.file-parent-row.is-disabled { .file-parent-row.is-disabled {